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

@@ -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` },
],
},
},
},
],
}
}