Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandBrandTab.vue
T
huangzd1997 14a723ab1c refactor(品牌工具页): 历史批量删除抽为 runBatchDelete
16 个品牌页逐字相同的 batchDeleteHistory 循环(逐条删除 + 容忍个别失败计数)
收敛为 shared/utils/batch-delete.ts 的 runBatchDelete;各页仅保留自己的删除调用
与提示文案。补单测 4 例(空列表 / 全成功 / 部分失败继续 / 非 Error 异常)。

净减约 120 行;vue-tsc 构建与 699 个前端单测通过。
2026-09-13 23:32:27 +08:00

588 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<AmazonToolPageShell tool-id="brand">
<div class="main-content">
<aside class="left-panel">
<div class="section-title">待检品牌文件</div>
<div class="upload-zone">
<div class="hint">
Excel 每行含品牌列即会加入检测可多选文件或选择整个文件夹.xlsx)。检测由本机客户端执行请在桌面客户端中打开本页面
</div>
<div class="btns">
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFiles">选择 Excel</button>
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFolder">选择文件夹</button>
</div>
<div class="selected-files">
<template v-if="selectedPaths.length">
<span v-for="name in displayPaths" :key="name" :title="name">{{ baseName(name) }}</span>
<span v-if="selectedPaths.length > displayPaths.length" class="more-line">
还有 {{ selectedPaths.length - displayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择文件</span>
</div>
<div v-if="!hasBridge" class="bridge-tip">当前浏览器环境无桌面客户端桥接仅可预览页面请用本机客户端打开</div>
</div>
<div class="section-title">匹配策略</div>
<div class="radio-cards">
<label class="radio-card" :class="{ selected: strategy === 'Simple' }">
<input type="radio" name="brand-strategy" value="Simple" v-model="strategy" />
<span class="radio-card-name">精确匹配Simple</span>
<span class="radio-card-desc">仅精确命中才判为疑似误杀最低</span>
</label>
<label class="radio-card" :class="{ selected: strategy === 'Terms' }">
<input type="radio" name="brand-strategy" value="Terms" v-model="strategy" />
<span class="radio-card-name">词条 / 嵌入匹配Terms</span>
<span class="radio-card-desc">按品牌词条做宽范围比对误杀率低推荐第一遍使用</span>
</label>
</div>
<div class="section-title">运行方式</div>
<p class="loading-msg">提交后立即开始检测可在右侧任务列表查看进度或取消</p>
<div class="run-row">
<button
type="button"
class="btn-run"
:disabled="!selectedPaths.length || submitting || !hasBridge || !hasUid"
@click="submitRun"
>
{{ submitting ? '提交中...' : '立即运行品牌检测' }}
</button>
</div>
<p v-if="!hasBridge || !hasUid" class="env-tip">
品牌检测在桌面客户端内执行请在本机客户端登录后打开本页面使用浏览器仅可预览界面)。
</p>
<p v-else class="loading-msg">
检测完成后自动生成结果 Excel含原始数据不符合品牌查询失败品牌),到右侧任务列表下载
</p>
<div class="section-title">文档模板</div>
<div class="btns">
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="downloadTemplate('xlsx')">下载 Excel 模板</button>
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="downloadTemplate('zip')">下载文件夹模板</button>
</div>
</aside>
<section class="right-panel">
<div class="panel-actions-row">
<button type="button" class="btn-refresh" @click="syncTasksAndResumePolling">刷新任务状态</button>
</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>
</div>
</template>
<script setup lang="ts">
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 {
cancelBrandTask,
deleteBrandTask,
getBrandTask,
getBrandTaskDownloadUrl,
getBrandTasks,
runBrandNow,
type BrandTaskItem,
} from '@/shared/api/brand'
import { checkSelectedFiles, EXCEL_EXTENSIONS } from '@/shared/dispatch-guard.ts'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import type { UploadFileVo } from '@/shared/api/upload.ts'
import type { UploadedFileRef } from '@/shared/api/types/upload.ts'
import { formatDateTime } from '@/shared/utils/datetime'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
import { runBatchDelete } from '@/shared/utils/batch-delete'
const uploadedFiles = ref<UploadFileVo[]>([])
const strategy = ref<'Terms' | 'Simple'>('Simple')
const submitting = ref(false)
const tasks = ref<BrandTaskItem[]>([])
// 品牌检测已服务端化(/api/brand/* 走域名):浏览器降级桥提供文件选择,Web 端可用;
// 文件夹选择/模板桥等降级桥未实现的方法在点击时提示"仅桌面端"
const hasBridge = computed(() => Boolean(getPywebviewApi()))
const hasUid = computed(() => {
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
const numeric = Number(raw.trim())
return Number.isFinite(numeric) && numeric > 0
})
// 展示用:已上传文件列表的展示名(优先相对路径,其次原文件名)
const selectedPaths = computed(() =>
uploadedFiles.value.map((f) => f.relativePath || f.originalFilename || f.fileKey),
)
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
function baseName(path: string) {
return path.split(/[\\/]/).pop() || path
}
const timers = createCategorizedTimers('brand-brand-task')
let pollTimer: number | null = null
/** taskId -> 行级进度快照(按“已处理品牌行 / 总品牌行”推进,成功或查询失败都计入完成数) */
const lineProgressMap = ref<Record<string, { current: number; total: number; phase?: string }>>({})
const BRAND_PHASE_TEXT: Record<string, string> = {
crawling: '品牌查询中',
assembling: '结果生成中',
uploading: '结果上传中',
failed: '处理失败',
}
function hasBusyBrandTask() {
return tasks.value.some(isBusyBrandTask)
}
function stopPolling() {
if (pollTimer != null) {
timers.clearTimer('task-poll', pollTimer)
pollTimer = null
}
}
function scheduleNextPoll() {
if (pollTimer != null) return
pollTimer = timers.setTimeout('task-poll', () => {
pollTimer = null
void pollOnce()
}, getTaskPollIntervalMs())
}
async function pollOnce() {
await loadTasks()
const busy = tasks.value.filter(isBusyBrandTask)
if (!busy.length) {
stopPolling()
return
}
// 清理已不在运行集合的任务旧快照,并拉取运行中任务的实时行级进度
for (const key of Object.keys(lineProgressMap.value)) {
if (!busy.some((b) => String(b.id) === key)) delete lineProgressMap.value[key]
}
for (const item of busy) {
const id = Number(item.id)
if (!id) continue
try {
const res = await getBrandTask(id)
const lp = res?.line_progress
if (lp?.has_progress && lp.info) {
lineProgressMap.value[String(id)] = {
current: Number(lp.info.current_line) || 0,
total: Number(lp.info.total_lines) || 0,
phase: lp.info.phase,
}
} else {
delete lineProgressMap.value[String(id)]
}
} catch {
// 单条详情失败不中断整轮轮询
}
}
scheduleNextPoll()
}
/** 拉一次列表;若仍有运行/排队任务则保持自动轮询,否则停表 */
async function syncTasksAndResumePolling() {
await loadTasks()
if (hasBusyBrandTask()) {
scheduleNextPoll()
} else {
stopPolling()
}
}
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'
}
function canDelete(item: BrandTaskItem) {
return !isRunning(item)
}
function canDownload(item: BrandTaskItem) {
const status = (item.status || '').toLowerCase()
return status === 'success' && Boolean(item.result_paths?.zip_url)
}
function taskProgress(item: BrandTaskItem) {
const total = Number(item.progress_total) || 0
const current = Number(item.progress_current) || 0
if (!total) return 0
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
}
function errorMessage(item: BrandTaskItem) {
return (item.error_message || '').trim()
}
function statusText(status?: BrandTaskItem['status']) {
const value = (status || '').toLowerCase()
const map: Record<string, string> = {
pending: '排队中',
running: '执行中',
success: '已完成',
failed: '失败',
cancelled: '已取消',
}
return map[value] || String(status ?? '未知')
}
function statusClass(status?: BrandTaskItem['status']) {
const value = (status || '').toLowerCase()
if (value === 'running') return 'running'
if (value === 'success') return 'success'
if (value === 'failed') return 'failed'
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 totalFiles = Number(item.progress_total) || 0
const doneFiles = Number(item.progress_current) || 0
const lp = isRunning(item) ? lineProgressMap.value[String(item.id)] : undefined
const lineTotal = lp?.total || 0
const lineCurrent = lp?.current || 0
const extraLines = [
...(item.file_paths?.length ? [`文件:${item.file_paths.length} 个`] : []),
...(lp && lineTotal > 0 ? [`已处理 ${lineCurrent} 行 / 共 ${lineTotal} 行(含查询失败)`] : []),
...(errorMessage(item) ? [`错误:${errorMessage(item)}`] : []),
]
// 行级进度优先:完成数 = 已处理品牌行(成功或查询失败都计入),进度 = 完成数 / 总数 * 100
let progress: TaskItemView['progress'] = null
if (isRunning(item)) {
if (lp && lineTotal > 0) {
progress = {
percent: Math.max(0, Math.min(100, Math.round((lineCurrent / lineTotal) * 100))),
stage: (lp.phase && BRAND_PHASE_TEXT[lp.phase]) || '品牌检测中',
countLabel: `${lineCurrent}/${lineTotal}`,
}
} else {
progress = {
percent: taskProgress(item),
stage: '任务进度',
countLabel: totalFiles > 0 ? `${doneFiles}/${totalFiles}` : undefined,
}
}
}
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,
progress,
source: item,
}
}
/** 上传本地 xlsx 到 Java 临时目录,成功后回填 fileUrl(服务器本地路径,Java 直接读取) */
async function selectFiles() {
const bridge = getPywebviewApi()
if (!bridge?.select_brand_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
const paths = await bridge.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
try {
const files = await uploadPathsToJava(getPywebviewApi(), paths, { uploadOss: true })
uploadedFiles.value = files
ElMessage.success(`已选择并上传 ${files.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文件上传失败')
}
}
async function selectFolder() {
const bridge = getPywebviewApi()
if (!bridge?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
const folder = await bridge.select_brand_folder()
if (!folder) return
// 新版桌面客户端桥在本机展开文件夹(服务器无法访问用户本机目录)
if (!bridge.expand_brand_folder) {
ElMessage.warning('当前桌面客户端版本不支持文件夹展开,请使用"选择文件"(可多选)')
return
}
try {
const res = await bridge.expand_brand_folder(folder)
if (!res.success || !res.items?.length) {
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(res.items.map((i) => i.absolutePath), { allowedExtensions: EXCEL_EXTENSIONS })))) return
const files = await uploadPathsToJava(getPywebviewApi(), res.items, { uploadOss: true })
uploadedFiles.value = files
ElMessage.success(`已选择并上传文件夹内 ${files.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文件夹选择失败')
}
}
async function submitRun() {
if (!hasBridge.value) {
ElMessage.warning('请在桌面客户端中打开本页面执行品牌检测')
return
}
if (!hasUid.value) {
ElMessage.warning('未获取到登录用户,请先在本机客户端登录后再试')
return
}
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择待检 Excel 文件')
return
}
submitting.value = true
try {
// 上传后以可跨节点下载的源地址作为 fileUrl:优先 OSS urluploadToOss=true 的桌面桥),
// 旧桌面桥只回 localPath(Java 容器临时路径,双活组装可能读不到),作为兜底仍透传。
const files: UploadedFileRef[] = uploadedFiles.value.map((f) => ({
fileKey: f.fileKey,
originalFilename: f.originalFilename,
relativePath: f.relativePath,
fileUrl: f.url || f.localPath,
}))
const res = await runBrandNow(files, strategy.value)
if (res.success && res.task_id) {
ElMessage.success(`已创建任务 ${res.task_id},开始检测`)
} else {
throw new Error((res as { error?: string }).error || '运行失败')
}
uploadedFiles.value = []
await syncTasksAndResumePolling()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败')
} finally {
submitting.value = false
}
}
async function loadTasks() {
try {
const res = await getBrandTasks()
if (res.success && Array.isArray(res.items)) {
tasks.value = res.items
}
} catch (error) {
// 浏览器预览(无 uid / 无桌面后端)时静默,避免误报
if (hasBridge.value && hasUid.value) {
ElMessage.error(error instanceof Error ? error.message : '任务列表加载失败')
}
}
}
async function cancelTask(item: BrandTaskItem) {
try {
await ElMessageBox.confirm(`确认取消任务 ${item.id}`, '取消任务', { type: 'warning' })
} catch {
return
}
try {
const res = await cancelBrandTask(item.id)
if (!res.success) throw new Error((res as { error?: string }).error || '取消失败')
ElMessage.success('已取消')
await syncTasksAndResumePolling()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '取消失败')
}
}
async function deleteTask(item: BrandTaskItem) {
try {
await ElMessageBox.confirm(`确认删除任务 ${item.id}?删除后不可恢复。`, '删除任务', { type: 'warning' })
} catch {
return
}
try {
const res = await deleteBrandTask(item.id)
if (!res.success) throw new Error((res as { error?: string }).error || '删除失败')
ElMessage.success('已删除')
await syncTasksAndResumePolling()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function downloadResult(item: BrandTaskItem) {
const zipUrl = item.result_paths?.zip_url
const filename = `brand_task_${item.id}.zip`
const bridge = getPywebviewApi()
if (bridge?.save_file_from_url && zipUrl) {
try {
const res = await bridge.save_file_from_url(zipUrl, filename)
if (res.success) {
ElMessage.success(`已保存:${res.path || filename}`)
} else if (res.error && res.error !== '用户取消') {
ElMessage.error(res.error)
}
return
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败')
return
}
}
if (zipUrl) {
window.open(zipUrl, '_blank')
return
}
window.open(getBrandTaskDownloadUrl(item.id), '_blank')
}
async function downloadTemplate(kind: 'xlsx' | 'zip') {
const bridge = getPywebviewApi()
try {
let res: { success: boolean; path?: string; error?: string } | undefined
if (kind === 'xlsx') {
res = bridge?.save_template_xlsx ? await bridge.save_template_xlsx() : undefined
} else {
res = bridge?.save_template_zip ? await bridge.save_template_zip() : undefined
}
if (res?.success) {
ElMessage.success(`模板已保存至:${res.path}`)
} else if (res?.error) {
ElMessage.warning(res.error)
} else {
ElMessage.warning('当前环境不支持模板下载,请在本机客户端中打开')
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '模板下载失败')
}
}
onMounted(() => {
void syncTasksAndResumePolling()
})
onBeforeUnmount(() => {
stopPolling()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
const { total, failed } = await runBatchDelete(views, (view) => deleteTask(itemSource(view)))
if (!total) return
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${total} 条历史记录`)
}
}
</script>
<style scoped>
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 420px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #242424; display: flex; flex-direction: column; }
.section-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
.hint { margin-bottom: 12px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #242424; margin-bottom: 6px; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
.run-row { margin-top: 14px; }
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 7px; cursor: pointer; font-size: 12px; }
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.selected-files { margin-top: 12px; color: #a0acbe; font-size: 12px; word-break: break-all; }
.selected-files span { display: block; margin: 3px 0; }
.selected-files .more-line { color: #5e6878; }
.bridge-tip { margin-top: 10px; color: #e6a23c; font-size: 11px; }
.env-tip { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(230, 162, 60, 0.1); color: #e6a23c; font-size: 12px; line-height: 1.6; }
.radio-cards { display: flex; flex-direction: column; gap: 8px; margin-bottom: 6px; }
.radio-card { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #242424; cursor: pointer; }
.radio-card.selected { border-color: #409eff; background: rgba(64, 158, 255, 0.1); }
.radio-card input { position: absolute; opacity: 0; pointer-events: none; }
.radio-card-name { color: #c8d2e2; font-size: 13px; }
.radio-card-desc { color: #5e6878; font-size: 11px; }
.run-mode-zone { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #242424; }
.run-mode-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: #c8d2e2; font-size: 12px; }
.run-mode-row input { width: 15px; height: 15px; accent-color: #409eff; }
.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-actions-row { display: flex; justify-content: flex-end; padding: 10px 20px 0; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.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; }
}
</style>