892 lines
30 KiB
Vue
892 lines
30 KiB
Vue
<script setup>
|
||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { EditPen, Loading, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
|
||
import { useRouter } from 'vue-router'
|
||
import request from '../api/request'
|
||
|
||
const router = useRouter()
|
||
const loading = ref(false)
|
||
const list = ref([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const size = ref(20)
|
||
const keyword = ref('')
|
||
|
||
const formVisible = ref(false)
|
||
const saving = ref(false)
|
||
const generatingCover = ref(false)
|
||
// 新建/编辑共用弹窗(改名/前缀/描述/封面;改名同步迁移磁盘目录)
|
||
const dlgForm = reactive({
|
||
mode: 'create', // create | edit
|
||
id: 0,
|
||
name: '',
|
||
namePrefix: '',
|
||
sortOrder: 0, // 序号(列表排序主键,升序;同号按创建时间倒序)
|
||
description: '',
|
||
cover: '',
|
||
pendingCover: '', // 新建预生成封面文件名(创建请求回传写库)
|
||
// 生成参数池(创建时 VLM 自动生成;编辑模式可查看/手改/重新生成)
|
||
genSpecies: '',
|
||
genTone: '',
|
||
genHeights: '',
|
||
genScenes: '',
|
||
genActions: '',
|
||
genOcclusions: '',
|
||
genClasses: '',
|
||
})
|
||
const savingPools = ref(false) // VLM 重新生成参数请求中
|
||
const coverFile = ref(null) // 新选择的封面文件(el-upload 单文件)
|
||
const coverFileList = ref([]) // 封面回显:已有封面(服务端 url)或新选文件(本地预览)
|
||
const coverDeleted = ref(false) // 用户删除了已有封面(保存时调删除接口)
|
||
|
||
const trainingIds = reactive(new Set()) // 训练发起中的数据集 id(请求期间按钮置灰防重复点击)
|
||
|
||
const statusMap = { building: '建设中', labeled: '已标注', synced: '已同步' }
|
||
const statusTag = { building: 'info', labeled: 'success', synced: 'primary' }
|
||
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
|
||
request
|
||
.get('/datasets', { params: { page: page.value, size: size.value, keyword: keyword.value || undefined } })
|
||
.then((data) => {
|
||
list.value = data.list || []
|
||
total.value = data.total || 0
|
||
})
|
||
.finally(() => {
|
||
loading.value = false
|
||
})
|
||
}
|
||
|
||
function search() {
|
||
page.value = 1
|
||
load()
|
||
}
|
||
|
||
// 每页条数输入框变更:回到第一页重新加载(避免当前页超出新总页数)
|
||
function onSizeChange() {
|
||
page.value = 1
|
||
load()
|
||
}
|
||
|
||
// ---------- 新建 / 编辑数据集(名称 + 描述 + 封面;AI 端点/训练机 SSH 走 config.yml,与数据集无关) ----------
|
||
|
||
function resetCoverState() {
|
||
coverFile.value = null
|
||
coverDeleted.value = false
|
||
}
|
||
|
||
function openCreate() {
|
||
Object.assign(dlgForm, {
|
||
mode: 'create',
|
||
id: 0,
|
||
name: '',
|
||
namePrefix: '',
|
||
sortOrder: 0,
|
||
description: '',
|
||
cover: '',
|
||
pendingCover: '',
|
||
genSpecies: '',
|
||
genTone: '',
|
||
genHeights: '',
|
||
genScenes: '',
|
||
genActions: '',
|
||
genOcclusions: '',
|
||
genClasses: '',
|
||
})
|
||
resetCoverState()
|
||
coverFileList.value = []
|
||
formVisible.value = true
|
||
}
|
||
|
||
function openEdit(row) {
|
||
Object.assign(dlgForm, {
|
||
mode: 'edit',
|
||
id: row.id,
|
||
name: row.name,
|
||
namePrefix: row.namePrefix || '',
|
||
sortOrder: row.sortOrder ?? 0,
|
||
description: row.description || '',
|
||
cover: row.cover || '',
|
||
pendingCover: '',
|
||
genSpecies: row.genSpecies || '',
|
||
genTone: row.genTone || '',
|
||
genHeights: String(row.genHeights ?? ''),
|
||
genScenes: row.genScenes || '',
|
||
genActions: row.genActions || '',
|
||
genOcclusions: row.genOcclusions || '',
|
||
genClasses: row.genClasses || '',
|
||
})
|
||
resetCoverState()
|
||
// 已有封面回显:el-upload picture-card 直接以 url 项展示(带版本参数防浏览器缓存旧封面)
|
||
coverFileList.value = row.cover
|
||
? [{ name: '当前封面', url: imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}&v=${encodeURIComponent(row.updatedAt || '')}`) }]
|
||
: []
|
||
formVisible.value = true
|
||
}
|
||
|
||
// 封面文件选中:多选时只保留最后一个(替换语义);新文件选中即视为不再删除
|
||
function onCoverChange(file, fileList) {
|
||
if (fileList.length > 1) coverFileList.value = [fileList[fileList.length - 1]]
|
||
coverFile.value = coverFileList.value.length ? coverFileList.value[0].raw : null
|
||
if (coverFile.value) coverDeleted.value = false
|
||
}
|
||
|
||
// 已有一张封面时仍选择新文件(如相册多选触发 on-exceed):替换为最后一张,保持单张语义
|
||
function onCoverExceed(files) {
|
||
const last = files[files.length - 1]
|
||
coverFileList.value = [{ name: last.name, raw: last, url: URL.createObjectURL(last), status: 'ready' }]
|
||
coverFile.value = last
|
||
coverDeleted.value = false
|
||
ElMessage.info('封面仅支持一张,已替换为最后选择的图片')
|
||
}
|
||
|
||
// 删除项:删服务端已有封面(无 raw)→ 标记待删除;删新选文件且列表清空 → 同样按删除处理(服务端旧封面仍在)
|
||
function onCoverRemove(file) {
|
||
coverFile.value = null
|
||
if (!file.raw) {
|
||
coverDeleted.value = true
|
||
} else if (!coverFileList.value.length && dlgForm.cover) {
|
||
coverDeleted.value = true
|
||
}
|
||
}
|
||
|
||
// 提交新建/编辑:create 先建数据集拿 id,封面与描述复用 update/cover 接口串联写入;
|
||
// edit 按原配置保存流程。封面为独立 multipart 接口,update 始终不携带 cover。
|
||
async function submitDlg() {
|
||
const name = dlgForm.name.trim()
|
||
if (!name) {
|
||
ElMessage.warning('请输入数据集名称')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
let id = dlgForm.id
|
||
if (dlgForm.mode === 'create') {
|
||
// 创建会同步调 VLM 生成参数池 + z-image 生成封面,超时放宽 180s
|
||
const res = await request.post(
|
||
'/datasets',
|
||
{ name, namePrefix: dlgForm.namePrefix.trim(), source: 'manual', cover: dlgForm.pendingCover || undefined, sortOrder: dlgForm.sortOrder || 0 },
|
||
{ timeout: 180000 },
|
||
)
|
||
id = res.id
|
||
if (res.poolError) {
|
||
ElMessage.warning(`数据集已创建,但自动生成参数池失败:${res.poolError}(可编辑重试或手填提示词)`)
|
||
} else if (res.coverError) {
|
||
ElMessage.warning(`数据集已创建,参数池已生成,但封面生成失败:${res.coverError}(可稍后进入编辑上传)`)
|
||
} else if (res.poolsGenerated || res.coverGenerated) {
|
||
ElMessage.success('数据集已创建,生成参数池与封面已自动生成')
|
||
} else {
|
||
ElMessage.success('数据集已创建,请上传或生成图片')
|
||
}
|
||
}
|
||
if (coverFile.value) {
|
||
const fd = new FormData()
|
||
fd.append('datasetId', id)
|
||
fd.append('file', coverFile.value)
|
||
await request.post('/datasets/cover', fd, { timeout: 60000 })
|
||
} else if (dlgForm.mode === 'edit' && coverDeleted.value) {
|
||
await request.post('/datasets/cover/delete', { datasetId: id })
|
||
}
|
||
// create 且未填描述时跳过 update(服务端空值不覆盖,避免空跑一次);
|
||
// gen_* 参数池由 VLM 生成,不随本表单提交(重新生成走 gen-pools 接口)
|
||
if (dlgForm.mode === 'edit' || dlgForm.description.trim()) {
|
||
await request.post('/datasets/update', {
|
||
id,
|
||
name,
|
||
namePrefix: dlgForm.namePrefix.trim(),
|
||
description: dlgForm.description.trim(),
|
||
cover: '',
|
||
sortOrder: dlgForm.sortOrder || 0,
|
||
})
|
||
}
|
||
if (dlgForm.mode === 'edit') ElMessage.success('已保存')
|
||
formVisible.value = false
|
||
page.value = 1
|
||
load()
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
// 生成封面(z-image 文生图:16:9、1 雄 1 雌):新建模式预生成(datasetId=0,仅落盘,
|
||
// 创建请求带 cover 回传写库),编辑模式写库覆盖旧封面;成功后回显封面
|
||
async function genCover() {
|
||
if (generatingCover.value) return
|
||
if (dlgForm.mode === 'create' && !dlgForm.name.trim()) {
|
||
ElMessage.warning('请先填写数据集名称')
|
||
return
|
||
}
|
||
generatingCover.value = true
|
||
try {
|
||
// z-image 生成约 40s(首次含模型入显存可达分钟级),超时对齐后端 120s
|
||
const data = await request.post('/datasets/cover/generate', {
|
||
datasetId: dlgForm.id,
|
||
name: dlgForm.mode === 'create' ? dlgForm.name.trim() : undefined,
|
||
}, { timeout: 120000 })
|
||
if (dlgForm.mode === 'create') {
|
||
dlgForm.pendingCover = data.cover
|
||
coverFileList.value = [{
|
||
name: 'AI 封面',
|
||
url: imgUrl(`/api/v1/admin/datasets/cover?name=${encodeURIComponent(dlgForm.name.trim())}&filename=${encodeURIComponent(data.cover)}`),
|
||
}]
|
||
} else {
|
||
coverFileList.value = [{ name: 'AI 封面', url: imgUrl(`/api/v1/admin/datasets/cover?datasetId=${dlgForm.id}&v=${Date.now()}`) }]
|
||
coverDeleted.value = false
|
||
load()
|
||
}
|
||
ElMessage.success('封面已生成')
|
||
} catch (e) {
|
||
// 错误已由请求层提示(如 local-ai 未恢复/生成任务占用显存)
|
||
} finally {
|
||
generatingCover.value = false
|
||
}
|
||
}
|
||
|
||
// VLM 重新生成生成参数池(编辑模式;接口直接写库,成功回填展示;失败保留旧值,错误已由请求层提示)
|
||
function regenPools() {
|
||
if (savingPools.value) return
|
||
savingPools.value = true
|
||
request
|
||
.post('/datasets/gen-pools', { datasetId: dlgForm.id })
|
||
.then((data) => {
|
||
Object.assign(dlgForm, {
|
||
genSpecies: data.genSpecies || '',
|
||
genTone: data.genTone || '',
|
||
genHeights: String(data.genHeights ?? ''),
|
||
genScenes: data.genScenes || '',
|
||
genActions: data.genActions || '',
|
||
genOcclusions: data.genOcclusions || '',
|
||
genClasses: data.genClasses || '',
|
||
})
|
||
ElMessage.success('生成参数池已重新生成')
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
savingPools.value = false
|
||
})
|
||
}
|
||
|
||
// ---------- 开始训练(弹窗选档位,默认双档 s+n 各建一条排队任务;任务名自动生成,
|
||
// 参数走 config.yml training 节点;GPU 独占串行,忙时新任务自动排队 queued) ----------
|
||
|
||
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
|
||
// 后端已异步化(请求只做校验+落 queued 记录),60s 仅作兜底
|
||
.post('/trainings', { datasetId: row.id, variants }, { timeout: 60000 })
|
||
.then(() => {
|
||
ElMessage.success('训练已加入队列,GPU 空闲后自动按顺序执行')
|
||
startVisible.value = false
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
trainingIds.delete(row.id)
|
||
// 请求失败也可能已落 queued 记录(如响应异常),一律刷新拿真实状态
|
||
load()
|
||
})
|
||
}
|
||
|
||
// ---------- 删除 ----------
|
||
|
||
function removeDataset(row) {
|
||
const warn =
|
||
row.source === 'ai' || row.imageCount > 0
|
||
? '将删除图片文件、标注与记录,AI 生成图属付费资产,删除后无法恢复。'
|
||
: ''
|
||
ElMessageBox.confirm(`确定删除数据集「${row.name}」?${warn}`, '删除数据集', {
|
||
confirmButtonText: '删除',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
})
|
||
.then(() => request.post('/datasets/delete', { id: row.id }))
|
||
.then(() => {
|
||
ElMessage.success('已删除')
|
||
load()
|
||
})
|
||
.catch(() => {})
|
||
}
|
||
|
||
// <img> 标签无法带自定义请求头,图片地址以 query 参数携带 admin token
|
||
function imgUrl(u) {
|
||
return `${location.origin}${u}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
|
||
}
|
||
|
||
function coverUrl(row) {
|
||
if (!row.cover) return ''
|
||
// v=updatedAt 版本参数:换封面后 URL 变化,强制浏览器重新拉取
|
||
return imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}&v=${encodeURIComponent(row.updatedAt || '')}`)
|
||
}
|
||
|
||
// 训练进度(百分比):有总轮数才显示比例
|
||
function trainPercent(t) {
|
||
if (t.status !== 'running' || !t.totalEpochs) return 0
|
||
return Math.min(100, Math.round((t.currentEpoch / t.totalEpochs) * 100))
|
||
}
|
||
|
||
// 发布模型版本(按训练任务档位:s/n 各自的生效版本互不影响,版本号数据集内共用自增)
|
||
function publishTrain(row, t) {
|
||
const v = variantMeta[t.variant]
|
||
ElMessageBox.confirm(
|
||
`确定将「${row.name}」的 ${v.label}(训练 #${t.trainingId})结果发布为模型版本?版本号按数据集自增(m<主>.<次>.<修订>),客户端按识别档位热更新下载。`,
|
||
'发布模型版本',
|
||
{ confirmButtonText: '发布', cancelButtonText: '取消', type: 'warning' },
|
||
)
|
||
.then(() => request.post('/trainings/publish', { id: t.trainingId }))
|
||
.then((data) => {
|
||
ElMessage.success(`已发布版本 ${data.version}`)
|
||
load()
|
||
})
|
||
.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) => trList(r).some((t) => t.status === 'running'))) load()
|
||
}, 10000)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (trainPoll) clearInterval(trainPoll)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<el-card shadow="never">
|
||
<div class="toolbar">
|
||
<div class="toolbar-left">
|
||
<el-input
|
||
v-model="keyword"
|
||
class="kw"
|
||
placeholder="按名称搜索"
|
||
clearable
|
||
@keyup.enter="search"
|
||
@clear="search"
|
||
/>
|
||
<el-button @click="search">搜索</el-button>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<el-button type="primary" :icon="Plus" @click="openCreate">新建数据集</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-loading="loading" class="ds-grid">
|
||
<div v-for="row in list" :key="row.id" class="ds-card" @click="router.push(`/datasets/${row.id}`)">
|
||
<div class="ds-cover">
|
||
<img v-if="row.cover" :src="coverUrl(row)" :alt="row.name" loading="lazy" />
|
||
<div v-else class="ds-cover-placeholder"><el-icon :size="34"><Picture /></el-icon></div>
|
||
<div v-if="row.source === 'ai'" class="ds-cover-source">AI 生成</div>
|
||
</div>
|
||
<div class="ds-body">
|
||
<div class="ds-name" :title="row.name">{{ row.name }}</div>
|
||
<div class="ds-desc">{{ row.description || '暂无描述,点击进入管理图片与标注' }}</div>
|
||
<div class="ds-stats">
|
||
<span v-if="row.sortOrder">序号 {{ row.sortOrder }}</span>
|
||
<span>图片 {{ row.imageCount }}</span>
|
||
<span>已标注 {{ row.labeledCount }}</span>
|
||
<el-tag :type="statusTag[row.status] || 'info'" size="small">{{ statusMap[row.status] || row.status }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="ds-actions" @click.stop>
|
||
<el-button
|
||
type="primary"
|
||
size="small"
|
||
:icon="VideoPlay"
|
||
:loading="trainingIds.has(row.id)"
|
||
:disabled="trainingIds.has(row.id) || activeTrs(row).length >= 2"
|
||
@click="openStart(row)"
|
||
>
|
||
{{ 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>
|
||
</div>
|
||
|
||
<!-- 各档位(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-if="t.status !== 'success'" :type="trainStatusTag[t.status] || 'info'" size="small">
|
||
{{ trainStatusMap[t.status] || t.status }}
|
||
</el-tag>
|
||
<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="暂无数据集,点击右上角新建" />
|
||
</div>
|
||
|
||
<div class="pager-row">
|
||
<span class="pager-label">每页</span>
|
||
<el-input-number v-model="size" :min="1" :max="500" :step="10" size="small" @change="onSizeChange" />
|
||
<span class="pager-label">条</span>
|
||
<el-pagination
|
||
v-model:current-page="page"
|
||
v-model:page-size="size"
|
||
:total="total"
|
||
layout="total, prev, pager, next"
|
||
@change="load"
|
||
/>
|
||
</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">
|
||
<el-form-item label="名称">
|
||
<el-input
|
||
v-model="dlgForm.name"
|
||
maxlength="50"
|
||
show-word-limit
|
||
placeholder="中文/字母/数字/下划线/短横线,唯一且作磁盘目录名"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="序号">
|
||
<el-input-number v-model="dlgForm.sortOrder" :min="0" :max="999999" :step="1" controls-position="right" style="width: 160px" />
|
||
<span class="field-tip">列表按序号升序排列,同号按创建时间倒序;默认 0</span>
|
||
</el-form-item>
|
||
<el-form-item label="文件名前缀">
|
||
<el-input v-model="dlgForm.namePrefix" maxlength="50" placeholder="可选,如 pigeon:AI 生成图按 pigeon_01.jpg 顺序命名;留空用时间戳命名" />
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input v-model="dlgForm.description" type="textarea" :rows="6" maxlength="500" show-word-limit placeholder="数据集说明,展示在卡片上" />
|
||
</el-form-item>
|
||
<el-form-item v-if="dlgForm.mode === 'edit'" label="封面">
|
||
<div class="cover-row">
|
||
<el-upload
|
||
v-model:file-list="coverFileList"
|
||
:auto-upload="false"
|
||
accept=".jpg,.jpeg,.png"
|
||
list-type="picture-card"
|
||
:limit="1"
|
||
:on-change="onCoverChange"
|
||
:on-remove="onCoverRemove"
|
||
:on-exceed="onCoverExceed"
|
||
>
|
||
<div v-if="coverFileList.length === 0" class="upload-tile">
|
||
<el-icon :size="22"><Plus /></el-icon>
|
||
</div>
|
||
</el-upload>
|
||
<span class="add-tip">jpg/jpeg/png,≤2MB;悬停预览右上角 × 可删除当前封面</span>
|
||
<el-button size="small" :loading="generatingCover" @click="genCover">AI 生成封面</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item v-else label="封面">
|
||
<div class="cover-row">
|
||
<div v-if="coverFileList.length === 0" class="gen-cover-tile" @click="genCover">
|
||
<el-icon v-if="generatingCover" class="is-loading" :size="22"><Loading /></el-icon>
|
||
<el-icon v-else :size="22"><Picture /></el-icon>
|
||
<span>{{ generatingCover ? '生成中...' : '生成封面' }}</span>
|
||
</div>
|
||
<el-image v-else :src="coverFileList[0].url" fit="cover" class="gen-cover-preview" />
|
||
<span class="add-tip">点击生成 AI 封面(16:9、1 雄 1 雌,约 40 秒);创建后也可进入编辑重新生成</span>
|
||
</div>
|
||
</el-form-item>
|
||
<!-- 生成参数池:创建时 VLM 自动生成(无需手填);编辑模式只读展示,重新生成走 VLM -->
|
||
<el-form-item v-if="dlgForm.mode === 'edit'" label="生成参数池">
|
||
<div class="pools-wrap">
|
||
<div class="pools-head">
|
||
<span class="pools-tip">VLM 自动生成,AI 生成图片按此池组装提示词(站高cm 用于距离感公式)</span>
|
||
<el-button size="small" :loading="savingPools" @click="regenPools">VLM 重新生成参数</el-button>
|
||
</div>
|
||
<div class="pool-row"><span class="pool-key">物种</span><span class="pool-val">{{ dlgForm.genSpecies || '—' }}</span></div>
|
||
<div class="pool-row"><span class="pool-key">轮廓色词</span><span class="pool-val">{{ dlgForm.genTone || '—' }}</span></div>
|
||
<div class="pool-row"><span class="pool-key">站高 cm</span><span class="pool-val">{{ dlgForm.genHeights || '—' }}</span></div>
|
||
<div class="pool-row"><span class="pool-key">场景池</span><span class="pool-val">{{ dlgForm.genScenes || '—' }}</span></div>
|
||
<div class="pool-row"><span class="pool-key">动作池</span><span class="pool-val">{{ dlgForm.genActions || '—' }}</span></div>
|
||
<div class="pool-row"><span class="pool-key">遮挡池</span><span class="pool-val">{{ dlgForm.genOcclusions || '—' }}</span></div>
|
||
<div class="pool-row"><span class="pool-key">第二类别</span><span class="pool-val">{{ dlgForm.genClasses || '—' }}</span></div>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="formVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="saving" @click="submitDlg">{{ dlgForm.mode === 'create' ? '创建' : '保存' }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
</el-card>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 14px;
|
||
gap: 10px;
|
||
}
|
||
.toolbar-left {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.toolbar-right {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.kw {
|
||
width: 220px;
|
||
}
|
||
.pager-row {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
.pager-label {
|
||
color: var(--el-text-color-secondary);
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
.ds-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 14px;
|
||
min-height: 120px;
|
||
}
|
||
.ds-card {
|
||
background: #fff;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: box-shadow 0.2s;
|
||
}
|
||
.ds-card:hover {
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.ds-cover {
|
||
position: relative;
|
||
aspect-ratio: 16 / 9;
|
||
background: #f5f7fa;
|
||
}
|
||
.ds-cover img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.ds-cover-placeholder {
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #c0c4cc;
|
||
}
|
||
.ds-cover-source {
|
||
position: absolute;
|
||
left: 8px;
|
||
top: 8px;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
color: #fff;
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
}
|
||
.ds-train {
|
||
display: flex;
|
||
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;
|
||
}
|
||
.ds-train-text {
|
||
font-size: 12px;
|
||
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;
|
||
}
|
||
.ds-name {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.ds-desc {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.5;
|
||
height: 36px;
|
||
overflow: hidden;
|
||
margin-top: 4px;
|
||
}
|
||
.ds-stats {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
color: #606266;
|
||
margin-top: 8px;
|
||
}
|
||
.ds-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
padding: 8px 12px 12px;
|
||
border-top: 1px dashed #ebeef5;
|
||
margin-top: 8px;
|
||
}
|
||
.field-tip {
|
||
margin-left: 8px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
.cover-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
}
|
||
/* 上传方块:虚线边框 + 居中加号(el-upload picture-card 触发块) */
|
||
.upload-tile {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #8c939d;
|
||
}
|
||
.add-tip {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.6;
|
||
align-self: flex-start;
|
||
margin-top: 12px;
|
||
}
|
||
.gen-cover-tile {
|
||
width: 148px;
|
||
height: 148px;
|
||
border: 1px dashed #d9d9d9;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
color: #8c939d;
|
||
cursor: pointer;
|
||
background: #fafafa;
|
||
flex-shrink: 0;
|
||
}
|
||
.gen-cover-tile:hover {
|
||
border-color: #409eff;
|
||
color: #409eff;
|
||
}
|
||
.gen-cover-preview {
|
||
width: 148px;
|
||
height: 148px;
|
||
border-radius: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
.pools-wrap {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.pools-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
.pools-tip {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.5;
|
||
}
|
||
.pool-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 6px 10px;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
.pool-key {
|
||
flex-shrink: 0;
|
||
color: #909399;
|
||
width: 72px;
|
||
}
|
||
.pool-val {
|
||
word-break: break-all;
|
||
font-family: monospace;
|
||
}
|
||
|
||
@media (max-width: 767px) {
|
||
.toolbar {
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
}
|
||
.toolbar-left {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
}
|
||
.toolbar-right {
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
.kw {
|
||
flex: 1;
|
||
width: auto;
|
||
}
|
||
.ds-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|