refactor: 训练日志组件化与 Mock 数据增强

拆分 TrainingTaskOverview 组件与 trainingLogModel 状态模型,TrainingLogView 大幅瘦身;Mock 新增按文件路由的训练日志内容与更真实的 GPU 进程占用数据,adapter 类型收敛为 AxiosAdapter,配套新增 mock 内容回归脚本。
This commit is contained in:
caoxiaozhu
2026-07-13 15:29:49 +08:00
parent e580ec4791
commit e212de1693
7 changed files with 1473 additions and 914 deletions

View File

@@ -2,17 +2,35 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import ts from 'typescript'
import { parse as parseTemplate } from '@vue/compiler-dom'
import { parse as parseSfc } from '@vue/compiler-sfc'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const viewPath = path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue')
const source = await readFile(viewPath, 'utf8')
const overviewPath = path.resolve(scriptDir, '../src/views/system/training-log/TrainingTaskOverview.vue')
const modelPath = path.resolve(scriptDir, '../src/views/system/training-log/trainingLogModel.ts')
const [source, overviewSource, modelSource] = await Promise.all([
readFile(viewPath, 'utf8'),
readFile(overviewPath, 'utf8'),
readFile(modelPath, 'utf8'),
])
const { descriptor } = parseSfc(source, { filename: viewPath })
const template = descriptor.template?.content || ''
const style = descriptor.styles.map((item) => item.content).join('\n')
const { descriptor: overviewDescriptor } = parseSfc(overviewSource, { filename: overviewPath })
const template = [descriptor.template?.content, overviewDescriptor.template?.content].filter(Boolean).join('\n')
const viewStyle = descriptor.styles.map((item) => item.content).join('\n')
const overviewStyle = overviewDescriptor.styles.map((item) => item.content).join('\n')
const style = `${viewStyle}\n${overviewStyle}`
const templateAst = parseTemplate(template)
const modelModuleCode = ts.transpileModule(modelSource, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
const {
parseTrainingLog,
resolveTrainingLogFile,
} = await import(`data:text/javascript;base64,${Buffer.from(modelModuleCode).toString('base64')}`)
function findElements(node, predicate, result = []) {
if (node?.type === 1 && predicate(node)) result.push(node)
for (const child of node?.children || []) findElements(child, predicate, result)
@@ -50,41 +68,71 @@ function extractCssBlock(css, marker) {
assert.fail(`样式规则缺少右花括号:${marker}`)
}
function relativeLuminance(hex) {
const channels = hex
.replace('#', '')
.match(/.{2}/g)
.map((channel) => Number.parseInt(channel, 16) / 255)
.map((value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4))
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
}
const logFiles = [
{ file: 'first_pid111.log', name: 'first-task', size: '1 KB', pid: 111 },
{ file: 'target_pid222.log', name: 'target-task', size: '1 KB', pid: 222 },
]
assert.equal(
resolveTrainingLogFile(logFiles, { process_id: 222, name: 'target-task' })?.file,
'target_pid222.log',
'训练日志必须按 task.process_id 精确匹配文件 pid不能只判断两者是否存在',
)
assert.equal(
resolveTrainingLogFile(logFiles, { process_id: 999, name: 'target-task' })?.file,
'target_pid222.log',
'PID 无匹配时应按任务名回退选择日志',
)
assert.equal(
resolveTrainingLogFile(logFiles, { process_id: 999, name: 'missing-task' }),
undefined,
'任务无匹配日志时不得静默退回第一份日志',
)
function contrastRatio(foreground, background) {
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background))
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background))
return (lighter + 0.05) / (darker + 0.05)
}
const parsed = parseTrainingLog([
"INFO {'epoch': .5, 'learning_rate': 1.2E-5, 'grad_norm': -2.5e+0, 'loss': +3.25}",
'***** train metrics *****',
'train_runtime = 1.7852e3',
"'train_loss': -3.42e-1",
'epoch: 1.0',
'***** train metrics end *****',
].join('\n'))
assert.deepEqual(parsed.metrics.loss, [3.25], '指标解析应允许字段乱序和带符号数值')
assert.deepEqual(parsed.metrics.gradNorm, [-2.5], '梯度范数应支持科学计数法')
assert.deepEqual(parsed.metrics.lr, [1.2e-5], '学习率应支持大小写科学计数法')
assert.deepEqual(
parsed.summary,
{ epoch: '1.0', trainLoss: '-3.42e-1', runtime: '1.7852e3' },
'训练汇总应同时支持等号、冒号、可选引号和科学计数法',
)
assert.deepEqual(
parseTrainingLog('普通日志,无训练指标').summary,
{ epoch: '', trainLoss: '', runtime: '' },
'每次解析必须返回全新的空汇总,避免保留上一份日志的旧值',
)
assert.match(
source,
/const currentTask = await loadTask\(\)[\s\S]*?loadLog\(currentTask\)/,
'刷新流程必须先加载 task再使用该 task 选择日志',
)
assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源')
assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新')
assert.match(source, /if \(refreshInFlight\) return/, '轮询刷新必须阻止并发重叠')
assert.match(source, /onUnmounted\([\s\S]*?clearInterval\(timer\)/, '组件卸载时必须清理轮询定时器')
const overview = findElements(
templateAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'),
)
assert.equal(overview.length, 1, '双栏任务档案容器必须且只能存在一个')
for (const expectedClass of ['task-profile', 'dataset-profile', 'runtime-panel', 'parameter-groups']) {
const matched = findElements(
templateAst,
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes(expectedClass),
)
assert.equal(matched.length, 1, `缺少或重复布局结构:${expectedClass}`)
}
assert.equal(overview.length, 1, '标准任务概况容器必须且只能存在一个')
assert.doesNotMatch(overviewSource, /<aside\b/, '任务概况仍保留左右侧栏结构')
assert.doesNotMatch(overviewSource, /<i class="fa\b/, '任务概况仍包含过多装饰性图标')
const toggleButtons = findElements(
templateAst,
(node) => node.tag === 'button'
(node) => node.tag === 'el-button'
&& staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'),
)
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的原生 button')
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的标准按钮')
const toggleButton = toggleButtons[0]
assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded')
@@ -96,33 +144,80 @@ const controlledRegions = findElements(
)
assert.equal(controlledRegions.length, 1, 'aria-controls 指向的参数内容区域不存在或重复')
const pageTitleIndex = source.indexOf('id="task-page-title"')
const summaryCardIndex = source.indexOf('id="training-overview-title"')
const taskOverviewIndex = source.indexOf('<TrainingTaskOverview')
assert.notEqual(pageTitleIndex, -1, '页面缺少独立的训练任务标题')
assert.ok(pageTitleIndex < summaryCardIndex, '训练任务标题必须出现在训练概览之前')
assert.ok(summaryCardIndex < taskOverviewIndex, '训练概览必须出现在任务信息之前')
assert.equal((source.match(/<h1\b/g) || []).length, 1, '训练详情页必须且只能有一个一级标题')
assert.doesNotMatch(overviewSource, /<h1\b/, '任务信息卡片不得重复渲染页面一级标题')
assert.ok(overviewSource.includes('title="任务信息"'), '任务详情卡片缺少明确的“任务信息”标题')
const firstChartIndex = template.indexOf('<!-- 训练曲线 -->')
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
assert.equal(
template.slice(0, firstChartIndex).includes('<el-descriptions'),
false,
'任务概览、数据集和训练参数仍使用带表格感的 el-descriptions',
assert.ok(template.includes('training-progress-panel'), '训练概览缺少独立的主进度区域')
assert.ok(template.includes('training-metric-grid'), '训练概览缺少次级训练指标摘要')
assert.ok(template.includes('gpu-device-list'), '训练概览缺少紧凑的 GPU 设备列表')
assert.ok(template.includes('gpu-list-header'), 'GPU 设备列表缺少企业表格式列标题')
assert.ok(template.includes('gpu-device-row'), 'GPU 设备列表缺少统一行结构')
assert.doesNotMatch(template, /training-summary-strip|gpu-device-card/, '训练概览仍保留同级宫格或 GPU 嵌套卡片结构')
assert.ok(template.includes('id="training-overview-title"'), '训练概览缺少可访问的卡片标题')
assert.ok(
template.indexOf('id="training-overview-title"') < template.indexOf('<TrainingTaskOverview'),
'训练概览必须位于任务档案之前,成为页面第一块内容',
)
assert.ok(
template.indexOf('training-progress-panel') < template.indexOf('training-metric-grid')
&& template.indexOf('training-metric-grid') < template.indexOf('gpu-device-list'),
'训练概览必须按主进度、次级指标、GPU 设备列表的顺序展示',
)
assert.ok(template.includes('id="gpu-monitor-title"'), '训练概览缺少 GPU 运行状态区域')
assert.ok(template.includes('计算利用率'), 'GPU 运行状态缺少计算利用率')
assert.ok(template.includes('显存占用'), 'GPU 运行状态缺少显存占用')
assert.ok(template.includes('每 5 秒刷新'), 'GPU 运行状态缺少刷新频率说明')
assert.ok(template.includes(':aria-label="`GPU ${item.index} 计算利用率'), 'GPU 强度条缺少可访问说明')
assert.match(source, /const GPU_PREVIEW_LIMIT = 4/, '多 GPU 默认预览数量必须限制为 4 张')
assert.match(source, /const visibleGpuItems = computed/, '多 GPU 缺少渐进披露列表计算')
assert.match(source, /gpuRuntimePriority/, '折叠状态必须优先展示异常和运行中的 GPU')
assert.ok(template.includes('v-for="item in visibleGpuItems"'), 'GPU 列表没有使用受控预览数据')
assert.ok(template.includes('taskGpuItems.length > GPU_PREVIEW_LIMIT'), 'GPU 数量超过预览上限时没有展开入口')
const gpuToggleButtons = findElements(
templateAst,
(node) => node.tag === 'el-button'
&& staticAttribute(node, 'class')?.split(/\s+/).includes('gpu-toggle-button'),
)
assert.equal(gpuToggleButtons.length, 1, '多 GPU 展开控制必须且只能存在一个')
assert.equal(boundExpression(gpuToggleButtons[0], 'aria-expanded'), 'gpuExpanded', 'GPU 展开按钮未绑定 aria-expanded')
assert.equal(staticAttribute(gpuToggleButtons[0], 'aria-controls'), 'gpu-device-list', 'GPU 展开按钮缺少正确的 aria-controls')
assert.ok(template.includes('title="训练曲线"'), '训练曲线没有使用标准 PageCard 标题')
assert.ok(template.includes('title="训练日志"'), '训练日志没有使用标准 PageCard 标题')
assert.ok(template.includes('每 5 秒刷新'), '训练监控区缺少自动刷新状态说明')
assert.ok((template.match(/<el-descriptions\b/g) || []).length >= 3, '任务概况和训练参数没有使用标准详情表格')
assert.equal((template.match(/class="chart-section"/g) || []).length, 3, '三组训练曲线没有按上下结构完整展示')
assert.doesNotMatch(template, /<el-row\b|<el-col\b/, '训练曲线仍保留左右分栏布局')
assert.doesNotMatch(template, /class="chart-card"/, '训练曲线仍存在卡片嵌套')
assert.doesNotMatch(template, /summary-metric is-primary/, '训练概览仍保留大块装饰性主色背景')
for (const selector of ['.summary-card', '.parameters-card', '.metrics-panel', '.log-card']) {
const cardBlock = extractCssBlock(style, selector)
assert.match(cardBlock, /border-radius:\s*8px/, `${selector} 未统一为 8px 企业卡片圆角`)
assert.match(cardBlock, /box-shadow:\s*none/, `${selector} 仍保留不统一的浮层阴影`)
}
assert.ok(template.includes("task?.output_model_name || '暂未生成'"), '输出模型缺失值文案不正确')
assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
const media1100 = extractCssBlock(style, '@media (max-width: 1100px)')
const overviewAt1100 = extractCssBlock(media1100, '.overview-layout')
assert.match(overviewAt1100, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '1100px 断点未将双栏改为单栏')
const overviewLayoutBlock = extractCssBlock(overviewStyle, '.overview-layout')
assert.match(overviewLayoutBlock, /width:\s*100%/, '任务概况没有使用全宽单列布局')
assert.doesNotMatch(overviewLayoutBlock, /grid-template-columns/, '任务档案仍保留左右分栏规则')
const media700 = extractCssBlock(style, '@media (max-width: 700px)')
for (const selector of ['.dataset-metrics', '.runtime-list', '.parameter-grid']) {
assert.ok(media700.includes(selector), `700px 断点缺少单列规则:${selector}`)
}
assert.match(media700, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '700px 断点未设置单列网格')
const mutedBlock = extractCssBlock(style, '.is-muted')
const mutedColor = mutedBlock.match(/color:\s*(#[0-9a-f]{6})/i)?.[1]
assert.ok(mutedColor, '未配置文本缺少明确颜色')
assert.ok(
contrastRatio(mutedColor, '#ffffff') >= 4.5,
`未配置文本颜色 ${mutedColor} 与白色背景对比度不足 4.5:1`,
)
const overviewMedia700 = extractCssBlock(overviewStyle, '@media (max-width: 700px)')
assert.ok(overviewMedia700.includes('.task-descriptions'), '700px 断点缺少任务详情表单列规则')
assert.match(overviewMedia700, /display:\s*block/, '移动端任务详情表没有转换为单列')
const viewMedia600 = extractCssBlock(viewStyle, '@media (max-width: 600px)')
assert.ok(viewMedia600.includes('.parameter-descriptions'), '600px 断点缺少训练参数表单列规则')
assert.match(viewMedia600, /display:\s*block/, '移动端训练参数表没有转换为单列')
console.log('训练日志详情布局回归检查通过')