diff --git a/app/main.py b/app/main.py index b1225f5..721bd10 100644 --- a/app/main.py +++ b/app/main.py @@ -16,7 +16,6 @@ import webview import threading import time import requests -import subprocess with open("version.txt","w",encoding="utf-8") as file: @@ -235,21 +234,6 @@ class WindowAPI: except Exception as e: return {'success': False, 'error': str(e)} - def open_external_url(self, url): - if not url or not str(url).strip(): - return {'success': False, 'error': '链接为空'} - target = str(url).strip() - try: - if sys.platform.startswith('win'): - os.startfile(target) - elif sys.platform == 'darwin': - subprocess.Popen(['open', target]) - else: - subprocess.Popen(['xdg-open', target]) - return {'success': True} - except Exception as e: - return {'success': False, 'error': str(e)} - def enqueue_json(self, data): """保存任务到队列""" try: @@ -366,7 +350,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.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.enqueue_json) webview.start( debug=True, storage_path=cache_path, diff --git a/backend-java/aiimage-backend.log.2026-03-30.0.gz b/backend-java/aiimage-backend.log.2026-03-30.0.gz new file mode 100644 index 0000000..f900a6e Binary files /dev/null and b/backend-java/aiimage-backend.log.2026-03-30.0.gz differ 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 49d3d4c..157ef90 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 @@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandTaskBatchReque import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskBatchVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskDeletionStatusVo; import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -83,6 +84,15 @@ public class DeleteBrandRunController { return ApiResponse.success(deleteBrandRunService.getTaskDetails(request.getTaskIds())); } + @GetMapping("/tasks/{taskId}/deletion-status") + @Operation(summary = "获取删除品牌任务删除状态", description = "供 Python/插件按 taskId 查询该删除品牌任务是否已被前端删空。") + public ApiResponse getTaskDeletionStatus( + @PathVariable Long taskId, + @Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY) + @RequestParam("user_id") Long userId) { + return ApiResponse.success(deleteBrandRunService.getTaskDeletionStatus(taskId, userId)); + } + @PostMapping("/tasks/{taskId}/result") @Operation(summary = "提交删除品牌处理结果", description = "插件/前端分片回传处理结果;后端累计分片并推进进度,全部完成后再执行最终组装与落库。") public ApiResponse submitResult( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDeletionStatusVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDeletionStatusVo.java new file mode 100644 index 0000000..27f389d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDeletionStatusVo.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "删除品牌任务删除状态响应") +public class DeleteBrandTaskDeletionStatusVo { + @Schema(description = "任务ID") + private Long taskId; + + @Schema(description = "该任务的结果记录是否已被全部删除") + private boolean deleted; + + @Schema(description = "该任务当前剩余的结果记录数量") + private long remainingResultCount; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java index 684a806..a562275 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java @@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandLineProgressVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandResultItemVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskDeletionStatusVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskDetailVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskItemVo; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; @@ -31,6 +32,8 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; @@ -68,7 +71,7 @@ public class DeleteBrandRunService { private final FileResultMapper fileResultMapper; private final LocalFileStorageService localFileStorageService; private final DeleteBrandTaskCacheService deleteBrandTaskCacheService; - private final com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService ziniaoAuthService; + private final ZiniaoShopSwitchService ziniaoShopSwitchService; private final OssStorageService ossStorageService; private final ObjectMapper objectMapper; private final DeleteBrandProgressProperties deleteBrandProgressProperties; @@ -96,7 +99,7 @@ public class DeleteBrandRunService { int parsedSuccessCount = 0; int parsedFailedCount = 0; Map parsedPayloadByFileIdentity = new LinkedHashMap<>(); - Map storeMatchByShopName = new LinkedHashMap<>(); + Map storeMatchByShopName = new LinkedHashMap<>(); for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) { DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); @@ -117,25 +120,22 @@ public class DeleteBrandRunService { item.setPreviewRows(parsed.previewRows()); try { - String normalizedShopName = normalizeShopName(item.getShopName()); - com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = normalizedShopName.isBlank() - ? new com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult(false, null, null, null, null, null) + String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName()); + ZiniaoShopMatchResultVo matchResult = normalizedShopName.isBlank() + ? ziniaoShopSwitchService.emptyMatchResult() : storeMatchByShopName.computeIfAbsent(normalizedShopName, - ignored -> ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null)); - item.setMatched(matchResult.matched()); - if (matchResult.matched()) { - item.setShopId(matchResult.shopId()); - item.setPlatform(matchResult.platform()); - item.setOpenStoreUrl(matchResult.openStoreUrl()); + ignored -> ziniaoShopSwitchService.matchStoreByNameAcrossStaff(item.getShopName(), null)); + item.setMatched(matchResult.isMatched()); + if (matchResult.isMatched()) { + item.setShopId(matchResult.getShopId()); + item.setPlatform(matchResult.getPlatform()); + item.setOpenStoreUrl(matchResult.getOpenStoreUrl()); } else { throw new BusinessException("未匹配到紫鸟店铺,跳过处理"); } } catch (BusinessException ex) { String message = ex.getMessage(); - if (message != null && (message.contains("code=40004") - || message.contains("userId存在无效的参数值") - || message.contains("无权限") - || message.contains("没有权限")) && !message.contains("白名单")) { + if (ziniaoShopSwitchService.isSkippableMatchError(ex)) { log.warn("[ziniao-match] suppressed upstream permission error for {}, msg: {}", item.getShopName(), message); item.setMatched(false); } else { @@ -301,6 +301,24 @@ public class DeleteBrandRunService { return vo; } + public DeleteBrandTaskDeletionStatusVo getTaskDeletionStatus(Long taskId, Long userId) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { + throw new BusinessException("任务不存在"); + } + + Long remainingResultCount = fileResultMapper.selectCount(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getUserId, userId)); + + DeleteBrandTaskDeletionStatusVo vo = new DeleteBrandTaskDeletionStatusVo(); + vo.setTaskId(taskId); + vo.setRemainingResultCount(remainingResultCount == null ? 0L : remainingResultCount); + vo.setDeleted(vo.getRemainingResultCount() == 0); + return vo; + } + public void deleteHistory(Long resultId, Long userId) { FileResultEntity entity = fileResultMapper.selectById(resultId); if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { @@ -400,13 +418,6 @@ public class DeleteBrandRunService { return value.replace(" ", "").trim().toUpperCase(Locale.ROOT); } - private String normalizeShopName(String value) { - if (value == null) { - return ""; - } - return value.replace("\u3000", " ").trim(); - } - private List resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) { List pairs = new ArrayList<>(); int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum()); @@ -876,7 +887,7 @@ public class DeleteBrandRunService { throw new BusinessException("存在未完成的结果分片: " + parsedFile.getSourceFilename()); } MergedDeleteBrandFile mergedFile = mergeChunks(parsedFile, chunks); - File outputFile = buildResultWorkbook(task.getId(), parsedFile, mergedFile); + File outputFile = buildResultWorkbookPreserveLayout(task.getId(), parsedFile, mergedFile); deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( "phase", DeleteBrandTaskCacheService.PHASE_UPLOADING, @@ -1032,6 +1043,52 @@ public class DeleteBrandRunService { } } + private File buildResultWorkbookPreserveLayout(Long taskId, + DeleteBrandParsedFileCacheDto parsedFile, + MergedDeleteBrandFile mergedFile) { + File outputDir = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "delete-brand-result", String.valueOf(taskId))); + File outputFile = buildNamedOutputFile(outputDir, blankToDefault(parsedFile.getSourceFilename(), "delete-brand-result.xlsx")); + + try (Workbook workbook = new XSSFWorkbook(); FileOutputStream outputStream = new FileOutputStream(outputFile)) { + Sheet sheet = workbook.createSheet("删除品牌结果"); + Row countryRow = sheet.createRow(0); + Row headerRow = sheet.createRow(1); + + for (int countryIndex = 0; countryIndex < mergedFile.countries().size(); countryIndex++) { + DeleteBrandProcessedCountryDto country = mergedFile.countries().get(countryIndex); + int asinColumnIndex = countryIndex * 2; + int statusColumnIndex = asinColumnIndex + 1; + + createTextCell(countryRow, asinColumnIndex, country.getCountry()); + createTextCell(headerRow, asinColumnIndex, "删除ASIN"); + createTextCell(headerRow, statusColumnIndex, "状态"); + + List items = country.getItems(); + if (items == null) { + continue; + } + + for (int itemIndex = 0; itemIndex < items.size(); itemIndex++) { + DeleteBrandCountryResultItemDto item = items.get(itemIndex); + Row row = sheet.getRow(itemIndex + 2); + if (row == null) { + row = sheet.createRow(itemIndex + 2); + } + createTextCell(row, asinColumnIndex, item.getAsin()); + createTextCell(row, statusColumnIndex, item.getStatus()); + } + } + + for (int i = 0; i < mergedFile.countries().size() * 2; i++) { + sheet.autoSizeColumn(i); + } + workbook.write(outputStream); + return outputFile; + } catch (Exception ex) { + throw new BusinessException("生成删除品牌结果文件失败: " + parsedFile.getSourceFilename()); + } + } + private void createTextCell(Row row, int index, String value) { Cell cell = row.createCell(index); cell.setCellValue(value == null ? "" : value); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopMatchResultVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopMatchResultVo.java new file mode 100644 index 0000000..b88ca41 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/vo/ZiniaoShopMatchResultVo.java @@ -0,0 +1,30 @@ +package com.nanri.aiimage.modules.ziniao.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "紫鸟店铺匹配结果") +public class ZiniaoShopMatchResultVo { + @Schema(description = "是否匹配成功") + private boolean matched; + + @Schema(description = "店铺ID") + private String shopId; + + @Schema(description = "店铺名称") + private String shopName; + + @Schema(description = "平台") + private String platform; + + @Schema(description = "匹配到的员工 userId") + private Long matchedUserId; + + @Schema(description = "打开店铺链接") + private String openStoreUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java new file mode 100644 index 0000000..3f9b6d6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopSwitchService.java @@ -0,0 +1,53 @@ +package com.nanri.aiimage.modules.ziniao.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ZiniaoShopSwitchService { + + private final ZiniaoAuthService ziniaoAuthService; + + public ZiniaoShopMatchResultVo matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) { + String normalizedShopName = normalizeShopName(targetShopName); + if (normalizedShopName.isBlank()) { + return emptyMatchResult(); + } + + ZiniaoAuthService.StoreMatchResult result = ziniaoAuthService.matchStoreByNameAcrossStaff(normalizedShopName, preferUserId); + return new ZiniaoShopMatchResultVo( + result.matched(), + result.shopId(), + result.shopName(), + result.platform(), + result.matchedUserId(), + result.openStoreUrl() + ); + } + + public ZiniaoShopMatchResultVo emptyMatchResult() { + return new ZiniaoShopMatchResultVo(false, null, null, null, null, null); + } + + public String normalizeShopName(String value) { + if (value == null) { + return ""; + } + return value.replace("\u3000", " ").trim(); + } + + public boolean isSkippableMatchError(BusinessException ex) { + String message = ex == null ? null : ex.getMessage(); + if (message == null || message.isBlank()) { + return false; + } + return (message.contains("code=40004") + || message.contains("userId\u5b58\u5728\u65e0\u6548\u7684\u53c2\u6570\u503c") + || message.contains("\u65e0\u6743\u9650") + || message.contains("\u6ca1\u6709\u6743\u9650")) + && !message.contains("\u767d\u540d\u5355"); + } +} diff --git a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue index 2db5a03..182e4f7 100644 --- a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue @@ -249,6 +249,9 @@ const queuePayloadText = ref('') const taskDetails = ref>({}) const pollTimer = ref(null) const pollingInFlight = ref(false) +const chainStarted = ref(false) +const activeItemKey = ref('') +const autoAdvancing = ref(false) const displayPaths = computed(() => selectedPaths.value.slice(0, 10)) const currentSectionItems = computed(() => { @@ -517,6 +520,7 @@ function scheduleNextPoll(immediate = false) { let stateChanged = false try { await refreshTaskDetails(taskIds) + await maybeAutoAdvance() for (const taskId of taskIds) { if (isTaskTerminal(taskId)) { removeSessionTaskFromStorage(taskId) @@ -766,34 +770,38 @@ function isPythonQueueBusy() { return false } -async function triggerItemRun(item: DeleteBrandResultItem) { +function getItemKey(item: DeleteBrandResultItem) { + if (item.resultId != null) return `result:${item.resultId}` + return `task:${item.taskId || 0}:${item.sourceFilename || ''}` +} + +function findNextAutoRunnableItem() { + return currentSectionItems.value.find((item) => { + if (!item.openStoreUrl) return false + if (getItemKey(item) === activeItemKey.value) return false + return getQueueStatus(item) === '未入队' + }) +} + +async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }) { if (isPythonQueueBusy()) { - ElMessage.warning('当前存在正在处理的任务,请等待其完成后再推送下一条!') - return + if (!options?.auto) { + ElMessage.warning('当前存在正在处理的任务,请等待其完成后再推送下一条!') + } + return false } const api = getPywebviewApi() - if (item.openStoreUrl) { - if (api?.open_external_url) { - const openResult = await api.open_external_url(item.openStoreUrl) - if (!openResult?.success) { - ElMessage.error(openResult?.error || '打开紫鸟失败') - return - } - } else { - window.open(item.openStoreUrl, '_self') - } - } const taskId = item.taskId - if (!taskId) return + if (!taskId) return false const sessionTask = sessionTasks.value.find(t => t.taskId === taskId) - if (!sessionTask) return + if (!sessionTask) return false if (!api?.enqueue_json) { ElMessage.error('当前环境未启用 pywebview enqueue_json(浏览器环境不会推送)') - return + return false } const payload = { @@ -811,18 +819,60 @@ async function triggerItemRun(item: DeleteBrandResultItem) { if (pushResult?.success) { qpr = `已单独推送文件 ${item.sourceFilename},当前队列长度:${pushResult.queue_size ?? '-'}` - // Mark the item as pushed const sessionItem = sessionTask.items.find(i => i.resultId === item.resultId || i.sourceFilename === item.sourceFilename) if (sessionItem) { (sessionItem as any)._pushed = true } + activeItemKey.value = getItemKey(item) saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value) - ensurePolling(true) } else { qpr = `推送到 Python 队列失败:${pushResult?.error || '未知错误'}` } queuePushResult.value = qpr + return Boolean(pushResult?.success) +} + +async function maybeAutoAdvance() { + if (!chainStarted.value || autoAdvancing.value) return + if (isPythonQueueBusy()) return + + const currentKey = activeItemKey.value + if (currentKey) { + const currentItem = currentSectionItems.value.find(item => getItemKey(item) === currentKey) + if (currentItem) { + const status = getQueueStatus(currentItem) + if (status === '已入队' || status === '处理中') { + return + } + } + } + + const nextItem = findNextAutoRunnableItem() + if (!nextItem) { + activeItemKey.value = '' + chainStarted.value = false + return + } + + autoAdvancing.value = true + try { + const started = await runItem(nextItem, { auto: true }) + if (!started) { + chainStarted.value = false + } + } finally { + autoAdvancing.value = false + } +} + +async function triggerItemRun(item: DeleteBrandResultItem) { + chainStarted.value = true + const started = await runItem(item) + if (!started) { + chainStarted.value = false + activeItemKey.value = '' + } } function openShop(url: string) { diff --git a/frontend-vue/src/shared/bridges/pywebview.ts b/frontend-vue/src/shared/bridges/pywebview.ts index fe4dc0f..2a08292 100644 --- a/frontend-vue/src/shared/bridges/pywebview.ts +++ b/frontend-vue/src/shared/bridges/pywebview.ts @@ -21,7 +21,6 @@ export interface PywebviewApi { save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }> save_template_zip?: () => Promise<{ success: boolean; path?: string; error?: string }> enqueue_json?: (data: unknown) => Promise<{ success: boolean; queue_size?: number; error?: string }> - open_external_url?: (url: string) => Promise<{ success: boolean; error?: string }> } declare global {