1
This commit is contained in:
@@ -52,6 +52,14 @@ async function loadAll() {
|
||||
const tasks = await request.get('/label-tasks', { params: { page: 1, size: 100 } }).catch(() => null)
|
||||
const running = (tasks?.list || []).find((t) => t.datasetId === datasetId.value && t.status === 'running')
|
||||
if (running) pollLabelTask(running.id)
|
||||
// 刷新时恢复进行中的 AI 生成任务(或展示最近一次失败的提示)
|
||||
const gt = await request.get('/datasets/gen-task', { params: { datasetId: datasetId.value } }).catch(() => null)
|
||||
if (gt && gt.status === 'running') {
|
||||
genTask.value = { id: gt.id, status: gt.status, total: gt.total, done: gt.done, error: gt.error }
|
||||
pollGenTask()
|
||||
} else if (gt && gt.error) {
|
||||
genTask.value = gt
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -59,41 +67,63 @@ async function loadAll() {
|
||||
|
||||
// ---------- 添加图片(AI 生成) ----------
|
||||
const addVisible = ref(false)
|
||||
const genForm = reactive({ species: '', count: 1, size: '704x1248', distance: 25 })
|
||||
const speciesOptions = ref([])
|
||||
// 单物种规则:物种固定取数据集名,表单无物种输入
|
||||
const genForm = reactive({ count: 1, size: '704x1248', distance: 25 })
|
||||
const generating = ref(false)
|
||||
|
||||
// 生成表单选项(物种池/场景池来自 config,随配置热更新)
|
||||
function loadGenOptions() {
|
||||
request.get('/datasets/gen-options', { params: { datasetId: datasetId.value } }).then((d) => {
|
||||
speciesOptions.value = d.species || []
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
genForm.species = ''
|
||||
loadGenOptions()
|
||||
genForm.count = 1
|
||||
genForm.size = '704x1248'
|
||||
genForm.distance = 25
|
||||
addVisible.value = true
|
||||
}
|
||||
|
||||
// AI 生成为异步任务:提交即返回 taskId,轮询 /datasets/gen-task 看进度
|
||||
const genTask = ref(null)
|
||||
const genPoll = ref(null)
|
||||
const genRunning = computed(() => genTask.value && genTask.value.status === 'running')
|
||||
|
||||
function pollGenTask() {
|
||||
stopGenPoll()
|
||||
genPoll.value = setInterval(async () => {
|
||||
const d = await request.get('/datasets/gen-task', { params: { datasetId: datasetId.value } }).catch(() => null)
|
||||
if (!d) return
|
||||
genTask.value = { id: d.id, status: d.status, total: d.total, done: d.done, error: d.error }
|
||||
if (d.status !== 'running') {
|
||||
stopGenPoll()
|
||||
if (d.error) {
|
||||
ElMessage.error(`AI 生成未完成:${d.error}`)
|
||||
} else if (d.total > 0) {
|
||||
ElMessage.success(`AI 生成完成(${d.done}/${d.total} 张),自动标注处理中…`)
|
||||
}
|
||||
loadAll().then(() => {
|
||||
if (editorVisible.value) nextTick(resetEditor)
|
||||
})
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopGenPoll() {
|
||||
if (genPoll.value) {
|
||||
clearInterval(genPoll.value)
|
||||
genPoll.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function submitGen() {
|
||||
generating.value = true
|
||||
request
|
||||
.post('/datasets/generate', {
|
||||
datasetId: datasetId.value,
|
||||
species: genForm.species.trim(),
|
||||
count: genForm.count,
|
||||
size: genForm.size,
|
||||
distance: genForm.distance,
|
||||
}, { timeout: 2400000 })
|
||||
})
|
||||
.then((data) => {
|
||||
const rj = data.rejected ? `,${data.rejected} 张不合格已跳过` : ''
|
||||
ElMessage.success(`已生成 ${data.generated || 0} 张${rj}(生成图片为付费资产,删除前请确认),自动标注处理中…`)
|
||||
addVisible.value = false
|
||||
loadAll()
|
||||
genTask.value = { id: data.taskId, status: 'running', total: data.total, done: 0, error: '' }
|
||||
ElMessage.success(`已提交生成任务(共 ${data.total} 张,约 40 秒/张),进度见页顶进度条`)
|
||||
pollGenTask()
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -491,6 +521,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopLabelPoll()
|
||||
stopGenPoll()
|
||||
stopHighlight()
|
||||
window.removeEventListener('resize', onWinResize)
|
||||
})
|
||||
@@ -512,7 +543,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 图片工具 -->
|
||||
<div class="img-toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="openAdd">添加</el-button>
|
||||
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
|
||||
<el-button :icon="MagicStick" :disabled="labelRunning" @click="startFullLabelTask">全量标注</el-button>
|
||||
</div>
|
||||
|
||||
@@ -526,6 +557,16 @@ onBeforeUnmount(() => {
|
||||
<div v-if="labelTask && labelTask.status === 'done' && labelTask.error" class="label-error">
|
||||
自动标注未完成:{{ labelTask.error }}
|
||||
</div>
|
||||
<div v-if="genRunning" class="label-progress">
|
||||
<el-progress
|
||||
:percentage="genTask.total ? Math.min(100, Math.round((genTask.done / genTask.total) * 100)) : 0"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
<span class="label-progress-tip">AI 生成中 {{ genTask.done }}/{{ genTask.total }} 张(约 40 秒/张)…</span>
|
||||
</div>
|
||||
<div v-if="genTask && genTask.status !== 'running' && genTask.error" class="label-error">
|
||||
AI 生成未完成:{{ genTask.error }}
|
||||
</div>
|
||||
|
||||
<!-- 图片卡片:点击缩略图直接进标注弹窗(有标注框直接显示在图上,可继续画框) -->
|
||||
<div v-loading="loading" class="rows-area">
|
||||
@@ -564,21 +605,8 @@ onBeforeUnmount(() => {
|
||||
<!-- 添加图片:AI 生成 -->
|
||||
<el-dialog v-model="addVisible" title="AI 生成图片" width="min(520px, 92vw)" append-to-body>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="动物名称">
|
||||
<el-select
|
||||
v-model="genForm.species"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="留空按数据集物种池随机"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="s in speciesOptions" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="生成张数">
|
||||
<el-input-number v-model="genForm.count" :min="1" :max="8" />
|
||||
<el-input-number v-model="genForm.count" :min="1" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标距离">
|
||||
<div class="gen-inline">
|
||||
@@ -589,11 +617,11 @@ onBeforeUnmount(() => {
|
||||
<el-form-item label="图片尺寸">
|
||||
<span class="gen-size">竖版 704x1248</span>
|
||||
</el-form-item>
|
||||
<div class="gen-tip">生成图片为付费资产,请勿随意删除;手填提示词时无需写目标位置(系统统一约束目标只出现在画面上部三分之一区域)。</div>
|
||||
<div class="gen-tip">按本数据集物种({{ dataset?.name || '' }})生成,每张固定 1 只;生成图片为付费资产,请勿随意删除;批量生成为后台任务(约 40 秒/张),进度见页顶进度条;手填提示词时无需写拍摄角度/位置(系统自动约束画面中有且只有这一只动物)。</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="primary" :loading="generating" @click="submitGen">开始生成(可能需数分钟)</el-button>
|
||||
<el-button type="primary" :loading="generating" @click="submitGen">开始生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { EditPen, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { EditPen, Loading, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../api/request'
|
||||
|
||||
@@ -15,6 +15,7 @@ const keyword = ref('')
|
||||
|
||||
const formVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const generatingCover = ref(false)
|
||||
// 新建/编辑共用弹窗(改名/前缀/描述/封面;改名同步迁移磁盘目录)
|
||||
const dlgForm = reactive({
|
||||
mode: 'create', // create | edit
|
||||
@@ -23,7 +24,17 @@ const dlgForm = reactive({
|
||||
namePrefix: '',
|
||||
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) // 用户删除了已有封面(保存时调删除接口)
|
||||
@@ -61,7 +72,22 @@ function resetCoverState() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(dlgForm, { mode: 'create', id: 0, name: '', namePrefix: '', description: '', cover: '' })
|
||||
Object.assign(dlgForm, {
|
||||
mode: 'create',
|
||||
id: 0,
|
||||
name: '',
|
||||
namePrefix: '',
|
||||
description: '',
|
||||
cover: '',
|
||||
pendingCover: '',
|
||||
genSpecies: '',
|
||||
genTone: '',
|
||||
genHeights: '',
|
||||
genScenes: '',
|
||||
genActions: '',
|
||||
genOcclusions: '',
|
||||
genClasses: '',
|
||||
})
|
||||
resetCoverState()
|
||||
coverFileList.value = []
|
||||
formVisible.value = true
|
||||
@@ -75,6 +101,14 @@ function openEdit(row) {
|
||||
namePrefix: row.namePrefix || '',
|
||||
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 项展示(带版本参数防浏览器缓存旧封面)
|
||||
@@ -122,7 +156,22 @@ async function submitDlg() {
|
||||
try {
|
||||
let id = dlgForm.id
|
||||
if (dlgForm.mode === 'create') {
|
||||
id = (await request.post('/datasets', { name, namePrefix: dlgForm.namePrefix.trim(), source: 'manual' })).id
|
||||
// 创建会同步调 VLM 生成参数池 + z-image 生成封面,超时放宽 180s
|
||||
const res = await request.post(
|
||||
'/datasets',
|
||||
{ name, namePrefix: dlgForm.namePrefix.trim(), source: 'manual', cover: dlgForm.pendingCover || undefined },
|
||||
{ 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()
|
||||
@@ -132,11 +181,18 @@ async function submitDlg() {
|
||||
} else if (dlgForm.mode === 'edit' && coverDeleted.value) {
|
||||
await request.post('/datasets/cover/delete', { datasetId: id })
|
||||
}
|
||||
// create 且未填描述时跳过 update(服务端空值不覆盖,避免空跑一次)
|
||||
// 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: '' })
|
||||
await request.post('/datasets/update', {
|
||||
id,
|
||||
name,
|
||||
namePrefix: dlgForm.namePrefix.trim(),
|
||||
description: dlgForm.description.trim(),
|
||||
cover: '',
|
||||
})
|
||||
}
|
||||
ElMessage.success(dlgForm.mode === 'create' ? '数据集已创建,请上传或生成图片' : '已保存')
|
||||
if (dlgForm.mode === 'edit') ElMessage.success('已保存')
|
||||
formVisible.value = false
|
||||
page.value = 1
|
||||
load()
|
||||
@@ -145,6 +201,64 @@ async function submitDlg() {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成封面(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
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 开始训练(一键发起:任务名自动生成,参数走 config.yml training 节点) ----------
|
||||
|
||||
function startTrain(row) {
|
||||
@@ -338,7 +452,7 @@ onBeforeUnmount(() => {
|
||||
<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 label="封面">
|
||||
<el-form-item v-if="dlgForm.mode === 'edit'" label="封面">
|
||||
<div class="cover-row">
|
||||
<el-upload
|
||||
v-model:file-list="coverFileList"
|
||||
@@ -355,6 +469,34 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
@@ -511,6 +653,67 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user