Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue
T
huangzd1997 3ce0569c59 fix(提示语): 失败提示优先展示后端真实原因,修正 10+ 处误导文案
上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台:
- 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗)
- 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING
- 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案
- dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
2026-09-17 23:02:15 +08:00

1788 lines
58 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<AmazonToolPageShell tool-id="delasin">
<div class="main-content">
<aside class="left-panel left-panel--with-template">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">上传删除品牌 Excel直接上传文件或上传文件夹批量导入都可以每个 Excel 文件名会作为店铺名</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
</div>
<div class="selected-files clean-placeholder split-selected-files">
<template v-if="selectedPaths.length">
<span v-for="path in displayPaths" :key="path">{{ path }}</span>
<span v-if="selectedPaths.length > displayPaths.length" class="more-line">
还有 {{ selectedPaths.length - displayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择删除品牌文件</span>
</div>
</div>
<div class="section-title">执行说明</div>
<div class="option-group">
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">文件名作为店铺名</span>
<div class="desc">例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明并自动联动紫鸟进行店铺匹配</div>
</div>
</label>
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">文件夹仅用于批量导入</span>
<div class="desc">文件夹名和子目录名不会参与店铺匹配只是方便一次上传多个 Excel</div>
</div>
</label>
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">按国家分组去重</span>
<div class="desc">解析后按国家返回并在每个国家内按 ASIN 去重保留首次出现的状态</div>
</div>
</label>
</div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row">
<button type="button" class="btn-run" :disabled="running" @click="submitRun">
{{ running ? '解析中...' : '开始解析' }}
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length"
@click="pushToPythonQueue">
{{ pushing ? '启动中...' : '启动任务' }}
</button>
<span class="loading-msg">
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
</span>
</div>
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
<div class="section-title queue-debug-title">任务启动结果</div>
<div v-if="queuePushResult" class="queue-debug-line">
{{ queuePushResult }}
</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
</div>
<ModuleTemplateDownload module-code="delete-brand" filename="删除指定待售 文档格式.xlsx" />
</aside>
<section class="right-panel">
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="删除品牌结果"
:cards="deleteBrandCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无删除品牌结果完成解析后会在这里展示按国家分组的去重结果"
history-empty-text="暂无历史记录"
>
<template #item-actions="{ item }">
<button v-if="canDownloadTaskResult(itemSource(item))" type="button" class="download"
@click="downloadTaskResult(itemSource(item))">下载</button>
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
@click="deleteHistoryRecord(itemSource(item).resultId!)">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownloadTaskResult(itemSource(item))" type="button" class="download"
@click="downloadTaskResult(itemSource(item))">下载</button>
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
@click="deleteHistoryRecord(itemSource(item).resultId!)">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { expandBrandFolderRecursive } from '@/shared/api/brand'
import {
deleteDeleteBrandHistory,
getDeleteBrandHistory,
getDeleteBrandResultDownloadUrl,
getDeleteBrandTaskProgress,
getDeleteBrandTaskDownloadUrl,
runDeleteBrand,
type DeleteBrandPreviewRow,
type DeleteBrandResultItem,
type DeleteBrandRunVo,
type DeleteBrandTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
EXCEL_EXTENSIONS,
checkQueuePayload,
checkSelectedFiles,
guardBlocked,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
import { formatDateTime } from '@/shared/utils/datetime'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
import { runBatchDelete } from '@/shared/utils/batch-delete'
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
_pushed?: boolean
_completed?: boolean
}
const ziniaoVersion = useZiniaoVersion()
interface StoredCurrentTask {
taskId: number
items: SessionDeleteBrandItem[]
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 sessionTasks = ref<StoredCurrentTask[]>([])
const historyItems = ref<DeleteBrandResultItem[]>([])
const summary = ref<DeleteBrandRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const queuePushResult = ref('')
const queuePayloadText = ref('')
const taskDetails = ref<Record<number, DeleteBrandTaskDetailVo>>({})
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
const pushing = ref(false)
const chainStarted = ref(false)
const activeItemKey = ref('')
const autoAdvancing = ref(false)
const autoRetryTimer = ref<number | null>(null)
const waitingForBackendRecovery = ref(false)
let disposed = false
const timers = createCategorizedTimers('delete-brand')
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
const currentSectionItems = computed(() => {
const sortedTasks = [...sessionTasks.value].sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0))
const allCurrent: DeleteBrandResultItem[] = [];
sortedTasks.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) {
if (!items?.length) return
const existingIndex = sessionTasks.value.findIndex(t => t.taskId === taskId)
const existingTask = existingIndex >= 0 ? sessionTasks.value[existingIndex] : null
// 全量保留任务项(不在存储层按 matched/success 过滤),避免单任务多店铺时出现“未处理项丢失”。
// 同时尽量继承已有的 _pushed/_completed 会话态。
const mergedItems = items.map((item) => {
const prev = existingTask?.items.find((i) => isSameDeleteBrandItem(i, item))
// 保护大字段:当新值为空数组时,保留本地已有的非空数据,避免被轮询详情的精简 payload 覆盖。
const nextCountries = Array.isArray(item.countries) ? item.countries : undefined
const prevCountries = Array.isArray(prev?.countries) ? prev?.countries : undefined
const countries = (nextCountries && nextCountries.length > 0)
? nextCountries
: (prevCountries && prevCountries.length > 0 ? prevCountries : (nextCountries ?? prevCountries))
const nextPreviewRows = Array.isArray(item.previewRows) ? item.previewRows : undefined
const prevPreviewRows = Array.isArray(prev?.previewRows) ? prev?.previewRows : undefined
const previewRows = (nextPreviewRows && nextPreviewRows.length > 0)
? nextPreviewRows
: (prevPreviewRows && prevPreviewRows.length > 0 ? prevPreviewRows : (nextPreviewRows ?? prevPreviewRows))
return {
...item,
countries,
previewRows,
_pushed: prev?._pushed ?? (item as SessionDeleteBrandItem)._pushed ?? false,
_completed: prev?._completed ?? (item as SessionDeleteBrandItem)._completed ?? false,
} as SessionDeleteBrandItem
})
const newTask: StoredCurrentTask = {
taskId,
items: mergedItems,
createdAt: existingTask?.createdAt || Date.now(),
queuePushResult: pushResult ?? existingTask?.queuePushResult,
queuePayloadText: payloadText ?? existingTask?.queuePayloadText,
}
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))
}
}
function isTerminalStatus(status?: string | null) {
return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED'
}
function pruneTerminalSessionTasksByHistory(items: DeleteBrandResultItem[]) {
if (!items?.length) return false
const terminalTaskIds = new Set<number>()
items.forEach((item) => {
if (item.taskId && isTerminalStatus(item.taskStatus)) {
terminalTaskIds.add(item.taskId)
}
})
if (!terminalTaskIds.size) return false
const oldLen = sessionTasks.value.length
sessionTasks.value = sessionTasks.value.filter((task) => !terminalTaskIds.has(task.taskId))
if (sessionTasks.value.length !== oldLen) {
persistSessionTasks()
return true
}
return false
}
const historySectionItems = computed(() =>
historyItems.value.filter(
(item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item))
),
);
const queueRunnableItems = computed(() =>
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未启动'),
)
const deleteBrandCards = computed<TaskStatCard[]>(() => {
const historyRows = historySectionItems.value
let successCount = 0
let failedCount = 0
for (const item of historyRows) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'SUCCESS') {
successCount++
} else if (status === 'FAILED') {
failedCount++
} else if (item.success === true) {
successCount++
} else {
failedCount++
}
}
return [
{ label: '运行中任务', value: currentSectionItems.value.length },
{ label: '已结束任务', value: historyRows.length },
{ label: '成功任务', value: successCount },
{ label: '失败任务', value: failedCount },
]
})
const currentTaskViews = computed<TaskItemView[]>(() =>
currentSectionItems.value.map(toDeleteBrandTaskView),
)
const historyTaskViews = computed<TaskItemView[]>(() =>
historySectionItems.value.map(toDeleteBrandTaskView),
)
function itemSource(item: TaskItemView): DeleteBrandResultItem {
return item.source as DeleteBrandResultItem
}
function toDeleteBrandTaskView(item: DeleteBrandResultItem): TaskItemView {
const statusInfo = getTaskStatusInfo(item)
const showProgress = Boolean(shouldShowProgress(item))
const queueStatus = getQueueStatus(item)
const taskStatus = (item.taskStatus || '').toUpperCase()
const detailStatus = (getTaskStatus(item.taskId) || '').toUpperCase()
const isTerminal = isTerminalStatus(taskStatus) || isTerminalStatus(detailStatus)
const extraLines: string[] = [
`店铺名:${item.shopName || '-'}`,
`匹配结果:${formatMatchResult(item)}`,
...(item.platform ? [`平台:${item.platform}`] : []),
...(!isTerminal && queueStatus ? [`队列状态:${queueStatus}`] : []),
...(item.countryCount !== undefined && item.matched ? [`国家数:${item.countryCount}`] : []),
...(item.totalRows !== undefined && item.matched ? [`去重后 ${item.totalRows} 条`] : []),
...(showProgress && formatProgress(item) ? [formatProgress(item)] : []),
...(item.previewRows?.length ? [formatPreview(item.previewRows)] : []),
...(getDisplayError(item) ? [`错误信息:${getDisplayError(item)}`] : []),
]
return {
key: `delete-brand-${item.taskId ?? ''}-${item.resultId ?? item.fileKey ?? item.sourceFilename}`,
title: item.sourceFilename || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(taskCreatedAt(item)),
finishedAt: taskFinishedAt(item) ? formatDateTime(taskFinishedAt(item)) : '进行中',
statusText: statusInfo.text,
statusClass: statusInfo.className as TaskItemView['statusClass'],
extraLines,
progress: showProgress ? { percent: formatProgressPercent(item), stage: '任务进度' } : null,
source: item,
}
}
function persistSessionTasks() {
if (typeof window !== 'undefined') {
if (sessionTasks.value.length === 0) window.localStorage.removeItem(getStorageKey())
else window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
}
function isMultiFileTask(taskId?: number) {
if (!taskId) return false
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
return (sessionTask?.items.length || 0) > 1
}
function findSessionTask(taskId?: number) {
if (!taskId) return null
return sessionTasks.value.find(t => t.taskId === taskId) || null
}
function isSameDeleteBrandItem(left: Pick<DeleteBrandResultItem, 'resultId' | 'fileKey' | 'sourceFilename'> | null | undefined,
right: Pick<DeleteBrandResultItem, 'resultId' | 'fileKey' | 'sourceFilename'> | null | undefined) {
if (!left || !right) return false
if (left.resultId != null && right.resultId != null) {
return left.resultId === right.resultId
}
if (left.fileKey && right.fileKey) {
return left.fileKey === right.fileKey
}
return left.sourceFilename === right.sourceFilename
}
function findSessionItem(taskId: number | undefined, item: DeleteBrandResultItem) {
const sessionTask = findSessionTask(taskId)
if (!sessionTask) return null
return sessionTask.items.find(i => isSameDeleteBrandItem(i, item)) || null
}
function updateCompletedItemsFromProgress(taskId: number | undefined) {
if (!taskId) return false
const sessionTask = findSessionTask(taskId)
if (!sessionTask) return false
let changed = false
const info = taskDetails.value[taskId]?.line_progress?.info
const multiFileTask = isMultiFileTask(taskId)
// 关键修复:多文件任务下不再用 finished_files 按顺序回填 completed。
// 否则当第 1 个文件完成后,推入第 2 个文件时会因为 finished_files=1 被误判为“本文件已完成(100%)”。
// 改为仅当后端进度明确指向该文件且 current_line >= total_lines 时,才标记该文件 completed。
if (info?.file_name && info.current_line != null && info.total_lines != null && info.total_lines > 0) {
if (info.current_line >= info.total_lines) {
const sessionItem = sessionTask.items.find(i => i.sourceFilename === info.file_name)
if (sessionItem && !sessionItem._completed) {
sessionItem._completed = true
changed = true
}
}
}
// 单文件任务兜底:任务终态时标记完成,避免偶发进度丢包导致状态卡住。
if (!multiFileTask) {
const status = taskDetails.value[taskId]?.task?.status
if ((status === 'SUCCESS' || status === 'FAILED') && sessionTask.items.length === 1) {
const onlyItem = sessionTask.items[0]
if (onlyItem && !onlyItem._completed) {
onlyItem._completed = true
changed = true
}
}
}
if (changed) {
persistSessionTasks()
}
return changed
}
function getTaskStatus(taskId?: number) {
if (!taskId) return ''
return taskDetails.value[taskId]?.task?.status || ''
}
function getDisplayError(item: DeleteBrandResultItem) {
if (item.error) return item.error
if (item.matchStatus && item.matchStatus !== 'MATCHED') {
return item.matchMessage || ''
}
const taskId = item.taskId
if (!taskId) return ''
return taskDetails.value[taskId]?.task?.errorMessage || ''
}
function shouldShowProgress(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return false
const progress = taskDetails.value[taskId]?.line_progress
const status = getTaskStatus(taskId)
const multiFileTask = isMultiFileTask(taskId)
const qs = getQueueStatus(item)
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, item)
const isActiveItem = getItemKey(item) === activeItemKey.value
const isProgressTarget = Boolean(progress?.has_progress && progress?.info?.file_name === item.sourceFilename)
// 多文件任务下,展示规则更宽松:
// 1) 当前活跃文件;2) 后端进度指向的文件;3) 已启动文件;4) 已完成文件(任务未终态时保留)。
if (isActiveItem || isProgressTarget || sessionItem?._pushed) {
return true
}
if ((status === 'RUNNING' || status === 'PENDING' || !status) && sessionItem?._completed) {
return true
}
return { success: false, retryable: true }
}
// 单文件任务:完成/终态后隐藏。
if (qs === '本文件解析完毕' || qs === '已被全局判定为终态') {
return false
}
if (progress?.has_progress || progress?.info) {
return true
}
return qs === '已提交' || qs === '处理中'
}
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
return items.map((item) => {
if (isUsableMatchedItem(item)) {
return {
...item,
matched: true,
_pushed: false,
_completed: false,
} as SessionDeleteBrandItem
}
return {
...item,
matched: false,
openStoreUrl: undefined,
error: item.error || undefined,
_pushed: false,
_completed: false,
} as SessionDeleteBrandItem
})
}
/** 任务卡开始时间(任务详情快照的创建时间) */
function taskCreatedAt(item: DeleteBrandResultItem) {
if (!item.taskId) return ''
return taskDetails.value[item.taskId]?.task?.createdAt ?? ''
}
/** 任务卡结束时间(任务详情快照的完成时间) */
function taskFinishedAt(item: DeleteBrandResultItem) {
if (!item.taskId) return ''
return taskDetails.value[item.taskId]?.task?.finishedAt ?? ''
}
function formatMatchResult(item: DeleteBrandResultItem) {
if (item.matchStatus === 'INDEX_STALE' && isUsableMatchedItem(item)) {
return `已匹配 ${item.shopId || ''}(匹配信息已过期,可继续启动)`.trim()
}
if (item.matchStatus === 'MATCHED' || item.matched) {
return `已匹配 ${item.shopId || ''}`.trim()
}
if (item.matchMessage) {
return item.matchMessage
}
if (item.matchStatus === 'CONFLICT') {
return '存在多个同名店铺,请人工确认'
}
if (item.matchStatus === 'PENDING') {
return '店铺尚未匹配完成'
}
if (item.matchStatus) {
return item.matchStatus
}
return item.matched ? `已匹配 ${item.shopId || ''}`.trim() : '待匹配'
}
function mergeVisibleItems() {
resultItems.value = [...currentSectionItems.value, ...historySectionItems.value]
}
function syncResultState() {
mergeVisibleItems()
updateSummary(resultItems.value)
}
function updateSummary(items: DeleteBrandResultItem[]) {
summary.value = {
total: items.length,
successCount: items.filter((item) => item.success).length,
failedCount: items.filter((item) => !item.success).length,
items,
}
}
function getFileProgress(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return null
const detail = taskDetails.value[taskId]
const fileProgress = detail?.fileProgress || []
return fileProgress.find((fp) => {
if (fp.fileKey && item.fileKey) return fp.fileKey === item.fileKey
if (fp.sourceFilename && item.sourceFilename) return fp.sourceFilename === item.sourceFilename
return false
}) || null
}
function formatProgress(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return ''
const detail = taskDetails.value[taskId]
const info = detail?.line_progress?.info
const fp = getFileProgress(item)
const multiFileTask = isMultiFileTask(taskId)
// 多文件任务优先按“当前运行文件 + 本地会话态”展示,避免后端 finished_files 按顺序分配导致某个文件被误判 COMPLETED。
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, item)
const status = detail?.task?.status
const isRunningTarget = Boolean(info?.file_name && info.file_name === item.sourceFilename)
let displayStatus = sessionItem?._completed ? 'COMPLETED' : (sessionItem?._pushed ? 'PENDING' : 'PENDING')
let processedRows = 0
let totalRows = Math.max(0, item.totalRows ?? fp?.totalRows ?? 0)
if (status === 'SUCCESS') {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (status === 'FAILED' && sessionItem?._completed) {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (isRunningTarget) {
displayStatus = 'RUNNING'
processedRows = Math.max(0, Math.min(totalRows || Number.MAX_SAFE_INTEGER, info?.current_line ?? 0))
if (!totalRows && info?.total_lines && info.total_lines > 0) {
totalRows = info.total_lines
}
} else if (sessionItem?._completed) {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (sessionItem?._pushed) {
displayStatus = 'PENDING'
processedRows = 0
}
const parts: string[] = []
parts.push(`文件状态:${displayStatus}`)
parts.push(`文件进度:${processedRows}/${totalRows}`)
if (info?.finished_files != null) {
const total = info.file_total && info.file_total > 0 ? `/${info.file_total}` : ''
parts.push(`已完成文件:${info.finished_files}${total}`)
}
if (info?.phase) parts.push(`阶段:${info.phase}`)
return parts.join('')
}
if (fp) {
const parts: string[] = []
const processedRows = fp.processedRows ?? 0
const totalRows = fp.totalRows ?? 0
if (fp.status) parts.push(`文件状态:${fp.status}`)
parts.push(`文件进度:${processedRows}/${totalRows}`)
if (info?.finished_files != null) {
const total = info.file_total && info.file_total > 0 ? `/${info.file_total}` : ''
parts.push(`已完成文件:${info.finished_files}${total}`)
}
if (info?.phase) parts.push(`阶段:${info.phase}`)
return parts.join('')
}
if (!detail?.line_progress?.has_progress || !info) {
const status = detail?.task?.status
return status ? `任务状态:${status}` : ''
}
const parts: string[] = []
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(`${isMultiFileTask(taskId) ? '当前文件进度' : '进度'}${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) {
const total = info.file_total && info.file_total > 0 ? `/${info.file_total}` : ''
parts.push(`已完成文件:${info.finished_files}${total}`)
}
return parts.join('')
}
function formatProgressPercent(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return 0
const info = taskDetails.value[taskId]?.line_progress?.info
const multiFileTask = isMultiFileTask(taskId)
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, item)
const taskStatus = taskDetails.value[taskId]?.task?.status
if (taskStatus === 'SUCCESS' || sessionItem?._completed) return 100
const totalRows = Math.max(0, item.totalRows ?? getFileProgress(item)?.totalRows ?? 0)
const isRunningTarget = Boolean(info?.file_name && info.file_name === item.sourceFilename)
if (isRunningTarget) {
if (info?.current_line != null && info?.total_lines && info.total_lines > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / info.total_lines) * 100)))
}
if (info?.current_line != null && totalRows > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / totalRows) * 100)))
}
}
return 0
}
const fp = getFileProgress(item)
if (fp) {
return Math.max(0, Math.min(100, fp.percent ?? 0))
}
if (!info) return 0
if (info.current_line != null && info.total_lines && info.total_lines > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / info.total_lines) * 100)))
}
if (info.finished_files != null && info.file_total && info.file_total > 0) {
return Math.max(0, Math.min(100, Math.round((info.finished_files / info.file_total) * 100)))
}
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS' ? 100 : 0
}
function isTaskTerminal(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return isTerminalStatus(status)
}
function getPollingTaskIds() {
const ids = new Set<number>()
sessionTasks.value.forEach(task => {
// 只轮询有文件被推给 Python 的任务
if (task.items.some(i => (i as any)._pushed)) {
ids.add(task.taskId)
}
})
return Array.from(ids)
}
function getQueueStatus(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return ''
const sessionTask = findSessionTask(taskId)
if (!sessionTask) return ''
const sessionItem = findSessionItem(taskId, item)
const status = getTaskStatus(taskId)
const info = taskDetails.value[taskId]?.line_progress
const multiFileTask = isMultiFileTask(taskId)
if (!multiFileTask && (status === 'SUCCESS' || status === 'FAILED' || item.taskStatus === 'SUCCESS' || item.taskStatus === 'FAILED')) {
return '已被全局判定为终态'
}
// 后端已经把单文件结果组装出来时,以文件终态为准。
// 多店铺同 taskId 场景下任务本身仍是 RUNNING,不能因此挡住下一个店铺入队。
if (isFileResultReady(item) || isFileResultReady(sessionItem)) {
return '本文件解析完毕'
}
// 先看前端会话态:一旦该文件已判定完成,优先展示完成,避免被 RUNNING 覆盖导致链式推进卡住。
if (sessionItem?._completed) {
return '本文件解析完毕'
}
// 多文件场景下,如果后端进度明确指向当前文件,即使本地 _pushed 丢失也应判定为处理中(例如刷新页面后恢复场景)。
if (status === 'RUNNING' && info?.has_progress && info?.info?.file_name === item.sourceFilename) {
return '处理中'
}
// 多文件:未推送且未完成,才视为未启动。
if (multiFileTask && !sessionItem?._pushed && !sessionItem?._completed) {
return '未启动'
}
if (multiFileTask && status === 'SUCCESS') {
return '本文件解析完毕'
}
if (!multiFileTask) {
const detail = taskDetails.value[taskId]
if (detail?.items) {
const finished = detail.items.find((i: DeleteBrandResultItem) => isSameDeleteBrandItem(i, item))
if (finished && status === 'SUCCESS') {
return '本文件解析完毕'
}
}
}
if (sessionItem?._pushed) {
return '已提交'
}
return '未启动'
}
function canDownloadTaskResult(item: DeleteBrandResultItem) {
if (item.downloadUrl) return true
if (item.fileReady) return true
return false
}
function isFileResultReady(item?: Partial<DeleteBrandResultItem> | null) {
if (!item) return false
const fileStatus = String(item.fileStatus || '').toUpperCase()
return Boolean(
item.fileReady
|| item.downloadUrl
|| fileStatus === 'SUCCESS'
|| fileStatus === 'COMPLETED',
)
}
function preferLatestValue<T>(latestValue: T | null | undefined, currentValue: T | null | undefined) {
return latestValue ?? currentValue
}
function mergeTaskDetailItemsIntoSession(detail: DeleteBrandTaskDetailVo) {
const taskId = detail?.task?.id
if (!taskId) return false
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return { success: false, retryable: false }
const detailItems = detail?.items || []
let changed = false
// 以本地会话项为主做增量合并,保留 _pushed/_completed
// 对后端新增返回的项追加到末尾,避免单任务多店铺/后续补齐时丢项。
const mergedItems = sessionTask.items.map((item) => {
const latest = detailItems.find((candidate) => isSameDeleteBrandItem(candidate, item))
if (!latest) return item
const latestCountries = Array.isArray(latest.countries) ? latest.countries : undefined
const itemCountries = Array.isArray(item.countries) ? item.countries : undefined
const countries = (latestCountries && latestCountries.length > 0)
? latestCountries
: (itemCountries && itemCountries.length > 0 ? itemCountries : (latestCountries ?? itemCountries))
const latestPreviewRows = Array.isArray(latest.previewRows) ? latest.previewRows : undefined
const itemPreviewRows = Array.isArray(item.previewRows) ? item.previewRows : undefined
const previewRows = (latestPreviewRows && latestPreviewRows.length > 0)
? latestPreviewRows
: (itemPreviewRows && itemPreviewRows.length > 0 ? itemPreviewRows : (latestPreviewRows ?? itemPreviewRows))
const merged = {
...item,
...latest,
// progress/batch 返回的是轻量文件状态,店铺匹配信息和执行 payload 可能为空;
// 不能覆盖本地解析出的完整店铺数据,否则后续文件会失去入队资格。
shopName: preferLatestValue(latest.shopName, item.shopName),
companyName: preferLatestValue(latest.companyName, item.companyName),
shopId: preferLatestValue(latest.shopId, item.shopId),
platform: preferLatestValue(latest.platform, item.platform),
openStoreUrl: preferLatestValue(latest.openStoreUrl, item.openStoreUrl),
matchStatus: preferLatestValue(latest.matchStatus, item.matchStatus),
matchMessage: preferLatestValue(latest.matchMessage, item.matchMessage),
matched: isUsableMatchedItem(item) || isUsableMatchedItem(latest),
countryCount: preferLatestValue(latest.countryCount, item.countryCount),
countries,
previewRows,
_pushed: item._pushed,
_completed: item._completed || isFileResultReady(latest),
} as SessionDeleteBrandItem
if (JSON.stringify(merged) !== JSON.stringify(item)) {
changed = true
}
return merged
})
for (const latest of detailItems) {
if (!mergedItems.some((item) => isSameDeleteBrandItem(item, latest))) {
mergedItems.push({
...latest,
_pushed: false,
_completed: isFileResultReady(latest),
} as SessionDeleteBrandItem)
changed = true
}
}
if (JSON.stringify(mergedItems) !== JSON.stringify(sessionTask.items)) {
sessionTask.items = mergedItems
changed = true
}
if (changed) {
persistSessionTasks()
}
return changed
}
async function refreshTaskDetails(taskIds?: number[]) {
const ids = taskIds || getPollingTaskIds()
if (!ids.length) return
try {
const batch = await getDeleteBrandTaskProgress(ids)
if (waitingForBackendRecovery.value && chainStarted.value) {
queuePushResult.value = '服务已恢复,继续执行当前任务并自动衔接后续任务...'
}
waitingForBackendRecovery.value = false
let changed = false
for (const detail of batch.items || []) {
const id = detail?.task?.id
if (typeof id === 'number' && id > 0) {
taskDetails.value[id] = detail
if (mergeTaskDetailItemsIntoSession(detail)) {
changed = true
}
if (updateCompletedItemsFromProgress(id)) {
changed = true
}
}
}
if (changed) {
syncResultState()
}
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (transient && chainStarted.value) {
waitingForBackendRecovery.value = true
queuePushResult.value = '当前任务运行中,正在等待服务恢复...'
}
}
}
function getPollIntervalMs() {
return getTaskPollIntervalMs()
}
function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value) {
if (!immediate) return
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
const executePoll = async () => {
pollTimer.value = null
if (disposed) return
if (pollingInFlight.value) {
scheduleNextPoll()
return
}
const taskIds = getPollingTaskIds()
if (!taskIds.length) return
pollingInFlight.value = true
let stateChanged = false
let shouldRefreshHistory = false
try {
await refreshTaskDetails(taskIds)
await maybeAutoAdvance()
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
// 以后端任务终态为准:进入 SUCCESS/FAILED 后立即清理本地当前任务并刷新 history,
// 避免 _completed 会话态偶发不同步导致持续轮询 batch。
removeSessionTaskFromStorage(taskId)
stateChanged = true
shouldRefreshHistory = true
}
}
} finally {
pollingInFlight.value = false
}
if (shouldRefreshHistory) {
await loadHistory()
} else if (stateChanged) {
syncResultState()
}
if (!disposed && getPollingTaskIds().length > 0) {
scheduleNextPoll()
}
}
if (immediate) {
executePoll()
} else {
pollTimer.value = timers.setTimeout('task-poll', executePoll, getPollIntervalMs())
}
}
function ensurePolling(immediate = false) {
if (pollTimer.value && !immediate) return
scheduleNextPoll(immediate)
}
function getTaskStatusInfo(item: DeleteBrandResultItem) {
if (item.fileReady || item.downloadUrl) return { className: 'success', text: '已完成' }
// 1. 优先查实时详情中的状态
const status = item.taskId ? taskDetails.value[item.taskId]?.task?.status : null
if (status === 'SUCCESS') return { className: 'success', text: '已完成' }
if (status === 'FAILED') return { className: 'failed', text: '失败' }
if (status === 'RUNNING') return { className: 'running', text: '执行中' }
// 2. 其次看 history 接口带回来的后端任务状态
if (item.taskStatus === 'SUCCESS') return { className: 'success', text: '已完成' }
if (item.taskStatus === 'FAILED') return { className: 'failed', text: '失败' }
if (item.taskStatus === 'RUNNING') return { className: 'running', text: '执行中' }
// 3. 最后根据 item 本身的 success 标识兜底
if (item.success) return { className: 'success', text: '已完成' }
return { className: 'failed', text: '失败' }
}
function stopPolling() {
if (pollTimer.value) {
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
clearAutoRetryTimer()
}
async function selectFiles() {
const api = getPywebviewApi()
if (!api?.select_brand_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
selectedPaths.value = paths
uploadedFiles.value = await uploadPathsToJava(getPywebviewApi(), paths)
ElMessage.success(`已选择 ${paths.length} 个删除品牌文件`)
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
const result = await expandBrandFolderRecursive(folder)
if (!result.success || !result.items?.length) {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
selectedPaths.value = result.items.map((item) => item.relativePath || item.absolutePath)
uploadedFiles.value = await uploadPathsToJava(getPywebviewApi(), result.items)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
} catch (error) {
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function submitRun() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择待处理文件')
return
}
try {
running.value = true
const result = await runDeleteBrand({
files: uploadedFiles.value.map((item) => ({
fileKey: item.fileKey,
originalFilename: item.originalFilename,
relativePath: item.relativePath,
})),
})
const normalizedItems = normalizeDeleteBrandItems(result.items || [])
// 一条都没解析出来说明文件根本没读懂,直接拦下,不写入会话与历史
if (!normalizedItems.length) {
await passGuard(
guardBlocked(
'解析结果无法执行',
'这批文件里没有解析出任何删除品牌记录。\n' +
'请确认文件名能对应到店铺名、表内有数据行且表头与模板一致,修正后重新上传。',
'delete-brand.empty-result',
),
)
queuePushResult.value = '解析完成,但没有解析出任何记录'
queuePayloadText.value = ''
await loadHistory()
return
}
const hasRunnableItems = normalizedItems.some((item) => isUsableMatchedItem(item))
const hasStaleMatchedItems = normalizedItems.some((item) => item.matchStatus === 'INDEX_STALE' && item.matched)
const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item))
queuePushResult.value = hasRunnableItems
? '解析完成,可在左侧点击“启动任务”开始依次处理'
: '解析完成,当前没有可启动的文件'
queuePayloadText.value = ''
if (normalizedItems.length && normalizedItems[0].taskId) {
saveSessionTask(normalizedItems[0].taskId, normalizedItems, queuePushResult.value, queuePayloadText.value)
}
syncResultState()
if (hasBlockedItems) {
// 具体原因(店铺未录入 / 索引未命中 / 表头错误等)在各文件项里,直接带出来;
// 只报「请稍后重试」会把「需去后台添加店铺」误导成「等一等就好」
const firstBlocked = normalizedItems.find((item) => !isUsableMatchedItem(item))
const detail = firstBlocked ? getDisplayError(firstBlocked) : ''
ElMessage.warning(detail
? `部分文件不可启动:${detail}`
: '部分文件尚未匹配到店铺,已保留状态信息,请稍后重试。')
} else if (hasStaleMatchedItems) {
ElMessage.warning('部分文件匹配到的店铺信息已过期,仍可启动任务;后台会自动重新匹配。')
} else if (hasRunnableItems) {
ElMessage.success('删除品牌解析完成,请在左侧启动任务')
} else {
ElMessage.warning('当前没有可启动的文件,请先等待店铺匹配完成。')
}
// 更新历史记录,以展示那些未进入队列的失败项
await loadHistory()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '执行失败')
} finally {
running.value = false
}
}
async function downloadTaskResult(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) {
ElMessage.warning('未找到任务ID')
return
}
const url = item.resultId ? getDeleteBrandResultDownloadUrl(item.resultId) : getDeleteBrandTaskDownloadUrl(taskId)
const filename =
item.outputFilename ||
item.sourceFilename ||
`delete-brand_${taskId}.xlsx`
const result = await saveUrlWithProgress(url, filename, item.resultId ? `delete-brand:${item.resultId}` : `delete-brand-task:${taskId}`)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
}
async function loadHistory() {
try {
const response = await getDeleteBrandHistory()
historyItems.value = normalizeDeleteBrandItems(response.items || [])
pruneTerminalSessionTasksByHistory(historyItems.value)
syncResultState()
// 关键:页面加载历史后,立刻拉取详情触发状态机
await refreshTaskDetails()
ensurePolling(true)
} catch (err) {
const message = (err instanceof Error ? err.message : String(err || '')).toLowerCase()
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (!transient) {
console.error('loadHistory error:', err)
}
}
}
function removeRecordFromView(resultId: number) {
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) {
stopPolling()
}
}
async function deleteHistoryRecord(resultId: number) {
try {
await deleteDeleteBrandHistory(resultId)
removeRecordFromView(resultId)
await loadHistory()
ElMessage.success('已删除')
} catch (error) {
const message = error instanceof Error ? error.message : '删除失败'
// 后端可能已被清理/重复点击导致「记录不存在」,前端也应立即移除并停止轮询
if (typeof message === 'string' && message.includes('记录不存在')) {
removeRecordFromView(resultId)
ElMessage.success('已删除')
return
}
ElMessage.error(message)
}
}
function formatPreview(rows: DeleteBrandPreviewRow[]) {
const preview = rows.slice(0, 5).map((item) => `${item.country}:${item.asin}${item.status ? `${item.status}` : ''}`)
const hiddenCount = rows.length - preview.length
return hiddenCount > 0 ? `${preview.join('、')}${rows.length} 条` : preview.join('、')
}
function debugAutoAdvance(step: string, payload?: Record<string, unknown>) {
if (!import.meta.env.DEV) return
console.log(`[DeleteBrandAutoAdvance] ${step}`, payload || {})
}
function clearAutoRetryTimer() {
if (autoRetryTimer.value) {
timers.clearTimer('auto-retry', autoRetryTimer.value)
autoRetryTimer.value = null
}
}
function scheduleAutoAdvanceRetry(delayMs = 3000) {
if (disposed) return
clearAutoRetryTimer()
autoRetryTimer.value = timers.setTimeout('auto-retry', () => {
autoRetryTimer.value = null
if (!disposed) maybeAutoAdvance().catch(() => undefined)
}, delayMs)
}
function isQueueBusyError(message?: string) {
if (!message) return false
return message.includes('当前店铺正在执行中')
}
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
}
function getItemKey(item: DeleteBrandResultItem) {
if (item.resultId != null) return `result:${item.resultId}`
return `task:${item.taskId || 0}:${item.sourceFilename || ''}`
}
function isUsableMatchedItem(item: DeleteBrandResultItem) {
return Boolean(
item.matched
|| item.matchStatus === 'MATCHED'
|| (item.matchStatus === 'INDEX_STALE' && item.shopId),
)
}
function hasRunnableDeleteBrandPayload(item: DeleteBrandResultItem) {
return Boolean(
item.shopName
&& Array.isArray(item.countries)
&& item.countries.length > 0,
)
}
function canPushToQueue(item: DeleteBrandResultItem) {
return Boolean(item.taskId && (isUsableMatchedItem(item) || hasRunnableDeleteBrandPayload(item)))
}
function findNextAutoRunnableItem() {
return currentSectionItems.value.find((item) => {
if (!canPushToQueue(item)) return false
if (getItemKey(item) === activeItemKey.value) return false
const sessionItem = findSessionItem(item.taskId, item)
if (sessionItem?._completed) return false
return getQueueStatus(item) === '未启动'
})
}
async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }) {
if (options?.auto && isPythonQueueBusy()) {
debugAutoAdvance('runItem blocked by busy queue', {
mode: 'auto',
taskId: item.taskId,
sourceFilename: item.sourceFilename,
})
return { success: false, retryable: true }
}
const api = getPywebviewApi()
const taskId = item.taskId
if (!taskId) return { success: false, retryable: false }
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return { success: false, retryable: false }
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用任务队列能力(浏览器预览环境不会启动)')
return { success: false, retryable: false }
}
const payload = {
type: 'delete-brand-run',
ts: Date.now(),
data: {
taskId: taskId,
ziniao_version: ziniaoVersion.value,
items: [item]
},
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
const guard = checkQueuePayload(payload, {
expectedType: 'delete-brand-run',
requiredDataKeys: ['taskId'],
nonEmptyArrayKeys: ['items'],
})
if (!(await passGuard(guard))) {
queuePushResult.value = `文件 ${item.sourceFilename || taskId} 数据校验未通过,已阻止启动`
saveSessionTask(taskId, sessionTask.items, queuePushResult.value, queuePayloadText.value)
return { success: false, retryable: false }
}
const pushResult = await api.enqueue_json(payload)
let qpr = ''
if (pushResult?.success) {
qpr = `已启动文件 ${item.sourceFilename},排队中的任务数:${pushResult.queue_size ?? '-'}`
const sessionItem = findSessionItem(taskId, item)
if (sessionItem) {
sessionItem._pushed = true
sessionItem._completed = false
}
activeItemKey.value = getItemKey(item)
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
ensurePolling(true)
queuePushResult.value = qpr
return { success: true, retryable: false }
} else {
const message = pushResult?.error || '未知错误'
qpr = `启动任务失败:${message}`
queuePushResult.value = qpr
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
if (options?.auto && isQueueBusyError(message)) {
return { success: false, retryable: true }
}
}
queuePushResult.value = qpr
return { success: false, retryable: false }
}
async function pushToPythonQueue() {
const nextItem = findNextAutoRunnableItem()
if (!nextItem) {
ElMessage.warning('当前没有可启动的文件')
return
}
pushing.value = true
chainStarted.value = true
clearAutoRetryTimer()
const started = await runItem(nextItem)
pushing.value = false
if (!started.success) {
chainStarted.value = false
activeItemKey.value = ''
return
}
ElMessage.success('已开始按顺序启动任务')
}
async function maybeAutoAdvance() {
if (!chainStarted.value || autoAdvancing.value) {
debugAutoAdvance('skip: chain not started or auto advancing', {
chainStarted: chainStarted.value,
autoAdvancing: autoAdvancing.value,
})
return
}
if (isPythonQueueBusy()) {
debugAutoAdvance('skip: queue busy')
return
}
const currentKey = activeItemKey.value
if (currentKey) {
const currentItem = currentSectionItems.value.find(item => getItemKey(item) === currentKey)
if (currentItem) {
const taskId = currentItem.taskId
const multiFileTask = isMultiFileTask(taskId)
if (multiFileTask) {
const changed = updateCompletedItemsFromProgress(taskId)
debugAutoAdvance('sync completed from progress', {
taskId,
changed,
status: getQueueStatus(currentItem),
})
}
const status = getQueueStatus(currentItem)
if (status === '已提交' || status === '处理中') {
debugAutoAdvance('skip: current item still running', {
currentKey,
status,
taskId,
sourceFilename: currentItem.sourceFilename,
})
return
}
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, currentItem)
if (sessionItem && !sessionItem._completed) {
debugAutoAdvance('skip: multi-file current not completed yet', {
currentKey,
taskId,
sourceFilename: currentItem.sourceFilename,
pushed: sessionItem._pushed,
completed: sessionItem._completed,
})
return
}
}
}
}
const nextItem = findNextAutoRunnableItem()
if (!nextItem) {
debugAutoAdvance('stop chain: no next runnable item', {
currentKey,
})
activeItemKey.value = ''
chainStarted.value = false
return
}
autoAdvancing.value = true
try {
debugAutoAdvance('try start next item', {
taskId: nextItem.taskId,
sourceFilename: nextItem.sourceFilename,
nextKey: getItemKey(nextItem),
})
const started = await runItem(nextItem, { auto: true })
if (!started.success) {
if (started.retryable) {
debugAutoAdvance('delay retry next item', {
taskId: nextItem.taskId,
sourceFilename: nextItem.sourceFilename,
})
scheduleAutoAdvanceRetry()
return
}
debugAutoAdvance('stop chain: runItem returned false', {
taskId: nextItem.taskId,
sourceFilename: nextItem.sourceFilename,
})
chainStarted.value = false
}
} finally {
autoAdvancing.value = false
}
}
onMounted(() => {
loadSessionTasksFromStorage()
const taskIds = getPollingTaskIds()
if (taskIds.length > 0) {
refreshTaskDetails(taskIds).then(async () => {
let stateChanged = false
let shouldRefreshHistory = false
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
// 首屏恢复也以后端终态为准,避免依赖 _completed 造成卡轮询。
removeSessionTaskFromStorage(taskId)
stateChanged = true
shouldRefreshHistory = true
}
}
if (shouldRefreshHistory) {
await loadHistory()
} else if (stateChanged) {
syncResultState()
}
ensurePolling(true)
})
} else {
syncResultState()
}
loadHistory().catch(() => undefined)
})
onUnmounted(() => {
disposed = true
stopPolling()
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
const { total, failed } = await runBatchDelete(views, (view) => deleteHistoryRecord(itemSource(view).resultId!))
if (!total) return
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${total} 条历史记录`)
}
}
</script>
<style scoped>
.queue-debug-card {
margin-top: 18px;
padding: 14px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #222b3d;
}
.queue-debug-title {
margin-bottom: 8px;
}
.queue-debug-line {
color: #a0acbe;
font-size: 12px;
line-height: 1.6;
margin-bottom: 8px;
}
.queue-debug-payload {
margin: 0;
max-height: 220px;
overflow: auto;
padding: 10px;
border-radius: 8px;
background: #10141f;
color: #8fd3ff;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.module-page {
min-height: 100vh;
background: #242424;
}
.main-content {
display: flex;
min-height: calc(100vh - 56px);
height: calc(100vh - 56px);
}
.left-panel {
width: 380px;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #242424;
}
.section-title {
font-size: 13px;
color: #a0acbe;
margin-bottom: 10px;
}
.upload-zone {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 24px;
text-align: center;
background: #242424;
margin-bottom: 20px;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: #3498db;
background: #242424;
}
.hint {
color: #5e6878;
font-size: 13px;
margin-bottom: 12px;
line-height: 1.5;
}
.btns {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.opt-btn,
.btn-run,
.download,
.btn-delete {
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.opt-btn {
padding: 8px 16px;
font-size: 13px;
color: #c8d2e2;
background: #242424;
border: 1px solid #3e4a62;
border-radius: 6px;
}
.opt-btn:hover {
color: #3498db;
background: #333;
border-color: #3498db;
}
.selected-files {
margin-top: 14px;
font-size: 12px;
color: #5e6878;
max-height: 112px;
overflow-y: auto;
text-align: left;
}
.selected-files span {
display: block;
margin: 4px 0;
word-break: break-all;
}
.more-line {
color: #a0acbe;
}
.split-selected-files {
max-height: 120px;
min-height: 72px;
padding-right: 4px;
}
.option-group {
margin-bottom: 20px;
}
.radio-item {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
padding: 10px 12px;
border-radius: 8px;
background: #242424;
border: 1px solid #2e3a52;
margin-bottom: 8px;
transition: all 0.2s ease;
}
.radio-item:hover,
.radio-item.active {
background: #242424;
border-color: #3e4a62;
}
.radio-item input {
margin-top: 3px;
}
.label {
font-weight: 500;
color: #f5f8fc;
font-size: 13px;
}
.desc {
font-size: 12px;
color: #5e6878;
margin-top: 2px;
line-height: 1.5;
font-weight: 400;
}
.run-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
margin-top: 16px;
}
.btn-run {
padding: 10px 22px;
background: #3498db;
color: #f5f8fc;
border-radius: 8px;
font-size: 14px;
}
.btn-run:hover {
background: #2980b9;
}
.btn-run:disabled {
background: #555;
color: #a0acbe;
cursor: not-allowed;
}
.loading-msg {
color: #3498db;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
margin-top: 4px;
}
.download {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(52, 152, 219, 0.18);
color: #69b6ff;
}
.download:hover {
background: rgba(52, 152, 219, 0.28);
}
.btn-delete {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(231, 76, 60, 0.12);
color: #ff8f8f;
}
.btn-delete:hover {
background: rgba(231, 76, 60, 0.22);
}
.clean-placeholder {
max-height: 112px;
}
@media (max-width: 1100px) {
.main-content {
flex-direction: column;
height: auto;
}
.left-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid #2e3a52;
}
.right-panel {
min-height: 420px;
}
}
</style>