fix(web): optimize rendering performance of large text logs using xterm.js (#102)

This commit is contained in:
duorameng
2026-05-21 14:21:38 +08:00
parent 3988309f43
commit f132ebe287
+155 -15
View File
@@ -1,6 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { AlertCircle, Loader2 } from 'lucide-vue-next' import { AlertCircle, Loader2 } from 'lucide-vue-next'
import Ansi from 'ansi-to-vue3' import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { useTheme } from '@/composables/useTheme'
import '@xterm/xterm/css/xterm.css'
interface Props { interface Props {
content?: string content?: string
@@ -10,17 +14,136 @@ interface Props {
emptyDescription?: string emptyDescription?: string
} }
withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
content: '', content: '',
loading: false, loading: false,
loadingText: '正在获取日志内容', loadingText: '正在获取日志内容',
emptyTitle: '未检测到输出内容', emptyTitle: '未检测到输出内容',
emptyDescription: '此任务执行期间未产生标准输出(Stdout)或错误输出(Stderr)日志。' emptyDescription: '此任务执行期间未产生标准输出(Stdout)或错误输出(Stderr)日志。'
}) })
const terminalRef = ref<HTMLDivElement | null>(null)
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let resizeObserver: ResizeObserver | null = null
const { resolvedTheme } = useTheme()
let lastContentLength = 0
let lastContent = ''
function getTheme() {
const isDark = resolvedTheme.value === 'dark'
return {
background: isDark ? '#00000000' : '#e4e4e7',
foreground: isDark ? '#d4d4d4' : '#333333',
cursor: '#00000000',
selectionBackground: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.2)',
}
}
function initTerminal() {
if (!terminalRef.value) return
terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
fontSize: 12,
lineHeight: 1.15,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
theme: getTheme(),
allowTransparency: true,
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(terminalRef.value)
requestAnimationFrame(() => {
try {
fitAddon?.fit()
} catch (e) {}
})
if (props.content) {
writeContent(props.content)
}
}
function writeContent(content: string) {
if (!terminal) return
if (content.length > lastContentLength && content.substring(0, lastContentLength) === lastContent) {
// 增量更新:只截取新的部分
const diff = content.substring(lastContentLength)
// 替换换行符,因为 xterm 需要 \r\n 才能回到行首
const formattedDiff = diff.replace(/\r?\n/g, '\r\n')
terminal.write(formattedDiff)
} else {
// 全量更新
const formattedContent = content.replace(/\r?\n/g, '\r\n')
terminal.clear()
terminal.write(formattedContent)
}
lastContentLength = content.length
lastContent = content
}
watch(resolvedTheme, () => {
if (terminal) {
terminal.options.theme = getTheme()
}
})
watch(() => props.content, (newContent) => {
if (newContent) {
nextTick(() => {
writeContent(newContent)
})
} else {
terminal?.clear()
lastContentLength = 0
lastContent = ''
}
})
// 监听容器大小变化以适配 xterm
onMounted(() => {
if (terminalRef.value) {
resizeObserver = new ResizeObserver(() => {
try {
fitAddon?.fit()
} catch (e) {}
})
resizeObserver.observe(terminalRef.value)
}
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
terminal?.dispose()
terminal = null
})
// 当不显示 loading 且有 content 时,初始化 terminal
watch(
() => (!props.loading && props.content && props.content.trim()),
(shouldShow) => {
if (shouldShow && !terminal) {
nextTick(() => {
initTerminal()
})
}
},
{ immediate: true }
)
</script> </script>
<template> <template>
<div class="flex-1 flex flex-col h-full"> <div class="flex-1 flex flex-col h-full w-full relative">
<!-- 加载状态 --> <!-- 加载状态 -->
<template v-if="loading"> <template v-if="loading">
<div class="flex-1 flex flex-col items-center justify-center p-4 select-none text-center"> <div class="flex-1 flex flex-col items-center justify-center p-4 select-none text-center">
@@ -43,23 +166,40 @@ withDefaults(defineProps<Props>(), {
</template> </template>
<!-- 正常内容 --> <!-- 正常内容 -->
<template v-else> <div
<div class="p-4 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed"> v-show="!loading && content && content.trim()"
<Ansi>{{ content }}</Ansi> class="flex-1 w-full h-full p-2 overflow-hidden"
</div> >
</template> <div ref="terminalRef" class="w-full h-full log-terminal"></div>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
:deep(code) { .log-terminal :deep(.xterm) {
display: block; padding: 0.5rem;
padding: 0 !important;
margin: 0 !important;
background: transparent !important;
} }
:deep(span) { .log-terminal :deep(.xterm-viewport) {
vertical-align: top; scrollbar-width: thin;
scrollbar-color: rgba(150, 150, 150, 0.3) transparent;
}
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar-track) {
background: transparent;
}
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
background: rgba(150, 150, 150, 0.3);
border-radius: 4px;
}
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: rgba(150, 150, 150, 0.5);
} }
</style> </style>