feat(brand): 品牌检测任务统一轮询驱动并展示行级实时进度
任务面板状态改为独立轮询(getTaskPollIntervalMs 节奏),刷新/提交/取消/删除后 按是否有运行中任务自动续轮询或停表;运行中任务通过详情接口拉取 Redis 行级进度, 进度按"已处理品牌行/总行(含查询失败)"推进并展示 crawling/assembling/uploading 阶段文案
This commit is contained in:
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
<section class="right-panel">
|
<section class="right-panel">
|
||||||
<div class="panel-actions-row">
|
<div class="panel-actions-row">
|
||||||
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
|
<button type="button" class="btn-refresh" @click="syncTasksAndResumePolling">刷新任务状态</button>
|
||||||
</div>
|
</div>
|
||||||
<TaskCenterPanel
|
<TaskCenterPanel
|
||||||
:on-batch-delete="batchDeleteHistory"
|
:on-batch-delete="batchDeleteHistory"
|
||||||
@@ -113,6 +113,7 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
|||||||
import {
|
import {
|
||||||
cancelBrandTask,
|
cancelBrandTask,
|
||||||
deleteBrandTask,
|
deleteBrandTask,
|
||||||
|
getBrandTask,
|
||||||
getBrandTaskDownloadUrl,
|
getBrandTaskDownloadUrl,
|
||||||
getBrandTasks,
|
getBrandTasks,
|
||||||
runBrandNow,
|
runBrandNow,
|
||||||
@@ -120,6 +121,8 @@ import {
|
|||||||
} from '@/shared/api/brand'
|
} from '@/shared/api/brand'
|
||||||
import { checkSelectedFiles, EXCEL_EXTENSIONS } from '@/shared/dispatch-guard.ts'
|
import { checkSelectedFiles, EXCEL_EXTENSIONS } from '@/shared/dispatch-guard.ts'
|
||||||
import { passGuard } from '@/shared/dispatch-guard-ui'
|
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 { UploadFileVo } from '@/shared/api/upload.ts'
|
||||||
import type { UploadedFileRef } from '@/shared/api/types/upload.ts'
|
import type { UploadedFileRef } from '@/shared/api/types/upload.ts'
|
||||||
import type { BrandExpandFolderItem } from '@/shared/api/types/modules/brand'
|
import type { BrandExpandFolderItem } from '@/shared/api/types/modules/brand'
|
||||||
@@ -147,8 +150,79 @@ function baseName(path: string) {
|
|||||||
return path.split(/[\\/]/).pop() || path
|
return path.split(/[\\/]/).pop() || path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timers = createCategorizedTimers('brand-brand-task')
|
||||||
let pollTimer: number | null = null
|
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) {
|
function isRunning(item: BrandTaskItem) {
|
||||||
return (item.status || '').toLowerCase() === 'running'
|
return (item.status || '').toLowerCase() === 'running'
|
||||||
@@ -227,8 +301,33 @@ function itemSource(item: TaskItemView): BrandTaskItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toBrandTaskView(item: BrandTaskItem): TaskItemView {
|
function toBrandTaskView(item: BrandTaskItem): TaskItemView {
|
||||||
const total = Number(item.progress_total) || 0
|
const totalFiles = Number(item.progress_total) || 0
|
||||||
const current = Number(item.progress_current) || 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 {
|
return {
|
||||||
key: `brand-${item.id}`,
|
key: `brand-${item.id}`,
|
||||||
title: (item.desc || '').trim() || '品牌检测',
|
title: (item.desc || '').trim() || '品牌检测',
|
||||||
@@ -237,17 +336,8 @@ function toBrandTaskView(item: BrandTaskItem): TaskItemView {
|
|||||||
finishedAt: isTerminal(item) ? formatDateTime(item.updated_at) : '',
|
finishedAt: isTerminal(item) ? formatDateTime(item.updated_at) : '',
|
||||||
statusText: statusText(item.status),
|
statusText: statusText(item.status),
|
||||||
statusClass: statusClass(item.status),
|
statusClass: statusClass(item.status),
|
||||||
extraLines: [
|
extraLines,
|
||||||
...(item.file_paths?.length ? [`文件:${item.file_paths.length} 个`] : []),
|
progress,
|
||||||
...(errorMessage(item) ? [`错误:${errorMessage(item)}`] : []),
|
|
||||||
],
|
|
||||||
progress: isRunning(item)
|
|
||||||
? {
|
|
||||||
percent: taskProgress(item),
|
|
||||||
stage: '任务进度',
|
|
||||||
countLabel: total > 0 ? `${current}/${total}` : undefined,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
source: item,
|
source: item,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,7 +450,7 @@ async function submitRun() {
|
|||||||
throw new Error((res as { error?: string }).error || '运行失败')
|
throw new Error((res as { error?: string }).error || '运行失败')
|
||||||
}
|
}
|
||||||
uploadedFiles.value = []
|
uploadedFiles.value = []
|
||||||
await loadTasks()
|
await syncTasksAndResumePolling()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '提交失败')
|
ElMessage.error(error instanceof Error ? error.message : '提交失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -392,7 +482,7 @@ async function cancelTask(item: BrandTaskItem) {
|
|||||||
const res = await cancelBrandTask(item.id)
|
const res = await cancelBrandTask(item.id)
|
||||||
if (!res.success) throw new Error((res as { error?: string }).error || '取消失败')
|
if (!res.success) throw new Error((res as { error?: string }).error || '取消失败')
|
||||||
ElMessage.success('已取消')
|
ElMessage.success('已取消')
|
||||||
await loadTasks()
|
await syncTasksAndResumePolling()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '取消失败')
|
ElMessage.error(error instanceof Error ? error.message : '取消失败')
|
||||||
}
|
}
|
||||||
@@ -408,7 +498,7 @@ async function deleteTask(item: BrandTaskItem) {
|
|||||||
const res = await deleteBrandTask(item.id)
|
const res = await deleteBrandTask(item.id)
|
||||||
if (!res.success) throw new Error((res as { error?: string }).error || '删除失败')
|
if (!res.success) throw new Error((res as { error?: string }).error || '删除失败')
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
await loadTasks()
|
await syncTasksAndResumePolling()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
}
|
}
|
||||||
@@ -461,14 +551,11 @@ async function downloadTemplate(kind: 'xlsx' | 'zip') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadTasks()
|
void syncTasksAndResumePolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (pollTimer) {
|
stopPolling()
|
||||||
window.clearTimeout(pollTimer)
|
|
||||||
pollTimer = null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -45,9 +45,28 @@ export interface BrandTaskListResponse {
|
|||||||
error?: string
|
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 {
|
export interface BrandTaskDetailResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
task?: BrandTaskItem
|
task?: BrandTaskItem
|
||||||
|
line_progress?: BrandLineProgress
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user