Files
observer/server_admin/src/views/DatasetDetail.vue
T
2026-08-29 01:11:01 +08:00

988 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Minus, MagicStick } from '@element-plus/icons-vue'
import request from '../api/request'
const route = useRoute()
const router = useRouter()
const datasetId = computed(() => Number(route.params.id))
const dataset = ref(null)
// ---------- 数据 ----------
const detail = ref(null) // 工作台数据:images[{filename,url,width,height,boxes,labeled}]
const imageMeta = ref({}) // filename -> {id, source, prompt}(来自 /datasets/images,行内展示用)
const loading = ref(false)
const page = ref(1)
const pageSize = 18
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 }))
})
function meta(item) {
return imageMeta.value[item.filename] || {}
}
function imgUrl(u) {
return `${location.origin}${u}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
}
async function loadAll() {
loading.value = true
try {
const [d, wb] = await Promise.all([
request.get('/datasets', { params: { page: 1, size: 100 } }),
request.get('/label-workbench', { params: { datasetId: datasetId.value } }).catch(() => null),
])
dataset.value = (d.list || []).find((x) => x.id === datasetId.value) || null
if (!dataset.value) {
ElMessage.error('数据集不存在')
router.push('/datasets')
return
}
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, prompt: i.prompt }
detail.value = wb || { images: [] }
if (editIndex.value >= (detail.value.images || []).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')
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
}
}
// ---------- 添加图片(AI 生成) ----------
const addVisible = ref(false)
// 单物种规则:物种固定取数据集名,表单无物种输入
const genForm = reactive({ count: 1, size: '704x1248', distance: 25 })
const generating = ref(false)
function openAdd() {
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,
count: genForm.count,
size: genForm.size,
distance: genForm.distance,
})
.then((data) => {
addVisible.value = false
genTask.value = { id: data.taskId, status: 'running', total: data.total, done: 0, error: '' }
ElMessage.success(`已提交生成任务(共 ${data.total} 张,约 40 秒/张),进度见页顶进度条`)
pollGenTask()
})
.catch(() => {})
.finally(() => {
generating.value = false
})
}
// ---------- 自动标注(图片入库自动触发,此处仅展示任务进度) ----------
const labelTask = ref(null)
const labelPoll = ref(null)
const labelRunning = computed(() => labelTask.value && labelTask.value.status === 'running')
function pollLabelTask(id) {
stopLabelPoll()
labelTask.value = { id, status: 'running', total: 0, done: 0, error: '' }
labelPoll.value = setInterval(async () => {
const d = await request.get('/label-tasks/detail', { params: { id } }).catch(() => null)
if (!d) return
labelTask.value = { id, status: d.status, total: d.total, done: d.done, error: d.error }
if (d.status === 'done') {
stopLabelPoll()
if (d.error) {
ElMessage.error(`自动标注未完成:${d.error}`)
} else {
ElMessage.success(`自动标注完成(${d.done}/${d.total} 张),标注已更新`)
}
loadAll().then(() => {
// 标注弹窗开着时刷新当前图的标注
if (editorVisible.value) nextTick(resetEditor)
})
}
}, 3000)
}
function stopLabelPoll() {
if (labelPoll.value) {
clearInterval(labelPoll.value)
labelPoll.value = null
}
}
// 全量标注:重扫数据集全部图片,覆盖各图已有标注(含人工修改/清理的框)
function startFullLabelTask() {
ElMessageBox.confirm('将重新扫描数据集全部图片并生成标注,覆盖各图已有标注(含人工确认/清理的结果)。确定继续?', '全量标注', {
confirmButtonText: '开始标注',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => request.post('/label-tasks', { datasetId: datasetId.value }))
.then((data) => {
ElMessage.success('已发起全量标注,进度见页顶进度条')
pollLabelTask(data.id)
})
.catch(() => {})
}
// VLM 藏匿位补检(两阶段标注第二阶段):排除已确认框,按环境/光线/习性找可能藏匿处,追加疑似框
const vlmReviewing = ref(false)
async function vlmReview() {
if (!currentImage.value) return
const m = meta(currentImage.value)
if (!m.id) return
vlmReviewing.value = true
try {
const d = await request.post('/datasets/images/vlm-review', { datasetId: datasetId.value, imageId: m.id }, { timeout: 300000 })
if (d.added > 0) {
ElMessage.success(`VLM 补检新增 ${d.added} 个疑似框(黄框,请人工确认)`)
} else {
ElMessage.info(d.note || 'VLM 未发现可能的藏匿位')
}
dirty.value = false
await loadAll()
nextTick(resetEditor)
} finally {
vlmReviewing.value = false
}
}
// 删除单张图片(标注随图片删除,后端级联)
function removeCardImage(item) {
const m = meta(item)
const tip = m.source === 'ai' ? '该图为 AI 生成的付费资产,删除后无法恢复。' : ''
ElMessageBox.confirm(
`确定删除图片「${item.filename}」?其标注将一并删除。${tip}`,
'删除图片',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
)
.then(() => request.post('/datasets/images/delete', { datasetId: datasetId.value, ids: [m.id] }))
.then(() => {
ElMessage.success('已删除')
loadAll()
})
.catch(() => {})
}
// ---------- 标注编辑弹窗(canvas 画框) ----------
const editorVisible = ref(false)
const editIndex = ref(0)
const currentImage = computed(() => (detail.value && detail.value.images ? detail.value.images[editIndex.value] : null))
const canvasEl = ref(null)
const canvasWrap = ref(null)
const imgEl = ref(null)
const naturalW = ref(0)
const naturalH = ref(0)
const confirmed = ref([])
const dirty = ref(false)
const pointerDownPos = ref(null)
const saving = ref(false)
// class 0 = 确认(红框),class 1 = 疑似(黄框)
const classColor = ['#f56c6c', '#e6a23c']
const classLabel = ['确认', '疑似']
const confirmCount = computed(() => confirmed.value.filter((b) => b.class === 0).length)
const suspectCount = computed(() => confirmed.value.filter((b) => b.class === 1).length)
// 标注列表高亮闪烁(点击列表项在画布上定位对应框)
const highlightIndex = ref(-1)
const highlightOn = ref(false)
let highlightTimer = null
function stopHighlight() {
if (highlightTimer) {
clearInterval(highlightTimer)
highlightTimer = null
}
highlightIndex.value = -1
}
function toggleHighlight(i) {
if (i === highlightIndex.value) return
stopHighlight()
highlightIndex.value = i
highlightOn.value = true
highlightTimer = setInterval(() => {
highlightOn.value = !highlightOn.value
draw()
}, 350)
draw()
}
function removeBox(i) {
confirmed.value.splice(i, 1)
markDirty()
if (highlightIndex.value === i) stopHighlight()
else if (highlightIndex.value > i) highlightIndex.value--
draw()
}
// 疑似框确认(class1 → class0):VLM 补检产出的疑似框由人工确认
function confirmBox(i) {
const b = confirmed.value[i]
if (!b || b.class !== 1) return
b.class = 0
markDirty()
draw()
}
function openEditor(item, gi) {
editIndex.value = gi
editorVisible.value = true
nextTick(resetEditor)
}
function onEditorBeforeClose(done) {
if (!dirty.value) {
done()
return
}
ElMessageBox.confirm('当前图片有未保存的修改,确定放弃?', '未保存修改', {
confirmButtonText: '放弃修改',
cancelButtonText: '继续编辑',
type: 'warning',
})
.then(() => done())
.catch(() => {})
}
async function switchEdit(idx) {
if (idx === editIndex.value || !detail.value) return
if (dirty.value) {
try {
await ElMessageBox.confirm('当前图片有未保存的修改,确定放弃并切换?', '未保存修改', {
confirmButtonText: '放弃修改',
cancelButtonText: '留在本张',
type: 'warning',
})
} catch {
return
}
}
editIndex.value = idx
page.value = Math.floor(idx / pageSize) + 1
nextTick(resetEditor)
}
function resetEditor() {
stopHighlight()
dirty.value = false
confirmed.value = []
if (currentImage.value) {
confirmed.value = (currentImage.value.boxes || []).map((b) => ({ ...b }))
}
nextTick(draw)
}
async function onImageLoad() {
const img = imgEl.value
if (!img) return
naturalW.value = img.naturalWidth
naturalH.value = img.naturalHeight
await nextTick()
fitZoom()
draw()
}
// 显示缩放:初始自动适应弹窗(等比例 fit),可放大缩小
const zoom = ref(1)
const zoomPercent = computed(() => `${Math.round(zoom.value * 100)}%`)
function fitZoom() {
const wrap = canvasWrap.value
if (!wrap || !naturalW.value || !naturalH.value) return
const f = Math.min(wrap.clientWidth / naturalW.value, wrap.clientHeight / naturalH.value)
zoom.value = f > 0 ? f : 1
}
function zoomIn() {
// 上限 4 倍:画布按原图分辨率等比放大,4x 约 151MB 已是内存安全边界
zoom.value = Math.min(4, zoom.value * 1.2)
draw()
}
function zoomOut() {
zoom.value = Math.max(0.05, zoom.value / 1.2)
draw()
}
// Ctrl/⌘+滚轮缩放;普通滚轮留给画布区滚动
function onWheel(e) {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
const f = e.deltaY < 0 ? 1.1 : 1 / 1.1
zoom.value = Math.min(4, Math.max(0.05, zoom.value * f))
draw()
}
function resizeCanvas() {
const c = canvasEl.value
if (!c) return
c.width = Math.round(naturalW.value * zoom.value)
c.height = Math.round(naturalH.value * zoom.value)
}
function normToPx(box) {
const c = canvasEl.value
return {
x: box.cx * c.width - (box.w * c.width) / 2,
y: box.cy * c.height - (box.h * c.height) / 2,
w: box.w * c.width,
h: box.h * c.height,
}
}
function draw() {
const c = canvasEl.value
if (!c || !currentImage.value) return
const ctx = c.getContext('2d')
resizeCanvas()
ctx.clearRect(0, 0, c.width, c.height)
// 放大(>1x)时最近邻采样保留原文件像素,平滑插值会模糊成"缩略图"观感;缩小才平滑
ctx.imageSmoothingEnabled = zoom.value <= 1
ctx.drawImage(imgEl.value, 0, 0, c.width, c.height)
for (let i = 0; i < confirmed.value.length; i++) {
const b = confirmed.value[i]
drawBox(ctx, b, false, i === highlightIndex.value && highlightOn.value)
}
}
function drawBox(ctx, box, ghost = false, highlighted = false) {
const color = classColor[box.class] || '#409eff'
const p = normToPx(box)
ctx.save()
ctx.strokeStyle = color
ctx.lineWidth = highlighted ? 4.5 : 2.5
ctx.globalAlpha = ghost ? 0.6 : 1
ctx.fillStyle = color
ctx.globalAlpha = 0.14 * (ghost ? 0.5 : 1)
ctx.fillRect(p.x, p.y, p.w, p.h)
ctx.globalAlpha = 1
ctx.strokeRect(p.x, p.y, p.w, p.h)
// 高亮闪烁:外圈白色描边增强定位效果
if (highlighted) {
ctx.lineWidth = 2
ctx.strokeStyle = '#fff'
ctx.strokeRect(p.x - 2.5, p.y - 2.5, p.w + 5, p.h + 5)
}
// AI 框带置信度(<1 时显示),人工框(confidence=1)不显示
const conf = box.confidence && box.confidence < 1 ? ' ' + box.confidence.toFixed(2) : ''
const text = `${classLabel[box.class] || box.class}${conf}`
ctx.font = '11px sans-serif'
ctx.fillStyle = color
ctx.globalAlpha = 0.9
ctx.fillText(text, p.x, p.y - 3)
ctx.restore()
}
function eventPos(e) {
const rect = canvasEl.value.getBoundingClientRect()
const px = (e.clientX - rect.left) / rect.width
const py = (e.clientY - rect.top) / rect.height
return {
x: Math.min(1, Math.max(0, px)),
y: Math.min(1, Math.max(0, py)),
}
}
function onPointerDown(e) {
e.preventDefault()
const pos = eventPos(e)
pointerDownPos.value = { ...pos }
canvasEl.value.setPointerCapture(e.pointerId)
}
// 点击框选中(审核模式:不做手动画框,疑似框由 VLM 补检产出,人工仅确认/删除)
function onPointerUp(e) {
if (!pointerDownPos.value) return
const pos = eventPos(e)
const moved = Math.abs(pos.x - pointerDownPos.value.x) + Math.abs(pos.y - pointerDownPos.value.y)
pointerDownPos.value = null
if (moved < 0.03) {
const idx = confirmed.value.findIndex((b) => pointInBox(pos, b))
if (idx >= 0) toggleHighlight(idx)
else stopHighlight()
}
draw()
}
function pointInBox(pos, box) {
const x1 = box.cx - box.w / 2
const x2 = box.cx + box.w / 2
const y1 = box.cy - box.h / 2
const y2 = box.cy + box.h / 2
return pos.x >= x1 && pos.x <= x2 && pos.y >= y1 && pos.y <= y2
}
function markDirty() {
dirty.value = true
}
function clearAllBoxes() {
confirmed.value = []
markDirty()
draw()
}
async function saveCurrent() {
if (!currentImage.value) return
saving.value = true
try {
const data = await request.post('/label-tasks/save', {
datasetId: datasetId.value,
filename: currentImage.value.filename,
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
} finally {
saving.value = false
}
}
// 弹窗「确定」:有未保存修改先保存,再关闭
async function confirmEditor() {
if (dirty.value) {
try {
await saveCurrent()
} catch {
ElMessage.error('保存失败,请重试')
return
}
}
editorVisible.value = false
}
function onWinResize() {
if (editorVisible.value) draw()
}
onMounted(() => {
loadAll()
window.addEventListener('resize', onWinResize)
})
onBeforeUnmount(() => {
stopLabelPoll()
stopGenPoll()
stopHighlight()
window.removeEventListener('resize', onWinResize)
})
</script>
<template>
<el-card shadow="never">
<div class="detail-topbar">
<el-button link @click="router.push('/datasets')"> 返回数据集</el-button>
<span class="detail-title">{{ dataset?.name || '数据训练' }}</span>
<el-tag
v-if="dataset"
:type="dataset.trainingStatus === 'running' ? 'primary' : (dataset.trainingStatus === 'success' ? 'success' : (dataset.trainingStatus === 'failed' ? 'danger' : 'info'))"
size="small"
>
{{ dataset.trainingStatus === 'running' ? `训练中 ${dataset.trainingCurrentEpoch || 0}/${dataset.trainingTotalEpochs || '-'}` : (dataset.trainingStatus === 'success' ? '训练完成' : (dataset.trainingStatus === 'failed' ? '训练失败' : (dataset.status === 'labeled' ? '已标注' : '建设中'))) }}
</el-tag>
</div>
<!-- 图片工具 -->
<div class="img-toolbar">
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
<el-button :icon="MagicStick" :disabled="labelRunning" @click="startFullLabelTask">全量标注</el-button>
</div>
<div v-if="labelRunning" class="label-progress">
<el-progress
:percentage="labelTask.total ? Math.min(100, Math.round((labelTask.done / labelTask.total) * 100)) : 0"
:stroke-width="10"
/>
<span class="label-progress-tip">自动标注中 {{ labelTask.done }}/{{ labelTask.total }} AI 全图扫描)…</span>
</div>
<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">
<template v-if="detail && detail.images.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)">
<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 class="card-sub">
{{ (item.boxes || []).length }} 个标注框
</div>
<div v-if="meta(item).prompt" class="card-prompt" :title="meta(item).prompt">{{ meta(item).prompt }}</div>
<div class="card-actions">
<el-button size="small" type="primary" @click="openEditor(item, gi)">审核</el-button>
<el-button size="small" type="danger" @click="removeCardImage(item)">删除</el-button>
</div>
</div>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="detail.images.length"
layout="prev, pager, next, total"
background
/>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无图片,请上传或 AI 生成" />
</div>
<!-- 添加图片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-input-number v-model="genForm.count" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="目标距离">
<div class="gen-inline">
<el-input-number v-model="genForm.distance" :min="1" :max="500" />
<span class="gen-note">注入提示词距离描述 25米外</span>
</div>
</el-form-item>
<el-form-item label="图片尺寸">
<span class="gen-size">竖版 704x1248</span>
</el-form-item>
<div class="gen-tip">按本数据集物种{{ dataset?.name || '' }}生成每张固定 1 生成图片为付费资产请勿随意删除批量生成为后台任务 40 /),进度见页顶进度条手填提示词时无需写拍摄角度/位置系统自动约束画面中有且只有这一只动物)。</div>
</el-form>
<template #footer>
<el-button type="primary" :loading="generating" @click="submitGen">开始生成</el-button>
</template>
</el-dialog>
<!-- 图片审核全屏查看 + 标注审核):已标注图直接在其上显示框可确认疑似/删除误检不做手动画框 -->
<el-dialog
v-model="editorVisible"
fullscreen
class="editor-fullscreen"
:title="currentImage?.filename || '图片审核'"
append-to-body
:before-close="onEditorBeforeClose"
:close-on-click-modal="false"
>
<div class="editor-toolbar">
<span class="tip">点框选中 · 列表确认疑似/删除误检 · VLM 自动标注审核</span>
<div class="toolbar-right">
<span class="box-count"><i class="dot red" />确认 {{ confirmCount }} · <i class="dot yellow" />疑似 {{ suspectCount }}</span>
<el-button size="small" :disabled="!confirmed.length" @click="clearAllBoxes">清空本张</el-button>
<el-button size="small" type="warning" :loading="vlmReviewing" @click="vlmReview">VLM 补检</el-button>
<el-divider direction="vertical" />
<span class="zoom-ctrl">
<el-button size="small" circle :icon="Plus" @click="zoomIn" />
<span class="zoom-pct">{{ zoomPercent }}</span>
<el-button size="small" circle :icon="Minus" @click="zoomOut" />
</span>
</div>
</div>
<div v-if="currentImage" class="canvas-wrap" ref="canvasWrap" @wheel="onWheel">
<canvas
ref="canvasEl"
class="wb-canvas"
@pointerdown="onPointerDown"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
<img :src="imgUrl(currentImage.url)" class="wb-img" ref="imgEl" alt="" @load="onImageLoad" />
</div>
<!-- 本图全部标注列表点击高亮定位画布对应框闪烁),疑似框可确认可删除 -->
<div v-if="confirmed.length" class="box-list">
<div
v-for="(b, i) in confirmed"
:key="i"
class="box-item"
:class="{ active: i === highlightIndex }"
@click="toggleHighlight(i)"
>
<i class="dot" :class="b.class === 0 ? 'red' : 'yellow'" />
<span class="box-item-label">{{ classLabel[b.class] || b.class }}{{ b.confidence && b.confidence < 1 ? ' ' + b.confidence.toFixed(2) : '' }}</span>
<el-button v-if="b.class === 1" size="small" type="success" text @click.stop="confirmBox(i)">确认</el-button>
<el-button size="small" type="danger" text @click.stop="removeBox(i)">删除</el-button>
</div>
</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>
</div>
<template #footer>
<el-button type="primary" :loading="saving" @click="confirmEditor">确定</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
/* 类别色点:确认红 / 疑似黄,与画布框色一致 */
.dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.dot.red {
background: #f56c6c;
}
.dot.yellow {
background: #e6a23c;
}
.box-count {
font-size: 12px;
color: #606266;
white-space: nowrap;
}
/* 标注列表:本图全部框,点击高亮闪烁定位 */
.box-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
max-height: 108px;
overflow-y: auto;
}
.box-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid #ebeef5;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
background: #fff;
}
.box-item:hover,
.box-item.active {
border-color: #409eff;
background: #ecf5ff;
}
.box-item-label {
color: #606266;
}
.detail-topbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.detail-title {
font-size: 16px;
font-weight: 600;
}
.img-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: 12px 0;
}
.label-progress {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.label-progress .el-progress {
flex: 1;
max-width: 420px;
}
.label-progress-tip {
font-size: 12px;
color: #909399;
}
.label-error {
margin-bottom: 12px;
color: #f56c6c;
font-size: 13px;
background: #fef0f0;
padding: 8px 10px;
border-radius: 4px;
}
.gen-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
}
.add-mode {
margin-bottom: 14px;
}
.add-upload {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.add-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
}
/* 生成表单行内组合(数量/距离范围 + 说明) */
.gen-inline {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.gen-note {
font-size: 12px;
color: #909399;
line-height: 1.6;
}
/* 上传方块:虚线边框 + 居中加号(el-upload picture-card 触发块) */
.upload-tile {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8c939d;
gap: 4px;
}
.upload-tile-text {
font-size: 12px;
color: #909399;
}
/* 图片卡片 */
.rows-area {
min-height: 200px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.img-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
}
.card-img {
position: relative;
overflow: hidden;
border-radius: 6px;
background: #000;
cursor: zoom-in;
border: 1px solid #dcdfe6;
}
.card-img img {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.badge {
position: absolute;
top: 6px;
right: 6px;
font-size: 11px;
color: #fff;
background: rgba(64, 158, 255, 0.85);
border-radius: 3px;
padding: 1px 6px;
line-height: 1.5;
}
.badge.source-badge {
left: 6px;
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;
color: #303133;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-sub {
font-size: 12px;
color: #606266;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-prompt {
font-size: 11px;
color: #909399;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.pager {
display: flex;
justify-content: center;
margin-top: 14px;
}
/* 标注编辑(全屏弹窗:画布区占满剩余高度,画布等比例居中,可放大缩小) */
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tip {
color: #909399;
font-size: 12px;
}
.zoom-ctrl {
display: inline-flex;
align-items: center;
gap: 6px;
}
.zoom-pct {
font-size: 12px;
color: #606266;
min-width: 44px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.canvas-wrap {
position: relative;
background: #000;
border-radius: 6px;
overflow: auto;
touch-action: none;
flex: 1;
min-height: 0;
display: flex;
}
.wb-canvas {
display: block;
cursor: crosshair;
margin: auto;
flex-shrink: 0;
}
.wb-img {
display: none;
}
.nav-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 10px;
}
.nav-info {
font-size: 13px;
color: #606266;
}
</style>
<!-- 全屏弹窗由 teleport 渲染到 bodyscoped 属性不会落到其内部 DOM须用非 scoped 规则 -->
<style>
.editor-fullscreen.el-dialog.is-fullscreen {
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-fullscreen .el-dialog__body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
padding: 12px 16px;
}
</style>