feat: 菜单优化

This commit is contained in:
Blizzard
2026-04-27 17:34:18 +08:00
parent 9af7fe7f37
commit 3cade8e7ef
5 changed files with 452 additions and 250 deletions
+224 -213
View File
@@ -1,242 +1,262 @@
import { useState, useEffect, useCallback } from 'react'
import { Search, Loader2, ScrollText, Eye, Clock, ArrowUpDown } from 'lucide-react'
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 { 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 { Badge } from '@/components/ui/badge'
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 { 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'
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() {
export default function Logs() {
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 [loading, setLoading] = useState(false)
const [search, setSearch] = useState({ operatorName: '', clientId: 'all', path: '' })
const [pagination, setPagination] = useState({ current: 1, pageSize: 12 })
// Dialog State for viewing details
const [detailOpen, setDetailOpen] = useState(false)
const [detail, setDetail] = useState<OperationLog | null>(null)
const [activeLog, setActiveLog] = useState<OperationLog | null>(null)
const fetchList = useCallback(async () => {
const fetchLogs = 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])
const res = await getLogList({ ...pagination, ...search })
if (res.data) {
setLogs(res.data.list)
setTotal(res.data.total)
}
} finally {
setLoading(false)
}
}
useEffect(() => { fetchList() }, [fetchList])
useEffect(() => { fetchLogs() }, [pagination.current, pagination.pageSize])
const totalPages = Math.ceil(total / 15)
const handleSearch = () => {
if (pagination.current === 1) fetchLogs()
else setPagination({ ...pagination, current: 1 })
}
// 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
const handleReset = () => {
setSearch({ operatorName: '', clientId: 'all', path: '' })
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">
{/* 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 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>
{/* 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" />
<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>
<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 className="flex flex-wrap items-center gap-3">
<Select value={search.clientId} onValueChange={v => setSearch({ ...search, clientId: v })}>
<SelectTrigger className="w-[140px] h-9">
<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>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="操作者账号..."
className="pl-9 w-40 h-9"
value={search.operatorName}
onChange={e => setSearch({ ...search, operatorName: e.target.value })}
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>
<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>
<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.operatorName || '-'}</TableCell>
<TableCell>
<Badge variant="outline" className="bg-primary/5 font-normal">
{log.clientName || 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">{log.title}</span>
<span className="text-xs text-muted-foreground font-mono">{log.path}</span>
</div>
</TableCell>
<TableCell>
{log.statusCode === 200 ? (
<div className="flex items-center text-emerald-600 text-sm">
<Activity className="w-4 h-4 mr-1" /> 200 OK
</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 className="flex items-center text-red-500 text-sm font-medium">
<AlertCircle className="w-4 h-4 mr-1" /> {log.statusCode}
</div>
)}
</TableCell>
<TableCell>
<div className={`flex items-center text-sm font-mono ${log.duration > 200 ? 'text-amber-500 font-medium' : 'text-muted-foreground'}`}>
<Clock className="w-3.5 h-3.5 mr-1.5 opacity-70" />
{log.duration}ms
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm font-mono">
{new Date(log.createdAt).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>
)}
{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>
)}
</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()} />
<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">:</span> <span className="font-medium">{activeLog.operatorName}</span></div>
<div><span className="text-muted-foreground inline-block w-20">:</span> {activeLog.clientName}</div>
<div><span className="text-muted-foreground inline-block w-20">IP地址:</span> <span className="font-mono">{activeLog.ip}</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.userAgent}</span></div>
</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 && (
{activeLog.requestBody && (
<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>
<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.requestBody), null, 2)}
</pre>
</ScrollArea>
</div>
)}
{detail.requestBody && (
{activeLog.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.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>
<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.responseBody), null, 2)}
</pre>
</ScrollArea>
</div>
)}
</div>
@@ -246,12 +266,3 @@ export default function LogsPage() {
</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>
)
}