This commit is contained in:
super
2026-03-30 20:39:13 +08:00
parent a07b9d7fb2
commit b8f88ca06e
7 changed files with 319 additions and 129 deletions

View File

@@ -40,6 +40,8 @@ import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.io.FileInputStream;
@@ -125,6 +127,8 @@ public class DeleteBrandRunService {
item.setShopId(matchResult.shopId());
item.setPlatform(matchResult.platform());
item.setOpenStoreUrl(matchResult.openStoreUrl());
} else {
throw new BusinessException("未匹配到紫鸟店铺,跳过处理");
}
} catch (BusinessException ex) {
String message = ex.getMessage();
@@ -191,9 +195,10 @@ public class DeleteBrandRunService {
task.setFailedFileCount(parsedFailedCount);
task.setResultJson(JSONUtil.toJsonStr(items));
task.setUpdatedAt(LocalDateTime.now());
task.setSourceFileCount(parsedSuccessCount);
if (parsedSuccessCount <= 0) {
task.setStatus("FAILED");
task.setErrorMessage(parsedFailedCount > 0 ? "删除品牌文件解析失败" : null);
task.setErrorMessage(parsedFailedCount > 0 ? "全部文件解析或匹配失败" : null);
task.setFinishedAt(LocalDateTime.now());
}
fileTaskMapper.updateById(task);
@@ -269,11 +274,20 @@ public class DeleteBrandRunService {
}
if (entity.getSourceFilename().equals(candidate.getSourceFilename())) {
item.setMatched(candidate.isMatched());
// 如果物理结果已经是成功了,说明已经处理过了,强制置为匹配成功
if (item.isSuccess()) {
item.setMatched(true);
}
item.setShopId(candidate.getShopId());
item.setPlatform(candidate.getPlatform());
item.setOpenStoreUrl(candidate.getOpenStoreUrl());
// 历史表可能只有通用 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());
}
break;
@@ -636,7 +650,10 @@ public class DeleteBrandRunService {
}
}
@Transactional
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) {
throw new BusinessException("taskId 不合法");
}
@@ -659,24 +676,33 @@ public class DeleteBrandRunService {
throw new BusinessException("任务原始数据已过期,请重新执行解析");
}
task.setStatus("RUNNING");
task.setErrorMessage(null);
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(null);
fileTaskMapper.updateById(task);
if (!"RUNNING".equals(task.getStatus())) {
task.setStatus("RUNNING");
task.setErrorMessage(null);
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(null);
fileTaskMapper.updateById(task);
}
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);
if (fileIdentity.isBlank()) {
throw new BusinessException("fileKey/sourceFilename 不能为空");
}
DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity);
if (parsedFile == null) {
log.warn("[DeleteBrand] parsed payload not found for identity: {}", fileIdentity);
throw new BusinessException("文件标识不匹配: " + fileIdentity);
}
// 更早的 duplicate fast-path尽量在较重的校验/进度写入之前直接短路
if (deleteBrandTaskCacheService.hasSameResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto)) {
log.info("[DeleteBrand] Duplicate chunk ignored for taskId: {}, file: {}, chunkIndex: {}", taskId, fileIdentity, fileDto.getChunkIndex());
continue;
}
@@ -699,6 +725,16 @@ public class DeleteBrandRunService {
progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis()));
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);
@@ -724,12 +760,10 @@ public class DeleteBrandRunService {
return;
}
Map<Object, Object> progressSnapshot = deleteBrandTaskCacheService.getProgress(taskId);
int expectedFiles = parsedPayload.size();
Integer finishedFilesHint = parseInt(progressSnapshot == null ? null : progressSnapshot.get("finished_files"));
if (finishedFilesHint != null && finishedFilesHint < expectedFiles) {
return;
}
// 移除 finishedFilesHint 强行返回逻辑,因为 Redis 里的 finished_files 可能是旧的,
// 既然进入了 tryFinalizeTask就说明有新片到达应该以 loadMergedChunks 为准
Map<String, List<DeleteBrandResultFileDto>> mergedByFile = loadMergedChunks(taskId);
int finishedFiles = countCompletedFiles(mergedByFile);
@@ -747,8 +781,11 @@ public class DeleteBrandRunService {
fileTaskMapper.updateById(task);
if (finishedFiles < expectedFiles) {
log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles);
return;
}
log.info("[DeleteBrand] tryFinalizeTask calling finalizeTask -> finishedFiles: {}, expectedFiles: {}", finishedFiles, expectedFiles);
finalizeTask(task, parsedPayload, mergedByFile);
}

