Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue
2026-04-01 21:20:01 +08:00

1389 lines
39 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">
<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>
<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">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="getQueueStatus(item)" class="files">队列状态{{ getQueueStatus(item) }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount
}}</div>
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }}
</div>
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
<div class="delete-brand-progress-header">
<span>任务进度</span>
<span>{{ formatProgressPercent(item.taskId!) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill"
:style="{ width: `${formatProgressPercent(item.taskId!)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item.taskId!) }}</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="item.openStoreUrl" type="button" class="download" @click="triggerItemRun(item)">
打开紫鸟
</button>
<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">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</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.taskId!) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill"
:style="{ width: `${formatProgressPercent(item.taskId!)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item.taskId!) }}</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,
getDeleteBrandTaskDownloadUrl,
runDeleteBrand,
type DeleteBrandPreviewRow,
type DeleteBrandResultItem,
type DeleteBrandRunVo,
type DeleteBrandTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
interface StoredCurrentTask {
taskId: number
items: DeleteBrandResultItem[]
createdAt: number
queuePushResult?: string
queuePayloadText?: string
}
function getStorageKey() {
const uid = typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
return `delete-brand:current-tasks:${uid}`
}
const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadedJavaFile[]>([])
const running = ref(false)
const resultItems = ref<DeleteBrandResultItem[]>([])
const 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 chainStarted = ref(false)
const activeItemKey = ref('')
const autoAdvancing = 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) {
// 只将解析成功且匹配成功的任务放入当前队列中,失败的将留在历史记录里
const validItems = items.filter(i => i.matched !== false && i.success !== false)
if (validItems.length === 0) return
const newTask: StoredCurrentTask = {
taskId,
items: validItems,
createdAt: Date.now(),
queuePushResult: pushResult,
queuePayloadText: payloadText
}
const existingIndex = sessionTasks.value.findIndex(t => t.taskId === taskId)
if (existingIndex >= 0) {
sessionTasks.value[existingIndex] = newTask
} else {
sessionTasks.value.push(newTask)
}
if (typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
}
function removeSessionTaskFromStorage(taskId: number) {
const oldLen = sessionTasks.value.length
sessionTasks.value = sessionTasks.value.filter(t => t.taskId !== taskId)
if (sessionTasks.value.length !== oldLen && typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
}
const historySectionItems = computed(() =>
historyItems.value.filter(
(item) => !currentSectionItems.value.some(c => c.taskId === item.taskId || (c.resultId && c.resultId === item.resultId))
),
);
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
function getTaskStatus(taskId?: number) {
if (!taskId) return ''
return taskDetails.value[taskId]?.task?.status || ''
}
function getDisplayError(item: DeleteBrandResultItem) {
if (item.error) return item.error
const taskId = item.taskId
if (!taskId) return ''
return taskDetails.value[taskId]?.task?.errorMessage || ''
}
function shouldShowProgress(item: DeleteBrandResultItem) {
const qs = getQueueStatus(item)
return qs === '已入队' || qs === '处理中'
}
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
return items.map((item) => {
// 只有在既没成功,又显式标记为未匹配时,才做错误兜底
if (!item.success && item.matched === false) {
return {
...item,
error: item.error || '未匹配到紫鸟店铺',
openStoreUrl: undefined,
}
}
return item
})
}
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 getItemTaskId(item: DeleteBrandResultItem) {
return item.taskId || 0
}
function formatProgress(taskId: number) {
if (!taskId) return ''
const detail = taskDetails.value[taskId]
const info = detail?.line_progress?.info
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(`进度:${info.current_line}/${info.total_lines}`)
}
if (info.current_country) parts.push(`国家:${info.current_country}`)
if (info.current_asin) parts.push(`ASIN${info.current_asin}`)
if (info.finished_files != null) parts.push(`已完成文件:${info.finished_files}`)
return parts.join('')
}
function formatProgressPercent(taskId: number) {
if (!taskId) return 0
const info = taskDetails.value[taskId]?.line_progress?.info
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 isTaskRunning(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return status === 'RUNNING'
}
function isTaskTerminal(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS' || status === 'FAILED'
}
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 = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return ''
const status = getTaskStatus(taskId)
if (status === 'SUCCESS' || status === 'FAILED' || item.taskStatus === 'SUCCESS' || item.taskStatus === 'FAILED') {
return '已被全局判定为终态'
}
const sessionItem = sessionTask.items.find(i => i.resultId === item.resultId || i.sourceFilename === item.sourceFilename)
const info = taskDetails.value[taskId]?.line_progress
// 此时这个特定文件是否正好被后端报了进度名
if (status === 'RUNNING' && info?.has_progress && info?.info?.file_name === item.sourceFilename) {
return '处理中'
}
// 查查是否已经完结了这个特定文件
const detail = taskDetails.value[taskId]
if (detail?.items) {
// 后端返回的 items 是 `resultJson` 解析来的,里面只含有已经做完的!
const finished = detail.items.find((i: DeleteBrandResultItem) => i.sourceFilename === item.sourceFilename)
if (finished) {
return '本文件解析完毕'
}
}
if (sessionItem && (sessionItem as any)._pushed) {
return '已入队'
}
return '未入队'
}
function canDownloadTaskResult(item: DeleteBrandResultItem) {
if (item.downloadUrl) return true
const taskId = item.taskId
if (!taskId) return false
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS'
}
async function refreshTaskDetails(taskIds?: number[]) {
const ids = taskIds || getPollingTaskIds()
if (!ids.length) return
try {
const batch = await getDeleteBrandTaskDetails(ids)
for (const detail of batch.items || []) {
const id = detail?.task?.id
if (typeof id === 'number' && id > 0) {
taskDetails.value[id] = detail
}
}
} catch {
// ignore polling failures
}
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 4500 : 10000
}
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
try {
await refreshTaskDetails(taskIds)
await maybeAutoAdvance()
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
removeSessionTaskFromStorage(taskId)
stateChanged = true
}
}
} finally {
pollingInFlight.value = false
}
if (stateChanged) {
loadHistory()
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
}
}
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 hasUnmatched = normalizedItems.some((item) => !item.matched)
queuePushResult.value = '解析完成,等待手动点击打开紫鸟进行单任务处理'
queuePayloadText.value = ''
if (normalizedItems.length && normalizedItems[0].taskId) {
saveSessionTask(normalizedItems[0].taskId, normalizedItems, queuePushResult.value, queuePayloadText.value)
}
syncResultState()
if (hasUnmatched) {
ElMessage.error('存在未匹配到紫鸟店铺的文件,请检查报错内容。这些文件已自动归档至历史记录。')
} else {
ElMessage.success('删除品牌解析完成,请手动推送')
}
// 更新历史记录,以展示那些未进入队列的失败项
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) {
const result = await api.save_file_from_url(url, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
window.open(url, '_blank')
}
async function loadHistory() {
try {
const response = await getDeleteBrandHistory()
historyItems.value = normalizeDeleteBrandItems(response.items || [])
syncResultState()
// 关键:页面加载历史后,立刻拉取详情触发状态机
await refreshTaskDetails()
ensurePolling(true)
} catch (err) {
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 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 findNextAutoRunnableItem() {
return currentSectionItems.value.find((item) => {
if (!item.openStoreUrl) return false
if (getItemKey(item) === activeItemKey.value) return false
return getQueueStatus(item) === '未入队'
})
}
async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }) {
if (isPythonQueueBusy()) {
if (!options?.auto) {
ElMessage.warning('当前存在正在处理的任务,请等待其完成后再推送下一条!')
}
return false
}
const api = getPywebviewApi()
const taskId = item.taskId
if (!taskId) return false
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return false
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用 pywebview enqueue_json浏览器环境不会推送')
return 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 = sessionTask.items.find(i => i.resultId === item.resultId || i.sourceFilename === item.sourceFilename)
if (sessionItem) {
(sessionItem as any)._pushed = true
}
activeItemKey.value = getItemKey(item)
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
ensurePolling(true)
} else {
qpr = `推送到 Python 队列失败:${pushResult?.error || '未知错误'}`
}
queuePushResult.value = qpr
return Boolean(pushResult?.success)
}
async function maybeAutoAdvance() {
if (!chainStarted.value || autoAdvancing.value) return
if (isPythonQueueBusy()) return
const currentKey = activeItemKey.value
if (currentKey) {
const currentItem = currentSectionItems.value.find(item => getItemKey(item) === currentKey)
if (currentItem) {
const status = getQueueStatus(currentItem)
if (status === '已入队' || status === '处理中') {
return
}
}
}
const nextItem = findNextAutoRunnableItem()
if (!nextItem) {
activeItemKey.value = ''
chainStarted.value = false
return
}
autoAdvancing.value = true
try {
const started = await runItem(nextItem, { auto: true })
if (!started) {
chainStarted.value = false
}
} finally {
autoAdvancing.value = false
}
}
async function triggerItemRun(item: DeleteBrandResultItem) {
chainStarted.value = true
const started = await runItem(item)
if (!started) {
chainStarted.value = false
activeItemKey.value = ''
}
}
function openShop(url: string) {
window.open(url, '_blank')
}
onMounted(() => {
loadSessionTasksFromStorage()
const taskIds = getPollingTaskIds()
if (taskIds.length > 0) {
refreshTaskDetails(taskIds).then(() => {
let stateChanged = false
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
removeSessionTaskFromStorage(taskId)
stateChanged = true
}
}
if (stateChanged) {
syncResultState()
}
ensurePolling(true)
})
} else {
syncResultState()
}
loadHistory().catch(() => undefined)
})
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>