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