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