feat: 菜单优化
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
import { get } from '@/lib/request'
|
||||||
|
import { USE_MOCK, delay, paginate } from '@/mock'
|
||||||
|
import { mockLogs } from '@/mock/system/logs'
|
||||||
|
import type { OperationLog } from '../system'
|
||||||
|
|
||||||
|
export async function getLogList(params: {
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
operatorName?: string;
|
||||||
|
clientId?: string;
|
||||||
|
path?: string;
|
||||||
|
status?: number;
|
||||||
|
}) {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await delay()
|
||||||
|
let list = [...mockLogs]
|
||||||
|
if (params.operatorName) list = list.filter(l => l.operatorName?.includes(params.operatorName!))
|
||||||
|
if (params.clientId && params.clientId !== 'all') list = list.filter(l => l.clientId === params.clientId)
|
||||||
|
if (params.path) list = list.filter(l => l.path.includes(params.path!))
|
||||||
|
if (params.status) list = list.filter(l => l.statusCode === params.status)
|
||||||
|
|
||||||
|
return { data: paginate(list, params.current, params.pageSize) }
|
||||||
|
}
|
||||||
|
return get<{ data: { list: OperationLog[]; total: number } }>('/system/logs', params)
|
||||||
|
}
|
||||||
+207
-196
@@ -1,242 +1,262 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Search, Loader2, ScrollText, Eye, Clock, ArrowUpDown } from 'lucide-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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { getOperationLogList } from '@/api/systemCrud'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
||||||
|
import { getLogList } from '@/api/system/log'
|
||||||
import type { OperationLog } from '@/api/system'
|
import type { OperationLog } from '@/api/system'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const methodColor: Record<string, string> = {
|
export default function Logs() {
|
||||||
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 [logs, setLogs] = useState<OperationLog[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [page, setPage] = useState(1)
|
const [loading, setLoading] = useState(false)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState({ operatorName: '', clientId: 'all', path: '' })
|
||||||
const [clientFilter, setClientFilter] = useState('all')
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 12 })
|
||||||
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 () => {
|
// Dialog State for viewing details
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [activeLog, setActiveLog] = useState<OperationLog | null>(null)
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const params: any = { current: page, pageSize: 15, keyword: search || undefined }
|
const res = await getLogList({ ...pagination, ...search })
|
||||||
if (clientFilter !== 'all') params.clientId = clientFilter
|
if (res.data) {
|
||||||
if (methodFilter !== 'all') params.method = methodFilter
|
setLogs(res.data.list)
|
||||||
if (statusFilter !== 'all') params.statusCode = parseInt(statusFilter)
|
setTotal(res.data.total)
|
||||||
const res = await getOperationLogList(params)
|
}
|
||||||
if (res?.data) { setLogs(res.data.list || []); setTotal(res.data.total || 0) }
|
} finally {
|
||||||
} catch (e) { console.error(e) } finally { setLoading(false) }
|
setLoading(false)
|
||||||
}, [page, search, clientFilter, methodFilter, statusFilter])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 handleReset = () => {
|
||||||
const avgDuration = logs.length ? Math.round(logs.reduce((s, l) => s + l.duration, 0) / logs.length) : 0
|
setSearch({ operatorName: '', clientId: 'all', path: '' })
|
||||||
const errorCount = logs.filter(l => l.statusCode >= 400).length
|
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 (
|
return (
|
||||||
<div className="space-y-6 animate-fadeIn">
|
<div className="space-y-6 animate-fadeIn pb-8">
|
||||||
{/* Stats row */}
|
<div>
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<h1 className="text-2xl font-bold tracking-tight">操作日志</h1>
|
||||||
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
<p className="text-muted-foreground mt-1">监控系统接口调用情况、操作耗时及返回状态。</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Main table card */}
|
<Card className="border-border/60 shadow-soft">
|
||||||
<Card className="border-border/60 shadow-sm">
|
<CardHeader className="pb-3 border-b border-border/40">
|
||||||
<CardHeader className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 pb-6">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div className="space-y-1">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
<ScrollText className="h-5 w-5 text-primary" /> 日志流水
|
||||||
<ScrollText className="h-5 w-5 text-primary" />操作日志
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>查看系统 API 调用记录,支持按客户端、方法、状态筛选</CardDescription>
|
|
||||||
</div>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-2 w-full lg:w-auto flex-wrap">
|
<Select value={search.clientId} onValueChange={v => setSearch({ ...search, clientId: v })}>
|
||||||
<div className="relative flex-1 lg:flex-none">
|
<SelectTrigger className="w-[140px] h-9">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<SelectValue placeholder="客户端来源" />
|
||||||
<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) }} />
|
</SelectTrigger>
|
||||||
</div>
|
|
||||||
<Select value={clientFilter} onValueChange={v => { setClientFilter(v); setPage(1) }}>
|
|
||||||
<SelectTrigger className="w-[120px] h-9 text-sm"><SelectValue placeholder="客户端" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部客户端</SelectItem>
|
<SelectItem value="all">所有客户端</SelectItem>
|
||||||
<SelectItem value="gateway">API 网关</SelectItem>
|
<SelectItem value="gateway">API 网关</SelectItem>
|
||||||
<SelectItem value="plant">Plant</SelectItem>
|
<SelectItem value="plant">Plant 服务</SelectItem>
|
||||||
<SelectItem value="radio">Radio</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>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
{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>
|
<Table>
|
||||||
<TableHeader className="bg-muted/50">
|
<TableHeader className="bg-muted/30">
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow>
|
||||||
<TableHead className="font-semibold pl-4 w-[130px]">时间</TableHead>
|
<TableHead className="pl-6 w-[120px]">操作者</TableHead>
|
||||||
<TableHead className="font-semibold w-[60px]">方法</TableHead>
|
<TableHead className="w-[120px]">客户端</TableHead>
|
||||||
<TableHead className="font-semibold">接口</TableHead>
|
<TableHead className="w-[100px]">方法</TableHead>
|
||||||
<TableHead className="font-semibold w-[90px]">客户端</TableHead>
|
<TableHead>接口描述 & 路径</TableHead>
|
||||||
<TableHead className="font-semibold w-[80px]">操作者</TableHead>
|
<TableHead className="w-[100px]">状态</TableHead>
|
||||||
<TableHead className="font-semibold w-[60px]">状态</TableHead>
|
<TableHead className="w-[120px]">耗时</TableHead>
|
||||||
<TableHead className="font-semibold w-[70px]">耗时</TableHead>
|
<TableHead className="w-[180px]">操作时间</TableHead>
|
||||||
<TableHead className="w-[50px]" />
|
<TableHead className="text-right pr-6 w-[80px]">详情</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{logs.length === 0 ? (
|
{loading ? (
|
||||||
<TableRow><TableCell colSpan={8} className="h-32 text-center text-muted-foreground">暂无日志</TableCell></TableRow>
|
<TableRow>
|
||||||
) : logs.map(l => (
|
<TableCell colSpan={8} className="h-48 text-center text-muted-foreground">
|
||||||
<TableRow key={l.id} className="group hover:bg-muted/30 text-sm">
|
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-primary/50" />
|
||||||
<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>
|
||||||
<TableCell><Badge className={cn('text-[10px] font-mono font-bold px-1.5', methodColor[l.method])}>{l.method}</Badge></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>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<Badge variant="outline" className="bg-primary/5 font-normal">
|
||||||
<span className="font-mono text-xs truncate max-w-[200px]">{l.path}</span>
|
{log.clientName || log.clientId || 'System'}
|
||||||
<span className="text-xs text-muted-foreground hidden lg:inline">({l.title})</span>
|
</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>
|
</div>
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100" onClick={() => { setDetail(l); setDetailOpen(true) }}>
|
{log.statusCode === 200 ? (
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<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.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>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 text-sm text-muted-foreground">
|
||||||
{totalPages > 1 && (
|
<div>共 {total} 条记录</div>
|
||||||
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
|
<Button
|
||||||
<span className="text-sm text-muted-foreground px-2">{page}/{totalPages}</span>
|
variant="outline" size="sm"
|
||||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</Button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Detail Dialog */}
|
{/* Detail Dialog */}
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader><DialogTitle className="flex items-center gap-2"><Eye className="h-5 w-5 text-primary" />日志详情</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
{detail && (
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<div className="space-y-4 text-sm">
|
<ScrollText className="w-5 h-5 text-primary" /> 日志详情
|
||||||
<div className="grid grid-cols-2 gap-3">
|
</DialogTitle>
|
||||||
<InfoRow label="操作" value={detail.title} />
|
<DialogDescription>
|
||||||
<InfoRow label="操作者" value={detail.operatorName} />
|
请求链路分析与载荷追踪
|
||||||
<InfoRow label="客户端" value={detail.clientName} />
|
</DialogDescription>
|
||||||
<InfoRow label="IP" value={detail.ip} />
|
</DialogHeader>
|
||||||
<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>} />
|
{activeLog && (
|
||||||
<InfoRow label="耗时" value={<span className={cn('font-mono font-semibold', durationColor(detail.duration))}>{detail.duration}ms</span>} />
|
<div className="space-y-4">
|
||||||
<InfoRow label="时间" value={new Date(detail.createdAt).toLocaleString()} />
|
<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>
|
||||||
|
|
||||||
|
{activeLog.requestBody && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold text-muted-foreground">接口路径</p>
|
<h4 className="text-sm font-semibold flex items-center gap-2"><Activity className="w-4 h-4" /> Request Payload</h4>
|
||||||
<code className="block bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono break-all">{detail.path}</code>
|
<ScrollArea className="h-[120px] w-full rounded-md border bg-zinc-950 p-4">
|
||||||
</div>
|
<pre className="text-xs text-emerald-400 font-mono leading-relaxed">
|
||||||
{detail.userAgent && (
|
{JSON.stringify(JSON.parse(activeLog.requestBody), null, 2)}
|
||||||
<div className="space-y-2">
|
</pre>
|
||||||
<p className="text-xs font-semibold text-muted-foreground">User-Agent</p>
|
</ScrollArea>
|
||||||
<code className="block bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono break-all">{detail.userAgent}</code>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detail.requestBody && (
|
|
||||||
|
{activeLog.responseBody && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold text-muted-foreground">请求体</p>
|
<h4 className="text-sm font-semibold flex items-center gap-2"><Activity className="w-4 h-4" /> Response Data</h4>
|
||||||
<pre className="bg-muted/50 rounded-lg px-3 py-2 text-xs font-mono overflow-x-auto">{detail.requestBody}</pre>
|
<ScrollArea className="h-[150px] w-full rounded-md border bg-zinc-950 p-4">
|
||||||
</div>
|
<pre className="text-xs text-blue-400 font-mono leading-relaxed">
|
||||||
)}
|
{JSON.stringify(JSON.parse(activeLog.responseBody), null, 2)}
|
||||||
{detail.responseBody && (
|
</pre>
|
||||||
<div className="space-y-2">
|
</ScrollArea>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -246,12 +266,3 @@ export default function LogsPage() {
|
|||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+63
-18
@@ -7,6 +7,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
||||||
import { getMenuTree, createMenu, updateMenu, deleteMenu } from '@/api/system/menu'
|
import { getMenuTree, createMenu, updateMenu, deleteMenu } from '@/api/system/menu'
|
||||||
import type { SystemMenu } from '@/api/system'
|
import type { SystemMenu } from '@/api/system'
|
||||||
@@ -24,37 +25,52 @@ function MenuRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableRow className="hover:bg-muted/20">
|
<TableRow className={`group transition-colors ${depth === 0 ? 'bg-muted/20 hover:bg-muted/30 border-b-2 border-b-muted/50' : 'hover:bg-muted/10'}`}>
|
||||||
<TableCell className="font-medium" style={{ paddingLeft: `${depth * 24 + 24}px` }}>
|
<TableCell className="font-medium py-3">
|
||||||
|
<div className="flex items-center" style={{ paddingLeft: `${depth * 28}px` }}>
|
||||||
|
{depth > 0 && (
|
||||||
|
<div className="w-4 h-px bg-border/80 mr-2 -ml-2" /> // L-shape connector
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<button onClick={() => setExpanded(!expanded)} className="p-0.5 hover:bg-muted rounded-md text-muted-foreground">
|
<button
|
||||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center justify-center w-5 h-5 rounded border border-border/60 bg-background hover:bg-muted hover:text-foreground text-muted-foreground transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{expanded ? <Icons.Minus className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||||
</button>
|
</button>
|
||||||
) : <span className="w-5" />}
|
) : <span className="w-5" />}
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<IconCmp className="h-4 w-4 text-muted-foreground" />
|
<div className={`flex items-center gap-2 px-2 py-1 rounded-md ${depth === 0 ? 'text-foreground font-semibold' : 'text-muted-foreground font-medium'}`}>
|
||||||
|
<IconCmp className={`h-4 w-4 ${depth === 0 ? 'text-primary' : 'opacity-70'}`} />
|
||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm">{item.path || '-'}</TableCell>
|
<TableCell className="text-muted-foreground text-sm font-mono">
|
||||||
|
{item.path ? (
|
||||||
|
<span className="bg-muted px-2 py-0.5 rounded text-xs">{item.path}</span>
|
||||||
|
) : '-'}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className={item.category === 1 ? 'border-emerald-200 text-emerald-600 bg-emerald-50' : 'border-blue-200 text-blue-600 bg-blue-50'}>
|
<Badge variant={item.category === 1 ? "default" : "secondary"} className={item.category === 1 ? 'bg-primary/10 text-primary hover:bg-primary/20 shadow-none' : 'font-normal shadow-none'}>
|
||||||
{item.category === 1 ? '菜单' : '按钮/权限'}
|
{item.category === 1 ? '目录/菜单' : '操作权限'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.sort}</TableCell>
|
<TableCell>
|
||||||
|
<span className="bg-muted/50 text-muted-foreground border border-border/40 px-2 py-0.5 rounded-md text-xs font-mono">{item.sort}</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right pr-6">
|
<TableCell className="text-right pr-6">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-emerald-600" title="添加子节点" onClick={() => onAddChild(item.id)}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30" onClick={() => onAddChild(item.id)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-3.5 w-3.5 mr-1" /> 子项
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-blue-500" title="编辑" onClick={() => onEdit(item)}>
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-blue-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950/30" onClick={() => onEdit(item)}>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-red-500 hover:text-red-600" title="删除" onClick={() => onDelete(item.id)}>
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30" onClick={() => onDelete(item.id)}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -85,6 +101,19 @@ export default function Menus() {
|
|||||||
|
|
||||||
useEffect(() => { fetchMenus() }, [])
|
useEffect(() => { fetchMenus() }, [])
|
||||||
|
|
||||||
|
// Flatten menus for Parent Selection
|
||||||
|
const getFlatMenuOptions = (items: SystemMenu[], prefix = '') => {
|
||||||
|
let options: { id: string; title: string }[] = []
|
||||||
|
items.forEach(item => {
|
||||||
|
options.push({ id: item.id, title: `${prefix}${item.title}` })
|
||||||
|
if (item.children?.length) {
|
||||||
|
options = options.concat(getFlatMenuOptions(item.children, prefix + '— '))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
const flatMenuOptions = getFlatMenuOptions(menus)
|
||||||
|
|
||||||
const openCreateDialog = (parentId: string = '') => {
|
const openCreateDialog = (parentId: string = '') => {
|
||||||
setEditingMenu(null)
|
setEditingMenu(null)
|
||||||
setFormData({ title: '', name: '', path: '', icon: '', sort: 1, category: 1, parentId })
|
setFormData({ title: '', name: '', path: '', icon: '', sort: 1, category: 1, parentId })
|
||||||
@@ -167,6 +196,22 @@ export default function Menus() {
|
|||||||
<DialogTitle>{editingMenu ? '编辑菜单' : '新增菜单'}</DialogTitle>
|
<DialogTitle>{editingMenu ? '编辑菜单' : '新增菜单'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">上级菜单</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={formData.parentId || 'root'} onValueChange={v => setFormData({ ...formData, parentId: v === 'root' ? '' : v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择上级菜单" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[280px]">
|
||||||
|
<SelectItem value="root" className="text-primary font-medium">根目录 (Root)</SelectItem>
|
||||||
|
{flatMenuOptions.filter(m => m.id !== editingMenu?.id).map(opt => (
|
||||||
|
<SelectItem key={opt.id} value={opt.id}>{opt.title}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="title" className="text-right">菜单名称</Label>
|
<Label htmlFor="title" className="text-right">菜单名称</Label>
|
||||||
<Input id="title" value={formData.title} onChange={e => setFormData({ ...formData, title: e.target.value })} className="col-span-3" />
|
<Input id="title" value={formData.title} onChange={e => setFormData({ ...formData, title: e.target.value })} className="col-span-3" />
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
||||||
import { getRoleList, createRole, updateRole, deleteRole } from '@/api/system/role'
|
import { getRoleList, createRole, updateRole, deleteRole } from '@/api/system/role'
|
||||||
import type { SystemRole } from '@/api/system'
|
import { getMenuTree } from '@/api/system/menu'
|
||||||
|
import type { SystemRole, SystemMenu } from '@/api/system'
|
||||||
|
|
||||||
export default function Roles() {
|
export default function Roles() {
|
||||||
const [roles, setRoles] = useState<SystemRole[]>([])
|
const [roles, setRoles] = useState<SystemRole[]>([])
|
||||||
@@ -21,6 +24,12 @@ export default function Roles() {
|
|||||||
const [editingRole, setEditingRole] = useState<SystemRole | null>(null)
|
const [editingRole, setEditingRole] = useState<SystemRole | null>(null)
|
||||||
const [formData, setFormData] = useState({ name: '', code: '', sort: 0 })
|
const [formData, setFormData] = useState({ name: '', code: '', sort: 0 })
|
||||||
|
|
||||||
|
// Menu Permissions Dialog State
|
||||||
|
const [permDialogOpen, setPermDialogOpen] = useState(false)
|
||||||
|
const [allMenus, setAllMenus] = useState<SystemMenu[]>([])
|
||||||
|
const [selectedMenuIds, setSelectedMenuIds] = useState<string[]>([])
|
||||||
|
const [activeRole, setActiveRole] = useState<SystemRole | null>(null)
|
||||||
|
|
||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -34,7 +43,15 @@ export default function Roles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchRoles() }, [])
|
const fetchMenus = async () => {
|
||||||
|
const res = await getMenuTree()
|
||||||
|
if (res.data) setAllMenus(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles()
|
||||||
|
fetchMenus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSearch = () => fetchRoles()
|
const handleSearch = () => fetchRoles()
|
||||||
const handleReset = () => { setSearch({ name: '' }); setTimeout(() => fetchRoles(), 0) }
|
const handleReset = () => { setSearch({ name: '' }); setTimeout(() => fetchRoles(), 0) }
|
||||||
@@ -65,6 +82,45 @@ export default function Roles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openPermDialog = (role: SystemRole) => {
|
||||||
|
setActiveRole(role)
|
||||||
|
// mock some selected ids
|
||||||
|
setSelectedMenuIds(['1', '10', '11', '12'])
|
||||||
|
setPermDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSavePerms = async () => {
|
||||||
|
// In real app, call assignMenusToRole API
|
||||||
|
setPermDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMenu = (id: string, checked: boolean) => {
|
||||||
|
if (checked) setSelectedMenuIds(prev => [...prev, id])
|
||||||
|
else setSelectedMenuIds(prev => prev.filter(i => i !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderMenuTree = (menus: SystemMenu[], depth = 0) => {
|
||||||
|
return menus.map(menu => (
|
||||||
|
<div key={menu.id} className="flex flex-col" style={{ marginLeft: depth > 0 ? 24 : 0 }}>
|
||||||
|
<div className="flex items-center space-x-2 py-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`menu-${menu.id}`}
|
||||||
|
checked={selectedMenuIds.includes(menu.id)}
|
||||||
|
onCheckedChange={c => toggleMenu(menu.id, c as boolean)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`menu-${menu.id}`} className="font-normal cursor-pointer flex items-center gap-2">
|
||||||
|
{menu.title} {menu.category === 2 && <span className="text-[10px] bg-muted px-1 rounded text-muted-foreground">按钮</span>}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{menu.children && menu.children.length > 0 && (
|
||||||
|
<div className="border-l border-border/40 ml-2 pl-2">
|
||||||
|
{renderMenuTree(menu.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fadeIn">
|
<div className="space-y-6 animate-fadeIn">
|
||||||
<div>
|
<div>
|
||||||
@@ -133,7 +189,7 @@ export default function Roles() {
|
|||||||
<Button variant="ghost" size="sm" className="h-8 gap-1" onClick={() => openEditDialog(role)}>
|
<Button variant="ghost" size="sm" className="h-8 gap-1" onClick={() => openEditDialog(role)}>
|
||||||
<Edit className="h-3.5 w-3.5 text-blue-500" /> 编辑
|
<Edit className="h-3.5 w-3.5 text-blue-500" /> 编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1" onClick={() => {}}>
|
<Button variant="ghost" size="sm" className="h-8 gap-1" onClick={() => openPermDialog(role)}>
|
||||||
<Settings2 className="h-3.5 w-3.5 text-emerald-500" /> 菜单权限
|
<Settings2 className="h-3.5 w-3.5 text-emerald-500" /> 菜单权限
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1 hover:text-red-500" onClick={() => handleDelete(role.id)}>
|
<Button variant="ghost" size="sm" className="h-8 gap-1 hover:text-red-500" onClick={() => handleDelete(role.id)}>
|
||||||
@@ -185,6 +241,26 @@ export default function Roles() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>分配菜单权限</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
为角色 <span className="font-semibold text-foreground">{activeRole?.name}</span> 分配可访问的菜单和按钮权限。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[400px] mt-4 pr-4 border rounded-md p-4 bg-muted/10">
|
||||||
|
{renderMenuTree(allMenus)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button variant="outline" onClick={() => setPermDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSavePerms}>保存权限</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-11
@@ -8,9 +8,11 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
|
||||||
import { getUserList, createUser, updateUser, deleteUser } from '@/api/system/user'
|
import { getUserList, createUser, updateUser, deleteUser } from '@/api/system/user'
|
||||||
import type { SystemUser } from '@/api/system'
|
import { getRoleList } from '@/api/system/role'
|
||||||
|
import type { SystemUser, SystemRole } from '@/api/system'
|
||||||
|
|
||||||
export default function UserManage() {
|
export default function UserManage() {
|
||||||
const [users, setUsers] = useState<SystemUser[]>([])
|
const [users, setUsers] = useState<SystemUser[]>([])
|
||||||
@@ -18,10 +20,13 @@ export default function UserManage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [search, setSearch] = useState({ account: '', name: '' })
|
const [search, setSearch] = useState({ account: '', name: '' })
|
||||||
|
|
||||||
|
// All Roles for assignment
|
||||||
|
const [allRoles, setAllRoles] = useState<SystemRole[]>([])
|
||||||
|
|
||||||
// Dialog State
|
// Dialog State
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [editingUser, setEditingUser] = useState<SystemUser | null>(null)
|
const [editingUser, setEditingUser] = useState<SystemUser | null>(null)
|
||||||
const [formData, setFormData] = useState({ account: '', name: '', phone: '', clientId: '' })
|
const [formData, setFormData] = useState({ account: '', name: '', phone: '', clientId: '', roleIds: [] as string[] })
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -36,7 +41,15 @@ export default function UserManage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchUsers() }, [])
|
const fetchRoles = async () => {
|
||||||
|
const res = await getRoleList({ current: 1, pageSize: 100 })
|
||||||
|
if (res.data) setAllRoles(res.data.list)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
fetchRoles()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSearch = () => fetchUsers()
|
const handleSearch = () => fetchUsers()
|
||||||
|
|
||||||
@@ -47,13 +60,16 @@ export default function UserManage() {
|
|||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
setEditingUser(null)
|
setEditingUser(null)
|
||||||
setFormData({ account: '', name: '', phone: '', clientId: '' })
|
setFormData({ account: '', name: '', phone: '', clientId: '', roleIds: [] })
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditDialog = (user: SystemUser) => {
|
const openEditDialog = (user: SystemUser) => {
|
||||||
setEditingUser(user)
|
setEditingUser(user)
|
||||||
setFormData({ account: user.account, name: user.name, phone: user.phone || '', clientId: user.clientId || '' })
|
setFormData({
|
||||||
|
account: user.account, name: user.name, phone: user.phone || '', clientId: user.clientId || '',
|
||||||
|
roleIds: user.roles?.map(r => r.id) || []
|
||||||
|
})
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +90,19 @@ export default function UserManage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleRole = (roleId: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setFormData(prev => ({ ...prev, roleIds: [...prev.roleIds, roleId] }))
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({ ...prev, roleIds: prev.roleIds.filter(id => id !== roleId) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fadeIn">
|
<div className="space-y-6 animate-fadeIn">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">用户管理</h1>
|
<h1 className="text-2xl font-bold tracking-tight">用户管理</h1>
|
||||||
<p className="text-muted-foreground mt-1">管理系统和各个客户端(Plant, Radio等)的用户访问权限。</p>
|
<p className="text-muted-foreground mt-1">管理系统和各个客户端(Plant, Radio等)的用户访问权限及角色绑定。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-border/60 shadow-soft">
|
<Card className="border-border/60 shadow-soft">
|
||||||
@@ -159,7 +183,7 @@ export default function UserManage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{user.roles?.map(r => (
|
{user.roles?.map(r => (
|
||||||
<Badge key={r.id} variant="default" className="text-[10px] h-5 font-normal">
|
<Badge key={r.id} variant="default" className="text-[10px] h-5 font-normal bg-emerald-500 hover:bg-emerald-600 text-white">
|
||||||
{r.name}
|
{r.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)) || <span className="text-muted-foreground text-xs">无角色</span>}
|
)) || <span className="text-muted-foreground text-xs">无角色</span>}
|
||||||
@@ -179,7 +203,7 @@ export default function UserManage() {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onClick={() => openEditDialog(user)}>
|
<DropdownMenuItem onClick={() => openEditDialog(user)}>
|
||||||
<Edit className="mr-2 h-4 w-4 text-blue-500" /> 编辑信息
|
<Edit className="mr-2 h-4 w-4 text-blue-500" /> 编辑 & 赋权
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => handleDelete(user.id)} className="text-red-500 focus:text-red-500 focus:bg-red-50 dark:focus:bg-red-950">
|
<DropdownMenuItem onClick={() => handleDelete(user.id)} className="text-red-500 focus:text-red-500 focus:bg-red-50 dark:focus:bg-red-950">
|
||||||
@@ -194,7 +218,6 @@ export default function UserManage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{/* Simple Pagination Footer */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 text-sm text-muted-foreground">
|
<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>共 {total} 条记录</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -211,7 +234,7 @@ export default function UserManage() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingUser ? '编辑用户' : '新增用户'}</DialogTitle>
|
<DialogTitle>{editingUser ? '编辑用户' : '新增用户'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{editingUser ? '修改用户信息和客户端绑定。' : '在系统中创建一个新用户账号。'}
|
{editingUser ? '修改用户信息并分配角色。' : '在系统中创建一个新用户账号并分配角色。'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
@@ -229,7 +252,29 @@ export default function UserManage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="clientId" className="text-right">客户端</Label>
|
<Label htmlFor="clientId" className="text-right">客户端</Label>
|
||||||
<Input id="clientId" placeholder="如: plant, radio 或留空" value={formData.clientId} onChange={e => setFormData({ ...formData, clientId: e.target.value })} className="col-span-3" />
|
<Input id="clientId" placeholder="如: plant, radio" value={formData.clientId} onChange={e => setFormData({ ...formData, clientId: e.target.value })} className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles Assignment */}
|
||||||
|
<div className="grid grid-cols-4 items-start gap-4 mt-2">
|
||||||
|
<Label className="text-right pt-2">分配角色</Label>
|
||||||
|
<div className="col-span-3 grid grid-cols-2 gap-3 p-3 bg-muted/30 rounded-lg border border-border/50">
|
||||||
|
{allRoles.map(role => (
|
||||||
|
<div key={role.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`role-${role.id}`}
|
||||||
|
checked={formData.roleIds.includes(role.id)}
|
||||||
|
onCheckedChange={(checked) => toggleRole(role.id, checked as boolean)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`role-${role.id}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
Reference in New Issue
Block a user