This commit is contained in:
2026-08-27 17:54:48 +08:00
parent f89e1e4de8
commit b30ca673cc
12 changed files with 168 additions and 92 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
| POST | `/admin/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 |
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","source":"manual"\|"ai"}`(name ≤50 字唯一,目录自动建) |
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs |
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"description":"...","cover":"a.jpg"}`:描述/封面,空值字段不覆盖原值 |
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"name":"新名","description":"...","cover":"a.jpg"}`名称(改名)/描述/封面,空值字段不覆盖原值;改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 |
| POST | `/admin/datasets/cover` | 上传数据集封面(multipart`datasetId`+`file`jpg/jpeg/png ≤2MB):**自动转 jpg + UUID 命名**落盘并覆盖旧封面 |
| GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位) |
| POST | `/admin/datasets/cover/delete` | 删除数据集封面 `{"datasetId":1}`:删文件 + 清 cover 字段 |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视野管理端</title>
<script type="module" crossorigin src="/admin/assets/index-Dtws4Ia3.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-C62TRiv2.css">
<script type="module" crossorigin src="/admin/assets/index-C_zeeHvw.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-f1eLEuzq.css">
</head>
<body>
<div id="app"></div>
+6 -2
View File
@@ -97,10 +97,14 @@ func (d *datasetDao) UpdateCounters(ctx context.Context, id int64, imgDelta, lab
return err
}
// UpdateConfigs 更新数据集展示配置(封面/描述;仅更新非零字段,留空字段保留原值——
// 前端提交整包配置,未填项不覆盖。AI 端点/训练机 SSH 为全局训练配置,不在此表维护)
// UpdateConfigs 更新数据集展示配置(名称唯一性与目录迁移由 service 校验后传入;
// 仅更新非零字段,留空字段保留原值——前端提交整包配置,未填项不覆盖。
// AI 端点/训练机 SSH 为全局训练配置,不在此表维护)
func (d *datasetDao) UpdateConfigs(ctx context.Context, id int64, m *entity.Dataset) error {
data := g.Map{"updated_at": gtime.Now()}
if m.Name != "" {
data["name"] = m.Name
}
if m.Cover != "" {
data["cover"] = m.Cover
}
+4 -2
View File
@@ -45,11 +45,13 @@ type AdminDatasetListRes struct {
List []*AdminDatasetItem `json:"list"`
}
// AdminDatasetUpdateReq 更新数据集展示配置(空值字段不覆盖原值,保留现有配置;
// AdminDatasetUpdateReq 更新数据集配置(空值字段不覆盖原值,保留现有配置;改名时同步迁移
// 图片目录与模型文件,名称唯一且同是磁盘目录名;
// AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh))
type AdminDatasetUpdateReq struct {
g.Meta `path:"/datasets/update" method:"post" summary:"更新数据集配置" tags:"管理端"`
Id int64 `json:"id" v:"required|min:1" dc:"数据集 id"`
Name string `json:"name" v:"length:0,50" dc:"数据集名称(非空且变化才更新;同是目录名)"`
Description string `json:"description" v:"length:0,500" dc:"描述(非空才更新)"`
Cover string `json:"cover" v:"length:0,200" dc:"封面文件名(非空才更新)"`
}
@@ -95,7 +97,7 @@ type AdminDatasetGenerateReq struct {
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
Prompt string `json:"prompt" v:"required|length:5,500" dc:"生成提示词(禁止含位置描述)"`
Count int `json:"count" v:"required|integer|min:1|max:8" dc:"生成张数 1-8"`
Size string `json:"size" v:"required|in:1152x2048,704x704" dc:"图片尺寸"`
Size string `json:"size" v:"required|in:704x1248" dc:"图片尺寸"`
}
type AdminDatasetGenerateRes struct {
+81
View File
@@ -35,6 +35,10 @@ var Dataset = &datasetService{}
// imgExts 允许上传/保存的图片扩展名
var imgExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true}
// datasetNameRe 数据集名称规则(与新建接口 DTO 校验一致:中文/字母/数字/下划线/短横线,1-50)
// 更新接口的名称为可选字段,空值合法,正则不能进 DTO 校验链,放 service 侧判定。
var datasetNameRe = regexp.MustCompile(`^[a-zA-Z0-9_一-龥-]{1,50}$`)
// AdminListDatasets 数据集分页列表(卡片展示:封面/描述/训练配置标记 + 最新训练状态聚合)
func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDatasetListReq) (*dto.AdminDatasetListRes, error) {
page, size := common.NormalizePage(req.Page, req.Size)
@@ -93,6 +97,13 @@ func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminD
if existing == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
newName := strings.TrimSpace(req.Name)
if newName != "" && newName != existing.Name {
if err := s.renameDataset(ctx, existing.Id, existing.Name, newName); err != nil {
return nil, err
}
existing.Name = newName
}
// 封面须属于该数据集图片(防伪造文件名指向任意文件)
if req.Cover != "" {
img, err := dao.DatasetImage.GetByFilename(ctx, req.Id, req.Cover)
@@ -104,6 +115,7 @@ func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminD
}
}
if err := dao.Dataset.UpdateConfigs(ctx, req.Id, &entity.Dataset{
Name: newName,
Cover: req.Cover,
Description: req.Description,
}); err != nil {
@@ -112,6 +124,75 @@ func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminD
return &dto.AdminDatasetUpdateRes{Id: req.Id}, nil
}
// renameDataset 数据集改名:名称同是磁盘目录名,需同步迁移图片目录与当前生效模型文件。
// 进行中的标注/训练任务持有旧路径,须先等任务结束;改名在 Serial 串行执行,与其他写操作互斥。
func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, newName string) error {
err := common.Serial().Submit(ctx, func() error {
if !datasetNameRe.MatchString(newName) {
return gerror.New("数据集名称仅支持中文/字母/数字/下划线/短横线,长度 1-50")
}
// 预标注任务进行中(RF-DETR 正在按旧目录读图)
if t, err := dao.LabelTask.GetRunningByDataset(ctx, id); err != nil {
return err
} else if t != nil {
return gerror.NewCode(common.CodeLabelTaskRunning)
}
// 训练进行中(同步目录与产物路径以旧名构建)
if t, err := dao.Training.RunningByDataset(ctx, id); err != nil {
return err
} else if t != nil {
return gerror.New("该数据集有训练任务进行中,无法重命名")
}
// 同名占用检查(UNIQUE 约束兜底)
exists, err := dao.Dataset.GetByName(ctx, newName)
if err != nil {
return err
}
if exists != nil {
return gerror.NewCode(common.CodeDatasetNameDuplicate)
}
// 目录迁移:任一步失败回滚已迁移项(图片目录 / 当前生效模型文件可能不存在,缺省跳过)
oldImgDir := common.DatasetImagesDir(ctx, oldName)
newImgDir := common.DatasetImagesDir(ctx, newName)
oldModel := common.TrainingModelPath(ctx, oldName)
newModel := common.TrainingModelPath(ctx, newName)
movedImgDir, movedModel := false, false
if _, err := os.Stat(oldImgDir); err == nil {
if err := os.Rename(oldImgDir, newImgDir); err != nil {
return gerror.Wrap(err, "迁移图片目录失败")
}
movedImgDir = true
}
if _, err := os.Stat(oldModel); err == nil {
if err := os.Rename(oldModel, newModel); err != nil {
if movedImgDir {
if rbErr := os.Rename(newImgDir, oldImgDir); rbErr != nil {
g.Log().Errorf(ctx, "回滚图片目录失败: %+v", rbErr)
}
}
return gerror.Wrap(err, "迁移模型文件失败")
}
movedModel = true
}
if err := dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Name: newName}); err != nil {
if movedModel {
if rbErr := os.Rename(newModel, oldModel); rbErr != nil {
g.Log().Errorf(ctx, "回滚模型文件失败: %+v", rbErr)
}
}
if movedImgDir {
if rbErr := os.Rename(newImgDir, oldImgDir); rbErr != nil {
g.Log().Errorf(ctx, "回滚图片目录失败: %+v", rbErr)
}
}
return err
}
g.Log().Infof(ctx, "数据集 %d 改名完成: %s -> %s", id, oldName, newName)
return nil
})
return err
}
// AdminUploadCover 上传数据集封面:解码校验图片 → 转 jpg(Quality 92)→ UUID 命名落盘,
// 删除旧封面文件(UUID 互不覆盖,但 DB 指向切换)→ 更新 cover 字段。
func (s *datasetService) AdminUploadCover(ctx context.Context, req *dto.AdminDatasetCoverUploadReq) (*dto.AdminDatasetCoverUploadRes, error) {
+3 -3
View File
@@ -35,9 +35,9 @@ app:
imageGen:
provider: localai # dashscope | localai
baseUrl: "http://192.168.3.210:18080" # localai 必填:训练机 local-ai 地址(/v1/images/generations
model: qwen-image # localai 上加载的模型名;dashscope 用 qwen-image-3.0
model: z-image-turbo # localai 上加载的模型名;dashscope 用 qwen-image-3.0
apiKey: "" # dashscope 必填:DashScope API Key
timeoutSeconds: 600 # 每张生成超时(默认 120;localai 训练机约 5min/张,建议 600
timeoutSeconds: 1800 # 每张生成超时(默认 120;localai 训练机 704x1248 竖版实测约 26min/张
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务):
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
@@ -52,7 +52,7 @@ training:
workdir: /opt/pheasant_data # 训练机工作目录(train_server.py / yolov8n.pt 所在)
venvPython: /opt/pheasant_data/venv/bin/python
datasetDir: datasets # 训练机数据集根目录(相对 workdiryolo/<name> 为子目录)
timeoutMinutes: 600 # 训练超时判死
timeoutMinutes: 240 # 训练超时判死started_at 起算;实测 100 张约 3 分钟,千图级约 1 小时)
imgsz: 704 # 训练/导出分辨率(须与 App 端推理输入对齐)
epochs: 150 # 训练轮数(patience 30 早停,设大可自动停)
batch: 16 # 批大小(按训练机显存调整)
Binary file not shown.
+2 -2
View File
@@ -328,7 +328,7 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不
- `dashscope`(通义万相付费 API):`apiKey` + `model: qwen-image-3.0`;内部自管 120s 轮询上限
- `localai`(本机/训练机 local-ai`POST {baseUrl}/v1/images/generations`OpenAI 兼容):`baseUrl` + `model`(local-ai 上加载的模型名,如 qwen-image+ `timeoutSeconds`(训练机 qwen-image 1024x1024/30 步约 5 分钟,默认 600);返回 url 为容器内 localhost 地址,下载时替换为 `baseUrl` 的 hoststep/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传
- `baseUrl`/`apiKey` 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
- 尺寸参数由前端传(下拉可选,默认 1152x2048 竖图,与既有训练图一致;另提供 704x704 档)
- 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);**实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟)**,超时与前端等待按此配置,一次建议 1 张
- 生成同步执行(count 1..8,每张超时 `imageGen.timeoutSeconds`,默认 120slocalai 建议 600),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则)
- prompt 由前端预设模板 + 用户微调;**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束)
@@ -342,7 +342,7 @@ training:
venvPython: /opt/pheasant_data/venv/bin/python
datasetDir: datasets # 训练机数据集根目录(相对 workdir,数据集为子目录)
concurrency: 1 # GPU 独占:同时仅一个 running,新任务排队
timeoutMinutes: 600 # 超时判死
timeoutMinutes: 240 # 超时判死started_at 起算;100 张实测约 3 分钟)
imgsz: 704 # 训练/导出分辨率(须与 App 端推理输入对齐)
epochs: 150 # 训练轮数(patience 30 早停,设大可自动停)
batch: 16 # 批大小(按训练机显存调整)
+5 -8
View File
@@ -62,15 +62,15 @@ const addVisible = ref(false)
const addMode = ref('upload') // upload | ai
const uploadList = ref([])
const uploading = ref(false)
const genForm = reactive({ prompt: '', count: 4, size: '1152x2048' })
const genForm = reactive({ prompt: '', count: 1, size: '704x1248' })
const generating = ref(false)
function openAdd() {
addMode.value = 'upload'
uploadList.value = []
genForm.prompt = ''
genForm.count = 4
genForm.size = '1152x2048'
genForm.count = 1
genForm.size = '704x1248'
addVisible.value = true
}
@@ -109,7 +109,7 @@ function submitGen() {
prompt: genForm.prompt.trim(),
count: genForm.count,
size: genForm.size,
}, { timeout: 600000 })
}, { timeout: 2400000 })
.then((data) => {
ElMessage.success(`已生成 ${data.generated || 0} 张(生成图片为付费资产,删除前请确认),自动标注处理中…`)
addVisible.value = false
@@ -633,10 +633,7 @@ onBeforeUnmount(() => {
<el-input-number v-model="genForm.count" :min="1" :max="8" />
</el-form-item>
<el-form-item label="图片尺寸">
<el-radio-group v-model="genForm.size">
<el-radio value="1152x2048">竖版 1152x2048</el-radio>
<el-radio value="704x704">方形 704x704</el-radio>
</el-radio-group>
<span class="gen-size">竖版 704x1248很慢 26 分钟/建议一次只生成 1 </span>
</el-form-item>
<div class="gen-tip">生成图片为付费资产请勿随意删除提示词中不要描述目标在画面中的位置由模型自行构图</div>
</el-form>
+56 -64
View File
@@ -1,7 +1,7 @@
<script setup>
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Picture, Setting, VideoPlay } from '@element-plus/icons-vue'
import { EditPen, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import request from '../api/request'
@@ -13,14 +13,13 @@ const page = ref(1)
const size = ref(12)
const keyword = ref('')
const createVisible = ref(false)
const createForm = reactive({ name: '' })
const creating = ref(false)
const configVisible = ref(false)
const configSaving = ref(false)
const configForm = reactive({
const formVisible = ref(false)
const saving = ref(false)
// 新建/编辑共用弹窗:edit 模式下名称只读(服务端 update 接口不支持改名)
const dlgForm = reactive({
mode: 'create', // create | edit
id: 0,
name: '',
description: '',
cover: '',
})
@@ -53,45 +52,34 @@ function search() {
load()
}
// ---------- 新建 / 编辑数据集(名称 + 描述 + 封面;AI 端点/训练机 SSH 走 config.yml,与数据集无关) ----------
function resetCoverState() {
coverFile.value = null
coverDeleted.value = false
}
function openCreate() {
createForm.name = ''
createVisible.value = true
Object.assign(dlgForm, { mode: 'create', id: 0, name: '', description: '', cover: '' })
resetCoverState()
coverFileList.value = []
formVisible.value = true
}
function submitCreate() {
if (!createForm.name.trim()) {
ElMessage.warning('请输入数据集名称')
return
}
creating.value = true
request
.post('/datasets', { name: createForm.name.trim(), source: 'manual' })
.then(() => {
ElMessage.success('数据集已创建,请上传或生成图片')
createVisible.value = false
page.value = 1
load()
})
.finally(() => {
creating.value = false
})
}
// ---------- 数据集级配置(描述 + 封面;AI 端点/训练机 SSH 走 config.yml,与数据集无关) ----------
function openConfig(row) {
Object.assign(configForm, {
function openEdit(row) {
Object.assign(dlgForm, {
mode: 'edit',
id: row.id,
name: row.name,
description: row.description || '',
cover: row.cover || '',
})
coverFile.value = null
coverDeleted.value = false
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 || '')}`) }]
: []
configVisible.value = true
formVisible.value = true
}
// 封面文件选中:多选时只保留最后一个(替换语义);新文件选中即视为不再删除
@@ -106,29 +94,43 @@ function onCoverRemove(file) {
coverFile.value = null
if (!file.raw) {
coverDeleted.value = true
} else if (!coverFileList.value.length && configForm.cover) {
} else if (!coverFileList.value.length && dlgForm.cover) {
coverDeleted.value = true
}
}
async function submitConfig() {
configSaving.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') {
id = (await request.post('/datasets', { name, source: 'manual' })).id
}
if (coverFile.value) {
const fd = new FormData()
fd.append('datasetId', configForm.id)
fd.append('datasetId', 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 })
} else if (dlgForm.mode === 'edit' && coverDeleted.value) {
await request.post('/datasets/cover/delete', { datasetId: id })
}
// cover 由上传接口单独维护,update 不携带
await request.post('/datasets/update', { ...configForm, cover: '' })
ElMessage.success('配置已保存')
configVisible.value = false
// create 且未填描述时跳过 update(服务端空值不覆盖,避免空跑一次)
if (dlgForm.mode === 'edit' || dlgForm.description.trim()) {
await request.post('/datasets/update', { id, name, description: dlgForm.description.trim(), cover: '' })
}
ElMessage.success(dlgForm.mode === 'create' ? '数据集已创建,请上传或生成图片' : '已保存')
formVisible.value = false
page.value = 1
load()
} finally {
configSaving.value = false
saving.value = false
}
}
@@ -262,7 +264,7 @@ onBeforeUnmount(() => {
>
{{ row.trainingStatus === 'running' ? '训练中' : '开始训练' }}
</el-button>
<el-button size="small" :icon="Setting" @click="openConfig(row)">配置</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>
@@ -303,29 +305,19 @@ onBeforeUnmount(() => {
@change="load"
/>
<!-- 新建数据集 -->
<el-dialog v-model="createVisible" title="新建数据集" width="min(440px, 92vw)">
<!-- 新建 / 编辑数据集同一弹窗编辑模式回填原值且允许改名 -->
<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="createForm.name"
v-model="dlgForm.name"
maxlength="50"
show-word-limit
placeholder="中文/字母/数字/下划线/短横线,唯一且作磁盘目录名"
/>
</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-input v-model="dlgForm.description" type="textarea" :rows="6" maxlength="500" show-word-limit placeholder="数据集说明,展示在卡片上" />
</el-form-item>
<el-form-item label="封面">
<div class="cover-row">
@@ -346,8 +338,8 @@ onBeforeUnmount(() => {
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configVisible = false">取消</el-button>
<el-button type="primary" :loading="configSaving" @click="submitConfig">保存</el-button>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitDlg">{{ dlgForm.mode === 'create' ? '创建' : '保存' }}</el-button>
</template>
</el-dialog>