Files
mai-bot/dashboard/src/routes/logs.tsx
2026-01-13 06:24:35 +08:00

629 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect, useMemo } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { Card } from '@/components/ui/card'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Search, RefreshCw, Download, Filter, Trash2, Pause, Play, Calendar as CalendarIcon, X, Type, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { logWebSocket, type LogEntry } from '@/lib/log-websocket'
import { format } from 'date-fns'
import { zhCN } from 'date-fns/locale'
// 字号配置
type FontSize = 'xs' | 'sm' | 'base'
const fontSizeConfig: Record<FontSize, { label: string; rowHeight: number; class: string }> = {
xs: { label: '小', rowHeight: 28, class: 'text-[10px] sm:text-xs' },
sm: { label: '中', rowHeight: 36, class: 'text-xs sm:text-sm' },
base: { label: '大', rowHeight: 44, class: 'text-sm sm:text-base' },
}
export function LogViewerPage() {
const [logs, setLogs] = useState<LogEntry[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [levelFilter, setLevelFilter] = useState<string>('all')
const [moduleFilter, setModuleFilter] = useState<string>('all')
const [dateFrom, setDateFrom] = useState<Date | undefined>(undefined)
const [dateTo, setDateTo] = useState<Date | undefined>(undefined)
const [autoScroll, setAutoScroll] = useState(true)
const [connected, setConnected] = useState(false)
const [fontSize, setFontSize] = useState<FontSize>('xs') // 默认使用小字号以显示更多信息
const [lineSpacing, setLineSpacing] = useState(4) // 行间距默认4px紧凑
const [filtersOpen, setFiltersOpen] = useState(false) // 控制折叠面板,默认折叠
const parentRef = useRef<HTMLDivElement>(null)
// 订阅全局 WebSocket 连接
useEffect(() => {
// 初始化时加载缓存的日志
const cachedLogs = logWebSocket.getAllLogs()
setLogs(cachedLogs)
// 订阅日志消息 - 直接使用全局缓存而不是组件状态
const unsubscribeLogs = logWebSocket.onLog(() => {
// 每次收到新日志,重新从全局缓存加载
setLogs(logWebSocket.getAllLogs())
})
// 订阅连接状态
const unsubscribeConnection = logWebSocket.onConnectionChange((isConnected) => {
setConnected(isConnected)
})
// 清理订阅
return () => {
unsubscribeLogs()
unsubscribeConnection()
}
}, [])
// 获取所有唯一的模块名(过滤掉空字符串)
const uniqueModules = useMemo(() => {
const modules = new Set(logs.map(log => log.module).filter(m => m && m.trim() !== ''))
return Array.from(modules).sort()
}, [logs])
// 日志级别颜色映射
const getLevelColor = (level: LogEntry['level']) => {
switch (level) {
case 'DEBUG':
return 'text-muted-foreground'
case 'INFO':
return 'text-blue-500 dark:text-blue-400'
case 'WARNING':
return 'text-yellow-600 dark:text-yellow-500'
case 'ERROR':
return 'text-red-600 dark:text-red-500'
case 'CRITICAL':
return 'text-red-700 dark:text-red-400 font-bold'
default:
return 'text-foreground'
}
}
const getLevelBgColor = (level: LogEntry['level']) => {
switch (level) {
case 'DEBUG':
return 'bg-gray-800/30 dark:bg-gray-800/50'
case 'INFO':
return 'bg-blue-900/20 dark:bg-blue-500/20'
case 'WARNING':
return 'bg-yellow-900/20 dark:bg-yellow-500/20'
case 'ERROR':
return 'bg-red-900/20 dark:bg-red-500/20'
case 'CRITICAL':
return 'bg-red-900/30 dark:bg-red-600/30'
default:
return 'bg-gray-800/20 dark:bg-gray-800/30'
}
}
// 刷新日志(刷新页面)
const handleRefresh = () => {
window.location.reload()
}
// 清空日志
const handleClear = () => {
logWebSocket.clearLogs() // 清空全局缓存
setLogs([])
}
// 导出日志为 TXT 格式
const handleExport = () => {
// 格式化日志为文本
const logText = filteredLogs.map(log =>
`${log.timestamp} [${log.level.padEnd(8)}] [${log.module}] ${log.message}`
).join('\n')
const dataBlob = new Blob([logText], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `logs-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.txt`
link.click()
URL.revokeObjectURL(url)
}
// 切换自动滚动
const toggleAutoScroll = () => {
setAutoScroll(!autoScroll)
}
// 清除时间筛选
const clearDateFilter = () => {
setDateFrom(undefined)
setDateTo(undefined)
}
// 过滤日志
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
// 搜索过滤
const matchesSearch =
searchQuery === '' ||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.module.toLowerCase().includes(searchQuery.toLowerCase())
// 级别过滤
const matchesLevel = levelFilter === 'all' || log.level === levelFilter
// 模块过滤
const matchesModule = moduleFilter === 'all' || log.module === moduleFilter
// 时间过滤
let matchesDate = true
if (dateFrom || dateTo) {
const logDate = new Date(log.timestamp)
if (dateFrom) {
const fromDate = new Date(dateFrom)
fromDate.setHours(0, 0, 0, 0)
matchesDate = matchesDate && logDate >= fromDate
}
if (dateTo) {
const toDate = new Date(dateTo)
toDate.setHours(23, 59, 59, 999)
matchesDate = matchesDate && logDate <= toDate
}
}
return matchesSearch && matchesLevel && matchesModule && matchesDate
})
}, [logs, searchQuery, levelFilter, moduleFilter, dateFrom, dateTo])
// 虚拟滚动配置 - 根据字号和行间距动态计算行高
const estimatedRowHeight = fontSizeConfig[fontSize].rowHeight + lineSpacing
const rowVirtualizer = useVirtualizer({
count: filteredLogs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimatedRowHeight,
overscan: 50, // 增加预渲染数量以减少快速滚动时的空白
})
// 用于追踪是否是程序触发的滚动
const isAutoScrollingRef = useRef(false)
// 用于追踪上一次的日志数量
const prevLogCountRef = useRef(filteredLogs.length)
// 检测用户滚动行为,当用户向上滚动时禁用自动滚动
useEffect(() => {
const scrollElement = parentRef.current
if (!scrollElement) return
const handleScroll = () => {
// 如果是程序触发的滚动,忽略
if (isAutoScrollingRef.current) return
const { scrollTop, scrollHeight, clientHeight } = scrollElement
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 如果距离底部超过 100px说明用户在向上查看禁用自动滚动
if (distanceFromBottom > 100 && autoScroll) {
setAutoScroll(false)
}
// 如果用户滚动到接近底部(小于 50px可以重新启用自动滚动
else if (distanceFromBottom < 50 && !autoScroll) {
setAutoScroll(true)
}
}
scrollElement.addEventListener('scroll', handleScroll, { passive: true })
return () => scrollElement.removeEventListener('scroll', handleScroll)
}, [autoScroll])
// 自动滚动到底部
useEffect(() => {
// 只有在日志数量增加时才滚动(避免删除日志时触发)
const logCountIncreased = filteredLogs.length > prevLogCountRef.current
prevLogCountRef.current = filteredLogs.length
if (autoScroll && filteredLogs.length > 0 && logCountIncreased) {
isAutoScrollingRef.current = true
rowVirtualizer.scrollToIndex(filteredLogs.length - 1, {
align: 'end',
behavior: 'auto',
})
// 稍后重置标志,给滚动事件处理一些时间
requestAnimationFrame(() => {
requestAnimationFrame(() => {
isAutoScrollingRef.current = false
})
})
}
}, [filteredLogs.length, autoScroll, rowVirtualizer])
return (
<div className="h-full flex flex-col overflow-hidden">
{/* 顶部操作面板 - 紧凑设计,默认折叠 */}
<div className="flex-shrink-0 space-y-2 sm:space-y-3 p-2 sm:p-3 lg:p-4">
{/* 标题和连接状态 */}
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold"></h1>
<p className="text-xs text-muted-foreground mt-0.5 hidden sm:block">
</p>
</div>
{/* 连接状态指示器 */}
<div className="flex items-center gap-2">
<div
className={cn(
'h-2 w-2 sm:h-2.5 sm:w-2.5 rounded-full',
connected ? 'bg-green-500 animate-pulse' : 'bg-red-500'
)}
/>
<span className="text-xs text-muted-foreground">
{connected ? '已连接' : '未连接'}
</span>
</div>
</div>
{/* 控制栏 - 可折叠 */}
<Card className="p-2 sm:p-3">
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
<div className="flex flex-col gap-2">
{/* 第一行:始终显示 - 搜索、快捷操作、展开按钮 */}
<div className="flex gap-2">
{/* 搜索框 */}
<div className="flex-1 relative min-w-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="搜索日志..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-xs sm:text-sm"
/>
</div>
{/* 快捷操作按钮 */}
<div className="flex gap-1 flex-shrink-0">
<Button
variant={autoScroll ? 'default' : 'outline'}
size="sm"
onClick={toggleAutoScroll}
className="h-8 px-2"
title={autoScroll ? '自动滚动' : '已暂停'}
>
{autoScroll ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
<span className="ml-1 text-xs hidden sm:inline">
{autoScroll ? '滚动' : '暂停'}
</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClear}
className="h-8 px-2"
title="清空日志"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="ml-1 text-xs hidden md:inline"></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
className="h-8 px-2 hidden sm:flex"
title="导出日志"
>
<Download className="h-3.5 w-3.5" />
<span className="ml-1 text-xs hidden lg:inline"></span>
</Button>
{/* 展开/收起按钮 */}
<CollapsibleTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2"
title={filtersOpen ? '收起筛选' : '展开筛选'}
>
<Filter className="h-3.5 w-3.5" />
{filtersOpen ? (
<ChevronUp className="h-3.5 w-3.5 ml-1" />
) : (
<ChevronDown className="h-3.5 w-3.5 ml-1" />
)}
</Button>
</CollapsibleTrigger>
</div>
</div>
{/* 日志数量显示 */}
<div className="text-xs text-muted-foreground text-center sm:text-right -mt-1">
<span className="font-mono">
{filteredLogs.length} / {logs.length}
</span>
<span className="ml-1"></span>
</div>
{/* 可折叠的筛选区域 */}
<CollapsibleContent className="space-y-2">
{/* 级别和模块筛选 */}
<div className="flex flex-col gap-2 sm:flex-row sm:gap-2">
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-full sm:flex-1 h-8 text-xs">
<Filter className="h-3.5 w-3.5 mr-1.5" />
<SelectValue placeholder="级别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="DEBUG">DEBUG</SelectItem>
<SelectItem value="INFO">INFO</SelectItem>
<SelectItem value="WARNING">WARNING</SelectItem>
<SelectItem value="ERROR">ERROR</SelectItem>
<SelectItem value="CRITICAL">CRITICAL</SelectItem>
</SelectContent>
</Select>
<Select value={moduleFilter} onValueChange={setModuleFilter}>
<SelectTrigger className="w-full sm:flex-1 h-8 text-xs">
<Filter className="h-3.5 w-3.5 mr-1.5" />
<SelectValue placeholder="模块" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{uniqueModules.map(module => (
<SelectItem key={module} value={module}>
{module}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 时间筛选 */}
<div className="flex flex-col gap-2 sm:flex-row sm:gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
'w-full sm:flex-1 justify-start text-left font-normal h-8',
!dateFrom && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-1.5 h-3.5 w-3.5" />
<span className="text-xs">
{dateFrom ? format(dateFrom, 'PP', { locale: zhCN }) : '开始日期'}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateFrom}
onSelect={setDateFrom}
initialFocus
locale={zhCN}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
'w-full sm:flex-1 justify-start text-left font-normal h-8',
!dateTo && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-1.5 h-3.5 w-3.5" />
<span className="text-xs">
{dateTo ? format(dateTo, 'PP', { locale: zhCN }) : '结束日期'}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateTo}
onSelect={setDateTo}
initialFocus
locale={zhCN}
/>
</PopoverContent>
</Popover>
{(dateFrom || dateTo) && (
<Button
variant="outline"
size="sm"
onClick={clearDateFilter}
className="w-full sm:w-auto h-8"
>
<X className="h-3.5 w-3.5 sm:mr-1" />
<span className="text-xs"></span>
</Button>
)}
</div>
{/* 显示设置 */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 pt-2 border-t border-border/50">
{/* 字号调整 */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Type className="h-3.5 w-3.5" />
<span></span>
</div>
<div className="flex gap-1">
{(Object.keys(fontSizeConfig) as FontSize[]).map((size) => (
<Button
key={size}
variant={fontSize === size ? 'default' : 'outline'}
size="sm"
onClick={() => setFontSize(size)}
className="h-6 px-2 text-xs"
>
{fontSizeConfig[size].label}
</Button>
))}
</div>
</div>
{/* 行间距调整 */}
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
<span className="text-xs text-muted-foreground whitespace-nowrap"></span>
<Slider
value={[lineSpacing]}
onValueChange={([value]) => setLineSpacing(value)}
min={0}
max={12}
step={2}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-7">{lineSpacing}px</span>
</div>
{/* 额外操作按钮(移动端) */}
<div className="flex gap-2 sm:hidden">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
className="flex-1 h-8"
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
className="flex-1 h-8"
>
<Download className="h-3.5 w-3.5 mr-1" />
<span className="text-xs"></span>
</Button>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
</Card>
</div>
{/* 日志终端 - 占据剩余所有空间 */}
<div className="flex-1 min-h-0 px-2 sm:px-3 lg:px-4 pb-2 sm:pb-3 lg:pb-4">
<Card className="bg-black dark:bg-gray-950 border-gray-800 dark:border-gray-900 h-full overflow-hidden">
<div
ref={parentRef}
className={cn(
"h-full overflow-auto",
// 自定义滚动条样式
"[&::-webkit-scrollbar]:w-2.5",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb:hover]:bg-border/80"
)}
>
<div
className={cn("p-2 sm:p-3 font-mono relative", fontSizeConfig[fontSize].class)}
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{filteredLogs.length === 0 ? (
<div className="text-gray-500 dark:text-gray-600 text-center py-8 text-xs sm:text-sm">
</div>
) : (
rowVirtualizer.getVirtualItems().map((virtualRow) => {
const log = filteredLogs[virtualRow.index]
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={cn(
'absolute top-0 left-0 w-full px-2 sm:px-3 rounded hover:bg-white/5 transition-colors',
getLevelBgColor(log.level)
)}
style={{
transform: `translateY(${virtualRow.start}px)`,
paddingTop: `${lineSpacing / 2}px`,
paddingBottom: `${lineSpacing / 2}px`,
}}
>
{/* 移动端:垂直布局 */}
<div className="flex flex-col gap-0.5 sm:hidden">
{/* 第一行:时间戳和级别 */}
<div className="flex items-center gap-2">
<span className="text-gray-500 dark:text-gray-600 text-[10px]">
{log.timestamp}
</span>
<span
className={cn(
'font-semibold text-[10px]',
getLevelColor(log.level)
)}
>
[{log.level}]
</span>
</div>
{/* 第二行:模块名 */}
<div className="text-cyan-400 dark:text-cyan-500 truncate text-[10px]">
{log.module}
</div>
{/* 第三行:消息内容 */}
<div className="text-gray-300 dark:text-gray-400 whitespace-pre-wrap break-words text-[10px]">
{log.message}
</div>
</div>
{/* 平板/桌面端:水平布局 */}
<div className="hidden sm:flex gap-2 items-start">
{/* 时间戳 */}
<span className="text-gray-500 dark:text-gray-600 flex-shrink-0 w-[130px] lg:w-[160px]">
{log.timestamp}
</span>
{/* 日志级别 */}
<span
className={cn(
'flex-shrink-0 w-[65px] lg:w-[75px] font-semibold',
getLevelColor(log.level)
)}
>
[{log.level}]
</span>
{/* 模块名 */}
<span className="text-cyan-400 dark:text-cyan-500 flex-shrink-0 w-[100px] lg:w-[130px] truncate">
{log.module}
</span>
{/* 消息内容 */}
<span className="text-gray-300 dark:text-gray-400 flex-1 whitespace-pre-wrap break-words">
{log.message}
</span>
</div>
</div>
)
})
)}
</div>
</div>
</Card>
</div>
</div>
)
}