refactor: Dashboard 看板布局与图表优化

DashboardView 调整看板卡片与栅格展示,ECharts 补充图表类型,主布局 dashboard 区域由内嵌滚动改为自适应可滚动以避免内容被裁剪,回归脚本适配。
This commit is contained in:
caoxiaozhu
2026-07-14 16:10:59 +08:00
parent 8b83b8facf
commit a70cf6addd
4 changed files with 270 additions and 37 deletions

View File

@@ -19,6 +19,8 @@ assert.match(
assert.match(echartsSource, /import\s*\{[^}]*BarChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 BarChart')
assert.match(echartsSource, /use\(\[[\s\S]*?BarChart[\s\S]*?\]\)/, 'BarChart 未加入 ECharts 按需注册列表')
assert.match(echartsSource, /import\s*\{[^}]*PieChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 PieChart')
assert.match(echartsSource, /use\(\[[\s\S]*?PieChart[\s\S]*?\]\)/, 'PieChart 未加入 ECharts 按需注册列表')
for (const copy of [
'平台运行状态',
@@ -39,16 +41,24 @@ 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, /\.dashboard-view\s*\{[\s\S]*?gap:\s*16px;/, '服务看板区块间距应保持舒展')
assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?min-height:\s*100%;/, '服务看板应至少填满页面可用高度')
assert.match(dashboardSource, /\.dashboard-middle\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?min-height:\s*0;/, '中间区域不应因新增统计卡片被压缩')
assert.match(dashboardSource, /grid-template-columns:\s*minmax\(0,\s*1\.9fr\)\s*minmax\(300px,\s*0\.82fr\);/, '服务状态列应收窄,为训练图表释放更多宽度')
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, /\.training-chart\s*\{[\s\S]*?height:\s*300px;[\s\S]*?min-height:\s*300px;/, '训练统计图应保持舒展、稳定的展示高度')
assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸')
assert.match(dashboardSource, /\.tasks-heading\s*\{[\s\S]*?min-height:\s*32px;[\s\S]*?padding:\s*0 14px 10px;/, '训练任务标题区应适当加高')
assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*36px;/, '训练任务表格行应适当加高')
assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*36px;[\s\S]*?th\s*\{[\s\S]*?height:\s*32px;/, '低高度桌面视口也应提高训练任务占比')
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;/, '服务看板页面画布应约束在可用视口内')
assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作分布[\s\S]*?登录时长排行[\s\S]*?最近登录用户/, '服务看板应展示三块用户统计卡片')
assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示')
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数')
assert.match(dashboardSource, /const operationChartOption[\s\S]*?position:\s*['"]outside['"][\s\S]*?labelLine:\s*\{[\s\S]*?show:\s*true/, '饼图应以外侧引导线标注操作名称')
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?grid:\s*\{\s*top:\s*8,\s*right:\s*12,\s*bottom:\s*6,\s*left:\s*8,[\s\S]*?max:\s*Math\.ceil\(Math\.max[\s\S]*?position:\s*['"]insideRight['"]/, '登录时长图应收紧左右边距、按数据范围拉伸,并将数值置于柱内')
assert.match(dashboardSource, /\.user-stats-row\s*\{[\s\S]*?repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '宽屏用户统计卡片应保持三列')
assert.match(dashboardSource, /@media\s*\(max-width:\s*1180px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?repeat\(2,\s*minmax\(0,\s*1fr\)\)/, '中等宽度应将用户统计卡片降为两列')
assert.match(dashboardSource, /@media\s*\(max-width:\s*720px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?grid-template-columns:\s*1fr;/, '窄屏应将用户统计卡片降为单列')
assert.match(mainLayoutSource, /\.layout-content:has\(\.dashboard-view\)[\s\S]*?overflow-y:\s*auto;/, '服务看板应允许在内容超出视口时纵向滚动')
assert.match(mainLayoutSource, /\.page-canvas\s*\{[\s\S]*?flex:\s*1 0 auto;[\s\S]*?overflow:\s*visible;/, '服务看板画布应允许新增卡片完整显示')
console.log('服务看板回归检查通过')

View File

@@ -84,13 +84,13 @@ onUnmounted(() => {
@media (min-width: 1181px) {
.layout-content:has(.dashboard-view) {
overflow-y: hidden;
overflow-y: auto;
.page-canvas {
flex: 1 1 auto;
flex: 1 0 auto;
min-height: 0;
padding: 16px;
overflow: hidden;
overflow: visible;
}
}
}

View File

@@ -4,7 +4,7 @@
*/
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart } from 'echarts/charts'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
@@ -17,6 +17,7 @@ use([
CanvasRenderer,
BarChart,
LineChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,

View File

@@ -25,6 +25,19 @@ interface DashboardTask {
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')
@@ -185,6 +198,110 @@ const chartOption = computed<EChartsOption>(() => ({
],
}))
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')
}
@@ -262,6 +379,38 @@ function viewTask(task: DashboardTask) {
</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>
@@ -319,22 +468,102 @@ function viewTask(task: DashboardTask) {
.dashboard-view {
display: flex;
flex-direction: column;
height: 100%;
gap: 10px;
min-height: 100%;
gap: 16px;
min-width: 0;
min-height: 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;
@@ -429,8 +658,8 @@ function viewTask(task: DashboardTask) {
.dashboard-middle {
display: grid;
grid-template-columns: minmax(0, 1.9fr) minmax(300px, 0.82fr);
flex: 1 1 auto;
gap: 10px;
flex: 0 0 auto;
gap: 16px;
min-height: 0;
}
@@ -438,8 +667,8 @@ function viewTask(task: DashboardTask) {
.service-section {
display: flex;
flex-direction: column;
min-height: 280px;
padding: 12px 14px;
min-height: 360px;
padding: 16px 18px;
}
.section-heading-row {
@@ -460,8 +689,8 @@ function viewTask(task: DashboardTask) {
.training-chart {
flex: 1 1 auto;
width: 100%;
height: auto;
min-height: 230px;
height: 300px;
min-height: 300px;
}
.service-table {
@@ -614,10 +843,6 @@ function viewTask(task: DashboardTask) {
}
@media (max-height: 900px) and (min-width: 1181px) {
.dashboard-view {
gap: 8px;
}
.section-title {
font-size: 14px;
}
@@ -666,20 +891,6 @@ function viewTask(task: DashboardTask) {
}
}
.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;
@@ -741,6 +952,17 @@ function viewTask(task: DashboardTask) {
.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) {