Files
YG_FT/frontend/src/views/dashboard/DashboardView.vue
caoxiaozhu a70cf6addd refactor: Dashboard 看板布局与图表优化
DashboardView 调整看板卡片与栅格展示,ECharts 补充图表类型,主布局 dashboard 区域由内嵌滚动改为自适应可滚动以避免内容被裁剪,回归脚本适配。
2026-07-14 16:10:59 +08:00

976 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
}
interface LoginDurationStat {
id: number
username: string
duration: number
}
interface RecentLoginUser {
id: number
username: string
role: string
lastLogin: 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 },
},
],
}))
const operationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
tooltip: { trigger: 'item' },
color: ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899'],
series: [
{
name: '操作分类',
type: 'pie',
radius: ['40%', '64%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
position: 'outside',
formatter: '{b}',
color: '#475569',
fontSize: 11,
lineHeight: 16,
width: 70,
overflow: 'truncate',
},
emphasis: {
label: { show: true, fontSize: 12, fontWeight: 'bold', color: '#1e293b' }
},
labelLine: {
show: true,
length: 10,
length2: 8,
lineStyle: { color: '#94a3b8', width: 1 },
},
data: [
{ value: 1048, name: '模型训练' },
{ value: 735, name: '数据处理' },
{ value: 580, name: '模型评测' },
{ value: 484, name: '模型推理' },
{ value: 300, name: '系统设置' }
]
}
]
}))
const loginDurationStats: LoginDurationStat[] = [
{ id: 1, username: 'admin', duration: 124 },
{ id: 2, username: 'zhangsan', duration: 86 },
{ id: 3, username: 'lisi', duration: 42 },
{ id: 4, username: 'wangwu', duration: 18 },
]
const loginDurationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
grid: { top: 8, right: 40, bottom: 6, left: 8, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (value) => `${value} 小时`,
},
xAxis: {
type: 'value',
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) / 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.map((user) => user.username),
axisLabel: { color: '#475569', fontSize: 12 },
axisLine: { show: false },
axisTick: { show: false },
},
series: [
{
name: '登录时长',
type: 'bar',
data: loginDurationStats.map((user) => user.duration),
barMaxWidth: 18,
barCategoryGap: '34%',
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right', distance: 8, color: '#64748b', fontSize: 11, formatter: '{c} 小时' },
},
],
}))
const recentLoginUsers: RecentLoginUser[] = [
{ id: 1, username: 'admin', role: '超级管理员', lastLogin: '10 分钟前' },
{ id: 2, username: 'zhangsan', role: '操作员', lastLogin: '2 小时前' },
{ id: 5, username: 'zhaoliu', role: '观察员', lastLogin: '5 小时前' },
{ id: 3, username: 'lisi', role: '操作员', lastLogin: '昨天 15:30' },
]
const roleTagType: Record<string, 'danger' | 'primary' | 'info'> = {
'超级管理员': 'danger',
'操作员': 'primary',
'观察员': 'info',
}
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>
<div class="user-stats-row">
<section class="stat-card" aria-labelledby="op-dist-title">
<h2 id="op-dist-title" class="section-title">用户操作分布</h2>
<div class="chart-container">
<VChart class="pie-chart" :option="operationChartOption" autoresize />
</div>
</section>
<section class="stat-card" aria-labelledby="login-dur-title">
<h2 id="login-dur-title" class="section-title">登录时长排行 (本月)</h2>
<VChart class="duration-chart" :option="loginDurationChartOption" autoresize />
</section>
<section class="stat-card" aria-labelledby="recent-login-title">
<h2 id="recent-login-title" class="section-title">最近登录用户</h2>
<div class="list-container">
<div v-for="user in recentLoginUsers" :key="user.id" class="list-item recent-user-item">
<div class="user-info">
<el-avatar :size="32" class="user-avatar" :style="{ backgroundColor: '#e2e8f0', color: '#475569' }">
{{ user.username.slice(0, 1).toUpperCase() }}
</el-avatar>
<div class="user-meta">
<span class="username">{{ user.username }}</span>
<span class="login-time">{{ user.lastLogin }}</span>
</div>
</div>
<el-tag :type="roleTagType[user.role]" size="small">{{ user.role }}</el-tag>
</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;
min-height: 100%;
gap: 16px;
min-width: 0;
color: #1e293b;
}
.overview-section,
.chart-section,
.service-section,
.stat-card,
.tasks-section {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
}
.user-stats-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.stat-card {
display: flex;
flex-direction: column;
padding: 16px 18px;
min-height: 284px;
min-width: 0;
}
.chart-container {
flex: 1 1 auto;
min-height: 224px;
margin-top: 10px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.pie-chart {
width: 100%;
height: 224px;
}
.duration-chart {
width: 100%;
height: 224px;
margin-top: 10px;
}
.list-container {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
font-size: 14px;
font-weight: 600;
}
.username {
font-size: 13px;
font-weight: 500;
color: #334155;
}
.user-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.login-time {
font-size: 11px;
color: #94a3b8;
}
.recent-user-item {
padding: 2px 0;
}
.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.9fr) minmax(300px, 0.82fr);
flex: 0 0 auto;
gap: 16px;
min-height: 0;
}
.chart-section,
.service-section {
display: flex;
flex-direction: column;
min-height: 360px;
padding: 16px 18px;
}
.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: 300px;
min-height: 300px;
}
.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: 12px 0 0;
overflow: hidden;
}
.tasks-heading {
min-height: 32px;
padding: 0 14px 10px;
}
.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: 42px;
padding: 6px 10px;
border-top: 1px solid #eef2f7;
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
th {
height: 36px;
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) {
.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;
}
}
.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: 8px;
}
.tasks-heading {
min-height: 28px;
padding: 0 12px 8px;
}
.tasks-table {
th,
td {
height: 36px;
padding: 4px 8px;
}
th {
height: 32px;
}
}
}
@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;
}
.user-stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.user-stats-row {
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>