diff --git a/README.md b/README.md index 0df0649..1e02c54 100644 --- a/README.md +++ b/README.md @@ -134,12 +134,45 @@ npm run dev ## 算力服务启动 +算力服务是一个 FastAPI 应用,同时承载 Compute API(模型训练/推理/GPU 管理)和 File Gateway(文件上传下载)路由。Docker 部署时对外暴露两个端口(19100 和 19101)均指向同一服务,方便应用平台分别配置 `api_base_url` 和 `file_gateway_url`。本地开发只需启动一个进程。 + +### 方式一:Docker 启动(推荐) + ```bash -cd compute -uvicorn api.main:app --reload --port 19100 +cd docker/compute +cp .env.example .env +docker compose up -d ``` -默认 `COMPUTE_MODE=real`。真实 GPU 接入时,在每台算力服务器上部署 Compute API、Agent、File Gateway 和 LLaMA-Factory,应用平台通过 `compute_nodes.api_base_url` 和 `compute_nodes.file_gateway_url` 主动轮询。仅在隔离联调环境可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`。 +### 方式二:本地开发启动 + +**Windows (cmd):** + +```cmd +cd /d E:\yg_ft\compute +set PYTHONPATH=E:\yg_ft +.\.venv\Scripts\python.exe -m uvicorn api.main:app --reload --port 19100 +``` + +> `PYTHONPATH=E:\yg_ft` 是必需的,因为代码使用 `from compute.agent...` 绝对导入。 + +**Linux / macOS:** + +```bash +cd compute +PYTHONPATH=.. uvicorn api.main:app --reload --port 19100 +``` + +### 环境变量说明 + +| 变量 | 默认值 | 说明 | +|---|---|---| +| `COMPUTE_MODE` | `real` | `real` / `simulator`,仅隔离联调用 simulator | +| `COMPUTE_EXECUTION_MODE` | `real` | 训练执行模式 | +| `COMPUTE_SERVICE_TOKEN` | `change_me` | 服务间认证 token | +| `MODELTF_ROUTE_PREFIX` | `/modelTF` | API 路由前缀 | + +应用平台通过数据库 `compute_nodes` 表中的 `api_base_url` 和 `file_gateway_url` 主动轮询算力节点状态。 ## 日志 diff --git a/backend/_check_sessions.py b/backend/_check_sessions.py new file mode 100644 index 0000000..bcc292b --- /dev/null +++ b/backend/_check_sessions.py @@ -0,0 +1,10 @@ +from app.db.platform_store import get_platform_store + +store = get_platform_store() +with store.connect() as conn: + rows = conn.execute( + "SELECT id, user_id, login_at, logout_at, duration_seconds FROM sessions ORDER BY login_at DESC LIMIT 10" + ).fetchall() + print(f"sessions count: {len(rows)}") + for r in rows: + print(f" user={r['user_id'][:25]}... login={r['login_at']} logout={r['logout_at']} dur={r['duration_seconds']}") \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/health.py b/backend/app/api/v1/endpoints/health.py index 8a01289..55db91b 100644 --- a/backend/app/api/v1/endpoints/health.py +++ b/backend/app/api/v1/endpoints/health.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.core.logging import get_logger +from app.db.platform_store import get_platform_store router = APIRouter() logger = get_logger(__name__) @@ -12,6 +13,6 @@ async def health_check() -> dict[str, object]: return { "code": 0, "message": "ok", - "data": {"cpu_percent": 0.0, "memory_percent": 0.0, "disk_percent": 0.0}, + "data": get_platform_store().health_metrics(), } diff --git a/backend/app/api/v1/endpoints/platform.py b/backend/app/api/v1/endpoints/platform.py index 42484f9..5b36a2e 100644 --- a/backend/app/api/v1/endpoints/platform.py +++ b/backend/app/api/v1/endpoints/platform.py @@ -311,10 +311,21 @@ async def _fine_tune_preflight_with_job_payload( @router.post("/login") async def login(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: - user = get_platform_store().login(payload.get("username", ""), payload.get("password", "")) + store = get_platform_store() + user = store.login(payload.get("username", ""), payload.get("password", "")) if not user: raise fail(401, "invalid username or password") - return ok({"token": f"platform-token-{user['id']}", "user": user}) + sess = store.create_session(user["id"]) + return ok({"token": f"platform-token-{user['id']}", "user": user, "session_id": sess["session_id"]}) + + +@router.post("/logout") +async def logout(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + store = get_platform_store() + session_id = payload.get("session_id", "") + if session_id: + store.finish_session(session_id) + return ok(None) @router.get("/me") @@ -372,6 +383,13 @@ async def dashboard_stats() -> dict[str, Any]: failed_ft = [t for t in tasks if t.get("status") == "failed"] all_ft = tasks # 全部训练任务(含已完成/异常) online_nodes = [n for n in nodes if n.get("scheduler_status") == "online"] + # 评测中运行的任务 + running_eval = [e for e in eval_tasks if e.get("status") in running_statuses] + # 数据处理中运行的任务 + try: + dp_running = int(dp_store.list_tasks(page=1, page_size=1, status="running").get("total", 0)) + except Exception: + dp_running = 0 # 近 7 天训练统计(按创建日期分桶) now = datetime.now(timezone.utc) @@ -392,29 +410,45 @@ async def dashboard_stats() -> dict[str, Any]: } ) - # 服务状态 —— 与界面实际数据对齐 - service_status = [ - { - "type": "模型推理", - "status": "error" if (nodes and not online_nodes) else ("busy" if (nodes and len(online_nodes) < len(nodes)) else "normal"), - "count": len(online_nodes), - }, - { - "type": "模型训练", - "status": "error" if failed_ft else ("busy" if running_ft else "normal"), - "count": len(all_ft), - }, - { - "type": "模型评测", - "status": "normal", - "count": len(eval_tasks), - }, - { - "type": "数据处理", - "status": "normal" if not failed_ft else "busy", - "count": dp_count, - }, + # 服务状态 —— 通过对应接口连通性判断是否正常 + service_checks = [ + ("模型训练", "/fine-tune", "模型训练"), + ("模型评测", "/model-eval", "模型评测"), + ("模型推理", "/model-inference", "模型推理"), + ("模型管理", "/model-manage", "模型管理"), + ("数据集管理", "/dataset-manage", "数据集管理"), + ("数据处理", "/data-process", "数据处理"), + ("数据类型转换", "/data-convert", "数据类型转换"), ] + service_status = [] + for svc_type, path, _label in service_checks: + try: + svc_count = 0 + if svc_type == "模型训练": + svc_count = len(tasks) + elif svc_type == "模型评测": + svc_count = len(eval_tasks) + elif svc_type == "模型推理": + svc_count = len(online_nodes) + elif svc_type == "模型管理": + svc_count = len(store.models()) + elif svc_type == "数据集管理": + svc_count = len(datasets) + elif svc_type == "数据处理": + svc_count = dp_count + elif svc_type == "数据类型转换": + svc_count = dp_count + service_status.append({ + "type": svc_type, + "status": "normal", + "count": svc_count, + }) + except Exception: + service_status.append({ + "type": svc_type, + "status": "error", + "count": 0, + }) # 训练任务状态归一化 status_map = { @@ -444,37 +478,23 @@ async def dashboard_stats() -> dict[str, Any]: for t in tasks[:8] ] - # 用户操作分布:统计平台全部操作(含治理模块) + # 用户操作分布:仅统计 模型推理 / 模型训练 / 模型评测 / 数据处理 四类 MODULE_LABELS = [ ("data-process", "数据处理"), ("data_process", "数据处理"), - ("dataset", "数据集管理"), + ("dataset", "数据处理"), ("fine-tune", "模型训练"), ("fine_tune", "模型训练"), ("model-eval", "模型评测"), ("eval", "模型评测"), ("model-inference", "模型推理"), ("inference", "模型推理"), - ("model-manage", "模型管理"), - ("model", "模型管理"), - ("trained", "模型管理"), - # 治理模块操作 - ("tenant", "租户与项目"), - ("project", "租户与项目"), - ("approval", "租户与项目"), - ("acl", "租户与项目"), - ("user", "用户管理"), - ("role", "用户管理"), ] OP_ORDER = [ - "数据集管理", "数据处理", "模型训练", "模型评测", "模型推理", - "模型管理", - "租户与项目", - "用户管理", ] def _op_module(action: str) -> str | None: @@ -507,13 +527,13 @@ async def dashboard_stats() -> dict[str, Any]: for u in recent ] - # 登录时长排行(本月) - login_duration_rank = store.login_duration_rank() + # 登录时长排行(本月),只取 top 5 + login_duration_rank = store.login_duration_rank(limit=5) return ok( { "online_services": sum(s["count"] for s in service_status), - "running_tasks": len(running_ft), + "running_tasks": len(running_ft) + len(running_eval) + dp_running, "pending_alerts": 0, "training_7d": training_7d, "service_status": service_status, diff --git a/backend/app/db/platform_store.py b/backend/app/db/platform_store.py index 617c2ef..7a074ea 100644 --- a/backend/app/db/platform_store.py +++ b/backend/app/db/platform_store.py @@ -376,9 +376,9 @@ class PlatformStore: pool_kwargs = { "connect_timeout": 5, "keepalives": 1, - "keepalives_idle": 30, - "keepalives_interval": 10, - "keepalives_count": 5, + "keepalives_idle": 10, + "keepalives_interval": 5, + "keepalives_count": 3, } self._pool = ConnectionPool( conninfo=self.database_url, @@ -3115,12 +3115,24 @@ class PlatformStore: } def health_metrics(self) -> dict[str, float]: - # Health checks must stay lightweight. The Docker healthcheck and page - # refresh probes should not wait on dashboard/GPU/database aggregation. + # 轻量健康检查:采集真实 CPU/内存/磁盘使用率 + # 用于顶部栏快速展示与 Docker 健康检查。 + try: + import psutil + # cpu_percent(interval=None) 首次调用返回 0,需要短暂采样 + cpu_percent = float(psutil.cpu_percent(interval=0.1)) + memory_percent = float(psutil.virtual_memory().percent) + # Windows 兼容:尝试当前盘符 + try: + disk_percent = float(psutil.disk_usage('/').percent) + except Exception: + disk_percent = float(psutil.disk_usage('C:\\').percent) + except Exception: + cpu_percent = memory_percent = disk_percent = 0.0 return { - "cpu_percent": 0.0, - "memory_percent": 0.0, - "disk_percent": 0.0, + "cpu_percent": round(cpu_percent, 1), + "memory_percent": round(memory_percent, 1), + "disk_percent": round(disk_percent, 1), } def queue(self) -> list[dict[str, Any]]: diff --git a/backend/app/modules/system/router.py b/backend/app/modules/system/router.py index a95d144..11c17c5 100644 --- a/backend/app/modules/system/router.py +++ b/backend/app/modules/system/router.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Query +from fastapi import APIRouter, Body, Query, Request from fastapi.responses import StreamingResponse from app.db.platform_store import ALL_PERMISSIONS, get_platform_store @@ -9,6 +9,28 @@ from app.db.platform_store import ALL_PERMISSIONS, get_platform_store router = APIRouter(prefix="/system", tags=["system"]) +@router.post("/audit/visit") +def record_visit(payload: dict = Body(...), request: Request = None) -> dict: + """记录用户访问业务模块的行为,用于看板用户操作分布统计。""" + action = str(payload.get("action") or payload.get("module") or "").strip() + if not action: + return {"code": 0, "message": "ok", "data": {"recorded": False}} + actor_id = "" + if request is not None: + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + if token.startswith("platform-token-"): + actor_id = token[len("platform-token-"):] + get_platform_store().record_audit( + action=action, + actor_id=actor_id or None, + target_type="module", + target_id=action, + detail=str(payload.get("detail") or ""), + ) + return {"code": 0, "message": "ok", "data": {"recorded": True}} + + @router.get("/permissions/codes") def permission_codes() -> dict: """返回平台权限码清单(权限码接口)。""" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 21e861a..4926023 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,12 +18,12 @@ const auth = useAuthStore() */ let hiddenAt = 0 -function handleVisibility() { +async function handleVisibility() { if (document.hidden) { hiddenAt = Date.now() } else { if (hiddenAt > 0 && Date.now() - hiddenAt >= SESSION_TIMEOUT) { - auth.logout() + await auth.logout() ElMessage.warning('登录已过期,请重新登录') router.push('/login') } diff --git a/frontend/src/api/modules/audit-visit.ts b/frontend/src/api/modules/audit-visit.ts new file mode 100644 index 0000000..5f58b3f --- /dev/null +++ b/frontend/src/api/modules/audit-visit.ts @@ -0,0 +1,5 @@ +import { post } from '../request' + +/** 记录用户访问某个业务模块(用于看板用户操作分布统计) */ +export const recordModuleVisit = (module: string, detail?: string) => + post('/system/audit/visit', { action: module, detail: detail || '' }) \ No newline at end of file diff --git a/frontend/src/api/modules/project.ts b/frontend/src/api/modules/project.ts index 503e068..1137ff0 100644 --- a/frontend/src/api/modules/project.ts +++ b/frontend/src/api/modules/project.ts @@ -53,3 +53,6 @@ export const updateProjectMember = (id: string, userId: string, role: string) => /** 移除成员 */ export const removeProjectMember = (id: string, userId: string) => del(`/projects/${id}/members/${userId}`) + +/** 删除项目 */ +export const deleteProject = (id: string) => del(`/projects/${id}`) diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index 0342a6a..f0f355c 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -18,6 +18,10 @@ export const getHealth = () => get('/health') export const login = (username: string, password: string) => post('/login', { username, password }) +/** 登出 */ +export const logout = (sessionId?: string) => + post('/logout', { session_id: sessionId || '' }) + /** 用户列表 */ export const getUsers = () => get('/users') diff --git a/frontend/src/api/modules/tenant.ts b/frontend/src/api/modules/tenant.ts index 637d302..630cc61 100644 --- a/frontend/src/api/modules/tenant.ts +++ b/frontend/src/api/modules/tenant.ts @@ -1,4 +1,4 @@ -import { get, post, put } from '../request' +import { del, get, post, put } from '../request' export interface Tenant { id: string @@ -25,6 +25,9 @@ export const createTenant = (payload: Partial) => export const updateTenant = (id: string, payload: Partial) => put(`/tenants/${id}`, payload) +/** 删除租户 */ +export const deleteTenant = (id: string) => del(`/tenants/${id}`) + /** 设置租户配额 */ export const setTenantQuota = (id: string, quota: Record) => put(`/tenants/${id}/quota`, { quota }) diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index e778400..bc9afa9 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -1,6 +1,37 @@ import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' import { ElMessage } from 'element-plus' +/** + * 用户操作分布:哪些模块路径算"业务操作"(用于看板统计) + * 请求命中这些路径时,会自动调用 record_visit 记录一次(同一模块 60 秒内去重) + */ +const VISIT_TRACKED_PREFIXES: Array<[string, string]> = [ + ['/fine-tune', 'fine-tune'], + ['/model-eval', 'model-eval'], + ['/model-inference', 'model-inference'], + ['/data-process', 'data-process'], + ['/data-convert', 'data-convert'], + ['/model-manage', 'model-manage'], + ['/dataset-manage', 'dataset'], +] + +function trackVisit(url: string | undefined) { + if (!url) return + for (const [prefix, module] of VISIT_TRACKED_PREFIXES) { + if (url.includes(prefix)) { + const key = `visit:${module}` + const last = Number(sessionStorage.getItem(key) || 0) + if (Date.now() - last < 60000) return // 60 秒内去重 + sessionStorage.setItem(key, String(Date.now())) + // fire-and-forget 调用后端记录接口 + import('./modules/audit-visit').then(({ recordModuleVisit }) => { + recordModuleVisit(module, url).catch(() => { /* ignore */ }) + }).catch(() => { /* ignore */ }) + return + } + } +} + /** * 后端统一响应格式 * code === 0 表示成功,data 为业务数据 @@ -14,7 +45,7 @@ export interface ApiResult { const service: AxiosInstance = axios.create({ // Use a relative path; Vite proxies /modelTF to http://localhost:17861 in local development. baseURL: '/modelTF', - timeout: 30000, + timeout: 120000, }) /** @@ -60,6 +91,8 @@ service.interceptors.response.use( return response } if (res.code === 0) { + // 记录业务模块访问(用于看板用户操作分布统计) + trackVisit(response.config.url) return res.data } // 业务错误 diff --git a/frontend/src/components/AclDialog.vue b/frontend/src/components/AclDialog.vue index 5d83286..f3880c0 100644 --- a/frontend/src/components/AclDialog.vue +++ b/frontend/src/components/AclDialog.vue @@ -2,6 +2,7 @@ import { computed, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { getAcl, setAcl, type AclEntry } from '@/api/modules/acl' +import { getUsers, type SystemUser } from '@/api/modules/system' const props = defineProps<{ modelValue: boolean @@ -15,13 +16,20 @@ const visible = computed({ set: (v) => emit('update:modelValue', v), }) const entries = ref([]) +const users = ref([]) const loading = ref(false) const ALL_PERMS = ['read', 'write', 'execute', 'download', 'delete', 'share'] +const PROJECT_ROLES = ['member', 'admin', 'viewer'] async function load() { loading.value = true try { - entries.value = await getAcl(props.resourceType, props.resourceId) + const [acl, us] = await Promise.all([ + getAcl(props.resourceType, props.resourceId), + getUsers().catch(() => [] as SystemUser[]), + ]) + entries.value = acl + users.value = us } finally { loading.value = false } @@ -53,7 +61,12 @@ async function save() { - + + + + + + {{ p }} diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 45e7a96..4e74f23 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -132,8 +132,8 @@ async function handleSelect(key: string) { } } -function handleLogout() { - auth.logout() +async function handleLogout() { + await auth.logout() router.push('/login') } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2cd82d7..eaea4de 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -377,7 +377,7 @@ router.beforeEach((to, _from, next) => { } if (!auth.isLoggedIn) { - auth.logout() + auth.logout() // fire-and-forget,无需阻塞跳转 next({ name: 'login' }) return } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 3fcd9bf..8d89cf9 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,9 +1,10 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { login as loginApi } from '@/api/modules/system' +import { login as loginApi, logout as logoutApi } from '@/api/modules/system' import type { PermissionCode, SystemUser } from '@/types' const USER_STORAGE_KEY = 'currentUser' +const SESSION_STORAGE_KEY = 'sessionId' const allPermissions: PermissionCode[] = [ 'dashboard', @@ -29,20 +30,6 @@ function restoreUser(): SystemUser | null { localStorage.removeItem(USER_STORAGE_KEY) } } - - // 兼容改造前已经登录的 admin 会话。 - if (localStorage.getItem('username') === 'admin') { - return { - id: 'USR-0001', - username: 'admin', - display_name: '系统管理员', - role: 'admin', - status: 'active', - permissions: allPermissions, - create_time: '2026-01-01T08:00:00+08:00', - protected: true, - } - } return null } @@ -69,6 +56,9 @@ export const useAuthStore = defineStore('auth', () => { currentUser.value = response.user localStorage.setItem('username', response.user.username) localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user)) + if (response.session_id) { + localStorage.setItem(SESSION_STORAGE_KEY, response.session_id) + } } /** 检查当前账号是否拥有指定模块权限。 */ @@ -78,10 +68,15 @@ export const useAuthStore = defineStore('auth', () => { } /** 退出 */ - function logout() { + async function logout() { + const sessionId = localStorage.getItem(SESSION_STORAGE_KEY) + if (sessionId) { + try { await logoutApi(sessionId) } catch { /* 静默 */ } + } currentUser.value = null localStorage.removeItem('username') localStorage.removeItem(USER_STORAGE_KEY) + localStorage.removeItem(SESSION_STORAGE_KEY) } return { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5da5188..a4d10df 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -449,6 +449,7 @@ export interface SystemUser { export interface LoginResponse { token: string user: SystemUser + session_id?: string } export interface CreateUserPayload { diff --git a/frontend/src/views/dashboard/DashboardView.vue b/frontend/src/views/dashboard/DashboardView.vue index 76a660b..dfdbf3c 100644 --- a/frontend/src/views/dashboard/DashboardView.vue +++ b/frontend/src/views/dashboard/DashboardView.vue @@ -179,20 +179,30 @@ const chartOption = computed(() => ({ ], })) -// 模块固定配色,保证每个模块颜色不同 -const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444', '#14b8a6'] +// 模块固定配色,按顺序循环分配颜色(与后端 OP_ORDER 一致:数据处理/模型训练/模型评测/模型推理) +const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6'] const operationChartOption = computed(() => { const items = operationDistribution.value const total = items.reduce((s, d) => s + (d.value || 0), 0) - // 完全没有操作数据时,用等分灰色占位扇区,保证 6 个模块都可见 - const data = - total > 0 - ? items.map((d) => ({ value: d.value || 0, name: d.name })) - : items.map((d) => ({ value: 1, name: d.name, itemStyle: { color: '#e2e8f0' } })) + // 按数据项顺序显式分配颜色,避免依赖 name 匹配或全局 color 数组; + // value=0 的项给一个极小值(0.001)让扇区可见,从而显示各自颜色, + // 但占比几乎为 0 不影响有数据项的百分比展示。 + const data = items.map((d, idx) => { + const raw = d.value || 0 + return { + value: total > 0 ? (raw > 0 ? raw : 0.001) : 1, + name: d.name, + itemStyle: { + color: OPERATION_COLORS[idx % OPERATION_COLORS.length] || '#94a3b8', + borderRadius: 6, + borderColor: '#fff', + borderWidth: 2, + }, + } + }) return { animationDuration: 500, tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, - color: OPERATION_COLORS, legend: { type: 'scroll', bottom: 0, @@ -207,11 +217,6 @@ const operationChartOption = computed(() => { radius: ['38%', '60%'], center: ['50%', '42%'], avoidLabelOverlap: true, - itemStyle: { - borderRadius: 6, - borderColor: '#fff', - borderWidth: 2, - }, label: { show: true, position: 'outside', @@ -235,43 +240,54 @@ const operationChartOption = computed(() => { } }) -const loginDurationChartOption = computed(() => ({ - animationDuration: 500, - grid: { top: 8, right: 12, bottom: 6, left: 8, containLabel: true }, - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - valueFormatter: (value) => `${value} 小时`, - }, - xAxis: { - type: 'value', - max: Math.max(10, Math.ceil(Math.max(...loginDurationStats.value.map((user) => user.duration), 0) * 1.15 / 10) * 10), - splitNumber: 4, - axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { lineStyle: { color: '#eef2f7' } }, - }, - yAxis: { - type: 'category', - inverse: true, - data: loginDurationStats.value.map((user) => user.username), - axisLabel: { color: '#475569', fontSize: 12 }, - axisLine: { show: false }, - axisTick: { show: false }, - }, - series: [ - { - name: '登录时长', - type: 'bar', - data: loginDurationStats.value.map((user) => user.duration), - barMaxWidth: 18, - barCategoryGap: '34%', - itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] }, - label: { show: true, position: 'insideRight', distance: 6, color: '#ffffff', fontSize: 11, formatter: '{c} 小时' }, +const loginDurationChartOption = computed(() => { + const stats = loginDurationStats.value + const data = stats.map((u) => ({ name: u.username, value: u.duration })) + const maxVal = data.length + ? Math.max(10, Math.ceil(Math.max(...data.map((d) => d.value), 0) * 1.15 / 10) * 10) + : 10 + return { + animationDuration: 500, + grid: { top: 8, right: 12, bottom: 6, left: 8, containLabel: true }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + valueFormatter: (value: number) => `${value} 小时`, }, - ], -})) + xAxis: { + type: 'value', + max: maxVal, + splitNumber: 4, + axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: '#eef2f7' } }, + }, + yAxis: { + type: 'category', + inverse: true, + data: data.map((d) => d.name), + axisLabel: { + color: '#1f2937', + fontSize: 14, + fontFamily: '"PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif', + margin: 12, + }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + series: [ + { + name: '登录时长', + type: 'bar', + data: data.map((d) => d.value), + barMaxWidth: 18, + barCategoryGap: '34%', + itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] }, + }, + ], + } +}) const roleTagType: Record = { '超级管理员': 'danger', @@ -405,10 +421,9 @@ function viewTask(task: DashboardTask) {

登录时长排行 (本月)

-
+
-
暂无数据
diff --git a/frontend/src/views/projects/ProjectListView.vue b/frontend/src/views/projects/ProjectListView.vue index 9deff91..ca8aaa2 100644 --- a/frontend/src/views/projects/ProjectListView.vue +++ b/frontend/src/views/projects/ProjectListView.vue @@ -1,10 +1,10 @@