fa5a59e5cd
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 共享模块及单元测试
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
/**
|
||
* 页面三通道加载编排(任务 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
|
||
}
|