feat: 新增服务看板页面

实现 Dashboard 看板视图并将应用默认入口由调优改为看板,路由、侧边栏默认菜单与登录跳转同步调整;侧边栏增加导航失败高亮校正,布局与 ECharts 注册柱状图适配看板,补充两份回归脚本、设计走查记录与五张设计截图。
This commit is contained in:
caoxiaozhu
2026-07-11 14:49:37 +08:00
parent ddf47eb8fe
commit cc113db1aa
15 changed files with 1013 additions and 11 deletions

View File

@@ -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)"

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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()

View 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>

View File

@@ -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 {