Files
xinfra/frontend/src/views/task/TaskCenter.vue
T
wonder 221d7dcc95 feat(task-logs): 优化任务中心日志前端展示
- 引入 @tanstack/vue-virtual 实现虚拟滚动,仅渲染可见区域日志行
- 添加自动滚动开关,用户可手动控制是否自动滚动
- 实现用户滚动检测,向上滚动时暂停自动滚动,滚到底部时恢复
- 优化日志面板头部布局,添加自动滚动开关 UI
2026-07-28 17:23:03 +08:00

407 lines
9.5 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>
</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, ref, watch } from 'vue'
import { taskLogApi, 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 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()
scrollToBottom()
} catch (error) {
logs.value = [{ time: formatTime(new Date()), message: error instanceof Error ? error.message : '任务日志加载失败', class: 'err' }]
} finally {
loadingLogs.value = false
}
}
function selectTask(taskId: string) {
if (selectedTaskId.value === taskId) {
return
}
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()
})
</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;
}
.task-log-panel .panel-head h3 {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.selected-name {
color: var(--text-dim);
font-weight: 500;
}
.panel-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auto-scroll-switch {
--el-switch-on-color: var(--accent);
}
.task-log-panel .panel-head {
flex-wrap: wrap;
}
.task-log-panel .panel-head .panel-actions {
margin-left: auto;
}
.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>