init: initial commit
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Loader2, ScrollText, Eye, Clock, ArrowUpDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getOperationLogList } from '@/api/systemCrud'
|
||||
import type { OperationLog } from '@/api/system'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const methodColor: Record<string, string> = {
|
||||
GET: 'bg-blue-500/10 text-blue-700 border-blue-500/20',
|
||||
POST: 'bg-green-500/10 text-green-700 border-green-500/20',
|
||||
PUT: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
|
||||
DELETE: 'bg-red-500/10 text-red-700 border-red-500/20',
|
||||
}
|
||||
|
||||
const statusColor = (code: number) => {
|
||||
if (code >= 200 && code < 300) return 'bg-green-500/10 text-green-700'
|
||||
if (code >= 400 && code < 500) return 'bg-amber-500/10 text-amber-700'
|
||||
return 'bg-red-500/10 text-red-700'
|
||||
}
|
||||
|
||||
const durationColor = (ms: number) => {
|
||||
if (ms < 100) return 'text-green-600'
|
||||
if (ms < 300) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [clientFilter, setClientFilter] = useState('all')
|
||||
const [methodFilter, setMethodFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<OperationLog | null>(null)
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = { current: page, pageSize: 15, keyword: search || undefined }
|
||||
if (clientFilter !== 'all') params.clientId = clientFilter
|
||||
if (methodFilter !== 'all') params.method = methodFilter
|
||||
if (statusFilter !== 'all') params.statusCode = parseInt(statusFilter)
|
||||
const res = await getOperationLogList(params)
|
||||
if (res?.data) { setLogs(res.data.list || []); setTotal(res.data.total || 0) }
|
||||
} catch (e) { console.error(e) } finally { setLoading(false) }
|
||||
}, [page, search, clientFilter, methodFilter, statusFilter])
|
||||
|
||||
useEffect(() => { fetchList() }, [fetchList])
|
||||
|
||||
const totalPages = Math.ceil(total / 15)
|
||||
|
||||
// Stats from current filtered results
|
||||
const avgDuration = logs.length ? Math.round(logs.reduce((s, l) => s + l.duration, 0) / logs.length) : 0
|
||||
const errorCount = logs.filter(l => l.statusCode >= 400).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">总记录</CardTitle>
|
||||
<ScrollText className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{total}</div></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-green-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">平均耗时</CardTitle>
|
||||
<Clock className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{avgDuration}<span className="text-sm font-normal text-muted-foreground ml-1">ms</span></div></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-red-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">异常请求</CardTitle>
|
||||
<ArrowUpDown className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold text-red-600">{errorCount}</div></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-purple-500 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">成功率</CardTitle>
|
||||
<Badge className="bg-purple-500/10 text-purple-700 text-xs">%</Badge>
|
||||
</CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{logs.length ? ((1 - errorCount / logs.length) * 100).toFixed(1) : '—'}</div></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main table card */}
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<ScrollText className="h-5 w-5 text-primary" />操作日志
|
||||
</CardTitle>
|
||||
<CardDescription>查看系统 API 调用记录,支持按客户端、方法、状态筛选</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full lg:w-auto flex-wrap">
|
||||
<div className="relative flex-1 lg:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="搜索路径/操作/人..." className="pl-9 w-full lg:w-[180px] h-9 bg-muted/30 text-sm" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} />
|
||||
</div>
|
||||
<Select value={clientFilter} onValueChange={v => { setClientFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[120px] h-9 text-sm"><SelectValue placeholder="客户端" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部客户端</SelectItem>
|
||||
<SelectItem value="gateway">API 网关</SelectItem>
|
||||
<SelectItem value="plant">Plant</SelectItem>
|
||||
<SelectItem value="radio">Radio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={methodFilter} onValueChange={v => { setMethodFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm"><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>
|
||||
<Select value={statusFilter} onValueChange={v => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm"><SelectValue placeholder="状态" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-4 w-[130px]">时间</TableHead>
|
||||
<TableHead className="font-semibold w-[60px]">方法</TableHead>
|
||||
<TableHead className="font-semibold">接口</TableHead>
|
||||
<TableHead className="font-semibold w-[90px]">客户端</TableHead>
|
||||
<TableHead className="font-semibold w-[80px]">操作者</TableHead>
|
||||
<TableHead className="font-semibold w-[60px]">状态</TableHead>
|
||||
<TableHead className="font-semibold w-[70px]">耗时</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="h-32 text-center text-muted-foreground">暂无日志</TableCell></TableRow>
|
||||
) : logs.map(l => (
|
||||
<TableRow key={l.id} className="group hover:bg-muted/30 text-sm">
|
||||
<TableCell className="pl-4 text-xs text-muted-foreground font-mono whitespace-nowrap">
|
||||
{new Date(l.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</TableCell>
|
||||
<TableCell><Badge className={cn('text-[10px] font-mono font-bold px-1.5', methodColor[l.method])}>{l.method}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono text-xs truncate max-w-[200px]">{l.path}</span>
|
||||
<span className="text-xs text-muted-foreground hidden lg:inline">({l.title})</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-[10px]">{l.clientName}</Badge></TableCell>
|
||||
<TableCell className="text-xs">{l.operatorName}</TableCell>
|
||||
<TableCell><Badge className={cn('text-[10px] font-mono', statusColor(l.statusCode))}>{l.statusCode}</Badge></TableCell>
|
||||
<TableCell><span className={cn('text-xs font-mono font-semibold', durationColor(l.duration))}>{l.duration}ms</span></TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100" onClick={() => { setDetail(l); setDetailOpen(true) }}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">共 {total} 条</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">{page}/{totalPages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader><DialogTitle className="flex items-center gap-2"><Eye className="h-5 w-5 text-primary" />日志详情</DialogTitle></DialogHeader>
|
||||
{detail && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoRow label="操作" value={detail.title} />
|
||||
<InfoRow label="操作者" value={detail.operatorName} />
|
||||
<InfoRow label="客户端" value={detail.clientName} />
|
||||
<InfoRow label="IP" value={detail.ip} />
|
||||
<InfoRow label="方法" value={<Badge className={cn('text-[10px] font-mono', methodColor[detail.method])}>{detail.method}</Badge>} />
|
||||
<InfoRow label="状态" value={<Badge className={cn('text-[10px] font-mono', statusColor(detail.statusCode))}>{detail.statusCode}</Badge>} />
|
||||
<InfoRow label="耗时" value={<span className={cn('font-mono font-semibold', durationColor(detail.duration))}>{detail.duration}ms</span>} />
|
||||
<InfoRow label="时间" value={new Date(detail.createdAt).toLocaleString()} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">接口路径</p>
|
||||
<code className="block bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono break-all">{detail.path}</code>
|
||||
</div>
|
||||
{detail.userAgent && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">User-Agent</p>
|
||||
<code className="block bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono break-all">{detail.userAgent}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.requestBody && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">请求体</p>
|
||||
<pre className="bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono overflow-x-auto">{detail.requestBody}</pre>
|
||||
</div>
|
||||
)}
|
||||
{detail.responseBody && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground">响应体</p>
|
||||
<pre className="bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono overflow-x-auto">{detail.responseBody}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-12 shrink-0">{label}</span>
|
||||
<span className="text-xs font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user