This commit is contained in:
super
2026-03-30 20:39:13 +08:00
parent cb3b9e9cfe
commit 53df8b9971
16 changed files with 328 additions and 138 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title> <title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script> <script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-C8Bik-Sc.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-Dee3nQjE.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css">
<link rel="stylesheet" crossorigin href="/assets/convert-Cfr3mUjs.css"> <link rel="stylesheet" crossorigin href="/assets/convert-Cfr3mUjs.css">
</head> </head>

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title> <title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script> <script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-C8Bik-Sc.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-Dee3nQjE.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-CS18ls2z.css"> <link rel="stylesheet" crossorigin href="/assets/dedupe-CS18ls2z.css">
</head> </head>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title> <title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script> <script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-C8Bik-Sc.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-Dee3nQjE.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BRIDBaoM.css"> <link rel="stylesheet" crossorigin href="/assets/delete-brand-Chu8awlt.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title> <title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script> <script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-C8Bik-Sc.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-Dee3nQjE.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css">
<link rel="stylesheet" crossorigin href="/assets/split-mwLUJT1K.css"> <link rel="stylesheet" crossorigin href="/assets/split-mwLUJT1K.css">
</head> </head>

Binary file not shown.

View File

@@ -40,6 +40,8 @@ import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
@@ -125,6 +127,8 @@ public class DeleteBrandRunService {
item.setShopId(matchResult.shopId()); item.setShopId(matchResult.shopId());
item.setPlatform(matchResult.platform()); item.setPlatform(matchResult.platform());
item.setOpenStoreUrl(matchResult.openStoreUrl()); item.setOpenStoreUrl(matchResult.openStoreUrl());
} else {
throw new BusinessException("未匹配到紫鸟店铺,跳过处理");
} }
} catch (BusinessException ex) { } catch (BusinessException ex) {
String message = ex.getMessage(); String message = ex.getMessage();
@@ -191,9 +195,10 @@ public class DeleteBrandRunService {
task.setFailedFileCount(parsedFailedCount); task.setFailedFileCount(parsedFailedCount);
task.setResultJson(JSONUtil.toJsonStr(items)); task.setResultJson(JSONUtil.toJsonStr(items));
task.setUpdatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now());
task.setSourceFileCount(parsedSuccessCount);
if (parsedSuccessCount <= 0) { if (parsedSuccessCount <= 0) {
task.setStatus("FAILED"); task.setStatus("FAILED");
task.setErrorMessage(parsedFailedCount > 0 ? "删除品牌文件解析失败" : null); task.setErrorMessage(parsedFailedCount > 0 ? "全部文件解析或匹配失败" : null);
task.setFinishedAt(LocalDateTime.now()); task.setFinishedAt(LocalDateTime.now());
} }
fileTaskMapper.updateById(task); fileTaskMapper.updateById(task);
@@ -269,11 +274,20 @@ public class DeleteBrandRunService {
} }
if (entity.getSourceFilename().equals(candidate.getSourceFilename())) { if (entity.getSourceFilename().equals(candidate.getSourceFilename())) {
item.setMatched(candidate.isMatched()); item.setMatched(candidate.isMatched());
// 如果物理结果已经是成功了,说明已经处理过了,强制置为匹配成功
if (item.isSuccess()) {
item.setMatched(true);
}
item.setShopId(candidate.getShopId()); item.setShopId(candidate.getShopId());
item.setPlatform(candidate.getPlatform()); item.setPlatform(candidate.getPlatform());
item.setOpenStoreUrl(candidate.getOpenStoreUrl()); item.setOpenStoreUrl(candidate.getOpenStoreUrl());
// 历史表可能只有通用 errorMessage优先返回任务当时的真实 error // 历史表可能只有通用 errorMessage优先返回任务当时的真实 error
if ((item.getError() == null || item.getError().isBlank()) && candidate.getError() != null && !candidate.getError().isBlank()) { // 但是,如果物理结果是成功的,就不应该显示所谓的“未匹配”报错
if (item.isSuccess()) {
item.setError(null);
} else if ((item.getError() == null || item.getError().isBlank()) && candidate.getError() != null && !candidate.getError().isBlank()) {
item.setError(candidate.getError()); item.setError(candidate.getError());
} }
break; break;
@@ -636,7 +650,10 @@ public class DeleteBrandRunService {
} }
} }
@Transactional
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) { public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
log.info("[DeleteBrand] Received submitResult for taskId: {}, files count: {}", taskId, request == null || request.getFiles() == null ? 0 : request.getFiles().size());
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法"); throw new BusinessException("taskId 不合法");
} }
@@ -659,24 +676,33 @@ public class DeleteBrandRunService {
throw new BusinessException("任务原始数据已过期,请重新执行解析"); throw new BusinessException("任务原始数据已过期,请重新执行解析");
} }
task.setStatus("RUNNING"); if (!"RUNNING".equals(task.getStatus())) {
task.setErrorMessage(null); task.setStatus("RUNNING");
task.setUpdatedAt(LocalDateTime.now()); task.setErrorMessage(null);
task.setFinishedAt(null); task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task); task.setFinishedAt(null);
fileTaskMapper.updateById(task);
}
for (DeleteBrandResultFileDto fileDto : request.getFiles()) { for (DeleteBrandResultFileDto fileDto : request.getFiles()) {
log.info("[DeleteBrand] submitResult processing chunk -> taskId: {}, file: {}, chunkIndex: {}/{}, processedRows: {}/{}",
taskId, fileDto.getSourceFilename(), fileDto.getChunkIndex(), fileDto.getChunkTotal(),
fileDto.getProcessedRows(), fileDto.getTotalRows());
String fileIdentity = resolveFileIdentity(fileDto); String fileIdentity = resolveFileIdentity(fileDto);
if (fileIdentity.isBlank()) { if (fileIdentity.isBlank()) {
throw new BusinessException("fileKey/sourceFilename 不能为空"); throw new BusinessException("fileKey/sourceFilename 不能为空");
} }
DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity); DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity);
if (parsedFile == null) { if (parsedFile == null) {
log.warn("[DeleteBrand] parsed payload not found for identity: {}", fileIdentity);
throw new BusinessException("文件标识不匹配: " + fileIdentity); throw new BusinessException("文件标识不匹配: " + fileIdentity);
} }
// 更早的 duplicate fast-path尽量在较重的校验/进度写入之前直接短路 // 更早的 duplicate fast-path尽量在较重的校验/进度写入之前直接短路
if (deleteBrandTaskCacheService.hasSameResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto)) { if (deleteBrandTaskCacheService.hasSameResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto)) {
log.info("[DeleteBrand] Duplicate chunk ignored for taskId: {}, file: {}, chunkIndex: {}", taskId, fileIdentity, fileDto.getChunkIndex());
continue; continue;
} }
@@ -699,6 +725,16 @@ public class DeleteBrandRunService {
progress.put("updated_at", String.valueOf(System.currentTimeMillis())); progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis())); progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis()));
deleteBrandTaskCacheService.saveProgress(taskId, progress); deleteBrandTaskCacheService.saveProgress(taskId, progress);
// 如果该文件已完成,立即更新成品计数的 Redis 缓存「提示」,让后续 tryFinalizeTask 能更精确
if (isFileCompleted(fileDto)) {
log.info("[DeleteBrand] File chunk transmission fully reached via chunkTotal for taskId: {}, file: {}", taskId, fileDto.getSourceFilename());
java.util.Map<Object, Object> currentProgress = deleteBrandTaskCacheService.getProgress(taskId);
int finishedCount = parseInt(currentProgress.get("finished_files")) == null ? 0 : parseInt(currentProgress.get("finished_files"));
// 这里我们暂不直接 increment而是标记需要重算或在 tryFinalizeTask 中重新计算
// 为了保险,我们直接在 tryFinalizeTask 里重新遍历分片状态
}
} }
tryFinalizeTask(taskId, false); tryFinalizeTask(taskId, false);
@@ -724,12 +760,10 @@ public class DeleteBrandRunService {
return; return;
} }
Map<Object, Object> progressSnapshot = deleteBrandTaskCacheService.getProgress(taskId);
int expectedFiles = parsedPayload.size(); int expectedFiles = parsedPayload.size();
Integer finishedFilesHint = parseInt(progressSnapshot == null ? null : progressSnapshot.get("finished_files")); // 移除 finishedFilesHint 强行返回逻辑,因为 Redis 里的 finished_files 可能是旧的,
if (finishedFilesHint != null && finishedFilesHint < expectedFiles) { // 既然进入了 tryFinalizeTask就说明有新片到达应该以 loadMergedChunks 为准
return;
}
Map<String, List<DeleteBrandResultFileDto>> mergedByFile = loadMergedChunks(taskId); Map<String, List<DeleteBrandResultFileDto>> mergedByFile = loadMergedChunks(taskId);
int finishedFiles = countCompletedFiles(mergedByFile); int finishedFiles = countCompletedFiles(mergedByFile);
@@ -747,8 +781,11 @@ public class DeleteBrandRunService {
fileTaskMapper.updateById(task); fileTaskMapper.updateById(task);
if (finishedFiles < expectedFiles) { if (finishedFiles < expectedFiles) {
log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles);
return; return;
} }
log.info("[DeleteBrand] tryFinalizeTask calling finalizeTask -> finishedFiles: {}, expectedFiles: {}", finishedFiles, expectedFiles);
finalizeTask(task, parsedPayload, mergedByFile); finalizeTask(task, parsedPayload, mergedByFile);
} }

