From dbb930d0cb128d5cdea9ac4bd7582c00945e57fc Mon Sep 17 00:00:00 2001 From: hezhaohui Date: Wed, 29 Jul 2026 14:01:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BC=98=E5=8C=96=20task=20lo?= =?UTF-8?q?g=20=E5=89=8D=E7=AB=AF=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 简化虚拟滚动实现,移除 @tanstack/vue-virtual 依赖 - 添加分页组件,支持翻页查看历史任务 - 添加 SSE 指数退避重试机制(最多 3 次) - 更新 API 接口支持分页参数 - 添加重试状态指示器 背景:前端存在虚拟滚动与 SSE 不兼容、缺少分页、连接断开无重试等问题 关联 commit:fix/logs 分支 --- frontend/src/api/taskLog.ts | 28 +++++- frontend/src/views/task/TaskCenter.vue | 113 +++++++++++++++++++------ 2 files changed, 112 insertions(+), 29 deletions(-) diff --git a/frontend/src/api/taskLog.ts b/frontend/src/api/taskLog.ts index 4791b15..c4505d9 100644 --- a/frontend/src/api/taskLog.ts +++ b/frontend/src/api/taskLog.ts @@ -24,10 +24,20 @@ export interface TaskLogLine { export interface TaskLogListParams { source?: string businessLineId?: number + page?: number + pageSize?: number +} + +export interface TaskLogListResult { + items: TaskLogSummary[] + total: number + page: number + page_size: number + total_pages: number } export const taskLogApi = { - async list(params: TaskLogListParams = {}): Promise { + async list(params: TaskLogListParams = {}): Promise { const query = new URLSearchParams() if (params.source && params.source !== 'all') { query.set('source', params.source) @@ -35,9 +45,21 @@ export const taskLogApi = { if (params.businessLineId) { query.set('business_line_id', String(params.businessLineId)) } + if (params.page) { + query.set('page', String(params.page)) + } + if (params.pageSize) { + query.set('page_size', String(params.pageSize)) + } const suffix = query.toString() ? `?${query.toString()}` : '' const data = await authRequest(`/auth/api/v1/task-logs${suffix}`) - return Array.isArray(data.items) ? data.items : [] + return { + items: Array.isArray(data.items) ? data.items : [], + total: data.total || 0, + page: data.page || 1, + page_size: data.page_size || 20, + total_pages: data.total_pages || 0, + } }, async get(id: string): Promise<{ task: TaskLogSummary; lines: TaskLogLine[] }> { @@ -89,6 +111,8 @@ export interface TaskLogStreamCallbacks { export function createTaskLogStream(taskId: string, callbacks: TaskLogStreamCallbacks): EventSource { const token = getToken() + // 注意:EventSource 不支持自定义请求头,只能通过 query parameter 传递 token + // TODO: 在生产环境中,应考虑使用 WebSocket 或 HttpOnly Cookie 方式以提高安全性 const url = `/auth/api/v1/task-logs/stream?task_id=${encodeURIComponent(taskId)}&access_token=${encodeURIComponent(token || '')}` const es = new EventSource(url) diff --git a/frontend/src/views/task/TaskCenter.vue b/frontend/src/views/task/TaskCenter.vue index 402ac67..c68f673 100644 --- a/frontend/src/views/task/TaskCenter.vue +++ b/frontend/src/views/task/TaskCenter.vue @@ -42,7 +42,11 @@ 暂无任务记录 @@ -53,6 +57,7 @@ 任务日志 {{ selectedTaskName }} ● 实时更新中 + 重试中 ({{ streamRetryCount }}/3)
- +
-
- {{ logs[virtualRow.index].time }}{{ logs[virtualRow.index].message }} -
+ {{ log.time }}{{ log.message }}
...加载中 @@ -104,7 +98,6 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { taskLogApi, createTaskLogStream, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog' import { useBusinessLineStore } from '@/stores/businessLine' import { useBusinessLineMockProfile } from '@/utils/businessLineMock' -import { useVirtualizer } from '@tanstack/vue-virtual' const { currentName } = useBusinessLineMockProfile() const businessLineStore = useBusinessLineStore() @@ -121,13 +114,16 @@ let eventSource: EventSource | null = null const autoScroll = ref(true) const logContainerRef = ref(null) -// 虚拟滚动配置 -const virtualizer = useVirtualizer({ - count: logs.value.length, - getScrollElement: () => logContainerRef.value, - estimateSize: () => 20, - overscan: 5, -}) +// 分页相关 +const currentPage = ref(1) +const pageSize = ref(20) +const totalTasks = ref(0) +const totalPages = computed(() => Math.ceil(totalTasks.value / pageSize.value)) + +// SSE 重试相关 +const streamRetryCount = ref(0) +const maxRetryCount = 3 +let retryTimeout: ReturnType | null = null const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value)) const selectedTaskName = computed(() => selectedTask.value?.name || '未选择') @@ -137,10 +133,14 @@ const lastLoadedText = computed(() => lastLoadedAt.value ? `更新于 ${formatTi async function loadTasks() { loadingTasks.value = true try { - tasks.value = await taskLogApi.list({ + const result = await taskLogApi.list({ source: sourceFilter.value, businessLineId: businessLineStore.current?.id, + page: currentPage.value, + pageSize: pageSize.value, }) + tasks.value = result.items + totalTasks.value = result.total lastLoadedAt.value = new Date() if (!tasks.value.some((task) => task.id === selectedTaskId.value)) { selectedTaskId.value = tasks.value[0]?.id || '' @@ -163,6 +163,7 @@ async function loadLogs(taskId: string) { const data = await taskLogApi.get(taskId) logs.value = data.lines lastLoadedAt.value = new Date() + scrollToBottom() // 启动 SSE 流式更新 startStream(taskId) } catch (error) { @@ -174,6 +175,7 @@ async function loadLogs(taskId: string) { function startStream(taskId: string) { stopStream() // 关闭之前的连接 + streamRetryCount.value = 0 eventSource = createTaskLogStream(taskId, { onInit: (lines) => { @@ -182,23 +184,45 @@ function startStream(taskId: string) { scrollToBottom() }, onUpdate: (newLines) => { - logs.value = [...logs.value, ...newLines] + logs.value = newLines lastLoadedAt.value = new Date() scrollToBottom() }, onFinished: () => { isStreaming.value = false + streamRetryCount.value = 0 }, onError: (error) => { console.error('SSE error:', error) isStreaming.value = false + // 尝试重连 + retryStream(taskId) }, }) isStreaming.value = true } +function retryStream(taskId: string) { + if (streamRetryCount.value >= maxRetryCount) { + console.error('Max retry count reached') + return + } + + streamRetryCount.value++ + // 指数退避:1s, 2s, 4s + const delay = Math.pow(2, streamRetryCount.value - 1) * 1000 + + retryTimeout = setTimeout(() => { + startStream(taskId) + }, delay) +} + function stopStream() { + if (retryTimeout) { + clearTimeout(retryTimeout) + retryTimeout = null + } if (eventSource) { eventSource.close() eventSource = null @@ -244,6 +268,20 @@ function refreshCurrent() { void loadTasks() } +function prevPage() { + if (currentPage.value > 1) { + currentPage.value-- + void loadTasks() + } +} + +function nextPage() { + if (currentPage.value < totalPages.value) { + currentPage.value++ + void loadTasks() + } +} + function formatTime(date: Date) { return date.toLocaleTimeString('zh-CN', { hour12: false }) } @@ -251,6 +289,7 @@ function formatTime(date: Date) { watch( () => businessLineStore.current?.id, () => { + currentPage.value = 1 void loadTasks() }, ) @@ -419,6 +458,26 @@ onUnmounted(() => { font-size: 13px; } +.pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + font-size: 12px; + color: var(--text-dim); +} + +.pagination-actions { + display: flex; + gap: 8px; +} + +.retry-indicator { + font-size: 11px; + color: var(--warn); + animation: pulse 1s ease-in-out infinite; +} + @media (max-width: 1180px) { .task-layout { grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);