Files
crawler-plugin/frontend-vue/src/shared/page-separated-loads.ts
T
huangzd1997 fa5a59e5cd
Build Backend JAR / build (push) Has been cancelled
task-116: 后台管理新增/导入表单弹窗化 + 分组管理独立菜单与UI优化 + 轻量进度端点
- 后台管理页(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 共享模块及单元测试
2026-09-01 12:54:13 +08:00

160 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 页面三通道加载编排(任务 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
}