1
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Minus, MagicStick, Search } from '@element-plus/icons-vue'
|
||||
@@ -12,14 +12,34 @@ const dataset = ref(null)
|
||||
|
||||
// ---------- 数据 ----------
|
||||
const detail = ref(null) // 工作台数据:images[{filename,url,width,height,boxes,labeled}]
|
||||
const imageMeta = ref({}) // filename -> {id, source}(来自 /datasets/images,行内展示用)
|
||||
const imageMeta = ref({}) // filename -> {id, source, cleanExcluded}(来自 /datasets/images,行内展示用)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 18
|
||||
// 列表 tab:未标注(默认,待处理优先) | 已标注 | 已清洗(clean_excluded=1,数据清洗排除,可恢复)——
|
||||
// 卡片上不再叠「已标注/未标注」角标,分类由 tab 表达(2026-09-02)
|
||||
const listTab = ref('unlabeled')
|
||||
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 visibleImages = computed(() => {
|
||||
const imgs = detail.value?.images || []
|
||||
return imgs.filter((it) => {
|
||||
if (meta(it).cleanExcluded) return listTab.value === 'cleaned'
|
||||
return (listTab.value === 'labeled') === !!it.labeled
|
||||
})
|
||||
})
|
||||
const pagedImages = computed(() => {
|
||||
if (!detail.value || !detail.value.images) return []
|
||||
const start = (page.value - 1) * pageSize
|
||||
return detail.value.images.slice(start, start + pageSize).map((item, i) => ({ item, gi: start + i }))
|
||||
return visibleImages.value.slice(start, start + pageSize).map((item, i) => ({ item, gi: start + i }))
|
||||
})
|
||||
const emptyTip = computed(() => {
|
||||
if (!tabCounts.unlabeled && !tabCounts.labeled && !tabCounts.cleaned) return '暂无图片,请上传或 AI 生成'
|
||||
if (listTab.value === 'cleaned') return '暂无已清洗图片(数据清洗排除的图在此,可勾选恢复)'
|
||||
return listTab.value === 'unlabeled' ? '暂无未标注图片' : '暂无已标注图片'
|
||||
})
|
||||
|
||||
function meta(item) {
|
||||
@@ -45,9 +65,18 @@ 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 }
|
||||
for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source, cleanExcluded: i.cleanExcluded || 0 }
|
||||
// 记录刷新前编辑中的图:刷新可能使其移出当前 tab(预标完成/删除/人工保存等),
|
||||
// 原 editIndex 会漂移到邻图,继续编辑保存会把框存错图——按 filename 重定位,找不到即关弹窗
|
||||
const editFn = editorVisible.value && currentImage.value ? currentImage.value.filename : null
|
||||
detail.value = wb || { images: [] }
|
||||
if (editIndex.value >= (detail.value.images || []).length) editIndex.value = 0
|
||||
if (editFn) {
|
||||
const idx = visibleImages.value.findIndex((it) => it.filename === editFn)
|
||||
if (idx >= 0) editIndex.value = idx
|
||||
else editorVisible.value = false
|
||||
} else if (editIndex.value >= visibleImages.value.length) {
|
||||
editIndex.value = 0
|
||||
}
|
||||
// 刷新时恢复进行中的自动标注任务
|
||||
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')
|
||||
@@ -60,6 +89,9 @@ async function loadAll() {
|
||||
} else if (gt && gt.error) {
|
||||
genTask.value = gt
|
||||
}
|
||||
} catch (e) {
|
||||
// 任一请求失败会中断本轮刷新(列表/计数停留旧值),明示原因避免误以为操作未生效
|
||||
ElMessage.error(`刷新失败:${e?.message || e}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -168,6 +200,22 @@ function stopLabelPoll() {
|
||||
// 勾选标注:重扫勾选图片,覆盖各图已有标注(含人工修改/清理的框)。
|
||||
// 标注入口仅此一处(2026-09-02 决策):无单图/全量按钮,勾选后点顶栏「标注 N 张」。
|
||||
const selected = ref([])
|
||||
// 翻页/切 tab 即清空勾选:预标/补标按选中集执行,跨页/跨 tab 残留会导致误标其他分类图片(2026-09-02)
|
||||
watch(page, () => {
|
||||
selected.value = []
|
||||
})
|
||||
watch(listTab, () => {
|
||||
page.value = 1
|
||||
selected.value = []
|
||||
})
|
||||
// 当前 tab 列表缩水(删除/标注状态变更后重载)导致页码越界时回钳
|
||||
watch(
|
||||
() => visibleImages.value.length,
|
||||
() => {
|
||||
const max = Math.max(1, Math.ceil(visibleImages.value.length / pageSize))
|
||||
if (page.value > max) page.value = max
|
||||
},
|
||||
)
|
||||
// 单选框:受控模式(el-checkbox 单独用 v-model 绑数组会在勾选时把数组替换成布尔值,故手动维护)
|
||||
function toggleOne(filename, checked) {
|
||||
const s = selected.value
|
||||
@@ -204,6 +252,7 @@ function startLabelTask() {
|
||||
.then(() => request.post('/label-tasks', { datasetId: datasetId.value, filenames: selected.value }))
|
||||
.then((data) => {
|
||||
ElMessage.success('已发起预标,进度见页顶进度条')
|
||||
selected.value = [] // 任务异步跑完才迁移分类,勾选使命已结束,立即清空防「幽灵勾选」残留计数
|
||||
pollLabelTask(data.id)
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -242,6 +291,7 @@ async function vlmBatchReview() {
|
||||
ElMessage.info(note || 'VLM 未发现可能的藏匿位')
|
||||
}
|
||||
await loadAll()
|
||||
selected.value = [] // 补标完成即清勾选:这批已扫描过,避免按钮残留计数重复补检
|
||||
// 标注弹窗开着时刷新当前图的标注
|
||||
if (editorVisible.value) nextTick(resetEditor)
|
||||
} catch (e) {
|
||||
@@ -273,10 +323,125 @@ function deleteSelected() {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 恢复勾选的已清洗图片(清 clean_excluded 标记,回训练集;「已清洗」tab 专用动作)
|
||||
function restoreSelected() {
|
||||
if (!selected.value.length) return
|
||||
const images = (detail.value.images || []).filter((it) => selected.value.includes(it.filename))
|
||||
const ids = images.map((it) => meta(it).id).filter(Boolean)
|
||||
if (!ids.length) return
|
||||
ElMessageBox.confirm(`确定恢复选中的 ${ids.length} 张图片?恢复后重新计入训练集打包(图片/标注不动)。`, '恢复图片', {
|
||||
confirmButtonText: '恢复',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => request.post('/datasets/clean/apply', { datasetId: datasetId.value, imageIds: ids, exclude: false }))
|
||||
.then((d) => {
|
||||
ElMessage.success(`已恢复 ${d.applied} 张`)
|
||||
selected.value = []
|
||||
loadAll()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// ---------- 数据清洗(训练集去冗余:按目标尺寸档配额 + 桶内 dHash 多样性保留出候选,
|
||||
// 排除仅打标记不删文件,被排除图集中在「已清洗」tab,见技术设计.md「数据清洗」) ----------
|
||||
const cleanVisible = ref(false)
|
||||
const cleanLoading = ref(false)
|
||||
const cleanApplying = ref(false)
|
||||
const cleanBuckets = ref([]) // 档分布[{label,exempt,total,excluded,quota,over}]
|
||||
const cleanCand = ref([]) // 超配桶候选清单
|
||||
const cleanTotal = ref(0)
|
||||
const cleanExcludedCount = ref(0)
|
||||
const cleanQuotas = ref([]) // 配额输入(与档位一一对应;豁免档禁用)
|
||||
const quotaTouched = ref(false)
|
||||
const cleanSel = ref([]) // 勾选的候选 imageId(默认全选)
|
||||
const candAllChecked = computed(
|
||||
() => cleanCand.value.length > 0 && cleanCand.value.every((c) => cleanSel.value.includes(c.imageId)),
|
||||
)
|
||||
|
||||
async function loadCleanPreview() {
|
||||
cleanLoading.value = true
|
||||
try {
|
||||
const body =
|
||||
cleanQuotas.value.length === 8 ? { datasetId: datasetId.value, quotas: cleanQuotas.value } : { datasetId: datasetId.value }
|
||||
const d = await request.post('/datasets/clean/preview', body)
|
||||
cleanBuckets.value = d.buckets || []
|
||||
cleanCand.value = d.candidates || []
|
||||
cleanTotal.value = d.total || 0
|
||||
cleanExcludedCount.value = d.excluded || 0
|
||||
// 首次打开(用户未动过配额)用后端默认回填输入框
|
||||
if (!quotaTouched.value && cleanQuotas.value.length !== 8) {
|
||||
cleanQuotas.value = cleanBuckets.value.map((b) => b.quota)
|
||||
}
|
||||
cleanSel.value = cleanCand.value.map((c) => c.imageId) // 候选默认全选
|
||||
} finally {
|
||||
cleanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 配额改动防抖重算候选(输入数字即实时预览)
|
||||
let cleanTimer = null
|
||||
function onQuotaChange() {
|
||||
quotaTouched.value = true
|
||||
if (cleanTimer) clearTimeout(cleanTimer)
|
||||
cleanTimer = setTimeout(loadCleanPreview, 350)
|
||||
}
|
||||
|
||||
function openClean() {
|
||||
cleanVisible.value = true
|
||||
cleanQuotas.value = []
|
||||
quotaTouched.value = false
|
||||
loadCleanPreview()
|
||||
}
|
||||
|
||||
function toggleCleanAll() {
|
||||
if (candAllChecked.value) cleanSel.value = []
|
||||
else cleanSel.value = cleanCand.value.map((c) => c.imageId)
|
||||
}
|
||||
|
||||
// 候选单选框(受控模式同 toggleOne)
|
||||
function toggleCleanOne(id, checked) {
|
||||
const s = cleanSel.value
|
||||
if (checked) {
|
||||
if (!s.includes(id)) s.push(id)
|
||||
} else {
|
||||
const i = s.indexOf(id)
|
||||
if (i >= 0) s.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanThumbUrl(c) {
|
||||
return imgUrl(`/api/v1/admin/datasets/image?datasetId=${datasetId.value}&filename=${encodeURIComponent(c.filename)}`)
|
||||
}
|
||||
|
||||
function applyClean() {
|
||||
if (!cleanSel.value.length) return
|
||||
ElMessageBox.confirm(
|
||||
`将排除选中的 ${cleanSel.value.length} 张图片(仅移出训练集打包,不删文件不删标注;被排除图在「已清洗」tab,可随时恢复)。确定排除?`,
|
||||
'数据清洗',
|
||||
{ confirmButtonText: '排除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
.then(() => {
|
||||
cleanApplying.value = true
|
||||
return request.post('/datasets/clean/apply', { datasetId: datasetId.value, imageIds: cleanSel.value, exclude: true })
|
||||
})
|
||||
.then((d) => {
|
||||
ElMessage.success(`已排除 ${d.applied} 张,可在「已清洗」tab 查看/恢复`)
|
||||
cleanVisible.value = false
|
||||
selected.value = []
|
||||
loadAll()
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
cleanApplying.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 标注编辑弹窗(canvas 画框) ----------
|
||||
const editorVisible = ref(false)
|
||||
const editIndex = ref(0)
|
||||
const currentImage = computed(() => (detail.value && detail.value.images ? detail.value.images[editIndex.value] : null))
|
||||
// 编辑器导航基于当前 tab 可见列表(visibleImages):tab 内连续标注不串到另一分类
|
||||
const currentImage = computed(() => visibleImages.value[editIndex.value] || null)
|
||||
const canvasEl = ref(null)
|
||||
const canvasWrap = ref(null)
|
||||
const imgEl = ref(null)
|
||||
@@ -381,6 +546,7 @@ async function switchEdit(idx) {
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
if (!editorVisible.value) return // 刷新类回调可能已在 loadAll 内关弹窗,避免对空画布操作
|
||||
stopHighlight()
|
||||
dirty.value = false
|
||||
confirmed.value = []
|
||||
@@ -688,16 +854,22 @@ function clearAllBoxes() {
|
||||
async function saveCurrent() {
|
||||
if (!currentImage.value) return
|
||||
saving.value = true
|
||||
const fn = currentImage.value.filename
|
||||
try {
|
||||
const data = await request.post('/label-tasks/save', {
|
||||
datasetId: datasetId.value,
|
||||
filename: currentImage.value.filename,
|
||||
filename: fn,
|
||||
boxes: confirmed.value,
|
||||
})
|
||||
ElMessage.success(`已保存(该数据集已标注 ${data.labeledCount} 张)`)
|
||||
dirty.value = false
|
||||
currentImage.value.boxes = confirmed.value.map((b) => ({ ...b }))
|
||||
currentImage.value.labeled = confirmed.value.length > 0
|
||||
// 保存翻转 labeled 后该行会移出当前 tab(未标注→已标注等),visibleImages 收缩使
|
||||
// currentImage 静默漂移到邻图,继续编辑再保存会把框存到别的图——检测到漂移立即关弹窗
|
||||
if (!currentImage.value || currentImage.value.filename !== fn) {
|
||||
editorVisible.value = false
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -747,7 +919,8 @@ onBeforeUnmount(() => {
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 图片工具 -->
|
||||
<!-- 图片工具:未标注/已标注 tab 走勾选预标/补标/删除;已标注 tab 另有数据清洗入口;
|
||||
已清洗 tab(clean_excluded=1)工具条改为恢复驱动(2026-09-02) -->
|
||||
<div class="img-toolbar">
|
||||
<el-button
|
||||
:disabled="!pagedImages.length"
|
||||
@@ -755,15 +928,28 @@ onBeforeUnmount(() => {
|
||||
>{{ allSelected ? '取消全选' : '全选' }}</el-button>
|
||||
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
|
||||
<el-button
|
||||
v-if="listTab === 'labeled'"
|
||||
:disabled="cleanLoading"
|
||||
@click="openClean"
|
||||
>数据清洗<span v-if="tabCounts.cleaned" class="toolbar-note">(已排除 {{ tabCounts.cleaned }})</span></el-button>
|
||||
<el-button
|
||||
v-if="listTab !== 'cleaned'"
|
||||
:icon="MagicStick"
|
||||
:disabled="labelRunning || !selected.length"
|
||||
@click="startLabelTask"
|
||||
>预标{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
v-if="listTab !== 'cleaned'"
|
||||
:icon="Search"
|
||||
:disabled="vlmReviewing || !selected.length"
|
||||
@click="vlmBatchReview"
|
||||
>补标{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
v-if="listTab === 'cleaned'"
|
||||
type="primary"
|
||||
:disabled="!selected.length"
|
||||
@click="restoreSelected"
|
||||
>恢复选中{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="!selected.length"
|
||||
@@ -799,9 +985,19 @@ onBeforeUnmount(() => {
|
||||
AI 生成未完成:{{ genTask.error }}
|
||||
</div>
|
||||
|
||||
<!-- 图片列表 tab:未标注(默认) | 已标注 | 已清洗(clean_excluded=1,数据清洗排除,勾选可恢复);
|
||||
标注状态由 tab 表达,卡片上不再叠角标(2026-09-02) -->
|
||||
<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="labeled">已标注 {{ tabCounts.labeled }}</el-radio-button>
|
||||
<el-radio-button value="cleaned">已清洗 {{ tabCounts.cleaned }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 图片卡片:点击缩略图直接进标注弹窗(有标注框直接显示在图上,可继续画框) -->
|
||||
<div v-loading="loading" class="rows-area">
|
||||
<template v-if="detail && detail.images.length">
|
||||
<template v-if="pagedImages.length">
|
||||
<div class="card-grid">
|
||||
<div v-for="{ item, gi } in pagedImages" :key="item.filename" class="img-card">
|
||||
<div class="card-img" @click="openEditor(item, gi)">
|
||||
@@ -813,7 +1009,6 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<img :src="imgUrl(item.url)" :alt="item.filename" loading="lazy" />
|
||||
<span v-if="meta(item).source === 'ai'" class="badge source-badge">AI</span>
|
||||
<span class="badge" :class="item.labeled ? 'ok' : ''">{{ item.labeled ? '已标注' : '未标注' }}</span>
|
||||
</div>
|
||||
<div class="card-name" :title="item.filename">{{ item.filename }}</div>
|
||||
</div>
|
||||
@@ -822,13 +1017,13 @@ onBeforeUnmount(() => {
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="detail.images.length"
|
||||
:total="visibleImages.length"
|
||||
layout="prev, pager, next, total"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!loading" description="暂无图片,请上传或 AI 生成" />
|
||||
<el-empty v-else-if="!loading" :description="emptyTip" />
|
||||
</div>
|
||||
|
||||
<!-- 添加图片:AI 生成 -->
|
||||
@@ -854,6 +1049,74 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 数据清洗(训练集去冗余):已标注 tab 工具条入口;候选默认全选,执行 = 打排除标记(不删文件) -->
|
||||
<el-dialog v-model="cleanVisible" title="数据清洗" width="min(980px, 96vw)" append-to-body :close-on-click-modal="false">
|
||||
<div v-loading="cleanLoading">
|
||||
<div class="clean-tip">
|
||||
按标注目标占画面高度分档统计,超配档内整图 dHash 贪心保留多样性(同场景连拍帧只留 1 张),其余进候选;
|
||||
调整配额即实时重算。<b>排除 ≠ 删除</b>:仅移出训练集打包(图片/标注不动),被排除图集中到「已清洗」tab 可随时恢复。
|
||||
</div>
|
||||
<div class="clean-bucket-head clean-bucket-row">
|
||||
<span class="cb-label">目标尺寸档</span>
|
||||
<span class="cb-num">现有</span>
|
||||
<span class="cb-num">已排除</span>
|
||||
<span class="cb-num cb-quota">保留配额</span>
|
||||
<span class="cb-num">超配可排</span>
|
||||
</div>
|
||||
<div v-for="(b, i) in cleanBuckets" :key="b.label" class="clean-bucket-row">
|
||||
<span class="cb-label">
|
||||
{{ b.label }}
|
||||
<el-tag v-if="b.exempt" size="small" type="info" effect="plain">极远档豁免</el-tag>
|
||||
</span>
|
||||
<span class="cb-num">{{ b.total }}</span>
|
||||
<span class="cb-num">{{ b.excluded }}</span>
|
||||
<span class="cb-num cb-quota">
|
||||
<el-input-number
|
||||
v-if="!b.exempt"
|
||||
v-model="cleanQuotas[i]"
|
||||
:min="0"
|
||||
:max="5000"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
@change="onQuotaChange"
|
||||
/>
|
||||
<span v-else class="cb-exempt">不适用</span>
|
||||
</span>
|
||||
<span class="cb-num" :class="{ 'cb-over': b.over > 0 }">{{ b.over || 0 }}</span>
|
||||
</div>
|
||||
<div class="clean-summary">参与统计 {{ cleanTotal }} 张 · 已排除 {{ cleanExcludedCount }} 张(见「已清洗」tab)</div>
|
||||
|
||||
<div v-if="cleanCand.length" class="clean-cand-head">
|
||||
<span class="clean-cand-title">建议排除候选({{ cleanCand.length }} 张,默认全选)</span>
|
||||
<el-button link type="primary" size="small" @click="toggleCleanAll">{{ candAllChecked ? '取消全选' : '全选' }}</el-button>
|
||||
</div>
|
||||
<div v-else class="clean-cand-empty">当前配额下无超配候选,无需排除(<2% 极远档固定豁免)</div>
|
||||
<div class="clean-cand-list">
|
||||
<label v-for="c in cleanCand" :key="c.imageId" class="clean-cand">
|
||||
<el-checkbox
|
||||
:model-value="cleanSel.includes(c.imageId)"
|
||||
@click.stop
|
||||
@change="(v) => toggleCleanOne(c.imageId, v)"
|
||||
/>
|
||||
<img class="clean-thumb" :src="cleanThumbUrl(c)" :alt="c.filename" loading="lazy" />
|
||||
<span class="clean-cand-name" :title="c.filename">{{ c.filename }}</span>
|
||||
<span class="clean-cand-meta">
|
||||
<span v-if="c.source === 'ai'" class="badge-mini">AI</span>
|
||||
<span class="clean-pct" :title="'主目标 ' + c.boxHeightPct + '%'">主 {{ c.boxHeightPct }}%
|
||||
<template v-if="c.targetCount > 1"> · 最小 {{ c.minHeightPct }}% · {{ c.targetCount }} 目标</template>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :disabled="cleanLoading" @click="cleanVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="cleanApplying" :disabled="!cleanSel.length" @click="applyClean">
|
||||
排除选中{{ cleanSel.length ? ` ${cleanSel.length} 张` : '' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图片标注(全屏查看 + 标注编辑):已标注图直接在其上显示框,可确认疑似/删除误检,可手动画框补标 -->
|
||||
<el-dialog
|
||||
v-model="editorVisible"
|
||||
@@ -906,8 +1169,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="nav-bar">
|
||||
<el-button size="small" :disabled="editIndex <= 0" @click="switchEdit(editIndex - 1)">上一张</el-button>
|
||||
<span class="nav-info">{{ editIndex + 1 }}/{{ detail?.images.length }}</span>
|
||||
<el-button size="small" :disabled="editIndex >= detail?.images.length - 1" @click="switchEdit(editIndex + 1)">下一张</el-button>
|
||||
<span class="nav-info">{{ editIndex + 1 }}/{{ visibleImages.length }}</span>
|
||||
<el-button size="small" :disabled="editIndex >= visibleImages.length - 1" @click="switchEdit(editIndex + 1)">下一张</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" :loading="saving" @click="confirmEditor">确定</el-button>
|
||||
@@ -1055,6 +1318,9 @@ onBeforeUnmount(() => {
|
||||
.rows-area {
|
||||
min-height: 200px;
|
||||
}
|
||||
.list-tabs {
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
@@ -1105,9 +1371,6 @@ onBeforeUnmount(() => {
|
||||
right: auto;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.badge.ok {
|
||||
background: rgba(103, 194, 58, 0.9);
|
||||
}
|
||||
.card-name {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
@@ -1123,6 +1386,132 @@ onBeforeUnmount(() => {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* 数据清洗:配额表 + 候选清单(2026-09-02) */
|
||||
.toolbar-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.clean-tip {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #f4f4f5;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.clean-tip b {
|
||||
color: #303133;
|
||||
}
|
||||
.clean-bucket-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.clean-bucket-head {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.cb-label {
|
||||
flex: 0 0 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #303133;
|
||||
}
|
||||
.cb-num {
|
||||
flex: 0 0 110px;
|
||||
color: #606266;
|
||||
}
|
||||
.cb-quota {
|
||||
flex: 0 0 170px;
|
||||
}
|
||||
.cb-exempt {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.cb-over {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clean-summary {
|
||||
margin: 8px 8px 14px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.clean-cand-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
.clean-cand-title {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clean-cand-empty {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.clean-cand-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.clean-cand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.clean-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clean-cand-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.clean-cand-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.badge-mini {
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: rgba(64, 158, 255, 0.85);
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.clean-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 标注编辑(全屏弹窗:画布区占满剩余高度,画布等比例居中,可放大缩小) */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user