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
@@ -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>