284 lines
14 KiB
TypeScript
284 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Search, RefreshCw, ScrollText, Eye, Clock, Activity, AlertCircle } from 'lucide-react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
|
|
import { getLogList } from '@/api/system/log'
|
|
import type { OperationLog } from '@/api/system'
|
|
|
|
export default function Logs() {
|
|
const [logs, setLogs] = useState<OperationLog[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [loading, setLoading] = useState(false)
|
|
const [search, setSearch] = useState({ path: '', method: '', status: undefined as number | undefined })
|
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 12 })
|
|
|
|
// Dialog State for viewing details
|
|
const [detailOpen, setDetailOpen] = useState(false)
|
|
const [activeLog, setActiveLog] = useState<OperationLog | null>(null)
|
|
|
|
const fetchLogs = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await getLogList({ ...pagination, ...search })
|
|
if (res) {
|
|
setLogs(res.list)
|
|
setTotal(res.total)
|
|
}
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => { fetchLogs() }, [pagination.current, pagination.pageSize])
|
|
|
|
const handleSearch = () => {
|
|
if (pagination.current === 1) fetchLogs()
|
|
else setPagination({ ...pagination, current: 1 })
|
|
}
|
|
|
|
const handleReset = () => {
|
|
setSearch({ path: '', method: '', status: undefined })
|
|
if (pagination.current === 1) setTimeout(() => fetchLogs(), 0)
|
|
else setPagination({ ...pagination, current: 1 })
|
|
}
|
|
|
|
const openDetail = (log: OperationLog) => {
|
|
setActiveLog(log)
|
|
setDetailOpen(true)
|
|
}
|
|
|
|
const getMethodColor = (method: string) => {
|
|
switch (method) {
|
|
case 'GET': return 'bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
|
case 'POST': return 'bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
|
|
case 'PUT': return 'bg-amber-50 text-amber-600 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
|
case 'DELETE': return 'bg-red-50 text-red-600 border-red-200 dark:bg-red-900/30 dark:text-red-400'
|
|
default: return 'bg-muted text-muted-foreground'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fadeIn pb-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">操作日志</h1>
|
|
<p className="text-muted-foreground mt-1">监控系统接口调用情况、操作耗时及返回状态。</p>
|
|
</div>
|
|
|
|
<Card className="border-border/60 shadow-soft">
|
|
<CardHeader className="pb-3 border-b border-border/40">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<ScrollText className="h-5 w-5 text-primary" /> 日志流水
|
|
</CardTitle>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Select value={search.method || 'all'} onValueChange={v => setSearch({ ...search, method: v === 'all' ? '' : v })}>
|
|
<SelectTrigger className="w-[140px] h-9">
|
|
<SelectValue placeholder="请求方法" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">所有方法</SelectItem>
|
|
<SelectItem value="GET">GET</SelectItem>
|
|
<SelectItem value="POST">POST</SelectItem>
|
|
<SelectItem value="PUT">PUT</SelectItem>
|
|
<SelectItem value="DELETE">DELETE</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="状态码 (如 200, 400)..."
|
|
type="number"
|
|
className="pl-9 w-40 h-9"
|
|
value={search.status || ''}
|
|
onChange={e => setSearch({ ...search, status: e.target.value ? Number(e.target.value) : undefined })}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
/>
|
|
</div>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="请求路径 (如 /api/user)..."
|
|
className="pl-9 w-56 h-9"
|
|
value={search.path}
|
|
onChange={e => setSearch({ ...search, path: e.target.value })}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
/>
|
|
</div>
|
|
<Button onClick={handleSearch} className="h-9">查询</Button>
|
|
<Button variant="outline" onClick={handleReset} className="h-9 px-3">重置</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader className="bg-muted/30">
|
|
<TableRow>
|
|
<TableHead className="pl-6 w-[120px]">操作者</TableHead>
|
|
<TableHead className="w-[120px]">客户端</TableHead>
|
|
<TableHead className="w-[100px]">方法</TableHead>
|
|
<TableHead>接口描述 & 路径</TableHead>
|
|
<TableHead className="w-[100px]">状态</TableHead>
|
|
<TableHead className="w-[120px]">耗时</TableHead>
|
|
<TableHead className="w-[180px]">操作时间</TableHead>
|
|
<TableHead className="text-right pr-6 w-[80px]">详情</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={8} className="h-48 text-center text-muted-foreground">
|
|
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-primary/50" />
|
|
检索日志中...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : logs.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={8} className="h-48 text-center text-muted-foreground">未检索到相关日志</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
logs.map(log => (
|
|
<TableRow key={log.id} className="hover:bg-muted/20">
|
|
<TableCell className="font-medium pl-6">{log.userId || '-'}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="bg-primary/5 font-normal">
|
|
{log.clientId || 'System'}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className={`font-mono text-[10px] ${getMethodColor(log.method)}`}>
|
|
{log.method}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="font-medium text-sm text-muted-foreground font-mono">{log.path}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{log.status === 200 ? (
|
|
<div className="flex items-center text-emerald-600 text-sm">
|
|
<Activity className="w-4 h-4 mr-1" /> 200 OK
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center text-red-500 text-sm font-medium">
|
|
<AlertCircle className="w-4 h-4 mr-1" /> {log.status}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className={`flex items-center text-sm font-mono ${log.latency > 200000000 ? 'text-amber-500 font-medium' : 'text-muted-foreground'}`}>
|
|
<Clock className="w-3.5 h-3.5 mr-1.5 opacity-70" />
|
|
{(log.latency / 1000000).toFixed(0)}ms
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm font-mono">
|
|
{new Date(log.createdAt * 1000).toLocaleString('zh-CN')}
|
|
</TableCell>
|
|
<TableCell className="text-right pr-6">
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-blue-500" onClick={() => openDetail(log)}>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 text-sm text-muted-foreground">
|
|
<div>共 {total} 条记录</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline" size="sm"
|
|
disabled={pagination.current === 1}
|
|
onClick={() => setPagination(p => ({ ...p, current: p.current - 1 }))}
|
|
>
|
|
上一页
|
|
</Button>
|
|
<span className="px-2">第 {pagination.current} 页</span>
|
|
<Button
|
|
variant="outline" size="sm"
|
|
disabled={logs.length < pagination.pageSize}
|
|
onClick={() => setPagination(p => ({ ...p, current: p.current + 1 }))}
|
|
>
|
|
下一页
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Detail Dialog */}
|
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<ScrollText className="w-5 h-5 text-primary" /> 日志详情
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
请求链路分析与载荷追踪
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{activeLog && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/30 border border-border/50 text-sm">
|
|
<div><span className="text-muted-foreground inline-block w-20">接口路径:</span> <span className="font-mono font-medium">{activeLog.path}</span></div>
|
|
<div><span className="text-muted-foreground inline-block w-20">操作者ID:</span> <span className="font-medium">{activeLog.userId}</span></div>
|
|
<div><span className="text-muted-foreground inline-block w-20">客户端:</span> {activeLog.clientId}</div>
|
|
<div><span className="text-muted-foreground inline-block w-20">IP地址:</span> <span className="font-mono">{activeLog.ip}</span></div>
|
|
<div>
|
|
<span className="text-muted-foreground inline-block w-20">响应耗时:</span>
|
|
<span className={`font-mono font-medium ${activeLog.latency > 200000000 ? 'text-amber-500' : 'text-emerald-600'}`}>
|
|
{(activeLog.latency / 1000000).toFixed(0)}ms
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground inline-block w-20">状态码:</span>
|
|
<span className={`font-mono font-medium ${activeLog.status === 200 ? 'text-emerald-600' : 'text-red-500'}`}>
|
|
{activeLog.status}
|
|
</span>
|
|
</div>
|
|
<div className="col-span-2"><span className="text-muted-foreground inline-block w-20">操作时间:</span> <span className="font-mono text-xs">{new Date(activeLog.createdAt * 1000).toLocaleString('zh-CN')}</span></div>
|
|
<div className="col-span-2"><span className="text-muted-foreground inline-block w-20">User-Agent:</span> <span className="text-xs text-muted-foreground">{activeLog.agent}</span></div>
|
|
</div>
|
|
|
|
|
|
{activeLog.body && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-semibold flex items-center gap-2"><Activity className="w-4 h-4" /> Request Payload</h4>
|
|
<ScrollArea className="h-[120px] w-full rounded-md border bg-zinc-950 p-4">
|
|
<pre className="text-xs text-emerald-400 font-mono leading-relaxed">
|
|
{JSON.stringify(JSON.parse(activeLog.body), null, 2)}
|
|
</pre>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
|
|
{activeLog.resp && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-semibold flex items-center gap-2"><Activity className="w-4 h-4" /> Response Data</h4>
|
|
<ScrollArea className="h-[150px] w-full rounded-md border bg-zinc-950 p-4">
|
|
<pre className="text-xs text-blue-400 font-mono leading-relaxed">
|
|
{JSON.stringify(JSON.parse(activeLog.resp), null, 2)}
|
|
</pre>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|