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
}