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