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

@@ -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()))}`
}