1
This commit is contained in:
@@ -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 另有数据清洗入口;
|
||||
已清洗 tab(clean_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;
|
||||
|
||||
@@ -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">
|
||||
<!-- 训练数据双 tab(2026-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;
|
||||
|
||||
Reference in New Issue
Block a user