fix(web): optimize rendering performance of large text logs using xterm.js (#102)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user