feat: 新增服务看板页面
实现 Dashboard 看板视图并将应用默认入口由调优改为看板,路由、侧边栏默认菜单与登录跳转同步调整;侧边栏增加导航失败高亮校正,布局与 ECharts 注册柱状图适配看板,补充两份回归脚本、设计走查记录与五张设计截图。
This commit is contained in:
120
design-qa.md
120
design-qa.md
@@ -158,3 +158,123 @@ The `type-check` script now uses project-reference mode (`vue-tsc -b --noEmit`)
|
||||
Design-QA final result: passed
|
||||
|
||||
final result: passed
|
||||
|
||||
---
|
||||
|
||||
# Service Dashboard Design QA
|
||||
|
||||
## Evidence
|
||||
|
||||
- Source visual truth: `docs/superpowers/specs/assets/service-dashboard-approved-1440.png`
|
||||
- Browser-rendered implementation: `docs/superpowers/specs/assets/service-dashboard-implementation-1440.png`
|
||||
- Viewport: 1440 × 1024
|
||||
- State: authenticated `admin` user on `/dashboard`; service dashboard navigation active; 7-day chart visible; four training tasks visible.
|
||||
|
||||
## Full-view comparison
|
||||
|
||||
The implementation preserves the approved composition: the product shell stays intact, the service dashboard is the active navigation item, the platform-health summary spans the top, the grouped training chart occupies the wide middle track, service health occupies the narrow track, and the training-task table spans the bottom. The three bar series, dual axes, dates, values, service counts, task names, statuses, progress, accuracy, and actions match the selected mock.
|
||||
|
||||
A separate focused crop was not required because both source and implementation evidence are full-resolution desktop captures at a readable scale; the chart labels, axis units, service rows, and every task-table column are legible in the full-view comparison.
|
||||
|
||||
## Required fidelity surfaces
|
||||
|
||||
- Fonts and typography: the existing Inter/system/PingFang stack is retained. Heading, section title, metric, table header, and muted-copy weights and sizes match the selected direction.
|
||||
- Spacing and layout rhythm: 24px page padding, 14px section gaps, 10px panel radii, light separators, and the wide-chart/narrow-status grid preserve the selected hierarchy. The implementation uses the repository's 240px sidebar and 60px header exactly.
|
||||
- Colors and visual tokens: white page canvas, `#f3f5f8` shell, indigo `#4f46e5`, green `#10b981`, amber `#f59e0b`, red `#ef4444`, and slate text are aligned with the source and current product tokens.
|
||||
- Image and icon fidelity: the page contains no decorative raster imagery. The supplied product logo is preserved and existing Font Awesome icons are used consistently; no emoji, handcrafted SVG, placeholder image, or CSS illustration was introduced.
|
||||
- Copy and content: dashboard title, health summary, chart legend and units, service states, task names, task status, model names, progress, accuracy, timestamps, and action labels match the approved design.
|
||||
|
||||
## Interaction and runtime checks
|
||||
|
||||
- Login with the existing `admin` credentials navigated to `/dashboard`, confirming the requested default entry behavior.
|
||||
- ECharts rendered one canvas; hovering 07/10 exposed the tooltip values: training count 18, GPU count 7, and average accuracy 91%.
|
||||
- “查看全部任务” navigated to `/fine-tune` and browser back restored `/dashboard`.
|
||||
- The first “查看详情” action navigated to `/training-log/103942` and browser back restored `/dashboard`.
|
||||
- Browser console errors: none.
|
||||
- `npm run test:default-dashboard`: passed.
|
||||
- `npm run test:dashboard`: passed.
|
||||
- `npx vite build`: passed.
|
||||
|
||||
## Comparison history
|
||||
|
||||
### Iteration 1 — passed
|
||||
|
||||
No actionable P0/P1/P2 differences remain. The only intentional product constraint is that the sidebar active background follows the repository's current neutral active token instead of the slightly bluer tint produced by ImageGen; location, contrast, label, and active-state clarity remain equivalent.
|
||||
|
||||
## Validation gap
|
||||
|
||||
`npm run type-check` remains blocked by pre-existing TypeScript errors in the mock adapter, dataset mock typing, data-process list, evaluation tabs, and fine-tune views. No reported error points to `DashboardView.vue`, the ECharts registration, router defaults, login redirect, or dashboard regression scripts. The direct Vite production build succeeds.
|
||||
|
||||
Design-QA final result: passed
|
||||
|
||||
final result: passed
|
||||
|
||||
## Compact dashboard revision
|
||||
|
||||
- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-43f63327-063f-47da-9e96-31b53cebc49d.png`
|
||||
- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-compact-1440.png`
|
||||
- Viewport: 1440 × 1024
|
||||
- State: authenticated dashboard, compact layout, redundant title/action row removed.
|
||||
|
||||
### Iteration 2 — passed
|
||||
|
||||
The annotated header row containing the duplicate “服务看板” title, subtitle, and “查看告警” action was removed entirely. Section gaps, overview height, health icon, metric type, chart height, service rows, task heading, and task rows were reduced by roughly 10%–15%. The result preserves chart labels, dual-axis readability, service-state text, task progress, accuracy, and all task actions while bringing the primary content closer to the top of the page.
|
||||
|
||||
- ECharts tooltip remains functional after the height reduction and reports all three 07/10 series values.
|
||||
- The revised page contains no browser console errors.
|
||||
- The full-resolution comparison makes the removed annotation target and the compact replacement legible; no focused crop is necessary.
|
||||
- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build` pass.
|
||||
|
||||
Design-QA final result: passed
|
||||
|
||||
final result: passed
|
||||
|
||||
## One-screen dashboard revision
|
||||
|
||||
- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-3c166ffc-7243-4e57-94fc-48949599c4f1.png`
|
||||
- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-one-screen-1440x768.png`
|
||||
- Viewport: 1440 × 768
|
||||
- State: authenticated dashboard with the desktop low-height compact rules active.
|
||||
|
||||
### Iteration 3 — passed
|
||||
|
||||
The platform-status block was reduced again, including its container padding, inner gap, health icon, status copy, metric labels, and metric values. The chart, service rows, task rows, and page-canvas padding now use a dedicated `max-height: 900px` desktop mode. The dashboard page canvas is constrained to the available application viewport so the outer content area does not introduce a vertical scrollbar.
|
||||
|
||||
Browser measurements at 1440 × 768:
|
||||
|
||||
- document overflow: false
|
||||
- layout-content overflow: false
|
||||
- page-canvas overflow: false
|
||||
- dashboard overflow: false
|
||||
- task section bottom: 653px within the 768px viewport
|
||||
- ECharts tooltip: passed with all three series present
|
||||
- browser console errors: none
|
||||
- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build`: passed
|
||||
|
||||
Design-QA final result: passed
|
||||
|
||||
final result: passed
|
||||
|
||||
## Flexible middle-region revision
|
||||
|
||||
- User annotation reference: `/var/folders/nk/yks07zp14wb4rv3jqq0pt_4h0000gn/T/codex-clipboard-daf2bbae-baea-418d-9962-7d0e1a1c219b.png`
|
||||
- Revised implementation screenshot: `docs/superpowers/specs/assets/service-dashboard-flex-middle-1440x900.png`
|
||||
- Viewport: 1440 × 900
|
||||
- State: authenticated dashboard with flexible middle-region growth.
|
||||
|
||||
### Iteration 4 — passed
|
||||
|
||||
The previous fixed-height middle row caused unused white space beneath the task table on taller screens. The dashboard now reserves compact intrinsic height for the platform summary and task table while allowing the chart/service row to consume all remaining viewport height. The ECharts canvas grows with that row, and the service-state rows distribute across the matching height.
|
||||
|
||||
Browser measurements:
|
||||
|
||||
- at 1440 × 768, chart height: 283px; no document, layout, canvas, or dashboard overflow
|
||||
- at 1440 × 900, chart height: 415px; no document, layout, canvas, or dashboard overflow
|
||||
- task table bottom at 1440 × 900: 868px within the 900px viewport
|
||||
- ECharts tooltip: passed with all three series present
|
||||
- browser console errors: none
|
||||
- `npm run test:dashboard`, `npm run test:default-dashboard`, and `npx vite build`: passed
|
||||
|
||||
Design-QA final result: passed
|
||||
|
||||
final result: passed
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/superpowers/specs/assets/service-dashboard-compact-1440.png
Normal file
BIN
docs/superpowers/specs/assets/service-dashboard-compact-1440.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -8,6 +8,8 @@
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc -b --noEmit",
|
||||
"test:default-dashboard": "node scripts/regression-default-dashboard.mjs",
|
||||
"test:dashboard": "node scripts/regression-dashboard.mjs",
|
||||
"test:data-process-list": "node scripts/regression-data-process-list.mjs",
|
||||
"test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs",
|
||||
"test:dataset-task-tab": "node scripts/regression-dataset-task-tab.mjs",
|
||||
|
||||
50
frontend/scripts/regression-dashboard.mjs
Normal file
50
frontend/scripts/regression-dashboard.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const [routerSource, dashboardSource, echartsSource, mainLayoutSource] = await Promise.all([
|
||||
readFile(path.resolve(scriptDir, '../src/router/index.ts'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/views/dashboard/DashboardView.vue'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/plugins/echarts.ts'), 'utf8'),
|
||||
readFile(path.resolve(scriptDir, '../src/layouts/MainLayout.vue'), 'utf8'),
|
||||
])
|
||||
|
||||
assert.match(
|
||||
routerSource,
|
||||
/path:\s*['"]dashboard['"][\s\S]*?DashboardView\.vue/,
|
||||
'服务看板路由应使用独立 DashboardView',
|
||||
)
|
||||
|
||||
assert.match(echartsSource, /import\s*\{[^}]*BarChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 BarChart')
|
||||
assert.match(echartsSource, /use\(\[[\s\S]*?BarChart[\s\S]*?\]\)/, 'BarChart 未加入 ECharts 按需注册列表')
|
||||
|
||||
for (const copy of [
|
||||
'平台运行状态',
|
||||
'近 7 天训练统计',
|
||||
'训练次数(次)',
|
||||
'GPU 使用数(个)',
|
||||
'平均准确率(%)',
|
||||
'服务状态',
|
||||
'训练任务',
|
||||
'查看全部任务',
|
||||
]) {
|
||||
assert.ok(dashboardSource.includes(copy), `服务看板缺少关键内容:${copy}`)
|
||||
}
|
||||
|
||||
assert.match(dashboardSource, /yAxis:\s*\[[\s\S]*?次数 \/ GPU 数[\s\S]*?准确率/, '柱状图应使用双 Y 轴表达不同单位')
|
||||
assert.match(dashboardSource, /name:\s*['"]平均准确率(%)['"][\s\S]*?yAxisIndex:\s*1/, '准确率柱应绑定右侧百分比坐标轴')
|
||||
assert.match(dashboardSource, /router\.push\(['"]\/fine-tune['"]\)/, '查看全部任务应进入模型微调列表')
|
||||
assert.match(dashboardSource, /router\.push\(`\/training-log\/\$\{task\.id\}`\)/, '训练任务详情应进入训练日志页')
|
||||
assert.doesNotMatch(dashboardSource, /class=["']dashboard-heading["']/, '服务看板不应重复展示页面标题栏')
|
||||
assert.doesNotMatch(dashboardSource, /查看告警/, '服务看板不应保留冗余的顶部告警按钮')
|
||||
assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?gap:\s*10px;/, '服务看板应使用紧凑的区块间距')
|
||||
assert.match(dashboardSource, /\.dashboard-middle\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?min-height:\s*0;/, '中间区域应弹性占满剩余高度')
|
||||
assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?height:\s*auto;[\s\S]*?min-height:\s*230px;/, '训练统计图应随可用空间自动拉伸')
|
||||
assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.training-chart\s*\{[\s\S]*?min-height:\s*200px;/, '较矮桌面视口应保留图表最小可读高度')
|
||||
assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸')
|
||||
assert.match(mainLayoutSource, /\.layout-content:has\(\.dashboard-view\)[\s\S]*?overflow-y:\s*hidden;/, '服务看板容器应避免产生页面纵向滚动条')
|
||||
assert.match(mainLayoutSource, /\.page-canvas\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?overflow:\s*hidden;/, '服务看板页面画布应约束在可用视口内')
|
||||
|
||||
console.log('服务看板回归检查通过')
|
||||
39
frontend/scripts/regression-default-dashboard.mjs
Normal file
39
frontend/scripts/regression-default-dashboard.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
|
||||
function read(relativePath) {
|
||||
return readFileSync(resolve(root, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
const router = read('src/router/index.ts')
|
||||
const login = read('src/views/login/LoginView.vue')
|
||||
const sidebar = read('src/components/AppSidebar.vue')
|
||||
|
||||
assert(
|
||||
router.includes("redirect: '/dashboard'"),
|
||||
'The authenticated root and fallback routes should default to 服务看板',
|
||||
)
|
||||
|
||||
assert(
|
||||
router.includes("next('/dashboard')"),
|
||||
'An authenticated user visiting the login page should enter 服务看板',
|
||||
)
|
||||
|
||||
assert(
|
||||
login.includes("router.push('/dashboard')"),
|
||||
'A successful login should navigate to 服务看板',
|
||||
)
|
||||
|
||||
assert(
|
||||
sidebar.includes("route.path.split('/')[1] || 'dashboard'"),
|
||||
'The sidebar fallback active item should be 服务看板',
|
||||
)
|
||||
|
||||
console.log('default-dashboard regression checks passed')
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@@ -9,7 +9,7 @@ const auth = useAuthStore()
|
||||
|
||||
/** 当前激活菜单 key(取路由路径第一段,如 /fine-tune/create → fine-tune) */
|
||||
const activeMenu = computed(() => {
|
||||
const seg = route.path.split('/')[1] || 'fine-tune'
|
||||
const seg = route.path.split('/')[1] || 'dashboard'
|
||||
// 训练日志归到模型调优
|
||||
if (seg === 'training-log') return 'fine-tune'
|
||||
// 维度管理归到模型评测
|
||||
@@ -62,10 +62,33 @@ const menuGroups = [
|
||||
},
|
||||
]
|
||||
|
||||
function handleSelect(key: string) {
|
||||
/**
|
||||
* 传给 el-menu 的激活值。
|
||||
*
|
||||
* el-menu 在点击菜单项时会立即把内部高亮移到被点击项,再触发 @select。
|
||||
* 若随后 router.push 被路由守卫拦截(如未保存草稿确认),route.path 不变,
|
||||
* activeMenu 也不会变化,导致 el-menu 无法把高亮校正回当前路由对应的项。
|
||||
* 这里用一个可写 ref 包裹,导航失败时主动触发一次「置空 → 还原」,
|
||||
* 强制 el-menu 重新对齐到真实激活项。
|
||||
*/
|
||||
const menuActive = ref(activeMenu.value)
|
||||
|
||||
watch(activeMenu, (value) => {
|
||||
menuActive.value = value
|
||||
})
|
||||
|
||||
async function handleSelect(key: string) {
|
||||
const all = menuGroups.flatMap((g) => g.items)
|
||||
const target = all.find((i) => i.key === key)
|
||||
if (target) router.push(target.to)
|
||||
if (!target) return
|
||||
|
||||
const failure = await router.push(target.to)
|
||||
// 导航被守卫取消(如未保存修改确认)时,把高亮校正回当前路由
|
||||
if (failure) {
|
||||
menuActive.value = ''
|
||||
await nextTick()
|
||||
menuActive.value = activeMenu.value
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
@@ -85,7 +108,7 @@ function handleLogout() {
|
||||
<!-- 导航 -->
|
||||
<el-scrollbar class="sidebar-nav" wrap-class="nav-scrollbar-wrap">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:default-active="menuActive"
|
||||
class="sidebar-menu"
|
||||
background-color="transparent"
|
||||
text-color="var(--sidebar-text)"
|
||||
|
||||
@@ -79,6 +79,20 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (min-width: 1181px) {
|
||||
.layout-content:has(.dashboard-view) {
|
||||
overflow-y: hidden;
|
||||
|
||||
.page-canvas {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-canvas {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
|
||||
@@ -11,13 +11,13 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/fine-tune',
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
// 服务看板
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/components/PlaceholderView.vue'),
|
||||
component: () => import('@/views/dashboard/DashboardView.vue'),
|
||||
meta: { title: '服务看板' },
|
||||
},
|
||||
// 模型调优
|
||||
@@ -202,7 +202,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/fine-tune',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -219,7 +219,7 @@ router.beforeEach((to, _from, next) => {
|
||||
if (to.meta.public) {
|
||||
// 已登录访问登录页则跳转主页
|
||||
if (to.name === 'login' && auth.isLoggedIn) {
|
||||
next('/fine-tune')
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
next()
|
||||
|
||||
753
frontend/src/views/dashboard/DashboardView.vue
Normal file
753
frontend/src/views/dashboard/DashboardView.vue
Normal file
@@ -0,0 +1,753 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
|
||||
type ServiceState = 'normal' | 'busy' | 'error'
|
||||
type TaskState = 'running' | 'pending' | 'completed' | 'failed'
|
||||
|
||||
interface ServiceStatus {
|
||||
name: string
|
||||
icon: string
|
||||
state: ServiceState
|
||||
instances: string
|
||||
}
|
||||
|
||||
interface DashboardTask {
|
||||
id: number
|
||||
name: string
|
||||
state: TaskState
|
||||
trainType: string
|
||||
trainMethod: string
|
||||
baseModel: string
|
||||
progress: number
|
||||
accuracy: number | null
|
||||
startedAt: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const period = ref('7d')
|
||||
|
||||
const serviceStatuses: ServiceStatus[] = [
|
||||
{ name: '模型推理', icon: 'fa-cube', state: 'normal', instances: '6 / 6' },
|
||||
{ name: '模型微调', icon: 'fa-sliders', state: 'busy', instances: '4 / 6' },
|
||||
{ name: '模型评测', icon: 'fa-bar-chart', state: 'normal', instances: '3 / 3' },
|
||||
{ name: '数据处理', icon: 'fa-filter', state: 'error', instances: '1 / 3' },
|
||||
]
|
||||
|
||||
const trainingTasks: DashboardTask[] = [
|
||||
{
|
||||
id: 103942,
|
||||
name: 'finance-sft-003',
|
||||
state: 'running',
|
||||
trainType: 'SFT',
|
||||
trainMethod: 'LoRA',
|
||||
baseModel: 'Qwen2.5-7B-Instruct',
|
||||
progress: 68,
|
||||
accuracy: 89.2,
|
||||
startedAt: '今天 09:18',
|
||||
},
|
||||
{
|
||||
id: 593021,
|
||||
name: 'legal-eval-008',
|
||||
state: 'pending',
|
||||
trainType: 'DPO',
|
||||
trainMethod: 'LoRA',
|
||||
baseModel: 'Qwen2.5-7B-Instruct',
|
||||
progress: 0,
|
||||
accuracy: null,
|
||||
startedAt: '今天 08:55',
|
||||
},
|
||||
{
|
||||
id: 849301,
|
||||
name: 'medical-cpt-002',
|
||||
state: 'completed',
|
||||
trainType: 'CPT',
|
||||
trainMethod: 'Full',
|
||||
baseModel: 'Qwen2.5-14B-Instruct',
|
||||
progress: 100,
|
||||
accuracy: 91.6,
|
||||
startedAt: '07/10 16:20',
|
||||
},
|
||||
{
|
||||
id: 201948,
|
||||
name: 'finance-sft-002',
|
||||
state: 'failed',
|
||||
trainType: 'SFT',
|
||||
trainMethod: 'LoRA',
|
||||
baseModel: 'Qwen2.5-7B-Instruct',
|
||||
progress: 42,
|
||||
accuracy: null,
|
||||
startedAt: '07/10 11:08',
|
||||
},
|
||||
]
|
||||
|
||||
const serviceStateMeta: Record<ServiceState, { label: string; className: string }> = {
|
||||
normal: { label: '正常', className: 'is-normal' },
|
||||
busy: { label: '繁忙', className: 'is-busy' },
|
||||
error: { label: '异常', className: 'is-error' },
|
||||
}
|
||||
|
||||
const taskStateMeta: Record<TaskState, { label: string; className: string }> = {
|
||||
running: { label: '训练中', className: 'is-running' },
|
||||
pending: { label: '等待中', className: 'is-pending' },
|
||||
completed: { label: '已完成', className: 'is-completed' },
|
||||
failed: { label: '训练失败', className: 'is-failed' },
|
||||
}
|
||||
|
||||
const progressColor: Record<TaskState, string> = {
|
||||
running: '#4f46e5',
|
||||
pending: '#cbd5e1',
|
||||
completed: '#10b981',
|
||||
failed: '#ef4444',
|
||||
}
|
||||
|
||||
const chartOption = computed<EChartsOption>(() => ({
|
||||
animationDuration: 500,
|
||||
color: ['#4f46e5', '#10b981', '#f59e0b'],
|
||||
grid: { top: 50, right: 50, bottom: 38, left: 46, containLabel: false },
|
||||
legend: {
|
||||
top: 4,
|
||||
left: 0,
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
itemGap: 22,
|
||||
textStyle: { color: '#64748b', fontSize: 11 },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
padding: [10, 12],
|
||||
textStyle: { color: '#ffffff', fontSize: 12 },
|
||||
valueFormatter: (value) => `${value}`,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['07/05', '07/06', '07/07', '07/08', '07/09', '07/10', '07/11\n今天'],
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#64748b', fontSize: 11, lineHeight: 16, margin: 12 },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '次数 / GPU 数',
|
||||
min: 0,
|
||||
max: 20,
|
||||
interval: 5,
|
||||
nameTextStyle: { color: '#64748b', fontSize: 11, align: 'left' },
|
||||
axisLabel: { color: '#64748b', fontSize: 11 },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: '#eef2f7' } },
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '准确率',
|
||||
min: 0,
|
||||
max: 100,
|
||||
interval: 25,
|
||||
nameTextStyle: { color: '#64748b', fontSize: 11, align: 'right' },
|
||||
axisLabel: { color: '#64748b', fontSize: 11, formatter: '{value}%' },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '训练次数(次)',
|
||||
type: 'bar',
|
||||
data: [8, 12, 10, 15, 13, 18, 11],
|
||||
barMaxWidth: 16,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
name: 'GPU 使用数(个)',
|
||||
type: 'bar',
|
||||
data: [3, 4, 4, 6, 5, 7, 5],
|
||||
barMaxWidth: 16,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
label: { show: true, position: 'top', color: '#64748b', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
name: '平均准确率(%)',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: [82, 85, 84, 88, 87, 91, 89],
|
||||
barMaxWidth: 16,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
label: { show: true, position: 'top', color: '#d97706', fontSize: 10 },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
function viewAllTasks() {
|
||||
router.push('/fine-tune')
|
||||
}
|
||||
|
||||
function viewTask(task: DashboardTask) {
|
||||
router.push(`/training-log/${task.id}`)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-view">
|
||||
<section class="overview-section" aria-labelledby="overview-title">
|
||||
<h2 id="overview-title" class="section-title">平台运行状态</h2>
|
||||
<div class="overview-content">
|
||||
<div class="health-summary">
|
||||
<div class="health-icon" aria-hidden="true"><i class="fa fa-check" /></div>
|
||||
<div>
|
||||
<strong>整体运行正常</strong>
|
||||
<p>平台运行良好,各项服务稳定</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-metrics">
|
||||
<div class="overview-metric">
|
||||
<span>在线服务</span>
|
||||
<strong>12</strong>
|
||||
<small>全部在线</small>
|
||||
</div>
|
||||
<div class="overview-metric">
|
||||
<span>运行中任务</span>
|
||||
<strong>5</strong>
|
||||
<small>较昨日 +1</small>
|
||||
</div>
|
||||
<div class="overview-metric is-alert">
|
||||
<span>待处理告警</span>
|
||||
<strong>2</strong>
|
||||
<small>较昨日 -1</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-middle">
|
||||
<section class="chart-section" aria-labelledby="chart-title">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="chart-title" class="section-title">近 7 天训练统计</h2>
|
||||
<el-select v-model="period" class="period-select" aria-label="统计周期">
|
||||
<el-option label="近7天" value="7d" />
|
||||
</el-select>
|
||||
</div>
|
||||
<VChart class="training-chart" :option="chartOption" autoresize />
|
||||
</section>
|
||||
|
||||
<section class="service-section" aria-labelledby="service-title">
|
||||
<h2 id="service-title" class="section-title">服务状态</h2>
|
||||
<div class="service-table" role="table" aria-label="服务状态">
|
||||
<div class="service-row service-header" role="row">
|
||||
<span role="columnheader">服务名称</span>
|
||||
<span role="columnheader">状态</span>
|
||||
<span role="columnheader">实例数</span>
|
||||
</div>
|
||||
<div v-for="service in serviceStatuses" :key="service.name" class="service-row" role="row">
|
||||
<span class="service-name" role="cell">
|
||||
<i class="fa" :class="service.icon" aria-hidden="true" />
|
||||
{{ service.name }}
|
||||
</span>
|
||||
<span role="cell">
|
||||
<span class="state-pill" :class="serviceStateMeta[service.state].className">
|
||||
{{ serviceStateMeta[service.state].label }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="instance-count" role="cell">{{ service.instances }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="tasks-section" aria-labelledby="tasks-title">
|
||||
<div class="section-heading-row tasks-heading">
|
||||
<h2 id="tasks-title" class="section-title">训练任务</h2>
|
||||
<el-button link type="primary" @click="viewAllTasks">查看全部任务</el-button>
|
||||
</div>
|
||||
<div class="tasks-table-wrap">
|
||||
<table class="tasks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务名称</th>
|
||||
<th>任务状态</th>
|
||||
<th>训练方式</th>
|
||||
<th>基座模型</th>
|
||||
<th>训练进度</th>
|
||||
<th>准确率</th>
|
||||
<th>开始时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in trainingTasks" :key="task.id">
|
||||
<td class="task-name">{{ task.name }}</td>
|
||||
<td>
|
||||
<span class="task-state" :class="taskStateMeta[task.state].className">
|
||||
{{ taskStateMeta[task.state].label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ task.trainType }} / {{ task.trainMethod }}</td>
|
||||
<td>{{ task.baseModel }}</td>
|
||||
<td>
|
||||
<div class="progress-cell">
|
||||
<el-progress
|
||||
:percentage="task.progress"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
:color="progressColor[task.state]"
|
||||
/>
|
||||
<span>{{ task.progress }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="numeric-value">{{ task.accuracy == null ? '--' : `${task.accuracy}%` }}</td>
|
||||
<td>{{ task.startedAt }}</td>
|
||||
<td>
|
||||
<el-button link type="primary" @click="viewTask(task)">查看详情</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.overview-section,
|
||||
.chart-section,
|
||||
.service-section,
|
||||
.tasks-section {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
padding: 12px 14px 13px;
|
||||
}
|
||||
|
||||
.overview-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1.05fr) minmax(540px, 1.75fr);
|
||||
align-items: stretch;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.health-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 86px;
|
||||
padding: 6px 18px 6px 12px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
color: #10b981;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.health-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: 0 0 56px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
color: #ffffff;
|
||||
font-size: 26px;
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.14);
|
||||
}
|
||||
|
||||
.overview-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.overview-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
span,
|
||||
small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&.is-alert strong {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-middle {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.65fr) minmax(330px, 0.95fr);
|
||||
flex: 1 1 auto;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-section,
|
||||
.service-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 280px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.section-heading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
width: 94px;
|
||||
|
||||
:deep(.el-select__wrapper) {
|
||||
min-height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.training-chart {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 230px;
|
||||
}
|
||||
|
||||
.service-table {
|
||||
display: grid;
|
||||
grid-template-rows: 36px repeat(4, minmax(48px, 1fr));
|
||||
flex: 1 1 auto;
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.service-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 1.35fr) minmax(72px, 0.7fr) 72px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.service-header {
|
||||
min-height: 36px;
|
||||
border-top: none;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
|
||||
.fa {
|
||||
width: 18px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.state-pill,
|
||||
.task-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.is-normal,
|
||||
.is-completed {
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.is-busy,
|
||||
.is-pending {
|
||||
color: #d97706;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.is-error,
|
||||
.is-failed {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.is-running {
|
||||
color: #4f46e5;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.instance-count,
|
||||
.numeric-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tasks-section {
|
||||
padding: 10px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tasks-heading {
|
||||
min-height: 30px;
|
||||
padding: 0 14px 8px;
|
||||
}
|
||||
|
||||
.tasks-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tasks-table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 38px;
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
height: 34px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
th:nth-child(1) { width: 14%; }
|
||||
th:nth-child(2) { width: 10%; }
|
||||
th:nth-child(3) { width: 11%; }
|
||||
th:nth-child(4) { width: 17%; }
|
||||
th:nth-child(5) { width: 17%; }
|
||||
th:nth-child(6) { width: 9%; }
|
||||
th:nth-child(7) { width: 12%; }
|
||||
th:nth-child(8) { width: 10%; }
|
||||
}
|
||||
|
||||
.task-name {
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-cell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 1fr) 38px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
:deep(.el-progress-bar__outer) {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 900px) and (min-width: 1181px) {
|
||||
.dashboard-view {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
padding: 8px 12px 9px;
|
||||
}
|
||||
|
||||
.overview-content {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.health-summary {
|
||||
gap: 10px;
|
||||
min-height: 64px;
|
||||
padding: 4px 14px 4px 8px;
|
||||
|
||||
strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.health-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-basis: 44px;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.overview-metric {
|
||||
gap: 2px;
|
||||
|
||||
span,
|
||||
small {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-middle {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-section,
|
||||
.service-section {
|
||||
min-height: 240px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.training-chart {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.service-table {
|
||||
grid-template-rows: 32px repeat(4, minmax(40px, 1fr));
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.service-row {
|
||||
min-height: 40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.service-header {
|
||||
min-height: 32px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.state-pill,
|
||||
.task-state {
|
||||
min-height: 20px;
|
||||
padding-inline: 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tasks-section {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.tasks-heading {
|
||||
min-height: 24px;
|
||||
padding: 0 12px 6px;
|
||||
}
|
||||
|
||||
.tasks-table {
|
||||
th,
|
||||
td {
|
||||
height: 32px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
th {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.overview-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.health-summary {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.overview-metrics {
|
||||
min-height: 94px;
|
||||
}
|
||||
|
||||
.dashboard-middle {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-view * {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -28,7 +28,7 @@ async function handleLogin() {
|
||||
try {
|
||||
await auth.login(loginForm.username, loginForm.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/fine-tune')
|
||||
router.push('/dashboard')
|
||||
} catch {
|
||||
// 拦截器已提示错误
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user