diff --git a/frontend-vue/src/pages/brand/components/BrandBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandBrandTab.vue index bd3d0373..1c35788b 100644 --- a/frontend-vue/src/pages/brand/components/BrandBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandBrandTab.vue @@ -68,7 +68,7 @@
- +
行级进度快照(按“已处理品牌行 / 总品牌行”推进,成功或查询失败都计入完成数) */ +const lineProgressMap = ref>({}) +const BRAND_PHASE_TEXT: Record = { + 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() }) /** diff --git a/frontend-vue/src/shared/api/types/modules/brand.ts b/frontend-vue/src/shared/api/types/modules/brand.ts index 148f2d38..f71ffe0e 100644 --- a/frontend-vue/src/shared/api/types/modules/brand.ts +++ b/frontend-vue/src/shared/api/types/modules/brand.ts @@ -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 }