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('训练日志详情布局回归检查通过')

View File

@@ -0,0 +1,77 @@
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 mockDataPath = path.resolve(scriptDir, '../src/mock/data.ts')
const mockAdapterPath = path.resolve(scriptDir, '../src/mock/adapter.ts')
const modelPath = path.resolve(scriptDir, '../src/views/system/training-log/trainingLogModel.ts')
const [mockData, mockAdapter, modelSource] = await Promise.all([
readFile(mockDataPath, 'utf8'),
readFile(mockAdapterPath, 'utf8'),
readFile(modelPath, 'utf8'),
])
function objectFromMarker(source, marker) {
const markerIndex = source.indexOf(marker)
assert.notEqual(markerIndex, -1, `未找到 Mock 数据标记:${marker}`)
const openBrace = source.lastIndexOf('{', markerIndex)
assert.notEqual(openBrace, -1, `Mock 数据缺少对象起点:${marker}`)
let depth = 0
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') depth += 1
if (source[index] === '}') depth -= 1
if (depth === 0) return source.slice(openBrace, index + 1)
}
assert.fail(`Mock 数据缺少对象终点:${marker}`)
}
const taskNames = [
'finance-sft-001',
'legal-sft-002',
'medical-cpt-001',
'service-dpo-001',
'finance-sft-002',
'general-sft-001',
]
const commonTrainingFields = [
'output_model_name',
'batch_size',
'learning_rate',
'n_epochs',
'save_steps',
'lr_scheduler_type',
'max_length',
'warmup_ratio',
'weight_decay',
]
for (const taskName of taskNames) {
const task = objectFromMarker(mockData, `name: '${taskName}'`)
for (const field of commonTrainingFields) {
assert.match(task, new RegExp(`\\b${field}\\s*:`), `${taskName} 缺少训练参数:${field}`)
}
if (/train_method:\s*'lora'/.test(task)) {
for (const field of ['lora_rank', 'lora_alpha', 'lora_dropout']) {
assert.match(task, new RegExp(`\\b${field}\\s*:`), `${taskName} 缺少 LoRA 参数:${field}`)
}
}
}
const medicalTask = objectFromMarker(mockData, "name: 'medical-cpt-001'")
assert.match(medicalTask, /\bprocess_id\s*:/, '运行中的医疗训练任务缺少进程 ID')
assert.match(mockData, /medical-cpt-001_pid28741\.log/, '医疗训练任务缺少 PID 对应的日志文件')
assert.match(mockData, /export const mockTrainingLogContents/, '训练日志没有按文件提供独立 Mock 内容')
assert.match(mockData, /Num examples\s*=\s*9,800/, '医疗训练日志缺少样本规模信息')
assert.match(mockData, /Total optimization steps\s*=\s*921/, '医疗训练日志缺少真实训练步数信息')
assert.match(mockData, /\\'epoch\\':\s*1\.92/, '医疗训练日志的 Epoch 未与 64% 进度对齐')
assert.match(mockData, /\\'loss\\':\s*1\.146/, '医疗训练日志缺少当前 Loss 指标')
assert.match(mockAdapter, /const file = String\(params\.file/, '训练日志接口没有读取请求中的日志文件名')
assert.match(mockAdapter, /mockTrainingLogContents\[file\]/, '训练日志接口没有按请求文件返回对应内容')
assert.match(modelSource, /epoch:\s*number\[\]/, '训练指标模型没有保留逐步 Epoch')
console.log('训练日志 Mock 数据回归检查通过')

View File

@@ -3,7 +3,7 @@
* 拦截所有 API 请求并返回 mock 数据
* 通过 URL + method 路由到对应的 mock 响应
*/
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
import type { AxiosAdapter, AxiosInstance, AxiosRequestConfig } from 'axios'
import {
mockLoginOk,
mockHealth,
@@ -21,6 +21,7 @@ import {
mockLogFiles,
mockTrainingLogFiles,
mockLogContent,
mockTrainingLogContents,
} from './data'
import {
activateDatasetVersion,
@@ -164,7 +165,8 @@ async function handleMock(config: AxiosRequestConfig) {
}
m = url.match(/^\/dataset-manage\/([^/]+)$/)
if (m && method === 'get') {
const found = mockDatasets.find((x) => String(x.id) === m[1])
const datasetId = m[1]
const found = mockDatasets.find((x) => String(x.id) === datasetId)
return found ? ok(found) : fail('数据集不存在', 404)
}
if (m && (method === 'put' || method === 'delete')) {
@@ -261,7 +263,8 @@ async function handleMock(config: AxiosRequestConfig) {
}
m = url.match(/^\/fine-tune\/progress\/([^/]+)$/)
if (m && method === 'get') {
const task = mockFineTuneList.find((t) => String(t.id) === m[1])
const taskId = m[1]
const task = mockFineTuneList.find((t) => String(t.id) === taskId)
if (!task) return fail('任务不存在', 404)
if (task.status === 'running') {
return ok({
@@ -276,7 +279,8 @@ async function handleMock(config: AxiosRequestConfig) {
}
m = url.match(/^\/fine-tune\/([^/]+)$/)
if (m && method === 'get') {
const found = mockFineTuneList.find((x) => String(x.id) === m[1])
const taskId = m[1]
const found = mockFineTuneList.find((x) => String(x.id) === taskId)
return found ? ok(found) : fail('任务不存在', 404)
}
m = url.match(/^\/fine-tune\/stop\/([^/]+)$/)
@@ -296,7 +300,8 @@ async function handleMock(config: AxiosRequestConfig) {
}
m = url.match(/^\/model-compare\/([^/]+)$/)
if (m && method === 'get') {
const found = mockCompareList.find((x) => String(x.id) === m[1])
const compareId = m[1]
const found = mockCompareList.find((x) => String(x.id) === compareId)
return found ? ok(found) : fail('任务不存在', 404)
}
if (m && method === 'delete') return ok({ deleted: m[1] })
@@ -363,7 +368,14 @@ async function handleMock(config: AxiosRequestConfig) {
if (url === '/log-files' && method === 'get') return ok(mockLogFiles)
if (url === '/log-content' && method === 'get') return ok(mockLogContent)
if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles)
if (url === '/training-log-content' && method === 'get') return ok(mockLogContent)
if (url === '/training-log-content' && method === 'get') {
const file = String(params.file || '')
return ok(mockTrainingLogContents[file] || {
file,
size: '0 KB',
content: `[Mock] 未找到训练日志内容:${file}`,
})
}
// 未匹配的请求 → 兜底返回空成功(避免阻断 UI
console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params)
@@ -380,12 +392,13 @@ function safeJSON(str: string) {
/** 给 axios instance 安装 mock adapter */
export function installMockAdapter(instance: AxiosInstance) {
instance.defaults.adapter = async (config: AxiosRequestConfig) => {
const adapter = async (config: AxiosRequestConfig) => {
try {
const response = await handleMock(config)
return response
} catch (e: any) {
return fail(e.message || 'Mock 错误', 500, config)
} catch (error: unknown) {
return fail(error instanceof Error ? error.message : 'Mock 错误', 500, config)
}
}
instance.defaults.adapter = adapter as AxiosAdapter
}

View File

@@ -58,37 +58,40 @@ export const mockSystemInfo: SystemInfo = {
id: 0,
uuid: 'GPU-MOCK-A800-00',
name: 'NVIDIA A800',
status: 'idle',
gpu_percent: 0,
memory_used_gb: 0,
status: 'busy',
gpu_percent: 74,
memory_used_gb: 41.8,
memory_total_gb: 80,
memory_percent: 0,
temperature: 32,
power_w: 38,
memory_percent: 52.3,
temperature: 63,
power_w: 286,
power_limit_w: 400,
fan_speed: 0,
fan_speed: 51,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [],
processes: [
{ pid: 28741, name: 'python', task_name: 'medical-cpt-001 / rank 0', user: 'trainer', memory_used_gb: 39.6 },
{ pid: 28768, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 2.2 },
],
},
{
id: 1,
uuid: 'GPU-MOCK-A800-01',
name: 'NVIDIA A800',
status: 'busy',
gpu_percent: 28,
memory_used_gb: 22.5,
gpu_percent: 71,
memory_used_gb: 42.1,
memory_total_gb: 80,
memory_percent: 28.1,
temperature: 52,
power_w: 165,
memory_percent: 52.6,
temperature: 62,
power_w: 279,
power_limit_w: 400,
fan_speed: 32,
fan_speed: 49,
clock_mhz: 1410,
driver_version: '535.86.10',
processes: [
{ pid: 18421, name: 'python', task_name: '指令微调任务', user: 'trainer', memory_used_gb: 18.6 },
{ pid: 18503, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 3.9 },
{ pid: 28742, name: 'python', task_name: 'medical-cpt-001 / rank 1', user: 'trainer', memory_used_gb: 39.9 },
{ pid: 28769, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 2.2 },
],
},
{
@@ -249,7 +252,7 @@ export const mockLocalModels = {
}
// ============ 数据集 ============
export const mockDatasets: DatasetItem[] = [
export const mockDatasets: DatasetItem[] = ([
{
id: 1,
name: '金融问答-训练集',
@@ -273,7 +276,7 @@ export const mockDatasets: DatasetItem[] = [
{ id: 8, name: '通用指令构造集', type: 'train', storage_type: 'local', source: 'task', task_id: 492015, size: '148 MB', count: 12600, description: '由指令微调数据构造任务生成', create_time: '2026-07-09T01:42:00Z' },
{ id: 9, name: '用户反馈脱敏集', type: 'test', storage_type: 'minio', source: 'task', task_id: 731948, size: '72 MB', count: 9340, description: '由敏感信息脱敏任务生成', create_time: '2026-07-09T09:18:00Z' },
{ id: 10, name: '多轮对话增强集', type: 'eval', storage_type: 'local', source: 'task', task_id: 582012, size: '41 MB', count: 2780, description: '由多轮对话拼接任务生成', create_time: '2026-07-10T02:06:00Z' },
].map((dataset) => ({
] satisfies DatasetItem[]).map((dataset) => ({
...dataset,
files: dataset.files?.length
? dataset.files
@@ -339,12 +342,181 @@ export const mockDatasetPreviews: Record<string, string> = {
// ============ 训练任务 ============
export const mockFineTuneList: FineTuneTask[] = [
{ id: 103942, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' },
{ id: 349102, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' },
{ id: 849301, name: 'medical-cpt-001', description: '医疗领域继续预训练', status: 'running', train_type: 'CPT', train_method: 'lora', template: 'qwen2_5', base_model: 2, train_dataset_id: 6, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' },
{ id: 593021, name: 'service-dpo-001', description: '客服对话偏好训练', status: 'pending', train_type: 'DPO', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 3, gpus: [2], progress: 0, train_duration: '-', create_time: '2026-02-08T14:00:00Z' },
{ id: 201948, name: 'finance-sft-002', description: '金融领域二轮微调', status: 'failed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [3], progress: 32, train_duration: '18分钟', create_time: '2026-02-10T11:00:00Z' },
{ id: 940212, name: 'general-sft-001', description: '通用能力微调', status: 'completed', train_type: 'SFT', train_method: 'full', template: 'llama3', base_model: 3, train_dataset_id: 3, gpus: [0, 2], progress: 100, train_duration: '3小时05分钟', create_time: '2026-02-12T13:00:00Z' },
{
id: 103942,
name: 'finance-sft-001',
description: '金融领域 SFT 训练',
status: 'completed',
train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 1,
output_model_name: 'qwen2.5-7b-finance-sft-v1',
auto_merge: true,
gpus: [0],
batch_size: 8,
learning_rate: 0.00002,
n_epochs: 3,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.05,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 12345,
progress: 100,
train_duration: '2小时18分钟',
create_time: '2026-01-15T08:00:00Z',
},
{
id: 349102,
name: 'legal-sft-002',
description: '法律文书 SFT',
status: 'completed',
train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 2,
output_model_name: 'qwen2.5-7b-legal-sft-v2',
auto_merge: true,
gpus: [1],
batch_size: 4,
learning_rate: 0.000015,
n_epochs: 4,
save_steps: 120,
lr_scheduler_type: 'linear',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.01,
lora_rank: 32,
lora_alpha: 64,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 12350,
progress: 100,
train_duration: '1小时46分钟',
create_time: '2026-01-18T10:00:00Z',
},
{
id: 849301,
name: 'medical-cpt-001',
description: '医疗领域继续预训练',
status: 'running',
train_type: 'CPT',
train_method: 'lora',
template: 'qwen2_5',
base_model: 2,
train_dataset_id: 6,
output_model_name: 'qwen2.5-14b-medical-cpt-v1',
auto_merge: true,
gpus: [0, 1],
batch_size: 2,
learning_rate: 0.0001,
n_epochs: 3,
save_steps: 200,
lr_scheduler_type: 'cosine',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 28741,
progress: 64,
train_duration: '36分钟',
create_time: '2026-07-13T06:40:00Z',
},
{
id: 593021,
name: 'service-dpo-001',
description: '客服对话偏好训练',
status: 'pending',
train_type: 'DPO',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 3,
output_model_name: 'qwen2.5-7b-service-dpo-v1',
auto_merge: true,
gpus: [2],
batch_size: 4,
learning_rate: 0.000005,
n_epochs: 2,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.1,
weight_decay: 0,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.1,
quantization_bit: 0,
progress: 0,
train_duration: '等待调度',
create_time: '2026-02-08T14:00:00Z',
},
{
id: 201948,
name: 'finance-sft-002',
description: '金融领域二轮微调',
status: 'failed',
train_type: 'SFT',
train_method: 'lora',
template: 'qwen',
base_model: 1,
train_dataset_id: 1,
output_model_name: 'qwen2.5-7b-finance-sft-v2',
auto_merge: false,
gpus: [3],
batch_size: 8,
learning_rate: 0.00002,
n_epochs: 3,
save_steps: 100,
lr_scheduler_type: 'cosine',
max_length: 2048,
warmup_ratio: 0.05,
weight_decay: 0.01,
lora_rank: 16,
lora_alpha: 32,
lora_dropout: 0.05,
quantization_bit: 0,
process_id: 27654,
progress: 32,
train_duration: '18分钟',
create_time: '2026-02-10T11:00:00Z',
},
{
id: 940212,
name: 'general-sft-001',
description: '通用能力全参数微调',
status: 'completed',
train_type: 'SFT',
train_method: 'full',
template: 'llama3',
base_model: 3,
train_dataset_id: 3,
output_model_name: 'llama3-8b-general-sft-v1',
auto_merge: false,
gpus: [0, 2],
batch_size: 2,
learning_rate: 0.00001,
n_epochs: 2,
save_steps: 250,
lr_scheduler_type: 'cosine',
max_length: 4096,
warmup_ratio: 0.03,
weight_decay: 0.1,
process_id: 26318,
progress: 100,
train_duration: '3小时05分钟',
create_time: '2026-02-12T13:00:00Z',
},
]
// ============ 模型推理/对比 ============
@@ -549,11 +721,48 @@ export const mockLogFiles: LogFile[] = [
]
export const mockTrainingLogFiles: TrainingLogFile[] = [
{ file: 'medical-cpt-001_pid28741.log', name: 'medical-cpt-001', size: '6.8 MB', pid: 28741, date: '2026-07-13' },
{ file: 'qwen-ft-finance-001_pid12345.log', name: 'finance-sft-001', size: '4.5 MB', pid: 12345, date: '2026-02-15' },
{ file: 'llama3-ft-customer-service_pid12346.log', name: 'service-dpo-001', size: '2.1 MB', pid: 12346, date: '2026-02-18' },
{ file: 'qwen-ft-legal-002_pid12350.log', name: 'legal-sft-002', size: '5.8 MB', pid: 12350, date: '2026-02-20' },
]
const medicalTrainingLogLines = [
'[2026-07-13 14:40:01] INFO: Launching distributed training with torchrun --nproc_per_node=2',
'[2026-07-13 14:40:02] INFO: Process rank: 0, world size: 2, device: cuda:0, distributed training: True',
'[2026-07-13 14:40:04] INFO: Loading tokenizer from /data/models/qwen2.5-14b-instruct',
'[2026-07-13 14:40:16] INFO: Loading checkpoint shards: 100% | 8/8 | 00:12',
'[2026-07-13 14:40:18] INFO: Loading dataset 医疗问答-训练集 (9,800 samples)',
'[2026-07-13 14:40:27] INFO: Tokenizing dataset: 100% | 9,800/9,800 | 00:09',
'[2026-07-13 14:40:28] INFO: LoRA config: rank=16, alpha=32, dropout=0.05, target_modules=q_proj,k_proj,v_proj,o_proj',
'[2026-07-13 14:40:29] INFO: Trainable params: 83,886,080 / 14,787,584,000 (0.5673%)',
'[2026-07-13 14:40:30] INFO: ***** Running training *****',
'[2026-07-13 14:40:30] INFO: Num examples = 9,800',
'[2026-07-13 14:40:30] INFO: Num Epochs = 3',
'[2026-07-13 14:40:30] INFO: Instantaneous batch size per device = 2',
'[2026-07-13 14:40:30] INFO: Total train batch size = 32',
'[2026-07-13 14:40:30] INFO: Gradient Accumulation steps = 8',
'[2026-07-13 14:40:30] INFO: Total optimization steps = 921',
'[2026-07-13 14:42:18] INFO: step=40 {\'loss\': 2.684, \'grad_norm\': 1.184, \'learning_rate\': 9.82e-05, \'epoch\': 0.13}',
'[2026-07-13 14:44:26] INFO: step=80 {\'loss\': 2.312, \'grad_norm\': 1.092, \'learning_rate\': 9.68e-05, \'epoch\': 0.26}',
'[2026-07-13 14:46:34] INFO: step=120 {\'loss\': 2.084, \'grad_norm\': 1.037, \'learning_rate\': 9.43e-05, \'epoch\': 0.39}',
'[2026-07-13 14:48:42] INFO: step=160 {\'loss\': 1.932, \'grad_norm\': 0.986, \'learning_rate\': 9.08e-05, \'epoch\': 0.52}',
'[2026-07-13 14:50:49] INFO: step=200 {\'loss\': 1.801, \'grad_norm\': 0.944, \'learning_rate\': 8.64e-05, \'epoch\': 0.65}',
'[2026-07-13 14:50:54] INFO: Saving checkpoint to /data/checkpoints/medical-cpt-001/checkpoint-200',
'[2026-07-13 14:52:58] INFO: step=240 {\'loss\': 1.696, \'grad_norm\': 0.913, \'learning_rate\': 8.15e-05, \'epoch\': 0.78}',
'[2026-07-13 14:55:06] INFO: step=280 {\'loss\': 1.611, \'grad_norm\': 0.887, \'learning_rate\': 7.61e-05, \'epoch\': 0.91}',
'[2026-07-13 14:57:13] INFO: step=320 {\'loss\': 1.532, \'grad_norm\': 0.852, \'learning_rate\': 7.06e-05, \'epoch\': 1.04}',
'[2026-07-13 14:59:21] INFO: step=360 {\'loss\': 1.461, \'grad_norm\': 0.829, \'learning_rate\': 6.49e-05, \'epoch\': 1.17}',
'[2026-07-13 15:01:29] INFO: step=400 {\'loss\': 1.396, \'grad_norm\': 0.811, \'learning_rate\': 5.91e-05, \'epoch\': 1.30}',
'[2026-07-13 15:01:34] INFO: Saving checkpoint to /data/checkpoints/medical-cpt-001/checkpoint-400',
'[2026-07-13 15:03:37] INFO: step=440 {\'loss\': 1.337, \'grad_norm\': 0.795, \'learning_rate\': 5.35e-05, \'epoch\': 1.43}',
'[2026-07-13 15:05:45] INFO: step=480 {\'loss\': 1.286, \'grad_norm\': 0.776, \'learning_rate\': 4.80e-05, \'epoch\': 1.56}',
'[2026-07-13 15:07:53] INFO: step=520 {\'loss\': 1.232, \'grad_norm\': 0.758, \'learning_rate\': 4.28e-05, \'epoch\': 1.69}',
'[2026-07-13 15:11:59] INFO: step=560 {\'loss\': 1.184, \'grad_norm\': 0.741, \'learning_rate\': 3.79e-05, \'epoch\': 1.82}',
'[2026-07-13 15:16:05] INFO: step=590 {\'loss\': 1.146, \'grad_norm\': 0.728, \'learning_rate\': 3.44e-05, \'epoch\': 1.92}',
'[2026-07-13 15:16:06] INFO: Training is running normally, estimated remaining time: 00:20:15',
]
const fakeLogLines = [
"[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b",
"[2026-02-15 08:30:13] INFO: Loading dataset finance-train-001 (8560 samples)",
@@ -591,3 +800,16 @@ export const mockLogContent: LogContent = {
size: '2.3 MB',
content: fakeLogLines.join('\n'),
}
export const mockTrainingLogContents: Record<string, LogContent> = {
'medical-cpt-001_pid28741.log': {
file: 'medical-cpt-001_pid28741.log',
size: '6.8 MB',
content: medicalTrainingLogLines.join('\n'),
},
'qwen-ft-finance-001_pid12345.log': {
file: 'qwen-ft-finance-001_pid12345.log',
size: '4.5 MB',
content: fakeLogLines.join('\n'),
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import PageCard from '@/components/PageCard.vue'
import { DATASET_TYPE_MAP, STORAGE_MAP, TRAIN_METHOD_MAP, TRAIN_TYPE_MAP } from '@/constants'
import type { DatasetItem, FineTuneTask } from '@/types'
defineProps<{
task: FineTuneTask | null
dataset: DatasetItem | null
baseModelName: string
}>()
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<PageCard class="task-overview" title="任务信息" subtitle="模型、数据集与运行配置">
<div class="overview-layout" aria-label="训练任务信息">
<el-descriptions class="task-descriptions task-profile" :column="2" border>
<el-descriptions-item label="任务 ID">{{ task?.id ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(task?.create_time) }}</el-descriptions-item>
<el-descriptions-item label="基座模型">{{ baseModelName }}</el-descriptions-item>
<el-descriptions-item label="输出模型">{{ task?.output_model_name || '暂未生成' }}</el-descriptions-item>
<el-descriptions-item label="训练方式">
{{ task?.train_type ? (TRAIN_TYPE_MAP[task.train_type] || task.train_type) : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="训练方法">
{{ task?.train_method ? (TRAIN_METHOD_MAP[task.train_method] || task.train_method) : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="训练数据集" class-name="dataset-profile">
{{ dataset?.name || (task?.train_dataset_id ? '正在加载' : '未配置') }}
</el-descriptions-item>
<el-descriptions-item label="数据类型">
{{ dataset ? (DATASET_TYPE_MAP[dataset.type] || dataset.type || '未配置') : '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="数据条数">
{{ dataset?.count?.toLocaleString('zh-CN') ?? '未配置' }}{{ dataset?.count != null ? ' 条' : '' }}
</el-descriptions-item>
<el-descriptions-item label="数据大小">{{ dataset?.size || '未配置' }}</el-descriptions-item>
<el-descriptions-item label="训练开始时间" class-name="runtime-panel">
{{ formatDateTime(task?.create_time) }}
</el-descriptions-item>
<el-descriptions-item label="训练时长">{{ task?.train_duration || '未配置' }}</el-descriptions-item>
<el-descriptions-item label="存储位置">
{{ STORAGE_MAP[dataset?.storage_type || ''] || dataset?.storage_type || '未配置' }}
</el-descriptions-item>
<el-descriptions-item label="使用 GPU">
{{ task?.gpus?.length ? task.gpus.join('、') : '未配置' }}
</el-descriptions-item>
</el-descriptions>
</div>
</PageCard>
</template>
<style scoped lang="scss">
.task-overview {
margin-bottom: 0;
border: 1px solid #e4e7ed !important;
border-radius: 8px !important;
box-shadow: none !important;
}
.overview-layout { width: 100%; }
.task-descriptions :deep(.el-descriptions__label) {
width: 132px;
color: #606266;
font-weight: 500;
background: #f7f8fa !important;
}
.task-descriptions :deep(.el-descriptions__content) {
color: #303133;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.task-descriptions :deep(.el-descriptions__cell) { padding: 12px 16px !important; }
@media (max-width: 700px) {
.task-descriptions :deep(.el-descriptions__body),
.task-descriptions :deep(.el-descriptions__table),
.task-descriptions :deep(.el-descriptions__tbody),
.task-descriptions :deep(.el-descriptions__row),
.task-descriptions :deep(.el-descriptions__cell) { display: block; width: 100%; box-sizing: border-box; }
.task-descriptions :deep(.el-descriptions__label) { width: 100%; border-bottom: 0 !important; }
}
</style>

View File

@@ -0,0 +1,165 @@
import type { EChartsOption } from 'echarts'
import type { FineTuneTask, TrainingLogFile } from '@/types'
export interface TrainingMetricData {
loss: number[]
gradNorm: number[]
lr: number[]
epoch: number[]
}
export interface TrainingSummary {
epoch: string
trainLoss: string
runtime: string
}
export interface ParsedTrainingLog {
metrics: TrainingMetricData
summary: TrainingSummary
}
const NUMBER_SOURCE = '[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?'
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function extractNumber(source: string, key: string) {
const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*:\\s*(${NUMBER_SOURCE})`, 'i'))
return match ? Number(match[1]) : undefined
}
function extractSummaryValue(source: string, key: string) {
const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*(?:=|:)\\s*(${NUMBER_SOURCE})`, 'i'))
return match?.[1] || ''
}
/** 根据任务精确选择日志PID 优先,任务名仅作为明确兜底。 */
export function resolveTrainingLogFile(
files: TrainingLogFile[],
task: Pick<FineTuneTask, 'process_id' | 'name'>,
) {
const processId = task.process_id
if (processId != null) {
const pidMatch = files.find((file) => file.pid === processId)
if (pidMatch) return pidMatch
const pidPattern = new RegExp(`(?:^|[^0-9])(?:pid)?${processId}(?:[^0-9]|$)`, 'i')
const filenameMatch = files.find((file) => pidPattern.test(file.file))
if (filenameMatch) return filenameMatch
}
const taskName = task.name.trim()
if (!taskName) return undefined
return files.find((file) => file.name.includes(taskName) || file.file.includes(taskName))
}
/** 解析日志中的逐步指标。字段顺序和常见数值格式均不受限制。 */
export function parseTrainingMetrics(text: string): TrainingMetricData {
const metrics: TrainingMetricData = { loss: [], gradNorm: [], lr: [], epoch: [] }
const blocks = text.match(/\{[^{}\r\n]*\}/g) || []
for (const block of blocks) {
const loss = extractNumber(block, 'loss')
const gradNorm = extractNumber(block, 'grad_norm')
const learningRate = extractNumber(block, 'learning_rate')
const epoch = extractNumber(block, 'epoch')
if (loss == null || gradNorm == null || learningRate == null) continue
metrics.loss.push(loss)
metrics.gradNorm.push(gradNorm)
metrics.lr.push(learningRate)
if (epoch != null) metrics.epoch.push(epoch)
}
return metrics
}
/** 每次都返回新对象,日志截断或切换时不会残留上一轮汇总。 */
export function parseTrainingSummary(text: string): TrainingSummary {
const emptySummary: TrainingSummary = { epoch: '', trainLoss: '', runtime: '' }
const startMatch = /\*{5}\s*train metrics\s*\*{5}/i.exec(text)
if (!startMatch) return emptySummary
const tail = text.slice(startMatch.index + startMatch[0].length)
const endMatch = /\*{5}\s*train metrics end\s*\*{5}/i.exec(tail)
const body = endMatch ? tail.slice(0, endMatch.index) : tail
return {
epoch: extractSummaryValue(body, 'epoch'),
trainLoss: extractSummaryValue(body, 'train_loss'),
runtime: extractSummaryValue(body, 'train_runtime'),
}
}
export function parseTrainingLog(text: string): ParsedTrainingLog {
return {
metrics: parseTrainingMetrics(text),
summary: parseTrainingSummary(text),
}
}
/** 构建单条训练指标曲线。 */
export function buildMetricChartOption(
label: string,
data: number[],
color: string,
logScale = false,
): EChartsOption {
return {
grid: { top: 24, right: 20, bottom: 56, left: 56 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderWidth: 0,
textStyle: { color: '#fff', fontSize: 12 },
},
xAxis: {
type: 'category',
boundaryGap: false,
name: 'Step',
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#94a3b8', fontSize: 11 },
splitLine: { show: false },
},
yAxis: {
type: logScale ? 'log' : 'value',
name: label,
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#94a3b8', fontSize: 11 },
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
dataZoom: data.length > 30
? [
{ type: 'inside', start: 0, end: 100 },
{ type: 'slider', height: 16, bottom: 8, borderColor: 'transparent', fillerColor: 'rgba(79,70,229,0.08)', handleStyle: { color: '#4f46e5' } },
]
: [],
series: [
{
name: label,
type: 'line',
data,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}55` },
{ offset: 1, color: `${color}05` },
],
},
},
},
],
}
}