fix: logviewer style
This commit is contained in:
Generated
+38
@@ -17,6 +17,7 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"ansi-to-vue3": "^0.1.2",
|
||||
"apexcharts": "^5.3.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -800,6 +801,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.7.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -1034,6 +1066,12 @@
|
||||
"@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": {
|
||||
"version": "2.0.2",
|
||||
"license": "MIT"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"ansi-to-vue3": "^0.1.2",
|
||||
"apexcharts": "^5.3.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user