feat(frontend): Add ANSI color support for system logs

- Create AnsiLogViewer component using xterm.js
- Replace Monaco Editor with xterm for log viewing
- Native ANSI escape code rendering (colors, bold, etc.)
- Auto-scroll to bottom, clickable URLs support

Benefits:
- Colorized logs for better readability
- No more escape codes like [32m[0m in UI
- Professional terminal-like experience
This commit is contained in:
yyhuni
2025-12-30 17:39:12 +08:00
parent 2a31e29aa2
commit 8ee76eef69
3 changed files with 114 additions and 40 deletions

View File

@@ -0,0 +1,103 @@
"use client"
import { useEffect, useRef } from "react"
import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
import { WebLinksAddon } from "@xterm/addon-web-links"
import "@xterm/xterm/css/xterm.css"
interface AnsiLogViewerProps {
content: string
className?: string
}
export function AnsiLogViewer({ content, className }: AnsiLogViewerProps) {
const terminalRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
useEffect(() => {
if (!terminalRef.current) return
// 创建 Terminal 实例
const terminal = new Terminal({
fontSize: 12,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
},
rows: 30,
scrollback: 10000,
convertEol: true,
disableStdin: true, // 只读模式
cursorBlink: false,
})
// 添加插件
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
// 挂载到 DOM
terminal.open(terminalRef.current)
fitAddon.fit()
// 保存引用
xtermRef.current = terminal
fitAddonRef.current = fitAddon
// 监听窗口大小变化
const handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize)
return () => {
window.removeEventListener("resize", handleResize)
terminal.dispose()
}
}, [])
// 更新日志内容
useEffect(() => {
const terminal = xtermRef.current
if (!terminal || !content) return
// 清空终端
terminal.clear()
// 写入新内容
terminal.write(content.replace(/\n/g, "\r\n")) // 转换换行符
// 滚动到底部
terminal.scrollToBottom()
}, [content])
// 监听主题变化(可选)
useEffect(() => {
const terminal = xtermRef.current
if (!terminal) return
// 根据系统主题切换颜色
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches
terminal.options.theme = isDark
? {
background: "#1e1e1e",
foreground: "#d4d4d4",
}
: {
background: "#ffffff",
foreground: "#000000",
}
}, [])
return (
<div
ref={terminalRef}
className={className}
style={{
height: "100%",
width: "100%",
}}
/>
)
}

View File

@@ -1 +1,2 @@
export { SystemLogsView } from "./system-logs-view"
export { AnsiLogViewer } from "./ansi-log-viewer"

View File

@@ -1,19 +1,17 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import Editor, { type Monaco } from "@monaco-editor/react"
import { useColorTheme } from "@/hooks/use-color-theme"
import { useEffect, useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { Card, CardContent } from "@/components/ui/card"
import { useSystemLogs, useLogFiles } from "@/hooks/use-system-logs"
import { LogToolbar } from "./log-toolbar"
import { AnsiLogViewer } from "./ansi-log-viewer"
const DEFAULT_FILE = "xingrin.log"
const DEFAULT_LINES = 500
export function SystemLogsView() {
const { currentTheme } = useColorTheme()
const t = useTranslations("settings.systemLogs")
// 状态管理
@@ -41,20 +39,6 @@ export function SystemLogsView() {
const content = useMemo(() => logsData?.content ?? "", [logsData?.content])
const editorRef = useRef<any>(null)
// 自动滚动到底部
useEffect(() => {
const editor = editorRef.current
if (!editor) return
const model = editor.getModel?.()
if (!model) return
const lastLine = model.getLineCount?.() ?? 1
editor.revealLine?.(lastLine)
}, [content])
return (
<Card>
<CardContent className="space-y-4">
@@ -67,28 +51,14 @@ export function SystemLogsView() {
onLinesChange={setLines}
onAutoRefreshChange={setAutoRefresh}
/>
<div className="h-[calc(100vh-300px)] min-h-[360px] rounded-lg border overflow-hidden">
<Editor
height="100%"
defaultLanguage="log"
value={content || t("noContent")}
theme={currentTheme.isDark ? "vs-dark" : "light"}
onMount={(editor) => {
editorRef.current = editor
}}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "off",
scrollBeyondLastLine: false,
automaticLayout: true,
folding: false,
wordWrap: "off",
renderLineHighlight: "none",
padding: { top: 12, bottom: 12 },
}}
/>
<div className="h-[calc(100vh-300px)] min-h-[360px] rounded-lg border overflow-hidden bg-[#1e1e1e]">
{content ? (
<AnsiLogViewer content={content} />
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t("noContent")}
</div>
)}
</div>
</CardContent>
</Card>