diff --git a/src/api/system/log.ts b/src/api/system/log.ts new file mode 100644 index 0000000..da36b89 --- /dev/null +++ b/src/api/system/log.ts @@ -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) +} diff --git a/src/pages/system/Logs.tsx b/src/pages/system/Logs.tsx index 0a51577..5d10a4f 100644 --- a/src/pages/system/Logs.tsx +++ b/src/pages/system/Logs.tsx @@ -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 = { - 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([]) - 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(null) + const [activeLog, setActiveLog] = useState(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 ( -
- {/* Stats row */} -
- - - 总记录 - - -
{total}
-
- - - 平均耗时 - - -
{avgDuration}ms
-
- - - 异常请求 - - -
{errorCount}
-
- - - 成功率 - % - -
{logs.length ? ((1 - errorCount / logs.length) * 100).toFixed(1) : '—'}
-
+
+
+

操作日志

+

监控系统接口调用情况、操作耗时及返回状态。

- {/* Main table card */} - - -
- - 操作日志 + + +
+ + 日志流水 - 查看系统 API 调用记录,支持按客户端、方法、状态筛选 -
-
-
- - { setSearch(e.target.value); setPage(1) }} /> + +
+ + +
+ + setSearch({ ...search, operatorName: e.target.value })} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + /> +
+
+ + setSearch({ ...search, path: e.target.value })} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + /> +
+ +
- - -
- - {loading ? ( -
- ) : ( -
- - - - 时间 - 方法 - 接口 - 客户端 - 操作者 - 状态 - 耗时 - - - - - {logs.length === 0 ? ( - 暂无日志 - ) : logs.map(l => ( - - - {new Date(l.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })} - - {l.method} - -
- {l.path} - ({l.title}) + +
+ + + 操作者 + 客户端 + 方法 + 接口描述 & 路径 + 状态 + 耗时 + 操作时间 + 详情 + + + + {loading ? ( + + + + 检索日志中... + + + ) : logs.length === 0 ? ( + + 未检索到相关日志 + + ) : ( + logs.map(log => ( + + {log.operatorName || '-'} + + + {log.clientName || log.clientId || 'System'} + + + + + {log.method} + + + +
+ {log.title} + {log.path} +
+
+ + {log.statusCode === 200 ? ( +
+ 200 OK
-
- {l.clientName} - {l.operatorName} - {l.statusCode} - {l.duration}ms - - - -
- ))} -
-
+ ) : ( +
+ {log.statusCode} +
+ )} + + +
200 ? 'text-amber-500 font-medium' : 'text-muted-foreground'}`}> + + {log.duration}ms +
+
+ + {new Date(log.createdAt).toLocaleString('zh-CN')} + + + + + + )) + )} + + + +
+
共 {total} 条记录
+
+ + 第 {pagination.current} 页 +
- )} - {totalPages > 1 && ( -
-

共 {total} 条

-
- - {page}/{totalPages} - -
-
- )} +
{/* Detail Dialog */} - - 日志详情 - {detail && ( -
-
- - - - - {detail.method}} /> - {detail.statusCode}} /> - {detail.duration}ms} /> - + + + + 日志详情 + + + 请求链路分析与载荷追踪 + + + + {activeLog && ( +
+
+
接口路径: {activeLog.path}
+
操作者: {activeLog.operatorName}
+
客户端: {activeLog.clientName}
+
IP地址: {activeLog.ip}
+
User-Agent: {activeLog.userAgent}
-
-

接口路径

- {detail.path} -
- {detail.userAgent && ( + + {activeLog.requestBody && (
-

User-Agent

- {detail.userAgent} +

Request Payload

+ +
+                      {JSON.stringify(JSON.parse(activeLog.requestBody), null, 2)}
+                    
+
)} - {detail.requestBody && ( + + {activeLog.responseBody && (
-

请求体

-
{detail.requestBody}
-
- )} - {detail.responseBody && ( -
-

响应体

-
{detail.responseBody}
+

Response Data

+ +
+                      {JSON.stringify(JSON.parse(activeLog.responseBody), null, 2)}
+                    
+
)}
@@ -246,12 +266,3 @@ export default function LogsPage() {
) } - -function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { - return ( -
- {label} - {value} -
- ) -} diff --git a/src/pages/system/Menus.tsx b/src/pages/system/Menus.tsx index 3de8450..ac530a2 100644 --- a/src/pages/system/Menus.tsx +++ b/src/pages/system/Menus.tsx @@ -7,6 +7,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' 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 type { SystemMenu } from '@/api/system' @@ -24,37 +25,52 @@ function MenuRow({ return ( <> - - -
- {hasChildren ? ( - - ) : } - - - {item.title} - + + +
+ {depth > 0 && ( +
// L-shape connector + )} +
+ {hasChildren ? ( + + ) : } + +
+ + {item.title} +
+
- {item.path || '-'} + + {item.path ? ( + {item.path} + ) : '-'} + - - {item.category === 1 ? '菜单' : '按钮/权限'} + + {item.category === 1 ? '目录/菜单' : '操作权限'} - {item.sort} + + {item.sort} + -
- - -
@@ -85,6 +101,19 @@ export default function Menus() { 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 = '') => { setEditingMenu(null) setFormData({ title: '', name: '', path: '', icon: '', sort: 1, category: 1, parentId }) @@ -167,6 +196,22 @@ export default function Menus() { {editingMenu ? '编辑菜单' : '新增菜单'}
+
+ +
+ +
+
setFormData({ ...formData, title: e.target.value })} className="col-span-3" /> diff --git a/src/pages/system/Roles.tsx b/src/pages/system/Roles.tsx index ad885d1..fb7bcfc 100644 --- a/src/pages/system/Roles.tsx +++ b/src/pages/system/Roles.tsx @@ -6,9 +6,12 @@ import { Input } from '@/components/ui/input' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' 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 type { SystemRole } from '@/api/system' +import { getMenuTree } from '@/api/system/menu' +import type { SystemRole, SystemMenu } from '@/api/system' export default function Roles() { const [roles, setRoles] = useState([]) @@ -21,6 +24,12 @@ export default function Roles() { const [editingRole, setEditingRole] = useState(null) const [formData, setFormData] = useState({ name: '', code: '', sort: 0 }) + // Menu Permissions Dialog State + const [permDialogOpen, setPermDialogOpen] = useState(false) + const [allMenus, setAllMenus] = useState([]) + const [selectedMenuIds, setSelectedMenuIds] = useState([]) + const [activeRole, setActiveRole] = useState(null) + const fetchRoles = async () => { setLoading(true) 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 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 => ( +
0 ? 24 : 0 }}> +
+ toggleMenu(menu.id, c as boolean)} + /> + +
+ {menu.children && menu.children.length > 0 && ( +
+ {renderMenuTree(menu.children, depth + 1)} +
+ )} +
+ )) + } + return (
@@ -133,7 +189,7 @@ export default function Roles() { -
+ + + + + 分配菜单权限 + + 为角色 {activeRole?.name} 分配可访问的菜单和按钮权限。 + + + + + {renderMenuTree(allMenus)} + + + + + + + +
) } diff --git a/src/pages/system/Users.tsx b/src/pages/system/Users.tsx index afdb0d1..823413e 100644 --- a/src/pages/system/Users.tsx +++ b/src/pages/system/Users.tsx @@ -8,9 +8,11 @@ import { Badge } from '@/components/ui/badge' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' 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() { const [users, setUsers] = useState([]) @@ -18,10 +20,13 @@ export default function UserManage() { const [loading, setLoading] = useState(false) const [search, setSearch] = useState({ account: '', name: '' }) + // All Roles for assignment + const [allRoles, setAllRoles] = useState([]) + // Dialog State const [dialogOpen, setDialogOpen] = useState(false) const [editingUser, setEditingUser] = useState(null) - const [formData, setFormData] = useState({ account: '', name: '', phone: '', clientId: '' }) + const [formData, setFormData] = useState({ account: '', name: '', phone: '', clientId: '', roleIds: [] as string[] }) const fetchUsers = async () => { 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() @@ -47,13 +60,16 @@ export default function UserManage() { const openCreateDialog = () => { setEditingUser(null) - setFormData({ account: '', name: '', phone: '', clientId: '' }) + setFormData({ account: '', name: '', phone: '', clientId: '', roleIds: [] }) setDialogOpen(true) } const openEditDialog = (user: SystemUser) => { 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) } @@ -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 (

用户管理

-

管理系统和各个客户端(Plant, Radio等)的用户访问权限。

+

管理系统和各个客户端(Plant, Radio等)的用户访问权限及角色绑定。

@@ -159,7 +183,7 @@ export default function UserManage() {
{user.roles?.map(r => ( - + {r.name} )) || 无角色} @@ -179,7 +203,7 @@ export default function UserManage() { 操作 openEditDialog(user)}> - 编辑信息 + 编辑 & 赋权 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() { - {/* Simple Pagination Footer */}
共 {total} 条记录
@@ -211,7 +234,7 @@ export default function UserManage() { {editingUser ? '编辑用户' : '新增用户'} - {editingUser ? '修改用户信息和客户端绑定。' : '在系统中创建一个新用户账号。'} + {editingUser ? '修改用户信息并分配角色。' : '在系统中创建一个新用户账号并分配角色。'}
@@ -229,7 +252,29 @@ export default function UserManage() {
- setFormData({ ...formData, clientId: e.target.value })} className="col-span-3" /> + setFormData({ ...formData, clientId: e.target.value })} className="col-span-3" /> +
+ + {/* Roles Assignment */} +
+ +
+ {allRoles.map(role => ( +
+ toggleRole(role.id, checked as boolean)} + /> + +
+ ))} +