Files
xinfra/frontend/src/views/task/TaskCenter.vue
T

483 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="page-head">
<div>
<h1>任务中心</h1>
<p>AWX 交付任务与 Wayne 部署服务的执行记录</p>
</div>
<div class="task-filters">
<el-select v-model="sourceFilter" size="small" class="filter-select" @change="loadTasks">
<el-option label="全部来源" value="all" />
<el-option label="AWX" value="awx" />
<el-option label="Wayne" value="wayne" />
</el-select>
<el-button size="small" :loading="loadingTasks || loadingLogs" @click="refreshCurrent">刷新</el-button>
</div>
</div>
<div class="task-layout">
<div class="panel task-list-panel">
<div class="panel-head">
<h3>任务列表</h3>
<span class="meta">{{ lastLoadedText }}</span>
</div>
<div class="panel-body">
<div class="task-list">
<button
v-for="task in tasks"
:key="task.id"
type="button"
class="task-row"
:class="{ active: task.id === selectedTaskId }"
@click="selectTask(task.id)"
>
<span :class="['task-status', task.status_class]">● {{ task.status_text }}</span>
<span class="task-main">
<span class="task-name">{{ task.name }}</span>
<span class="task-runner mono">{{ task.runner }}</span>
</span>
</button>
</div>
<div v-if="!loadingTasks && !tasks.length" class="empty-state">
暂无任务记录
</div>
<div class="pagination">
<span>共 {{ tasks.length }} 条 · 当前业务线:{{ currentName }}</span>
</div>
</div>
</div>
<div class="panel task-log-panel">
<div class="panel-head">
<h3>
<span>任务日志</span>
<span class="selected-name">{{ selectedTaskName }}</span>
<span v-if="isStreaming" class="streaming-indicator">● 实时更新中</span>
</h3>
<div class="panel-actions">
<el-switch
v-model="autoScroll"
active-text="自动滚动"
class="auto-scroll-switch"
/>
<span class="meta">{{ selectedTaskMeta }}</span>
</div>
</div>
<div
ref="logContainerRef"
class="panel-body log-stream"
@scroll="handleScroll"
>
<!-- 虚拟滚动容器 -->
<div
:style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }"
>
<div
v-for="virtualRow in virtualizer.getVirtualItems()"
:key="String(virtualRow.key)"
:class="['task-log-line', logs[virtualRow.index].class]"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}"
>
<span class="t">{{ logs[virtualRow.index].time }}</span>{{ logs[virtualRow.index].message }}
</div>
</div>
<div v-if="loadingLogs" class="task-log-line">
<span class="t">...</span>加载中
</div>
<div v-if="!loadingLogs && !logs.length" class="empty-state">
请选择任务查看日志
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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()
const sourceFilter = ref('all')
const selectedTaskId = ref('')
const tasks = ref<TaskLogSummary[]>([])
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 autoScroll = ref(true)
const logContainerRef = ref<HTMLDivElement | null>(null)
// 虚拟滚动配置
const virtualizer = useVirtualizer({
count: logs.value.length,
getScrollElement: () => logContainerRef.value,
estimateSize: () => 20,
overscan: 5,
})
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
const selectedTaskName = computed(() => selectedTask.value?.name || '未选择')
const selectedTaskMeta = computed(() => selectedTask.value ? `${selectedTask.value.source.toUpperCase()} · ${selectedTask.value.runner}` : '接口日志')
const lastLoadedText = computed(() => lastLoadedAt.value ? `更新于 ${formatTime(lastLoadedAt.value)}` : '')
async function loadTasks() {
loadingTasks.value = true
try {
tasks.value = await taskLogApi.list({
source: sourceFilter.value,
businessLineId: businessLineStore.current?.id,
})
lastLoadedAt.value = new Date()
if (!tasks.value.some((task) => task.id === selectedTaskId.value)) {
selectedTaskId.value = tasks.value[0]?.id || ''
}
if (selectedTaskId.value) {
await loadLogs(selectedTaskId.value)
} else {
logs.value = []
}
} catch (error) {
logs.value = [{ time: formatTime(new Date()), message: error instanceof Error ? error.message : '任务日志加载失败', class: 'err' }]
} finally {
loadingTasks.value = false
}
}
async function loadLogs(taskId: string) {
loadingLogs.value = true
try {
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 {
loadingLogs.value = false
}
}
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() {
if (!autoScroll.value) return
nextTick(() => {
if (logContainerRef.value) {
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
}
})
}
function handleScroll() {
if (!logContainerRef.value) return
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.value
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 距离底部小于 50px 时认为在底部,恢复自动滚动
if (distanceFromBottom < 50) {
autoScroll.value = true
} else if (distanceFromBottom > 100) {
// 用户向上滚动超过 100px 时暂停自动滚动
autoScroll.value = false
}
}
function selectTask(taskId: string) {
if (selectedTaskId.value === taskId) {
return
}
stopStream() // 停止之前的流
selectedTaskId.value = taskId
void loadLogs(taskId)
}
function scrollToBottom() {
if (!autoScroll.value) return
nextTick(() => {
if (logContainerRef.value) {
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
}
})
}
function handleScroll() {
if (!logContainerRef.value) return
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.value
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 距离底部小于 50px 时认为在底部,恢复自动滚动
if (distanceFromBottom < 50) {
autoScroll.value = true
} else if (distanceFromBottom > 100) {
// 用户向上滚动超过 100px 时暂停自动滚动
autoScroll.value = false
}
}
function refreshCurrent() {
void loadTasks()
}
function formatTime(date: Date) {
return date.toLocaleTimeString('zh-CN', { hour12: false })
}
watch(
() => businessLineStore.current?.id,
() => {
void loadTasks()
},
)
onMounted(() => {
void loadTasks()
})
onUnmounted(() => {
stopStream()
})
</script>
<style scoped>
/* 公共样式已在 global.css 中定义 */
.task-layout {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.task-list-panel,
.task-log-panel {
min-width: 0;
}
.task-list-panel .panel-body {
padding-top: 0;
}
.task-list {
max-height: calc(100vh - 245px);
min-height: 360px;
overflow-y: auto;
}
.task-row {
width: 100%;
min-height: 62px;
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 10px 16px;
border: 0;
border-bottom: 1px solid var(--line-soft);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.task-row:hover,
.task-row.active {
background: var(--bg-panel-2);
}
.task-status {
font-size: 12px;
white-space: nowrap;
}
.task-status.ok {
color: var(--accent);
}
.task-status.warn {
color: var(--warn);
}
.task-status.err {
color: var(--err);
}
.task-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.task-name,
.task-runner,
.selected-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-name {
color: var(--text-hi);
font-size: 12.5px;
font-weight: 600;
}
.task-runner {
color: var(--text-dim);
font-size: 11px;
}
.task-log-panel .panel-head {
gap: 14px;
flex-wrap: wrap;
}
.task-log-panel .panel-head h3 {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.task-log-panel .panel-head .panel-actions {
margin-left: auto;
}
.selected-name {
color: var(--text-dim);
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; }
}
.panel-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auto-scroll-switch {
--el-switch-on-color: var(--accent);
}
.log-stream {
max-height: calc(100vh - 210px);
min-height: 520px;
overflow-y: auto;
overflow-x: auto;
}
.task-filters {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.filter-select {
width: 160px;
}
.empty-state {
padding: 18px 0;
color: var(--text-dim);
font-size: 13px;
}
@media (max-width: 1180px) {
.task-layout {
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
}
}
@media (max-width: 1024px) {
.task-layout {
grid-template-columns: 1fr;
}
.task-list,
.log-stream {
max-height: none;
}
.log-stream {
min-height: 420px;
}
}
@media (max-width: 640px) {
.filter-select {
width: 100%;
}
.task-filters {
width: 100%;
}
.task-row {
grid-template-columns: 64px minmax(0, 1fr);
padding: 10px 12px;
}
}
</style>