管理端:训练档位选择/排队状态/按档发布(双档体系 UI)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 另有数据清洗入口;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user