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 @@