feat: 新增服务看板页面
实现 Dashboard 看板视图并将应用默认入口由调优改为看板,路由、侧边栏默认菜单与登录跳转同步调整;侧边栏增加导航失败高亮校正,布局与 ECharts 注册柱状图适配看板,补充两份回归脚本、设计走查记录与五张设计截图。
This commit is contained in:
@@ -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