This commit is contained in:
2026-09-07 10:11:36 +08:00
parent 47a06af0d4
commit b8dd969cb1
33 changed files with 3247 additions and 173 deletions
+318 -11
View File
@@ -52,26 +52,45 @@ const pageSize = ref(pageSizeOptions.includes(savedSize) ? savedSize : 20)
// 列表 tab:未标注(默认,待处理优先) | 已标注 | 已清洗(clean_excluded=1,数据清洗排除,可恢复)——
// 卡片上不再叠「已标注/未标注」角标,分类由 tab 表达(2026-09-02
const listTab = ref('unlabeled')
// tab 分类(2026-09-04 审核三态):reviewStatus 0=未标注 / 1=待审核 / 2=已标注;
// 已清洗(clean_excluded=1)优先于审核态;已下发众包任务(annotateTaskId>0)的图从未标注 tab 隐藏
// 2026-09-07 图片粒度下发,停用任务释放后回来)
const reviewStatusOf = (it) => it.reviewStatus ?? 0
const tabCounts = computed(() => {
const imgs = detail.value?.images || []
const cleaned = imgs.filter((it) => meta(it).cleanExcluded).length
const labeled = imgs.filter((it) => it.labeled && !meta(it).cleanExcluded).length
return { labeled, cleaned, unlabeled: imgs.length - labeled - cleaned }
const countRs = (rs) =>
imgs.filter((it) => {
if (meta(it).cleanExcluded) return false
// 已下发众包任务只隐藏「未标注」口径(提交后进待审核/已标注照常计数)
if (rs === 0 && meta(it).annotateTaskId) return false
return reviewStatusOf(it) === rs
}).length
return {
unlabeled: countRs(0),
pending: countRs(1),
labeled: countRs(2),
cleaned: imgs.filter((it) => meta(it).cleanExcluded).length,
}
})
const visibleImages = computed(() => {
const imgs = detail.value?.images || []
const rsMap = { unlabeled: 0, pending: 1, labeled: 2 }
return imgs.filter((it) => {
if (meta(it).cleanExcluded) return listTab.value === 'cleaned'
return (listTab.value === 'labeled') === !!it.labeled
if (listTab.value === 'unlabeled' && meta(it).annotateTaskId) return false
return reviewStatusOf(it) === rsMap[listTab.value]
})
})
const pagedImages = computed(() => {
const start = (page.value - 1) * pageSize.value
return visibleImages.value.slice(start, start + pageSize.value).map((item, i) => ({ item, gi: start + i }))
})
// 图片类 tab 才渲染图片工具条与卡片网格;tasks/records 为管理面板
const isImageTab = computed(() => listTab.value !== 'tasks' && listTab.value !== 'records')
const emptyTip = computed(() => {
if (!tabCounts.unlabeled && !tabCounts.labeled && !tabCounts.cleaned) return '暂无图片,请上传或 AI 生成'
if (!tabCounts.unlabeled && !tabCounts.pending && !tabCounts.labeled && !tabCounts.cleaned) return '暂无图片,请上传或 AI 生成'
if (listTab.value === 'cleaned') return '暂无已清洗图片(数据清洗排除的图在此,可勾选恢复)'
if (listTab.value === 'pending') return '暂无待审核图片(预标/众包提交的标注在此等人工审核)'
return listTab.value === 'unlabeled' ? '暂无未标注图片' : '暂无已标注图片'
})
@@ -98,7 +117,8 @@ async function loadAll() {
}
const imgs = await request.get('/datasets/images', { params: { datasetId: datasetId.value } }).catch(() => null)
imageMeta.value = {}
for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source, cleanExcluded: i.cleanExcluded || 0 }
for (const i of imgs?.list || [])
imageMeta.value[i.filename] = { id: i.id, source: i.source, cleanExcluded: i.cleanExcluded || 0, annotateTaskId: i.annotateTaskId || 0 }
// 记录刷新前编辑中的图:刷新可能使其移出当前 tab(预标完成/删除/人工保存等),
// 原 editIndex 会漂移到邻图,继续编辑保存会把框存错图——按 filename 重定位,找不到即关弹窗
const editFn = editorVisible.value && currentImage.value ? currentImage.value.filename : null
@@ -322,6 +342,164 @@ function startLabelTask() {
})
}
// 下发众包标注任务(2026-09-07 图片粒度:勾选的未标注图即任务图集,下发后从未标注 tab 消失,
// 停用任务释放未领取图回池;App 端按任务领取)
async function publishTask() {
if (!selected.value.length) return
const ids = (detail.value.images || [])
.filter((it) => selected.value.includes(it.filename))
.map((it) => meta(it).id)
.filter(Boolean)
if (!ids.length) {
ElMessage.warning('选中图片缺少 id,请刷新后重试')
return
}
try {
const { value } = await ElMessageBox.prompt(`任务名(App 端展示),下发选中的 ${ids.length} 张图片`, '下发标注任务', {
inputValue: `${dataset.value?.name || ''} 标注任务`,
inputValidator: (v) => (!!v && v.trim().length <= 50) || '1~50 字',
confirmButtonText: '下发',
cancelButtonText: '取消',
})
const res = await request.post('/annotate-tasks', { datasetId: datasetId.value, name: value.trim(), imageIds: ids })
ElMessage.success(`任务已下发(id=${res.id}),选中的 ${ids.length} 张图已从未标注 tab 移除,App 用户可领取`)
selected.value = []
await loadAll()
} catch (e) {
const msg = e?.message || e
if (msg !== 'cancel' && msg !== 'close') ElMessage.error(`任务下发失败:${msg}`)
}
}
// 众包审核(待审核 tab 勾选驱动):通过 → 已标注(入训练集);拒绝 → 清标注回未标注池
// 并计入对应众包用户低质统计(通过比例过低自动冻结)
const reviewing = ref(false)
async function reviewSelected(approve) {
if (!selected.value.length || reviewing.value) return
const ids = (detail.value.images || [])
.filter((it) => selected.value.includes(it.filename))
.map((it) => meta(it).id)
.filter(Boolean)
if (!ids.length) {
ElMessage.warning('选中图片缺少 id,请刷新后重试')
return
}
try {
await ElMessageBox.confirm(
approve
? `审核通过选中的 ${ids.length} 张图片?通过后转入已标注(纳入训练集)。`
: `拒绝选中的 ${ids.length} 张图片?将清除标注退回未标注池,并计入对应众包用户的低质统计。`,
'标注审核',
{ type: 'warning', confirmButtonText: approve ? '通过' : '拒绝', cancelButtonText: '取消' },
)
} catch {
return
}
reviewing.value = true
try {
const res = await request.post('/annotate-review', { imageIds: ids, approve })
const frozen = res?.frozenPhones || []
ElMessage.success(
`${approve ? '通过' : '拒绝'} ${res?.affected ?? ids.length}${frozen.length ? `;低质冻结用户:${frozen.join('、')}` : ''}`,
)
selected.value = []
await loadAll()
} finally {
reviewing.value = false
}
}
// ---------- 标注任务/标注记录面板(详情页 tab 内嵌,仅本数据集,切 tab 惰性加载) ----------
const anLoading = ref(false)
const anTasks = ref([])
const anTasksTotal = ref(0)
const anTaskPage = ref(1)
const anRecords = ref([])
const anRecordsTotal = ref(0)
const anRecordPage = ref(1)
const anRecordPhone = ref('')
const anRecordStatus = ref('')
const anStatusOptions = [
{ value: '', label: '全部状态' },
{ value: 'pending', label: '领取中' },
{ value: 'submitted', label: '待审核' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已拒绝' },
]
const anTagType = { pending: 'info', submitted: 'warning', approved: 'success', rejected: 'danger' }
const anTagText = { pending: '领取中', submitted: '待审核', approved: '已通过', rejected: '已拒绝' }
async function loadAnnotateTasks() {
anLoading.value = true
try {
const d = await request.get('/annotate-tasks', {
params: { datasetId: datasetId.value, page: anTaskPage.value, size: 20 },
})
anTasks.value = d.list || []
anTasksTotal.value = d.total || 0
} finally {
anLoading.value = false
}
}
async function stopAnnotateTask(row) {
try {
await ElMessageBox.confirm(`停用任务「${row.name}」?停用后 App 端不可再领取。`, '停用任务', {
type: 'warning',
confirmButtonText: '停用',
cancelButtonText: '取消',
})
} catch {
return
}
await request.post('/annotate-tasks/stop', { id: row.id })
ElMessage.success('已停用')
loadAnnotateTasks()
}
async function loadAnnotateRecords() {
anLoading.value = true
try {
const d = await request.get('/annotate-records', {
params: {
datasetId: datasetId.value,
phone: anRecordPhone.value.trim(),
status: anRecordStatus.value,
page: anRecordPage.value,
size: 20,
},
})
anRecords.value = d.list || []
anRecordsTotal.value = d.total || 0
} finally {
anLoading.value = false
}
}
async function unfreezeRecordUser() {
const target = anRecordPhone.value.trim()
if (!target) {
ElMessage.warning('请先在输入框填要解冻的手机号')
return
}
try {
await ElMessageBox.confirm(`解冻用户 ${target} 的标注资格?解冻后通过比例统计重新累计。`, '手动解冻', {
type: 'warning',
confirmButtonText: '解冻',
cancelButtonText: '取消',
})
} catch {
return
}
await request.post('/annotate-unfreeze', { phone: target })
ElMessage.success('已解冻')
}
watch(listTab, (t) => {
if (t === 'tasks' && !anTasks.value.length) loadAnnotateTasks()
if (t === 'records' && !anRecords.value.length) loadAnnotateRecords()
})
// VLM 藏匿位补检(「补标」按钮,勾选驱动批量执行):排除已确认框,按环境/光线/习性
// 找可能藏匿处,逐张追加疑似框(黄框,人工确认);依赖已确认框作为排除基准
const vlmReviewing = ref(false)
@@ -974,9 +1152,10 @@ onBeforeUnmount(() => {
<el-tag v-if="dataset" :type="trainBanner.type" size="small">{{ trainBanner.text }}</el-tag>
</div>
<!-- 图片工具未标注/已标注 tab 走勾选预标/补标/删除已标注 tab 另有数据清洗入口
<!-- 图片工具仅图片类 tab 显示标注任务/记录为管理面板无勾选操作
未标注/已标注 tab 走勾选预标/补标/删除已标注 tab 另有数据清洗入口
已清洗 tabclean_excluded=1工具条改为恢复驱动2026-09-02 -->
<div class="img-toolbar">
<div v-if="isImageTab" class="img-toolbar">
<el-button
:disabled="!pagedImages.length"
@click="toggleSelectAll"
@@ -988,17 +1167,38 @@ onBeforeUnmount(() => {
@click="openClean"
>数据清洗<span v-if="tabCounts.cleaned" class="toolbar-note">已排除 {{ tabCounts.cleaned }}</span></el-button>
<el-button
v-if="listTab !== 'cleaned'"
v-if="listTab === 'pending'"
type="success"
:disabled="reviewing || !selected.length"
@click="reviewSelected(true)"
>通过{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
v-if="listTab === 'pending'"
type="danger"
plain
:disabled="reviewing || !selected.length"
@click="reviewSelected(false)"
>拒绝{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
v-if="listTab === 'unlabeled' || listTab === 'labeled'"
:icon="MagicStick"
:disabled="labelRunning || !selected.length"
@click="startLabelTask"
>预标{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
v-if="listTab !== 'cleaned'"
v-if="listTab === 'unlabeled' || listTab === 'labeled'"
:icon="Search"
:disabled="vlmReviewing || !selected.length"
@click="vlmBatchReview"
>补标{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<!-- 下发众包任务2026-09-07 图片粒度勾选的未标注图即任务图集 -->
<el-button
v-if="listTab === 'unlabeled'"
type="warning"
plain
:disabled="!selected.length"
@click="publishTask"
>下发标注任务{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
v-if="listTab === 'cleaned'"
type="primary"
@@ -1045,13 +1245,109 @@ onBeforeUnmount(() => {
<div class="list-tabs">
<el-radio-group v-model="listTab" size="small">
<el-radio-button value="unlabeled">未标注 {{ tabCounts.unlabeled }}</el-radio-button>
<el-radio-button value="pending">待审核 {{ tabCounts.pending }}</el-radio-button>
<el-radio-button value="labeled">已标注 {{ tabCounts.labeled }}</el-radio-button>
<el-radio-button value="cleaned">已清洗 {{ tabCounts.cleaned }}</el-radio-button>
<el-radio-button value="tasks">标注任务</el-radio-button>
<el-radio-button value="records">标注记录</el-radio-button>
</el-radio-group>
</div>
<!-- 标注任务面板仅本数据集进度/停用下发入口在未标注 tab 勾选图片顶栏 -->
<div v-if="listTab === 'tasks'" v-loading="anLoading" class="rows-area">
<div class="panel-toolbar">
<span class="panel-tip">下发入口未标注tab 勾选图片后点顶栏下发标注任务</span>
<el-button @click="loadAnnotateTasks">刷新</el-button>
</div>
<el-table :data="anTasks" size="small">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="任务名" min-width="140" show-overflow-tooltip />
<el-table-column prop="poolRemain" label="池余量" width="80" />
<el-table-column prop="pending" label="领取中" width="80" />
<el-table-column prop="submitted" label="待审核" width="80" />
<el-table-column prop="approved" label="已通过" width="80" />
<el-table-column prop="rejected" label="已拒绝" width="80" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'published' ? 'success' : 'info'" size="small">
{{ row.status === 'published' ? '进行中' : '已停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="下发时间" width="170" show-overflow-tooltip />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'published'" link type="danger" @click="stopAnnotateTask(row)">停用</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
:current-page="anTaskPage"
:page-size="20"
:total="anTasksTotal"
layout="total, prev, pager, next"
@current-change="(p) => { anTaskPage = p; loadAnnotateTasks() }"
/>
</div>
</div>
<!-- 标注记录面板(仅本数据集:每用户每张图的处理明细 + 手动解冻) -->
<div v-else-if="listTab === 'records'" v-loading="anLoading" class="rows-area">
<div class="panel-toolbar">
<el-input
v-model="anRecordPhone"
placeholder="手机号精确过滤"
clearable
style="width: 180px"
@keyup.enter="() => { anRecordPage = 1; loadAnnotateRecords() }"
/>
<el-select v-model="anRecordStatus" style="width: 130px" @change="() => { anRecordPage = 1; loadAnnotateRecords() }">
<el-option v-for="o in anStatusOptions" :key="o.value" :value="o.value" :label="o.label" />
</el-select>
<el-button type="primary" @click="() => { anRecordPage = 1; loadAnnotateRecords() }">查询</el-button>
<el-button type="warning" plain @click="unfreezeRecordUser">解冻输入的手机号</el-button>
</div>
<el-table :data="anRecords" size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="phoneNum" label="手机号" width="130" />
<el-table-column label="图片" width="76">
<template #default="{ row }">
<el-image
v-if="row.imageUrl"
:src="imgUrl(row.imageUrl)"
:preview-src-list="[imgUrl(row.imageUrl)]"
preview-teleported
fit="cover"
style="width: 56px; height: 56px; border-radius: 4px"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="anTagType[row.status] || 'info'" size="small">{{ anTagText[row.status] || row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="框数" width="70">
<template #default="{ row }">{{ (row.boxes || []).length }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="领取时间" width="170" show-overflow-tooltip />
<el-table-column prop="submittedAt" label="提交时间" width="170" show-overflow-tooltip />
<el-table-column prop="reviewedAt" label="审核时间" width="170" show-overflow-tooltip />
</el-table>
<div class="pager">
<el-pagination
:current-page="anRecordPage"
:page-size="20"
:total="anRecordsTotal"
layout="total, prev, pager, next"
@current-change="(p) => { anRecordPage = p; loadAnnotateRecords() }"
/>
</div>
</div>
<!-- 图片卡片:点击缩略图直接进标注弹窗(有标注框直接显示在图上,可继续画框) -->
<div v-loading="loading" class="rows-area">
<div v-else-if="isImageTab" v-loading="loading" class="rows-area">
<template v-if="pagedImages.length">
<div class="card-grid">
<div v-for="{ item, gi } in pagedImages" :key="item.filename" class="img-card">
@@ -1301,6 +1597,17 @@ onBeforeUnmount(() => {
flex-wrap: wrap;
margin: 12px 0;
}
.panel-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.panel-tip {
font-size: 12px;
color: #909399;
}
.label-progress {
display: flex;
align-items: center;
+156
View File
@@ -42,6 +42,88 @@ const coverDeleted = ref(false) // 用户删除了已有封面(保存时调删
const trainingIds = reactive(new Set()) // 训练发起中的数据集 id(请求期间按钮置灰防重复点击)
// ---------- 负样本库(技术设计.md「负样本库」):source=negative 特殊数据集,
// 统一背景样本训练打包时混入全部物种数据集;图片无标注/审核流程,仅上传/删除 ----------
const pageTab = ref('datasets') // datasets 数据集卡片 | negatives 负样本库
const negLib = ref(null) // 负样本库数据集记录 {id, name, imageCount}
const negList = ref([]) // 图片列表 [{id, filename, url}]
const negLoading = ref(false)
const negUploading = ref(false)
const negSelected = ref(new Set()) // 勾选的图片 id
function negImageUrl(filename) {
return `${location.origin}/api/v1/admin/datasets/image?datasetId=${negLib.value.id}&filename=${encodeURIComponent(filename)}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
}
// 懒创建 + 拉图片列表(进入负样本 tab 时调用;库不存在后端自动创建)
function loadNegatives() {
negLoading.value = true
request
.post('/datasets/negative')
.then((lib) => {
negLib.value = lib
return request.get('/datasets/images', { params: { datasetId: lib.id } }).then((d) => {
negList.value = (d.list || []).map((i) => ({ id: i.id, filename: i.filename, url: negImageUrl(i.filename) }))
negSelected.value = new Set()
})
})
.catch(() => {})
.finally(() => {
negLoading.value = false
})
}
function switchTab(tab) {
pageTab.value = tab
if (tab === 'negatives' && !negLib.value) loadNegatives()
}
const fileInput = ref(null)
function pickFiles() {
fileInput.value?.click()
}
async function onFilesPicked(e) {
const files = Array.from(e.target.files || [])
e.target.value = ''
if (!files.length || !negLib.value) return
negUploading.value = true
try {
const fd = new FormData()
fd.append('datasetId', negLib.value.id)
for (const f of files) fd.append('files', f)
const res = await request.post('/datasets/upload', fd, { timeout: 300000 })
ElMessage.success(`已上传 ${res.added}${res.skipped ? `,重名跳过 ${res.skipped}` : ''}`)
loadNegatives()
} catch {
} finally {
negUploading.value = false
}
}
function removeNegSelected() {
const ids = [...negSelected.value]
if (!ids.length) return
ElMessageBox.confirm(`确定删除选中的 ${ids.length} 张负样本图片?删除后无法恢复。`, '删除负样本', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => request.post('/datasets/images/delete', { datasetId: negLib.value.id, ids }))
.then(() => {
ElMessage.success('已删除')
loadNegatives()
})
.catch(() => {})
}
function toggleNeg(id) {
const s = new Set(negSelected.value)
if (s.has(id)) s.delete(id)
else s.add(id)
negSelected.value = s
}
const statusMap = { building: '建设中', labeled: '已标注', synced: '已同步' }
const statusTag = { building: 'info', labeled: 'success', synced: 'primary' }
const trainStatusMap = { queued: '排队中', running: '训练中', success: '已训练', failed: '训练失败' }
@@ -413,6 +495,16 @@ onBeforeUnmount(() => {
<template>
<el-card shadow="never">
<!-- 训练数据双 tab2026-09-07数据集 = 物种数据集卡片负样本 = 统一背景样本库
无标注训练打包时自动混入全部数据集 -->
<div class="page-tabs">
<el-radio-group :model-value="pageTab" size="small" @update:model-value="switchTab">
<el-radio-button value="datasets">数据集</el-radio-button>
<el-radio-button value="negatives">负样本{{ negLib ? ` ${negLib.imageCount}` : '' }}</el-radio-button>
</el-radio-group>
</div>
<template v-if="pageTab === 'datasets'">
<div class="toolbar">
<div class="toolbar-left">
<el-input
@@ -509,6 +601,37 @@ onBeforeUnmount(() => {
@change="load"
/>
</div>
</template>
<!-- 负样本 tab上传/删除/网格无标注与训练流程训练打包自动混入 -->
<template v-else>
<div class="toolbar">
<div class="neg-tip">
负样本 = 不含目标动物的背景图草地/树丛/误报源如猫狗树桩等训练打包时统一混入全部物种数据集
空标签 = 背景教会模型这不是目标动物无需任何标注
</div>
<div class="toolbar-right">
<input ref="fileInput" type="file" accept=".jpg,.jpeg,.png" multiple hidden @change="onFilesPicked" />
<el-button type="primary" :icon="Plus" :loading="negUploading" @click="pickFiles">上传图片</el-button>
<el-button type="danger" :disabled="!negSelected.size" @click="removeNegSelected">
删除选中{{ negSelected.size ? ` ${negSelected.size} ` : '' }}
</el-button>
<el-button :loading="negLoading" @click="loadNegatives">刷新</el-button>
</div>
</div>
<div v-loading="negLoading" class="neg-grid">
<div
v-for="img in negList"
:key="img.id"
class="neg-cell"
:class="{ 'is-sel': negSelected.has(img.id) }"
@click="toggleNeg(img.id)"
>
<img :src="img.url" :alt="img.filename" loading="lazy" />
</div>
<el-empty v-if="!negLoading && !negList.length" description="暂无负样本,点击右上角上传" />
</div>
</template>
<!-- 发起训练档位选择默认双档 s+n训练机 GPU 独占串行有任务时新任务排队 queued 逐个执行 -->
<el-dialog v-model="startVisible" title="发起训练" width="min(440px, 94vw)">
@@ -611,6 +734,39 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.page-tabs {
margin-bottom: 14px;
}
.neg-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
flex: 1;
min-width: 0;
}
.neg-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
min-height: 120px;
}
.neg-cell {
aspect-ratio: 4 / 3;
border: 2px solid transparent;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
background: #f5f7fa;
}
.neg-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.neg-cell.is-sel {
border-color: var(--el-color-primary);
}
.toolbar {
display: flex;
justify-content: space-between;