上传完整的WebUI前端仓库
This commit is contained in:
628
dashboard/src/routes/logs.tsx
Normal file
628
dashboard/src/routes/logs.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user