feat(brand): 品牌检测任务统一轮询驱动并展示行级实时进度

任务面板状态改为独立轮询(getTaskPollIntervalMs 节奏),刷新/提交/取消/删除后
按是否有运行中任务自动续轮询或停表;运行中任务通过详情接口拉取 Redis 行级进度,
进度按"已处理品牌行/总行(含查询失败)"推进并展示 crawling/assembling/uploading 阶段文案
This commit is contained in:
2026-09-08 22:52:49 +08:00
parent 3f599547e6
commit 5fad0ae027
2 changed files with 128 additions and 22 deletions
@@ -68,7 +68,7 @@
<section class="right-panel">
<div class="panel-actions-row">
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
<button type="button" class="btn-refresh" @click="syncTasksAndResumePolling">刷新任务状态</button>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
@@ -113,6 +113,7 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
import {
cancelBrandTask,
deleteBrandTask,
getBrandTask,
getBrandTaskDownloadUrl,
getBrandTasks,
runBrandNow,
@@ -120,6 +121,8 @@ import {
} 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 type { BrandExpandFolderItem } from '@/shared/api/types/modules/brand'
@@ -147,8 +150,79 @@ 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'
@@ -227,8 +301,33 @@ function itemSource(item: TaskItemView): BrandTaskItem {
}
function toBrandTaskView(item: BrandTaskItem): TaskItemView {
const total = Number(item.progress_total) || 0
const current = Number(item.progress_current) || 0
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() || '品牌检测',
@@ -237,17 +336,8 @@ function toBrandTaskView(item: BrandTaskItem): TaskItemView {
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,
extraLines,
progress,
source: item,
}
}
@@ -360,7 +450,7 @@ async function submitRun() {
throw new Error((res as { error?: string }).error || '运行失败')
}
uploadedFiles.value = []
await loadTasks()
await syncTasksAndResumePolling()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败')
} finally {
@@ -392,7 +482,7 @@ async function cancelTask(item: BrandTaskItem) {
const res = await cancelBrandTask(item.id)
if (!res.success) throw new Error((res as { error?: string }).error || '取消失败')
ElMessage.success('已取消')
await loadTasks()
await syncTasksAndResumePolling()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '取消失败')
}
@@ -408,7 +498,7 @@ async function deleteTask(item: BrandTaskItem) {
const res = await deleteBrandTask(item.id)
if (!res.success) throw new Error((res as { error?: string }).error || '删除失败')
ElMessage.success('已删除')
await loadTasks()
await syncTasksAndResumePolling()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
@@ -461,14 +551,11 @@ async function downloadTemplate(kind: 'xlsx' | 'zip') {
}
onMounted(() => {
void loadTasks()
void syncTasksAndResumePolling()
})
onBeforeUnmount(() => {
if (pollTimer) {
window.clearTimeout(pollTimer)
pollTimer = null
}
stopPolling()
})
/**
@@ -45,9 +45,28 @@ export interface BrandTaskListResponse {
error?: string
}
/** Java 详情接口返回的行级进度(Redis 实时进度,每提交一个分片推进 current_line */
export interface BrandLineProgressInfo {
file_index?: number
file_total?: number
file_name?: string
/** 已处理行/品牌数 */
current_line?: number
/** 总行/品牌数 */
total_lines?: number
/** crawling / assembling / uploading / failed */
phase?: string
}
export interface BrandLineProgress {
has_progress?: boolean
info?: BrandLineProgressInfo
}
export interface BrandTaskDetailResponse {
success: boolean
task?: BrandTaskItem
line_progress?: BrandLineProgress
error?: string
}