diff --git a/web/package-lock.json b/web/package-lock.json index a53e1c5..d382e84 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/package.json b/web/package.json index 9c64bdd..5ff9d86 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/LogTerminal.vue b/web/src/components/LogTerminal.vue new file mode 100644 index 0000000..a4972fd --- /dev/null +++ b/web/src/components/LogTerminal.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts index 5cf0ba1..5c8e1d7 100644 --- a/web/src/composables/useTheme.ts +++ b/web/src/composables/useTheme.ts @@ -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('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, } } diff --git a/web/src/utils/ansi.ts b/web/src/utils/ansi.ts index 79812e2..9d5d244 100644 --- a/web/src/utils/ansi.ts +++ b/web/src/utils/ansi.ts @@ -1,40 +1,77 @@ import AnsiUp from 'ansi-to-html' +const emojiMap: Record = { + ':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, '$1') + return html.replace(regex, '$1') } diff --git a/web/src/views/history/History.vue b/web/src/views/history/History.vue index 07c8ae8..8216a85 100644 --- a/web/src/views/history/History.vue +++ b/web/src/views/history/History.vue @@ -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) => { -
-

-            
连接中...
+
+ +
+ 连接中... +
diff --git a/web/src/views/history/LogViewer.vue b/web/src/views/history/LogViewer.vue index 6cc3af0..3d00932 100644 --- a/web/src/views/history/LogViewer.vue +++ b/web/src/views/history/LogViewer.vue @@ -1,8 +1,11 @@