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
@@ -33,6 +33,7 @@ export const API_ENDPOINTS = {
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',
@@ -48,6 +49,7 @@ export const API_ENDPOINTS = {
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',
@@ -62,6 +64,7 @@ export const API_ENDPOINTS = {
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',
},
@@ -80,6 +83,7 @@ export const API_ENDPOINTS = {
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',
},
@@ -95,6 +99,7 @@ export const API_ENDPOINTS = {
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',
@@ -109,6 +114,7 @@ export const API_ENDPOINTS = {
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',
@@ -124,6 +130,7 @@ export const API_ENDPOINTS = {
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',
@@ -138,6 +145,7 @@ export const API_ENDPOINTS = {
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',
@@ -153,6 +161,7 @@ export const API_ENDPOINTS = {
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',
@@ -173,6 +182,7 @@ export const API_ENDPOINTS = {
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',
@@ -185,6 +195,7 @@ export const API_ENDPOINTS = {
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',
@@ -196,6 +207,7 @@ export const API_ENDPOINTS = {
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',
@@ -14,6 +14,7 @@ export * from "./types/modules/image-video.ts";
export * from "./types/modules/brand.ts";
export * from "./types/modules/permission.ts";
export * from "./types/modules/digital-human.ts";
export * from "./progress-light.ts";
export * from "./upload.ts";
export * from "./download-url.ts";
export { JAVA_API_PREFIX } from "./url.ts";
@@ -0,0 +1,112 @@
import { post, unwrapJavaResponse, type JavaApiResponse } from './http.ts'
import { buildJavaUrl } from './url.ts'
import { API_ENDPOINTS } from './endpoints.ts'
import { getTaskProgressCacheTtlMs, getTaskProgressTimeoutMs } from '../task-progress-config.ts'
import { createTaskProgressRequestCache } from '../task-progress-request-cache.ts'
import type { TaskProgressBatchVo } from './types/task.ts'
/**
* 共享 progress/light 轻量进度端点(任务 118)。
*
* 后端 12 个模块已提供 /tasks/progress/lightTask 109-113),本模块按模块名
* 统一路由到对应端点,把轻量响应归一为现有 TaskProgressBatchVo 结构
* items + missingTaskIdsmissingTaskIds 缺失时补空数组),页面可渐进切换:
* useLight=false 时返回空结构不发起请求(调用方自行回退旧 progress/batch 端点)。
*
* 与各模块 progress/batch 函数一致:同参数请求共享 TTL 缓存与 in-flight 合并,
* 进度类请求默认 10s 超时。
*/
export const PROGRESS_LIGHT_MODULES = [
'publish',
'deleteBrand',
'productRisk',
'shopMatch',
'patrolDelete',
'queryAsin',
'shopDataCrawl',
'withdraw',
'priceTrack',
'similarAsin',
'appearancePatent',
'collectData',
] as const
export type ProgressLightModule = (typeof PROGRESS_LIGHT_MODULES)[number]
/** 轻量进度项(与后端 TaskProgressLightVo 白名单一致) */
export interface TaskProgressLightItem {
taskId?: number
status?: string
statusCode?: string | null
fileStatus?: string | null
fileError?: string | null
fileReady?: boolean | null
updatedAt?: string | null
}
export interface GetModuleProgressLightOptions {
/** 是否走 light 端点;false 时不发起请求返回空结构(调用方回退旧端点),默认 true */
useLight?: boolean
/** 跳过缓存强制请求 */
force?: boolean
}
const progressLightCache = createTaskProgressRequestCache<TaskProgressBatchVo<TaskProgressLightItem>>({
ttlMs: () => getTaskProgressCacheTtlMs(),
maxEntries: 100,
maxInflight: 16,
})
function normalizeTaskIds(taskIds: number[]) {
return Array.from(
new Set(
(taskIds || [])
.map((taskId) => Number(taskId))
.filter((taskId) => Number.isFinite(taskId) && taskId > 0),
),
).sort((left, right) => left - right)
}
export function getModuleProgressLight(
module: ProgressLightModule,
taskIds: number[],
options: GetModuleProgressLightOptions = {},
): Promise<TaskProgressBatchVo<TaskProgressLightItem>> {
if (options.useLight === false) {
return Promise.resolve({ items: [], missingTaskIds: [] })
}
const normalizedTaskIds = normalizeTaskIds(taskIds)
if (!normalizedTaskIds.length) {
return Promise.resolve({ items: [], missingTaskIds: [] })
}
const path = buildJavaUrl(API_ENDPOINTS[module].progressLight)
const cacheKey = `${path}::${normalizedTaskIds.join(",")}`
const cached = progressLightCache.get(cacheKey)
if (!options.force && cached !== undefined) {
return Promise.resolve(cached)
}
const inflight = progressLightCache.getInflight(cacheKey)
if (!options.force && inflight) {
return inflight
}
const requestPromise = unwrapJavaResponse(
post<JavaApiResponse<TaskProgressBatchVo<TaskProgressLightItem>>, { taskIds: number[] }>(
path,
{ taskIds: normalizedTaskIds },
{ timeout: getTaskProgressTimeoutMs() },
),
)
.then((data) => {
const normalized = {
items: data?.items || [],
missingTaskIds: data?.missingTaskIds || [],
}
progressLightCache.set(cacheKey, normalized)
return normalized
})
.finally(() => {
progressLightCache.endInflight(cacheKey)
})
progressLightCache.startInflight(cacheKey, requestPromise)
return requestPromise
}
@@ -0,0 +1,159 @@
/**
* 页面三通道加载编排(任务 119):dashboard / history / progress 请求路径分离。
*
* 约定页面初载只调三类轻量接口——统计(stats)、历史摘要(summary)、轻量进度
* light);明细(detail)与结果(result)仅在用户展开时按需加载。本模块把
* 该约定固化为可测试的编排:注入的 loader 各自独立、互不调用,首开仅并发发起
* 三类轻量加载,展开时再按需取明细/结果,并返回请求日志供断言。
*
* 页面接入示例:
* const page = createPageSeparatedLoads({
* statsLoader: () => getSimilarAsinDashboard(),
* summaryLoader: () => getSimilarAsinHistory(),
* lightLoader: (ids) => getModuleProgressLight('similarAsin', ids),
* detailLoader: (taskId) => getSimilarAsinTaskProgressBatch([taskId]),
* resultLoader: (taskId) => getSimilarAsinResult(taskId),
* })
* onMounted(() => page.initialLoad())
*/
export type PageLoadPhase = 'stats' | 'summary' | 'light' | 'detail' | 'result'
export interface PageRequestLogEntry {
phase: PageLoadPhase
taskIds: number[]
at: number
}
export interface PageSeparatedLoadsDeps {
statsLoader: () => Promise<unknown>
summaryLoader: () => Promise<unknown>
lightLoader: (taskIds: number[]) => Promise<unknown>
detailLoader: (taskId: number) => Promise<unknown>
resultLoader: (taskId: number) => Promise<unknown>
}
export interface PageSeparatedLoadsOptions {
/** 初载并发上限:三类轻量请求 + 每次按需展开 1 个明细/结果 */
maxConcurrentRequests?: number
}
export interface PageSeparatedLoadsApi {
/** dashboard 通道:只调统计 */
loadDashboard: () => Promise<void>
/** history 通道:只调摘要 */
loadHistory: () => Promise<void>
/** progress 通道:只调轻量进度 */
loadProgress: () => Promise<void>
/** 初载:并发发起 stats/summary/light 三类轻量请求,不加载明细 */
initialLoad: () => Promise<void>
/** 展开明细:仅当轻量条目已就绪时按需加载该任务明细 */
openDetail: (taskId: number) => Promise<unknown>
/** 取结果:按需加载指定任务结果文件 */
openResult: (taskId: number) => Promise<unknown>
requestLog: () => PageRequestLogEntry[]
}
export function createPageSeparatedLoads(
deps: PageSeparatedLoadsDeps,
options: PageSeparatedLoadsOptions = {},
): PageSeparatedLoadsApi {
const maxConcurrentRequests = options.maxConcurrentRequests ?? 3
if (maxConcurrentRequests < 1) {
throw new Error('maxConcurrentRequests 必须 ≥ 1')
}
if (typeof deps?.statsLoader !== 'function' || typeof deps?.summaryLoader !== 'function') {
throw new Error('statsLoader / summaryLoader 必须是函数')
}
if (typeof deps?.lightLoader !== 'function' || typeof deps?.detailLoader !== 'function') {
throw new Error('lightLoader / detailLoader 必须是函数')
}
if (typeof deps?.resultLoader !== 'function') {
throw new Error('resultLoader 必须是函数')
}
let inflight = 0
const releaseWaiters: Array<() => void> = []
const log: PageRequestLogEntry[] = []
const loadedLight = new Set<number>()
async function guarded(task: () => Promise<void>) {
while (inflight >= maxConcurrentRequests) {
await new Promise<void>((resolve) => {
releaseWaiters.push(resolve)
})
}
inflight += 1
try {
await task()
} finally {
inflight -= 1
const next = releaseWaiters.shift()
if (next) next()
}
}
function record(phase: PageLoadPhase, taskIds: number[] = []) {
log.push({ phase, taskIds: [...taskIds], at: log.length })
}
return {
async loadDashboard() {
await guarded(async () => {
record('stats')
await deps.statsLoader()
})
},
async loadHistory() {
await guarded(async () => {
record('summary')
await deps.summaryLoader()
})
},
async loadProgress() {
await guarded(async () => {
const ids = loadedLight.size ? [...loadedLight] : []
record('light', ids)
const result = await deps.lightLoader(ids)
for (const item of extractTaskIds(result) ?? []) {
loadedLight.add(item)
}
})
},
async initialLoad() {
await Promise.all([
this.loadDashboard(),
this.loadHistory(),
this.loadProgress(),
])
},
async openDetail(taskId: number) {
if (!loadedLight.has(taskId)) return undefined
let detail: unknown = undefined
await guarded(async () => {
record('detail', [taskId])
detail = await deps.detailLoader(taskId)
})
return detail
},
async openResult(taskId: number) {
if (!loadedLight.has(taskId)) return undefined
let result: unknown = undefined
await guarded(async () => {
record('result', [taskId])
result = await deps.resultLoader(taskId)
})
return result
},
requestLog: () => log,
}
}
/** 从 light 响应中提取已就绪 taskId 列表(兼容 items[{taskId}] 结构,null 安全) */
function extractTaskIds(result: unknown): number[] | undefined {
const items = (result as { items?: Array<{ taskId?: number }> } | null)?.items
if (!Array.isArray(items)) return undefined
const ids = items
.map((item) => item?.taskId)
.filter((id): id is number => typeof id === 'number' && id > 0)
return ids.length ? ids : undefined
}
+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, '类型应同一引用')
})