feat(ota): 支持按版本号+版本名+灰度状态检查与分组显示

- 后端新增检查版本是否存在时对版本名称做匹配,避免重复版本名称
- 更新时同步考虑版本名称,防止名称冲突
- 前端实现同型号+版本号+灰度多条记录折叠显示,支持展开查看子记录
- 调整表格列宽及样式,确保树形结构显示正常且细节优化
- 增加分组行视觉区分,弱化折叠子行背景
- 重置展开状态,确保刷新数据后树形结构收起
This commit is contained in:
eafonyang
2026-09-03 15:53:03 +08:00
parent f60255574e
commit e6da74cf01
3 changed files with 60 additions and 15 deletions
+10 -8
View File
@@ -215,7 +215,7 @@ router.post('/api/ota/', async (req: Request, res: Response) => {
const data = parsed.data;
logger.info(`Creating OTA: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}`);
// 检查版本号是否已存在
// 检查版本号+版本名称是否已存在
const [existing] = await db
.select()
.from(otas)
@@ -223,11 +223,12 @@ router.post('/api/ota/', async (req: Request, res: Response) => {
eq(otas.verCode, data.verCode),
eq(otas.model, data.model!),
eq(otas.beta, data.beta ?? 0),
eq(otas.verName, data.verName),
))
.limit(1);
if (existing) {
logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}, beta=${data.beta ?? 0}`);
res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)'));
logger.warn(`OTA version already exists: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}, beta=${data.beta ?? 0}`);
res.json(ApiResponse.error('该版本已存在(相同版本+名称+灰度状态)'));
return;
}
@@ -279,20 +280,21 @@ router.put('/api/ota/:ota_id', async (req: Request, res: Response) => {
return;
}
// 如果更新版本号或灰度状态,检查是否冲突
// 如果更新版本号、名称或灰度状态,检查是否冲突
const newVerCode = data.verCode !== undefined && data.verCode !== null ? data.verCode : dbOta.verCode;
const newModel = data.model !== undefined && data.model !== null ? data.model : dbOta.model;
const newBeta = data.beta !== undefined && data.beta !== null ? data.beta : dbOta.beta;
const newVerName = data.verName !== undefined && data.verName !== null ? data.verName : dbOta.verName;
if (newVerCode !== dbOta.verCode || newModel !== dbOta.model || newBeta !== dbOta.beta) {
if (newVerCode !== dbOta.verCode || newModel !== dbOta.model || newBeta !== dbOta.beta || newVerName !== dbOta.verName) {
const [existing] = await db
.select()
.from(otas)
.where(and(eq(otas.verCode, newVerCode), eq(otas.model, newModel!), eq(otas.beta, newBeta)))
.where(and(eq(otas.verCode, newVerCode), eq(otas.model, newModel!), eq(otas.beta, newBeta), eq(otas.verName, newVerName)))
.limit(1);
if (existing) {
logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}, beta=${newBeta}`);
res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)'));
logger.warn(`OTA version already exists: verCode=${newVerCode}, verName=${newVerName}, model=${newModel}, beta=${newBeta}`);
res.json(ApiResponse.error('该版本已存在(相同版本+名称+灰度状态)'));
return;
}
}
-4
View File
@@ -41,8 +41,6 @@ declare module 'vue' {
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NDataTable: typeof import('naive-ui')['NDataTable']
NDatePicker: typeof import('naive-ui')['NDatePicker']
@@ -134,8 +132,6 @@ declare global {
const NButton: typeof import('naive-ui')['NButton']
const NCard: typeof import('naive-ui')['NCard']
const NCheckbox: typeof import('naive-ui')['NCheckbox']
const NCollapse: typeof import('naive-ui')['NCollapse']
const NCollapseItem: typeof import('naive-ui')['NCollapseItem']
const NColorPicker: typeof import('naive-ui')['NColorPicker']
const NDataTable: typeof import('naive-ui')['NDataTable']
const NDatePicker: typeof import('naive-ui')['NDatePicker']
@@ -47,6 +47,9 @@ interface OtaRow {
target?: number;
status?: number;
create_at?: string;
children?: OtaRow[];
__groupChild?: boolean;
__groupCount?: number;
[key: string]: unknown;
}
@@ -66,6 +69,8 @@ const packageFileList = ref<UploadFileInfo[]>([]);
const packageUploading = ref(false);
const skipPackageClearOnModelWatch = ref(false);
const tableData = ref<OtaRow[]>([]);
// 受控的树形展开状态:同组折叠行首列箭头展开,刷新数据后重置为收起
const expandedRowKeys = ref<Array<string | number>>([]);
const searchForm = reactive<{
verName: string;
@@ -193,15 +198,20 @@ function moreOptions(): DropdownOption[] {
}
const columns = computed<DataTableColumns<OtaRow>>(() => [
{ title: 'ID', key: 'id', width: 70 },
// 树形子行首列自带库的缩进补偿(indent 16px + placeholder 24px),
// 列宽需容纳该补偿 + ID 文本,否则内容换行撑高行高
{ title: 'ID', key: 'id', width: 100 },
{ title: '版本号', key: 'verCode', width: 90 },
{
title: '版本名',
key: 'verName',
width: 200,
width: 280,
render(row) {
return h('div', { class: 'flex-y-center gap-8px' }, [
h('span', { class: 'truncate', title: row.verName }, row.verName || '-'),
row.__groupCount && row.__groupCount > 1
? h(NTag, { type: 'info', size: 'small', bordered: false }, { default: () => `${row.__groupCount ?? 1} 条版本` })
: null,
getLatestTag(row)
? h(
NTag,
@@ -287,6 +297,10 @@ const columns = computed<DataTableColumns<OtaRow>>(() => [
}
]);
function rowClassName(row: OtaRow) {
return row.__groupChild ? 'ota-group-child-row' : '';
}
function buildPayload() {
const model = (formData.model == null || formData.model === '' ? '' : String(formData.model)).trim();
return {
@@ -360,6 +374,29 @@ function handlePackageRemove() {
clearPackageFields();
}
// 同「型号+版本号+灰度」的多条记录折叠为一行:后端按 id 倒序返回,
// 组内首次出现即 id 最大的一条,作为主行;其余记录挂到 children,
// 点击主行首列箭头展开查看。分页在服务端按行切分,同组记录跨页时
// 各页独立分组(数据不丢失,仅不折叠)。
const groupedTableData = computed<OtaRow[]>(() => {
const mainByKey = new Map<string, OtaRow>();
const result: OtaRow[] = [];
for (const row of tableData.value) {
const key = `${row.model || ''}__${row.verCode}__${row.beta ?? 0}`;
const main = mainByKey.get(key);
if (!main) {
const copy: OtaRow = { ...row, __groupCount: 1 };
mainByKey.set(key, copy);
result.push(copy);
} else {
main.__groupCount = (main.__groupCount ?? 1) + 1;
if (!main.children) main.children = [];
main.children.push({ ...row, __groupChild: true });
}
}
return result;
});
async function loadData() {
loading.value = true;
// 同步刷新全局最新版本(latest 标签依据,增删改后保持一致)
@@ -378,6 +415,7 @@ async function loadData() {
if (!error && data) {
tableData.value = (data.items as OtaRow[]) || [];
pagination.itemCount = data.total || 0;
expandedRowKeys.value = [];
} else if (!error) {
tableData.value = [];
pagination.itemCount = 0;
@@ -768,7 +806,11 @@ onMounted(() => {
<NDataTable
:loading="loading"
:columns="columns"
:data="tableData"
:data="groupedTableData"
:row-key="(row: OtaRow) => row.id"
:row-class-name="rowClassName"
:expanded-row-keys="expandedRowKeys"
@update:expanded-row-keys="keys => (expandedRowKeys = keys)"
:bordered="false"
:single-line="false"
size="small"
@@ -973,4 +1015,9 @@ onMounted(() => {
:deep(.ota-copy-icon:hover) {
color: var(--n-primary-color);
}
/* 折叠组内的子行(同版本号下较早的记录):弱化背景以区分主行 */
:deep(.ota-group-child-row > td) {
background-color: rgba(128, 128, 128, 0.05);
}
</style>