View File

@@ -39,6 +39,16 @@ public class DeleteBrandStaleTaskService {
Map<Object, Object> progress = deleteBrandTaskCacheService.getProgress(task.getId()); Map<Object, Object> progress = deleteBrandTaskCacheService.getProgress(task.getId());
boolean hasProgress = progress != null && !progress.isEmpty() && progress.containsKey("last_heartbeat_at"); boolean hasProgress = progress != null && !progress.isEmpty() && progress.containsKey("last_heartbeat_at");
boolean isActive = false;
if (progress != null && progress.containsKey("has_progress")) {
Object hpObj = progress.get("has_progress");
if (hpObj instanceof Boolean) {
isActive = (Boolean) hpObj;
} else if (hpObj instanceof String) {
isActive = Boolean.parseBoolean((String) hpObj);
}
}
long lastHeartbeatAt = 0L; long lastHeartbeatAt = 0L;
if (hasProgress) { if (hasProgress) {
try { try {
@@ -48,18 +58,19 @@ public class DeleteBrandStaleTaskService {
} }
LocalDateTime lastActivityTime; LocalDateTime lastActivityTime;
if (lastHeartbeatAt > 0) { // 只有当 has_progress 为 true 且超过 15 分钟没心跳,才认为是异常卡死
// 有心跳,用最后心跳时间对比 15 分钟 if (lastHeartbeatAt > 0 && isActive) {
// 有心跳,用最后心跳时间对比 15 分钟 (threshold 是 now - 15)
lastActivityTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastHeartbeatAt), ZoneId.systemDefault()); lastActivityTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastHeartbeatAt), ZoneId.systemDefault());
if (lastActivityTime.isAfter(threshold)) { if (lastActivityTime.isAfter(threshold)) {
continue; // 没超时 continue; // 没超时
} }
} else { } else {
// 没有心跳,说明任务一直没被 Python 取出来执行(处在排队状态中 // 没有心跳,或者当前处在 has_progress: false (等待用户点击下一个任务推送的静默期
// 排队的宽限期我们设为 12 个小时,如果建了 12 个小时都没执行,再杀死 // 静默期和排队的宽限期一样,设为 12 个小时
LocalDateTime queuedThreshold = LocalDateTime.now().minusHours(12); LocalDateTime queuedThreshold = LocalDateTime.now().minusHours(12);
if (task.getCreatedAt() != null && task.getCreatedAt().isAfter(queuedThreshold)) { if (task.getCreatedAt() != null && task.getCreatedAt().isAfter(queuedThreshold)) {
continue; // 排队中,不要杀它! continue; // 等待下个队列推送中,不要杀它!
} }
} }

