管理端:训练档位选择/排队状态/按档发布(双档体系 UI)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:20:48 +08:00
co-authored by Claude Opus 4.7
parent 6f8c54c31d
commit 71d042906c
2 changed files with 203 additions and 53 deletions
+31 -7
View File
@@ -10,6 +10,36 @@ const router = useRouter()
const datasetId = computed(() => Number(route.params.id))
const dataset = ref(null)
// 顶部状态汇总:各档位训练状态依次拼(双档 2026-09-03:s 高识别 / n 高性能 各一条最新记录);
// running 带进度,queued 提示排队;无训练记录回退数据集状态
const variantLabel = { s: 's高识别', n: 'n高性能' }
const trainBanner = computed(() => {
const d = dataset.value
if (!d) return { text: '', type: 'info' }
const trs = d.trains || []
if (!trs.length) {
const fallback = { building: ['建设中', 'info'], labeled: ['已标注', 'success'], synced: ['已同步', 'primary'] }
const [text, type] = fallback[d.status] || ['', 'info']
return { text, type }
}
const parts = []
for (const t of trs) {
const lab = variantLabel[t.variant] || t.variant
if (t.status === 'running') parts.push(`${lab} 训练中 ${t.currentEpoch || 0}/${t.totalEpochs || '-'}`)
else if (t.status === 'queued') parts.push(`${lab} 排队中`)
else if (t.status === 'success') parts.push(`${lab} ${t.published ? '已训练·已发布' : '已训练'}`)
else if (t.status === 'failed') parts.push(`${lab} 训练失败`)
}
const type = trs.some((t) => t.status === 'running')
? 'primary'
: trs.some((t) => t.status === 'queued')
? 'warning'
: trs.some((t) => t.status === 'failed')
? 'danger'
: 'success'
return { text: parts.join(' / ') || '—', type }
})
// ---------- 数据 ----------
const detail = ref(null) // 工作台数据:images[{filename,url,width,height,boxes,labeled}]
const imageMeta = ref({}) // filename -> {id, source, cleanExcluded}(来自 /datasets/images,行内展示用)
@@ -910,13 +940,7 @@ onBeforeUnmount(() => {
<div class="detail-topbar">
<el-button link @click="router.push('/datasets')"> 返回数据集</el-button>
<span class="detail-title">{{ dataset?.name || '数据训练' }}</span>
<el-tag
v-if="dataset"
:type="dataset.trainingStatus === 'running' ? 'primary' : (dataset.trainingStatus === 'success' ? 'success' : (dataset.trainingStatus === 'failed' ? 'danger' : 'info'))"
size="small"
>
{{ dataset.trainingStatus === 'running' ? `训练中 ${dataset.trainingCurrentEpoch || 0}/${dataset.trainingTotalEpochs || '-'}` : (dataset.trainingStatus === 'success' ? '训练完成' : (dataset.trainingStatus === 'failed' ? '训练失败' : (dataset.status === 'labeled' ? '已标注' : '建设中'))) }}
</el-tag>
<el-tag v-if="dataset" :type="trainBanner.type" size="small">{{ trainBanner.text }}</el-tag>
</div>
<!-- 图片工具未标注/已标注 tab 走勾选预标/补标/删除已标注 tab 另有数据清洗入口
+172 -46
View File
@@ -44,8 +44,22 @@ const trainingIds = reactive(new Set()) // 训练发起中的数据集 id(请
const statusMap = { building: '建设中', labeled: '已标注', synced: '已同步' }
const statusTag = { building: 'info', labeled: 'success', synced: 'primary' }
const trainStatusMap = { running: '训练中', success: '已训练', failed: '训练失败' }
const trainStatusTag = { running: 'primary', success: 'success', failed: 'danger' }
const trainStatusMap = { queued: '排队中', running: '训练中', success: '已训练', failed: '训练失败' }
const trainStatusTag = { queued: 'info', running: 'primary', success: 'success', failed: 'danger' }
// 训练档位:s=高识别(yolov8s@1280 精度优先) | n=高性能(yolov8n@704 速度优先)
const variantMeta = {
s: { label: 's 高识别', tag: 'primary' },
n: { label: 'n 高性能', tag: 'warning' },
}
// 卡片训练状态行:各档位(s/n)最新训练,无记录为空
function trList(row) {
return row?.trains || []
}
function activeTrs(row) {
// queued/running 均为进行中(双档并存时最多两条,GPU 串行)
return trList(row).filter((t) => t.status === 'running' || t.status === 'queued')
}
function load() {
loading.value = true
@@ -269,21 +283,44 @@ function regenPools() {
})
}
// ---------- 开始训练(一键发起:任务名自动生成,参数走 config.yml training 节点) ----------
// ---------- 开始训练(弹窗选档位,默认双档 s+n 各建一条排队任务;任务名自动生成,
// 参数走 config.yml training 节点;GPU 独占串行,忙时新任务自动排队 queued ----------
function startTrain(row) {
if (trainingIds.has(row.id)) return
const startVisible = ref(false)
const startRow = ref(null) // 当前发起训练的数据集
const startSel = reactive({ s: true, n: true })
function openStart(row) {
// 已有进行中档位置灰:同 (数据集,档位) 防重由后端校验,前端仅提示
const act = activeTrs(row).map((t) => t.variant)
startRow.value = row
startSel.s = !act.includes('s')
startSel.n = !act.includes('n')
startVisible.value = true
}
function startTrain() {
const variants = []
if (startSel.s) variants.push('s')
if (startSel.n) variants.push('n')
if (!variants.length) {
ElMessage.warning('请至少选择一个档位')
return
}
const row = startRow.value
if (!row || trainingIds.has(row.id)) return
trainingIds.add(row.id)
request
// 后端已异步化(请求只做校验+落记录),60s 仅作兜底
.post('/trainings', { datasetId: row.id }, { timeout: 60000 })
// 后端已异步化(请求只做校验+落 queued 记录),60s 仅作兜底
.post('/trainings', { datasetId: row.id, variants }, { timeout: 60000 })
.then(() => {
ElMessage.success('训练任务已发起,完成后可发布为模型版本')
ElMessage.success('训练已加入队列,GPU 空闲后自动按顺序执行')
startVisible.value = false
})
.catch(() => {})
.finally(() => {
trainingIds.delete(row.id)
// 请求失败也可能已落 running 记录(如响应异常),一律刷新拿真实状态
// 请求失败也可能已落 queued 记录(如响应异常),一律刷新拿真实状态
load()
})
}
@@ -320,18 +357,20 @@ function coverUrl(row) {
}
// 训练进度(百分比):有总轮数才显示比例
function trainPercent(row) {
if (row.trainingStatus !== 'running' || !row.trainingTotalEpochs) return 0
return Math.min(100, Math.round((row.trainingCurrentEpoch / row.trainingTotalEpochs) * 100))
function trainPercent(t) {
if (t.status !== 'running' || !t.totalEpochs) return 0
return Math.min(100, Math.round((t.currentEpoch / t.totalEpochs) * 100))
}
function publishModel(row) {
// 发布模型版本(按训练任务档位:s/n 各自的生效版本互不影响,版本号数据集内共用自增)
function publishTrain(row, t) {
const v = variantMeta[t.variant]
ElMessageBox.confirm(
`确定将「${row.name}」的最新训练结果发布为模型版本?按数据集版本号自增(m<主>.<次>.<修订>),客户端下次启动热更新下载。`,
`确定将「${row.name}」的 ${v.label}(训练 #${t.trainingId}结果发布为模型版本?版本号按数据集自增(m<主>.<次>.<修订>),客户端按识别档位热更新下载。`,
'发布模型版本',
{ confirmButtonText: '发布', cancelButtonText: '取消', type: 'warning' },
)
.then(() => request.post('/trainings/publish', { id: row.trainingId }))
.then(() => request.post('/trainings/publish', { id: t.trainingId }))
.then((data) => {
ElMessage.success(`已发布版本 ${data.version}`)
load()
@@ -339,12 +378,31 @@ function publishModel(row) {
.catch(() => {})
}
// 取消训练:running=终止进程;queued=直接取消排队
function cancelTrain(row, t) {
const v = variantMeta[t.variant]
const isRun = t.status === 'running'
ElMessageBox.confirm(
isRun
? `确定终止「${row.name}${v.label} 的本次训练?进程将被终止,进度不保留。`
: `确定取消「${row.name}${v.label} 的排队任务?`,
isRun ? '终止训练' : '取消排队训练',
{ confirmButtonText: '确定', cancelButtonText: '再想想', type: 'warning' },
)
.then(() => request.post('/trainings/cancel', { id: t.trainingId }))
.then(() => {
ElMessage.success('已取消')
load()
})
.catch(() => {})
}
// 有训练中的任务时周期刷新,卡片进度条保持最新
let trainPoll = null
onMounted(() => {
load()
trainPoll = setInterval(() => {
if (list.value.some((r) => r.trainingStatus === 'running')) load()
if (list.value.some((r) => trList(r).some((t) => t.status === 'running'))) load()
}, 10000)
})
@@ -395,42 +453,45 @@ onBeforeUnmount(() => {
size="small"
:icon="VideoPlay"
:loading="trainingIds.has(row.id)"
:disabled="trainingIds.has(row.id) || row.trainingStatus === 'running'"
@click="startTrain(row)"
:disabled="trainingIds.has(row.id) || activeTrs(row).length >= 2"
@click="openStart(row)"
>
{{ row.trainingStatus === 'running' ? '训练中' : '开始训练' }}
{{ activeTrs(row).length >= 2 ? '训练中' : '开始训练' }}
</el-button>
<el-button size="small" :icon="EditPen" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="removeDataset(row)">删除</el-button>
<!-- 训练成功且未发布过发布按钮与其他操作同行失败标签在下方状态行展示 -->
<el-button
v-if="row.trainingStatus === 'success' && row.trainingId && !row.trainingPublished"
size="small"
type="success"
@click="publishModel(row)"
>
发布模型
</el-button>
</div>
<!-- 训练任务进度与状态最新一条训练记录成功态只留操作行按钮不占独立行 -->
<div v-if="row.trainingStatus && row.trainingStatus !== 'success'" class="ds-train" @click.stop>
<template v-if="row.trainingStatus === 'running'">
<el-progress :percentage="trainPercent(row)" :stroke-width="6" :show-text="false" class="ds-train-bar" />
<div class="ds-train-text">
<span>训练中 {{ row.trainingCurrentEpoch || 0 }}/{{ row.trainingTotalEpochs || '-' }} </span>
</div>
</template>
<template v-else>
<el-tooltip v-if="row.trainingStatus === 'failed' && row.trainingError" :content="row.trainingError" placement="top">
<el-tag :type="trainStatusTag[row.trainingStatus] || 'info'" size="small">
{{ trainStatusMap[row.trainingStatus] || row.trainingStatus }}
</el-tag>
<!-- 各档位s/n最新训练状态行success 已发布只标记未发布给发布入口 -->
<div v-if="trList(row).length" class="ds-train" @click.stop>
<div v-for="t in trList(row)" :key="t.variant" class="ds-train-line">
<el-tag :type="variantMeta[t.variant]?.tag || 'info'" size="small">{{ variantMeta[t.variant]?.label || t.variant }}</el-tag>
<template v-if="t.status === 'running'">
<el-progress :percentage="trainPercent(t)" :stroke-width="6" :show-text="false" class="ds-train-bar" />
<span class="ds-train-text">{{ t.currentEpoch || 0 }}/{{ t.totalEpochs || '-' }} </span>
</template>
<el-tooltip v-else-if="t.status === 'failed' && t.error" :content="t.error" placement="top">
<el-tag :type="trainStatusTag[t.status] || 'info'" size="small">{{ trainStatusMap[t.status] }}</el-tag>
</el-tooltip>
<el-tag v-else :type="trainStatusTag[row.trainingStatus] || 'info'" size="small">
{{ trainStatusMap[row.trainingStatus] || row.trainingStatus }}
<el-tag v-else-if="t.status !== 'success'" :type="trainStatusTag[t.status] || 'info'" size="small">
{{ trainStatusMap[t.status] || t.status }}
</el-tag>
</template>
<template v-else>
<el-tag type="success" size="small">已训练</el-tag>
<el-button v-if="!t.published" size="small" type="success" class="ds-pub" @click="publishTrain(row, t)">发布模型</el-button>
<span v-else class="ds-train-text">已发布</span>
</template>
<el-button
v-if="t.status === 'running' || t.status === 'queued'"
size="small"
text
type="danger"
class="ds-cancel"
@click="cancelTrain(row, t)"
>
{{ t.status === 'queued' ? '取消排队' : '终止' }}
</el-button>
</div>
</div>
</div>
<el-empty v-if="!loading && !list.length" description="暂无数据集,点击右上角新建" />
@@ -449,6 +510,28 @@ onBeforeUnmount(() => {
/>
</div>
<!-- 发起训练档位选择默认双档 s+n训练机 GPU 独占串行有任务时新任务排队 queued 逐个执行 -->
<el-dialog v-model="startVisible" title="发起训练" width="min(440px, 94vw)">
<div class="start-dataset">数据集{{ startRow?.name }}</div>
<div class="start-opts">
<div class="start-opt" :class="{ 'is-act': activeTrs(startRow).some((t) => t.variant === 's') }">
<el-checkbox v-model="startSel.s" :disabled="activeTrs(startRow).some((t) => t.variant === 's')">s 高识别</el-checkbox>
<span class="start-tip">yolov8s @1280 · 精度优先默认档</span>
</div>
<div class="start-opt" :class="{ 'is-act': activeTrs(startRow).some((t) => t.variant === 'n') }">
<el-checkbox v-model="startSel.n" :disabled="activeTrs(startRow).some((t) => t.variant === 'n')">n 高性能</el-checkbox>
<span class="start-tip">yolov8n @704 · 速度优先</span>
</div>
</div>
<p class="start-note">
训练机 GPU 独占已有任务时本次训练自动排队queued按发起顺序逐个执行该档位已有进行中任务时会提示错误排队的任务可在卡片上取消
</p>
<template #footer>
<el-button @click="startVisible = false">取消</el-button>
<el-button type="primary" :loading="trainingIds.has(startRow?.id)" @click="startTrain">开始训练</el-button>
</template>
</el-dialog>
<!-- 新建 / 编辑数据集同一弹窗编辑模式回填原值且允许改名 -->
<el-dialog v-model="formVisible" :title="dlgForm.mode === 'create' ? '新建数据集' : '编辑数据集'" width="min(720px, 94vw)">
<el-form label-width="90px">
@@ -605,11 +688,17 @@ onBeforeUnmount(() => {
}
.ds-train {
display: flex;
align-items: center;
gap: 10px;
flex-direction: column;
gap: 6px;
padding: 8px 12px 12px;
border-top: 1px dashed #ebeef5;
}
.ds-train-line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ds-train-bar {
flex: 1;
}
@@ -618,6 +707,43 @@ onBeforeUnmount(() => {
color: #606266;
white-space: nowrap;
}
.ds-pub {
margin-left: auto;
}
.ds-cancel {
margin-left: auto;
}
.start-dataset {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
.start-opts {
display: flex;
flex-direction: column;
gap: 10px;
}
.start-opt {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #ebeef5;
border-radius: 6px;
}
.start-opt.is-act {
background: #f5f7fa;
}
.start-tip {
font-size: 12px;
color: #909399;
}
.start-note {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin: 12px 0 0;
}
.ds-body {
padding: 10px 12px 6px;
}