feat: 更新后端平台模块、前端组件及构建产物,新增工作计划文档

- 更新 backend 平台 API endpoints 及 platform_store
- 更新前端 ComputeNodesView、DataProcessCreateView、FineTuneCreateView 等组件
- 更新前端 API 模块(compute、fineTune)
- 重构 frontend/dist 构建产物(新 hash)
- 新增 docs/2026-07-24-work-plan.md 工作计划文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wuyongtao
2026-07-24 10:27:52 +08:00
parent b28cfbc6fa
commit 2b10c013ce
151 changed files with 925 additions and 181 deletions

View File

@@ -11,6 +11,7 @@ import {
getComputeGpus,
getComputeNodes,
getComputeQueue,
getComputeSyncJob,
getNodeReplicas,
repairNodeReplicas,
testComputeNode,
@@ -19,6 +20,7 @@ import {
type ComputeNode,
type ComputeQueueItem,
type ResourceReplica,
type ResourceSyncJob,
} from '@/api/modules/compute'
import { statusLabel, statusTagType } from '@/utils/status'
@@ -34,6 +36,7 @@ const nodes = ref<ComputeNode[]>([])
const gpus = ref<ComputeGpu[]>([])
const queue = ref<ComputeQueueItem[]>([])
const replicas = ref<ResourceReplica[]>([])
const activeSyncJob = ref<ResourceSyncJob | null>(null)
const selectedNodeId = ref('')
const lastUpdated = ref('')
const nodeDialogVisible = ref(false)
@@ -91,6 +94,7 @@ async function load(options: { showLoading?: boolean; showButtonLoading?: boolea
if ((!selectedNodeId.value || !nodeList.some((item) => item.id === selectedNodeId.value)) && nodeList.length) {
selectedNodeId.value = nodeList[0].id
}
await pollActiveSyncJob()
await loadReplicas()
lastUpdated.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} finally {
@@ -100,6 +104,19 @@ async function load(options: { showLoading?: boolean; showButtonLoading?: boolea
}
}
async function pollActiveSyncJob() {
if (!activeSyncJob.value?.id) return
try {
const sync = await getComputeSyncJob(activeSyncJob.value.id)
activeSyncJob.value = sync
if (['completed', 'failed', 'stopped', 'cancelled'].includes(sync.status)) {
await loadReplicas()
}
} catch {
activeSyncJob.value = null
}
}
async function loadReplicas() {
if (!selectedNodeId.value) {
replicas.value = []
@@ -150,8 +167,11 @@ async function handleReplicaRepair() {
repairingReplicas.value = true
try {
const result = await repairNodeReplicas(selectedNodeId.value, targets.map((item) => item.id))
activeSyncJob.value = result.sync
replicas.value = result.replicas || []
if (result.failed?.length) {
if (result.async) {
ElMessage.success('副本修复任务已提交,页面将自动刷新进度')
} else if (result.failed?.length) {
ElMessage.warning(`修复完成,失败 ${result.failed.length}`)
} else {
ElMessage.success('副本修复完成')
@@ -407,6 +427,13 @@ onUnmounted(() => {
<el-button :loading="checkingReplicas" @click="handleReplicaDriftCheck">漂移检测</el-button>
<el-button type="primary" :loading="repairingReplicas" @click="handleReplicaRepair">修复副本</el-button>
</div>
<div v-if="activeSyncJob" class="sync-progress">
<div>
<strong>同步任务 {{ activeSyncJob.id }}</strong>
<el-tag :type="statusTagType(activeSyncJob.status)" size="small">{{ statusLabel(activeSyncJob.status) }}</el-tag>
</div>
<el-progress :percentage="Number(activeSyncJob.progress || 0)" :stroke-width="8" />
</div>
<el-table :data="replicas" height="100%">
<el-table-column prop="resource_type" label="资源类型" width="110" />
<el-table-column prop="resource_id" label="资源 ID" min-width="180" />
@@ -582,6 +609,22 @@ onUnmounted(() => {
}
}
.sync-progress {
display: grid;
gap: 8px;
margin-bottom: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 14px;
background: #f8fafc;
> div {
display: flex;
align-items: center;
gap: 10px;
}
}
.node-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -233,7 +233,7 @@ function taskPayload() {
function externalPayload(): DataProcessExternalSourcePayload {
return {
type: externalSource.type,
type: 'postgresql',
url: externalSource.url.trim(),
auth_mode: externalSource.authMode,
username: externalSource.username || undefined,

View File

@@ -6,9 +6,11 @@ import PageCard from '@/components/PageCard.vue'
import ModelSelectDialog from '@/components/ModelSelectDialog.vue'
import {
createFineTune,
preflightFineTune,
startFineTune,
updateFineTune,
checkFineTuneName,
type FineTunePreflightResult,
} from '@/api/modules/fineTune'
import { getModelList } from '@/api/modules/model'
import { getDatasetList } from '@/api/modules/dataset'
@@ -27,6 +29,8 @@ import type { ModelItem, DatasetItem, GpuInfo } from '@/types'
const router = useRouter()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const preflightLoading = ref(false)
const preflightResult = ref<FineTunePreflightResult | null>(null)
const models = ref<ModelItem[]>([])
const datasets = ref<DatasetItem[]>([])
@@ -60,6 +64,12 @@ const modelDialogTitle = computed(() => selectedModel.value?.name || '')
/** 训练命令与提交载荷共用同一份表单模型。 */
const commandPreview = computed(() => buildFineTuneCommand(form, selectedGpus.value))
const remoteCommandPreview = computed(() => {
const command = preflightResult.value?.preview?.command
if (Array.isArray(command) && command.length) return command.join(' ')
return preflightResult.value?.preview?.command_text || ''
})
/** GPU 多选切换 */
function toggleGpu(index: number) {
const idx = selectedGpus.value.indexOf(index)
@@ -184,6 +194,11 @@ async function handleSubmit() {
}
const payload = buildFineTunePayload(form, selectedGpus.value)
const preflight = await runPreflight(payload)
if (!preflight?.valid) {
ElMessage.error('训练预检未通过,请先处理预检问题')
return
}
const createRes = await createFineTune(toCreateFineTunePayload(payload))
const taskId = createRes.id
@@ -203,6 +218,43 @@ async function handleSubmit() {
})
}
async function runPreflight(payload = buildFineTunePayload(form, selectedGpus.value)) {
preflightLoading.value = true
try {
const result = await preflightFineTune(payload)
preflightResult.value = result
if (result.valid) {
ElMessage.success('训练预检通过')
} else {
ElMessage.warning('训练预检未通过')
}
return result
} catch {
preflightResult.value = {
valid: false,
errors: ['预检接口调用失败,请检查后端服务和算力节点连接'],
warnings: [],
diagnostics: [{ level: 'error', title: '预检调用失败', suggestion: '请确认后端服务可访问 Compute API并检查浏览器或后端日志。' }],
}
ElMessage.error('训练预检失败')
return preflightResult.value
} finally {
preflightLoading.value = false
}
}
async function handlePreflightClick() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
if (selectedGpus.value.length === 0) {
ElMessage.warning('请至少选择一个 GPU')
return
}
await runPreflight()
})
}
function handleCancel() {
router.back()
}
@@ -437,9 +489,47 @@ onMounted(() => {
<!-- 训练命令预览 -->
<el-divider content-position="left">训练命令预览</el-divider>
<div class="preflight-actions">
<el-button type="primary" plain :loading="preflightLoading" @click="handlePreflightClick">
<i class="fa fa-check-circle-o" style="margin-right: 4px;" /> 预检训练配置
</el-button>
<span class="preflight-hint">预检会调用后端调度和 Compute validate提前检查数据格式模型路径GPU 条件和真实训练命令</span>
</div>
<div class="command-preview-wrapper">
<pre class="command-preview">{{ commandPreview }}</pre>
</div>
<div v-if="preflightResult" class="preflight-panel" :class="{ 'is-valid': preflightResult.valid, 'is-invalid': !preflightResult.valid }">
<div class="preflight-header">
<strong>{{ preflightResult.valid ? '预检通过' : '预检未通过' }}</strong>
<span v-if="preflightResult.node">
调度节点{{ preflightResult.node.code || preflightResult.node.name || preflightResult.node.id }}
</span>
</div>
<div v-if="remoteCommandPreview" class="preflight-section">
<span class="section-label">后端真实命令</span>
<pre class="command-preview remote">{{ remoteCommandPreview }}</pre>
</div>
<div v-if="preflightResult.errors?.length" class="preflight-section">
<span class="section-label">错误</span>
<ul>
<li v-for="item in preflightResult.errors" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="preflightResult.warnings?.length" class="preflight-section">
<span class="section-label">警告</span>
<ul>
<li v-for="item in preflightResult.warnings" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="preflightResult.diagnostics?.length" class="preflight-section">
<span class="section-label">诊断建议</span>
<ul>
<li v-for="item in preflightResult.diagnostics" :key="item.title">
<strong>{{ item.title }}</strong>{{ item.suggestion }}
</li>
</ul>
</div>
</div>
<div class="form-actions-wrapper">
<el-button type="primary" :loading="submitting" @click="handleSubmit">创建并启动训练</el-button>
@@ -604,6 +694,67 @@ onMounted(() => {
margin-bottom: 22px;
}
.preflight-actions {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 12px 80px;
}
.preflight-hint {
color: #64748b;
font-size: 12px;
}
.preflight-panel {
margin: -8px 0 24px 80px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 14px 16px;
background: #f8fafc;
&.is-valid {
border-color: #bbf7d0;
background: #f0fdf4;
}
&.is-invalid {
border-color: #fecaca;
background: #fef2f2;
}
}
.preflight-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 10px;
color: #111827;
span {
color: #64748b;
font-size: 12px;
}
}
.preflight-section {
margin-top: 10px;
ul {
margin: 6px 0 0;
padding-left: 18px;
color: #374151;
line-height: 1.7;
}
}
.section-label {
display: inline-flex;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.field-tip {
color: #909399;
font-size: 12px;
@@ -637,6 +788,11 @@ onMounted(() => {
overflow-x: auto;
width: 100%;
margin: 0;
&.remote {
margin-top: 6px;
background: #111827;
}
}
.model-picker-input {

View File

@@ -8,7 +8,7 @@ import TrainingTaskOverview from './training-log/TrainingTaskOverview.vue'
import { usePolling } from '@/composables/usePolling'
import '@/plugins/echarts-training-log'
import { useModelsStore } from '@/stores/models'
import { getFineTune, getFineTuneLogs } from '@/api/modules/fineTune'
import { getFineTune, getFineTuneDiagnostics, getFineTuneLogs, type TrainingDiagnostic } from '@/api/modules/fineTune'
import { getTrainingLogFiles, getTrainingLogContent } from '@/api/modules/log'
import { getDataset } from '@/api/modules/dataset'
import { getSystemInfo } from '@/api/modules/system'
@@ -35,6 +35,7 @@ const logContent = ref('')
const gpuPool = ref<GpuInfo[]>([])
const gpuUpdatedAt = ref<Date | null>(null)
const gpuLoadError = ref('')
const diagnostics = ref<TrainingDiagnostic[]>([])
/** 初始加载状态:首次数据返回前显示 loading避免空白闪烁 */
const loading = ref(true)
@@ -247,6 +248,19 @@ async function loadLog(currentTask: FineTuneTask) {
}
}
async function loadDiagnostics(currentTask: FineTuneTask) {
if (currentTask.status !== 'failed') {
diagnostics.value = []
return
}
try {
const result = await getFineTuneDiagnostics(currentTask.id)
diagnostics.value = result.diagnostics || []
} catch {
diagnostics.value = []
}
}
async function refreshAll() {
if (refreshInFlight) return
refreshInFlight = true
@@ -262,7 +276,7 @@ async function refreshAll() {
const datasetPromise = currentTask.train_dataset_id
? loadDataset(currentTask.train_dataset_id)
: Promise.resolve()
await Promise.all([datasetPromise, loadLog(currentTask), loadGpuStatus()])
await Promise.all([datasetPromise, loadLog(currentTask), loadGpuStatus(), loadDiagnostics(currentTask)])
} finally {
loading.value = false
refreshInFlight = false
@@ -535,6 +549,15 @@ onMounted(async () => {
</div>
</PageCard>
<PageCard v-if="diagnostics.length" class="diagnostics-card" title="失败诊断" subtitle="根据训练日志和失败原因生成的排查建议">
<div class="diagnostics-list">
<article v-for="item in diagnostics" :key="item.title" class="diagnostics-item">
<strong>{{ item.title }}</strong>
<p>{{ item.suggestion }}</p>
</article>
</div>
</PageCard>
<!-- 原始日志 -->
<PageCard class="log-card" title="训练日志" subtitle="查看训练任务的原始运行输出">
<template #extra><span class="log-meta">{{ logLineCount }} · 5 秒刷新</span></template>
@@ -634,6 +657,7 @@ onMounted(async () => {
.summary-card,
.parameters-card,
.metrics-panel,
.diagnostics-card,
.log-card {
margin-bottom: 0;
border: 1px solid #e4e7ed !important;
@@ -641,6 +665,28 @@ onMounted(async () => {
box-shadow: none !important;
}
.diagnostics-list {
display: grid;
gap: 10px;
}
.diagnostics-item {
border: 1px solid #fecaca;
border-radius: 8px;
padding: 12px 14px;
background: #fef2f2;
strong {
color: #991b1b;
}
p {
margin: 6px 0 0;
color: #374151;
line-height: 1.7;
}
}
.overview-card-header {
display: flex;
align-items: flex-start;