feat(web): 前端 SPA 化 + 工具页任务面板统一与历史批量删除

- SPA 化:22 个 MPA html 入口与 *-main.ts 合并为 index.html + vue-router(URL 无 .html 后缀),
  页面跳转全部 router-link,/new_web_source/xxx.html 旧路径归一为 /xxx
- 任务面板统一:共享 TaskCenterPanel/TaskItemCard/TaskStatCards/HistoryTaskLayer,
  16 个工具页右侧统一为统计卡 + 当前任务 + 历史任务弹层(任务ID/开始/结束/状态必展示)
- 历史记录支持单条删除 + 批量勾选删除(确认框/全选/失败提示)
- Java 7 模块(dedupe/convert/split/productrisk/shopmatch/pricetrack/deletebrand)
  history 接口补齐任务时间字段(VO+Service,复用 biz_file_task 列)
- 图片工作台/API 层(brand/permission/user)既有未提交改动一并提交
This commit is contained in:
2026-09-08 10:02:55 +08:00
parent abcfa5bef7
commit e248b6e43a
125 changed files with 7482 additions and 4304 deletions
@@ -1,6 +1,6 @@
<template>
<div class="secret-settings-entry">
<button type="button" class="secret-settings-trigger" @click="dialogVisible = true">
<button type="button" class="secret-settings-trigger" :class="{ 'variant-topbar': variant === 'topbar' }" @click="dialogVisible = true">
<span class="secret-settings-icon" aria-hidden="true"></span>
<span>密钥设置</span>
</button>
@@ -173,6 +173,16 @@ type SecretState = {
}
const dialogVisible = ref(false)
const props = withDefaults(
defineProps<{
/** topbar=顶栏纯文字样式(对齐主程序);默认=现有胶囊按钮 */
variant?: 'topbar'
}>(),
{
variant: undefined,
},
)
const proxyUrl = ref('')
const proxyMode = ref<ProxyMode>(1)
const proxyLoading = ref(false)
@@ -378,6 +388,29 @@ loadStates()
border-color: #4c647d;
}
/* topbar 变体:纯文字「🔑 密钥设置」,hover 整块变 CARD2(对齐主程序顶栏) */
.secret-settings-trigger.variant-topbar {
height: auto;
padding: 4px 10px;
border: 0;
border-radius: 0;
background: transparent;
color: #c8d2e2;
font-size: 11px;
font-weight: 400;
transition: background 0.12s ease, color 0.12s ease;
}
.secret-settings-trigger.variant-topbar:hover {
background: #343f55;
color: #f5f8fc;
}
.secret-settings-trigger.variant-topbar .secret-settings-icon {
color: #c8d2e2;
font-size: 12px;
}
.secret-settings-icon {
color: #8dc4ff;
font-size: 14px;
@@ -27,7 +27,7 @@
{{ parsing ? '解析中...' : '解析并创建任务' }}
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
{{ pushing ? '启动中...' : '启动任务' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后累计 50 条调用 LLM当前任务区展示 LLM 回流和结果组装进度</p>
@@ -41,96 +41,32 @@
</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>{{ dashboard.pendingTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已结束任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配任务</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>当前任务</span>
</div>
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressMessage(item, '处理中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</span>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>历史记录</span>
</div>
<div v-if="!historyOnlyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyOnlyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressMessage(item, '结果生成中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="外观专利检测"
:cards="patentCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务"
history-empty-text="暂无历史记录"
>
<template #item-extra="{ item }">
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
</template>
<template #history-item-extra="{ item }">
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
@click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -142,6 +78,8 @@ 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 {
activateAppearancePatentTask,
deleteAppearancePatentHistory,
@@ -244,6 +182,51 @@ const historyOnlyItems = computed(() =>
}),
)
const patentCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() =>
currentItems.value.map(toPatentTaskView),
)
const historyTaskViews = computed<TaskItemView[]>(() =>
historyOnlyItems.value.map(toPatentTaskView),
)
function itemSource(item: TaskItemView) {
return item.source as AppearancePatentHistoryItem
}
function toPatentTaskView(item: AppearancePatentHistoryItem): TaskItemView {
const showProgress = showFileProgress(item)
const progressPercent = fileProgressPercent(item)
return {
key: `patent-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '外观专利检测',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
...(item.resultFilename && !showProgress ? [item.resultFilename] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
progress: showProgress
? {
percent: progressPercent,
stage: displayFileProgressMessage(item, '处理中'),
}
: null,
source: item,
}
}
function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
if (item.taskId == null) return item
const liveItem = liveProgressItems.value[item.taskId]
@@ -531,7 +514,7 @@ async function pushToPythonQueue() {
clearParsedTask()
await loadDashboard()
await loadHistory({ force: true })
ElMessage.success('已推送到 Python 队列')
ElMessage.success('已启动任务')
} finally {
pushing.value = false
}
@@ -979,6 +962,26 @@ onUnmounted(() => {
stopPolling()
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -1006,36 +1009,12 @@ onUnmounted(() => {
.prompt-input:focus, .secret-input:focus { border-color: #3498db; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2e3a52; border-radius: 8px; background: #222b3d; color: #a0acbe; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; font-size: 15px; font-weight: 600; color: #c8d2e2; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
.summary-label { color: #5e6878; font-size: 12px; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; min-height: 180px; margin: 0 0 16px; }
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 18px; text-align: center; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.task-list { list-style: none; margin: 0; padding: 12px; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
.status.pending { background: rgba(149, 165, 166, .18); color: #a0acbe; }
.result-hint { margin-top: 6px; color: #e0b96d; }
.file-progress { margin-top: 8px; max-width: 520px; }
.file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; }
.file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #333f55; border: 1px solid #3b3b3b; }
.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; }
.download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); }
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
@@ -76,44 +76,36 @@
</aside>
<section class="right-panel">
<div class="panel-header">
<span>品牌检测任务</span>
<div class="panel-actions-row">
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
</div>
<div class="task-list-wrap">
<div v-if="!tasks.length" class="empty-tasks">
暂无品牌检测任务选择待检文件后点击运行添加到任务队列
</div>
<ul v-else class="task-list">
<li v-for="item in tasks" :key="`brand-${item.id}`" class="task-item">
<div class="left">
<span class="id" :title="taskDesc(item)">{{ taskDesc(item) }}</span>
<div class="files">任务 ID{{ item.id }}</div>
<div v-if="item.file_paths?.length" class="files">文件{{ item.file_paths.length }} </div>
<div class="files">创建时间{{ formatDateTime(item.created_at) }}</div>
<template v-if="isRunning(item)">
<div class="task-progress">
<div class="task-progress-meta">
<span>任务进度</span>
<span>{{ taskProgress(item) }}%</span>
</div>
<div class="task-progress-track">
<div class="task-progress-fill" :style="{ width: `${taskProgress(item)}%` }"></div>
</div>
</div>
<div v-if="errorMessage(item)" class="files error">{{ errorMessage(item) }}</div>
</template>
<div v-else-if="errorMessage(item)" class="files error">错误{{ errorMessage(item) }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
<button v-if="isRunning(item)" type="button" class="act-btn danger" @click="cancelTask(item)">取消</button>
<button v-if="canDownload(item)" type="button" class="act-btn ok" @click="downloadResult(item)">下载结果</button>
<button v-if="canDelete(item)" type="button" class="act-btn" @click="deleteTask(item)">删除</button>
</div>
</li>
</ul>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="品牌检测任务"
:cards="brandCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务提交检测后运行中任务会显示在这里"
history-empty-text="暂无历史记录"
>
<template #item-actions="{ item }">
<template v-if="itemSource(item)">
<button v-if="isRunning(itemSource(item))" type="button" class="btn-delete"
@click="cancelTask(itemSource(item))">取消</button>
<button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
@click="deleteTask(itemSource(item))">删除</button>
</template>
</template>
<template #history-item-actions="{ item }">
<template v-if="itemSource(item)">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
@click="deleteTask(itemSource(item))">删除</button>
</template>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -124,18 +116,21 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { requestPostJson } from '@/shared/api/http'
import {
cancelBrandTask,
createBrandTask,
deleteBrandTask,
expandBrandFolder,
getBrandTaskDownloadUrl,
getBrandTasks,
runBrandNow,
type BrandTaskItem,
} from '@/shared/api/brand'
/** 扩展文件夹接口响应(向后端真实字段 paths 兼容,shared 类型仅声明 items 时以本接口为准) */
interface BrandExpandFolderPathsResponse {
success: boolean
paths?: string[]
@@ -172,6 +167,11 @@ function isRunning(item: BrandTaskItem) {
return (item.status || '').toLowerCase() === 'running'
}
function isBusyBrandTask(item: BrandTaskItem) {
const status = (item.status || '').toLowerCase()
return status === 'pending' || status === 'running'
}
function isTerminal(item: BrandTaskItem) {
const status = (item.status || '').toLowerCase()
return status === 'success' || status === 'failed' || status === 'cancelled'
@@ -193,11 +193,6 @@ function taskProgress(item: BrandTaskItem) {
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
}
function taskDesc(item: BrandTaskItem) {
const desc = (item.desc || '').trim()
return desc ? (desc.length > 60 ? `${desc.slice(0, 60)}` : desc) : `任务 #${item.id}`
}
function errorMessage(item: BrandTaskItem) {
return (item.error_message || '').trim()
}
@@ -205,7 +200,7 @@ function errorMessage(item: BrandTaskItem) {
function statusText(status?: BrandTaskItem['status']) {
const value = (status || '').toLowerCase()
const map: Record<string, string> = {
pending: '等待中',
pending: '排队中',
running: '执行中',
success: '已完成',
failed: '失败',
@@ -219,10 +214,57 @@ function statusClass(status?: BrandTaskItem['status']) {
if (value === 'running') return 'running'
if (value === 'success') return 'success'
if (value === 'failed') return 'failed'
if (value === 'cancelled') return 'cancelled'
return 'pending'
}
// ---- 右侧任务面板统一视图(TaskCenterPanel----
/** 当前任务:排队中 / 执行中的任务 */
const currentTaskViews = computed<TaskItemView[]>(() =>
tasks.value.filter(isBusyBrandTask).map(toBrandTaskView),
)
/** 历史任务:终态(成功 / 失败 / 已取消 / 其他状态)的任务 */
const historyTaskViews = computed<TaskItemView[]>(() =>
tasks.value.filter((item) => !isBusyBrandTask(item)).map(toBrandTaskView),
)
const brandCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function itemSource(item: TaskItemView): BrandTaskItem {
return item.source as BrandTaskItem
}
function toBrandTaskView(item: BrandTaskItem): TaskItemView {
const total = Number(item.progress_total) || 0
const current = Number(item.progress_current) || 0
return {
key: `brand-${item.id}`,
title: (item.desc || '').trim() || '品牌检测',
taskId: item.id,
startedAt: formatDateTime(item.created_at),
finishedAt: isTerminal(item) ? formatDateTime(item.updated_at) : '',
statusText: statusText(item.status),
statusClass: statusClass(item.status),
extraLines: [
...(item.file_paths?.length ? [`文件:${item.file_paths.length}`] : []),
...(errorMessage(item) ? [`错误:${errorMessage(item)}`] : []),
],
progress: isRunning(item)
? {
percent: taskProgress(item),
stage: '任务进度',
countLabel: total > 0 ? `${current}/${total}` : undefined,
}
: null,
source: item,
}
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
@@ -267,7 +309,7 @@ async function selectFolder() {
try {
const folder = await bridge.select_brand_folder()
if (!folder) return
const res = await requestPostJson<BrandExpandFolderPathsResponse>('/api/brand/expand-folder', { folder })
const res = (await expandBrandFolder(folder)) as BrandExpandFolderPathsResponse
if (!res.success || !res.paths?.length) {
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
return
@@ -433,6 +475,26 @@ onBeforeUnmount(() => {
pollTimer = null
}
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTask(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -466,29 +528,12 @@ onBeforeUnmount(() => {
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-msg { margin-top: 10px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 600; }
.panel-actions-row { display: flex; justify-content: flex-end; padding: 10px 20px 0; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 28px; text-align: center; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 8px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.files.error, .error { color: #ff6b6b; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.cancelled { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.act-btn { padding: 5px 10px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
.act-btn.danger { background: rgba(231, 76, 60, 0.15); color: #ff8f8f; border-color: transparent; }
.task-progress { margin-top: 6px; max-width: 460px; }
.task-progress-meta { display: flex; justify-content: space-between; color: #5e6878; font-size: 11px; margin-bottom: 4px; }
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
.task-progress-fill { height: 100%; background: #3498db; transition: width 0.25s ease; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.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; border: none; cursor: pointer; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
@@ -102,10 +102,10 @@
:disabled="pushing || !lastTaskId"
@click="pushToPythonQueue"
>
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
{{ pushing ? '启动中...' : '启动任务' }}
</button>
</div>
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务PENDING推送到 Python 队列将激活任务RUNNINGPython 端按 50 /页拉取明细执行采集</p>
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务PENDING启动任务将激活任务RUNNINGPython 端按 50 /页拉取明细执行采集</p>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div>
@@ -117,93 +117,26 @@
</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>{{ dashboard.pendingTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已结束任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配任务</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>当前任务</span>
</div>
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div class="files">总数据去重过滤{{ item.dedupeFilteredCount ?? 0 }}</div>
<div class="files">不符合 ASIN 过滤{{ item.invalidFilteredCount ?? 0 }}</div>
<div v-if="taskKeywordProgress(item)" class="files">{{ taskKeywordProgress(item) }}</div>
<div v-if="taskStageProgress(item)" class="files">{{ taskStageProgress(item) }}</div>
<div class="task-progress">
<div class="task-progress-meta"><span>关键词进度</span><span>{{ taskProgressPercent(item) }}%</span></div>
<div class="task-progress-track"><div class="task-progress-fill" :style="{ width: `${taskProgressPercent(item)}%` }"></div></div>
</div>
</div>
<div class="task-right">
<span class="status running">
{{ statusText(item) }}
<span v-if="taskFilterSummary(item)" class="status-filter-summary">{{ taskFilterSummary(item) }}</span>
</span>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>历史记录</span>
</div>
<div v-if="!historyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">总数据去重过滤{{ item.dedupeFilteredCount ?? 0 }}</div>
<div class="files">不符合 ASIN 过滤{{ item.invalidFilteredCount ?? 0 }}</div>
<div v-if="item.resultFilename" class="files">{{ item.resultFilename }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
<div class="task-progress">
<div class="task-progress-meta"><span>关键词进度</span><span>{{ taskProgressPercent(item) }}%</span></div>
<div class="task-progress-track"><div class="task-progress-fill" :style="{ width: `${taskProgressPercent(item)}%` }"></div></div>
</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">
{{ statusText(item) }}
<span v-if="taskFilterSummary(item)" class="status-filter-summary">{{ taskFilterSummary(item) }}</span>
</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="采集数据"
:cards="collectCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务"
history-empty-text="暂无历史记录"
>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
@click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -215,6 +148,8 @@ import { computed, onBeforeUnmount, onMounted, reactive, 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 { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
@@ -518,7 +453,7 @@ async function pushToPythonQueue() {
}
const api = getPywebviewApi()
if (!api?.enqueue_json) {
ElMessage.warning('当前环境未提供 Python 队列能力')
ElMessage.warning('当前环境未提供任务队列能力')
return
}
pushing.value = true
@@ -549,12 +484,12 @@ async function pushToPythonQueue() {
if (!result?.success) {
throw new Error(result?.error || '入队失败')
}
queuePushResult.value = `推送至 Python 队列(队列长度:${result.queue_size ?? '-'}`
queuePushResult.value = `启动任务(队列长度:${result.queue_size ?? '-'}`
queuePayloadText.value = JSON.stringify(payload, null, 2)
if (lastTaskId.value) {
progressLoop.add(lastTaskId.value)
}
ElMessage.success('已推送到 Python 队列')
ElMessage.success('已启动任务')
await loadDashboard()
} catch (error) {
if (activated && lastTaskId.value) {
@@ -746,6 +681,63 @@ async function deleteTaskRecord(item: CollectDataHistoryItem) {
}
}
const collectCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toCollectTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historyItems.value.map(toCollectTaskView))
function itemSource(item: TaskItemView): CollectDataHistoryItem {
return item.source as CollectDataHistoryItem
}
function isCollectTaskActive(item: CollectDataHistoryItem) {
const status = normalizeTaskStatus(item)
return status !== 'SUCCESS' && status !== 'FAILED' && !item.success
}
function toCollectTaskView(item: CollectDataHistoryItem): TaskItemView {
const isActive = isCollectTaskActive(item)
const keywordProgress = taskKeywordProgress(item)
const stageProgress = taskStageProgress(item)
const percent = taskProgressPercent(item)
const finishedAt = item.finishedAt ? formatDateTime(item.finishedAt) : isActive ? '进行中' : '-'
const extraLines: string[] = isActive
? [
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
`总数据去重过滤:${item.dedupeFilteredCount ?? 0}`,
`不符合 ASIN 过滤:${item.invalidFilteredCount ?? 0}`,
...(keywordProgress ? [keywordProgress] : []),
...(stageProgress ? [stageProgress] : []),
]
: [
`总数据去重过滤:${item.dedupeFilteredCount ?? 0}`,
`不符合 ASIN 过滤:${item.invalidFilteredCount ?? 0}`,
...(item.resultFilename ? [item.resultFilename] : []),
...(taskFilterSummary(item) ? [`筛选统计:${taskFilterSummary(item)}`] : []),
...(item.error ? [`错误:${item.error}`] : []),
]
return {
key: `collect-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '采集数据',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt,
statusText: statusText(item),
statusClass: statusClass(item),
extraLines,
progress: isActive || percent > 0
? { percent, stage: '关键词进度' }
: null,
source: item,
}
}
onMounted(() => {
void loadCountryPreference()
void loadDashboard()
@@ -759,6 +751,26 @@ onBeforeUnmount(() => {
}
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -766,7 +778,7 @@ onBeforeUnmount(() => {
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.section-title, .subsection-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
.section-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #2e3a52; margin-bottom: 18px; }
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
@@ -831,35 +843,10 @@ onBeforeUnmount(() => {
.queue-debug-line { color: #9ad; font-size: 12px; }
.queue-debug-payload { margin-top: 8px; padding: 8px 10px; background: #151a25; border-radius: 6px; color: #a0acbe; font-size: 11px; overflow: auto; max-height: 200px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; font-size: 15px; font-weight: 600; color: #c8d2e2; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
.summary-label { color: #5e6878; font-size: 12px; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; min-height: 180px; margin: 0 0 16px; }
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 18px; text-align: center; }
.task-list { list-style: none; margin: 0; padding: 12px; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
.status.pending { background: rgba(149, 165, 166, .18); color: #a0acbe; }
.status-filter-summary { font-weight: 400; }
.download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); }
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
.task-progress { margin-top: 8px; max-width: 520px; }
.task-progress-meta { display: flex; justify-content: space-between; color: #5e6878; font-size: 11px; margin-bottom: 4px; }
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
.task-progress-fill { height: 100%; border-radius: inherit; background: #3498db; transition: width .25s ease; }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
@@ -111,59 +111,37 @@
</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>{{ convertSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ convertSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ convertSummary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>生成的结果压缩包列表</span>
</div>
<div v-if="convertResultItems.length === 0" class="empty-tasks">
暂无转换结果完成格式转换后会在这里展示结果压缩包
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in convertResultItems"
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item">
<div class="left">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">压缩包文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
@click="downloadConvertResult(item)">
下载压缩包
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteConvertHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="转换结果"
:cards="convertCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务完成格式转换后会在这里展示"
history-empty-text="暂无历史记录"
>
<template #history-item-actions="{ item }">
<template v-if="itemSource(item)">
<button
v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
type="button"
class="download"
@click="downloadConvertResult(itemSource(item))"
>
下载压缩包
</button>
<button
v-if="itemSource(item).resultId"
type="button"
class="btn-delete"
@click="deleteConvertHistoryRecord(itemSource(item).resultId!)"
>
删除
</button>
</template>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -174,6 +152,8 @@
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { expandBrandFolderRecursive, getBrandTemplateXlsxUrl, getBrandTemplateZipUrl } from '@/shared/api/brand'
import type { BrandExpandFolderItem } from '@/shared/api/brand'
import {
@@ -186,7 +166,6 @@ import {
runConvert,
setDefaultConvertTemplate,
type ConvertResultItem,
type ConvertRunVo,
type ConvertTemplateVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
@@ -199,7 +178,6 @@ const convertArchiveName = ref('')
const convertUploadedFiles = ref<UploadedJavaFile[]>([])
const convertRunning = ref(false)
const convertResultItems = ref<ConvertResultItem[]>([])
const convertSummary = ref<ConvertRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const convertTemplates = ref<ConvertTemplateVo[]>([])
const convertTemplateId = ref('')
const templateUploading = ref(false)
@@ -215,6 +193,77 @@ const canDeleteCurrentTemplate = computed(
() => Boolean(currentConvertTemplate.value && !currentConvertTemplate.value.builtIn),
)
// ---- 右侧任务面板统一视图(TaskCenterPanel----
const convertCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function isConvertTaskBusy(item: ConvertResultItem) {
const status = (item.taskStatus || '').toUpperCase()
return status === 'RUNNING' || status === 'PENDING'
}
/** 当前任务:运行中/待执行项(转换接口通常同步完成即出结果,正常为空) */
const currentTaskViews = computed<TaskItemView[]>(() =>
convertResultItems.value.filter(isConvertTaskBusy).map(toConvertTaskView),
)
/** 历史任务:全部已结束的结果项 */
const historyTaskViews = computed<TaskItemView[]>(() =>
convertResultItems.value.filter((item) => !isConvertTaskBusy(item)).map(toConvertTaskView),
)
function itemSource(item: TaskItemView): ConvertResultItem {
return item.source as ConvertResultItem
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function convertTaskStatusText(item: ConvertResultItem) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING') return '执行中'
if (status === 'PENDING') return '等待中'
return item.success ? '已完成' : '失败'
}
function convertTaskStatusClass(item: ConvertResultItem) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING') return 'running'
if (status === 'PENDING') return 'pending'
return item.success ? 'success' : 'failed'
}
function toConvertTaskView(item: ConvertResultItem): TaskItemView {
return {
key: `convert-${item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '格式转换',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: convertTaskStatusText(item),
statusClass: convertTaskStatusClass(item),
extraLines: [
...(item.outputFilename ? [`压缩包文件:${item.outputFilename}`] : []),
...(item.error ? [`错误信息:${item.error}`] : []),
],
source: item,
}
}
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
@@ -458,7 +507,6 @@ async function submitConvertRun() {
? convertArchiveName.value
: undefined,
})
convertSummary.value = result
convertResultItems.value = result.items || []
await loadConvertHistory()
if (result.total > 0 && result.successCount === 0) {
@@ -485,12 +533,6 @@ async function loadConvertHistory() {
try {
const response = await getConvertHistory()
convertResultItems.value = response.items || []
convertSummary.value = {
total: response.items?.length || 0,
successCount: response.items?.filter((item) => item.success).length || 0,
failedCount: response.items?.filter((item) => !item.success).length || 0,
items: response.items || [],
}
} catch {
// ignore history load errors
}
@@ -526,6 +568,26 @@ onMounted(() => {
loadConvertTemplates().catch(() => undefined)
loadConvertHistory().catch(() => undefined)
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteConvertHistoryRecord(itemSource(view).resultId!)
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -832,81 +894,6 @@ onMounted(() => {
font-size: 13px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.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 #2e3a52;
border-radius: 8px;
margin-bottom: 10px;
background: #1c2333;
}
.left {
flex: 1;
min-width: 0;
}
.id {
display: inline-block;
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
margin-top: 4px;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.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;
}
.download {
padding: 6px 10px;
border-radius: 6px;
@@ -931,60 +918,16 @@ onMounted(() => {
background: rgba(231, 76, 60, 0.22);
}
.empty-tasks {
.files {
font-size: 12px;
color: #5e6878;
font-size: 13px;
padding: 24px 8px;
margin-top: 4px;
}
.clean-placeholder {
max-height: none;
}
.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 #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
@media (max-width: 1100px) {
.main-content {
flex-direction: column;
@@ -1000,19 +943,5 @@ onMounted(() => {
.right-panel {
min-height: 420px;
}
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
}
</style>
@@ -85,59 +85,29 @@
</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>{{ cleanSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ cleanSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ cleanSummary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>处理后的 Excel 列表</span>
</div>
<div v-if="cleanResultItems.length === 0" class="empty-tasks">
暂无去重结果完成数据去重后会在这里展示输出文件
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in cleanResultItems"
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item">
<div class="left">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">输出文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
@click="downloadCleanResult(item)">
下载文件
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteCleanHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="去重结果"
:cards="dedupeCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务完成数据去重后会在这里展示"
history-empty-text="暂无历史记录"
>
<template #item-actions="{ item }">
<template v-if="itemSource(item)">
<button v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
type="button" class="download" @click="downloadCleanResult(itemSource(item))">
下载文件
</button>
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
@click="deleteCleanHistoryRecord(itemSource(item).resultId!)">
删除
</button>
</template>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -148,6 +118,8 @@
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
@@ -168,6 +140,88 @@ const cleanProgressProcessed = ref(0)
const cleanResultItems = ref<DedupeResultItem[]>([])
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
// 最近一次去重运行信息:用于右侧"当前任务"卡的占位展示(开始时间/运行ID/进度)
const latestRunId = ref('')
const cleanRunStartedAt = ref('')
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function itemSource(item: TaskItemView): DedupeResultItem {
return item.source as DedupeResultItem
}
const dedupeCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: cleanSummary.value.total },
{ label: '成功任务', value: cleanSummary.value.successCount },
{ label: '失败任务', value: cleanSummary.value.failedCount },
])
/** 当前任务:运行中的去重 run(占位卡) + 历史列表中非终态任务 */
const currentTaskViews = computed<TaskItemView[]>(() => {
const views: TaskItemView[] = []
if (cleanRunning.value && latestRunId.value) {
views.push({
key: `run-${latestRunId.value}`,
title: cleanArchiveName.value || '数据去重',
taskId: latestRunId.value,
startedAt: cleanRunStartedAt.value,
finishedAt: '',
statusText: '执行中',
statusClass: 'running',
progress: {
percent: cleanSummary.value.total
? Math.round((cleanProgressProcessed.value / cleanSummary.value.total) * 100)
: 0,
stage: `正在处理文件 ${cleanProgressProcessed.value}/${cleanSummary.value.total}`,
},
})
}
for (const item of cleanResultItems.value) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING' || status === 'PENDING') {
views.push(toDedupeTaskView(item))
}
}
return views
})
const historyTaskViews = computed<TaskItemView[]>(() =>
cleanResultItems.value
.filter((item) => {
const status = (item.taskStatus || '').toUpperCase()
return status !== 'RUNNING' && status !== 'PENDING'
})
.map(toDedupeTaskView),
)
function toDedupeTaskView(item: DedupeResultItem): TaskItemView {
return {
key: `dedupe-${item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: item.success ? '已完成' : '失败',
statusClass: item.success ? 'success' : 'failed',
extraLines: [
...(item.outputFilename ? [`输出文件:${item.outputFilename}`] : []),
...(item.error ? [`错误信息:${item.error}`] : []),
],
source: item,
}
}
function selectAllCleanColumns() {
cleanSelectedColumns.value = [...cleanAvailableColumns.value]
@@ -292,6 +346,8 @@ async function submitCleanRun() {
try {
cleanRunning.value = true
latestRunId.value = ''
cleanRunStartedAt.value = formatDateTime(new Date().toISOString())
const progress = await runDedupe({
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
selectedColumns: cleanSelectedColumns.value,
@@ -312,6 +368,7 @@ async function submitCleanRun() {
items: cleanResultItems.value,
}
})
latestRunId.value = progress.runId
cleanSummary.value = result
cleanResultItems.value = result.items || []
await loadCleanHistory()
@@ -410,6 +467,26 @@ async function downloadCleanResult(item: DedupeResultItem) {
onMounted(() => {
loadCleanHistory().catch(() => undefined)
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteCleanHistoryRecord(itemSource(view).resultId!)
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -666,81 +743,6 @@ onMounted(() => {
font-size: 13px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.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 #2e3a52;
border-radius: 8px;
margin-bottom: 10px;
background: #1c2333;
}
.left {
flex: 1;
min-width: 0;
}
.id {
display: inline-block;
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
margin-top: 4px;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.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;
}
.download {
padding: 6px 10px;
border-radius: 6px;
@@ -775,50 +777,6 @@ onMounted(() => {
max-height: none;
}
.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 #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
@media (max-width: 1100px) {
.main-content {
flex-direction: column;
@@ -834,19 +792,5 @@ onMounted(() => {
.right-panel {
min-height: 420px;
}
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
}
</style>
@@ -58,7 +58,7 @@
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length"
@click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
{{ pushing ? '启动中...' : '启动任务' }}
</button>
<span class="loading-msg">
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
@@ -66,7 +66,7 @@
</div>
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
<div class="section-title queue-debug-title">Python 队列推送结果</div>
<div class="section-title queue-debug-title">任务启动结果</div>
<div v-if="queuePushResult" class="queue-debug-line">
{{ queuePushResult }}
</div>
@@ -77,135 +77,29 @@
</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>
<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>
@@ -217,6 +111,8 @@ 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, type BrandExpandFolderItem } from '@/shared/api/brand'
import {
@@ -403,11 +299,78 @@ const historySectionItems = computed(() =>
(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) === '未入队'),
)
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())
@@ -560,6 +523,32 @@ function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
})
}
/** 任务卡开始时间(任务详情快照的创建时间) */
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 formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
}
function formatMatchResult(item: DeleteBrandResultItem) {
if (item.matchStatus === 'INDEX_STALE' && isUsableMatchedItem(item)) {
return `已匹配 ${item.shopId || ''}(索引过期,可继续推送)`.trim()
@@ -1135,7 +1124,7 @@ async function submitRun() {
const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item))
queuePushResult.value = hasRunnableItems
? '解析完成,可在左侧点击“推送到 Python 队列”开始串行处理'
? '解析完成,可在左侧点击“启动任务”开始串行处理'
: '解析完成,当前没有可推送的文件'
queuePayloadText.value = ''
@@ -1147,9 +1136,9 @@ async function submitRun() {
if (hasBlockedItems) {
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
} else if (hasStaleMatchedItems) {
ElMessage.warning('部分文件命中的是过期索引,仍可推送到 Python 队列;后台刷新后会自动重新匹配。')
ElMessage.warning('部分文件命中的是过期索引,仍可启动任务;后台刷新后会自动重新匹配。')
} else if (hasRunnableItems) {
ElMessage.success('删除品牌解析完成,请在左侧推送到 Python 队列')
ElMessage.success('删除品牌解析完成,请在左侧启动任务')
} else {
ElMessage.warning('当前没有可推送的文件,请先等待店铺索引刷新。')
}
@@ -1379,7 +1368,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
return { success: true, retryable: false }
} else {
const message = pushResult?.error || '未知错误'
qpr = `推送到 Python 队列失败:${message}`
qpr = `启动任务失败:${message}`
queuePushResult.value = qpr
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
if (options?.auto && isQueueBusyError(message)) {
@@ -1407,7 +1396,7 @@ async function pushToPythonQueue() {
activeItemKey.value = ''
return
}
ElMessage.success('已开始按顺序推送到 Python 队列')
ElMessage.success('已开始按顺序启动任务')
}
async function maybeAutoAdvance() {
@@ -1535,6 +1524,26 @@ onUnmounted(() => {
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteHistoryRecord(itemSource(view).resultId!)
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -1754,154 +1763,12 @@ onUnmounted(() => {
font-size: 13px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.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 #2e3a52;
border-radius: 8px;
margin-bottom: 10px;
background: #1c2333;
}
.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: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
margin-top: 4px;
}
.time {
font-size: 12px;
color: #5e6878;
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;
@@ -1926,60 +1793,10 @@ onUnmounted(() => {
background: rgba(231, 76, 60, 0.22);
}
.empty-tasks {
color: #5e6878;
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 #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
@media (max-width: 1100px) {
.main-content {
flex-direction: column;
@@ -1995,25 +1812,6 @@ onUnmounted(() => {
.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>
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div>
<div class="input-zone">
<div class="hint">
左侧负责录入店铺并加入备选区点击匹配店铺只会生成匹配结果确认后需手动点击推送到 Python 队列才会创建并执行任务任务只会跑下方勾选的国家
左侧负责录入店铺并加入备选区点击匹配店铺只会生成匹配结果确认后需手动点击启动任务才会创建并执行任务任务只会跑下方勾选的国家
</div>
<div class="input-row">
<el-input
@@ -136,7 +136,7 @@
:disabled="pushing || !matchedRunnableItems.length"
@click="pushToPythonQueue"
>
{{ pushing ? "任务执行中..." : "推送到 Python 队列" }}
{{ pushing ? "任务执行中..." : "启动任务" }}
</button>
</div>
@@ -154,215 +154,85 @@
</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>{{ dashboard.candidateCount }}</strong>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="patrolCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-empty-text="暂无当前任务启动任务后正在执行的任务会展示在这里历史任务可在右上角历史任务中查看"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">
完成匹配店铺这里会展示匹配结果
</div>
<div class="summary-card">
<span class="summary-label">已处理任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">
完成匹配店铺这里会展示匹配结果
</div>
<el-table
v-else
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
删除
</button>
</template>
</el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header">
<span>任务记录</span>
</div>
<div
v-if="!currentSectionItems.length && !historySectionItems.length"
class="empty-tasks"
<el-table
v-else
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
暂无任务记录推送到 Python 队列后这里会展示当前任务和历史任务
</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list">
<li
v-for="item in currentSectionItems"
:key="taskGroupKey(item)"
class="task-item"
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
<div class="left split-result-main">
<span class="id" :title="formatTaskGroupShopNames(item)">
{{ formatTaskGroupShopNames(item) }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="formatTaskGroupResultIds(item)" class="files">
结果 ID: {{ formatTaskGroupResultIds(item) }}
</div>
<div v-if="formatTaskGroupShopIds(item)" class="files">
店铺 ID: {{ formatTaskGroupShopIds(item) }}
</div>
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div class="files">
模板: {{ formatTemplateSummary(item) }}
</div>
<div v-if="formatTaskGroupErrors(item)" class="files">
错误: {{ formatTaskGroupErrors(item) }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史任务</div>
<ul class="task-list">
<li
v-for="item in historySectionItems"
:key="taskGroupKey(item)"
class="task-item"
>
<div class="left split-result-main">
<span class="id" :title="formatTaskGroupShopNames(item)">
{{ formatTaskGroupShopNames(item) }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="formatTaskGroupResultIds(item)" class="files">
结果 ID: {{ formatTaskGroupResultIds(item) }}
</div>
<div v-if="formatTaskGroupShopIds(item)" class="files">
店铺 ID: {{ formatTaskGroupShopIds(item) }}
</div>
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div v-if="item.finishedAt" class="files">
完成时间: {{ formatDateTime(item.finishedAt) }}
</div>
<div class="files">
模板: {{ formatTemplateSummary(item) }}
</div>
<div v-if="formatTaskGroupErrors(item)" class="files">
错误: {{ formatTaskGroupErrors(item) }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
v-if="canDownload(item)"
type="button"
class="archive"
@click="downloadResult(item)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
删除
</button>
</template>
</el-table-column>
</el-table>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -373,6 +243,8 @@
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from "@/shared/components/tasks/TaskCenterPanel.vue";
import type { TaskItemView, TaskStatCard } from "@/shared/components/tasks/types";
import {
addPatrolDeleteCandidate,
addPatrolDeleteCondition,
@@ -456,6 +328,48 @@ const currentSectionItems = computed(() =>
const historySectionItems = computed(() =>
taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
);
/** 统一统计卡:运行中/已结束按页面当前分区统计,成功/失败按已结束任务的 taskStatus 统计 */
const patrolCards = computed<TaskStatCard[]>(() => {
const ended = historySectionItems.value
return [
{ label: '运行中任务', value: currentSectionItems.value.length },
{ label: '已结束任务', value: ended.length },
{
label: '成功任务',
value: ended.filter((item) => item.taskStatus === 'SUCCESS' || item.taskStatus === 'COMPLETED').length,
},
{ label: '失败任务', value: ended.filter((item) => item.taskStatus === 'FAILED').length },
]
})
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toPatrolTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toPatrolTaskView))
function itemSource(item: TaskItemView) {
return item.source as PatrolDeleteHistoryItem
}
function toPatrolTaskView(item: PatrolDeleteHistoryItem): TaskItemView {
return {
key: taskGroupKey(item),
title: formatTaskGroupShopNames(item),
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.createdAt),
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : '进行中',
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: [
...(formatTaskGroupResultIds(item) ? [`结果 ID: ${formatTaskGroupResultIds(item)}`] : []),
...(formatTaskGroupShopIds(item) ? [`店铺 ID: ${formatTaskGroupShopIds(item)}`] : []),
...(item.platform ? [`平台: ${item.platform}`] : []),
`模板: ${formatTemplateSummary(item)}`,
...(formatTaskGroupErrors(item) ? [`错误: ${formatTaskGroupErrors(item)}`] : []),
],
source: item,
}
}
const hasQueueWork = computed(
() =>
queueWorkerRunning.value ||
@@ -571,7 +485,7 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可推送到 Python 队列";
return "索引已命中,可启动任务";
}
if (row.matched) {
return "已命中索引,请结合状态列确认";
@@ -1320,6 +1234,26 @@ onUnmounted(() => {
clearSleepTimers();
timers.clearScope();
});
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -1363,42 +1297,19 @@ onUnmounted(() => {
.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: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
.summary-label { font-size: 12px; color: #5e6878; }
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
.empty-tasks.narrow { padding: 12px 8px; }
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
.files { font-size: 12px; color: #5e6878; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.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; }
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.archive:hover { background: rgba(52, 152, 219, 0.28); }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.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; border: none; cursor: pointer; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
@@ -126,7 +126,7 @@
{{ matching ? '匹配中' : '匹配店铺' }}
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
{{ pushing ? '推送' : '推送到 Python 队列' }}
{{ pushing ? '启动' : '启动任务' }}
</button>
</div>
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>跟价结果合并输出全量和自定义的区别仅在于ASIN来源不同</p>
@@ -152,109 +152,53 @@
</aside>
<section class="right-panel">
<div class="panel-header">匹配与任务</div>
<div class="task-list-wrap">
<div class="clean-result-summary pr-summary">
<div class="summary-card">
<span class="summary-label">备选店铺数</span>
<strong>{{ dashboard.candidateCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已结束任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table-column label="状态" width="100" show-overflow-tooltip>
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
</el-table-column>
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
</template>
</el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header">
<span>处理记录</span>
</div>
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录推送队列并等待 Python 回传后将在此显示下载链接</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="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<div v-if="item.roundIndex" class="files">轮次 {{ item.roundIndex }} </div>
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
下载
</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</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="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<div v-if="item.roundIndex" class="files">轮次 {{ item.roundIndex }} </div>
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.outputFilename" class="files">文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
下载
</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="priceTrackCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-empty-text="暂无当前任务完成匹配店铺并启动任务后将在此展示处理进度"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table-column label="状态" width="100" show-overflow-tooltip>
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
</el-table-column>
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
</template>
</el-table-column>
</el-table>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -267,6 +211,8 @@ import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
@@ -386,6 +332,25 @@ const historyItems = ref<PriceTrackHistoryItem[]>([])
const pollingTaskIds = ref<number[]>([])
const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
function snapTask(item: PriceTrackHistoryItem) {
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
}
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
let disposed = false
@@ -1050,7 +1015,7 @@ async function pushToPythonQueueLegacy() {
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
ElMessage.success('已推送到 Python 队列')
ElMessage.success('已启动任务')
} catch (e) {
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value)
@@ -1097,7 +1062,7 @@ async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayloa
nonEmptyObjectKeys: mode === 'asin' ? ['asin_rows_by_country'] : [],
})
if (!(await passGuard(guard))) {
const reason = `任务 ${taskId} 数据校验未通过,已阻止推送到 Python 队列`
const reason = `任务 ${taskId} 数据校验未通过,已阻止启动任务`
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
@@ -1105,12 +1070,12 @@ async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayloa
try {
pushResult = await api.enqueue_json(queuePayload)
} catch (error) {
const reason = `Python 队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}`
const reason = `任务队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}`
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
if (!pushResult?.success) {
const reason = pushResult?.error || 'Python 队列拒绝接收任务'
const reason = pushResult?.error || '任务队列拒绝接收任务'
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
@@ -1650,7 +1615,40 @@ const historySectionItems = computed(() =>
historyItems.value.filter((row) => !row.taskId || !pollingTaskIds.value.includes(row.taskId) || isTaskTerminalById(row.taskId))
)
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const priceTrackCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toPriceTrackTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toPriceTrackTaskView))
function itemSource(item: TaskItemView) {
return item.source as PriceTrackHistoryItem
}
function toPriceTrackTaskView(item: PriceTrackHistoryItem): TaskItemView {
const task = snapTask(item)
return {
key: `follow-${item.resultId ?? `t${item.taskId ?? ''}`}-${item.shopName ?? ''}`,
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(task?.createdAt),
finishedAt: formatDateTime(task?.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.roundIndex ? [`轮次:第 ${item.roundIndex}`] : []),
...(item.platform ? [`平台:${item.platform}`] : []),
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
source: item,
}
}
function taskStatusOf(taskId?: number) {
if (!taskId) return ''
@@ -1770,6 +1768,26 @@ onUnmounted(() => {
timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -2253,61 +2271,19 @@ onUnmounted(() => {
}
/* Right panel */
.panel-header {
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: #f5f8fc;
border-bottom: 1px solid #2e3a52;
}
.task-list-wrap {
flex: 1;
padding: 16px 20px;
overflow: auto;
}
.pr-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 22px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.subsection-title {
font-size: 13px;
color: #a0acbe;
margin: 8px 0 10px;
}
.empty-tasks {
/* 匹配结果空态(#cards-extra 区块内使用) */
.match-empty {
color: #5e6878;
font-size: 13px;
padding: 16px;
text-align: center;
}
.empty-tasks.narrow {
padding: 12px 8px;
margin-bottom: 12px;
text-align: center;
}
.match-table {
@@ -2325,96 +2301,6 @@ onUnmounted(() => {
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 200px;
margin-top: 8px;
}
.result-list-header {
padding: 12px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
.result-subsection {
padding: 12px 12px 4px;
}
.result-subsection-title {
font-size: 12px;
color: #5e6878;
margin-bottom: 8px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 8px;
background: #222;
}
.split-result-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.id {
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
}
.task-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.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;
@@ -2454,10 +2340,6 @@ onUnmounted(() => {
border-right: none;
border-bottom: 1px solid #2e3a52;
}
.pr-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -6,7 +6,7 @@
<aside class="left-panel">
<div class="section-title">店铺名称</div>
<div class="input-zone">
<div class="hint">输入店铺名后点确认加入下方备选区按当前登录用户保存仅索引可命中的店铺可入库勾选备选后点匹配店铺推送到 Python 队列</div>
<div class="hint">输入店铺名后点确认加入下方备选区按当前登录用户保存仅索引可命中的店铺可入库勾选备选后点匹配店铺启动任务</div>
<div class="input-row">
<el-input v-model="shopInput" clearable placeholder="请输入店铺名称" @keyup.enter="confirmAdd" />
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">
@@ -56,7 +56,7 @@
<div v-if="countryPrefSaving" class="country-pref-status">保存中</div>
<div class="section-title">商品列表筛选</div>
<p class="hint listing-filter-hint">推送到 Python 队列一并下发 Python 区分处理场景</p>
<p class="hint listing-filter-hint">启动任务一并下发 Python 区分处理场景</p>
<div class="listing-filter-row">
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
<el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
@@ -72,7 +72,7 @@
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length"
@click="pushToPythonQueue">
{{ pushing ? '推送' : '推送到 Python 队列' }}
{{ pushing ? '启动' : '启动任务' }}
</button>
</div>
<p class="loading-msg">在搜索结果中禁止显示详情页面已删除会把匹配店铺合并为一个任务入队避免同时打开多个紫鸟窗口其它筛选仍按店铺逐条入队Python
@@ -87,107 +87,53 @@
</aside>
<section class="right-panel">
<div class="panel-header">匹配与任务</div>
<div class="task-list-wrap">
<div class="clean-result-summary pr-summary">
<div class="summary-card">
<span class="summary-label">备选店铺数</span>
<strong>{{ dashboard.candidateCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已结束任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table-column label="状态" width="100" show-overflow-tooltip>
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
</el-table-column>
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
</template>
</el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header">
<span>处理记录</span>
</div>
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录推送队列并等待 Python 回传后将在此显示下载链接</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="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
下载
</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</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="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.outputFilename" class="files">文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
下载
</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="riskCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-empty-text="暂无当前任务完成匹配店铺并启动任务后将在此展示处理进度"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table-column label="状态" width="100" show-overflow-tooltip>
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
</el-table-column>
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
</template>
</el-table-column>
</el-table>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -198,6 +144,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import {
addProductRiskCandidate,
@@ -387,6 +335,25 @@ const pollingTaskIds = ref<number[]>([])
const taskDetails = ref<Record<number, string>>({})
/** 本地缓存 POST /tasks/batch 各 task 最新快照 + 创建任务响应,便于刷新后查看 */
const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({})
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
function snapTask(item: ProductRiskHistoryItem) {
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
}
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
let disposed = false
@@ -702,7 +669,39 @@ const historySectionItems = computed(() =>
),
)
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
/** 统一统计卡:运行中任务用页面当前列表实时计算,其余沿用后端 dashboard */
const riskCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toRiskTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toRiskTaskView))
function itemSource(item: TaskItemView) {
return item.source as ProductRiskHistoryItem
}
function toRiskTaskView(item: ProductRiskHistoryItem): TaskItemView {
const task = snapTask(item)
return {
key: `risk-${item.resultId ?? `t${item.taskId ?? ''}`}-${item.shopName ?? ''}`,
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(task?.createdAt),
finishedAt: formatDateTime(task?.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.platform ? [`平台:${item.platform}`] : []),
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
source: item,
}
}
const TRANSIENT_BACKEND_ERROR_PATTERNS = [
'无法连接到后端服务',
@@ -1267,6 +1266,26 @@ onUnmounted(() => {
timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -1577,61 +1596,19 @@ onUnmounted(() => {
word-break: break-all;
}
.panel-header {
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: #f5f8fc;
border-bottom: 1px solid #2e3a52;
}
.task-list-wrap {
flex: 1;
padding: 16px 20px;
overflow: auto;
}
.clean-result-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 22px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.subsection-title {
font-size: 13px;
color: #a0acbe;
margin: 8px 0 10px;
}
.empty-tasks {
/* 匹配结果空态(#cards-extra 区块内使用) */
.match-empty {
color: #5e6878;
font-size: 13px;
padding: 16px;
text-align: center;
}
.empty-tasks.narrow {
padding: 12px 8px;
margin-bottom: 12px;
text-align: center;
}
.match-table {
@@ -1661,101 +1638,6 @@ onUnmounted(() => {
color: #e67e22;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 200px;
margin-top: 8px;
}
.result-list-header {
padding: 12px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
.result-subsection {
padding: 12px 12px 4px;
}
.result-subsection-title {
font-size: 12px;
color: #5e6878;
margin-bottom: 8px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 8px;
background: #222;
}
.left {
flex: 1;
min-width: 0;
}
.split-result-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.id {
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
}
.task-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.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;
@@ -1795,10 +1677,6 @@ onUnmounted(() => {
border-right: none;
border-bottom: 1px solid #2e3a52;
}
.clean-result-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -91,110 +91,91 @@
</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.completed }}</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>
<span class="history-count">历史任务 {{ historyItems.length }}</span>
</div>
<div v-if="!visibleTasks.length" class="empty-tasks">
暂无上架任务完成文件选择后点击开始上架创建任务
</div>
<section
v-for="detail in visibleTasks"
v-else
:key="detail.task.id"
class="task-section"
>
<div class="task-section-header">
<div class="task-title-wrap">
<strong>上架任务 #{{ detail.task.id }}</strong>
<span class="task-meta">创建时间{{ formatDateTime(detail.task.createdAt) }}</span>
</div>
<div class="task-actions">
<span class="status" :class="statusClass(detail.task.status)">
{{ statusText(detail.task.status) }}
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="上架结果"
:cards="publishCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无上架任务完成文件选择后点击开始上架创建任务"
history-empty-text="暂无历史记录"
>
<template #item-extra="{ item }">
<ul class="publish-file-list">
<li v-for="file in itemSource(item).files" :key="fileKey(file)" class="publish-file-row">
<div class="publish-file-row-head">
<span class="publish-file-row-name" :title="file.sourceFilename || ''">
{{ file.sourceFilename || `文件 ${file.fileId}` }}
</span>
<button
v-if="canDownload(detail)"
type="button"
class="download"
@click="downloadResult(detail)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
:disabled="isDeletingTask(detail.task.id)"
@click="deleteTaskRecord(detail)"
>
{{ isDeletingTask(detail.task.id) ? '删除中...' : '删除' }}
</button>
<span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
</div>
</div>
<div v-if="detail.task.errorMessage" class="task-error">
{{ detail.task.errorMessage }}
</div>
<ul class="file-list">
<li v-for="file in detail.files" :key="fileKey(file)" class="file-item">
<div class="file-main">
<div class="file-title" :title="file.sourceFilename || ''">
{{ file.sourceFilename || `文件 ${file.fileId}` }}
</div>
<div class="file-info">
<span>店铺{{ file.shopName || '-' }}</span>
<span>匹配{{ matchText(file) }}</span>
<span v-if="file.platform">平台{{ file.platform }}</span>
<span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<template v-if="shouldShowFileProgress(file)">
<div class="file-progress-header">
<span>{{ file.progressMessage || statusText(file.status) }}</span>
<span>{{ fileProgressPercent(file) }}%</span>
</div>
<div class="file-progress-track">
<div
class="file-progress-fill"
:class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"
/>
</div>
</template>
<div v-if="fileErrorText(file)" class="file-error">{{ fileErrorText(file) }}</div>
<div class="publish-file-row-info">
<span>店铺{{ file.shopName || '-' }}</span>
<span>匹配{{ matchText(file) }}</span>
<span v-if="file.platform">平台{{ file.platform }}</span>
<span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<template v-if="shouldShowFileProgress(file)">
<div class="publish-file-row-progress-meta">
<span>{{ file.progressMessage || statusText(file.status) }}</span>
<span>{{ fileProgressPercent(file) }}%</span>
</div>
<span class="status file-status" :class="statusClass(file.status)">
{{ statusText(file.status) }}
<div class="publish-file-row-progress-track">
<div class="publish-file-row-progress-fill" :class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"></div>
</div>
</template>
<div v-if="fileErrorText(file)" class="publish-file-row-error file-error">{{ fileErrorText(file) }}</div>
</li>
</ul>
</template>
<template #history-item-extra="{ item }">
<ul class="publish-file-list">
<li v-for="file in itemSource(item).files" :key="fileKey(file)" class="publish-file-row">
<div class="publish-file-row-head">
<span class="publish-file-row-name" :title="file.sourceFilename || ''">
{{ file.sourceFilename || `文件 ${file.fileId}` }}
</span>
</li>
</ul>
</section>
</div>
</div>
<span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
</div>
<div class="publish-file-row-info">
<span>店铺{{ file.shopName || '-' }}</span>
<span>匹配{{ matchText(file) }}</span>
<span v-if="file.platform">平台{{ file.platform }}</span>
<span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<template v-if="shouldShowFileProgress(file)">
<div class="publish-file-row-progress-meta">
<span>{{ file.progressMessage || statusText(file.status) }}</span>
<span>{{ fileProgressPercent(file) }}%</span>
</div>
<div class="publish-file-row-progress-track">
<div class="publish-file-row-progress-fill" :class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"></div>
</div>
</template>
<div v-if="fileErrorText(file)" class="publish-file-row-error file-error">{{ fileErrorText(file) }}</div>
</li>
</ul>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" :disabled="isDeletingTask(itemSource(item).task.id)"
@click="deleteTaskRecord(itemSource(item))">
{{ isDeletingTask(itemSource(item).task.id) ? '删除中...' : '删除' }}
</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" :disabled="isDeletingTask(itemSource(item).task.id)"
@click="deleteTaskRecord(itemSource(item))">
{{ isDeletingTask(itemSource(item).task.id) ? '删除中...' : '删除' }}
</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -207,6 +188,8 @@ import { ElMessage, ElMessageBox } 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, type BrandExpandFolderItem } from '@/shared/api/brand'
import {
@@ -410,6 +393,65 @@ const visibleTasks = computed(() => {
return Array.from(tasks.values()).sort((left, right) => right.task.id - left.task.id)
})
function isPublishTaskTerminal(detail: PublishTaskDetailVo) {
return isTerminalStatus(detail.task?.status)
}
const publishCards = computed<TaskStatCard[]>(() => {
const visible = visibleTasks.value
const terminal = visible.filter((detail) => isPublishTaskTerminal(detail))
let successCount = 0
for (const detail of terminal) {
const value = normalizeStatus(detail.task?.status)
if (value === 'SUCCESS' || value === 'COMPLETED') successCount += 1
}
return [
{ label: '运行中任务', value: visible.length - terminal.length },
{ label: '已结束任务', value: terminal.length },
{ label: '成功任务', value: successCount },
{ label: '失败任务', value: terminal.length - successCount },
]
})
const currentTaskViews = computed<TaskItemView[]>(() =>
visibleTasks.value
.filter((detail) => !isPublishTaskTerminal(detail))
.map(toPublishTaskView),
)
const historyTaskViews = computed<TaskItemView[]>(() =>
historyItems.value
.filter((detail) => isPublishTaskTerminal(detail))
.map(toPublishTaskView),
)
function itemSource(item: TaskItemView): PublishTaskDetailVo {
return item.source as PublishTaskDetailVo
}
function toPublishTaskView(detail: PublishTaskDetailVo): TaskItemView {
const task = detail.task
const taskStatus = task?.status
const terminal = isTerminalStatus(taskStatus)
const rawPercent = Number(task?.percent || 0)
const percent = Number.isFinite(rawPercent) ? Math.max(0, Math.min(100, Math.round(rawPercent))) : 0
const errorMessage = task?.errorMessage || ''
const extraLines: string[] = []
if (errorMessage) extraLines.push(`错误:${errorMessage}`)
return {
key: `publish-${task?.id ?? 'unknown'}`,
title: `上架任务 #${task?.id ?? '-'}`,
taskId: task?.id ?? '-',
startedAt: formatDateTime(task?.startedAt || task?.createdAt),
finishedAt: task?.finishedAt ? formatDateTime(task.finishedAt) : terminal ? '-' : '进行中',
statusText: statusText(taskStatus),
statusClass: statusClass(taskStatus),
extraLines,
progress: !terminal && percent > 0 ? { percent, stage: '任务进度' } : null,
source: detail,
}
}
const summary = computed(() => {
const files = visibleTasks.value.flatMap((detail) => detail.files || [])
const successCount = files.filter((file) => ['SUCCESS', 'COMPLETED'].includes(normalizeStatus(file.status))).length
@@ -870,7 +912,7 @@ async function processQueue() {
saveQueueState()
try {
await activatePublishFile(taskId, nextFileId)
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已派发到 Python 队列' })
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已启动任务' })
const payload = buildQueuePayload(taskId, file)
// totalRows/totalPages 为 0 时 Python 端翻不到任何明细,会一直停在执行中
const guard = checkQueuePayload(payload, {
@@ -882,7 +924,7 @@ async function processQueue() {
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`)
}
const result = await api.enqueue_json(payload)
if (!result?.success) throw new Error(result?.error || 'Python 队列拒绝接收任务')
if (!result?.success) throw new Error(result?.error || '任务队列拒绝接收任务')
queueMessage.value = pendingFileIds.value.length
? `文件 ${file.sourceFilename || nextFileId} 已入队,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
: `文件 ${file.sourceFilename || nextFileId} 已入队,等待执行完成。`
@@ -1158,6 +1200,26 @@ onBeforeUnmount(() => {
disposed = true
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -1167,8 +1229,8 @@ onBeforeUnmount(() => {
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
.upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; text-align: center; }
.hint, .loading-msg, .task-meta, .file-info, .history-count { color: #5e6878; font-size: 12px; line-height: 1.5; }
.btns, .run-row, .task-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.hint, .loading-msg { color: #5e6878; font-size: 12px; line-height: 1.5; }
.btns, .run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.btns { justify-content: center; }
.opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
.opt-btn { padding: 8px 16px; border: 1px solid #3e4a62; background: #2e3a52; color: #c8d2e2; font-size: 13px; }
@@ -1190,54 +1252,38 @@ onBeforeUnmount(() => {
.btn-run:hover:not(:disabled) { background: #2980b9; }
.queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #2e3a52; color: #b8d8ef; font-size: 12px; line-height: 1.6; }
.queue-status-title { margin-bottom: 4px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 15px; font-weight: 600; }
.task-list-wrap { flex: 1; padding: 16px; overflow-y: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 12px; margin-bottom: 18px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
.summary-label { color: #5e6878; font-size: 12px; }
.result-list-wrap { min-height: 260px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
.empty-tasks { padding: 24px 16px; color: #5e6878; font-size: 13px; }
.task-section + .task-section { border-top: 1px solid #333f55; }
.task-section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; background: #222; }
.task-title-wrap { display: flex; min-width: 0; flex-direction: column; gap: 4px; color: #c8d2e2; }
.task-error, .file-error { color: #ef8d8d; font-size: 12px; }
.task-error { padding: 10px 16px 0; }
.download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; }
.download:hover { background: #367d54; }
.btn-delete { padding: 7px 12px; background: #472929; color: #efaaaa; font-size: 12px; white-space: nowrap; }
.btn-delete:hover:not(:disabled) { background: #5a3030; color: #ffd0d0; }
.btn-delete:disabled { cursor: wait; opacity: .55; }
.status { display: inline-flex; align-items: center; justify-content: center; min-width: 58px; min-height: 26px; padding: 0 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
.status.pending { background: #3a3424; color: #e4c56a; }
.status.running { background: #24394a; color: #75bff1; }
.status.success { background: #233c2d; color: #72d598; }
.status.failed { background: #472929; color: #ef8d8d; }
.file-list { margin: 0; padding: 0; list-style: none; }
.file-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid #292929; }
.file-main { flex: 1; min-width: 0; }
.file-title { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.file-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6px; }
.file-progress-header { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; color: #969696; font-size: 11px; }
.file-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; }
.file-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
.file-progress-fill.running { background: #409eff; }
.file-progress-fill.success { background: #42b36b; }
.file-progress-fill.failed { background: #d06161; }
.file-error { margin-top: 7px; }
.file-status { flex-shrink: 0; }
.file-error { color: #ef8d8d; font-size: 12px; }
.publish-file-list { margin: 0; padding: 0; list-style: none; }
.publish-file-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #2e3a52; }
.publish-file-row:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.publish-file-row-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
.publish-file-row-name { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.publish-file-row-chip { flex-shrink: 0; padding: 2px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; }
.publish-file-row-chip.pending { background: #3a3424; color: #e4c56a; }
.publish-file-row-chip.running { background: #24394a; color: #75bff1; }
.publish-file-row-chip.success { background: #233c2d; color: #72d598; }
.publish-file-row-chip.failed { background: #472929; color: #ef8d8d; }
.publish-file-row-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 5px; color: #5e6878; font-size: 12px; line-height: 1.5; }
.publish-file-row-progress-meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 8px; color: #969696; font-size: 11px; }
.publish-file-row-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; }
.publish-file-row-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
.publish-file-row-progress-fill.running { background: #409eff; }
.publish-file-row-progress-fill.success { background: #42b36b; }
.publish-file-row-progress-fill.failed { background: #d06161; }
.publish-file-row-error { margin-top: 7px; }
@media (max-width: 1100px) {
.main-content { height: auto; flex-direction: column; }
.left-panel { width: 100%; border-right: 0; border-bottom: 1px solid #2e3a52; }
.right-panel { min-height: 420px; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.clean-result-summary { grid-template-columns: 1fr; }
.task-section-header, .file-item { flex-direction: column; }
.task-actions { width: 100%; }
.publish-file-row-head { flex-direction: column; align-items: flex-start; }
}
</style>
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div>
<div class="input-zone">
<div class="hint">
左侧负责录入店铺并加入备选区确认命中后推送到 Python 队列查询ASIN按店铺串行执行上一个任务完成后会自动开始下一个失败也会继续下一个
左侧负责录入店铺并加入备选区确认命中后启动任务查询ASIN按店铺串行执行上一个任务完成后会自动开始下一个失败也会继续下一个
</div>
<div class="input-row">
<el-input
@@ -77,7 +77,7 @@
:disabled="isQueueBusy || !matchedRunnableItems.length"
@click="pushToPythonQueue"
>
{{ isQueueBusy ? "串行执行中..." : "推送到 Python 队列" }}
{{ isQueueBusy ? "串行执行中..." : "启动任务" }}
</button>
</div>
@@ -95,221 +95,85 @@
</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>{{ dashboard.candidateCount }}</strong>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="queryAsinCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-empty-text="暂无当前任务启动任务后正在执行的任务会展示在这里历史任务可在右上角历史任务中查看"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">
完成匹配店铺这里会展示匹配结果
</div>
<div class="summary-card">
<span class="summary-label">已处理任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">
完成匹配店铺这里会展示匹配结果
</div>
<el-table
v-else
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
删除
</button>
</template>
</el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header">
<span>任务记录</span>
</div>
<div
v-if="!currentSectionItems.length && !historySectionItems.length"
class="empty-tasks"
<el-table
v-else
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
暂无任务记录推送到 Python 队列后这里会展示当前任务和历史任务
</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list">
<li
v-for="item in currentSectionItems"
:key="`${item.resultId}-${item.taskId}`"
class="task-item"
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">
{{ item.shopName || "-" }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="item.resultId" class="files">
结果 ID: {{ item.resultId }}
</div>
<div v-if="item.shopId" class="files">
店铺 ID: {{ item.shopId }}
</div>
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div class="files">
ASIN结构: {{ formatTemplateSummary(item) }}
</div>
<div v-if="item.error" class="files">
错误: {{ item.error }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史任务</div>
<ul class="task-list">
<li
v-for="item in historySectionItems"
:key="`${item.resultId}-${item.taskId}`"
class="task-item"
>
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">
{{ item.shopName || "-" }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="item.resultId" class="files">
结果 ID: {{ item.resultId }}
</div>
<div v-if="item.shopId" class="files">
店铺 ID: {{ item.shopId }}
</div>
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div v-if="item.finishedAt" class="files">
完成时间: {{ formatDateTime(item.finishedAt) }}
</div>
<div class="files">
ASIN结构: {{ formatTemplateSummary(item) }}
</div>
<div v-if="item.error" class="files">
错误: {{ item.error }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
v-if="canDownload(item)"
type="button"
class="archive"
@click="downloadResult(item)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
删除
</button>
</template>
</el-table-column>
</el-table>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -320,6 +184,8 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import { ElMessage } from "element-plus";
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from "@/shared/components/tasks/TaskCenterPanel.vue";
import type { TaskItemView, TaskStatCard } from "@/shared/components/tasks/types";
import {
addQueryAsinCandidate,
createQueryAsinTask,
@@ -386,6 +252,44 @@ const currentSectionItems = computed(() =>
const historySectionItems = computed(() =>
historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
);
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const queryAsinCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentSectionItems.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
]);
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toQueryAsinTaskView));
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toQueryAsinTaskView));
function itemSource(item: TaskItemView) {
return item.source as QueryAsinHistoryItem;
}
function toQueryAsinTaskView(item: QueryAsinHistoryItem): TaskItemView {
const startedRaw = taskStartTime(item.taskId);
return {
key: `qasin-${item.taskId ?? "r"}-${item.resultId ?? ""}-${item.shopName ?? ""}`,
title: item.shopName || "-",
taskId: item.taskId ?? "-",
startedAt: formatDateTime(startedRaw || item.createdAt),
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : "进行中",
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: [
...(item.resultId ? [`结果 ID: ${item.resultId}`] : []),
...(item.shopId ? [`店铺 ID: ${item.shopId}`] : []),
...(item.platform ? [`平台: ${item.platform}`] : []),
...(startedRaw && item.createdAt && startedRaw !== item.createdAt ? [`创建时间: ${formatDateTime(item.createdAt)}`] : []),
`ASIN结构: ${formatTemplateSummary(item)}`,
...(item.error ? [`错误: ${item.error}`] : []),
],
source: item,
};
}
const hasQueuedTaskWork = computed(
() =>
!!activeTaskId.value ||
@@ -499,7 +403,7 @@ function formatMatchRemark(row: QueryAsinShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可推送到 Python 队列";
return "索引已命中,可启动任务";
}
if (row.matched) {
return "已命中索引,请结合状态列确认";
@@ -1206,6 +1110,26 @@ onUnmounted(() => {
clearSleepTimers();
timers.clearScope();
});
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -1237,43 +1161,20 @@ onUnmounted(() => {
.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: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
.summary-label { font-size: 12px; color: #5e6878; }
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
.empty-tasks.narrow { padding: 12px 8px; }
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
.files { font-size: 12px; color: #5e6878; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.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; }
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.archive:hover { background: rgba(52, 152, 219, 0.28); }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.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; border: none; cursor: pointer; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
@@ -66,52 +66,51 @@
</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>{{ dashboard.candidateCount }}</strong></div>
<div class="summary-card"><span class="summary-label">已处理任务</span><strong>{{ dashboard.processedTaskCount }}</strong></div>
<div class="summary-card"><span class="summary-label">成功任务</span><strong>{{ dashboard.successTaskCount }}</strong></div>
<div class="summary-card"><span class="summary-label">失败任务</span><strong>{{ dashboard.failedTaskCount }}</strong></div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">匹配后将在这里显示结果</div>
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="64" align="center">
<template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template>
</el-table-column>
<el-table-column prop="shopId" label="店铺 ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="120" show-overflow-tooltip />
<el-table-column label="状态" width="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column>
<el-table-column label="操作" width="64" align="center"><template #default="{ row }">
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
</template></el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header"><span>任务记录</span></div>
<div v-if="!currentItems.length && !historySectionItems.length" class="empty-tasks">暂无任务记录</div>
<div v-if="currentItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list">
<TaskRow v-for="item in currentItems" :key="historyKey(item)" :item="item"
@download="downloadResult" @delete="deleteTaskRecord" />
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史记录</div>
<ul class="task-list">
<TaskRow v-for="item in historySectionItems" :key="historyKey(item)" :item="item"
@download="downloadResult" @delete="deleteTaskRecord" />
</ul>
</div>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="shopDataCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-empty-text="暂无当前任务启动任务后正在执行的任务会展示在这里历史任务可在右上角历史任务中查看"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">匹配后将在这里显示结果</div>
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="64" align="center">
<template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template>
</el-table-column>
<el-table-column prop="shopId" label="店铺 ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="120" show-overflow-tooltip />
<el-table-column label="状态" width="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column>
<el-table-column label="操作" width="64" align="center"><template #default="{ row }">
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
</template></el-table-column>
</el-table>
</template>
<template #item-extra="{ item }">
<div v-if="itemSource(item).error" class="files error-text">错误{{ itemSource(item).error }}</div>
</template>
<template #history-item-extra="{ item }">
<div v-if="itemSource(item).error" class="files error-text">错误{{ itemSource(item).error }}</div>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -119,9 +118,11 @@
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.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 { useZiniaoVersion } from '@/shared/utils/ziniao-version'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
@@ -160,28 +161,6 @@ const COUNTRY_OPTIONS = [
{ code: 'IT', label: '意大利' },
] as const
const TaskRow = defineComponent({
props: { item: { type: Object as () => ShopDataCrawlHistoryItem, required: true } },
emits: ['download', 'delete'],
setup(props, { emit }) {
return () => h('li', { class: 'task-item' }, [
h('div', { class: 'left split-result-main' }, [
h('span', { class: 'id', title: props.item.shopName || '' }, props.item.shopName || '-'),
h('div', { class: 'files' }, `任务 ID${props.item.taskId ?? '-'}`),
props.item.platform ? h('div', { class: 'files' }, `平台:${props.item.platform}`) : null,
props.item.outputFilename ? h('div', { class: 'files' }, `文件:${props.item.outputFilename}`) : null,
props.item.createdAt ? h('div', { class: 'files' }, `创建时间:${formatDateTime(props.item.createdAt)}`) : null,
props.item.error ? h('div', { class: 'files error-text' }, `错误:${props.item.error}`) : null,
]),
h('div', { class: 'task-right' }, [
h('span', { class: ['status', statusClass(props.item.taskStatus)] }, statusText(props.item)),
canDownload(props.item) ? h('button', { type: 'button', class: 'download', onClick: () => emit('download', props.item) }, '下载') : null,
h('button', { type: 'button', class: 'btn-delete', onClick: () => emit('delete', props.item) }, '删除'),
]),
])
},
})
const timers = createCategorizedTimers('shop-data-crawl')
const ziniaoVersion = useZiniaoVersion()
const shopInput = ref('')
@@ -213,6 +192,39 @@ let disposed = false
const matchedRunnableItems = computed(() => matchedItems.value.filter((item) => item.matched))
const currentItems = computed(() => historyItems.value.filter((item) => !isTerminal(item.taskStatus)))
const historySectionItems = computed(() => historyItems.value.filter((item) => isTerminal(item.taskStatus)))
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const shopDataCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentItems.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toShopDataCrawlTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toShopDataCrawlTaskView))
function itemSource(item: TaskItemView) {
return item.source as ShopDataCrawlHistoryItem
}
function toShopDataCrawlTaskView(item: ShopDataCrawlHistoryItem): TaskItemView {
return {
key: historyKey(item),
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.createdAt),
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : '',
statusText: statusText(item),
statusClass: statusClass(item.taskStatus),
extraLines: [
...(item.platform ? [`平台:${item.platform}`] : []),
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
],
source: item,
}
}
const isQueueBusy = computed(() => queueWorkerRunning.value || !!activeTaskId.value || pendingQueue.value.length > 0)
const countryCheckboxRows = computed(() => {
const selected = new Set(orderedCountryCodes.value)
@@ -566,20 +578,40 @@ onUnmounted(() => {
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 400px; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; background: #1c2333; }
.right-panel { flex: 1; min-width: 0; background: #151a25; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
.input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; }
.input-row { display: flex; gap: 10px; }
.opt-btn, .btn-run { min-height: 36px; padding: 0 16px; border: 0; border-radius: 5px; background: #409eff; color: #f5f8fc; white-space: nowrap; cursor: pointer; }
.opt-btn { flex: 0 0 auto; }
.opt-btn:disabled, .btn-run:disabled { opacity: .5; cursor: not-allowed; }
.empty-candidates, .empty-tasks { padding: 16px; border: 1px dashed #333; border-radius: 6px; color: #5e6878; font-size: 13px; }
.empty-candidates { padding: 16px; border: 1px dashed #333; border-radius: 6px; color: #5e6878; font-size: 13px; }
.candidate-table-scroll { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 6px; overflow: hidden; }
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.link-danger { border: 0; background: transparent; color: #f56c6c; cursor: pointer; }
@@ -595,25 +627,14 @@ onUnmounted(() => {
.country-pref-status, .queue-status { color: #8dc4ff; font-size: 12px; line-height: 1.5; }
.run-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
.btn-queue { background: #67c23a; }
.panel-header { height: 52px; padding: 16px 22px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 700; }
.task-list-wrap { height: calc(100% - 52px); padding: 18px 22px 28px; overflow-y: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(100px, 1fr)); gap: 12px; margin-bottom: 20px; }
.summary-card { display: flex; min-height: 70px; flex-direction: column; justify-content: center; padding: 12px 16px; border: 1px solid #333f55; border-radius: 6px; background: #222; }
.summary-label { margin-bottom: 4px; color: #5e6878; font-size: 12px; }
.summary-card strong { color: #f5f8fc; font-size: 22px; }
.subsection-title, .result-subsection-title { margin: 18px 0 10px; color: #a0acbe; font-size: 13px; font-weight: 700; }
.subsection-title { margin: 18px 0 10px; color: #a0acbe; font-size: 13px; font-weight: 700; }
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 16px; text-align: center; }
.match-table { margin-bottom: 20px; --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #292929; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #67c23a; }.fail, .error-text { color: #f56c6c; }
.result-list-header { padding: 13px 0; border-bottom: 1px solid #333f55; color: #f5f8fc; font-weight: 700; }
.task-list { margin: 0; padding: 0; list-style: none; }
:deep(.task-item) { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 84px; padding: 14px 0; border-bottom: 1px solid #2b2b2b; }
:deep(.split-result-main) { min-width: 0; }
:deep(.id) { display: block; margin-bottom: 5px; color: #f5f8fc; font-weight: 700; }
:deep(.files) { margin-top: 3px; color: #5e6878; font-size: 12px; }
:deep(.task-right) { display: flex; align-items: center; gap: 10px; }
:deep(.status) { min-width: 88px; font-size: 12px; text-align: center; white-space: nowrap; }
:deep(.status.success) { color: #67c23a; }:deep(.status.failed) { color: #f56c6c; }:deep(.status.running) { color: #e6a23c; }
:deep(.download), :deep(.btn-delete) { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #c8d2e2; cursor: pointer; }
:deep(.download) { border-color: #409eff; color: #8dc4ff; }:deep(.btn-delete) { color: #f56c6c; }
@media (max-width: 900px) { .main-content { height: auto; flex-direction: column; }.left-panel { width: 100%; border-right: 0; }.clean-result-summary { grid-template-columns: repeat(2, 1fr); } }
.ok { color: #67c23a; }
.files { font-size: 12px; color: #5e6878; }
.fail, .error-text { color: #f56c6c; }
.download, .btn-delete { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #c8d2e2; cursor: pointer; }
.download { border-color: #409eff; color: #8dc4ff; }
.btn-delete { color: #f56c6c; }
@media (max-width: 900px) { .main-content { height: auto; flex-direction: column; }.left-panel { width: 100%; border-right: 0; } }
</style>
@@ -5,7 +5,7 @@
<aside class="left-panel">
<div class="section-title">店铺候选</div>
<div class="input-zone">
<div class="hint">输入店铺名后点添加加入下方备选区勾选备选店铺后先做匹配店铺确认命中紫鸟索引后再推送到 Python 队列</div>
<div class="hint">输入店铺名后点添加加入下方备选区勾选备选店铺后先做匹配店铺确认命中紫鸟索引后再启动任务</div>
<div class="input-row">
<el-input v-model="shopInput" clearable placeholder="请输入店铺名后回车或点击添加" @keyup.enter="confirmAdd" />
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">{{ adding ? '添加中...' : '添加' }}</button>
@@ -39,7 +39,7 @@
</div>
<div v-if="countryPrefSaving" class="country-pref-status">保存中...</div>
<div class="section-title">商品列表筛选</div>
<p class="hint listing-filter-hint">推送到 Python 队列一并下发 Python 区分处理场景</p>
<p class="hint listing-filter-hint">启动任务一并下发 Python 区分处理场景</p>
<div class="listing-filter-row">
<el-select v-model="shopMatchListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
<el-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
@@ -59,47 +59,48 @@
<div class="run-row">
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '推送中...' : scheduleEnabled ? '创建定时任务' : '推送到 Python 队列' }}</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '启动中...' : scheduleEnabled ? '创建定时任务' : '启动任务' }}</button>
</div>
<p class="loading-msg">已支持多阶段定时执行Python 每处理完一店可回传 <code>/api/shop-match/tasks/{taskId}/result</code>后端会按阶段汇总并在超时场景自动收尾</p>
<div v-if="queuePushResult" class="queue-debug-card"><div class="section-title queue-debug-title">推送结果</div><div 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>{{ dashboard.candidateCount }}</strong></div>
<div class="summary-card"><span class="summary-label">已结束任务</span><strong>{{ dashboard.processedTaskCount }}</strong></div>
<div class="summary-card"><span class="summary-label">成功</span><strong>{{ dashboard.successTaskCount }}</strong></div>
<div class="summary-card"><span class="summary-label">失败</span><strong>{{ dashboard.failedTaskCount }}</strong></div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center"><template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template></el-table-column>
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table-column label="状态" width="100" show-overflow-tooltip><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
<el-table-column label="说明" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatMatchRemark(row) }}</template></el-table-column>
<el-table-column label="操作" width="72" align="center"><template #default="{ row }"><button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button></template></el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header"><span>处理记录</span></div>
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录推送队列并等待 Python 回传后将在此显示下载链接</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list"><li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`" class="task-item"><div class="left split-result-main"><span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span><div class="files">任务 ID: {{ item.taskId ?? '-' }}</div><div v-if="item.platform" class="files">平台: {{ item.platform }}</div><div v-if="currentTaskStageText(item)" class="files">{{ currentTaskStageText(item) }}</div><div v-if="nextScheduledDisplay(item)" class="files">下一次: {{ nextScheduledDisplay(item) }}</div><div v-if="item.error" class="files">错误: {{ item.error }}</div></div><div class="task-right"><span class="status" :class="statusClass(item)">{{ statusText(item) }}</span><button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button><button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button></div></li></ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史任务</div>
<ul class="task-list"><li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item"><div class="left split-result-main"><span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span><div class="files">任务 ID: {{ item.taskId ?? '-' }}</div><div v-if="item.outputFilename" class="files">文件: {{ item.outputFilename }}</div><div v-if="item.error" class="files">错误: {{ item.error }}</div></div><div class="task-right"><span class="status" :class="statusClass(item)">{{ statusText(item) }}</span><button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button><button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button></div></li></ul>
</div>
</template>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="shopMatchCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-empty-text="暂无当前任务完成匹配店铺并启动任务后将在此展示处理进度"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table">
<el-table-column prop="shopName" label="店铺" min-width="100" />
<el-table-column label="匹配" width="72" align="center"><template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template></el-table-column>
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table-column label="状态" width="100" show-overflow-tooltip><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
<el-table-column label="说明" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatMatchRemark(row) }}</template></el-table-column>
<el-table-column label="操作" width="72" align="center"><template #default="{ row }"><button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button></template></el-table-column>
</el-table>
</template>
<template #item-extra="{ item }">
<div v-if="currentTaskStageText(itemSource(item))" class="files">{{ currentTaskStageText(itemSource(item)) }}</div>
<div v-if="nextScheduledDisplay(itemSource(item))" class="files">下一次: {{ nextScheduledDisplay(itemSource(item)) }}</div>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download" @click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download" @click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -110,6 +111,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.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 { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
@@ -145,6 +148,11 @@ const historyItems = ref<ShopMatchHistoryItem[]>([])
const pollingTaskIds = ref<number[]>([])
const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, ShopMatchTaskDetailVo>>({})
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
function snapTask(item: ShopMatchHistoryItem) {
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
}
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
const dispatchTimers = new Map<number, number>()
@@ -156,7 +164,40 @@ let disposed = false
const timers = createCategorizedTimers('shop-match')
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current'))
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history'))
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const shopMatchCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toShopMatchTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toShopMatchTaskView))
function itemSource(item: TaskItemView) {
return item.source as ShopMatchHistoryItem
}
function toShopMatchTaskView(item: ShopMatchHistoryItem): TaskItemView {
const task = snapTask(item)
return {
key: taskRowKey(item),
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(task?.createdAt),
finishedAt: formatDateTime(task?.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.platform ? [`平台: ${item.platform}`] : []),
...(item.outputFilename ? [`文件: ${item.outputFilename}`] : []),
...(item.error ? [`错误: ${item.error}`] : []),
],
source: item,
}
}
const countryCheckboxRows = computed(() => { const selectedSet = new Set(orderedCountryCodes.value); return [...orderedCountryCodes.value.map((code) => ({ code, label: countryLabel(code) })), ...COUNTRY_OPTIONS.filter((item) => !selectedSet.has(item.code)).map((item) => ({ code: item.code, label: item.label }))] })
function uidForStorage() { return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0' }
@@ -507,6 +548,26 @@ function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() })
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -560,39 +621,18 @@ onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); cle
.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: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
.summary-label { font-size: 12px; color: #5e6878; }
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
.empty-tasks.narrow { padding: 12px 8px; }
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell),.match-table :deep(.el-table__body tr.hover-row > td.el-table__cell),.match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 200px; margin-top: 8px; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
.files { font-size: 12px; color: #5e6878; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.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; border: none; cursor: pointer; }
.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; border: none; cursor: pointer; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-row { flex-direction: column; align-items: stretch; } }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .schedule-row { flex-direction: column; align-items: stretch; } }
</style>
@@ -65,7 +65,7 @@
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId"
@click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
{{ pushing ? '启动中...' : '启动任务' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 idasin国家价格标题图片链接数组提交后端接收分片后按 10 条一批送检结果区展示真实 LLM 批次进度</p>
@@ -80,104 +80,32 @@
</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>{{ dashboard.pendingTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已结束任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配任务</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>当前任务</span>
</div>
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressStage(item, '处理中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</span>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>历史记录</span>
</div>
<div v-if="!historyOnlyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyOnlyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressStage(item, '结果生成中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download"
@click="downloadResult(item)">下载</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="货源查询"
:cards="asinCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务"
history-empty-text="暂无历史记录"
>
<template #item-extra="{ item }">
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
</template>
<template #history-item-extra="{ item }">
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
@click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
@@ -190,6 +118,8 @@ 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 {
activateSimilarAsinTask,
deleteSimilarAsinHistory,
@@ -301,6 +231,48 @@ const historyOnlyItems = computed(() =>
}),
)
const asinCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toAsinTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historyOnlyItems.value.map(toAsinTaskView))
function itemSource(item: TaskItemView): SimilarAsinHistoryItem {
return item.source as SimilarAsinHistoryItem
}
function toAsinTaskView(item: SimilarAsinHistoryItem): TaskItemView {
const showProgress = showFileProgress(item)
const isRunning = normalizeTaskStatus(item) === 'RUNNING'
return {
key: `asin-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '货源查询',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
...(item.resultFilename && !showProgress ? [item.resultFilename] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
progress: showProgress
? {
percent: fileProgressPercent(item),
stage: displayFileProgressStage(item, isRunning ? '处理中' : '结果生成中'),
countLabel: hasFileProgressCount(item) ? fileProgressCountLabel(item) : undefined,
}
: null,
source: item,
}
}
function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) {
if (item.taskId == null) return item
const liveItem = liveProgressItems.value[item.taskId]
@@ -599,7 +571,7 @@ async function pushToPythonQueue() {
clearParsedTask()
await loadDashboard()
await loadHistory({ force: true })
ElMessage.success('已推送到 Python 队列')
ElMessage.success('已启动任务')
} finally {
pushing.value = false
}
@@ -1122,6 +1094,26 @@ onUnmounted(() => {
stopPolling()
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -1152,8 +1144,7 @@ onUnmounted(() => {
flex-direction: column;
}
.section-title,
.subsection-title {
.section-title {
font-size: 13px;
color: #a0acbe;
margin-bottom: 10px;
@@ -1368,172 +1359,11 @@ onUnmounted(() => {
white-space: pre-wrap;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.task-list-wrap {
flex: 1;
padding: 16px 20px;
overflow: auto;
}
.clean-result-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 8px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
color: #f5f8fc;
font-size: 22px;
}
.summary-label {
color: #5e6878;
font-size: 12px;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 8px;
background: #1c2333;
min-height: 180px;
margin: 0 0 16px;
}
.result-list-header {
display: flex;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2e3a52;
color: #c8d2e2;
font-size: 14px;
}
.empty-tasks {
color: #5e6878;
font-size: 13px;
padding: 18px;
text-align: center;
}
.task-list {
list-style: none;
margin: 0;
padding: 12px;
}
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 8px;
background: #222;
}
.left {
flex: 1;
min-width: 0;
}
.id {
color: #f5f8fc;
font-size: 13px;
font-weight: 600;
}
.task-right {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
}
.status.success {
background: rgba(46, 204, 113, .18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, .18);
color: #ff6b6b;
}
.status.running {
background: rgba(52, 152, 219, .18);
color: #3498db;
}
.status.pending {
background: rgba(149, 165, 166, .18);
color: #a0acbe;
}
.result-hint {
margin-top: 6px;
color: #e0b96d;
}
.file-progress {
margin-top: 8px;
max-width: 520px;
}
.file-progress-meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: #d8c278;
font-size: 12px;
}
.file-progress-track {
margin-top: 5px;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: #333f55;
border: 1px solid #3b3b3b;
}
.file-progress-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #4aa3ff, #f0c75e);
transition: width .25s ease;
}
.file-progress-count {
margin-top: 4px;
color: #858585;
font-size: 11px;
}
.download {
padding: 6px 10px;
color: #c8d2e2;
@@ -1557,9 +1387,5 @@ onUnmounted(() => {
border-right: none;
border-bottom: 1px solid #2e3a52;
}
.clean-result-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -98,65 +98,37 @@
</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>{{ splitSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ splitSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ splitSummary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>拆分结果压缩包列表</span>
</div>
<div v-if="splitResultItems.length === 0" class="empty-tasks">
暂无拆分结果完成数据拆分后会在这里展示压缩包文件
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in splitResultItems"
:key="`${item.resultId || item.outputFilename || item.sourceFilename}-${item.rowCount || 0}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">压缩包文件{{ item.outputFilename }}</div>
<div v-if="item.rowCount !== undefined" class="time">压缩包共包含 {{ item.rowCount }} 条数据</div>
<div v-if="item.entryCount && item.entryCount > 0" class="files">压缩包内共 {{ item.entryCount }} 个拆分文件
</div>
<div v-if="item.entries && item.entries.length" class="files split-entry-list">
压缩包内容{{ formatSplitEntries(item.entries) }}
</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
@click="downloadSplitResult(item)">
下载压缩包
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteSplitHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="拆分结果"
:cards="splitCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务完成数据拆分后会在这里展示"
history-empty-text="暂无历史记录"
>
<template #history-item-actions="{ item }">
<template v-if="itemSource(item)">
<button
v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
type="button"
class="download"
@click="downloadSplitResult(itemSource(item))"
>
下载压缩包
</button>
<button
v-if="itemSource(item).resultId"
type="button"
class="btn-delete"
@click="deleteSplitHistoryRecord(itemSource(item).resultId!)"
>
删除
</button>
</template>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -167,8 +139,10 @@
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules'
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
@@ -185,15 +159,79 @@ const splitRowsPerFile = ref<number | null>(2000)
const splitParts = ref<number | null>(5)
const splitRunning = ref(false)
const splitResultItems = ref<SplitResultItem[]>([])
const splitSummary = ref<SplitRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const splitDisplayPaths = computed(() => splitSelectedPaths.value.slice(0, 8))
function updateSplitSummaryFromItems(items: SplitResultItem[]) {
splitSummary.value = {
total: items.length,
successCount: items.filter((item) => item.success).length,
failedCount: items.filter((item) => !item.success).length,
items,
// ---- 右侧任务面板统一视图(TaskCenterPanel----
const splitCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function isSplitTaskBusy(item: SplitResultItem) {
const status = (item.taskStatus || '').toUpperCase()
return status === 'RUNNING' || status === 'PENDING'
}
/** 当前任务:运行中/待执行项(拆分接口通常同步完成即出结果,正常为空) */
const currentTaskViews = computed<TaskItemView[]>(() =>
splitResultItems.value.filter(isSplitTaskBusy).map(toSplitTaskView),
)
/** 历史任务:全部已结束的结果项 */
const historyTaskViews = computed<TaskItemView[]>(() =>
splitResultItems.value.filter((item) => !isSplitTaskBusy(item)).map(toSplitTaskView),
)
function itemSource(item: TaskItemView): SplitResultItem {
return item.source as SplitResultItem
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function splitTaskStatusText(item: SplitResultItem) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING') return '执行中'
if (status === 'PENDING') return '等待中'
return item.success ? '已完成' : '失败'
}
function splitTaskStatusClass(item: SplitResultItem) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING') return 'running'
if (status === 'PENDING') return 'pending'
return item.success ? 'success' : 'failed'
}
function toSplitTaskView(item: SplitResultItem): TaskItemView {
return {
key: `split-${item.resultId ?? `${item.sourceFilename}-${item.rowCount ?? 0}`}`,
title: item.sourceFilename || '数据拆分',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: splitTaskStatusText(item),
statusClass: splitTaskStatusClass(item),
extraLines: [
...(item.outputFilename ? [`压缩包文件:${item.outputFilename}`] : []),
...(item.rowCount !== undefined ? [`压缩包共包含 ${item.rowCount} 条数据`] : []),
...(item.entryCount && item.entryCount > 0 ? [`压缩包内共 ${item.entryCount} 个拆分文件`] : []),
...(item.entries && item.entries.length ? [`压缩包内容:${formatSplitEntries(item.entries)}`] : []),
...(item.error ? [`错误信息:${item.error}`] : []),
],
source: item,
}
}
@@ -314,7 +352,6 @@ async function submitSplitRun() {
? splitArchiveName.value
: undefined,
})
splitSummary.value = result
splitResultItems.value = result.items || []
await loadSplitHistory()
if (result.total > 0 && result.successCount === 0) {
@@ -341,7 +378,6 @@ async function loadSplitHistory() {
try {
const response = await getSplitHistory()
splitResultItems.value = response.items || []
updateSplitSummaryFromItems(splitResultItems.value)
} catch {
// ignore history load errors
}
@@ -387,6 +423,26 @@ async function downloadSplitResult(item: SplitResultItem) {
onMounted(() => {
loadSplitHistory().catch(() => undefined)
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteSplitHistoryRecord(itemSource(view).resultId!)
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -685,110 +741,12 @@ onMounted(() => {
font-size: 13px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.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 #2e3a52;
border-radius: 8px;
margin-bottom: 10px;
background: #1c2333;
}
.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: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
margin-top: 4px;
}
.time {
font-size: 12px;
color: #5e6878;
margin-top: 2px;
}
.split-entry-list {
line-height: 1.6;
word-break: break-all;
max-width: 100%;
}
.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;
}
.download {
padding: 6px 10px;
border-radius: 6px;
@@ -813,60 +771,6 @@ onMounted(() => {
background: rgba(231, 76, 60, 0.22);
}
.empty-tasks {
color: #5e6878;
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 #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
@media (max-width: 1100px) {
.main-content {
flex-direction: column;
@@ -882,24 +786,5 @@ onMounted(() => {
.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>
@@ -5,7 +5,7 @@
<div v-if="$slots.logoExtra" class="logo-extra">
<slot name="logoExtra" />
</div>
<a v-if="showHomeLink" href="/home" class="btn-home">返回首页</a>
<router-link v-if="showHomeLink" to="/home" class="btn-home">返回首页</router-link>
</div>
<nav v-if="showNav" class="nav-sections" aria-label="顶部功能导航">
@@ -13,9 +13,9 @@
<div class="nav-section-title">{{ group.label }}</div>
<div class="nav-section-items">
<template v-for="item in group.items" :key="item.key">
<a v-if="item.href" :href="item.href" class="nav-item" :class="{ active: active === item.key }">
<router-link v-if="item.href" :to="item.href" class="nav-item" :class="{ active: active === item.key }">
{{ item.label }}
</a>
</router-link>
<span v-else class="nav-item disabled" :class="{ active: active === item.key }">
{{ item.label }}
</span>
@@ -94,30 +94,30 @@ const navGroups: ReadonlyArray<NavGroup> = [
columnKey: 'brand_front_tools',
label: '前端工具',
items: [
{ key: 'collect-data', label: '采集数据', href: '/new_web_source/collect-data.html' },
{ key: 'variant-collection', label: 'ASIN变体采集', href: '/new_web_source/variant-collection.html' },
// { key: 'image-video', label: '视频', href: '/new_web_source/image-video.html' },
{ key: 'brand', label: '品牌检测', href: '/new_web_source/brand.html' },
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' },
{ key: 'similar-asin', label: '货源查询', href: '/new_web_source/similar-asin.html' },
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' },
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' },
{ key: 'convert', label: '格式转换', href: '/new_web_source/convert.html' },
{ key: 'collect-data', label: '采集数据', href: '/collect-data' },
{ key: 'variant-collection', label: 'ASIN变体采集', href: '/variant-collection' },
// { key: 'image-video', label: '视频', href: '/image-video' },
{ key: 'brand', label: '品牌检测', href: '/brand' },
{ key: 'appearance-patent', label: '外观专利检测', href: '/appearance-patent' },
{ key: 'similar-asin', label: '货源查询', href: '/similar-asin' },
{ key: 'dedupe', label: '数据去重', href: '/dedupe' },
{ key: 'split', label: '数据拆分', href: '/split' },
{ key: 'convert', label: '格式转换', href: '/convert' },
],
},
{
columnKey: 'brand_operation_tools',
label: '运营工具',
items: [
{ key: 'publish', label: '上架', href: '/new_web_source/publish.html' },
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html', aliases: ['price-track'] },
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
{ key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' },
{ key: 'shop-data-crawl', label: '店铺数据抓取', href: '/new_web_source/shop-data-crawl.html', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] },
{ key: 'withdraw', label: '取款', href: '/new_web_source/withdraw.html' },
{ key: 'publish', label: '上架', href: '/publish' },
{ key: 'delete-brand', label: '删除ASIN', href: '/delete-brand' },
{ key: 'product-risk', label: '商品风险解决', href: '/product-risk' },
{ key: 'shop-match', label: '定时匹配', href: '/shop-match' },
{ key: 'pricing', label: '跟价', href: '/price-track', aliases: ['price-track'] },
{ key: 'patrol-delete', label: '巡店删除', href: '/patrol-delete' },
{ key: 'query-asin', label: '查询ASIN', href: '/query-asin' },
{ key: 'shop-data-crawl', label: '店铺数据抓取', href: '/shop-data-crawl', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] },
{ key: 'withdraw', label: '取款', href: '/withdraw' },
{ key: 'shop-status', label: '店铺状态查询' },
],
},
@@ -86,45 +86,9 @@
</aside>
<section class="right-panel">
<div class="panel-header">
<span>变体采集任务</span>
<div class="panel-actions">
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
</div>
</div>
<div class="task-list-wrap">
<div v-if="!currentTasks.length" class="empty-tasks">暂无变体采集任务</div>
<ul v-else class="task-list">
<li v-for="item in currentTasks" :key="`vc-${item.task_id}`" class="task-item">
<div class="left">
<span class="id">任务 {{ item.task_id }}</span>
<div v-if="taskMeta(item).source" class="files">来源{{ taskMeta(item).source }}</div>
<div class="files">采集国家{{ countryText(item) || '-' }}</div>
<div class="files">更新时间{{ item.update_time || formatDateTime(item.updated_at) }}</div>
<div v-if="latestMessage(item)" class="files">{{ latestMessage(item) }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
<button type="button" class="act-btn" @click="refreshTask(item)">更新</button>
<button type="button" class="act-btn" @click="showDetail(item)">详情</button>
<button
v-if="hasResultFiles(item)"
type="button"
class="act-btn ok"
@click="downloadResult(item)"
>下载结果</button>
<button
v-if="hasSourceFile(item)"
type="button"
class="act-btn"
@click="downloadSource(item)"
>下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(item)">重新导出</button>
</div>
</li>
</ul>
<div v-if="currentTasks.length" class="pagination">
<div class="panel-toolbar">
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
<div v-if="totalCount" class="toolbar-pagination">
<span class="page-info"> {{ totalCount }} · {{ currentPage }} / {{ totalPages }} </span>
<div class="page-btns">
<button type="button" class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
@@ -132,6 +96,38 @@
</div>
</div>
</div>
<TaskCenterPanel
title="变体采集任务"
:cards="variantCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无运行中的变体采集任务"
history-empty-text="暂无历史记录"
>
<template #item-actions="{ item }">
<template v-if="itemSource(item)">
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
@click="downloadSource(itemSource(item))">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
</template>
</template>
<template #history-item-actions="{ item }">
<template v-if="itemSource(item)">
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
@click="downloadSource(itemSource(item))">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
</template>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -166,6 +162,8 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import {
getPywebviewApi,
type VariantTaskListItem,
@@ -214,6 +212,59 @@ const canSubmit = computed(() =>
inputMode.value === 'asin' ? asinLines.value.length > 0 : selectedFiles.value.length > 0,
)
// ---- 右侧任务面板统一视图(TaskCenterPanel----
/** 当前任务:排队中(0) / 执行中(1) 的任务(当前分页内) */
const currentTaskViews = computed<TaskItemView[]>(() =>
currentTasks.value.filter((item) => isBusy(item.status)).map(toVariantTaskView),
)
/** 历史任务:已完成(2) / 失败(3) 的任务(当前分页内) */
const historyTaskViews = computed<TaskItemView[]>(() =>
currentTasks.value.filter((item) => !isBusy(item.status)).map(toVariantTaskView),
)
const variantCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function itemSource(item: TaskItemView): VariantEntry {
return item.source as VariantEntry
}
function variantStatusClass(status?: number | string) {
const numeric = Number(status)
if (numeric === 1 || /running|processing/i.test(String(status))) return 'running'
if (numeric === 2 || /success|completed|done/i.test(String(status))) return 'success'
if (numeric === 3 || /failed|error/i.test(String(status))) return 'failed'
return 'pending'
}
function toVariantTaskView(item: VariantEntry): TaskItemView {
const extraLines: string[] = []
const source = item.meta?.source || ''
if (source) extraLines.push(`来源:${source}`)
extraLines.push(`采集国家:${countryText(item) || '-'}`)
const updateText = item.update_time || formatDateTime(item.updated_at)
if (updateText) extraLines.push(`更新时间:${updateText}`)
extraLines.push(latestMessage(item))
return {
key: `vc-${item.task_id}`,
title: `任务 ${item.task_id}`,
taskId: item.task_id,
// 桥接列表未提供任务开始时间,开始时间占位为 '-'
startedAt: '-',
// 桥接仅提供最近更新时间,作为结束时间的近似展示
finishedAt: formatDateTime(item.update_time),
statusText: statusText(item.status),
statusClass: variantStatusClass(item.status),
extraLines,
source: item,
}
}
let autoTimer: number | null = null
let refreshTimer: number | null = null
@@ -651,26 +702,12 @@ onBeforeUnmount(() => {
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #27ae60; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-msg { margin-top: 10px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 600; }
.panel-actions { display: flex; gap: 8px; }
.panel-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 20px 0; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 28px; text-align: center; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 8px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.task-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.cancelled { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.toolbar-pagination { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.act-btn { padding: 4px 9px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
.act-btn.warn { background: rgba(230, 162, 60, 0.14); color: #e6a23c; border-color: transparent; }
.pagination { display: flex; justify-content: space-between; align-items: center; padding: 10px 0 4px; }
.page-info { color: #5e6878; font-size: 12px; }
.page-btns { display: flex; gap: 8px; }
.page-btn { padding: 5px 12px; border-radius: 6px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; }
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div>
<div class="input-zone">
<div class="hint">
左侧负责录入店铺并加入备选区确认命中后推送到 Python 队列每次推送会把本次选中的所有店铺合并为一条取款任务任务之间串行执行
左侧负责录入店铺并加入备选区确认命中后启动任务每次推送会把本次选中的所有店铺合并为一条取款任务任务之间串行执行
</div>
<div class="input-row">
<el-input
@@ -88,7 +88,7 @@
:disabled="isQueueBusy || !matchedRunnableItems.length"
@click="pushToPythonQueue"
>
{{ isQueueBusy ? "串行执行中..." : "推送到 Python 队列" }}
{{ isQueueBusy ? "串行执行中..." : "启动任务" }}
</button>
</div>
@@ -106,223 +106,114 @@
</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>{{ dashboard.candidateCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已处理任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
<div class="match-zone">
<div class="match-zone-header">
<span>匹配结果</span>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">
<div v-if="!matchedItems.length" class="match-zone-empty">
完成匹配店铺这里会展示匹配结果
</div>
<el-table
v-else
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
删除
</button>
</template>
</el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header">
<span>任务记录</span>
</div>
<div
v-if="!currentSectionItems.length && !historySectionItems.length"
class="empty-tasks"
<div v-else class="match-zone-scroll">
<el-table
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
暂无任务记录推送到 Python 队列后这里会展示当前任务和历史任务
</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list">
<li
v-for="item in currentSectionItems"
:key="`current-${item.taskId || item.resultId}`"
class="task-item"
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">
取款任务 {{ item.taskId ?? "-" }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div class="files">包含店铺: {{ item.shopName || "-" }}</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div class="files">
取款结果: {{ formatTemplateSummary(item) }}
</div>
<div v-if="item.shops.length" class="shop-detail-list">
<div
v-for="shop in item.shops"
:key="`cur-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
<div v-if="item.error" class="files">
错误: {{ item.error }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史任务</div>
<ul class="task-list">
<li
v-for="item in historySectionItems"
:key="`history-${item.taskId || item.resultId}`"
class="task-item"
>
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">
取款任务 {{ item.taskId ?? "-" }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div class="files">包含店铺: {{ item.shopName || "-" }}</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div v-if="item.finishedAt" class="files">
完成时间: {{ formatDateTime(item.finishedAt) }}
</div>
<div class="files">
取款结果: {{ formatTemplateSummary(item) }}
</div>
<div v-if="item.shops.length" class="shop-detail-list">
<div
v-for="shop in item.shops"
:key="`his-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
<div v-if="item.error" class="files">
错误: {{ item.error }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
v-if="canDownload(item)"
type="button"
class="archive"
@click="downloadResult(item)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</template>
删除
</button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="withdrawCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务启动任务后运行中的任务会显示在这里"
history-empty-text="暂无历史任务"
>
<template #item-extra="{ item }">
<div class="files">取款结果{{ formatTemplateSummary(itemSource(item)) }}</div>
<div v-if="itemSource(item).shops.length" class="shop-detail-list">
<div
v-for="shop in itemSource(item).shops"
:key="`cur-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
</template>
<template #history-item-extra="{ item }">
<div class="files">取款结果{{ formatTemplateSummary(itemSource(item)) }}</div>
<div v-if="itemSource(item).shops.length" class="shop-detail-list">
<div
v-for="shop in itemSource(item).shops"
:key="`his-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -333,6 +224,8 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import { ElMessage } from "element-plus";
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import {
addWithdrawCandidate,
createWithdrawTask,
@@ -419,6 +312,43 @@ const hasQueuedTaskWork = computed(
const hasQueueWork = computed(() => queueWorkerRunning.value || hasQueuedTaskWork.value);
const isQueueBusy = computed(() => queueWorkerRunning.value && hasQueuedTaskWork.value);
// ---- 右侧任务面板统一视图(TaskCenterPanel----
const withdrawCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
]);
/** 当前任务:按任务分组后非终态的任务卡 */
const currentTaskViews = computed<TaskItemView[]>(() =>
currentSectionItems.value.map(toWithdrawTaskView),
);
/** 历史任务:按任务分组后已终态的任务卡 */
const historyTaskViews = computed<TaskItemView[]>(() =>
historySectionItems.value.map(toWithdrawTaskView),
);
function itemSource(item: TaskItemView): WithdrawTaskGroupItem {
return item.source as WithdrawTaskGroupItem;
}
/** 一任务一卡:标题优先店铺名(多店以“、”连接),其次结果文件名 */
function toWithdrawTaskView(item: WithdrawTaskGroupItem): TaskItemView {
return {
key: `withdraw-${item.taskId ?? item.resultId ?? item.shopName}`,
title: item.shopName || item.outputFilename || `取款任务 ${item.taskId ?? "-"}`,
taskId: item.taskId ?? "-",
startedAt: formatDateTime(taskStartTime(item.taskId) || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: item.error ? [`错误:${item.error}`] : [],
source: item,
};
}
function uidForStorage() {
return typeof window !== "undefined"
? window.localStorage.getItem("uid") || "0"
@@ -604,7 +534,7 @@ function formatMatchRemark(row: WithdrawShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可推送到 Python 队列";
return "索引已命中,可启动任务";
}
if (row.matched) {
return "已命中索引,请结合状态列确认";
@@ -1313,6 +1243,26 @@ onUnmounted(() => {
clearSleepTimers();
timers.clearScope();
});
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
@@ -1347,43 +1297,74 @@ onUnmounted(() => {
.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: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
.summary-label { font-size: 12px; color: #5e6878; }
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
.empty-tasks.narrow { padding: 12px 8px; }
.match-table { margin-bottom: 18px; }
.match-zone {
flex: 0 0 auto;
margin: 16px 20px 0;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
overflow: hidden;
}
.match-zone-header {
padding: 10px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 13px;
color: #a0acbe;
}
.match-zone-empty {
padding: 18px 16px;
color: #5e6878;
font-size: 13px;
text-align: center;
}
.match-zone-scroll {
max-height: 260px;
overflow: auto;
}
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
.files { font-size: 12px; color: #5e6878; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.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; }
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.archive: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; border: none; cursor: pointer; }
.shop-detail-list {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
}
.download {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(52, 152, 219, 0.18);
color: #69b6ff;
border: none;
cursor: pointer;
}
.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;
border: none;
cursor: pointer;
}
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>