fix: logviewer style

This commit is contained in:
engigu
2026-03-12 17:51:17 +08:00
parent bb001a2210
commit 3ebbef591c
7 changed files with 276 additions and 53 deletions
+38
View File
@@ -17,6 +17,7 @@
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"ansi-to-vue3": "^0.1.2",
"apexcharts": "^5.3.6", "apexcharts": "^5.3.6",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -800,6 +801,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/anser": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz",
"integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==",
"license": "MIT"
},
"node_modules/ansi-to-html": { "node_modules/ansi-to-html": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
@@ -824,6 +831,31 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/ansi-to-vue3": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ansi-to-vue3/-/ansi-to-vue3-0.1.2.tgz",
"integrity": "sha512-mkfWeVNBKfmpoWLeLqmAeKQalqvOyAhhMnndqu1oJZEzz8tn56mbUqJC7OfvB8cOEg70ooKjmgeIZoI0i9cdxw==",
"license": "MIT",
"workspaces": [
"playground"
],
"dependencies": {
"anser": "^2.1.1",
"escape-carriage": "^1.3.1"
},
"engines": {
"node": ">=16",
"npm": ">=7"
},
"peerDependencies": {
"vue": "^3.2.0"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
},
"node_modules/anymatch": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1034,6 +1066,12 @@
"@esbuild/win32-x64": "0.27.2" "@esbuild/win32-x64": "0.27.2"
} }
}, },
"node_modules/escape-carriage": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz",
"integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==",
"license": "MIT"
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"license": "MIT" "license": "MIT"
+1
View File
@@ -18,6 +18,7 @@
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"ansi-to-vue3": "^0.1.2",
"apexcharts": "^5.3.6", "apexcharts": "^5.3.6",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+140
View File
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
const lightTheme = {
background: '#f4f4f5', // zinc-100
foreground: '#18181b', // zinc-900
cursor: '#18181b',
selectionBackground: 'rgba(0, 0, 0, 0.15)',
}
const darkTheme = {
background: '#09090b', // zinc-950
foreground: '#e4e4e7', // zinc-200
cursor: '#e4e4e7',
selectionBackground: 'rgba(255, 255, 255, 0.2)',
}
const props = withDefaults(
defineProps<{
content: string
fontSize?: number
theme?: 'dark' | 'light'
autoScroll?: boolean
}>(),
{
fontSize: 12,
theme: 'dark',
autoScroll: true
}
)
const terminalRef = ref<HTMLDivElement | null>(null)
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
const lightBackgroundClass = 'terminal-theme-light'
const darkBackgroundClass = 'terminal-theme-dark'
function getTheme() {
return props.theme === 'dark' ? darkTheme : lightTheme
}
function initTerminal() {
if (!terminalRef.value) return
terminal = new Terminal({
fontSize: props.fontSize,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
theme: getTheme(),
allowProposedApi: true,
convertEol: true,
disableStdin: true,
cursorBlink: false,
rows: 10,
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(terminalRef.value)
if (props.content) {
terminal.write(props.content)
}
setTimeout(() => {
fitAddon?.fit()
}, 50)
}
watch(() => props.content, (newContent, oldContent) => {
if (!terminal) return
if (newContent.length < oldContent.length) {
terminal.clear()
terminal.write(newContent)
} else {
const appended = newContent.slice(oldContent.length)
terminal.write(appended)
}
if (props.autoScroll) {
terminal.scrollToBottom()
}
})
watch(() => props.theme, () => {
if (terminal) {
terminal.options.theme = getTheme()
}
})
function handleResize() {
fitAddon?.fit()
}
onMounted(() => {
window.addEventListener('resize', handleResize)
setTimeout(initTerminal, 100)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (terminal) {
terminal.dispose()
}
})
defineExpose({
fit: () => fitAddon?.fit(),
clear: () => terminal?.clear()
})
</script>
<template>
<div
ref="terminalRef"
class="w-full h-full min-h-0"
:class="theme === 'dark' ? darkBackgroundClass : lightBackgroundClass"
/>
</template>
<style scoped>
.terminal-theme-light {
background-color: #f4f4f5;
}
.terminal-theme-dark {
background-color: #09090b;
}
:deep(.xterm) {
padding: 8px;
}
:deep(.xterm-viewport),
:deep(.xterm-screen) {
background-color: inherit !important;
}
</style>
+11 -4
View File
@@ -1,8 +1,9 @@
import { ref, watch, onMounted } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
export type Theme = 'light' | 'dark' | 'system' export type Theme = 'light' | 'dark' | 'system'
const theme = ref<Theme>('system') const theme = ref<Theme>('system')
const systemTheme = ref<'light' | 'dark'>('light')
function getSystemTheme(): 'light' | 'dark' { function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
@@ -18,15 +19,16 @@ function applyTheme(t: Theme) {
export function useTheme() { export function useTheme() {
onMounted(() => { onMounted(() => {
// 从 localStorage 读取主题 systemTheme.value = getSystemTheme()
const saved = localStorage.getItem('theme') as Theme | null const saved = localStorage.getItem('theme') as Theme | null
if (saved && ['light', 'dark', 'system'].includes(saved)) { if (saved && ['light', 'dark', 'system'].includes(saved)) {
theme.value = saved theme.value = saved
} }
applyTheme(theme.value) applyTheme(theme.value)
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
systemTheme.value = getSystemTheme()
if (theme.value === 'system') { if (theme.value === 'system') {
applyTheme('system') applyTheme('system')
} }
@@ -38,12 +40,17 @@ export function useTheme() {
applyTheme(newTheme) applyTheme(newTheme)
}) })
const resolvedTheme = computed<'light' | 'dark'>(() => {
return theme.value === 'system' ? systemTheme.value : theme.value
})
function setTheme(t: Theme) { function setTheme(t: Theme) {
theme.value = t theme.value = t
} }
return { return {
theme, theme,
setTheme resolvedTheme,
setTheme,
} }
} }
+55 -18
View File
@@ -1,40 +1,77 @@
import AnsiUp from 'ansi-to-html' import AnsiUp from 'ansi-to-html'
const emojiMap: Record<string, string> = {
':success:': '✅',
':check:': '✅',
':done:': '✅',
':error:': '❌',
':fail:': '❌',
':x:': '❌',
':warn:': '⚠️',
':warning:': '⚠️',
':info:': '️',
':rocket:': '🚀',
':sparkles:': '✨',
':fire:': '🔥',
':bug:': '🐛',
':lock:': '🔒',
':link:': '🔗',
':memo:': '📝',
':bulb:': '💡',
':clock:': '🕒',
':finish:': '🏁',
':start:': '🛫',
':cloud:': '☁️',
':bell:': '🔔',
}
const ansiUp = new AnsiUp({ const ansiUp = new AnsiUp({
newline: false, newline: false,
escapeXML: true, escapeXML: true,
stream: false, stream: false,
colors: { colors: {
// 基础 16 色优化,使其在深色背景下更鲜艳 // Optimized 16-color palette for dark terminal background
0: '#000000', // Black 0: '#09090b', // Black (Zinc-950)
1: '#ef4444', // Red (Tailwind red-500) 1: '#f87171', // Red (Tailwind red-400)
2: '#22c55e', // Green (Tailwind green-500) 2: '#4ade80', // Green (Tailwind green-400)
3: '#eab308', // Yellow (Tailwind yellow-500) 3: '#fbbf24', // Yellow (Tailwind amber-400)
4: '#3b82f6', // Blue (Tailwind blue-500) 4: '#60a5fa', // Blue (Tailwind blue-400)
5: '#a855f7', // Magenta (Tailwind purple-500) 5: '#c084fc', // Magenta (Tailwind purple-400)
6: '#06b6d4', // Cyan (Tailwind cyan-500) 6: '#22d3ee', // Cyan (Tailwind cyan-400)
7: '#d4d4d8', // White (Tailwind zinc-300) 7: '#e4e4e7', // White (Zinc-200)
8: '#71717a', // Bright Black 8: '#71717a', // Bright Black (Zinc-500)
9: '#f87171', // Bright Red 9: '#ef4444', // Bright Red (Tailwind red-500)
10: '#4ade80', // Bright Green 10: '#22c55e', // Bright Green (Tailwind green-500)
11: '#facc15', // Bright Yellow 11: '#f59e0b', // Bright Yellow (Tailwind amber-500)
12: '#60a5fa', // Bright Blue 12: '#3b82f6', // Bright Blue (Tailwind blue-500)
13: '#c084fc', // Bright Magenta 13: '#a855f7', // Bright Magenta (Tailwind purple-500)
14: '#22d3ee', // Bright Cyan 14: '#06b6d4', // Bright Cyan (Tailwind cyan-500)
15: '#ffffff' // Bright White 15: '#ffffff' // Bright White
} }
}) })
/**
* Convert ANSI escape codes to HTML and parse emoji shortcodes
*/
export function ansiToHtml(ansi: string): string { export function ansiToHtml(ansi: string): string {
if (!ansi) return '' if (!ansi) return ''
return ansiUp.toHtml(ansi)
// Parse emoji shortcodes
let processed = ansi.replace(/:([a-z0-9_-]+):/g, (match) => {
return emojiMap[match] || match
})
return ansiUp.toHtml(processed)
} }
/**
* Highlight keywords in HTML content while avoiding HTML tags
*/
export function highlightHtml(html: string, keyword: string): string { export function highlightHtml(html: string, keyword: string): string {
if (!keyword.trim()) return html if (!keyword.trim()) return html
const escaped = keyword.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const escaped = keyword.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Match keyword but not inside HTML tags // Match keyword but not inside HTML tags
const regex = new RegExp(`(${escaped})(?![^<]*>)`, 'gi') const regex = new RegExp(`(${escaped})(?![^<]*>)`, 'gi')
return html.replace(regex, '<mark class="bg-yellow-300 text-black">$1</mark>') return html.replace(regex, '<mark class="bg-yellow-400/30 text-yellow-100 border-b border-yellow-400/50 px-0.5 rounded-sm transition-colors">$1</mark>')
} }
+17 -12
View File
@@ -6,11 +6,12 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import Pagination from '@/components/Pagination.vue' import Pagination from '@/components/Pagination.vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import LogViewer from './LogViewer.vue'
import { import {
RefreshCw, X, Search, Maximize2, GitBranch, Terminal, RefreshCw, X, Search, Maximize2, GitBranch, Terminal,
CheckCircle2, XCircle, AlertCircle, Ban, Clock, Zap as ZapIcon, Check, Trash2 CheckCircle2, XCircle, AlertCircle, Ban, Clock, Zap as ZapIcon, Check, Trash2
} from 'lucide-vue-next' } from 'lucide-vue-next'
import LogViewer from './LogViewer.vue'
import LogTerminal from '@/components/LogTerminal.vue'
import { api, type TaskLog } from '@/api' import { api, type TaskLog } from '@/api'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
@@ -26,6 +27,7 @@ import {
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings' import { useSiteSettings } from '@/composables/useSiteSettings'
import TextOverflow from '@/components/TextOverflow.vue' import TextOverflow from '@/components/TextOverflow.vue'
import { useTheme } from '@/composables/useTheme'
const route = useRoute() const route = useRoute()
const { pageSize } = useSiteSettings() const { pageSize } = useSiteSettings()
@@ -55,15 +57,10 @@ const wsContent = ref('')
const isWsLoading = ref(false) const isWsLoading = ref(false)
let logSocket: WebSocket | null = null let logSocket: WebSocket | null = null
const { resolvedTheme } = useTheme()
import { ansiToHtml } from '@/utils/ansi'
const decompressedOutput = computed(() => { const decompressedOutput = computed(() => {
return wsContent.value || '无输出' return wsContent.value
})
const renderedOutput = computed(() => {
return ansiToHtml(decompressedOutput.value)
}) })
async function loadLogs() { async function loadLogs() {
@@ -570,10 +567,18 @@ watch(() => route.query, (newQuery) => {
<Maximize2 class="h-3.5 w-3.5" /> <Maximize2 class="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
<div class="flex-1 overflow-auto bg-zinc-950 min-h-[160px]"> <div
<pre class="flex-1 overflow-hidden min-h-[160px]"
class="p-4 text-xs font-mono whitespace-pre-wrap break-all log-pre leading-relaxed text-zinc-300" v-html="renderedOutput"></pre> :class="resolvedTheme === 'dark' ? 'bg-zinc-950' : 'bg-zinc-100'"
<div v-if="isWsLoading" class="p-4 text-sm text-zinc-500 italic">连接中...</div> ref="sideLogContainer"
>
<LogTerminal
:content="decompressedOutput"
:theme="resolvedTheme"
/>
<div v-if="isWsLoading" class="px-4 py-2 text-sm text-zinc-500 italic border-t border-zinc-200 dark:border-zinc-800">
连接中...
</div>
</div> </div>
</div> </div>
</div> </div>
+14 -19
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue' import { watch, onUnmounted } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { X } from 'lucide-vue-next'
import { X, Search } from 'lucide-vue-next' import LogTerminal from '@/components/LogTerminal.vue'
import { useTheme } from '@/composables/useTheme'
const { resolvedTheme } = useTheme()
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -15,15 +18,8 @@ const emit = defineEmits<{
'update:open': [value: boolean] 'update:open': [value: boolean]
}>() }>()
const searchKeyword = ref('') const lightLogBackgroundClass = 'bg-zinc-100'
const darkLogBackgroundClass = 'bg-zinc-950'
import { ansiToHtml, highlightHtml } from '@/utils/ansi'
// 高亮搜索结果并处理 ANSI 颜色
const highlightedContent = computed(() => {
const html = ansiToHtml(props.content)
return highlightHtml(html, searchKeyword.value)
})
function close() { function close() {
emit('update:open', false) emit('update:open', false)
@@ -41,7 +37,6 @@ function toggleBodyScroll(lock: boolean) {
// 监听打开状态 // 监听打开状态
watch(() => props.open, (val) => { watch(() => props.open, (val) => {
if (val) { if (val) {
searchKeyword.value = ''
toggleBodyScroll(true) toggleBodyScroll(true)
} else { } else {
toggleBodyScroll(false) toggleBodyScroll(false)
@@ -78,19 +73,19 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="relative flex-1 sm:flex-none">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input v-model="searchKeyword" placeholder="搜索内容..." class="h-8 pl-9 w-full sm:w-56 text-sm" />
</div>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="close"> <Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="close">
<X class="h-4 w-4" /> <X class="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
<div class="flex-1 overflow-auto bg-zinc-950"> <div class="flex-1 overflow-hidden" :class="resolvedTheme === 'dark' ? darkLogBackgroundClass : lightLogBackgroundClass">
<pre class="p-3 sm:p-4 text-xs font-mono whitespace-pre-wrap break-all text-zinc-300" v-html="highlightedContent"></pre> <LogTerminal
:content="content"
:theme="resolvedTheme"
/>
</div> </div>
</div> </div>
</div> </div>
</Teleport> </Teleport>
</template> </template>