feat(task-logs): 实现任务日志 SSE 实时推送
实现任务日志 SSE 实时推送功能,解决前端无法流式输出日志的问题 变更内容: - 后端:新增 /task-logs/stream SSE 端点,支持实时日志推送 - 后端:实现 2 秒轮询间隔检测新日志,支持心跳机制 - 后端:支持通过 query 参数 access_token 传递 JWT 认证 - 前端:新增 createTaskLogStream 函数封装 EventSource - 前端:修改 TaskCenter.vue 集成 SSE,支持实时更新和自动滚动 - 前端:添加流式连接状态指示器 关联 commit: - 实现后端 SSE Stream 方法 - 注册 SSE 路由 - 实现前端 SSE 客户端 - 集成 SSE 到任务中心页面
This commit is contained in:
@@ -79,3 +79,49 @@ function parseResponseBody(text: string) {
|
||||
return { error: text }
|
||||
}
|
||||
}
|
||||
|
||||
export interface TaskLogStreamCallbacks {
|
||||
onInit?: (lines: TaskLogLine[]) => void
|
||||
onUpdate?: (lines: TaskLogLine[]) => void
|
||||
onFinished?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function createTaskLogStream(taskId: string, callbacks: TaskLogStreamCallbacks): EventSource {
|
||||
const token = getToken()
|
||||
const url = `/auth/api/v1/task-logs/stream?task_id=${encodeURIComponent(taskId)}&access_token=${encodeURIComponent(token || '')}`
|
||||
|
||||
const es = new EventSource(url)
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
switch (data.type) {
|
||||
case 'init':
|
||||
callbacks.onInit?.(Array.isArray(data.lines) ? data.lines : [])
|
||||
break
|
||||
case 'update':
|
||||
callbacks.onUpdate?.(Array.isArray(data.lines) ? data.lines : [])
|
||||
break
|
||||
case 'finished':
|
||||
callbacks.onFinished?.()
|
||||
es.close()
|
||||
break
|
||||
case 'error':
|
||||
callbacks.onError?.(data.error || 'Unknown error')
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors for heartbeat etc.
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
// EventSource will auto-reconnect, but we report connection errors
|
||||
if (es.readyState === EventSource.CLOSED) {
|
||||
callbacks.onError?.('Connection closed')
|
||||
}
|
||||
}
|
||||
|
||||
return es
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<h3>
|
||||
<span>任务日志</span>
|
||||
<span class="selected-name">{{ selectedTaskName }}</span>
|
||||
<span v-if="isStreaming" class="streaming-indicator">● 实时更新中</span>
|
||||
</h3>
|
||||
<span class="meta">{{ selectedTaskMeta }}</span>
|
||||
</div>
|
||||
@@ -72,8 +73,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { taskLogApi, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog'
|
||||
import { computed, 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'
|
||||
|
||||
@@ -87,6 +88,8 @@ const logs = ref<TaskLogLine[]>([])
|
||||
const loadingTasks = ref(false)
|
||||
const loadingLogs = ref(false)
|
||||
const lastLoadedAt = ref<Date | null>(null)
|
||||
const isStreaming = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
|
||||
const selectedTaskName = computed(() => selectedTask.value?.name || '未选择')
|
||||
@@ -122,6 +125,8 @@ async function loadLogs(taskId: string) {
|
||||
const data = await taskLogApi.get(taskId)
|
||||
logs.value = data.lines
|
||||
lastLoadedAt.value = new Date()
|
||||
// 启动 SSE 流式更新
|
||||
startStream(taskId)
|
||||
} catch (error) {
|
||||
logs.value = [{ time: formatTime(new Date()), message: error instanceof Error ? error.message : '任务日志加载失败', class: 'err' }]
|
||||
} finally {
|
||||
@@ -129,10 +134,55 @@ async function loadLogs(taskId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function startStream(taskId: string) {
|
||||
stopStream() // 关闭之前的连接
|
||||
|
||||
eventSource = createTaskLogStream(taskId, {
|
||||
onInit: (lines) => {
|
||||
logs.value = lines
|
||||
lastLoadedAt.value = new Date()
|
||||
scrollToBottom()
|
||||
},
|
||||
onUpdate: (newLines) => {
|
||||
logs.value = [...logs.value, ...newLines]
|
||||
lastLoadedAt.value = new Date()
|
||||
scrollToBottom()
|
||||
},
|
||||
onFinished: () => {
|
||||
isStreaming.value = false
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('SSE error:', error)
|
||||
isStreaming.value = false
|
||||
},
|
||||
})
|
||||
|
||||
isStreaming.value = true
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
isStreaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
// 延迟滚动,等待 DOM 更新
|
||||
setTimeout(() => {
|
||||
const logStream = document.querySelector('.log-stream')
|
||||
if (logStream) {
|
||||
logStream.scrollTop = logStream.scrollHeight
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function selectTask(taskId: string) {
|
||||
if (selectedTaskId.value === taskId) {
|
||||
return
|
||||
}
|
||||
stopStream() // 停止之前的流
|
||||
selectedTaskId.value = taskId
|
||||
void loadLogs(taskId)
|
||||
}
|
||||
@@ -155,6 +205,10 @@ watch(
|
||||
onMounted(() => {
|
||||
void loadTasks()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStream()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -262,6 +316,17 @@ onMounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.streaming-indicator {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
max-height: calc(100vh - 210px);
|
||||
min-height: 520px;
|
||||
|
||||
Reference in New Issue
Block a user