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
+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'
const theme = ref<Theme>('system')
const systemTheme = ref<'light' | 'dark'>('light')
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
@@ -18,15 +19,16 @@ function applyTheme(t: Theme) {
export function useTheme() {
onMounted(() => {
// 从 localStorage 读取主题
systemTheme.value = getSystemTheme()
const saved = localStorage.getItem('theme') as Theme | null
if (saved && ['light', 'dark', 'system'].includes(saved)) {
theme.value = saved
}
applyTheme(theme.value)
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
systemTheme.value = getSystemTheme()
if (theme.value === 'system') {
applyTheme('system')
}
@@ -38,12 +40,17 @@ export function useTheme() {
applyTheme(newTheme)
})
const resolvedTheme = computed<'light' | 'dark'>(() => {
return theme.value === 'system' ? systemTheme.value : theme.value
})
function setTheme(t: Theme) {
theme.value = t
}
return {
theme,
setTheme
resolvedTheme,
setTheme,
}
}
+55 -18
View File
@@ -1,40 +1,77 @@
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({
newline: false,
escapeXML: true,
stream: false,
colors: {
// 基础 16 色优化,使其在深色背景下更鲜艳
0: '#000000', // Black
1: '#ef4444', // Red (Tailwind red-500)
2: '#22c55e', // Green (Tailwind green-500)
3: '#eab308', // Yellow (Tailwind yellow-500)
4: '#3b82f6', // Blue (Tailwind blue-500)
5: '#a855f7', // Magenta (Tailwind purple-500)
6: '#06b6d4', // Cyan (Tailwind cyan-500)
7: '#d4d4d8', // White (Tailwind zinc-300)
8: '#71717a', // Bright Black
9: '#f87171', // Bright Red
10: '#4ade80', // Bright Green
11: '#facc15', // Bright Yellow
12: '#60a5fa', // Bright Blue
13: '#c084fc', // Bright Magenta
14: '#22d3ee', // Bright Cyan
// Optimized 16-color palette for dark terminal background
0: '#09090b', // Black (Zinc-950)
1: '#f87171', // Red (Tailwind red-400)
2: '#4ade80', // Green (Tailwind green-400)
3: '#fbbf24', // Yellow (Tailwind amber-400)
4: '#60a5fa', // Blue (Tailwind blue-400)
5: '#c084fc', // Magenta (Tailwind purple-400)
6: '#22d3ee', // Cyan (Tailwind cyan-400)
7: '#e4e4e7', // White (Zinc-200)
8: '#71717a', // Bright Black (Zinc-500)
9: '#ef4444', // Bright Red (Tailwind red-500)
10: '#22c55e', // Bright Green (Tailwind green-500)
11: '#f59e0b', // Bright Yellow (Tailwind amber-500)
12: '#3b82f6', // Bright Blue (Tailwind blue-500)
13: '#a855f7', // Bright Magenta (Tailwind purple-500)
14: '#06b6d4', // Bright Cyan (Tailwind cyan-500)
15: '#ffffff' // Bright White
}
})
/**
* Convert ANSI escape codes to HTML and parse emoji shortcodes
*/
export function ansiToHtml(ansi: string): string {
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 {
if (!keyword.trim()) return html
const escaped = keyword.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Match keyword but not inside HTML tags
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 Pagination from '@/components/Pagination.vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import LogViewer from './LogViewer.vue'
import {
RefreshCw, X, Search, Maximize2, GitBranch, Terminal,
CheckCircle2, XCircle, AlertCircle, Ban, Clock, Zap as ZapIcon, Check, Trash2
} from 'lucide-vue-next'
import LogViewer from './LogViewer.vue'
import LogTerminal from '@/components/LogTerminal.vue'
import { api, type TaskLog } from '@/api'
import { Badge } from '@/components/ui/badge'
import {
@@ -26,6 +27,7 @@ import {
import { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings'
import TextOverflow from '@/components/TextOverflow.vue'
import { useTheme } from '@/composables/useTheme'
const route = useRoute()
const { pageSize } = useSiteSettings()
@@ -55,15 +57,10 @@ const wsContent = ref('')
const isWsLoading = ref(false)
let logSocket: WebSocket | null = null
import { ansiToHtml } from '@/utils/ansi'
const { resolvedTheme } = useTheme()
const decompressedOutput = computed(() => {
return wsContent.value || '无输出'
})
const renderedOutput = computed(() => {
return ansiToHtml(decompressedOutput.value)
return wsContent.value
})
async function loadLogs() {
@@ -570,10 +567,18 @@ watch(() => route.query, (newQuery) => {
<Maximize2 class="h-3.5 w-3.5" />
</Button>
</div>
<div class="flex-1 overflow-auto bg-zinc-950 min-h-[160px]">
<pre
class="p-4 text-xs font-mono whitespace-pre-wrap break-all log-pre leading-relaxed text-zinc-300" v-html="renderedOutput"></pre>
<div v-if="isWsLoading" class="p-4 text-sm text-zinc-500 italic">连接中...</div>
<div
class="flex-1 overflow-hidden min-h-[160px]"
:class="resolvedTheme === 'dark' ? 'bg-zinc-950' : 'bg-zinc-100'"
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>
+14 -19
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { watch, onUnmounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { X, Search } from 'lucide-vue-next'
import { X } from 'lucide-vue-next'
import LogTerminal from '@/components/LogTerminal.vue'
import { useTheme } from '@/composables/useTheme'
const { resolvedTheme } = useTheme()
const props = defineProps<{
open: boolean
@@ -15,15 +18,8 @@ const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const searchKeyword = ref('')
import { ansiToHtml, highlightHtml } from '@/utils/ansi'
// 高亮搜索结果并处理 ANSI 颜色
const highlightedContent = computed(() => {
const html = ansiToHtml(props.content)
return highlightHtml(html, searchKeyword.value)
})
const lightLogBackgroundClass = 'bg-zinc-100'
const darkLogBackgroundClass = 'bg-zinc-950'
function close() {
emit('update:open', false)
@@ -41,7 +37,6 @@ function toggleBodyScroll(lock: boolean) {
// 监听打开状态
watch(() => props.open, (val) => {
if (val) {
searchKeyword.value = ''
toggleBodyScroll(true)
} else {
toggleBodyScroll(false)
@@ -78,19 +73,19 @@ onUnmounted(() => {
</div>
</div>
<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">
<X class="h-4 w-4" />
</Button>
</div>
</div>
<div class="flex-1 overflow-auto bg-zinc-950">
<pre class="p-3 sm:p-4 text-xs font-mono whitespace-pre-wrap break-all text-zinc-300" v-html="highlightedContent"></pre>
<div class="flex-1 overflow-hidden" :class="resolvedTheme === 'dark' ? darkLogBackgroundClass : lightLogBackgroundClass">
<LogTerminal
:content="content"
:theme="resolvedTheme"
/>
</div>
</div>
</div>
</Teleport>
</template>