diff --git a/app/main.py b/app/main.py index 1c561fb..3cc2e65 100644 --- a/app/main.py +++ b/app/main.py @@ -154,7 +154,7 @@ class WindowAPI: ) return result[0] if result else '' - def save_file_from_url(self, url, default_filename='download.bin'): + def save_file_from_url_new(self, url, default_filename='download.bin'): """弹窗选择保存位置,从 url 下载文件并保存。根据文件后缀动态设置保存类型。""" if not url or not url.strip(): return {'success': False, 'error': '下载地址为空'} @@ -360,7 +360,7 @@ def main(): ) api = WindowAPI(window) window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, - api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del) + api.save_file_from_url_new, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del) webview.start( debug=True, storage_path=cache_path, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java index 2ffc659..0fff6e1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java @@ -95,7 +95,7 @@ public class ConvertRunController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载当前用户的转换结果文件。桌面端前端可继续通过 save_file_from_url 选择保存位置。") + @Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载当前用户的转换结果文件。桌面端前端可继续通过 save_file_from_url_new 选择保存位置。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java index 157ef90..54e0a83 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java @@ -103,7 +103,7 @@ public class DeleteBrandRunController { } @GetMapping("/tasks/{taskId}/download") - @Operation(summary = "下载删除品牌任务结果", description = "返回任务结果文件流,便于桌面端通过 save_file_from_url 直接保存。") + @Operation(summary = "下载删除品牌任务结果", description = "返回任务结果文件流,便于桌面端通过 save_file_from_url_new 直接保存。") public void download(@PathVariable Long taskId, jakarta.servlet.http.HttpServletResponse response) { String url = deleteBrandRunService.resolveTaskDownloadUrl(taskId); String filename = deleteBrandRunService.resolveTaskDownloadFilename(taskId); diff --git a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue index 5172082..cedd88a 100644 --- a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue @@ -391,8 +391,8 @@ async function saveTemplateFromUrl(url: string, fallbackFilename: string, bridge return } - if (api?.save_file_from_url) { - const result = await api.save_file_from_url(url, fallbackFilename) + if (api?.save_file_from_url_new) { + const result = await api.save_file_from_url_new(url, fallbackFilename) if (result.success) { ElMessage.success(`已保存:${result.path || fallbackFilename}`) } else if (result.error && result.error !== '用户取消') { @@ -480,8 +480,8 @@ async function downloadConvertResult(item: ConvertResultItem) { } const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.txt` - if (api?.save_file_from_url) { - const result = await api.save_file_from_url(item.downloadUrl, filename) + if (api?.save_file_from_url_new) { + const result = await api.save_file_from_url_new(item.downloadUrl, filename) if (result.success) { ElMessage.success(`已保存:${result.path || filename}`) } else if (result.error && result.error !== '用户取消') { diff --git a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue index a6cc2cd..2c7e4ec 100644 --- a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue @@ -335,8 +335,8 @@ async function downloadCleanResult(item: DedupeResultItem) { } const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_cleaned.xlsx` - if (api?.save_file_from_url) { - const result = await api.save_file_from_url(item.downloadUrl, filename) + if (api?.save_file_from_url_new) { + const result = await api.save_file_from_url_new(item.downloadUrl, filename) if (result.success) { ElMessage.success(`已保存:${result.path || filename}`) } else if (result.error && result.error !== '用户取消') { diff --git a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue index 07f3cb5..d3d0c91 100644 --- a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue @@ -286,22 +286,52 @@ function loadSessionTasksFromStorage() { } function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) { - const validItems = items.filter(i => i.matchStatus === 'MATCHED' && i.success !== false) - if (validItems.length === 0) return + if (!items?.length) return + + const existingIndex = sessionTasks.value.findIndex(t => t.taskId === taskId) + const existingTask = existingIndex >= 0 ? sessionTasks.value[existingIndex] : null + + // 全量保留任务项(不在存储层按 matched/success 过滤),避免单任务多店铺时出现“未处理项丢失”。 + // 同时尽量继承已有的 _pushed/_completed 会话态。 + const mergedItems = items.map((item) => { + const prev = existingTask?.items.find((i) => isSameDeleteBrandItem(i, item)) + + // 保护大字段:当新值为空数组时,保留本地已有的非空数据,避免被轮询详情的精简 payload 覆盖。 + const nextCountries = Array.isArray(item.countries) ? item.countries : undefined + const prevCountries = Array.isArray(prev?.countries) ? prev?.countries : undefined + const countries = (nextCountries && nextCountries.length > 0) + ? nextCountries + : (prevCountries && prevCountries.length > 0 ? prevCountries : (nextCountries ?? prevCountries)) + + const nextPreviewRows = Array.isArray(item.previewRows) ? item.previewRows : undefined + const prevPreviewRows = Array.isArray(prev?.previewRows) ? prev?.previewRows : undefined + const previewRows = (nextPreviewRows && nextPreviewRows.length > 0) + ? nextPreviewRows + : (prevPreviewRows && prevPreviewRows.length > 0 ? prevPreviewRows : (nextPreviewRows ?? prevPreviewRows)) + + return { + ...item, + countries, + previewRows, + _pushed: prev?._pushed ?? (item as SessionDeleteBrandItem)._pushed ?? false, + _completed: prev?._completed ?? (item as SessionDeleteBrandItem)._completed ?? false, + } as SessionDeleteBrandItem + }) const newTask: StoredCurrentTask = { taskId, - items: validItems, - createdAt: Date.now(), - queuePushResult: pushResult, - queuePayloadText: payloadText + items: mergedItems, + createdAt: existingTask?.createdAt || Date.now(), + queuePushResult: pushResult ?? existingTask?.queuePushResult, + queuePayloadText: payloadText ?? existingTask?.queuePayloadText, } - const existingIndex = sessionTasks.value.findIndex(t => t.taskId === taskId) + if (existingIndex >= 0) { sessionTasks.value[existingIndex] = newTask } else { sessionTasks.value.push(newTask) } + if (typeof window !== 'undefined') { window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value)) } @@ -318,7 +348,7 @@ function removeSessionTaskFromStorage(taskId: number) { const historySectionItems = computed(() => historyItems.value.filter( - (item) => !currentSectionItems.value.some(c => c.taskId === item.taskId || (c.resultId && c.resultId === item.resultId)) + (item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item)) ), ); const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0) @@ -366,30 +396,28 @@ function updateCompletedItemsFromProgress(taskId: number | undefined) { let changed = false const info = taskDetails.value[taskId]?.line_progress?.info + const multiFileTask = isMultiFileTask(taskId) - // 多文件任务优先依据后端 finished_files 同步已完成文件数量。 - // 规则:按 sessionTask.items 的顺序,标记「已推送」中的前 N 个为 completed。 - if (isMultiFileTask(taskId) && info?.finished_files != null && info.finished_files >= 0) { - const pushedItems = sessionTask.items.filter(i => i._pushed) - const shouldCompleted = Math.max(0, Math.min(info.finished_files, pushedItems.length)) - - for (let i = 0; i < shouldCompleted; i += 1) { - if (!pushedItems[i]._completed) { - pushedItems[i]._completed = true + // 关键修复:多文件任务下不再用 finished_files 按顺序回填 completed。 + // 否则当第 1 个文件完成后,推入第 2 个文件时会因为 finished_files=1 被误判为“本文件已完成(100%)”。 + // 改为仅当后端进度明确指向该文件且 current_line >= total_lines 时,才标记该文件 completed。 + if (info?.file_name && info.current_line != null && info.total_lines != null && info.total_lines > 0) { + if (info.current_line >= info.total_lines) { + const sessionItem = sessionTask.items.find(i => i.sourceFilename === info.file_name) + if (sessionItem && !sessionItem._completed) { + sessionItem._completed = true changed = true } } } - // 兼容单文件/兜底:当当前文件行进度到达末行时,标记活动文件完成。 - if (info?.file_name && info.current_line != null && info.total_lines != null && info.total_lines > 0) { - if (info.current_line >= info.total_lines) { - const activeItem = currentSectionItems.value.find(item => getItemKey(item) === activeItemKey.value) - const sessionItem = activeItem && activeItem.taskId === taskId - ? findSessionItem(taskId, activeItem) - : sessionTask.items.find(i => i.sourceFilename === info.file_name) - if (sessionItem && !sessionItem._completed) { - sessionItem._completed = true + // 单文件任务兜底:任务终态时标记完成,避免偶发进度丢包导致状态卡住。 + if (!multiFileTask) { + const status = taskDetails.value[taskId]?.task?.status + if ((status === 'SUCCESS' || status === 'FAILED') && sessionTask.items.length === 1) { + const onlyItem = sessionTask.items[0] + if (onlyItem && !onlyItem._completed) { + onlyItem._completed = true changed = true } } @@ -531,6 +559,48 @@ function formatProgress(item: DeleteBrandResultItem) { const detail = taskDetails.value[taskId] const info = detail?.line_progress?.info const fp = getFileProgress(item) + const multiFileTask = isMultiFileTask(taskId) + + // 多文件任务优先按“当前运行文件 + 本地会话态”展示,避免后端 finished_files 按顺序分配导致某个文件被误判 COMPLETED。 + if (multiFileTask) { + const sessionItem = findSessionItem(taskId, item) + const status = detail?.task?.status + const isRunningTarget = Boolean(info?.file_name && info.file_name === item.sourceFilename) + + let displayStatus = sessionItem?._completed ? 'COMPLETED' : (sessionItem?._pushed ? 'PENDING' : 'PENDING') + let processedRows = 0 + let totalRows = Math.max(0, item.totalRows ?? fp?.totalRows ?? 0) + + if (status === 'SUCCESS') { + displayStatus = 'COMPLETED' + processedRows = totalRows + } else if (status === 'FAILED' && sessionItem?._completed) { + displayStatus = 'COMPLETED' + processedRows = totalRows + } else if (isRunningTarget) { + displayStatus = 'RUNNING' + processedRows = Math.max(0, Math.min(totalRows || Number.MAX_SAFE_INTEGER, info?.current_line ?? 0)) + if (!totalRows && info?.total_lines && info.total_lines > 0) { + totalRows = info.total_lines + } + } else if (sessionItem?._completed) { + displayStatus = 'COMPLETED' + processedRows = totalRows + } else if (sessionItem?._pushed) { + displayStatus = 'PENDING' + processedRows = 0 + } + + const parts: string[] = [] + parts.push(`文件状态:${displayStatus}`) + parts.push(`文件进度:${processedRows}/${totalRows}`) + if (info?.finished_files != null) { + const total = info.file_total && info.file_total > 0 ? `/${info.file_total}` : '' + parts.push(`已完成文件:${info.finished_files}${total}`) + } + if (info?.phase) parts.push(`阶段:${info.phase}`) + return parts.join('|') + } if (fp) { const parts: string[] = [] @@ -571,12 +641,32 @@ function formatProgressPercent(item: DeleteBrandResultItem) { const taskId = item.taskId if (!taskId) return 0 + const info = taskDetails.value[taskId]?.line_progress?.info + const multiFileTask = isMultiFileTask(taskId) + + if (multiFileTask) { + const sessionItem = findSessionItem(taskId, item) + const taskStatus = taskDetails.value[taskId]?.task?.status + if (taskStatus === 'SUCCESS' || sessionItem?._completed) return 100 + + const totalRows = Math.max(0, item.totalRows ?? getFileProgress(item)?.totalRows ?? 0) + const isRunningTarget = Boolean(info?.file_name && info.file_name === item.sourceFilename) + if (isRunningTarget) { + if (info?.current_line != null && info?.total_lines && info.total_lines > 0) { + return Math.max(0, Math.min(100, Math.round((info.current_line / info.total_lines) * 100))) + } + if (info?.current_line != null && totalRows > 0) { + return Math.max(0, Math.min(100, Math.round((info.current_line / totalRows) * 100))) + } + } + return 0 + } + const fp = getFileProgress(item) if (fp) { return Math.max(0, Math.min(100, fp.percent ?? 0)) } - const info = taskDetails.value[taskId]?.line_progress?.info if (!info) return 0 if (info.current_line != null && info.total_lines && info.total_lines > 0) { @@ -667,30 +757,58 @@ function canDownloadTaskResult(item: DeleteBrandResultItem) { function mergeTaskDetailItemsIntoSession(detail: DeleteBrandTaskDetailVo) { const taskId = detail?.task?.id - if (!taskId || !detail?.items?.length) return false + if (!taskId) return false const sessionTask = sessionTasks.value.find(t => t.taskId === taskId) if (!sessionTask) return false + const detailItems = detail?.items || [] let changed = false + + // 以本地会话项为主做增量合并,保留 _pushed/_completed; + // 对后端新增返回的项追加到末尾,避免单任务多店铺/后续补齐时丢项。 const mergedItems = sessionTask.items.map((item) => { - const latest = detail.items?.find((candidate) => isSameDeleteBrandItem(candidate, item)) + const latest = detailItems.find((candidate) => isSameDeleteBrandItem(candidate, item)) if (!latest) return item + + const latestCountries = Array.isArray(latest.countries) ? latest.countries : undefined + const itemCountries = Array.isArray(item.countries) ? item.countries : undefined + const countries = (latestCountries && latestCountries.length > 0) + ? latestCountries + : (itemCountries && itemCountries.length > 0 ? itemCountries : (latestCountries ?? itemCountries)) + + const latestPreviewRows = Array.isArray(latest.previewRows) ? latest.previewRows : undefined + const itemPreviewRows = Array.isArray(item.previewRows) ? item.previewRows : undefined + const previewRows = (latestPreviewRows && latestPreviewRows.length > 0) + ? latestPreviewRows + : (itemPreviewRows && itemPreviewRows.length > 0 ? itemPreviewRows : (latestPreviewRows ?? itemPreviewRows)) + const merged = { ...item, ...latest, - success: item.success ?? latest.success, + countries, + previewRows, _pushed: item._pushed, _completed: item._completed, - } + } as SessionDeleteBrandItem if (JSON.stringify(merged) !== JSON.stringify(item)) { changed = true } return merged }) - const validItems = mergedItems.filter(i => i.matchStatus === 'MATCHED' && i.success !== false) - if (JSON.stringify(validItems) !== JSON.stringify(sessionTask.items)) { - sessionTask.items = validItems + for (const latest of detailItems) { + if (!mergedItems.some((item) => isSameDeleteBrandItem(item, latest))) { + mergedItems.push({ + ...latest, + _pushed: false, + _completed: false, + } as SessionDeleteBrandItem) + changed = true + } + } + + if (JSON.stringify(mergedItems) !== JSON.stringify(sessionTask.items)) { + sessionTask.items = mergedItems changed = true } @@ -750,21 +868,26 @@ function scheduleNextPoll(immediate = false) { pollingInFlight.value = true let stateChanged = false + let shouldRefreshHistory = false try { await refreshTaskDetails(taskIds) await maybeAutoAdvance() for (const taskId of taskIds) { if (isTaskTerminal(taskId)) { + // 以后端任务终态为准:进入 SUCCESS/FAILED 后立即清理本地当前任务并刷新 history, + // 避免 _completed 会话态偶发不同步导致持续轮询 batch。 removeSessionTaskFromStorage(taskId) stateChanged = true + shouldRefreshHistory = true } } } finally { pollingInFlight.value = false } - if (stateChanged) { - loadHistory() + if (shouldRefreshHistory) { + await loadHistory() + } else if (stateChanged) { syncResultState() } @@ -923,8 +1046,8 @@ async function downloadTaskResult(item: DeleteBrandResultItem) { item.sourceFilename || `delete-brand_${taskId}.xlsx` - if (api?.save_file_from_url) { - const result = await api.save_file_from_url(url, filename) + if (api?.save_file_from_url_new) { + const result = await api.save_file_from_url_new(url, filename) if (result.success) { ElMessage.success(`已保存:${result.path || filename}`) } else if (result.error && result.error !== '用户取消') { @@ -1174,15 +1297,20 @@ onMounted(() => { loadSessionTasksFromStorage() const taskIds = getPollingTaskIds() if (taskIds.length > 0) { - refreshTaskDetails(taskIds).then(() => { + refreshTaskDetails(taskIds).then(async () => { let stateChanged = false + let shouldRefreshHistory = false for (const taskId of taskIds) { if (isTaskTerminal(taskId)) { + // 首屏恢复也以后端终态为准,避免依赖 _completed 造成卡轮询。 removeSessionTaskFromStorage(taskId) stateChanged = true + shouldRefreshHistory = true } } - if (stateChanged) { + if (shouldRefreshHistory) { + await loadHistory() + } else if (stateChanged) { syncResultState() } ensurePolling(true) diff --git a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue index 2fca269..124ac85 100644 --- a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue @@ -18,13 +18,8 @@
与「推送到 Python 队列」一并下发,供 Python 区分处理场景。
与删除 ASIN 品牌相同:先创建任务,再按店铺逐条入队(每条 data.items 仅含一个店铺)。队列串行执行;Python 每处理完一店可 POST /api/product-risk-resolve/tasks/{taskId}/result(可多次,每次可只含已完成的店铺);全部完成后任务结束。前端轮询任务状态。
与删除 ASIN 品牌相同:先创建任务,再按店铺逐条入队(每条 data.items
+ 仅含一个店铺)。队列串行执行;Python
+ 每处理完一店可 POST /api/product-risk-resolve/tasks/{taskId}/result(可多次,每次可只含已完成的店铺);全部完成后任务结束。前端轮询任务状态。
+