1909 lines
58 KiB
Vue
1909 lines
58 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<BrandTopBar active="delete-brand" />
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel">
|
||
<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>
|
||
|
||
<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 ? '推送中...' : '推送到 Python 队列' }}
|
||
</button>
|
||
<span class="loading-msg">
|
||
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
|
||
<div class="section-title queue-debug-title">Python 队列推送结果</div>
|
||
<div v-if="queuePushResult" class="queue-debug-line">
|
||
{{ queuePushResult }}
|
||
</div>
|
||
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<div class="panel-header">删除品牌结果</div>
|
||
<div class="task-list-wrap">
|
||
<div class="clean-result-summary">
|
||
<div class="summary-card">
|
||
<span class="summary-label">已处理文件</span>
|
||
<strong>{{ summary.total }}</strong>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">成功结果</span>
|
||
<strong>{{ summary.successCount }}</strong>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">失败文件</span>
|
||
<strong>{{ summary.failedCount }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-list-wrap">
|
||
<div class="result-list-header">
|
||
<span>删除品牌结果列表</span>
|
||
</div>
|
||
|
||
<div v-if="!hasVisibleItems" class="empty-tasks">
|
||
暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div v-if="currentSectionItems.length" class="result-subsection">
|
||
<div class="result-subsection-title">当前任务</div>
|
||
<ul class="task-list clean-result-list">
|
||
<li v-for="item in currentSectionItems"
|
||
:key="`current-${item.taskId || item.resultId || item.sourceFilename}`"
|
||
class="task-item split-result-item">
|
||
<div class="left split-result-main">
|
||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
||
<div class="files">店铺名:{{ item.shopName || '-' }}</div>
|
||
<div class="files">匹配结果:{{ formatMatchResult(item) }}</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">
|
||
<div class="delete-brand-progress-header">
|
||
<span>任务进度</span>
|
||
<span>{{ formatProgressPercent(item) }}%</span>
|
||
</div>
|
||
<div class="delete-brand-progress-bar">
|
||
<div class="delete-brand-progress-bar-fill"
|
||
:style="{ width: `${formatProgressPercent(item)}%` }"></div>
|
||
</div>
|
||
<div class="files delete-brand-progress">{{ formatProgress(item) }}</div>
|
||
</div>
|
||
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
|
||
{{ formatPreview(item.previewRows) }}
|
||
</div>
|
||
<div v-if="getDisplayError(item)" class="files">错误信息:{{ getDisplayError(item) }}</div>
|
||
</div>
|
||
|
||
<div class="task-right split-result-actions">
|
||
<span class="status" :class="getTaskStatusInfo(item).className">
|
||
{{ getTaskStatusInfo(item).text }}
|
||
</span>
|
||
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
|
||
@click="downloadTaskResult(item)">
|
||
下载
|
||
</button>
|
||
<button v-if="item.resultId" type="button" class="btn-delete"
|
||
@click="deleteHistoryRecord(item.resultId)">
|
||
删除
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div v-if="historySectionItems.length" class="result-subsection">
|
||
<div class="result-subsection-title">历史记录</div>
|
||
<ul class="task-list clean-result-list">
|
||
<li v-for="item in historySectionItems"
|
||
:key="`history-${item.taskId || item.resultId || item.sourceFilename}`"
|
||
class="task-item split-result-item">
|
||
<div class="left split-result-main">
|
||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
||
<div class="files">店铺名:{{ item.shopName || '-' }}</div>
|
||
<div class="files">匹配结果:{{ formatMatchResult(item) }}</div>
|
||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</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">
|
||
<div class="delete-brand-progress-header">
|
||
<span>任务进度</span>
|
||
<span>{{ formatProgressPercent(item) }}%</span>
|
||
</div>
|
||
<div class="delete-brand-progress-bar">
|
||
<div class="delete-brand-progress-bar-fill"
|
||
:style="{ width: `${formatProgressPercent(item)}%` }"></div>
|
||
</div>
|
||
<div class="files delete-brand-progress">{{ formatProgress(item) }}</div>
|
||
</div>
|
||
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
|
||
{{ formatPreview(item.previewRows) }}
|
||
</div>
|
||
<div v-if="getDisplayError(item)" class="files">错误信息:{{ getDisplayError(item) }}</div>
|
||
</div>
|
||
|
||
<div class="task-right split-result-actions">
|
||
<span class="status" :class="getTaskStatusInfo(item).className">
|
||
{{ getTaskStatusInfo(item).text }}
|
||
</span>
|
||
<!-- 历史记录区不再显示打开紫鸟按钮 -->
|
||
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
|
||
@click="downloadTaskResult(item)">
|
||
下载
|
||
</button>
|
||
<button v-if="item.resultId" type="button" class="btn-delete"
|
||
@click="deleteHistoryRecord(item.resultId)">
|
||
删除
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import BrandTopBar from './BrandTopBar.vue'
|
||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||
import {
|
||
deleteDeleteBrandHistory,
|
||
getDeleteBrandHistory,
|
||
getDeleteBrandTaskDetails,
|
||
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'
|
||
|
||
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
|
||
_pushed?: boolean
|
||
_completed?: boolean
|
||
}
|
||
|
||
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)
|
||
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
|
||
|
||
const currentSectionItems = computed(() => {
|
||
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) {
|
||
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 hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
||
const queueRunnableItems = computed(() =>
|
||
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(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 (item.matchStatus === 'MATCHED') {
|
||
return {
|
||
...item,
|
||
_pushed: false,
|
||
_completed: false,
|
||
} as SessionDeleteBrandItem
|
||
}
|
||
return {
|
||
...item,
|
||
matched: false,
|
||
openStoreUrl: undefined,
|
||
error: item.error || undefined,
|
||
_pushed: false,
|
||
_completed: false,
|
||
} as SessionDeleteBrandItem
|
||
})
|
||
}
|
||
|
||
function formatMatchResult(item: DeleteBrandResultItem) {
|
||
if (item.matchStatus === 'MATCHED') {
|
||
return `已匹配 ${item.shopId || ''}`.trim()
|
||
}
|
||
if (item.matchMessage) {
|
||
return item.matchMessage
|
||
}
|
||
if (item.matchStatus === 'CONFLICT') {
|
||
return '存在多个同名店铺,请人工确认'
|
||
}
|
||
if (item.matchStatus === 'PENDING' || item.matchStatus === 'INDEX_STALE') {
|
||
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 '已被全局判定为终态'
|
||
}
|
||
|
||
// 先看前端会话态:一旦该文件已判定完成,优先展示完成,避免被 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
|
||
|
||
const taskId = item.taskId
|
||
if (!taskId) return { success: false, retryable: false }
|
||
const status = taskDetails.value[taskId]?.task?.status
|
||
return status === 'SUCCESS'
|
||
}
|
||
|
||
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,
|
||
countries,
|
||
previewRows,
|
||
_pushed: item._pushed,
|
||
_completed: item._completed,
|
||
} 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: false,
|
||
} 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 (pollTimer.value) {
|
||
if (!immediate) return
|
||
clearTimeout(pollTimer.value)
|
||
pollTimer.value = null
|
||
}
|
||
|
||
const executePoll = async () => {
|
||
pollTimer.value = null
|
||
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 (getPollingTaskIds().length > 0) {
|
||
scheduleNextPoll()
|
||
}
|
||
}
|
||
|
||
if (immediate) {
|
||
executePoll()
|
||
} else {
|
||
pollTimer.value = window.setTimeout(executePoll, getPollIntervalMs())
|
||
}
|
||
}
|
||
|
||
function ensurePolling(immediate = false) {
|
||
if (pollTimer.value && !immediate) return
|
||
scheduleNextPoll(immediate)
|
||
}
|
||
|
||
function getTaskStatusInfo(item: DeleteBrandResultItem) {
|
||
// 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) {
|
||
window.clearTimeout(pollTimer.value)
|
||
pollTimer.value = null
|
||
}
|
||
clearAutoRetryTimer()
|
||
}
|
||
|
||
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
|
||
const api = getPywebviewApi()
|
||
if (!api?.upload_file_to_java) {
|
||
throw new Error('当前桌面端未提供文件上传桥接能力')
|
||
}
|
||
const uploaded: UploadedJavaFile[] = []
|
||
for (const item of paths) {
|
||
const filePath = typeof item === 'string' ? item : item.absolutePath
|
||
const relativePath = typeof item === 'string' ? undefined : item.relativePath
|
||
const result = await api.upload_file_to_java(filePath, relativePath)
|
||
if (!result?.success || !result.data) {
|
||
throw new Error(result?.error || result?.message || `上传失败:${filePath}`)
|
||
}
|
||
uploaded.push(result.data)
|
||
}
|
||
return uploaded
|
||
}
|
||
|
||
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
|
||
selectedPaths.value = paths
|
||
uploadedFiles.value = await uploadPathsToJava(paths)
|
||
ElMessage.success(`已选择 ${paths.length} 个删除品牌文件`)
|
||
} catch (error) {
|
||
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
|
||
}
|
||
selectedPaths.value = result.items.map((item) => item.relativePath || item.absolutePath)
|
||
uploadedFiles.value = await uploadPathsToJava(result.items)
|
||
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
|
||
} catch (error) {
|
||
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 || [])
|
||
const hasMatchedItems = normalizedItems.some((item) => item.matchStatus === 'MATCHED')
|
||
const hasPendingItems = normalizedItems.some((item) => item.matchStatus && item.matchStatus !== 'MATCHED')
|
||
|
||
queuePushResult.value = hasMatchedItems
|
||
? '解析完成,可在左侧点击“推送到 Python 队列”开始串行处理'
|
||
: '解析完成,当前没有可推送的已匹配文件'
|
||
queuePayloadText.value = ''
|
||
|
||
if (normalizedItems.length && normalizedItems[0].taskId) {
|
||
saveSessionTask(normalizedItems[0].taskId, normalizedItems, queuePushResult.value, queuePayloadText.value)
|
||
}
|
||
syncResultState()
|
||
|
||
if (hasPendingItems) {
|
||
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
|
||
} else if (hasMatchedItems) {
|
||
ElMessage.success('删除品牌解析完成,请在左侧推送到 Python 队列')
|
||
} 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 api = getPywebviewApi()
|
||
const url = getDeleteBrandTaskDownloadUrl(taskId)
|
||
const filename =
|
||
item.outputFilename ||
|
||
item.sourceFilename ||
|
||
`delete-brand_${taskId}.xlsx`
|
||
|
||
if (api?.save_file_from_url_new) {
|
||
const result = await api.save_file_from_url_new(url, filename)
|
||
if (result.success) {
|
||
ElMessage.success(`已保存:${result.path || filename}`)
|
||
} else if (result.error && result.error !== '用户取消') {
|
||
ElMessage.error(result.error)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
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) {
|
||
window.clearTimeout(autoRetryTimer.value)
|
||
autoRetryTimer.value = null
|
||
}
|
||
}
|
||
|
||
function scheduleAutoAdvanceRetry(delayMs = 3000) {
|
||
clearAutoRetryTimer()
|
||
autoRetryTimer.value = window.setTimeout(() => {
|
||
autoRetryTimer.value = null
|
||
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 canPushToQueue(item: DeleteBrandResultItem) {
|
||
return Boolean(item.taskId && (item.matchStatus === 'MATCHED' || item.matched))
|
||
}
|
||
|
||
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('当前环境未启用 pywebview enqueue_json(浏览器环境不会推送)')
|
||
return { success: false, retryable: false }
|
||
}
|
||
|
||
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 ?? '-'}`
|
||
|
||
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 = `推送到 Python 队列失败:${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('已开始按顺序推送到 Python 队列')
|
||
}
|
||
|
||
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(() => {
|
||
stopPolling()
|
||
})
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.queue-debug-card {
|
||
margin-top: 18px;
|
||
padding: 14px;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 10px;
|
||
background: #202020;
|
||
}
|
||
|
||
.queue-debug-title {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.queue-debug-line {
|
||
color: #b8c1cc;
|
||
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: #141414;
|
||
color: #8fd3ff;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.module-page {
|
||
min-height: 100vh;
|
||
background: #1a1a1a;
|
||
}
|
||
|
||
.main-content {
|
||
display: flex;
|
||
min-height: calc(100vh - 56px);
|
||
height: calc(100vh - 56px);
|
||
}
|
||
|
||
.left-panel {
|
||
width: 380px;
|
||
background: #1e1e1e;
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
border-right: 1px solid #2a2a2a;
|
||
}
|
||
|
||
.right-panel {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
background: #1a1a1a;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 13px;
|
||
color: #bbb;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.upload-zone {
|
||
border: 1px dashed #3a3a3a;
|
||
border-radius: 10px;
|
||
padding: 24px;
|
||
text-align: center;
|
||
background: #252525;
|
||
margin-bottom: 20px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.upload-zone:hover {
|
||
border-color: #3498db;
|
||
background: #2a2a2a;
|
||
}
|
||
|
||
.hint {
|
||
color: #888;
|
||
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: #ccc;
|
||
background: #2a2a2a;
|
||
border: 1px solid #3a3a3a;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.opt-btn:hover {
|
||
color: #3498db;
|
||
background: #333;
|
||
border-color: #3498db;
|
||
}
|
||
|
||
.selected-files {
|
||
margin-top: 14px;
|
||
font-size: 12px;
|
||
color: #888;
|
||
max-height: 112px;
|
||
overflow-y: auto;
|
||
text-align: left;
|
||
}
|
||
|
||
.selected-files span {
|
||
display: block;
|
||
margin: 4px 0;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.more-line {
|
||
color: #b8c1cc;
|
||
}
|
||
|
||
.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: #252525;
|
||
border: 1px solid #2a2a2a;
|
||
margin-bottom: 8px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.radio-item:hover,
|
||
.radio-item.active {
|
||
background: #2a2a2a;
|
||
border-color: #3a3a3a;
|
||
}
|
||
|
||
.radio-item input {
|
||
margin-top: 3px;
|
||
}
|
||
|
||
.label {
|
||
font-weight: 500;
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.desc {
|
||
font-size: 12px;
|
||
color: #888;
|
||
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: #fff;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.btn-run:hover {
|
||
background: #2980b9;
|
||
}
|
||
|
||
.btn-run:disabled {
|
||
background: #555;
|
||
color: #999;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.loading-msg {
|
||
color: #3498db;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.panel-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #ddd;
|
||
}
|
||
|
||
.task-list-wrap {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 16px;
|
||
}
|
||
|
||
.task-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.task-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 14px 16px;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 8px;
|
||
margin-bottom: 10px;
|
||
background: #1e1e1e;
|
||
}
|
||
|
||
.split-result-item {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.left {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.split-result-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.id {
|
||
display: inline-block;
|
||
font-weight: 600;
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.files {
|
||
font-size: 12px;
|
||
color: #888;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.time {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.split-entry-list {
|
||
line-height: 1.6;
|
||
word-break: break-all;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.delete-brand-preview {
|
||
white-space: normal;
|
||
}
|
||
|
||
.delete-brand-progress-block {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.delete-brand-progress-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 6px;
|
||
font-size: 12px;
|
||
color: #8fbfff;
|
||
}
|
||
|
||
.delete-brand-progress-bar {
|
||
width: 100%;
|
||
height: 8px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.delete-brand-progress-bar-fill {
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, #2d8cf0 0%, #4db3ff 100%);
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.delete-brand-progress {
|
||
color: #69b6ff;
|
||
line-height: 1.6;
|
||
white-space: normal;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.task-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.split-result-actions {
|
||
flex-shrink: 0;
|
||
min-width: 180px;
|
||
justify-content: flex-end;
|
||
align-self: center;
|
||
}
|
||
|
||
.status {
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.status.success {
|
||
background: rgba(46, 204, 113, 0.18);
|
||
color: #2ecc71;
|
||
}
|
||
|
||
.status.failed {
|
||
background: rgba(231, 76, 60, 0.18);
|
||
color: #ff6b6b;
|
||
}
|
||
|
||
.status.running {
|
||
background: rgba(52, 152, 219, 0.18);
|
||
color: #3498db;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.empty-tasks {
|
||
color: #666;
|
||
font-size: 13px;
|
||
padding: 24px 8px;
|
||
}
|
||
|
||
.clean-placeholder {
|
||
max-height: 112px;
|
||
}
|
||
|
||
.clean-result-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 220px));
|
||
gap: 16px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.summary-card {
|
||
padding: 16px 18px;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 10px;
|
||
background: #1e1e1e;
|
||
}
|
||
|
||
.summary-card strong {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 24px;
|
||
color: #eaf4ff;
|
||
}
|
||
|
||
.summary-label {
|
||
font-size: 12px;
|
||
color: #8d8d8d;
|
||
}
|
||
|
||
.result-list-wrap {
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 10px;
|
||
background: #1e1e1e;
|
||
min-height: 260px;
|
||
}
|
||
|
||
.result-list-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
font-size: 14px;
|
||
color: #ddd;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.main-content {
|
||
flex-direction: column;
|
||
height: auto;
|
||
}
|
||
|
||
.left-panel {
|
||
width: 100%;
|
||
border-right: none;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
}
|
||
|
||
.right-panel {
|
||
min-height: 420px;
|
||
}
|
||
|
||
.task-item {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.task-right {
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.split-result-actions {
|
||
min-width: 0;
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.clean-result-summary {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|
||
|