task-116: 后台管理新增/导入表单弹窗化 + 分组管理独立菜单与UI优化 + 轻量进度端点
Build Backend JAR / build (push) Has been cancelled

- 后台管理页(admin)所有面板的新增/导入表单改为弹窗操作,原有字段 ID 全部保留、提交逻辑不变;导入删除入口不再触发二次确认拦截
- 分组管理升级为独立菜单(V102 + schema initializer),移除 5 个面板内的管理分组按钮;分组列表改为蓝白主题、增加权限分组横幅
- Python 侧 group-manage 权限守卫(_ensure_backend_menu_access 补充 group-manage)
- 引入 V101(biz_task_file_job 复合索引)+ 新增 TaskProgressLight/TaskFileJob 轻量端点与进度聚合作
- 前端 progress-light / page-separated-loads / dispatch-guard 共享模块及单元测试
This commit is contained in:
2026-09-01 12:54:13 +08:00
parent 759f0b15d8
commit fa5a59e5cd
139 changed files with 10540 additions and 8834 deletions
+12
View File
@@ -139,6 +139,7 @@ test('test_endpoints_frozen_snapshot', () => {
fileActivate: '/api/publish/tasks/{taskId}/files/{fileId}/activate',
items: '/api/publish/tasks/{taskId}/items',
progressBatch: '/api/publish/tasks/progress/batch',
progressLight: '/api/publish/tasks/progress/light',
result: '/api/publish/tasks/{taskId}/result',
dashboard: '/api/publish/dashboard',
history: '/api/publish/history',
@@ -154,6 +155,7 @@ test('test_endpoints_frozen_snapshot', () => {
resultDownload: '/api/delete-brand/results/{resultId}/download',
resultSubmit: '/api/delete-brand/tasks/{taskId}/result',
progressBatch: '/api/delete-brand/tasks/progress/batch',
progressLight: '/api/delete-brand/tasks/progress/light',
},
productRisk: {
candidates: '/api/product-risk-resolve/candidates',
@@ -168,6 +170,7 @@ test('test_endpoints_frozen_snapshot', () => {
taskDelete: '/api/product-risk-resolve/tasks/{taskId}',
taskBatch: '/api/product-risk-resolve/tasks/batch',
progressBatch: '/api/product-risk-resolve/tasks/progress/batch',
progressLight: '/api/product-risk-resolve/tasks/progress/light',
pendingShopResult: '/api/product-risk-resolve/pending-shop-result',
resultDownload: '/api/product-risk-resolve/results/{resultId}/download',
},
@@ -186,6 +189,7 @@ test('test_endpoints_frozen_snapshot', () => {
stageFinished: '/api/shop-match/tasks/{taskId}/stage-finished',
taskBatch: '/api/shop-match/tasks/batch',
progressBatch: '/api/shop-match/tasks/progress/batch',
progressLight: '/api/shop-match/tasks/progress/light',
skipAsins: '/api/shop-match/tasks/{taskId}/skip-asins/paginated',
resultDownload: '/api/shop-match/results/{resultId}/download',
},
@@ -201,6 +205,7 @@ test('test_endpoints_frozen_snapshot', () => {
history: '/api/patrol-delete/history',
historyDelete: '/api/patrol-delete/history/{resultId}',
progressBatch: '/api/patrol-delete/tasks/progress/batch',
progressLight: '/api/patrol-delete/tasks/progress/light',
taskCreate: '/api/patrol-delete/tasks',
taskDelete: '/api/patrol-delete/tasks/{taskId}',
taskResult: '/api/patrol-delete/tasks/{taskId}/result',
@@ -215,6 +220,7 @@ test('test_endpoints_frozen_snapshot', () => {
history: '/api/query-asin/history',
historyDelete: '/api/query-asin/history/{resultId}',
progressBatch: '/api/query-asin/tasks/progress/batch',
progressLight: '/api/query-asin/tasks/progress/light',
taskCreate: '/api/query-asin/tasks',
taskDelete: '/api/query-asin/tasks/{taskId}',
taskResult: '/api/query-asin/tasks/{taskId}/result',
@@ -230,6 +236,7 @@ test('test_endpoints_frozen_snapshot', () => {
history: '/api/shop-data-crawl/history',
historyDelete: '/api/shop-data-crawl/history/{resultId}',
progressBatch: '/api/shop-data-crawl/tasks/progress/batch',
progressLight: '/api/shop-data-crawl/tasks/progress/light',
taskCreate: '/api/shop-data-crawl/tasks',
taskDelete: '/api/shop-data-crawl/tasks/{taskId}',
resultDownload: '/api/shop-data-crawl/results/{resultId}/download',
@@ -244,6 +251,7 @@ test('test_endpoints_frozen_snapshot', () => {
history: '/api/withdraw/history',
historyDelete: '/api/withdraw/history/{resultId}',
progressBatch: '/api/withdraw/tasks/progress/batch',
progressLight: '/api/withdraw/tasks/progress/light',
taskCreate: '/api/withdraw/tasks',
taskDelete: '/api/withdraw/tasks/{taskId}',
taskResult: '/api/withdraw/tasks/{taskId}/result',
@@ -259,6 +267,7 @@ test('test_endpoints_frozen_snapshot', () => {
taskDelete: '/api/price-track/tasks/{taskId}',
tasksBatch: '/api/price-track/tasks/batch',
progressBatch: '/api/price-track/tasks/progress/batch',
progressLight: '/api/price-track/tasks/progress/light',
loopRuns: '/api/price-track/loop-runs',
loopRunDetail: '/api/price-track/loop-runs/{loopRunId}',
loopRunStop: '/api/price-track/loop-runs/{loopRunId}/stop',
@@ -279,6 +288,7 @@ test('test_endpoints_frozen_snapshot', () => {
history: '/api/similar-asin/history',
historyDelete: '/api/similar-asin/history/{resultId}',
progressBatch: '/api/similar-asin/tasks/progress/batch',
progressLight: '/api/similar-asin/tasks/progress/light',
taskActivate: '/api/similar-asin/tasks/{taskId}/activate',
taskDelete: '/api/similar-asin/tasks/{taskId}',
resultDownload: '/api/similar-asin/results/{resultId}/download',
@@ -291,6 +301,7 @@ test('test_endpoints_frozen_snapshot', () => {
history: '/api/appearance-patent/history',
historyDelete: '/api/appearance-patent/history/{resultId}',
progressBatch: '/api/appearance-patent/tasks/progress/batch',
progressLight: '/api/appearance-patent/tasks/progress/light',
taskActivate: '/api/appearance-patent/tasks/{taskId}/activate',
taskDelete: '/api/appearance-patent/tasks/{taskId}',
resultDownload: '/api/appearance-patent/results/{resultId}/download',
@@ -302,6 +313,7 @@ test('test_endpoints_frozen_snapshot', () => {
history: '/api/collect-data/history',
historyDelete: '/api/collect-data/history/{resultId}',
progressBatch: '/api/collect-data/tasks/progress/batch',
progressLight: '/api/collect-data/tasks/progress/light',
taskActivate: '/api/collect-data/tasks/{taskId}/activate',
taskDelete: '/api/collect-data/tasks/{taskId}',
fail: '/api/collect-data/tasks/{taskId}/fail',
@@ -0,0 +1,243 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
createPageSeparatedLoads,
type PageSeparatedLoadsDeps,
} from '../src/shared/page-separated-loads.ts'
const tick = (ms = 0) => new Promise<void>((r) => globalThis.setTimeout(r, ms))
function makeDeps(over: Partial<PageSeparatedLoadsDeps> = {}): PageSeparatedLoadsDeps & {
calls: string[]
} {
const calls: string[] = []
return {
calls,
statsLoader: async () => {
calls.push('stats')
return { pendingTaskCount: 1 }
},
summaryLoader: async () => {
calls.push('summary')
return { items: [] }
},
lightLoader: async (ids) => {
calls.push(`light:${ids.join(',')}`)
return { items: ids.map((id) => ({ taskId: id, status: 'RUNNING' })) }
},
detailLoader: async (taskId) => {
calls.push(`detail:${taskId}`)
return { taskId, fileName: `file-${taskId}.xlsx` }
},
resultLoader: async (taskId) => {
calls.push(`result:${taskId}`)
return { taskId, url: `/api/result/${taskId}` }
},
...over,
}
}
const ticks = (ms = 0) => globalThis.setTimeout as unknown as typeof setTimeout
void ticks
test('test_page_dashboard_only_stats', async () => {
// dashboard 通道只调统计接口:不触发 history/light/detail/result 任何调用
const deps = makeDeps()
const page = createPageSeparatedLoads(deps)
await page.loadDashboard()
assert.deepEqual(deps.calls, ['stats'], 'dashboard 通道只调 statsLoader')
assert.deepEqual(
page.requestLog().map((e) => e.phase),
['stats'],
'请求日志只有 stats 通道',
)
})
test('test_page_history_only_summary', async () => {
// history 通道只调摘要接口:不拉任何任务明细
const deps = makeDeps()
const page = createPageSeparatedLoads(deps)
await page.loadHistory()
assert.deepEqual(deps.calls, ['summary'], 'history 通道只调 summaryLoader')
assert.ok(!deps.calls.some((c) => c.startsWith('detail:')), 'history 不拉明细')
assert.ok(!deps.calls.some((c) => c.startsWith('result:')), 'history 不拉结果')
assert.equal(deps.calls.filter((c) => c === 'summary').length, 1, '摘要仅一次')
assert.deepEqual(
page.requestLog().map((e) => e.phase),
['summary'],
)
})
test('test_page_progress_only_light', async () => {
// progress 通道只调轻量进度:请求路径为 light,不触碰 detail/result 通道
const deps = makeDeps()
const page = createPageSeparatedLoads(deps)
await page.loadProgress()
assert.ok(deps.calls.some((c) => c.startsWith('light:')), 'progress 调用 lightLoader')
assert.ok(!deps.calls.some((c) => c.startsWith('detail:')), '不调明细')
assert.ok(!deps.calls.some((c) => c.startsWith('result:')), '不调结果')
const light = page.requestLog().find((e) => e.phase === 'light')
assert.ok(light, '请求日志记录 light 通道')
})
test('test_page_no_full_detail_on_load', async () => {
// 首次打开不加载全部明细:初载只有三类轻量请求,无 detail/result
const deps = makeDeps({
lightLoader: async () => ({ items: [1, 2, 3].map((taskId) => ({ taskId, status: 'RUNNING' })) }),
})
const page = createPageSeparatedLoads(deps)
await page.initialLoad()
assert.equal(
deps.calls.filter((c) => c.startsWith('detail:')).length,
0,
'首开不拉任何明细',
)
assert.equal(
deps.calls.filter((c) => c.startsWith('result:')).length,
0,
'首开不拉任何结果',
)
assert.deepEqual(
page.requestLog().map((e) => e.phase),
['stats', 'summary', 'light'],
'初载仅 stats/summary/light 三通道',
)
})
test('test_page_items_on_open', async () => {
// 打开明细才请求:expand 后仅请求对应任务,未展开的任务不请求
const deps = makeDeps({
lightLoader: async () => ({ items: [10, 20].map((taskId) => ({ taskId, status: 'RUNNING' })) }),
})
const page = createPageSeparatedLoads(deps)
await page.initialLoad()
const detail = await page.openDetail(10)
assert.ok(deps.calls.includes('detail:10'), '展开 10 才请求其明细')
assert.ok(!deps.calls.includes('detail:20'), '未展开 20 不请求明细')
assert.deepEqual(detail, { taskId: 10, fileName: 'file-10.xlsx' })
assert.equal(page.requestLog().filter((e) => e.phase === 'detail').length, 1)
})
test('test_page_result_on_demand', async () => {
// 结果按需:用户点取结果才请求结果文件,初载与展开明细都不触发
const deps = makeDeps({
lightLoader: async () => ({ items: [7].map((taskId) => ({ taskId, status: 'SUCCESS' })) }),
})
const page = createPageSeparatedLoads(deps)
await page.initialLoad()
await page.openDetail(7)
assert.equal(deps.calls.filter((c) => c.startsWith('result:')).length, 0, '取结果前不请求')
const result = await page.openResult(7)
assert.ok(deps.calls.includes('result:7'), '点取结果才请求')
assert.deepEqual(result, { taskId: 7, url: '/api/result/7' })
})
test('test_page_request_count_bounded', async () => {
// 请求数有界:初载恒为 3 个并发轻量请求,每次按需展开仅 +1,不因任务数放大
const deps = makeDeps({
lightLoader: async () => ({ items: [1, 2, 3, 4, 5].map((taskId) => ({ taskId, status: 'RUNNING' })) }),
})
const page = createPageSeparatedLoads(deps)
await page.initialLoad()
await Promise.all([1, 2, 3, 4, 5].map((id) => page.openDetail(id)))
await tick()
const counts = page.requestLog().reduce<Record<string, number>>((acc, e) => {
acc[e.phase] = (acc[e.phase] ?? 0) + 1
return acc
}, {})
assert.equal(counts.stats, 1, '统计仅 1 次')
assert.equal(counts.summary, 1, '摘要仅 1 次')
assert.equal(counts.light, 1, '轻量进度仅 1 次(批内合并)')
assert.equal(counts.detail ?? 0, 5, '5 个任务各展开 1 次明细')
assert.ok(Object.keys(counts).length <= 4, '不产生任何其它通道请求')
assert.ok(page.requestLog().length <= 3 + 5, '总请求数 = 初载 3 + 按需 5,有界')
})
test('test_page_snapshot', async () => {
// 请求快照:完整回放页面生命周期内的请求顺序与 taskIds
const calls: string[] = []
const deps: PageSeparatedLoadsDeps = {
statsLoader: async () => {
calls.push('stats')
return {}
},
summaryLoader: async () => {
calls.push('summary')
return { items: [] }
},
lightLoader: async (ids) => {
calls.push(`light:${ids.join(',')}`)
return { items: [1, 2].map((taskId) => ({ taskId, status: 'RUNNING' })) }
},
detailLoader: async (taskId) => {
calls.push(`detail:${taskId}`)
return { taskId }
},
resultLoader: async (taskId) => {
calls.push(`result:${taskId}`)
return { taskId }
},
}
const page = createPageSeparatedLoads(deps)
await page.initialLoad()
await page.openDetail(1)
await page.openResult(2)
assert.deepEqual(
page.requestLog().map((e) => [e.phase, e.taskIds]),
[
['stats', []],
['summary', []],
['light', []],
['detail', [1]],
['result', [2]],
],
'快照按请求发生顺序记录阶段与任务',
)
assert.deepEqual(
calls,
['stats', 'summary', 'light:', 'detail:1', 'result:2'],
'loader 调用序列与快照一致',
)
})
test('test_page_open_unknown_task_no_request', async () => {
// 轻量通道未就绪的任务展开/取结果不发起任何请求(不存在任务静默返回)
const deps = makeDeps({ lightLoader: async () => ({ items: [] }) })
const page = createPageSeparatedLoads(deps)
await page.initialLoad()
const detail = await page.openDetail(999)
const result = await page.openResult(999)
assert.equal(detail, undefined)
assert.equal(result, undefined)
assert.equal(deps.calls.filter((c) => c.startsWith('detail:')).length, 0)
assert.equal(deps.calls.filter((c) => c.startsWith('result:')).length, 0)
})
test('test_page_deps_validation', async () => {
await assert.rejects(async () => {
createPageSeparatedLoads(null as never).initialLoad()
}, /statsLoader/)
await assert.rejects(async () => {
createPageSeparatedLoads(
{
statsLoader: async () => ({}),
summaryLoader: async () => ({}),
lightLoader: async () => ({}),
detailLoader: async () => ({}),
resultLoader: async () => ({}),
} as never,
{ maxConcurrentRequests: 0 },
).initialLoad()
}, /maxConcurrentRequests/)
})
test('test_page_light_items_seeded', async () => {
// light 响应带 taskId 时,初载后这些任务即视为就绪,可直接按需展开
const deps = makeDeps({
lightLoader: async () => ({ items: [42].map((taskId) => ({ taskId, status: 'RUNNING' })) }),
})
const page = createPageSeparatedLoads(deps)
await page.initialLoad()
assert.equal((await page.openDetail(42)).fileName, 'file-42.xlsx')
assert.equal((await page.openResult(42)).url, '/api/result/42')
})
+166
View File
@@ -0,0 +1,166 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { http } from '../src/shared/api/http.ts'
import { API_ENDPOINTS } from '../src/shared/api/endpoints.ts'
import {
getModuleProgressLight,
type ProgressLightModule,
type TaskProgressLightItem,
} from '../src/shared/api/progress-light.ts'
import type { TaskProgressBatchVo } from '../src/shared/api/types/task.ts'
function setupWindow() {
;(globalThis as Record<string, unknown>).window = {
localStorage: { getItem: () => '42' },
location: { origin: 'http://localhost' },
}
}
function mockRequest(
t: Parameters<typeof test>[1] extends (t: infer T) => unknown ? T : never,
impl: (config: { url?: string; method?: string; params?: Record<string, unknown>; data?: unknown; timeout?: number }) => Promise<unknown>,
) {
t.mock.method(http, 'request', impl as never)
}
const okResponse = (data: unknown) => Promise.resolve({ data: { success: true, message: 'ok', data } })
const LIGHT_MODULES: ProgressLightModule[] = [
'publish', 'deleteBrand', 'productRisk', 'shopMatch', 'patrolDelete',
'queryAsin', 'shopDataCrawl', 'withdraw', 'priceTrack', 'similarAsin',
'appearancePatent', 'collectData',
]
test('test_light_url_module', async (t) => {
setupWindow()
const seen: { url?: string }[] = []
mockRequest(t, (config) => {
seen.push(config)
return okResponse({ items: [], missingTaskIds: [] })
})
for (const module of LIGHT_MODULES) {
const endpoint = (API_ENDPOINTS as Record<string, Record<string, string>>)[module].progressLight
assert.ok(endpoint && endpoint.endsWith('/tasks/progress/light'), `${module} 应有 progressLight 端点: ${endpoint}`)
await getModuleProgressLight(module, [1])
}
assert.equal(seen.length, LIGHT_MODULES.length)
assert.deepEqual(seen.map((c) => c.url), LIGHT_MODULES.map((m) => {
const raw = (API_ENDPOINTS as Record<string, Record<string, string>>)[m].progressLight
return `/newApi${raw}`
}), '每个模块的 light URL 与端点常量一一对应')
})
test('test_light_payload', async (t) => {
setupWindow()
let captured: { url?: string; method?: string; data?: unknown } = {}
mockRequest(t, (config) => {
captured = config
return okResponse({ items: [], missingTaskIds: [] })
})
await getModuleProgressLight('similarAsin', [5, 3, 5])
assert.equal(captured.url, '/newApi/api/similar-asin/tasks/progress/light')
assert.equal(captured.method, 'POST')
assert.deepEqual(captured.data, { taskIds: [3, 5] }, 'taskIds 去重排序后进 body')
const empty = await getModuleProgressLight('similarAsin', [])
assert.deepEqual(empty, { items: [], missingTaskIds: [] }, '空 taskIds 不发请求直接返回空结构')
})
test('test_light_response_shape', async (t) => {
setupWindow()
mockRequest(t, () => okResponse({
items: [
{ taskId: 1, status: 'RUNNING', statusCode: null, fileStatus: null, fileError: null, fileReady: false, updatedAt: '2026-09-01T10:00:00' },
{ taskId: 2, status: 'SUCCESS', statusCode: null, fileStatus: 'SUCCESS', fileError: null, fileReady: true, updatedAt: '2026-09-01T10:05:00' },
],
missingTaskIds: [],
}))
const result = await getModuleProgressLight('publish', [1, 2])
assert.equal(result.items.length, 2, 'items 数组保留')
assert.equal(result.items[0].taskId, 1)
assert.equal(result.items[0].status, 'RUNNING')
assert.equal(result.items[0].fileReady, false)
assert.equal(result.items[1].fileStatus, 'SUCCESS')
assert.equal(result.items[1].fileReady, true)
assert.equal(result.items[1].updatedAt, '2026-09-01T10:05:00')
assert.deepEqual(result.missingTaskIds, [])
})
test('test_light_fallback_flag', async (t) => {
setupWindow()
let called = 0
mockRequest(t, () => {
called += 1
return okResponse({ items: [], missingTaskIds: [] })
})
const result = await getModuleProgressLight('withdraw', [7], { useLight: false })
assert.equal(called, 0, 'useLight=false 时不发起 light 请求(调用方自行回退旧端点)')
assert.deepEqual(result, { items: [], missingTaskIds: [] })
const on = await getModuleProgressLight('withdraw', [7], { useLight: true })
assert.equal(called, 1, 'useLight=true(默认)时发起 light 请求')
assert.deepEqual(on, { items: [], missingTaskIds: [] })
})
test('test_light_missing_ids', async (t) => {
setupWindow()
mockRequest(t, () => okResponse({
items: [{ taskId: 1, status: 'RUNNING' }],
missingTaskIds: [9, 10],
}))
const result = await getModuleProgressLight('collectData', [1, 9, 10])
assert.deepEqual(result.missingTaskIds, [9, 10], 'missingTaskIds 映射为 number[]')
assert.equal(result.items.length, 1)
mockRequest(t, () => okResponse({ items: [{ taskId: 1, status: 'RUNNING' }] }))
const noKey = await getModuleProgressLight('collectData', [1], { force: true })
assert.deepEqual(noKey.missingTaskIds, [], '后端缺 missingTaskIds 字段时默认空数组')
})
test('test_light_timeout', async (t) => {
setupWindow()
let captured: { timeout?: number } = {}
mockRequest(t, (config) => {
captured = config
return okResponse({ items: [], missingTaskIds: [] })
})
await getModuleProgressLight('priceTrack', [1], { force: true })
assert.equal(captured.timeout, 10000, '进度类请求默认 10s 超时')
})
test('test_light_type_compat', async (t) => {
setupWindow()
mockRequest(t, () => okResponse({
items: [{ taskId: 1, status: 'PENDING' }],
missingTaskIds: [],
}))
const result = await getModuleProgressLight('shopMatch', [1], { force: true })
const batch: TaskProgressBatchVo<TaskProgressLightItem> = result
assert.ok('items' in batch && 'missingTaskIds' in batch, '结构与现有 TaskProgressBatchVo 兼容')
assert.equal(batch.items[0].taskId, 1)
assert.equal(batch.items[0].status, 'PENDING')
})
test('test_light_snapshot', async (t) => {
setupWindow()
const calls: Array<{ url?: string; method?: string; params?: Record<string, unknown>; data?: unknown }> = []
mockRequest(t, (config) => {
calls.push(config)
return okResponse({ items: [{ taskId: 1, status: 'RUNNING', fileReady: false }], missingTaskIds: [] })
})
await getModuleProgressLight('appearancePatent', [1], { force: true })
await getModuleProgressLight('deleteBrand', [2], { force: true })
assert.equal(calls[0].url, '/newApi/api/appearance-patent/tasks/progress/light')
assert.equal(calls[0].method, 'POST')
assert.deepEqual(calls[0].data, { taskIds: [1] })
assert.equal(calls[1].url, '/newApi/api/delete-brand/tasks/progress/light')
assert.deepEqual(calls[1].data, { taskIds: [2] })
})
test('test_light_reexport', async () => {
setupWindow()
const javaModules = await import('../src/shared/api/java-modules.ts')
const direct = await import('../src/shared/api/progress-light.ts')
assert.equal(javaModules.getModuleProgressLight, direct.getModuleProgressLight, 'java-modules 应 re-export getModuleProgressLight')
assert.equal(javaModules.TaskProgressLightItem, direct.TaskProgressLightItem, '类型应同一引用')
})