This commit is contained in:
2026-08-29 01:11:01 +08:00
parent 474d561173
commit 5c43370fc9
37 changed files with 1309 additions and 409 deletions
+61 -33
View File
@@ -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>
+210 -7
View File
@@ -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/png2MB悬停预览右上角 × 可删除当前封面</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:91 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 {