View File

@@ -294,7 +294,7 @@ public class ZiniaoAuthService {
.queryParam("storeId", storeId) .queryParam("storeId", storeId)
.queryParam("openapiToken", loginToken) .queryParam("openapiToken", loginToken)
.queryParam("userId", userId) .queryParam("userId", userId)
.queryParam("lanuchUrl", encodedLaunchUrl) .queryParam("launchUrl", encodedLaunchUrl)
.queryParam("autoopen", Boolean.TRUE.equals(ziniaoProperties.getOpenStoreAutoOpen())) .queryParam("autoopen", Boolean.TRUE.equals(ziniaoProperties.getOpenStoreAutoOpen()))
.queryParam("debuggPort", ziniaoProperties.getOpenStoreDebugPort()) .queryParam("debuggPort", ziniaoProperties.getOpenStoreDebugPort())
.queryParam("notPromptForDownload", Boolean.TRUE.equals(ziniaoProperties.getOpenStoreNotPromptForDownload()) ? 1 : 0); .queryParam("notPromptForDownload", Boolean.TRUE.equals(ziniaoProperties.getOpenStoreNotPromptForDownload()) ? 1 : 0);

View File

@@ -29,7 +29,7 @@ AIIMAGE_ZINIAO_APP_TOKEN_PATH=/auth/get_app_token
AIIMAGE_ZINIAO_STAFF_LIST_PATH=/superbrowser/rest/v1/erp/staff/list AIIMAGE_ZINIAO_STAFF_LIST_PATH=/superbrowser/rest/v1/erp/staff/list
AIIMAGE_ZINIAO_USER_STORES_PATH=/superbrowser/rest/v1/erp/user/stores AIIMAGE_ZINIAO_USER_STORES_PATH=/superbrowser/rest/v1/erp/user/stores
AIIMAGE_ZINIAO_USER_LOGIN_TOKEN_PATH=/superbrowser/rest/v1/token/user-login AIIMAGE_ZINIAO_USER_LOGIN_TOKEN_PATH=/superbrowser/rest/v1/token/user-login
AIIMAGE_ZINIAO_OPEN_STORE_SCHEME=superbrowser://OpenStrore AIIMAGE_ZINIAO_OPEN_STORE_SCHEME=superbrowser://OpenStore
AIIMAGE_ZINIAO_OPEN_STORE_USER_ID= AIIMAGE_ZINIAO_OPEN_STORE_USER_ID=
AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL=https://www.baidu.com AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL=https://www.baidu.com
AIIMAGE_ZINIAO_OPEN_STORE_AUTO_OPEN=true AIIMAGE_ZINIAO_OPEN_STORE_AUTO_OPEN=true

View File

@@ -96,7 +96,7 @@ aiimage:
staff-list-path: ${AIIMAGE_ZINIAO_STAFF_LIST_PATH:/superbrowser/rest/v1/erp/staff/list} staff-list-path: ${AIIMAGE_ZINIAO_STAFF_LIST_PATH:/superbrowser/rest/v1/erp/staff/list}
user-stores-path: ${AIIMAGE_ZINIAO_USER_STORES_PATH:/superbrowser/rest/v1/erp/user/stores} user-stores-path: ${AIIMAGE_ZINIAO_USER_STORES_PATH:/superbrowser/rest/v1/erp/user/stores}
user-login-token-path: ${AIIMAGE_ZINIAO_USER_LOGIN_TOKEN_PATH:/superbrowser/rest/v1/token/user-login} user-login-token-path: ${AIIMAGE_ZINIAO_USER_LOGIN_TOKEN_PATH:/superbrowser/rest/v1/token/user-login}
open-store-scheme: ${AIIMAGE_ZINIAO_OPEN_STORE_SCHEME:superbrowser://OpenStrore} open-store-scheme: ${AIIMAGE_ZINIAO_OPEN_STORE_SCHEME:superbrowser://OpenStore}
open-store-user-id: ${AIIMAGE_ZINIAO_OPEN_STORE_USER_ID:} open-store-user-id: ${AIIMAGE_ZINIAO_OPEN_STORE_USER_ID:}
open-store-launch-url: ${AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL:https://www.baidu.com} open-store-launch-url: ${AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL:https://www.baidu.com}
open-store-auto-open: ${AIIMAGE_ZINIAO_OPEN_STORE_AUTO_OPEN:true} open-store-auto-open: ${AIIMAGE_ZINIAO_OPEN_STORE_AUTO_OPEN:true}

View File

@@ -101,7 +101,8 @@
<ul class="task-list clean-result-list"> <ul class="task-list clean-result-list">
<li <li
v-for="item in currentSectionItems" v-for="item in currentSectionItems"
:key="`current-${item.resultId || item.taskId || item.sourceFilename}`" :key="`current-${item.taskId || item.resultId || item.sourceFilename}`"
class="task-item split-result-item" class="task-item split-result-item"
> >
<div class="left split-result-main"> <div class="left split-result-main">
@@ -109,6 +110,7 @@
<div class="files">店铺名{{ item.shopName || '-' }}</div> <div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div> <div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div> <div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="getQueueStatus(item)" class="files">队列状态{{ getQueueStatus(item) }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount }}</div> <div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount }}</div>
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} </div> <div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} </div>
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block"> <div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
@@ -135,7 +137,7 @@
v-if="item.openStoreUrl" v-if="item.openStoreUrl"
type="button" type="button"
class="download" class="download"
@click="openShop(item.openStoreUrl)" @click="triggerItemRun(item)"
> >
打开紫鸟 打开紫鸟
</button> </button>
@@ -165,7 +167,8 @@
<ul class="task-list clean-result-list"> <ul class="task-list clean-result-list">
<li <li
v-for="item in historySectionItems" v-for="item in historySectionItems"
:key="`history-${item.resultId || item.taskId || item.sourceFilename}`" :key="`history-${item.taskId || item.resultId || item.sourceFilename}`"
class="task-item split-result-item" class="task-item split-result-item"
> >
<div class="left split-result-main"> <div class="left split-result-main">
@@ -195,14 +198,7 @@
<span class="status" :class="getTaskStatusInfo(item).className"> <span class="status" :class="getTaskStatusInfo(item).className">
{{ getTaskStatusInfo(item).text }} {{ getTaskStatusInfo(item).text }}
</span> </span>
<button <!-- 历史记录区不再显示打开紫鸟按钮 -->
v-if="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
>
打开紫鸟
</button>
<button <button
v-if="canDownloadTaskResult(item)" v-if="canDownloadTaskResult(item)"
type="button" type="button"
@@ -249,11 +245,24 @@ import {
} from '@/shared/api/java-modules' } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
interface StoredCurrentTask {
taskId: number
items: DeleteBrandResultItem[]
createdAt: number
queuePushResult?: string
queuePayloadText?: string
}
function getStorageKey() {
const uid = typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
return `delete-brand:current-tasks:${uid}`
}
const selectedPaths = ref<string[]>([]) const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadedJavaFile[]>([]) const uploadedFiles = ref<UploadedJavaFile[]>([])
const running = ref(false) const running = ref(false)
const resultItems = ref<DeleteBrandResultItem[]>([]) const resultItems = ref<DeleteBrandResultItem[]>([])
const currentRunItems = ref<DeleteBrandResultItem[]>([]) const sessionTasks = ref<StoredCurrentTask[]>([])
const historyItems = ref<DeleteBrandResultItem[]>([]) const historyItems = ref<DeleteBrandResultItem[]>([])
const summary = ref<DeleteBrandRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] }) const summary = ref<DeleteBrandRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const queuePushResult = ref('') const queuePushResult = ref('')
@@ -262,36 +271,65 @@ const taskDetails = ref<Record<number, DeleteBrandTaskDetailVo>>({})
const pollTimer = ref<number | null>(null) const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false) const pollingInFlight = ref(false)
const displayPaths = computed(() => selectedPaths.value.slice(0, 10)) const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
const runningTaskIdsInHistory = computed(() => {
return historyItems.value
.filter(item => {
const status = item.taskId ? taskDetails.value[item.taskId]?.task?.status : null;
// 优先认后端的实时状态
if (status) return status === 'RUNNING';
// 如果还没拿到底层详情,看 history 接口带回来的全局任务状态
if (item.taskStatus) return item.taskStatus === 'RUNNING';
// 最后降级到根据 success/error 猜测(向下兼容)
return (item.taskId || 0) > 0 && item.success === false && !item.error;
})
.map(item => item.taskId);
});
const currentSectionItems = computed(() => { const currentSectionItems = computed(() => {
// 当前运行的任务 = 用户手动触发的 + 历史记录里还在跑的 const allCurrent: DeleteBrandResultItem[] = [];
const allCurrent = [...currentRunItems.value]; sessionTasks.value.forEach(task => {
allCurrent.push(...task.items);
historyItems.value.forEach(hItem => {
if (hItem.taskId && runningTaskIdsInHistory.value.includes(hItem.taskId)) {
// 避免重复
if (!allCurrent.some(c => c.taskId === hItem.taskId)) {
allCurrent.push(hItem);
}
}
}); });
return allCurrent; return allCurrent;
}); });
function loadSessionTasksFromStorage() {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(getStorageKey()) : null
if (raw) {
sessionTasks.value = JSON.parse(raw)
if (sessionTasks.value.length > 0) {
const lastTask = sessionTasks.value[sessionTasks.value.length - 1]
queuePushResult.value = lastTask.queuePushResult || ''
queuePayloadText.value = lastTask.queuePayloadText || ''
}
} else {
sessionTasks.value = []
}
} catch {
sessionTasks.value = []
}
}
function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) {
// 只将解析成功且匹配成功的任务放入当前队列中,失败的将留在历史记录里
const validItems = items.filter(i => i.matched !== false && i.success !== false)
if (validItems.length === 0) return
const newTask: StoredCurrentTask = {
taskId,
items: validItems,
createdAt: Date.now(),
queuePushResult: pushResult,
queuePayloadText: payloadText
}
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))
}
}
function removeSessionTaskFromStorage(taskId: number) {
const oldLen = sessionTasks.value.length
sessionTasks.value = sessionTasks.value.filter(t => t.taskId !== taskId)
if (sessionTasks.value.length !== oldLen && typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
}
const historySectionItems = computed(() => const historySectionItems = computed(() =>
historyItems.value.filter( historyItems.value.filter(
(item) => !currentSectionItems.value.some(c => c.taskId === item.taskId || (c.resultId && c.resultId === item.resultId)) (item) => !currentSectionItems.value.some(c => c.taskId === item.taskId || (c.resultId && c.resultId === item.resultId))
@@ -312,19 +350,17 @@ function getDisplayError(item: DeleteBrandResultItem) {
} }
function shouldShowProgress(item: DeleteBrandResultItem) { function shouldShowProgress(item: DeleteBrandResultItem) {
const taskId = item.taskId const qs = getQueueStatus(item)
if (!taskId) return false return qs === '已入队' || qs === '处理中'
const status = getTaskStatus(taskId)
// 只要状态是运行中就显示进度条,不管 matched 了matched 不对的状态应该在后端就标记为 FAILED
return status === 'RUNNING'
} }
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) { function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
return items.map((item) => { return items.map((item) => {
if (item.matched === false) { // 只有在既没成功,又显式标记为未匹配时,才做错误兜底
if (!item.success && item.matched === false) {
return { return {
...item, ...item,
success: false,
error: item.error || '未匹配到紫鸟店铺', error: item.error || '未匹配到紫鸟店铺',
openStoreUrl: undefined, openStoreUrl: undefined,
} }
@@ -368,7 +404,9 @@ function formatProgress(taskId: number) {
if (info.phase) parts.push(`阶段:${info.phase}`) if (info.phase) parts.push(`阶段:${info.phase}`)
if (info.file_index && info.file_total) parts.push(`文件:${info.file_index}/${info.file_total}`) if (info.file_index && info.file_total) parts.push(`文件:${info.file_index}/${info.file_total}`)
if (info.file_name) parts.push(`当前文件:${info.file_name}`) if (info.file_name) parts.push(`当前文件:${info.file_name}`)
if (info.current_line != null && info.total_lines != null) parts.push(`进度:${info.current_line}/${info.total_lines}`) if (info.current_line != null && info.total_lines != null) {
parts.push(`进度:${info.current_line}/${info.total_lines}`)
}
if (info.current_country) parts.push(`国家:${info.current_country}`) if (info.current_country) parts.push(`国家:${info.current_country}`)
if (info.current_asin) parts.push(`ASIN${info.current_asin}`) if (info.current_asin) parts.push(`ASIN${info.current_asin}`)
if (info.finished_files != null) parts.push(`已完成文件:${info.finished_files}`) if (info.finished_files != null) parts.push(`已完成文件:${info.finished_files}`)
@@ -402,38 +440,58 @@ function isTaskTerminal(taskId: number) {
function getPollingTaskIds() { function getPollingTaskIds() {
const ids = new Set<number>() const ids = new Set<number>()
sessionTasks.value.forEach(task => {
// 1. 检查本会话触发的任务 // 只轮询有文件被推给 Python 的任务
currentRunItems.value.forEach((item) => { if (task.items.some(i => (i as any)._pushed)) {
if (item.taskId && !isTaskTerminal(item.taskId)) { ids.add(task.taskId)
ids.add(item.taskId)
} }
}) })
// 2. 检查历史加载的任务
historyItems.value.forEach((item) => {
if (item.taskId) {
if (taskDetails.value[item.taskId]) {
// 如果已经拿到了详情,以后端详情为准
if (!isTaskTerminal(item.taskId)) {
ids.add(item.taskId)
}
} else {
// 还没拿到详情,看 history 接口中的 taskStatus 或 success 标记
const isRunning = item.taskStatus === 'RUNNING' || (!item.taskStatus && !item.success && !item.error);
if (isRunning) {
ids.add(item.taskId)
}
}
}
})
return Array.from(ids) return Array.from(ids)
} }
function canDownloadTaskResult(item: DeleteBrandResultItem) { function getQueueStatus(item: DeleteBrandResultItem) {
const taskId = item.taskId const taskId = item.taskId
if (!taskId || !item.matched) return false if (!taskId) return ''
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return ''
const status = getTaskStatus(taskId)
if (status === 'SUCCESS' || status === 'FAILED' || item.taskStatus === 'SUCCESS' || item.taskStatus === 'FAILED') {
return '已被全局判定为终态'
}
const sessionItem = sessionTask.items.find(i => i.resultId === item.resultId || i.sourceFilename === item.sourceFilename)
const info = taskDetails.value[taskId]?.line_progress
// 此时这个特定文件是否正好被后端报了进度名
if (status === 'RUNNING' && info?.has_progress && info?.info?.file_name === item.sourceFilename) {
return '处理中'
}
// 查查是否已经完结了这个特定文件
const detail = taskDetails.value[taskId]
if (detail?.items) {
// 后端返回的 items 是 `resultJson` 解析来的,里面只含有已经做完的!
const finished = detail.items.find((i: DeleteBrandResultItem) => i.sourceFilename === item.sourceFilename)
if (finished) {
return '本文件解析完毕'
}
}
if (sessionItem && (sessionItem as any)._pushed) {
return '已入队'
}
return '未入队'
}
function canDownloadTaskResult(item: DeleteBrandResultItem) {
// 核心修复:只要单个项目已经成功(后端返回了下载地址或标记为成功),就允许下载,哪怕整包任务还在“执行中”
if (item.downloadUrl || item.success) return true
const taskId = item.taskId
if (!taskId) return false
const status = taskDetails.value[taskId]?.task?.status const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS' return status === 'SUCCESS'
} }
@@ -477,14 +535,25 @@ function scheduleNextPoll(immediate = false) {
if (!taskIds.length) return if (!taskIds.length) return
pollingInFlight.value = true pollingInFlight.value = true
let stateChanged = false
try { try {
await refreshTaskDetails(taskIds) await refreshTaskDetails(taskIds)
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
removeSessionTaskFromStorage(taskId)
stateChanged = true
}
}
} finally { } finally {
pollingInFlight.value = false pollingInFlight.value = false
} }
const stillRunning = taskIds.some((id) => isTaskRunning(id)) if (stateChanged) {
if (stillRunning) { loadHistory()
syncResultState()
}
if (getPollingTaskIds().length > 0) {
scheduleNextPoll() scheduleNextPoll()
} }
} }
@@ -598,39 +667,24 @@ async function submitRun() {
})), })),
}) })
const normalizedItems = normalizeDeleteBrandItems(result.items || []) const normalizedItems = normalizeDeleteBrandItems(result.items || [])
currentRunItems.value = normalizedItems const hasUnmatched = normalizedItems.some((item) => !item.matched)
queuePushResult.value = '解析完成,等待手动点击打开紫鸟进行单任务处理'
queuePayloadText.value = ''
if (normalizedItems.length && normalizedItems[0].taskId) {
saveSessionTask(normalizedItems[0].taskId, normalizedItems, queuePushResult.value, queuePayloadText.value)
}
syncResultState() syncResultState()
await refreshTaskDetails(getPollingTaskIds())
ensurePolling()
const api = getPywebviewApi()
const hasUnmatched = normalizedItems.some((item) => !item.matched)
if (!hasUnmatched && api?.enqueue_json) {
const payload = {
type: 'delete-brand-run',
ts: Date.now(),
data: { ...result, items: normalizedItems },
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
const pushResult = await api.enqueue_json(payload)
if (pushResult?.success) {
queuePushResult.value = `已推送到 Python 队列,当前队列长度:${pushResult.queue_size ?? '-'}`
} else {
queuePushResult.value = `推送到 Python 队列失败:${pushResult?.error || '未知错误'}`
}
} else {
queuePushResult.value = hasUnmatched
? '存在未匹配到紫鸟店铺的文件,已跳过推送到 Python 队列'
: '当前环境未启用 pywebview enqueue_json浏览器环境不会推送'
queuePayloadText.value = ''
}
if (hasUnmatched) { if (hasUnmatched) {
ElMessage.error('存在未匹配到紫鸟店铺的文件,已标记为失败') ElMessage.error('存在未匹配到紫鸟店铺的文件,请检查报错内容。这些文件已自动归档至历史记录。')
} else { } else {
ElMessage.success('删除品牌解析完成') ElMessage.success('删除品牌解析完成,请手动推送')
} }
// 更新历史记录,以展示那些未进入队列的失败项
await loadHistory()
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '执行失败') ElMessage.error(error instanceof Error ? error.message : '执行失败')
} finally { } finally {
@@ -679,16 +733,17 @@ async function loadHistory() {
} }
function removeRecordFromView(resultId: number) { function removeRecordFromView(resultId: number) {
const removedFromCurrent = currentRunItems.value.find((item) => item.resultId === resultId) sessionTasks.value.forEach(st => {
const removedTaskId = removedFromCurrent?.taskId st.items = st.items.filter(item => item.resultId !== resultId)
})
currentRunItems.value = currentRunItems.value.filter((item) => item.resultId !== resultId) const oldLen = sessionTasks.value.length
historyItems.value = historyItems.value.filter((item) => item.resultId !== resultId) sessionTasks.value = sessionTasks.value.filter(st => st.items.length > 0)
if (sessionTasks.value.length !== oldLen && typeof window !== 'undefined') {
if (removedTaskId && !currentRunItems.value.some((item) => item.taskId === removedTaskId)) { window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
delete taskDetails.value[removedTaskId]
} }
historyItems.value = historyItems.value.filter((item) => item.resultId !== resultId)
syncResultState() syncResultState()
if (getPollingTaskIds().length === 0) { if (getPollingTaskIds().length === 0) {
@@ -720,11 +775,93 @@ function formatPreview(rows: DeleteBrandPreviewRow[]) {
return hiddenCount > 0 ? `${preview.join('、')}${rows.length}` : preview.join('、') return hiddenCount > 0 ? `${preview.join('、')}${rows.length}` : preview.join('、')
} }
function isPythonQueueBusy() {
for (const task of sessionTasks.value) {
for (const item of task.items) {
const qs = getQueueStatus(item)
if (qs === '已入队' || qs === '处理中') {
return true
}
}
}
return false
}
async function triggerItemRun(item: DeleteBrandResultItem) {
if (isPythonQueueBusy()) {
ElMessage.warning('当前存在正在处理的任务,请等待其完成后再推送下一条!')
return
}
if (item.openStoreUrl) {
window.open(item.openStoreUrl, '_blank')
}
const taskId = item.taskId
if (!taskId) return
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return
const api = getPywebviewApi()
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用 pywebview enqueue_json浏览器环境不会推送')
return
}
const payload = {
type: 'delete-brand-run',
ts: Date.now(),
data: {
taskId: taskId,
items: [item]
},
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
const pushResult = await api.enqueue_json(payload)
let qpr = ''
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
}
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
ensurePolling(true)
} else {
qpr = `推送到 Python 队列失败:${pushResult?.error || '未知错误'}`
}
queuePushResult.value = qpr
}
function openShop(url: string) { function openShop(url: string) {
window.open(url, '_blank') window.open(url, '_blank')
} }
onMounted(() => { onMounted(() => {
loadSessionTasksFromStorage()
const taskIds = getPollingTaskIds()
if (taskIds.length > 0) {
refreshTaskDetails(taskIds).then(() => {
let stateChanged = false
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
removeSessionTaskFromStorage(taskId)
stateChanged = true
}
}
if (stateChanged) {
syncResultState()
}
ensurePolling(true)
})
} else {
syncResultState()
}
loadHistory().catch(() => undefined) loadHistory().catch(() => undefined)
}) })

