This commit is contained in:
2026-08-26 18:15:54 +08:00
parent 54d343b739
commit a4568d8a55
79 changed files with 11264 additions and 560 deletions
+12 -10
View File
@@ -1,15 +1,22 @@
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
List as ListIcon,
Key as KeyIcon,
Refresh as RefreshIcon,
SwitchButton as SwitchIcon,
Picture as PictureIcon,
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
// 详情页 /datasets/:id 仍高亮「数据训练」菜单项
const activeMenu = computed(() =>
route.path.startsWith('/datasets') ? '/datasets' : route.path,
)
function logout() {
localStorage.removeItem('adminToken')
router.push('/login')
@@ -22,8 +29,11 @@ function logout() {
<el-container v-else class="app-layout">
<el-aside width="200px" class="app-aside">
<div class="app-logo">视野管理端</div>
<el-menu :default-active="route.path" router>
<el-menu :default-active="activeMenu" router>
<el-menu-item index="/datasets">
<el-icon><PictureIcon /></el-icon>
<span>数据训练</span>
</el-menu-item>
<el-menu-item index="/orders">
<el-icon><ListIcon /></el-icon>
<span>订单管理</span>
@@ -57,14 +67,6 @@ function logout() {
.app-aside {
background: #001529;
}
.app-logo {
height: 56px;
line-height: 56px;
text-align: center;
color: #fff;
font-size: 16px;
font-weight: 600;
}
.app-aside :deep(.el-menu) {
border-right: none;
background: #001529;
+5 -1
View File
@@ -3,13 +3,17 @@ import Login from '../views/Login.vue'
import Orders from '../views/Orders.vue'
import Licenses from '../views/Licenses.vue'
import AppVersions from '../views/AppVersions.vue'
import Datasets from '../views/Datasets.vue'
import DatasetDetail from '../views/DatasetDetail.vue'
// 生产部署于 /admin/ 前缀(后端静态托管),history 路由由后端 SPA fallback 兜底
const router = createRouter({
history: createWebHistory('/admin/'),
routes: [
{ path: '/login', name: 'login', component: Login, meta: { title: '登录' } },
{ path: '/', redirect: '/orders' },
{ path: '/', redirect: '/datasets' },
{ path: '/datasets', name: 'datasets', component: Datasets, meta: { title: '数据训练' } },
{ path: '/datasets/:id', name: 'datasetDetail', component: DatasetDetail, meta: { title: '数据训练详情' } },
{ path: '/orders', name: 'orders', component: Orders, meta: { title: '订单管理' } },
{ path: '/licenses', name: 'licenses', component: Licenses, meta: { title: '授权管理' } },
{ path: '/app-versions', name: 'appVersions', component: AppVersions, meta: { title: '版本管理' } },
File diff suppressed because it is too large Load Diff
+565
View File
@@ -0,0 +1,565 @@
<script setup>
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Picture, Setting, VideoPlay, Download } 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(12)
const keyword = ref('')
const createVisible = ref(false)
const createForm = reactive({ name: '', source: 'manual' })
const creating = ref(false)
const configVisible = ref(false)
const configSaving = ref(false)
const configForm = reactive({
id: 0,
description: '',
cover: '',
})
const coverFile = ref(null) // 新选择的封面文件(el-upload 单文件)
const coverFileList = ref([]) // 封面回显:已有封面(服务端 url)或新选文件(本地预览)
const coverDeleted = ref(false) // 用户删除了已有封面(保存时调删除接口)
const trainVisible = ref(false)
const training = ref(false)
const trainForm = reactive({ datasetId: 0, name: '', imgsz: 704, epochs: 150, batch: 16, device: '0' })
const statusMap = { building: '建设中', labeled: '已标注', synced: '已同步' }
const statusTag = { building: 'info', labeled: 'success', synced: 'primary' }
const trainStatusMap = { running: '训练中', success: '已训练', failed: '训练失败' }
const trainStatusTag = { running: 'primary', success: 'success', failed: 'danger' }
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 openCreate() {
createForm.name = ''
createForm.source = 'manual'
createVisible.value = true
}
function submitCreate() {
if (!createForm.name.trim()) {
ElMessage.warning('请输入数据集名称')
return
}
creating.value = true
request
.post('/datasets', { name: createForm.name.trim(), source: createForm.source })
.then(() => {
ElMessage.success('数据集已创建,请上传或生成图片')
createVisible.value = false
page.value = 1
load()
})
.finally(() => {
creating.value = false
})
}
// ---------- 数据集级配置(描述 + 封面;AI 端点/训练机 SSH 走 config.yml,与数据集无关) ----------
function openConfig(row) {
Object.assign(configForm, {
id: row.id,
description: row.description || '',
cover: row.cover || '',
})
coverFile.value = null
coverDeleted.value = false
// 已有封面回显:el-upload picture-card 直接以 url 项展示
coverFileList.value = row.cover ? [{ name: '当前封面', url: imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}`) }] : []
configVisible.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
}
// 删除项:删服务端已有封面(无 raw)→ 标记待删除;删新选文件且列表清空 → 同样按删除处理(服务端旧封面仍在)
function onCoverRemove(file) {
coverFile.value = null
if (!file.raw) {
coverDeleted.value = true
} else if (!coverFileList.value.length && configForm.cover) {
coverDeleted.value = true
}
}
async function submitConfig() {
configSaving.value = true
try {
if (coverFile.value) {
const fd = new FormData()
fd.append('datasetId', configForm.id)
fd.append('file', coverFile.value)
await request.post('/datasets/cover', fd, { timeout: 60000 })
} else if (coverDeleted.value) {
await request.post('/datasets/cover/delete', { datasetId: configForm.id })
}
// cover 由上传接口单独维护,update 不携带
await request.post('/datasets/update', { ...configForm, cover: '' })
ElMessage.success('配置已保存')
configVisible.value = false
load()
} finally {
configSaving.value = false
}
}
// ---------- 开始训练 ----------
function openTrain(row) {
trainForm.datasetId = row.id
trainForm.name = `${row.name} 训练`
trainForm.imgsz = 704
trainForm.epochs = 150
trainForm.batch = 16
trainForm.device = '0'
trainVisible.value = true
}
function submitTrain() {
if (!trainForm.name.trim()) {
ElMessage.warning('请输入任务名称')
return
}
training.value = true
request
.post('/trainings', { ...trainForm })
.then(() => {
ElMessage.success('训练任务已发起,完成后可发布为模型版本')
trainVisible.value = false
load()
})
.catch(() => {})
.finally(() => {
training.value = false
})
}
// ---------- 导出 / 删除 ----------
function exportDataset(row) {
window.open(`${location.origin}/api/v1/admin/datasets/export?datasetId=${row.id}`, '_blank')
}
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 ''
return imgUrl(`/api/v1/admin/datasets/cover?datasetId=${row.id}`)
}
// 训练进度(百分比):有总轮数才显示比例
function trainPercent(row) {
if (row.trainingStatus !== 'running' || !row.trainingTotalEpochs) return 0
return Math.min(100, Math.round((row.trainingCurrentEpoch / row.trainingTotalEpochs) * 100))
}
function publishModel(row) {
ElMessageBox.confirm(
`确定将「${row.name}」的最新训练结果发布为模型版本?将按数据集版本号自增(m<主>.<次>.<修订>),客户端下次启动热更新下载。`,
'发布模型版本',
{ confirmButtonText: '发布', cancelButtonText: '取消', type: 'warning' },
)
.then(() => request.post('/trainings/publish', { id: row.trainingId }))
.then((data) => {
ElMessage.success(`已发布版本 ${data.version}`)
load()
})
.catch(() => {})
}
// 有训练中的任务时周期刷新,卡片进度条保持最新
let trainPoll = null
onMounted(() => {
load()
trainPoll = setInterval(() => {
if (list.value.some((r) => r.trainingStatus === '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>图片 {{ 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" :disabled="row.trainingStatus === 'running'" @click="openTrain(row)">
{{ row.trainingStatus === 'running' ? '训练中' : '开始训练' }}
</el-button>
<el-button size="small" :icon="Setting" @click="openConfig(row)">配置</el-button>
<el-button size="small" :icon="Download" @click="exportDataset(row)">导出</el-button>
<el-button size="small" type="danger" @click="removeDataset(row)">删除</el-button>
</div>
<!-- 训练任务进度与状态最新一条训练记录 -->
<div v-if="row.trainingStatus" class="ds-train" @click.stop>
<template v-if="row.trainingStatus === 'running'">
<el-progress :percentage="trainPercent(row)" :stroke-width="6" :show-text="false" class="ds-train-bar" />
<div class="ds-train-text">
<span>训练中 {{ row.trainingCurrentEpoch || 0 }}/{{ row.trainingTotalEpochs || '-' }} </span>
</div>
</template>
<template v-else>
<el-tag :type="trainStatusTag[row.trainingStatus] || 'info'" size="small">
{{ trainStatusMap[row.trainingStatus] || row.trainingStatus }}
</el-tag>
<el-button v-if="row.trainingStatus === 'success' && row.trainingId" size="small" type="success" @click="publishModel(row)">
发布模型
</el-button>
</template>
</div>
</div>
<el-empty v-if="!loading && !list.length" description="暂无数据集,点击右上角新建" />
</div>
<el-pagination
class="pager"
v-model:current-page="page"
v-model:page-size="size"
:total="total"
:page-sizes="[12, 24, 48]"
layout="total, sizes, prev, pager, next"
@change="load"
/>
<!-- 新建数据集 -->
<el-dialog v-model="createVisible" title="新建数据集" width="min(440px, 92vw)">
<el-form label-width="90px">
<el-form-item label="名称">
<el-input
v-model="createForm.name"
maxlength="50"
show-word-limit
placeholder="中文/字母/数字/下划线/短横线,唯一且作磁盘目录名"
/>
</el-form-item>
<el-form-item label="图片来源">
<el-radio-group v-model="createForm.source">
<el-radio value="manual">手动上传</el-radio>
<el-radio value="ai">AI 生成</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<!-- 数据集配置 -->
<el-dialog v-model="configVisible" title="数据集配置" width="min(720px, 94vw)">
<el-form label-width="120px">
<el-form-item label="描述">
<el-input v-model="configForm.description" type="textarea" :rows="6" maxlength="500" show-word-limit placeholder="数据集说明,展示在卡片上" />
</el-form-item>
<el-form-item label="封面">
<div class="cover-row">
<el-upload
v-model:file-list="coverFileList"
:auto-upload="false"
accept=".jpg,.jpeg,.png"
list-type="picture-card"
:on-change="onCoverChange"
:on-remove="onCoverRemove"
>
<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/png2MB悬停预览右上角 × 可删除当前封面</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configVisible = false">取消</el-button>
<el-button type="primary" :loading="configSaving" @click="submitConfig">保存</el-button>
</template>
</el-dialog>
<!-- 开始训练 -->
<el-dialog v-model="trainVisible" title="开始训练" width="min(480px, 92vw)">
<el-form label-width="90px">
<el-form-item label="任务名称">
<el-input v-model="trainForm.name" maxlength="50" show-word-limit placeholder="如:雉鸡数据集 v2 训练" />
</el-form-item>
<el-form-item label="分辨率">
<el-input-number v-model="trainForm.imgsz" :min="64" :max="2048" :step="32" />
<span class="field-tip">与端侧推理对齐默认 704</span>
</el-form-item>
<el-form-item label="轮数">
<el-input-number v-model="trainForm.epochs" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="Batch">
<el-input-number v-model="trainForm.batch" :min="1" :max="128" />
</el-form-item>
<el-form-item label="设备">
<el-input v-model="trainForm.device" maxlength="16" placeholder="GPU 编号 0CPU 填 cpu" />
</el-form-item>
<div class="start-tip">训练前自动整理 80/20 训练/验证集须先完成图片标注训练机并发度 1已有运行中任务会被拒绝</div>
</el-form>
<template #footer>
<el-button @click="trainVisible = false">取消</el-button>
<el-button type="primary" :loading="training" @click="submitTrain">发起训练</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 {
margin-top: 14px;
justify-content: flex-end;
}
.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;
height: 150px;
background: #f5f7fa;
}
.ds-cover img {
width: 100%;
height: 100%;
object-fit: contain;
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;
align-items: center;
gap: 10px;
padding: 8px 12px 12px;
border-top: 1px dashed #ebeef5;
}
.ds-train-bar {
flex: 1;
}
.ds-train-text {
font-size: 12px;
color: #606266;
white-space: nowrap;
}
.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;
}
.start-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-left: 90px;
}
.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;
}
@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>
+1 -1
View File
@@ -23,7 +23,7 @@ function login() {
.then(() => {
localStorage.setItem('adminToken', token.value.trim())
ElMessage.success('登录成功')
router.push('/orders')
router.push('/datasets')
})
.catch(() => {
errorMsg.value = 'token 无效或服务端未配置(检查 config.yml admin.token'
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
},
server: {
proxy: {
'/api': 'http://127.0.0.1:8080',
'/api': 'http://127.0.0.1:18080',
},
},
})