diff --git a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue index e1ae7bf9..ff438458 100644 --- a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue @@ -274,6 +274,8 @@ import { } from "@/shared/country-options"; const MAX_TRANSIENT_ERRORS = 30; +/** 任务终态后等待结果文件(Java 侧异步生成)的最大轮次,12 × 10s ≈ 2 分钟 */ +const MAX_FILE_WAIT_ROUNDS = 12; const ziniaoVersion = useZiniaoVersion(); const shopInput = ref(""); @@ -624,6 +626,25 @@ function canDownload(item: PatrolDeleteHistoryItem) { ); } +/** + * 结果文件是否仍在生成中。 + * + * 任务终态(taskStatus=SUCCESS)只代表 Python 端回传完毕,结果 Excel 由 Java 侧 + * 收尾后经 MQ 异步生成(finalizeTaskWorkbook → enqueueAssembleResult),两步之间 + * 有几秒到几十秒的间隔。只按 taskStatus 停轮询会让前端本地的 fileReady 永远停在 + * false,历史记录里就不会出现「下载结果」按钮(任务 27547 即此现象)。 + * + * 判定只认「确实有文件任务」的信号(fileJobId / PENDING / RUNNING):全部店铺失败 + * 的任务不会生成结果文件,其 fileStatus 为空,不应继续等待。 + */ +function isResultFilePending(item?: PatrolDeleteHistoryItem) { + if (!item) return false; + if (item.fileReady || item.downloadUrl) return false; + const fileStatus = String(item.fileStatus || "").toUpperCase(); + if (fileStatus === "SUCCESS" || fileStatus === "FAILED") return false; + return item.fileJobId != null || fileStatus === "PENDING" || fileStatus === "RUNNING"; +} + function saveMatchedItems() { if (typeof window === "undefined") return; window.localStorage.setItem( @@ -965,6 +986,7 @@ function findHistoryItemByTaskId(taskId: number) { async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; + let fileWaitRounds = 0; while (true) { if (disposed) return "STOPPED"; if (activeTaskId.value !== taskId) return "DELETED"; @@ -982,6 +1004,22 @@ async function waitForTaskTerminal(taskId: number) { const item = findHistoryItemByTaskId(taskId); const status = item?.taskStatus || ""; if (status === "SUCCESS" || status === "FAILED" || status === "COMPLETED") { + // 任务终态后结果 Excel 还在异步生成,必须继续轮询到文件就绪再退出: + // 一旦退出,clearActiveQueueTask 会停掉历史轮询,fileReady 就再也刷不出来了 + if (isResultFilePending(item) && fileWaitRounds < MAX_FILE_WAIT_ROUNDS) { + fileWaitRounds += 1; + console.log( + `[patrol-delete] 任务 ${taskId} 已终态(${status}),等待结果文件生成 ${fileWaitRounds}/${MAX_FILE_WAIT_ROUNDS}`, + ); + queuePushResult.value = `任务 ${taskId} 已完成,正在生成结果文件(${fileWaitRounds}/${MAX_FILE_WAIT_ROUNDS})...`; + await sleep(getTaskPollIntervalMs()); + continue; + } + if (isResultFilePending(item)) { + console.warn( + `[patrol-delete] 任务 ${taskId} 结果文件等待超时(${MAX_FILE_WAIT_ROUNDS} 轮),按任务终态返回,可刷新页面查看`, + ); + } return status; } } catch (error) { @@ -1000,6 +1038,36 @@ async function waitForTaskTerminal(taskId: number) { } } +/** + * 页面加载时收敛:历史里有「任务已完成但结果文件仍在生成」的任务时(例如正好在生成 + * 窗口内刷新页面 / 重开客户端),继续轮询到文件就绪,避免下载按钮不出现。 + */ +async function settlePendingResultFiles() { + for (let round = 1; round <= MAX_FILE_WAIT_ROUNDS; round += 1) { + const pendingIds = Array.from(new Set( + taskRecordItems.value + .filter((item) => isTaskTerminal(item.taskStatus) && isResultFilePending(item)) + .map((item) => item.taskId) + .filter((taskId): taskId is number => typeof taskId === "number" && taskId > 0), + )); + if (!pendingIds.length) { + if (round > 1) { + console.log(`[patrol-delete] 结果文件已就绪(第 ${round - 1} 轮收敛完成)`); + } + return; + } + console.log( + `[patrol-delete] 任务 ${pendingIds.join(",")} 结果文件仍在生成,等待中 ${round}/${MAX_FILE_WAIT_ROUNDS}`, + ); + await sleep(getTaskPollIntervalMs()); + if (disposed) return; + await refreshActiveTaskProgress(pendingIds); + } + console.warn( + `[patrol-delete] 结果文件等待超时(${MAX_FILE_WAIT_ROUNDS} 轮),可稍后刷新页面查看下载按钮`, + ); +} + async function processQueue() { if (disposed || queueWorkerRunning.value) return; queueWorkerRunning.value = true; @@ -1196,6 +1264,9 @@ onMounted(async () => { ? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成` : ""; void processQueue(); + } else { + // 未完成任务由 processQueue 负责等待;这里只收敛「任务已完成、结果文件还在生成」的历史任务 + void settlePendingResultFiles(); } });