View File

@@ -203,6 +203,7 @@ export interface DeleteBrandTaskItem {
export interface DeleteBrandTaskDetailVo { export interface DeleteBrandTaskDetailVo {
task: DeleteBrandTaskItem task: DeleteBrandTaskItem
line_progress: DeleteBrandLineProgress line_progress: DeleteBrandLineProgress
items?: DeleteBrandResultItem[]
} }
export interface DeleteBrandTaskBatchVo { export interface DeleteBrandTaskBatchVo {
@@ -384,7 +385,11 @@ export function submitDeleteBrandResult(taskId: number, request: DeleteBrandSubm
} }
export function getJavaDownloadUrl(path: string) { export function getJavaDownloadUrl(path: string) {
const raw = path.startsWith('http://') || path.startsWith('https://') ? path : `${JAVA_API_PREFIX}${path}` let raw = path.startsWith('http://') || path.startsWith('https://') ? path : `${JAVA_API_PREFIX}${path}`
// 核心修复pywebview 的 save_file_from_url 需要完整带 schema 的 URL
if (!raw.startsWith('http://') && !raw.startsWith('https://') && typeof window !== 'undefined') {
raw = `${window.location.origin}${raw}`
}
const separator = raw.includes('?') ? '&' : '?' const separator = raw.includes('?') ? '&' : '?'
return `${raw}${separator}user_id=${encodeURIComponent(String(getCurrentUserId()))}` return `${raw}${separator}user_id=${encodeURIComponent(String(getCurrentUserId()))}`
} }