Files
observer/server_admin/src/views/Datasets.vue
T

892 lines
30 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 { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { EditPen, Loading, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import request from '../api/request'
const router = useRouter()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const keyword = ref('')
const formVisible = ref(false)
const saving = ref(false)
const generatingCover = ref(false)
// 新建/编辑共用弹窗(改名/前缀/描述/封面;改名同步迁移磁盘目录)
const dlgForm = reactive({
mode: 'create', // create | edit
id: 0,
name: '',
namePrefix: '',
sortOrder: 0, // 序号(列表排序主键,升序;同号按创建时间倒序)
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) // 用户删除了已有封面(保存时调删除接口)
const trainingIds = reactive(new Set()) // 训练发起中的数据集 id(请求期间按钮置灰防重复点击)
const statusMap = { building: '建设中', labeled: '已标注', synced: '已同步' }
const statusTag = { building: 'info', labeled: 'success', synced: 'primary' }
const trainStatusMap = { queued: '排队中', running: '训练中', success: '已训练', failed: '训练失败' }
const trainStatusTag = { queued: 'info', running: 'primary', success: 'success', failed: 'danger' }
// 训练档位:s=高识别(yolov8s@1280 精度优先) | n=高性能(yolov8n@704 速度优先)
const variantMeta = {
s: { label: 's 高识别', tag: 'primary' },
n: { label: 'n 高性能', tag: 'warning' },
}
// 卡片训练状态行:各档位(s/n)最新训练,无记录为空
function trList(row) {
return row?.trains || []
}
function activeTrs(row) {
// queued/running 均为进行中(双档并存时最多两条,GPU 串行)
return trList(row).filter((t) => t.status === 'running' || t.status === 'queued')
}
function load() {
loading.value = true
request
.get('/datasets', { params: { page: page.value, size: size.value, keyword: keyword.value || undefined } })
.then((data) => {
list.value = data.list || []
total.value = data.total || 0
})
.finally(() => {
loading.value = false
})
}
function search() {
page.value = 1
load()
}
// 每页条数输入框变更:回到第一页重新加载(避免当前页超出新总页数)
function onSizeChange() {
page.value = 1
load()
}
// ---------- 新建 / 编辑数据集(名称 + 描述 + 封面;AI 端点/训练机 SSH 走 config.yml,与数据集无关) ----------
function resetCoverState() {
coverFile.value = null
coverDeleted.value = false
}
function openCreate() {
Object.assign(dlgForm, {
mode: 'create',
id: 0,
name: '',
namePrefix: '',
sortOrder: 0,
description: '',
cover: '',
pendingCover: '',
genSpecies: '',
genTone: '',
genHeights: '',
genScenes: '',
genActions: '',
genOcclusions: '',
genClasses: '',
})
resetCoverState()
coverFileList.value = []
formVisible.value = true
}
function openEdit(row) {
Object.assign(dlgForm, {
mode: 'edit',
id: row.id,
name: row.name,
namePrefix: row.namePrefix || '',
sortOrder: row.sortOrder ?? 0,
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 项展示(带版本参数防浏览器缓存旧封面)
coverFileList.value = row.cover
? [{ name: '当前封面', url: imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}&v=${encodeURIComponent(row.updatedAt || '')}`) }]
: []
formVisible.value = true
}
// 封面文件选中:多选时只保留最后一个(替换语义);新文件选中即视为不再删除
function onCoverChange(file, fileList) {
if (fileList.length > 1) coverFileList.value = [fileList[fileList.length - 1]]
coverFile.value = coverFileList.value.length ? coverFileList.value[0].raw : null
if (coverFile.value) coverDeleted.value = false
}
// 已有一张封面时仍选择新文件(如相册多选触发 on-exceed):替换为最后一张,保持单张语义
function onCoverExceed(files) {
const last = files[files.length - 1]
coverFileList.value = [{ name: last.name, raw: last, url: URL.createObjectURL(last), status: 'ready' }]
coverFile.value = last
coverDeleted.value = false
ElMessage.info('封面仅支持一张,已替换为最后选择的图片')
}
// 删除项:删服务端已有封面(无 raw)→ 标记待删除;删新选文件且列表清空 → 同样按删除处理(服务端旧封面仍在)
function onCoverRemove(file) {
coverFile.value = null
if (!file.raw) {
coverDeleted.value = true
} else if (!coverFileList.value.length && dlgForm.cover) {
coverDeleted.value = true
}
}
// 提交新建/编辑:create 先建数据集拿 id,封面与描述复用 update/cover 接口串联写入;
// edit 按原配置保存流程。封面为独立 multipart 接口,update 始终不携带 cover。
async function submitDlg() {
const name = dlgForm.name.trim()
if (!name) {
ElMessage.warning('请输入数据集名称')
return
}
saving.value = true
try {
let id = dlgForm.id
if (dlgForm.mode === 'create') {
// 创建会同步调 VLM 生成参数池 + z-image 生成封面,超时放宽 180s
const res = await request.post(
'/datasets',
{ name, namePrefix: dlgForm.namePrefix.trim(), source: 'manual', cover: dlgForm.pendingCover || undefined, sortOrder: dlgForm.sortOrder || 0 },
{ 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()
fd.append('datasetId', id)
fd.append('file', coverFile.value)
await request.post('/datasets/cover', fd, { timeout: 60000 })
} else if (dlgForm.mode === 'edit' && coverDeleted.value) {
await request.post('/datasets/cover/delete', { datasetId: id })
}
// 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: '',
sortOrder: dlgForm.sortOrder || 0,
})
}
if (dlgForm.mode === 'edit') ElMessage.success('已保存')
formVisible.value = false
page.value = 1
load()
} finally {
saving.value = false
}
}
// 生成封面(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
})
}
// ---------- 开始训练(弹窗选档位,默认双档 s+n 各建一条排队任务;任务名自动生成,
// 参数走 config.yml training 节点;GPU 独占串行,忙时新任务自动排队 queued ----------
const startVisible = ref(false)
const startRow = ref(null) // 当前发起训练的数据集
const startSel = reactive({ s: true, n: true })
function openStart(row) {
// 已有进行中档位置灰:同 (数据集,档位) 防重由后端校验,前端仅提示
const act = activeTrs(row).map((t) => t.variant)
startRow.value = row
startSel.s = !act.includes('s')
startSel.n = !act.includes('n')
startVisible.value = true
}
function startTrain() {
const variants = []
if (startSel.s) variants.push('s')
if (startSel.n) variants.push('n')
if (!variants.length) {
ElMessage.warning('请至少选择一个档位')
return
}
const row = startRow.value
if (!row || trainingIds.has(row.id)) return
trainingIds.add(row.id)
request
// 后端已异步化(请求只做校验+落 queued 记录),60s 仅作兜底
.post('/trainings', { datasetId: row.id, variants }, { timeout: 60000 })
.then(() => {
ElMessage.success('训练已加入队列,GPU 空闲后自动按顺序执行')
startVisible.value = false
})
.catch(() => {})
.finally(() => {
trainingIds.delete(row.id)
// 请求失败也可能已落 queued 记录(如响应异常),一律刷新拿真实状态
load()
})
}
// ---------- 删除 ----------
function removeDataset(row) {
const warn =
row.source === 'ai' || row.imageCount > 0
? '将删除图片文件、标注与记录,AI 生成图属付费资产,删除后无法恢复。'
: ''
ElMessageBox.confirm(`确定删除数据集「${row.name}」?${warn}`, '删除数据集', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => request.post('/datasets/delete', { id: row.id }))
.then(() => {
ElMessage.success('已删除')
load()
})
.catch(() => {})
}
// <img> 标签无法带自定义请求头,图片地址以 query 参数携带 admin token
function imgUrl(u) {
return `${location.origin}${u}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
}
function coverUrl(row) {
if (!row.cover) return ''
// v=updatedAt 版本参数:换封面后 URL 变化,强制浏览器重新拉取
return imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}&v=${encodeURIComponent(row.updatedAt || '')}`)
}
// 训练进度(百分比):有总轮数才显示比例
function trainPercent(t) {
if (t.status !== 'running' || !t.totalEpochs) return 0
return Math.min(100, Math.round((t.currentEpoch / t.totalEpochs) * 100))
}
// 发布模型版本(按训练任务档位:s/n 各自的生效版本互不影响,版本号数据集内共用自增)
function publishTrain(row, t) {
const v = variantMeta[t.variant]
ElMessageBox.confirm(
`确定将「${row.name}」的 ${v.label}(训练 #${t.trainingId})结果发布为模型版本?版本号按数据集自增(m<主>.<次>.<修订>),客户端按识别档位热更新下载。`,
'发布模型版本',
{ confirmButtonText: '发布', cancelButtonText: '取消', type: 'warning' },
)
.then(() => request.post('/trainings/publish', { id: t.trainingId }))
.then((data) => {
ElMessage.success(`已发布版本 ${data.version}`)
load()
})
.catch(() => {})
}
// 取消训练:running=终止进程;queued=直接取消排队
function cancelTrain(row, t) {
const v = variantMeta[t.variant]
const isRun = t.status === 'running'
ElMessageBox.confirm(
isRun
? `确定终止「${row.name}${v.label} 的本次训练?进程将被终止,进度不保留。`
: `确定取消「${row.name}${v.label} 的排队任务?`,
isRun ? '终止训练' : '取消排队训练',
{ confirmButtonText: '确定', cancelButtonText: '再想想', type: 'warning' },
)
.then(() => request.post('/trainings/cancel', { id: t.trainingId }))
.then(() => {
ElMessage.success('已取消')
load()
})
.catch(() => {})
}
// 有训练中的任务时周期刷新,卡片进度条保持最新
let trainPoll = null
onMounted(() => {
load()
trainPoll = setInterval(() => {
if (list.value.some((r) => trList(r).some((t) => t.status === 'running'))) load()
}, 10000)
})
onBeforeUnmount(() => {
if (trainPoll) clearInterval(trainPoll)
})
</script>
<template>
<el-card shadow="never">
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="keyword"
class="kw"
placeholder="按名称搜索"
clearable
@keyup.enter="search"
@clear="search"
/>
<el-button @click="search">搜索</el-button>
</div>
<div class="toolbar-right">
<el-button type="primary" :icon="Plus" @click="openCreate">新建数据集</el-button>
</div>
</div>
<div v-loading="loading" class="ds-grid">
<div v-for="row in list" :key="row.id" class="ds-card" @click="router.push(`/datasets/${row.id}`)">
<div class="ds-cover">
<img v-if="row.cover" :src="coverUrl(row)" :alt="row.name" loading="lazy" />
<div v-else class="ds-cover-placeholder"><el-icon :size="34"><Picture /></el-icon></div>
<div v-if="row.source === 'ai'" class="ds-cover-source">AI 生成</div>
</div>
<div class="ds-body">
<div class="ds-name" :title="row.name">{{ row.name }}</div>
<div class="ds-desc">{{ row.description || '暂无描述,点击进入管理图片与标注' }}</div>
<div class="ds-stats">
<span v-if="row.sortOrder">序号 {{ row.sortOrder }}</span>
<span>图片 {{ row.imageCount }}</span>
<span>已标注 {{ row.labeledCount }}</span>
<el-tag :type="statusTag[row.status] || 'info'" size="small">{{ statusMap[row.status] || row.status }}</el-tag>
</div>
</div>
<div class="ds-actions" @click.stop>
<el-button
type="primary"
size="small"
:icon="VideoPlay"
:loading="trainingIds.has(row.id)"
:disabled="trainingIds.has(row.id) || activeTrs(row).length >= 2"
@click="openStart(row)"
>
{{ activeTrs(row).length >= 2 ? '训练中' : '开始训练' }}
</el-button>
<el-button size="small" :icon="EditPen" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="removeDataset(row)">删除</el-button>
</div>
<!-- 各档位s/n最新训练状态行success 已发布只标记未发布给发布入口 -->
<div v-if="trList(row).length" class="ds-train" @click.stop>
<div v-for="t in trList(row)" :key="t.variant" class="ds-train-line">
<el-tag :type="variantMeta[t.variant]?.tag || 'info'" size="small">{{ variantMeta[t.variant]?.label || t.variant }}</el-tag>
<template v-if="t.status === 'running'">
<el-progress :percentage="trainPercent(t)" :stroke-width="6" :show-text="false" class="ds-train-bar" />
<span class="ds-train-text">{{ t.currentEpoch || 0 }}/{{ t.totalEpochs || '-' }} </span>
</template>
<el-tooltip v-else-if="t.status === 'failed' && t.error" :content="t.error" placement="top">
<el-tag :type="trainStatusTag[t.status] || 'info'" size="small">{{ trainStatusMap[t.status] }}</el-tag>
</el-tooltip>
<el-tag v-else-if="t.status !== 'success'" :type="trainStatusTag[t.status] || 'info'" size="small">
{{ trainStatusMap[t.status] || t.status }}
</el-tag>
<template v-else>
<el-tag type="success" size="small">已训练</el-tag>
<el-button v-if="!t.published" size="small" type="success" class="ds-pub" @click="publishTrain(row, t)">发布模型</el-button>
<span v-else class="ds-train-text">已发布</span>
</template>
<el-button
v-if="t.status === 'running' || t.status === 'queued'"
size="small"
text
type="danger"
class="ds-cancel"
@click="cancelTrain(row, t)"
>
{{ t.status === 'queued' ? '取消排队' : '终止' }}
</el-button>
</div>
</div>
</div>
<el-empty v-if="!loading && !list.length" description="暂无数据集,点击右上角新建" />
</div>
<div class="pager-row">
<span class="pager-label">每页</span>
<el-input-number v-model="size" :min="1" :max="500" :step="10" size="small" @change="onSizeChange" />
<span class="pager-label"></span>
<el-pagination
v-model:current-page="page"
v-model:page-size="size"
:total="total"
layout="total, prev, pager, next"
@change="load"
/>
</div>
<!-- 发起训练档位选择默认双档 s+n训练机 GPU 独占串行有任务时新任务排队 queued 逐个执行 -->
<el-dialog v-model="startVisible" title="发起训练" width="min(440px, 94vw)">
<div class="start-dataset">数据集{{ startRow?.name }}</div>
<div class="start-opts">
<div class="start-opt" :class="{ 'is-act': activeTrs(startRow).some((t) => t.variant === 's') }">
<el-checkbox v-model="startSel.s" :disabled="activeTrs(startRow).some((t) => t.variant === 's')">s 高识别</el-checkbox>
<span class="start-tip">yolov8s @1280 · 精度优先默认档</span>
</div>
<div class="start-opt" :class="{ 'is-act': activeTrs(startRow).some((t) => t.variant === 'n') }">
<el-checkbox v-model="startSel.n" :disabled="activeTrs(startRow).some((t) => t.variant === 'n')">n 高性能</el-checkbox>
<span class="start-tip">yolov8n @704 · 速度优先</span>
</div>
</div>
<p class="start-note">
训练机 GPU 独占已有任务时本次训练自动排队queued),按发起顺序逐个执行该档位已有进行中任务时会提示错误排队的任务可在卡片上取消
</p>
<template #footer>
<el-button @click="startVisible = false">取消</el-button>
<el-button type="primary" :loading="trainingIds.has(startRow?.id)" @click="startTrain">开始训练</el-button>
</template>
</el-dialog>
<!-- 新建 / 编辑数据集同一弹窗编辑模式回填原值且允许改名 -->
<el-dialog v-model="formVisible" :title="dlgForm.mode === 'create' ? '新建数据集' : '编辑数据集'" width="min(720px, 94vw)">
<el-form label-width="90px">
<el-form-item label="名称">
<el-input
v-model="dlgForm.name"
maxlength="50"
show-word-limit
placeholder="中文/字母/数字/下划线/短横线,唯一且作磁盘目录名"
/>
</el-form-item>
<el-form-item label="序号">
<el-input-number v-model="dlgForm.sortOrder" :min="0" :max="999999" :step="1" controls-position="right" style="width: 160px" />
<span class="field-tip">列表按序号升序排列同号按创建时间倒序默认 0</span>
</el-form-item>
<el-form-item label="文件名前缀">
<el-input v-model="dlgForm.namePrefix" maxlength="50" placeholder="可选,如 pigeonAI 生成图按 pigeon_01.jpg 顺序命名;留空用时间戳命名" />
</el-form-item>
<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 v-if="dlgForm.mode === 'edit'" label="封面">
<div class="cover-row">
<el-upload
v-model:file-list="coverFileList"
:auto-upload="false"
accept=".jpg,.jpeg,.png"
list-type="picture-card"
:limit="1"
:on-change="onCoverChange"
:on-remove="onCoverRemove"
:on-exceed="onCoverExceed"
>
<div v-if="coverFileList.length === 0" class="upload-tile">
<el-icon :size="22"><Plus /></el-icon>
</div>
</el-upload>
<span class="add-tip">jpg/jpeg/png,≤2MB悬停预览右上角 × 可删除当前封面</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>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitDlg">{{ dlgForm.mode === 'create' ? '创建' : '保存' }}</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
gap: 10px;
}
.toolbar-left {
display: flex;
gap: 8px;
}
.toolbar-right {
display: flex;
gap: 8px;
}
.kw {
width: 220px;
}
.pager-row {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.pager-label {
color: var(--el-text-color-secondary);
font-size: 13px;
white-space: nowrap;
}
.ds-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
min-height: 120px;
}
.ds-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: box-shadow 0.2s;
}
.ds-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.ds-cover {
position: relative;
aspect-ratio: 16 / 9;
background: #f5f7fa;
}
.ds-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ds-cover-placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #c0c4cc;
}
.ds-cover-source {
position: absolute;
left: 8px;
top: 8px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
.ds-train {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px 12px;
border-top: 1px dashed #ebeef5;
}
.ds-train-line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ds-train-bar {
flex: 1;
}
.ds-train-text {
font-size: 12px;
color: #606266;
white-space: nowrap;
}
.ds-pub {
margin-left: auto;
}
.ds-cancel {
margin-left: auto;
}
.start-dataset {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
.start-opts {
display: flex;
flex-direction: column;
gap: 10px;
}
.start-opt {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #ebeef5;
border-radius: 6px;
}
.start-opt.is-act {
background: #f5f7fa;
}
.start-tip {
font-size: 12px;
color: #909399;
}
.start-note {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin: 12px 0 0;
}
.ds-body {
padding: 10px 12px 6px;
}
.ds-name {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
height: 36px;
overflow: hidden;
margin-top: 4px;
}
.ds-stats {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #606266;
margin-top: 8px;
}
.ds-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px 12px;
border-top: 1px dashed #ebeef5;
margin-top: 8px;
}
.field-tip {
margin-left: 8px;
font-size: 12px;
color: #909399;
}
.cover-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
/* 上传方块:虚线边框 + 居中加号(el-upload picture-card 触发块) */
.upload-tile {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #8c939d;
}
.add-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
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 {
flex-wrap: wrap;
align-items: flex-start;
}
.toolbar-left {
flex: 1;
min-width: 200px;
}
.toolbar-right {
width: 100%;
justify-content: flex-end;
}
.kw {
flex: 1;
width: auto;
}
.ds-grid {
grid-template-columns: 1fr;
}
}
</style>