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:
@@ -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