View File

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

View File

@@ -294,7 +294,7 @@ public class ZiniaoAuthService {
.queryParam("storeId", storeId)
.queryParam("openapiToken", loginToken)
.queryParam("userId", userId)
.queryParam("lanuchUrl", encodedLaunchUrl)
.queryParam("launchUrl", encodedLaunchUrl)
.queryParam("autoopen", Boolean.TRUE.equals(ziniaoProperties.getOpenStoreAutoOpen()))
.queryParam("debuggPort", ziniaoProperties.getOpenStoreDebugPort())
.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_USER_STORES_PATH=/superbrowser/rest/v1/erp/user/stores
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_LAUNCH_URL=https://www.baidu.com
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}
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}
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-launch-url: ${AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL:https://www.baidu.com}
open-store-auto-open: ${AIIMAGE_ZINIAO_OPEN_STORE_AUTO_OPEN:true}

View File

@@ -101,7 +101,8 @@
<ul class="task-list clean-result-list">
<li
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"
>
<div class="left split-result-main">
@@ -109,6 +110,7 @@
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</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.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} </div>
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
@@ -135,7 +137,7 @@
v-if="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
@click="triggerItemRun(item)"
>
打开紫鸟
</button>
@@ -165,7 +167,8 @@
<ul class="task-list clean-result-list">
<li
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"
>
<div class="left split-result-main">
@@ -195,14 +198,7 @@
<span class="status" :class="getTaskStatusInfo(item).className">
{{ getTaskStatusInfo(item).text }}
</span>
<button
v-if="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
>
打开紫鸟
</button>
<!-- 历史记录区不再显示打开紫鸟按钮 -->
<button
v-if="canDownloadTaskResult(item)"
type="button"
@@ -249,11 +245,24 @@ import {
} from '@/shared/api/java-modules'
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 uploadedFiles = ref<UploadedJavaFile[]>([])
const running = ref(false)
const resultItems = ref<DeleteBrandResultItem[]>([])
const currentRunItems = ref<DeleteBrandResultItem[]>([])
const sessionTasks = ref<StoredCurrentTask[]>([])
const historyItems = ref<DeleteBrandResultItem[]>([])
const summary = ref<DeleteBrandRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const queuePushResult = ref('')
@@ -262,36 +271,65 @@ const taskDetails = ref<Record<number, DeleteBrandTaskDetailVo>>({})
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
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 allCurrent = [...currentRunItems.value];
historyItems.value.forEach(hItem => {
if (hItem.taskId && runningTaskIdsInHistory.value.includes(hItem.taskId)) {
// 避免重复
if (!allCurrent.some(c => c.taskId === hItem.taskId)) {
allCurrent.push(hItem);
}
}
const allCurrent: DeleteBrandResultItem[] = [];
sessionTasks.value.forEach(task => {
allCurrent.push(...task.items);
});
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(() =>
historyItems.value.filter(
(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) {
const taskId = item.taskId
if (!taskId) return false
const status = getTaskStatus(taskId)
// 只要状态是运行中就显示进度条,不管 matched 了matched 不对的状态应该在后端就标记为 FAILED
return status === 'RUNNING'
const qs = getQueueStatus(item)
return qs === '已入队' || qs === '处理中'
}
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
return items.map((item) => {
if (item.matched === false) {
// 只有在既没成功,又显式标记为未匹配时,才做错误兜底
if (!item.success && item.matched === false) {
return {
...item,
success: false,
error: item.error || '未匹配到紫鸟店铺',
openStoreUrl: undefined,
}
@@ -368,7 +404,9 @@ function formatProgress(taskId: number) {
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_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_asin) parts.push(`ASIN${info.current_asin}`)
if (info.finished_files != null) parts.push(`已完成文件:${info.finished_files}`)
@@ -402,38 +440,58 @@ function isTaskTerminal(taskId: number) {
function getPollingTaskIds() {
const ids = new Set<number>()
// 1. 检查本会话触发的任务
currentRunItems.value.forEach((item) => {
if (item.taskId && !isTaskTerminal(item.taskId)) {
ids.add(item.taskId)
sessionTasks.value.forEach(task => {
// 只轮询有文件被推给 Python 的任务
if (task.items.some(i => (i as any)._pushed)) {
ids.add(task.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)
}
function canDownloadTaskResult(item: DeleteBrandResultItem) {
function getQueueStatus(item: DeleteBrandResultItem) {
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
return status === 'SUCCESS'
}
@@ -477,14 +535,25 @@ function scheduleNextPoll(immediate = false) {
if (!taskIds.length) return
pollingInFlight.value = true
let stateChanged = false
try {
await refreshTaskDetails(taskIds)
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
removeSessionTaskFromStorage(taskId)
stateChanged = true
}
}
} finally {
pollingInFlight.value = false
}
const stillRunning = taskIds.some((id) => isTaskRunning(id))
if (stillRunning) {
if (stateChanged) {
loadHistory()
syncResultState()
}
if (getPollingTaskIds().length > 0) {
scheduleNextPoll()
}
}
@@ -598,39 +667,24 @@ async function submitRun() {
})),
})
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()
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) {
ElMessage.error('存在未匹配到紫鸟店铺的文件,已标记为失败')
ElMessage.error('存在未匹配到紫鸟店铺的文件,请检查报错内容。这些文件已自动归档至历史记录。')
} else {
ElMessage.success('删除品牌解析完成')
ElMessage.success('删除品牌解析完成,请手动推送')
}
// 更新历史记录,以展示那些未进入队列的失败项
await loadHistory()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '执行失败')
} finally {
@@ -679,16 +733,17 @@ async function loadHistory() {
}
function removeRecordFromView(resultId: number) {
const removedFromCurrent = currentRunItems.value.find((item) => item.resultId === resultId)
const removedTaskId = removedFromCurrent?.taskId
currentRunItems.value = currentRunItems.value.filter((item) => item.resultId !== resultId)
historyItems.value = historyItems.value.filter((item) => item.resultId !== resultId)
if (removedTaskId && !currentRunItems.value.some((item) => item.taskId === removedTaskId)) {
delete taskDetails.value[removedTaskId]
sessionTasks.value.forEach(st => {
st.items = st.items.filter(item => item.resultId !== resultId)
})
const oldLen = sessionTasks.value.length
sessionTasks.value = sessionTasks.value.filter(st => st.items.length > 0)
if (sessionTasks.value.length !== oldLen && typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
historyItems.value = historyItems.value.filter((item) => item.resultId !== resultId)
syncResultState()
if (getPollingTaskIds().length === 0) {
@@ -720,11 +775,93 @@ function formatPreview(rows: DeleteBrandPreviewRow[]) {
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) {
window.open(url, '_blank')
}
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)
})

View File

@@ -203,6 +203,7 @@ export interface DeleteBrandTaskItem {
export interface DeleteBrandTaskDetailVo {
task: DeleteBrandTaskItem
line_progress: DeleteBrandLineProgress
items?: DeleteBrandResultItem[]
}
export interface DeleteBrandTaskBatchVo {
@@ -384,7 +385,11 @@ export function submitDeleteBrandResult(taskId: number, request: DeleteBrandSubm
}
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('?') ? '&' : '?'
return `${raw}${separator}user_id=${encodeURIComponent(String(getCurrentUserId()))}`
}