This commit is contained in:
2025-08-28 00:55:35 +08:00
commit 410f54a65e
93 changed files with 9863 additions and 0 deletions

40
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,40 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Users from './pages/Users'
import MainLayout from './layouts/MainLayout'
import { getToken } from './utils/token'
import Roles from './pages/Roles'
import Menus from './pages/Menus'
import Permissions from './pages/Permissions'
import Departments from './pages/Departments'
import Logs from './pages/Logs'
// 移除不存在的 Layout/RequireAuth 组件导入
// 新增
import Positions from './pages/Positions'
function RequireAuth({ children }: { children: any }) {
const token = getToken()
if (!token) return <Navigate to="/login" replace />
return children
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<RequireAuth><MainLayout /></RequireAuth>}>
<Route index element={<Dashboard />} />
<Route path="/users" element={<Users />} />
<Route path="/roles" element={<Roles />} />
<Route path="/menus" element={<Menus />} />
<Route path="/permissions" element={<Permissions />} />
<Route path="/departments" element={<Departments />} />
<Route path="/logs" element={<Logs />} />
{/* 新增 */}
<Route path="/positions" element={<Positions />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none"><rect width="120" height="120" rx="20" fill="#1677FF"/><path d="M30 72c10-2 18-8 30-8s20 6 30 8v8c-10-2-18-8-30-8s-20 6-30 8v-8z" fill="#fff" opacity=".8"/><circle cx="60" cy="44" r="14" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@ -0,0 +1,21 @@
import React from 'react'
import { Breadcrumb } from 'antd'
interface PageHeaderProps {
items: string[]
title: string
style?: React.CSSProperties
extra?: React.ReactNode
}
export default function PageHeader({ items, title, style, extra }: PageHeaderProps) {
return (
<div style={style}>
<Breadcrumb style={{ marginBottom: 12 }} items={items.map(t => ({ title: t }))} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>{title}</h2>
{extra ? <div>{extra}</div> : null}
</div>
</div>
)
}

View File

@ -0,0 +1,556 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Layout, Menu, theme, Avatar, Dropdown, Space, Modal, Form, Input, message, Breadcrumb, ConfigProvider, Tabs } from 'antd'
import { useNavigate, useLocation, useOutlet } from 'react-router-dom'
import { HomeOutlined, LogoutOutlined, TeamOutlined, SettingOutlined, AppstoreOutlined, KeyOutlined, UserOutlined, DashboardOutlined, FileOutlined, LockOutlined, MenuOutlined, PieChartOutlined, BarChartOutlined, TableOutlined, CalendarOutlined, FormOutlined, SearchOutlined, ToolOutlined, ShoppingCartOutlined, ShopOutlined, FolderOpenOutlined, FolderOutlined, CloudOutlined, DatabaseOutlined, ApiOutlined, CodeOutlined, BugOutlined, BellOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, EyeOutlined, EyeInvisibleOutlined, StarOutlined, HeartOutlined, LikeOutlined, DislikeOutlined, SmileOutlined, FrownOutlined, PhoneOutlined, MailOutlined, EnvironmentOutlined, GlobalOutlined, AimOutlined, CompassOutlined, CameraOutlined, VideoCameraOutlined, SoundOutlined, WifiOutlined, RocketOutlined, ThunderboltOutlined, ExperimentOutlined, BulbOutlined, GiftOutlined, BankOutlined, WalletOutlined, MoneyCollectOutlined, BookOutlined, ReadOutlined, ProfileOutlined, CloudUploadOutlined, CloudDownloadOutlined, InboxOutlined, FolderAddOutlined, SlidersOutlined, FilterOutlined, AlertOutlined, ClockCircleOutlined, FieldTimeOutlined, HistoryOutlined, ContactsOutlined, SolutionOutlined, IdcardOutlined, QrcodeOutlined, ScanOutlined, SafetyOutlined, SecurityScanOutlined, UnlockOutlined, HddOutlined, CopyOutlined, ScissorOutlined, SnippetsOutlined, FileProtectOutlined, DesktopOutlined, LaptopOutlined, MobileOutlined, TabletOutlined, ClusterOutlined, AppstoreAddOutlined, PlusSquareOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons'
import { clearToken, getUser as getUserLocal, setUser as setUserLocal } from '../utils/token'
import api from '../utils/axios'
import './layout.css'
import { PermissionProvider } from '../utils/permission'
const { Header, Sider, Content } = Layout
interface MenuItemResp { id: number; parent_id?: number | null; name: string; path?: string | null; component?: string | null; type: number; icon?: string | null; order_no: number; visible: boolean; status: number; perms?: string | null; keep_alive?: boolean }
import zhCN from 'antd/locale/zh_CN'
import enUS from 'antd/locale/en_US'
export default function MainLayout() {
const navigate = useNavigate()
const loc = useLocation()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
// 简易语言切换(存储到 localStorage
const [lang, setLang] = useState<'zh' | 'en'>(() => (localStorage.getItem('lang') === 'en' ? 'en' : 'zh'))
const localeObj = lang === 'zh' ? zhCN : enUS
const [user, setUser] = useState(() => getUserLocal())
const [profileOpen, setProfileOpen] = useState(false)
const [form] = Form.useForm()
const [rawMenus, setRawMenus] = useState([] as MenuItemResp[])
const [menuItems, setMenuItems] = useState([] as any[])
// 从菜单提取权限编码集合(后端通过菜单返回 perms包含页面/按钮权限)
const permissionCodes = useMemo(() => {
const set = new Set<string>()
rawMenus.forEach((m: MenuItemResp) => {
const p = m.perms
if (p && typeof p === 'string') {
p.split(',').map(s => s.trim()).filter(Boolean).forEach(code => set.add(code))
}
})
return set
}, [rawMenus])
const iconMap: Record<string, any> = useMemo(() => ({
HomeOutlined: <HomeOutlined />,
UserOutlined: <UserOutlined />,
TeamOutlined: <TeamOutlined />,
SettingOutlined: <SettingOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
KeyOutlined: <KeyOutlined />,
DashboardOutlined: <DashboardOutlined />,
FileOutlined: <FileOutlined />,
LockOutlined: <LockOutlined />,
MenuOutlined: <MenuOutlined />,
PieChartOutlined: <PieChartOutlined />,
BarChartOutlined: <BarChartOutlined />,
TableOutlined: <TableOutlined />,
CalendarOutlined: <CalendarOutlined />,
FormOutlined: <FormOutlined />,
SearchOutlined: <SearchOutlined />,
ToolOutlined: <ToolOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
ShopOutlined: <ShopOutlined />,
FolderOpenOutlined: <FolderOpenOutlined />,
FolderOutlined: <FolderOutlined />,
CloudOutlined: <CloudOutlined />,
DatabaseOutlined: <DatabaseOutlined />,
ApiOutlined: <ApiOutlined />,
CodeOutlined: <CodeOutlined />,
BugOutlined: <BugOutlined />,
BellOutlined: <BellOutlined />,
// 新增常用图标映射
PlusOutlined: <PlusOutlined />,
EditOutlined: <EditOutlined />,
DeleteOutlined: <DeleteOutlined />,
UploadOutlined: <UploadOutlined />,
DownloadOutlined: <DownloadOutlined />,
EyeOutlined: <EyeOutlined />,
EyeInvisibleOutlined: <EyeInvisibleOutlined />,
StarOutlined: <StarOutlined />,
HeartOutlined: <HeartOutlined />,
LikeOutlined: <LikeOutlined />,
DislikeOutlined: <DislikeOutlined />,
SmileOutlined: <SmileOutlined />,
FrownOutlined: <FrownOutlined />,
PhoneOutlined: <PhoneOutlined />,
MailOutlined: <MailOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
GlobalOutlined: <GlobalOutlined />,
AimOutlined: <AimOutlined />,
CompassOutlined: <CompassOutlined />,
CameraOutlined: <CameraOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
SoundOutlined: <SoundOutlined />,
WifiOutlined: <WifiOutlined />,
RocketOutlined: <RocketOutlined />,
ThunderboltOutlined: <ThunderboltOutlined />,
ExperimentOutlined: <ExperimentOutlined />,
BulbOutlined: <BulbOutlined />,
GiftOutlined: <GiftOutlined />,
BankOutlined: <BankOutlined />,
WalletOutlined: <WalletOutlined />,
MoneyCollectOutlined: <MoneyCollectOutlined />,
BookOutlined: <BookOutlined />,
ReadOutlined: <ReadOutlined />,
ProfileOutlined: <ProfileOutlined />,
CloudUploadOutlined: <CloudUploadOutlined />,
CloudDownloadOutlined: <CloudDownloadOutlined />,
InboxOutlined: <InboxOutlined />,
FolderAddOutlined: <FolderAddOutlined />,
SlidersOutlined: <SlidersOutlined />,
FilterOutlined: <FilterOutlined />,
AlertOutlined: <AlertOutlined />,
ClockCircleOutlined: <ClockCircleOutlined />,
FieldTimeOutlined: <FieldTimeOutlined />,
HistoryOutlined: <HistoryOutlined />,
ContactsOutlined: <ContactsOutlined />,
SolutionOutlined: <SolutionOutlined />,
IdcardOutlined: <IdcardOutlined />,
QrcodeOutlined: <QrcodeOutlined />,
ScanOutlined: <ScanOutlined />,
SafetyOutlined: <SafetyOutlined />,
SecurityScanOutlined: <SecurityScanOutlined />,
UnlockOutlined: <UnlockOutlined />,
HddOutlined: <HddOutlined />,
CopyOutlined: <CopyOutlined />,
ScissorOutlined: <ScissorOutlined />,
SnippetsOutlined: <SnippetsOutlined />,
FileProtectOutlined: <FileProtectOutlined />,
DesktopOutlined: <DesktopOutlined />,
LaptopOutlined: <LaptopOutlined />,
MobileOutlined: <MobileOutlined />,
TabletOutlined: <TabletOutlined />,
ClusterOutlined: <ClusterOutlined />,
AppstoreAddOutlined: <AppstoreAddOutlined />,
PlusSquareOutlined: <PlusSquareOutlined />,
SyncOutlined: <SyncOutlined />,
ReloadOutlined: <ReloadOutlined />,
}), [])
useEffect(() => {
const fetchMenus = async () => {
try {
const { data } = await api.get('/auth/menus')
if (data?.code === 0) {
const list: MenuItemResp[] = data.data || []
setRawMenus(list)
}
} catch (e) {
// ignore
}
}
fetchMenus()
}, [])
useEffect(() => {
// 前端移除不再需要的“权限”相关菜单项
const filtered = rawMenus.filter((m: MenuItemResp) => m.path !== '/permissions' && m.path !== '/demo/perms')
// build tree items from filtered
const map = new Map<number, any>()
const byId = new Map<number, MenuItemResp>()
filtered.forEach((m: MenuItemResp) => byId.set(m.id, m))
filtered
.filter((m: MenuItemResp) => m.type !== 3) // skip buttons
.forEach((m: MenuItemResp) => {
const key = m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`
const item: any = {
key,
label: m.name,
icon: m.icon ? iconMap[m.icon] : undefined,
// NOTE: 不要预先放 children: [],否则在 antd Menu 中会被当成可展开项
}
map.set(m.id, item)
})
const roots: any[] = []
filtered
.filter((m: MenuItemResp) => m.type !== 3)
.forEach((m: MenuItemResp) => {
const node = map.get(m.id)
const pid = m.parent_id || undefined
// 只有当父级不是按钮时,才允许挂载,避免将节点放到按钮下
if (pid && map.has(pid) && byId.get(pid)?.type !== 3) {
const parent = map.get(pid)
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
})
setMenuItems(roots)
}, [rawMenus, iconMap])
const handleLogout = async () => {
try {
await api.post('/auth/logout')
} catch (_) {}
clearToken()
navigate('/login', { replace: true })
}
const openProfile = () => {
const u = getUserLocal()
setUser(u)
form.setFieldsValue({ username: u?.username, nickname: u?.nickname, avatar: (u as any)?.avatar || '' })
setProfileOpen(true)
}
const handleSaveProfile = async () => {
try{
const values = await form.validateFields()
const uid = (user as any)?.id
if (uid) {
const { data } = await api.put(`/users/${uid}`, { nickname: values.nickname })
if (data?.code !== 0) throw new Error(data?.message || '更新失败')
}
const merged = { ...(user as any), nickname: values.nickname, avatar: values.avatar }
setUserLocal(merged)
setUser(merged)
message.success('资料已更新')
setProfileOpen(false)
}catch(e: any){ if(e?.errorFields) return; message.error(e?.message || '更新失败') }
}
const onMenuClick = (info: any) => {
const key = info?.key as string
if (key && key.startsWith('/')) navigate(key)
}
const selectedKeys = useMemo(() => {
const k = loc.pathname
return [k]
}, [loc.pathname])
// 根据当前路径计算需要展开的父级菜单 keys不包含叶子自身
const [openKeys, setOpenKeys] = useState<string[]>(() => {
try {
const v = localStorage.getItem('layout:menuOpenKeys')
const arr = v ? JSON.parse(v) : []
return Array.isArray(arr) ? (arr as string[]) : []
} catch { return [] }
})
// 持久化用户手动展开/收起的子菜单状态,刷新后保持
useEffect(() => {
try { localStorage.setItem('layout:menuOpenKeys', JSON.stringify(openKeys)) } catch {}
}, [openKeys])
// 确保当前路由对应的父级菜单也处于展开状态,但不覆盖用户手动展开的其他菜单(合并而非替换)
const currentAncestors = useMemo(() => {
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms')
const idMap = new Map<number, MenuItemResp>()
const pathToId = new Map<string, number>()
list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) })
const currentPath = loc.pathname
let foundId: number | undefined = pathToId.get(currentPath)
if (!foundId) {
const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path))
if (candidates.length) {
candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0))
foundId = candidates[0].id
}
}
if (!foundId) return [] as string[]
const chain: MenuItemResp[] = []
let cur: MenuItemResp | undefined = idMap.get(foundId)
let guard = 0
while (cur && guard++ < 50) {
chain.push(cur)
const pid = cur.parent_id || undefined
cur = pid ? idMap.get(pid) : undefined
}
chain.reverse()
const ancestors = chain.slice(0, Math.max(0, chain.length - 1))
return ancestors.map((m: MenuItemResp) => (m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`))
}, [rawMenus, loc.pathname])
useEffect(() => {
setOpenKeys(prev => {
const s = new Set(prev)
currentAncestors.forEach(k => s.add(k))
return Array.from(s)
})
}, [currentAncestors])
const dropdownMenuItems = useMemo(() => ([
{ key: 'profile', label: '个人信息' },
{ type: 'divider' as any },
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined /> },
]), [])
// 基于菜单树与当前路径计算面包屑
const breadcrumbItems = useMemo(() => {
// 过滤掉不需要显示的菜单与按钮type 3
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms')
const idMap = new Map<number, MenuItemResp>()
const pathToId = new Map<string, number>()
list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) })
const currentPath = loc.pathname
let foundId: number | undefined
if (pathToId.has(currentPath)) {
foundId = pathToId.get(currentPath)!
} else {
// 兜底:取路径前缀最长匹配(例如 /users/detail/123 匹配 /users
const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path))
if (candidates.length) {
candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0))
foundId = candidates[0].id
}
}
if (!foundId) return [] as any[]
const chain: MenuItemResp[] = []
let cur: MenuItemResp | undefined = idMap.get(foundId)
let guard = 0
while (cur && guard++ < 50) {
chain.push(cur)
const pid = cur.parent_id || undefined
cur = pid ? idMap.get(pid) : undefined
}
chain.reverse()
return chain.map((m: MenuItemResp, idx: number) => ({
title: (m.path && idx !== chain.length - 1)
? <a onClick={(e: any) => { e.preventDefault(); navigate(m.path!) }}>{m.name}</a>
: m.name
}))
}, [rawMenus, loc.pathname, navigate])
// 路由页签与缓存(放在 return 之前的顶层)
type TabItem = { key: string; title: string; keepAlive: boolean }
const [tabs, setTabs] = useState<TabItem[]>([])
const [cache, setCache] = useState<Record<string, React.ReactNode>>({})
const outlet = useOutlet()
// 右侧菜单折叠状态(默认收起)
// 左侧菜单折叠状态:持久化到 localStorage刷新不丢失
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(() => {
try {
const v = localStorage.getItem('layout:leftCollapsed')
return v ? v === '1' : false
} catch { return false }
})
// 断点状态:仅用于小屏时临时强制折叠,不写入持久化
const [isBroken, setIsBroken] = useState(false)
useEffect(() => {
try { localStorage.setItem('layout:leftCollapsed', leftCollapsed ? '1' : '0') } catch {}
}, [leftCollapsed])
// 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态)
// 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态)
// path -> menu 映射,便于取标题与 keep_alive
const pathToMenu = useMemo(() => {
const map = new Map<string, MenuItemResp>()
rawMenus.forEach((m: MenuItemResp) => { if (m.path) map.set(m.path, m) })
return map
}, [rawMenus])
// 当前路由激活 key
const activeKey = useMemo(() => loc.pathname, [loc.pathname])
// 根据菜单变更,修正已存在 tabs 的标题与 keepAlive解决刷新后标题变为路径的问题
useEffect(() => {
setTabs(prev => {
let changed = false
const next = prev.map(t => {
// 特殊处理首页:无菜单映射时也应显示“首页”
if (t.key === '/') {
const nextTitle = '首页'
const nextKeep = true
if (t.title !== nextTitle || t.keepAlive !== nextKeep) {
changed = true
return { ...t, title: nextTitle, keepAlive: nextKeep }
}
return t
}
const m = pathToMenu.get(t.key)
if (!m) return t
const nextKeep = (typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true
const nextTitle = m.name || t.title
if (t.title !== nextTitle || t.keepAlive !== nextKeep) {
changed = true
return { ...t, title: nextTitle, keepAlive: nextKeep }
}
return t
})
return changed ? next : prev
})
}, [pathToMenu])
// 根据当前路由补齐 tabs
useEffect(() => {
const curPath = loc.pathname
if (!curPath.startsWith('/')) return
const m = pathToMenu.get(curPath)
const title = m?.name || (curPath === '/' ? '首页' : curPath)
const keepAlive = (m && typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true
setTabs(prev => (prev.some(t => t.key === curPath) ? prev : [...prev, { key: curPath, title, keepAlive }]))
}, [loc.pathname, pathToMenu])
// 确保缓存当前激活页(若 keepAlive 打开)
useEffect(() => {
const cur = tabs.find(t => t.key === activeKey)
if (!cur) return
if (cur.keepAlive && outlet && !cache[activeKey]) {
setCache(prev => ({ ...prev, [activeKey]: outlet }))
}
}, [activeKey, outlet, tabs, cache])
const onTabChange = (key: string) => { if (key && key !== activeKey) navigate(key) }
const onTabEdit = (targetKey: any, action: 'add' | 'remove') => {
if (action === 'remove') {
const tKey = String(targetKey)
if (tKey === '/') return
setTabs(prev => {
const idx = prev.findIndex(t => t.key === tKey)
if (idx === -1) return prev
const next = prev.filter(t => t.key !== tKey)
// 清理缓存
setCache(c => { const n = { ...c }; delete n[tKey]; return n })
// 如果关闭的是当前激活页,跳转到相邻页
if (tKey === activeKey) {
const fallback = next[idx - 1] || next[idx] || { key: '/' }
if (fallback.key) navigate(fallback.key)
}
return next
})
}
}
// 确保“首页”始终存在且固定(仅初始化时补齐一次)
useEffect(() => {
setTabs(prev => (prev.some(t => t.key === '/') ? prev : [{ key: '/', title: '首页', keepAlive: true }, ...prev]))
}, [])
return (
<PermissionProvider codes={permissionCodes}>
<ConfigProvider locale={localeObj}>
<Layout style={{ minHeight: '100vh' }}>
{/* 其余内容保持不变 */}
<Sider
collapsible
collapsed={isBroken ? true : leftCollapsed}
onCollapse={(collapsed, type) => {
// 仅在用户点击触发器时更新用户偏好;响应式折叠不写入
if (type === 'clickTrigger') {
setLeftCollapsed(collapsed)
}
}}
breakpoint="lg"
onBreakpoint={(broken) => setIsBroken(broken)}
trigger={null}
collapsedWidth={56}
width={220}
style={{ background: colorBgContainer }}
>
<div
style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}
onClick={() => setLeftCollapsed(c => !c)}
title={leftCollapsed ? '展开菜单' : '收起菜单'}
>
{leftCollapsed ? 'U' : 'Udmin'}
</div>
<Menu
mode="inline"
selectedKeys={selectedKeys}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
inlineCollapsed={isBroken ? true : leftCollapsed}
items={menuItems}
onClick={onMenuClick}
style={{ borderInlineEnd: 0 }}
/>
</Sider>
<Layout>
<Header style={{ padding: '8px 16px', background: colorBgContainer }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 12 }}>
{/* 用户要求:隐藏展开/收起图标,保留名称点击收起/展开 */}
{tabs.length > 0 && (
<Tabs
className="top-tabs"
hideAdd
type="editable-card"
items={tabs.map(t => ({ key: t.key, label: t.title, closable: t.key !== '/' }))}
activeKey={activeKey}
onChange={onTabChange}
onEdit={onTabEdit as any}
/>
)}
</div>
<Space size={16} align="center">
<Dropdown
menu={{
items: [
{ key: 'zh', label: '中文' },
{ key: 'en', label: 'English' },
],
onClick: ({ key }) => { const v = key as 'zh'|'en'; setLang(v); localStorage.setItem('lang', v) }
}}
placement="bottomLeft"
>
<Space style={{ cursor: 'pointer', userSelect: 'none' }}>
<GlobalOutlined />
<span>{lang === 'zh' ? '中文' : 'English'}</span>
</Space>
</Dropdown>
<Dropdown
menu={{ items: dropdownMenuItems as any, onClick: (e: { key: string }) => { if (e.key === 'logout') handleLogout(); if (e.key === 'profile') openProfile() } }}
placement="bottomRight"
>
<Space style={{ cursor: 'pointer' }}>
<Avatar size={32} icon={<UserOutlined />} />
<span>{(user as any)?.nickname || (user as any)?.username || '用户'}</span>
</Space>
</Dropdown>
</Space>
</div>
</Header>
<Content style={{ margin: '16px', padding: 16, background: colorBgContainer, borderRadius: borderRadiusLG }}>
{/* 缓存的标签页:非激活隐藏 */}
{tabs.filter(t => t.keepAlive).map(t => (
<div key={t.key} style={{ display: t.key === activeKey ? 'block' : 'none' }}>
{cache[t.key]}
</div>
))}
{/* 未开启缓存:只渲染当前路由 */}
{(() => {
const cur = tabs.find(t => t.key === activeKey)
if (!cur) return null
if (cur.keepAlive) return null
return <div key={activeKey}>{outlet}</div>
})()}
</Content>
</Layout>
</Layout>
<Modal title="个人信息" open={profileOpen} onOk={handleSaveProfile} onCancel={() => setProfileOpen(false)} okText="保存" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="username" label="用户名">
<Input disabled />
</Form.Item>
<Form.Item name="nickname" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="avatar" label="头像 URL">
<Input placeholder="请输入头像图片地址URL" />
</Form.Item>
</Form>
</Modal>
</ConfigProvider>
</PermissionProvider>
)
}

View File

@ -0,0 +1 @@
.logo{height:48px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px}

12
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import 'antd/dist/reset.css'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>
)

View File

@ -0,0 +1,171 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Card, Col, Progress, Row, Statistic, Table, Tag, Typography } from 'antd'
import api from '../utils/axios'
import { formatDateTime } from '../utils/datetime'
import PageHeader from '../components/PageHeader'
interface UserItem { id: number; username: string; nickname?: string; status?: number; created_at?: string }
export default function Dashboard() {
const [loading, setLoading] = useState(false)
const [userTotal, setUserTotal] = useState(0)
const [roleTotal, setRoleTotal] = useState(0)
const [deptTotal, setDeptTotal] = useState(0)
const [menuTotal, setMenuTotal] = useState(0)
const [userSample, setUserSample] = useState([] as UserItem[])
useEffect(() => {
const fetchAll = async () => {
setLoading(true)
try {
const [usersRes, rolesRes, deptsRes, menusRes, usersSampleRes] = await Promise.all([
api.get('/users', { params: { page: 1, page_size: 1 } }),
api.get('/roles', { params: { page: 1, page_size: 1 } }),
api.get('/departments', { params: { keyword: '' } }),
api.get('/menus'),
api.get('/users', { params: { page: 1, page_size: 200 } }),
])
if (usersRes.data?.code === 0) setUserTotal(Number(usersRes.data?.data?.total || 0))
if (rolesRes.data?.code === 0) setRoleTotal(Number(rolesRes.data?.data?.total || 0))
if (deptsRes.data?.code === 0) setDeptTotal(Array.isArray(deptsRes.data?.data) ? deptsRes.data.data.length : 0)
if (menusRes.data?.code === 0) setMenuTotal(Array.isArray(menusRes.data?.data) ? menusRes.data.data.length : 0)
if (usersSampleRes.data?.code === 0) setUserSample(Array.isArray(usersSampleRes.data?.data?.items) ? usersSampleRes.data.data.items : [])
} catch (e) {
// ignore on dashboard
} finally { setLoading(false) }
}
fetchAll()
}, [])
// 用户状态分布(基于 sample 数据近似统计)
const statusDist = useMemo(() => {
const enabled = userSample.reduce((acc, u) => acc + (u.status === 1 ? 1 : 0), 0)
const total = userSample.length || 1
const percentEnabled = Math.round((enabled / total) * 100)
return { enabled, disabled: total - enabled, percentEnabled }
}, [userSample])
// 近7天新增用户基于 sample 的 created_at 统计)
const last7Days = useMemo(() => {
const today = new Date()
const days: string[] = []
for (let i = 6; i >= 0; i--) {
const d = new Date(today)
d.setDate(today.getDate() - i)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const da = String(d.getDate()).padStart(2, '0')
days.push(`${y}-${m}-${da}`)
}
const counter: Record<string, number> = Object.fromEntries(days.map(d => [d, 0]))
userSample.forEach(u => {
if (!u.created_at) return
const dt = new Date(u.created_at)
if (Number.isNaN(dt.getTime())) return
const y = dt.getFullYear()
const m = String(dt.getMonth() + 1).padStart(2, '0')
const da = String(dt.getDate()).padStart(2, '0')
const key = `${y}-${m}-${da}`
if (key in counter) counter[key] += 1
})
return days.map(d => ({ date: d, value: counter[d] || 0 }))
}, [userSample])
const userColumns = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' },
{ title: '昵称', dataIndex: 'nickname' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
], [])
const recentUsers = useMemo(() => {
const withTime = userSample.filter(u => !!u.created_at)
withTime.sort((a, b) => (new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()))
return withTime.slice(0, 8)
}, [userSample])
const maxDaily = Math.max(...last7Days.map(d => d.value), 1)
return (
<div>
<PageHeader items={["首页"]} title="首页" />
<Row gutter={[16, 16]}>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
<Statistic title="用户总数" value={userTotal} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
<Statistic title="角色总数" value={roleTotal} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
<Statistic title="部门总数" value={deptTotal} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card loading={loading}>
<Statistic title="菜单总数" value={menuTotal} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} md={12}>
<Card title="用户状态分布(样本)" loading={loading}>
<Row gutter={24}>
<Col span={12} style={{ textAlign: 'center' }}>
<Progress type="dashboard" percent={statusDist.percentEnabled} />
<div style={{ marginTop: 8 }}></div>
</Col>
<Col span={12}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div><Tag color="green">{statusDist.enabled}</Tag></div>
<div><Tag>{statusDist.disabled}</Tag></div>
<div>{userSample.length}</div>
</div>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} md={12}>
<Card title="近7天新增用户样本" loading={loading}>
<div style={{ height: 180, display: 'flex', alignItems: 'flex-end', gap: 8 }}>
{last7Days.map(d => (
<div key={d.date} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: 20, background: '#1677ff', height: Math.max(4, (d.value / maxDaily) * 140) }} />
<div style={{ marginTop: 6, fontSize: 12 }}>{d.value}</div>
<div style={{ marginTop: 2, fontSize: 12, color: '#999' }}>{d.date.slice(5)}</div>
</div>
))}
</div>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={24}>
<Card title="最近注册用户(样本)">
<Table<UserItem>
rowKey="id"
size="small"
pagination={false}
dataSource={recentUsers}
columns={userColumns as any}
/>
</Card>
</Col>
</Row>
<Card style={{ marginTop: 16 }}>
<Typography.Paragraph>
使 Udmin
</Typography.Paragraph>
</Card>
</div>
)
}

View File

@ -0,0 +1,257 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Form, Input, Modal, Select, Space, Table, Tag, message, Popconfirm, TreeSelect } from 'antd'
import api from '../utils/axios'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined, ApartmentOutlined, FolderOutlined, FileOutlined } from '@ant-design/icons'
import PageHeader from '../components/PageHeader'
interface DeptItem { id: number; parent_id?: number; name: string; order_no: number; status: number; created_at?: string; children?: DeptItem[] }
export default function Departments(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as DeptItem[])
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as DeptItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
const fetchList = async (kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get('/departments', { params: { keyword: kw } })
if(data?.code === 0){ setData(data.data || []) } else { throw new Error(data?.message || '获取部门失败') }
}catch(e: any){ message.error(e.message || '获取部门失败') } finally { setLoading(false) }
}
const didInitFetchRef = useRef(false)
useEffect(()=>{
if(didInitFetchRef.current) return
didInitFetchRef.current = true
fetchList('')
}, [])
// 构建部门树用于树形选择
type DeptTreeNode = { title: string; value: number; key: number; children?: DeptTreeNode[] }
const deptTreeData: DeptTreeNode[] = useMemo(() => {
const map = new Map<number, DeptTreeNode & { children: DeptTreeNode[] }>()
data.forEach((d: DeptItem) => map.set(d.id, { title: d.name, value: d.id, key: d.id, children: [] }))
const roots: (DeptTreeNode & { children: DeptTreeNode[] })[] = []
data.forEach((d: DeptItem) => {
const node = map.get(d.id)!
if (d.parent_id && map.has(d.parent_id)) {
const parent = map.get(d.parent_id)
if (parent) parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [data])
const getDescendantIds = (list: DeptItem[], id: number): Set<number> => {
const childrenMap = new Map<number, number[]>()
list.forEach(d => {
if(d.parent_id){
if(!childrenMap.has(d.parent_id)) childrenMap.set(d.parent_id, [])
childrenMap.get(d.parent_id)!.push(d.id)
}
})
const res = new Set<number>()
const stack = (childrenMap.get(id) || []).slice()
while(stack.length){
const cur = stack.pop()!
if(!res.has(cur)){
res.add(cur)
const next = childrenMap.get(cur) || []
next.forEach(n => stack.push(n))
}
}
return res
}
const filterTree = (nodes: DeptTreeNode[], blocked: Set<number>): DeptTreeNode[] => {
const recur = (arr: DeptTreeNode[]): DeptTreeNode[] => arr
.filter(n => !blocked.has(n.key as number))
.map(n => ({ ...n, children: n.children ? recur(n.children) : undefined }))
return recur(nodes)
}
const editTreeData = useMemo(() => {
if(!current) return deptTreeData
const blocked = getDescendantIds(data, current.id)
blocked.add(current.id)
return filterTree(deptTreeData, blocked)
}, [current, deptTreeData, data])
// 构建用于表格展示的部门树
const treeDataForTable: DeptItem[] = useMemo(() => {
const map = new Map<number, DeptItem>()
const roots: DeptItem[] = []
// 初始化节点(不预置 children避免叶子显示展开图标
data.forEach((d: DeptItem) => map.set(d.id, { ...d }))
data.forEach((d: DeptItem) => {
const node = map.get(d.id)!
if (d.parent_id && map.has(d.parent_id)) {
const parent = map.get(d.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [data])
// 默认展开顶层且有子节点的部门
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([])
useEffect(() => {
const rootsWithChildren = treeDataForTable
.filter((n: DeptItem) => Array.isArray(n.children) && n.children.length > 0)
.map((n: DeptItem) => n.id as React.Key)
setExpandedRowKeys(rootsWithChildren)
}, [treeDataForTable]);
// (已移除)一键展开/收起工具
// 已根据你的要求删除“展开顶层 / 展开全部 / 收起全部”相关函数与按钮
// 新增/编辑处理
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
const payload = {
...values,
parent_id: values.parent_id != null && values.parent_id !== '' ? Number(values.parent_id) : undefined,
order_no: values.order_no !== undefined && values.order_no !== '' ? Number(values.order_no) : 0,
status: values.status !== undefined ? Number(values.status) : 1,
}
const { data } = await api.post('/departments', payload)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchList(keyword) } else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: DeptItem) => { setCurrent(record); editForm.setFieldsValue({ parent_id: record.parent_id, name: record.name, order_no: record.order_no, status: record.status }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
const payload = {
...values,
parent_id: values.parent_id != null && values.parent_id !== '' ? Number(values.parent_id) : undefined,
order_no: values.order_no !== undefined && values.order_no !== '' ? Number(values.order_no) : undefined,
status: values.status !== undefined ? Number(values.status) : undefined,
}
const { data } = await api.put(`/departments/${current!.id}`, payload)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchList(keyword) } else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const columns: ColumnsType<DeptItem> = useMemo(() => [
{ title: '名称', dataIndex: 'name', render: (_: any, r: DeptItem) => {
const isRoot = r.parent_id == null
const hasChildren = Array.isArray(r.children) && r.children.length > 0
const Icon = isRoot ? ApartmentOutlined : hasChildren ? FolderOutlined : FileOutlined
const color = isRoot ? '#1677ff' : hasChildren ? '#faad14' : '#999'
return (
<Space size={6}>
<Icon style={{ color }} />
<span>{r.name}</span>
</Space>
)
}},
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '排序', dataIndex: 'order_no', width: 100 },
{ title: '状态', dataIndex: 'status', width: 120, render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', width: 200, render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 300, render: (_: any, r: DeptItem) => (
<Space>
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
<Popconfirm title={`确认删除部门「${r.name}」?(若存在子部门将无法删除)`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/departments/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchList(keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
</Space>
)},
], [keyword])
return (
<div>
<PageHeader items={["系统管理","部门管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchList(kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索部门" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchList('') }}></Button>
<Button type="primary" onClick={onCreate}></Button>
</Space>
</Form.Item>
</Form>
<Table<DeptItem>
rowKey="id"
loading={loading}
dataSource={treeDataForTable}
columns={columns}
expandable={{ expandedRowKeys, onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as React.Key[]), indentSize: 18, rowExpandable: (record: DeptItem) => Array.isArray(record.children) && record.children.length > 0 }}
pagination={false}
/>
<Modal title="新增部门" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="parent_id" label="上级部门">
<TreeSelect
allowClear
placeholder="选择上级(可为空,表示顶级部门)"
style={{ width: '100%' }}
treeData={deptTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
/>
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="请输入名称" />
</Form.Item>
<Form.Item name="order_no" label="排序" initialValue={0}>
<Input type="number" placeholder="数字越小越靠前" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑部门" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="parent_id" label="上级部门">
<TreeSelect
allowClear
placeholder="选择上级(不可选择自身及其子部门)"
style={{ width: '100%' }}
treeData={editTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
/>
</Form.Item>
<Form.Item name="name" label="名称">
<Input placeholder="请输入名称" />
</Form.Item>
<Form.Item name="order_no" label="排序">
<Input type="number" placeholder="数字越小越靠前" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,47 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { useNavigate } from 'react-router-dom'
import api from '../utils/axios'
import { setToken, setUser } from '../utils/token'
import './login.css'
export default function Login() {
const navigate = useNavigate()
const [form] = Form.useForm()
const onFinish = async (values: any) => {
try {
const { data } = await api.post('/auth/login', values)
if (data?.code === 0) {
const token = data.data.access_token
setToken(token)
setUser(data.data.user)
message.success('登录成功')
navigate('/', { replace: true })
} else {
throw new Error(data?.message || '登录失败')
}
} catch (e: any) {
message.error(e.message || '登录失败')
}
}
return (
<div className="login-wrap">
<Card className="login-card" variant="outlined">
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>Udmin </Typography.Title>
<Form form={form} onFinish={onFinish} layout="vertical" initialValues={{ username: 'admin', password: 'Admin@123' }}>
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
<Input size="large" prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password size="large" prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block></Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

172
frontend/src/pages/Logs.tsx Normal file
View File

@ -0,0 +1,172 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message, Descriptions, DatePicker, Drawer, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import api, { ApiResp } from '../utils/axios'
import dayjs from 'dayjs'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import { EyeOutlined } from '@ant-design/icons'
import PageHeader from '../components/PageHeader'
interface LogInfo {
id: number
path: string
method: string
request_params?: string
response_params?: string
status_code: number
user_id?: number
username?: string
request_time: string
duration_ms: number
}
interface PageResp<T> { items: T[]; total: number; page: number; page_size: number }
export default function Logs() {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [data, setData] = useState<LogInfo[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [detailOpen, setDetailOpen] = useState(false)
const [detail, setDetail] = useState<LogInfo | null>(null)
const fetchData = async (p = page, ps = pageSize) => {
const v = form.getFieldsValue()
const params: any = { page: p, page_size: ps }
if (v.path) params.path = v.path
if (v.time && Array.isArray(v.time) && v.time.length === 2) {
params.start_time = (v.time[0] as dayjs.Dayjs).toISOString()
params.end_time = (v.time[1] as dayjs.Dayjs).toISOString()
}
setLoading(true)
try {
const { data } = await api.get<ApiResp<PageResp<LogInfo>>>('/logs', { params })
if (data?.code === 0) {
setData(data.data?.items || [])
setTotal(data.data?.total || 0)
setPage(data.data?.page || p)
setPageSize(data.data?.page_size || ps)
}
} finally { setLoading(false) }
}
useEffect(() => { fetchData(1, 10) }, [])
const openDetail = (record: LogInfo) => { setDetail(record); setDetailOpen(true) }
const closeDetail = () => setDetailOpen(false)
const columns = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '时间', dataIndex: 'request_time', width: 180, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss') },
{ title: '路径', dataIndex: 'path', width: 240 },
{ title: '方法', dataIndex: 'method', width: 90, render: (m: string) => <Tag>{m}</Tag> },
{ title: '用户', dataIndex: 'username', width: 140, render: (_: any, r: LogInfo) => r.username || (r.user_id ? `UID:${r.user_id}` : '-') },
{ title: '状态码', dataIndex: 'status_code', width: 100 },
{ title: '耗时(ms)', dataIndex: 'duration_ms', width: 100 },
{ title: '请求参数', dataIndex: 'request_params', width: 260, ellipsis: true },
{ title: '响应参数', dataIndex: 'response_params', width: 260, ellipsis: true },
{ title: '操作', key: 'action', fixed: 'right' as any, width: 120, render: (_: any, r: LogInfo) => (
<Space>
<a className="action-link" onClick={() => openDetail(r)}>
<EyeOutlined />
<span></span>
</a>
</Space>
)},
], [])
const tryParse = (s: string) => { try { return JSON.parse(s) } catch { return undefined } }
const prettyJson = (raw?: string) => {
if (!raw) return ''
let text = raw
// First try direct JSON
const p1 = tryParse(text)
if (p1 !== undefined) {
if (typeof p1 === 'string') {
const p2 = tryParse(p1)
text = JSON.stringify(p2 !== undefined ? p2 : p1, null, 2)
} else {
text = JSON.stringify(p1, null, 2)
}
return text
}
// Try URI-decoded then JSON
try {
const decoded = decodeURIComponent(text)
const p3 = tryParse(decoded)
if (p3 !== undefined) return JSON.stringify(p3, null, 2)
} catch {}
return text
}
const highlightCode = (raw?: string) => {
const code = prettyJson(raw)
try { return hljs.highlight(code, { language: 'json' }).value } catch { return hljs.highlightAuto(code).value }
}
const reqHtml = useMemo(() => highlightCode(detail?.request_params), [detail?.request_params])
const respHtml = useMemo(() => highlightCode(detail?.response_params), [detail?.response_params])
return (
<div>
<PageHeader items={["系统管理","日志管理"]} title="" />
<Form form={form} layout="inline" onFinish={() => fetchData(1, pageSize)} style={{ marginBottom: 12 }}>
<Form.Item label="请求路径" name="path">
<Input placeholder="like /users" allowClear style={{ width: 260 }} />
</Form.Item>
<Form.Item label="发起时间" name="time">
<DatePicker.RangePicker showTime allowClear />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); fetchData(1, 10) }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
dataSource={data}
columns={columns as any}
scroll={{ x: 2000 }}
pagination={{ current: page, pageSize, total, onChange: (p, ps) => fetchData(p, ps) }}
/>
<Drawer title="日志详情" width={720} open={detailOpen} onClose={closeDetail} destroyOnClose placement="right">
{detail && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="时间">{dayjs(detail.request_time).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
<Descriptions.Item label="路径">{detail.path}</Descriptions.Item>
<Descriptions.Item label="方法"><Tag>{detail.method}</Tag></Descriptions.Item>
<Descriptions.Item label="用户">{detail.username || (detail.user_id ? `UID:${detail.user_id}` : '-')}</Descriptions.Item>
<Descriptions.Item label="状态码">{detail.status_code}</Descriptions.Item>
<Descriptions.Item label="耗时(ms)">{detail.duration_ms}</Descriptions.Item>
</Descriptions>
<div>
<Typography.Text strong></Typography.Text>
<pre className="hljs" style={{ background: '#f6f6f6', padding: 12, borderRadius: 6, maxHeight: 240, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
<code dangerouslySetInnerHTML={{ __html: reqHtml }} />
</pre>
</div>
<div>
<Typography.Text strong></Typography.Text>
<pre className="hljs" style={{ background: '#f6f6f6', padding: 12, borderRadius: 6, maxHeight: 520, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
<code dangerouslySetInnerHTML={{ __html: respHtml }} />
</pre>
</div>
</Space>
)}
</Drawer>
</div>
)
}

View File

@ -0,0 +1,538 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Select, Space, Table, Tag, message, Popconfirm, Popover, Switch, TreeSelect } from 'antd'
import api from '../utils/axios'
import { HomeOutlined, UserOutlined, TeamOutlined, SettingOutlined, AppstoreOutlined, KeyOutlined, DashboardOutlined, FileOutlined, LockOutlined, MenuOutlined, PieChartOutlined, BarChartOutlined, TableOutlined, CalendarOutlined, FormOutlined, SearchOutlined, ToolOutlined, ShoppingCartOutlined, ShopOutlined, FolderOpenOutlined, FolderOutlined, CloudOutlined, DatabaseOutlined, ApiOutlined, CodeOutlined, BugOutlined, BellOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, EyeOutlined, EyeInvisibleOutlined, StarOutlined, HeartOutlined, LikeOutlined, DislikeOutlined, SmileOutlined, FrownOutlined, PhoneOutlined, MailOutlined, EnvironmentOutlined, GlobalOutlined, AimOutlined, CompassOutlined, CameraOutlined, VideoCameraOutlined, SoundOutlined, WifiOutlined, RocketOutlined, ThunderboltOutlined, ExperimentOutlined, BulbOutlined, GiftOutlined, BankOutlined, WalletOutlined, MoneyCollectOutlined, BookOutlined, ReadOutlined, ProfileOutlined, CloudUploadOutlined, CloudDownloadOutlined, InboxOutlined, FolderAddOutlined, SlidersOutlined, FilterOutlined, AlertOutlined, ClockCircleOutlined, FieldTimeOutlined, HistoryOutlined, ContactsOutlined, SolutionOutlined, IdcardOutlined, QrcodeOutlined, ScanOutlined, SafetyOutlined, SecurityScanOutlined, UnlockOutlined, HddOutlined, CopyOutlined, ScissorOutlined, SnippetsOutlined, FileProtectOutlined, DesktopOutlined, LaptopOutlined, MobileOutlined, TabletOutlined, ClusterOutlined, AppstoreAddOutlined, PlusSquareOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
// 定义图标项类型,避免隐式 any
interface IconItem { name: string; node: any }
interface MenuItem {
id: number
parent_id?: number
name: string
code?: string
path?: string
component?: string
type: number // 1:目录 2:菜单 3:按钮
icon?: string
order_no?: number
visible?: boolean
status?: number
keep_alive?: boolean
perms?: string
created_at?: string
// 支持树形展示
children?: MenuItem[]
}
// 平铺图标选择器(悬停展开 + 可滚动)
interface IconPickerProps { value?: string; onChange?: (v?: string) => void; items: IconItem[] }
const IconPicker = ({ value, onChange, items }: IconPickerProps) => {
const current = items.find((i: IconItem) => i.name === value)
const content = (
<div style={{ width: 520 }}>
<div style={{ maxHeight: 260, overflowY: 'auto', padding: 8 }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 8,
}}
>
<div
onClick={() => onChange && onChange(undefined)}
style={{
border: value ? '1px solid #f0f0f0' : '1px solid #1677ff',
borderRadius: 6,
padding: 10,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
justifyContent: 'center',
}}
>
<span style={{ fontSize: 12 }}></span>
</div>
{items.map((it: IconItem) => {
const active = value === it.name
return (
<div
key={it.name}
onClick={() => onChange && onChange(it.name)}
style={{
border: active ? '1px solid #1677ff' : '1px solid #f0f0f0',
borderRadius: 6,
padding: 10,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
justifyContent: 'center',
}}
title={it.name}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>{it.node}</span>
<span style={{ fontSize: 12 }}>{it.name}</span>
</div>
)
})}
</div>
</div>
</div>
)
return (
<Popover trigger="hover" placement="bottomLeft" mouseEnterDelay={0.05} content={content}
overlayInnerStyle={{ padding: 0 }}
>
<div
style={{
border: '1px solid #d9d9d9',
borderRadius: 6,
height: 36,
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '0 12px',
cursor: 'pointer',
minWidth: 160,
}}
>
{current ? (
<>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>{current.node}</span>
<span style={{ fontSize: 12, color: '#555' }}>{current.name}</span>
</>
) : (
<span style={{ fontSize: 12, color: '#999' }}></span>
)}
</div>
</Popover>
)
}
export default function Menus(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as MenuItem[])
const [parents, setParents] = useState([] as MenuItem[])
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
// 是否显示按钮type=3
const [showButtons, setShowButtons] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as MenuItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 权限判断
const { has } = usePermission()
// 可选图标列表(名称需与 MainLayout.iconMap 保持一致)
const iconItems: IconItem[] = useMemo(() => [
{ name: 'HomeOutlined', node: <HomeOutlined /> },
{ name: 'UserOutlined', node: <UserOutlined /> },
{ name: 'TeamOutlined', node: <TeamOutlined /> },
{ name: 'SettingOutlined', node: <SettingOutlined /> },
{ name: 'AppstoreOutlined', node: <AppstoreOutlined /> },
{ name: 'KeyOutlined', node: <KeyOutlined /> },
{ name: 'DashboardOutlined', node: <DashboardOutlined /> },
{ name: 'FileOutlined', node: <FileOutlined /> },
{ name: 'LockOutlined', node: <LockOutlined /> },
{ name: 'MenuOutlined', node: <MenuOutlined /> },
{ name: 'PieChartOutlined', node: <PieChartOutlined /> },
{ name: 'BarChartOutlined', node: <BarChartOutlined /> },
{ name: 'TableOutlined', node: <TableOutlined /> },
{ name: 'CalendarOutlined', node: <CalendarOutlined /> },
{ name: 'FormOutlined', node: <FormOutlined /> },
{ name: 'SearchOutlined', node: <SearchOutlined /> },
{ name: 'ToolOutlined', node: <ToolOutlined /> },
{ name: 'ShoppingCartOutlined', node: <ShoppingCartOutlined /> },
{ name: 'ShopOutlined', node: <ShopOutlined /> },
{ name: 'FolderOpenOutlined', node: <FolderOpenOutlined /> },
{ name: 'FolderOutlined', node: <FolderOutlined /> },
{ name: 'CloudOutlined', node: <CloudOutlined /> },
{ name: 'DatabaseOutlined', node: <DatabaseOutlined /> },
{ name: 'ApiOutlined', node: <ApiOutlined /> },
{ name: 'CodeOutlined', node: <CodeOutlined /> },
{ name: 'BugOutlined', node: <BugOutlined /> },
{ name: 'BellOutlined', node: <BellOutlined /> },
// 新增常用图标
{ name: 'PlusOutlined', node: <PlusOutlined /> },
{ name: 'EditOutlined', node: <EditOutlined /> },
{ name: 'DeleteOutlined', node: <DeleteOutlined /> },
{ name: 'UploadOutlined', node: <UploadOutlined /> },
{ name: 'DownloadOutlined', node: <DownloadOutlined /> },
{ name: 'EyeOutlined', node: <EyeOutlined /> },
{ name: 'EyeInvisibleOutlined', node: <EyeInvisibleOutlined /> },
{ name: 'StarOutlined', node: <StarOutlined /> },
{ name: 'HeartOutlined', node: <HeartOutlined /> },
{ name: 'LikeOutlined', node: <LikeOutlined /> },
{ name: 'DislikeOutlined', node: <DislikeOutlined /> },
{ name: 'SmileOutlined', node: <SmileOutlined /> },
{ name: 'FrownOutlined', node: <FrownOutlined /> },
{ name: 'PhoneOutlined', node: <PhoneOutlined /> },
{ name: 'MailOutlined', node: <MailOutlined /> },
{ name: 'EnvironmentOutlined', node: <EnvironmentOutlined /> },
{ name: 'GlobalOutlined', node: <GlobalOutlined /> },
{ name: 'AimOutlined', node: <AimOutlined /> },
{ name: 'CompassOutlined', node: <CompassOutlined /> },
{ name: 'CameraOutlined', node: <CameraOutlined /> },
{ name: 'VideoCameraOutlined', node: <VideoCameraOutlined /> },
{ name: 'SoundOutlined', node: <SoundOutlined /> },
{ name: 'WifiOutlined', node: <WifiOutlined /> },
{ name: 'RocketOutlined', node: <RocketOutlined /> },
{ name: 'ThunderboltOutlined', node: <ThunderboltOutlined /> },
{ name: 'ExperimentOutlined', node: <ExperimentOutlined /> },
{ name: 'BulbOutlined', node: <BulbOutlined /> },
{ name: 'GiftOutlined', node: <GiftOutlined /> },
{ name: 'BankOutlined', node: <BankOutlined /> },
{ name: 'WalletOutlined', node: <WalletOutlined /> },
{ name: 'MoneyCollectOutlined', node: <MoneyCollectOutlined /> },
{ name: 'BookOutlined', node: <BookOutlined /> },
{ name: 'ReadOutlined', node: <ReadOutlined /> },
{ name: 'ProfileOutlined', node: <ProfileOutlined /> },
{ name: 'CloudUploadOutlined', node: <CloudUploadOutlined /> },
{ name: 'CloudDownloadOutlined', node: <CloudDownloadOutlined /> },
{ name: 'InboxOutlined', node: <InboxOutlined /> },
{ name: 'FolderAddOutlined', node: <FolderAddOutlined /> },
{ name: 'SlidersOutlined', node: <SlidersOutlined /> },
{ name: 'FilterOutlined', node: <FilterOutlined /> },
{ name: 'AlertOutlined', node: <AlertOutlined /> },
{ name: 'ClockCircleOutlined', node: <ClockCircleOutlined /> },
{ name: 'FieldTimeOutlined', node: <FieldTimeOutlined /> },
{ name: 'HistoryOutlined', node: <HistoryOutlined /> },
{ name: 'ContactsOutlined', node: <ContactsOutlined /> },
{ name: 'SolutionOutlined', node: <SolutionOutlined /> },
{ name: 'IdcardOutlined', node: <IdcardOutlined /> },
{ name: 'QrcodeOutlined', node: <QrcodeOutlined /> },
{ name: 'ScanOutlined', node: <ScanOutlined /> },
{ name: 'SafetyOutlined', node: <SafetyOutlined /> },
{ name: 'SecurityScanOutlined', node: <SecurityScanOutlined /> },
{ name: 'UnlockOutlined', node: <UnlockOutlined /> },
{ name: 'HddOutlined', node: <HddOutlined /> },
{ name: 'CopyOutlined', node: <CopyOutlined /> },
{ name: 'ScissorOutlined', node: <ScissorOutlined /> },
{ name: 'SnippetsOutlined', node: <SnippetsOutlined /> },
{ name: 'FileProtectOutlined', node: <FileProtectOutlined /> },
{ name: 'DesktopOutlined', node: <DesktopOutlined /> },
{ name: 'LaptopOutlined', node: <LaptopOutlined /> },
{ name: 'MobileOutlined', node: <MobileOutlined /> },
{ name: 'TabletOutlined', node: <TabletOutlined /> },
{ name: 'ClusterOutlined', node: <ClusterOutlined /> },
{ name: 'AppstoreAddOutlined', node: <AppstoreAddOutlined /> },
{ name: 'PlusSquareOutlined', node: <PlusSquareOutlined /> },
{ name: 'SyncOutlined', node: <SyncOutlined /> },
{ name: 'ReloadOutlined', node: <ReloadOutlined /> },
], [])
const fetchMenus = async (kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/menus`, { params: { keyword: kw } })
if(data?.code === 0){ setData(data.data || []); setParents((data.data || []).filter((m: MenuItem) => m.type !== 3)) }
else { throw new Error(data?.message || '获取菜单失败') }
}catch(e: any){ message.error(e.message || '获取菜单失败') } finally { setLoading(false) }
}
useEffect(() => { fetchMenus(keyword) }, [])
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
if (!values.icon) delete values.icon // 选择“无”或未选择,不提交字段
const payload = { ...values, status: values.status === true ? 1 : 0, parent_id: values.parent_id != null && values.parent_id !== '' ? Number(values.parent_id) : undefined }
const { data } = await api.post('/menus', payload)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchMenus() }
else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: MenuItem) => { setCurrent(record); editForm.setFieldsValue({ ...record, status: record.status === 1, type: record.type === 1 ? 2 : record.type }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
if (!values.icon) delete values.icon // 选择“无”或未选择,不提交字段
const payload = { ...values, status: values.status === true ? 1 : 0, parent_id: values.parent_id != null && values.parent_id !== '' ? Number(values.parent_id) : undefined }
const { data } = await api.put(`/menus/${current!.id}`, payload)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchMenus() }
else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
// 一键添加预设按钮(用户/角色/菜单页面的操作按钮)
const ensurePresetButtons = async () => {
const presets = [
{ parentName: '用户管理', buttons: [
{ name: '新增', perms: 'system:user:create' },
{ name: '编辑', perms: 'system:user:update' },
{ name: '重置密码', perms: 'system:user:reset' },
{ name: '删除', perms: 'system:user:delete' },
]},
{ parentName: '角色管理', buttons: [
{ name: '新增', perms: 'system:role:create' },
{ name: '编辑', perms: 'system:role:update' },
{ name: '分配菜单', perms: 'system:role:assign' },
{ name: '删除', perms: 'system:role:delete' },
]},
{ parentName: '菜单管理', buttons: [
{ name: '新增', perms: 'system:menu:create' },
{ name: '编辑', perms: 'system:menu:update' },
{ name: '删除', perms: 'system:menu:delete' },
]},
]
const menuByName: Record<string, MenuItem | undefined> = {}
data.forEach((m: MenuItem) => {
if (m.type !== 3 && (m.name === '用户管理' || m.name === '角色管理' || m.name === '菜单管理')) {
menuByName[m.name] = m
}
})
let created = 0
const missingParents: string[] = []
setLoading(true)
try {
for (const group of presets) {
const parent = menuByName[group.parentName]
if (!parent) { missingParents.push(group.parentName); continue }
for (const btn of group.buttons) {
const exists = data.some((m: MenuItem) => m.type === 3 && m.parent_id === parent.id && m.perms === btn.perms)
if (exists) continue
try {
const payload: Partial<MenuItem> = { parent_id: parent.id, name: btn.name, type: 3, perms: btn.perms, visible: true, status: 1 }
const { data: resp } = await api.post('/menus', payload)
if (resp?.code === 0) created++
} catch (e) {
// 单个失败忽略,继续后续
}
}
}
await fetchMenus(keyword)
if (created > 0) message.success(`已添加 ${created} 个按钮`)
else message.info('没有需要新增的按钮')
if (missingParents.length) message.warning(`未找到父菜单:${missingParents.join('、')}`)
} finally {
setLoading(false)
}
}
// 构建「上级菜单」树(非按钮均可作为上级,按钮不可作为上级)
type MenuTreeNode = { title: string; value: number; key: number; children?: MenuTreeNode[] }
const dirTreeData: MenuTreeNode[] = useMemo(() => {
const parents = data.filter((m: MenuItem) => m.type !== 3)
const map = new Map<number, MenuTreeNode & { children: MenuTreeNode[] }>()
parents.forEach((m: MenuItem) => map.set(m.id, { title: m.name, value: m.id, key: m.id, children: [] }))
const roots: (MenuTreeNode & { children: MenuTreeNode[] })[] = []
parents.forEach((m: MenuItem) => {
const node = map.get(m.id)!
if (m.parent_id && map.has(m.parent_id)) {
const parent = map.get(m.parent_id)
if (parent) parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [data])
// 计算某非按钮节点的所有子孙(编辑时用于禁选自身及其子孙)
const getNonButtonDescendantIds = (list: MenuItem[], id: number): Set<number> => {
const childrenMap = new Map<number, number[]>()
list.filter(m => m.type !== 3).forEach((m: MenuItem) => {
if (m.parent_id) {
if (!childrenMap.has(m.parent_id)) childrenMap.set(m.parent_id, [])
childrenMap.get(m.parent_id)!.push(m.id)
}
})
const res = new Set<number>()
const stack = (childrenMap.get(id) || []).slice()
while (stack.length) {
const cur = stack.pop()!
if (!res.has(cur)) {
res.add(cur)
const next = childrenMap.get(cur) || []
next.forEach(n => stack.push(n))
}
}
return res
}
const filterDirTree = (nodes: MenuTreeNode[], blocked: Set<number>): MenuTreeNode[] => {
const recur = (arr: MenuTreeNode[]): MenuTreeNode[] => arr
.filter(n => !blocked.has(n.key))
.map(n => ({ ...n, children: n.children ? recur(n.children) : undefined }))
return recur(nodes)
}
const editDirTreeData = useMemo(() => {
if (!current || current.type === 3) return dirTreeData
const blocked = getNonButtonDescendantIds(data, current.id)
blocked.add(current.id)
return filterDirTree(dirTreeData, blocked)
}, [current, data, dirTreeData])
// 将平铺数据构造成树形(用于表格展示)
const treeDataForTable: MenuItem[] = useMemo(() => {
const list = showButtons ? data : data.filter((m: MenuItem) => m.type !== 3)
const map = new Map<number, MenuItem>()
const roots: MenuItem[] = []
// 初始化节点(不预置 children避免叶子显示展开图标
list.forEach((m: MenuItem) => map.set(m.id, { ...m }))
list.forEach((m: MenuItem) => {
const node = map.get(m.id)!
if (m.parent_id && map.has(m.parent_id)) {
const parent = map.get(m.parent_id)!
// 按钮不可作为父级,若数据上挂在按钮下,则提升为根节点
if (parent.type !== 3) {
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
} else {
roots.push(node)
}
})
return roots
}, [data, showButtons])
// 默认展开最顶层菜单
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([])
useEffect(() => {
// 仅展开拥有子节点的顶层菜单
const rootsWithChildren = treeDataForTable
.filter((n: MenuItem) => Array.isArray(n.children) && n.children.length > 0)
.map((n: MenuItem) => n.id as React.Key)
setExpandedRowKeys(rootsWithChildren)
}, [treeDataForTable])
const columns: ColumnsType<MenuItem> = useMemo(() => [
// { title: 'ID', dataIndex: 'id', width: 80 },
{ title: '名称', dataIndex: 'name' },
{ title: '编码', dataIndex: 'code' },
{ title: '类型', dataIndex: 'type', render: (v: number) => v === 3 ? <Tag></Tag> : <Tag color="green"></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 360, render: (_: any, r: MenuItem) => (
<Space>
{has('system:menu:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:menu:delete') && (
<Popconfirm title={`确认删除菜单「${r.name}」?(若存在子菜单将无法删除)`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/menus/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchMenus(keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [keyword, has])
return (
<div>
<PageHeader items={["系统管理","菜单管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchMenus(kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索菜单名称/路径/权限" style={{ width: 360 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchMenus('') }}></Button>
{has('system:menu:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<MenuItem>
rowKey="id"
loading={loading}
dataSource={treeDataForTable}
columns={columns}
expandable={{ expandedRowKeys, onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as React.Key[]), indentSize: 18, rowExpandable: (record: MenuItem) => Array.isArray(record.children) && record.children.length > 0 }}
pagination={{ pageSize: 9999, hideOnSinglePage: true }}
/>
<Modal title="新增菜单" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right" initialValues={{ type: 2, visible: true, status: true, keep_alive: true }}>
<Form.Item name="parent_id" label="上级菜单">
<TreeSelect
allowClear
placeholder="选择上级菜单(按钮不可作为上级,可为空表示顶级)"
style={{ width: '100%' }}
treeData={dirTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
/>
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="请输入名称" />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Select options={[{label:'菜单',value:2},{label:'按钮',value:3}]} />
</Form.Item>
<Form.Item name="path" label="路径"><Input placeholder="/path" /></Form.Item>
<Form.Item name="component" label="组件"><Input placeholder="页面组件路径" /></Form.Item>
<Form.Item name="icon" label="图标">
<IconPicker items={iconItems} />
</Form.Item>
<Form.Item name="order_no" label="排序"><Input placeholder="数字越小越靠前" /></Form.Item>
<Form.Item name="visible" label="是否显示"><Select options={[{label:'显示',value:true},{label:'隐藏',value:false}]} /></Form.Item>
<Form.Item name="status" label="状态" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="禁用" /></Form.Item>
<Form.Item name="keep_alive" label="缓存"><Switch checkedChildren="开" unCheckedChildren="关" defaultChecked />
</Form.Item>
<Form.Item name="perms" label="权限标识"><Input placeholder="如 system:user:list" /></Form.Item>
</Form>
</Modal>
<Modal title="编辑菜单" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="parent_id" label="上级菜单">
<TreeSelect
allowClear
placeholder="选择上级菜单(不可选择自身及其子层,且按钮不可作为上级)"
style={{ width: '100%' }}
treeData={editDirTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
/>
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="请输入名称" />
</Form.Item>
<Form.Item name="type" label="类型"><Select options={[{label:'菜单',value:2},{label:'按钮',value:3}]} /></Form.Item>
<Form.Item name="path" label="路径"><Input placeholder="/path" /></Form.Item>
<Form.Item name="component" label="组件"><Input placeholder="页面组件路径" /></Form.Item>
<Form.Item name="icon" label="图标">
<IconPicker items={iconItems} />
</Form.Item>
<Form.Item name="order_no" label="排序"><Input placeholder="数字越小越靠前" /></Form.Item>
<Form.Item name="visible" label="是否显示" valuePropName="checked"><Switch checkedChildren="显示" unCheckedChildren="隐藏" /></Form.Item>
<Form.Item name="status" label="状态" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="禁用" /></Form.Item>
<Form.Item name="keep_alive" label="缓存" valuePropName="checked"><Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item name="perms" label="权限标识"><Input placeholder="如 system:user:list" /></Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,148 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'antd'
import api from '../utils/axios'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface PermissionItem {
id: number
code: string
name: string
description?: string
created_at?: string
}
export default function Permissions(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as PermissionItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as PermissionItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 权限判断
const { has } = usePermission()
const fetchPermissions = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/permissions`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取权限失败')
}
}catch(e: any){
message.error(e.message || '获取权限失败')
}finally{ setLoading(false) }
}
useEffect(() => { fetchPermissions(1, pageSize, keyword) }, [])
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/permissions', values)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchPermissions(1, pageSize) }
else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: PermissionItem) => { setCurrent(record); editForm.setFieldsValue({ name: record.name, description: record.description }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
const { data } = await api.put(`/permissions/${current!.id}`, values)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchPermissions(page, pageSize) }
else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const columns: ColumnsType<PermissionItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id' },
{ title: '名称', dataIndex: 'name' },
{ title: '权限标识', dataIndex: 'code' },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 280, render: (_: any, r: PermissionItem) => (
<Space>
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
<Popconfirm title={`确认删除权限「${r.name}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/permissions/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchPermissions(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
</Space>
)},
], [page, pageSize, keyword])
return (
<div>
<PageHeader items={["系统管理","权限管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchPermissions(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索权限编码/名称" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchPermissions(1, pageSize, '') }}></Button>
{has('system:permission:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<PermissionItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchPermissions(p, ps ?? pageSize, keyword) }}
/>
<Modal title="新增权限" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input placeholder="唯一编码,如 system:user:list" />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="权限名称" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="权限描述" />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑权限" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="权限名称" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="权限描述" />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,169 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import api from '../utils/axios'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface PositionItem {
id: number
name: string
code: string
description?: string
status?: number
order_no?: number
created_at?: string
}
export default function Positions(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as PositionItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as PositionItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
const { has } = usePermission()
const fetchPositions = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/positions`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取岗位失败')
}
}catch(e: any){
message.error(e.message || '获取岗位失败')
}finally{ setLoading(false) }
}
useEffect(() => { fetchPositions(1, pageSize, keyword) }, [])
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/positions', values)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchPositions(1, pageSize) }
else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: PositionItem) => { setCurrent(record); editForm.setFieldsValue({ name: record.name, description: record.description, status: record.status, order_no: record.order_no }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
const { data } = await api.put(`/positions/${current!.id}`, values)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchPositions(page, pageSize) }
else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const columns: ColumnsType<PositionItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '岗位名', dataIndex: 'name' },
{ title: '编码', dataIndex: 'code' },
{ title: '排序', dataIndex: 'order_no' },
{ title: '描述', dataIndex: 'description' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 280, render: (_: any, r: PositionItem) => (
<Space>
{has('system:position:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:position:delete') && (
<Popconfirm title={`确认删除岗位「${r.name}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/positions/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchPositions(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e: any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [page, pageSize, keyword, has])
return (
<div>
<PageHeader items={["系统管理","岗位管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchPositions(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索岗位名/编码" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchPositions(1, pageSize, '') }}></Button>
{has('system:position:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<PositionItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchPositions(p, ps ?? pageSize, keyword) }}
/>
<Modal title="新增岗位" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={640}>
<Form form={form} layout="horizontal" labelCol={{ span: 3 }} wrapperCol={{ span: 21 }} labelAlign="right">
<Form.Item name="name" label="岗位名" rules={[{ required: true }]}>
<Input placeholder="请输入岗位名" />
</Form.Item>
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input placeholder="唯一编码,如 hr:manager" />
</Form.Item>
<Form.Item name="order_no" label="排序" initialValue={0}>
<Input type="number" placeholder="数值越大越靠前" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="岗位描述" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑岗位" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={640}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 3 }} wrapperCol={{ span: 21 }} labelAlign="right">
<Form.Item name="name" label="岗位名" rules={[{ required: true }]}>
<Input placeholder="请输入岗位名" />
</Form.Item>
<Form.Item name="order_no" label="排序">
<Input type="number" placeholder="数值越大越靠前" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="岗位描述" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,296 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message, Tree } from 'antd'
import type { DataNode } from 'antd/es/tree'
import type { ColumnsType } from 'antd/es/table'
import api from '../utils/axios'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined, AppstoreOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface RoleItem {
id: number
name: string
code: string
description?: string
status?: number
created_at?: string
}
interface MenuItem { id: number; parent_id?: number; name: string; type: number }
// 放宽后端返回的字段类型number 或可转为数字的 string并统一收敛为 MenuItem
const toMenuItem = (o: unknown): MenuItem | null => {
if (typeof o !== 'object' || o === null) return null
const r = o as Record<string, unknown>
const id = Number(r.id)
const type = Number(r.type)
const name = r.name != null ? String(r.name) : ''
if (!Number.isFinite(id) || !Number.isFinite(type) || !name) return null
const rawPid = (r as any).parent_id
const pid = rawPid == null || rawPid === '' ? undefined : Number(rawPid)
if (pid !== undefined && !Number.isFinite(pid)) return null
return { id, type, name, parent_id: pid }
}
export default function Roles(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as RoleItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [current, setCurrent] = useState(null as RoleItem | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 权限判断
const { has } = usePermission()
// assignment states
const [assignMenuOpen, setAssignMenuOpen] = useState(false)
const [allMenus, setAllMenus] = useState([] as MenuItem[])
const [checkedMenuIds, setCheckedMenuIds] = useState([] as number[])
const [menuSearch, setMenuSearch] = useState('')
// 打开分配菜单:加载菜单树与已勾选
const onAssignMenus = async (record: RoleItem) => {
setCurrent(record)
setAssignMenuOpen(true)
try{
const [allRes, checkedRes] = await Promise.all([
api.get('/menus', { params: { keyword: '' } }),
api.get(`/roles/${record.id}/menus`),
])
if(allRes.data?.code === 0){
const arr = Array.isArray(allRes.data?.data) ? (allRes.data.data as unknown[]) : []
const normalized: MenuItem[] = arr.map(toMenuItem).filter((x): x is MenuItem => x !== null)
setAllMenus(normalized)
}
if(checkedRes.data?.code === 0){
const ids = Array.isArray(checkedRes.data?.data) ? (checkedRes.data.data as unknown[]).map(v => Number(v)).filter(n => Number.isFinite(n)) as number[] : []
setCheckedMenuIds(ids)
}
}catch(e: any){ message.error(e.message || '加载菜单失败') }
}
// 保存分配的菜单
const handleSaveMenus = async () => {
if(!current) return
try{
const { data } = await api.put(`/roles/${current.id}/menus`, { ids: checkedMenuIds })
if(data?.code === 0){ message.success('保存成功'); setAssignMenuOpen(false) }
else { throw new Error(data?.message || '保存失败') }
}catch(e: any){ message.error(e.message || '保存失败') }
}
const fetchRoles = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/roles`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取角色失败')
}
}catch(e: any){
message.error(e.message || '获取角色失败')
}finally{ setLoading(false) }
}
useEffect(() => { fetchRoles(1, pageSize, keyword) }, [])
const onCreate = () => { form.resetFields(); setCreateOpen(true) }
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/roles', values)
if(data?.code === 0){ message.success('创建成功'); setCreateOpen(false); fetchRoles(1, pageSize) }
else { throw new Error(data?.message || '创建失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = (record: RoleItem) => { setCurrent(record); editForm.setFieldsValue({ name: record.name, description: record.description, status: record.status }); setEditOpen(true) }
const handleEdit = async () => {
try{
const values = await editForm.validateFields()
const { data } = await api.put(`/roles/${current!.id}`, values)
if(data?.code === 0){ message.success('更新成功'); setEditOpen(false); fetchRoles(page, pageSize) }
else { throw new Error(data?.message || '更新失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
// build menu tree包含按钮 type=3一并展示
const menuTreeData: DataNode[] = useMemo(() => {
const list = allMenus
const map = new Map<number, DataNode & { children: DataNode[] }>()
const byId = new Map<number, MenuItem>()
list.forEach((m: MenuItem) => byId.set(m.id, m))
list.forEach((m: MenuItem) => map.set(m.id, { key: m.id, title: m.name, children: [] }))
const roots: (DataNode & { children: DataNode[] })[] = []
list.forEach((m: MenuItem) => {
const node = map.get(m.id)!
const pid = m.parent_id
if (pid && map.has(pid) && byId.get(pid)?.type !== 3) {
const parent = map.get(pid)
if (parent) parent.children.push(node)
} else {
// 父节点不存在、或父节点为按钮type=3作为根节点处理避免把子节点挂到按钮下
roots.push(node)
}
})
// 简单按标题排序,增强视觉一致性
const titleText = (n: DataNode): string => {
const t = (n as any).title
return typeof t === 'function' ? '' : String(t ?? '')
}
const sortNodes = (nodes: (DataNode & { children?: DataNode[] })[]) => {
nodes.sort((a, b) => titleText(a).localeCompare(titleText(b)))
nodes.forEach(n => { if (Array.isArray(n.children)) sortNodes(n.children as any) })
}
sortNodes(roots)
return roots
}, [allMenus])
// 基于标题的树过滤,匹配到的节点与其祖先均保留
const filteredMenuTreeData: DataNode[] = useMemo(() => {
const kw = menuSearch.trim().toLowerCase()
if (!kw) return menuTreeData
const titleText = (n: DataNode): string => {
const t = (n as any).title
return typeof t === 'function' ? '' : String(t ?? '')
}
const match = (n: DataNode) => titleText(n).toLowerCase().includes(kw)
const dfs = (nodes: DataNode[]): DataNode[] => {
const res: DataNode[] = []
nodes.forEach(n => {
const kids = (n.children ? dfs(n.children as DataNode[]) : [])
if (match(n) || kids.length > 0) {
res.push({ ...n, children: kids.length ? kids : undefined })
}
})
return res
}
return dfs(menuTreeData)
}, [menuTreeData, menuSearch])
const columns: ColumnsType<RoleItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '角色名', dataIndex: 'name' },
{ title: '编码', dataIndex: 'code' },
{ title: '描述', dataIndex: 'description' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 360, render: (_: any, r: RoleItem) => (
<Space>
{has('system:role:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:role:assign') && (
<a className="action-link" onClick={() => onAssignMenus(r)}>
<AppstoreOutlined />
<span></span>
</a>
)}
{has('system:role:delete') && (
<Popconfirm title={`确认删除角色「${r.name}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/roles/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchRoles(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e: any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [page, pageSize, keyword, has])
return (
<div>
<PageHeader items={["系统管理","角色管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchRoles(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索角色名/编码" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchRoles(1, pageSize, '') }}></Button>
{has('system:role:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<RoleItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchRoles(p, ps ?? pageSize, keyword) }}
/>
<Modal title="新增角色" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="name" label="角色名" rules={[{ required: true }]}>
<Input placeholder="请输入角色名" />
</Form.Item>
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input placeholder="唯一编码,如 system:admin" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="角色描述" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑角色" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="name" label="角色名" rules={[{ required: true }]}>
<Input placeholder="请输入角色名" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="角色描述" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
</Form>
</Modal>
{/* 移除分配权限模态框,仅保留分配菜单 */}
<Modal title={`分配菜单${current ? ' - ' + current.name : ''}`} open={assignMenuOpen} onOk={handleSaveMenus} onCancel={() => setAssignMenuOpen(false)} okText="保存" width={600}>
<Input allowClear placeholder="搜索菜单(名称)" value={menuSearch} onChange={(e)=>setMenuSearch(e.target.value)} style={{ marginBottom: 8 }} />
<Tree
checkable
showLine
treeData={filteredMenuTreeData}
checkedKeys={checkedMenuIds}
onCheck={(k: any) => {
const arr = Array.isArray(k) ? k : (k?.checked ?? [])
const next = (arr as (string|number)[]).map(v => Number(v))
setCheckedMenuIds(next)
}}
defaultExpandAll
style={{ maxHeight: 420, overflow: 'auto', padding: 8, border: '1px solid #f0f0f0', borderRadius: 6 }}
/>
<div style={{ marginTop: 12, color: '#888' }}>perms</div>
</Modal>
</div>
)
}

View File

@ -0,0 +1,482 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, TreeSelect } from 'antd'
import api from '../utils/axios'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface UserItem {
id: number
username: string
nickname?: string
status?: number
created_at?: string
}
// 简单的 {id, name} 类型守卫,便于从未知数组安全映射
type IdName = { id: number; name: string }
const isIdName = (o: unknown): o is IdName => {
if (typeof o !== 'object' || o === null) return false
const rec = o as Record<string, unknown>
return typeof rec.id === 'number' && typeof rec.name === 'string'
}
// 部门基础类型与守卫(包含 parent_id 便于构建树)
type DeptBasic = { id: number; name: string; parent_id?: number }
const isDeptBasic = (o: unknown): o is DeptBasic => {
if (typeof o !== 'object' || o === null) return false
const rec = o as Record<string, unknown>
const okId = typeof rec.id === 'number'
const okName = typeof rec.name === 'string'
const okPid = rec.parent_id === undefined || typeof rec.parent_id === 'number'
return okId && okName && okPid
}
export default function Users(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as UserItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [pwdOpen, setPwdOpen] = useState(false)
const [positionsOpen, setPositionsOpen] = useState(false)
const [current, setCurrent] = useState(null as UserItem | null)
const [currentUserId, setCurrentUserId] = useState(null as number | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [pwdForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 分配角色(移到编辑弹窗内)
const [selectedRoleIds, setSelectedRoleIds] = useState([] as number[])
const [allRoles, setAllRoles] = useState([] as { id: number; name: string }[])
// 分配部门(移到编辑弹窗内)
const [selectedDeptIds, setSelectedDeptIds] = useState([] as number[])
const [allDepts, setAllDepts] = useState([] as DeptBasic[])
// 岗位分配相关状态
const [positionOptions, setPositionOptions] = useState([] as { label: string; value: number }[])
const [userPositions, setUserPositions] = useState([] as number[])
// 新增/编辑弹窗内的岗位选择
const [createPositionIds, setCreatePositionIds] = useState([] as number[])
const [editPositionIds, setEditPositionIds] = useState([] as number[])
// 权限判断
const { has } = usePermission()
// 根据 allDepts 构建部门树(用于选择多个部门)
type DeptTreeNode = { title: string; value: number; key: number; children?: DeptTreeNode[] }
const deptTreeData: DeptTreeNode[] = useMemo(() => {
const map = new Map<number, DeptTreeNode & { children: DeptTreeNode[] }>()
allDepts.forEach((d: DeptBasic) => map.set(d.id, { title: d.name, value: d.id, key: d.id, children: [] }))
const roots: (DeptTreeNode & { children: DeptTreeNode[] })[] = []
allDepts.forEach((d: DeptBasic) => {
const node = map.get(d.id)!
if (d.parent_id && map.has(d.parent_id)) {
const parent = map.get(d.parent_id)
if (parent) parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [allDepts])
// 获取岗位选项
const fetchPositionOptions = async () => {
try {
const { data } = await api.get('/positions', { params: { page: 1, page_size: 1000 } })
if (data?.code === 0) {
setPositionOptions((data.data.items || []).map((it: any) => ({ label: it.name, value: it.id })))
}
} catch (e) {
console.error('获取岗位列表失败:', e)
}
}
useEffect(() => {
fetchPositionOptions()
}, [])
const fetchUsers = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/users`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取用户失败')
}
}catch(e: any){
message.error(e.message || '获取用户失败')
}finally{
setLoading(false)
}
}
useEffect(() => { fetchUsers(1, pageSize, keyword) }, [])
const onCreate = async () => {
form.resetFields()
// 新增用户:预置清空角色与部门选择,并加载候选数据
setSelectedRoleIds([])
setSelectedDeptIds([])
setCreatePositionIds([])
setCreateOpen(true)
try {
await fetchPositionOptions()
const [rolesRes, deptsRes] = await Promise.all([
api.get('/roles', { params: { page: 1, page_size: 1000 } }),
api.get('/departments', { params: { keyword: '' } })
])
if (rolesRes.data?.code === 0) {
const rolesItemsSrc = Array.isArray(rolesRes.data?.data?.items) ? (rolesRes.data.data.items as unknown[]) : []
const roleItems = rolesItemsSrc.filter(isIdName).map(({ id, name }) => ({ id, name }))
setAllRoles(roleItems)
}
if (deptsRes.data?.code === 0) {
const deptsSrc = Array.isArray(deptsRes.data?.data) ? (deptsRes.data.data as unknown[]) : []
const deptBasics = deptsSrc.filter(isDeptBasic)
setAllDepts(deptBasics)
}
} catch (e: any) {
message.error(e.message || '加载角色/部门失败')
}
}
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/users', values)
if(data?.code !== 0){ throw new Error(data?.message || '创建失败') }
const uid = typeof data?.data?.id === 'number' ? data.data.id : undefined
if (uid) {
const [rolesSave, deptsSave, posSave] = await Promise.all([
api.put(`/users/${uid}/roles`, { ids: selectedRoleIds }),
api.put(`/users/${uid}/departments`, { ids: selectedDeptIds }),
api.put(`/users/${uid}/positions`, { ids: createPositionIds })
])
if (rolesSave.data?.code !== 0) throw new Error(rolesSave.data?.message || '保存角色失败')
if (deptsSave.data?.code !== 0) throw new Error(deptsSave.data?.message || '保存部门失败')
if (posSave.data?.code !== 0) throw new Error(posSave.data?.message || '保存岗位失败')
} else {
message.warning('创建成功但未获取到用户ID未能分配角色/部门/岗位')
}
message.success('创建成功')
setCreateOpen(false)
fetchUsers(1, pageSize)
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = async (record: UserItem) => {
setCurrent(record)
editForm.setFieldsValue({ nickname: record.nickname, status: record.status })
setEditOpen(true)
// 加载该用户已分配的角色/部门/岗位及候选列表
try{
await fetchPositionOptions()
const [roleIdsRes, rolesRes, deptIdsRes, deptsRes, posIdsRes] = await Promise.all([
api.get(`/users/${record.id}/roles`),
api.get('/roles', { params: { page: 1, page_size: 1000 } }),
api.get(`/users/${record.id}/departments`),
api.get('/departments', { params: { keyword: '' } }),
api.get(`/users/${record.id}/positions`)
])
if(roleIdsRes.data?.code !== 0) throw new Error(roleIdsRes.data?.message || '获取用户角色失败')
if(rolesRes.data?.code !== 0) throw new Error(rolesRes.data?.message || '获取角色列表失败')
if(deptIdsRes.data?.code !== 0) throw new Error(deptIdsRes.data?.message || '获取用户部门失败')
if(deptsRes.data?.code !== 0) throw new Error(deptsRes.data?.message || '获取部门列表失败')
if(posIdsRes.data?.code !== 0) throw new Error(posIdsRes.data?.message || '获取用户岗位失败')
const roleIds = Array.isArray(roleIdsRes.data?.data) ? (roleIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setSelectedRoleIds(roleIds)
const rolesItemsSrc = Array.isArray(rolesRes.data?.data?.items) ? rolesRes.data.data.items as unknown[] : []
const roleItems = rolesItemsSrc.filter(isIdName).map(({ id, name }) => ({ id, name }))
setAllRoles(roleItems)
const deptIds = Array.isArray(deptIdsRes.data?.data) ? (deptIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setSelectedDeptIds(deptIds)
const deptsSrc = Array.isArray(deptsRes.data?.data) ? (deptsRes.data.data as unknown[]) : []
const deptBasics = deptsSrc.filter(isDeptBasic)
setAllDepts(deptBasics)
const posIds = Array.isArray(posIdsRes.data?.data) ? (posIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setEditPositionIds(posIds)
}catch(e: any){ message.error(e.message || '加载编辑数据失败') }
}
const handleEdit = async () => {
if(!current) return
try{
const values = await editForm.validateFields()
// 先保存基础信息
const { data: upd } = await api.put(`/users/${current!.id}`, values)
if(upd?.code !== 0) throw new Error(upd?.message || '更新失败')
// 再保存角色与部门
const [rolesSave, deptsSave] = await Promise.all([
api.put(`/users/${current.id}/roles`, { ids: selectedRoleIds }),
api.put(`/users/${current.id}/departments`, { ids: selectedDeptIds }),
api.put(`/users/${current.id}/positions`, { ids: editPositionIds })
])
if(rolesSave.data?.code !== 0) throw new Error(rolesSave.data?.message || '保存角色失败')
if(deptsSave.data?.code !== 0) throw new Error(deptsSave.data?.message || '保存部门失败')
message.success('更新成功')
setEditOpen(false)
fetchUsers(page, pageSize)
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const onResetPwd = (record: UserItem) => {
setCurrent(record)
pwdForm.resetFields()
setPwdOpen(true)
}
const handleResetPwd = async () => {
try{
const values = await pwdForm.validateFields()
const { data } = await api.post(`/users/${current!.id}/reset_password`, values)
if(data?.code === 0){
message.success('密码已重置')
setPwdOpen(false)
}else{ throw new Error(data?.message || '重置失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '重置失败') }
}
// 岗位分配相关方法
const openPositions = async (userId: number) => {
setCurrentUserId(userId)
try {
const { data } = await api.get(`/users/${userId}/positions`)
if (data?.code === 0) {
setUserPositions(data.data || [])
} else {
throw new Error(data?.message || '获取用户岗位失败')
}
} catch (e: any) {
message.error(e.message || '获取用户岗位失败')
setUserPositions([])
}
setPositionsOpen(true)
}
const savePositions = async () => {
if (!currentUserId) return
try {
const { data } = await api.put(`/users/${currentUserId}/positions`, { ids: userPositions })
if (data?.code === 0) {
message.success('岗位分配成功')
setPositionsOpen(false)
} else {
throw new Error(data?.message || '保存岗位失败')
}
} catch (e: any) {
message.error(e.message || '保存岗位失败')
}
}
const columns: ColumnsType<UserItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' },
{ title: '昵称', dataIndex: 'nickname' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 360, render: (_: any, r: UserItem) => (
<Space>
{has('system:user:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:user:reset') && (
<a className="action-link" onClick={() => onResetPwd(r)}>
<KeyOutlined />
<span></span>
</a>
)}
{has('system:user:assignPosition') && (
<a className="action-link" onClick={() => openPositions(r.id)}>
<UserOutlined />
<span></span>
</a>
)}
{has('system:user:delete') && (
<Popconfirm title={`确认删除用户「${r.username}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/users/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchUsers(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [page, pageSize, keyword, has])
return (
<div>
<PageHeader items={["系统管理","用户管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchUsers(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索用户名/昵称" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchUsers(1, pageSize, '') }}></Button>
{has('system:user:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<UserItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchUsers(p, ps ?? pageSize, keyword) }}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
</Typography.Paragraph>
<Modal title="新增用户" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
<Form.Item label="角色">
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择角色"
value={selectedRoleIds}
options={allRoles.map((r: { id: number; name: string }) => ({ label: r.name, value: r.id }))}
onChange={(vals: number[]) => setSelectedRoleIds(vals)}
/>
</Form.Item>
<Form.Item label="部门">
<TreeSelect
allowClear
multiple
treeCheckable
placeholder="选择部门(支持多选)"
style={{ width: '100%' }}
treeData={deptTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
value={selectedDeptIds}
onChange={(vals) => setSelectedDeptIds(Array.isArray(vals) ? (vals as (string | number)[]).map(v => Number(v)) : [])}
/>
</Form.Item>
{has('system:user:assignPosition') && (
<Form.Item label="岗位">
<Select
mode="multiple"
allowClear
placeholder="选择岗位"
value={createPositionIds}
options={positionOptions}
style={{ width: '100%' }}
onChange={setCreatePositionIds}
/>
</Form.Item>
)}
</Form>
</Modal>
<Modal title="编辑用户" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="nickname" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
<Form.Item label="角色">
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择角色"
value={selectedRoleIds}
options={allRoles.map((r: { id: number; name: string }) => ({ label: r.name, value: r.id }))}
onChange={(vals: number[]) => setSelectedRoleIds(vals)}
/>
</Form.Item>
<Form.Item label="部门">
<TreeSelect
allowClear
multiple
treeCheckable
placeholder="选择部门(支持多选)"
style={{ width: '100%' }}
treeData={deptTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
value={selectedDeptIds}
onChange={(vals) => setSelectedDeptIds(Array.isArray(vals) ? (vals as (string | number)[]).map(v => Number(v)) : [])}
/>
</Form.Item>
{has('system:user:assignPosition') && (
<Form.Item label="岗位">
<Select
mode="multiple"
allowClear
placeholder="选择岗位"
value={editPositionIds}
options={positionOptions}
style={{ width: '100%' }}
onChange={setEditPositionIds}
/>
</Form.Item>
)}
</Form>
</Modal>
<Modal title={`重置密码${current ? `${current.username}` : ''}`} open={pwdOpen} onOk={handleResetPwd} onCancel={() => setPwdOpen(false)} okText="重置">
<Form form={pwdForm} layout="vertical">
<Form.Item name="password" label="新密码" rules={[{ required: true }]}>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
</Form>
</Modal>
<Modal title="分配岗位" open={positionsOpen} onOk={savePositions} onCancel={() => setPositionsOpen(false)} okText="保存">
<Form layout="vertical">
<Form.Item label="选择岗位">
<Select
mode="multiple"
allowClear
value={userPositions}
onChange={setUserPositions}
options={positionOptions}
style={{ width: '100%' }}
placeholder="选择岗位"
/>
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@ -0,0 +1,2 @@
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e3f2fd,#e8f5e9)}
.login-card{width:420px}

View File

@ -0,0 +1,27 @@
html,body,#root{height:100%}
*{box-sizing:border-box}
/* 顶部多窗口 Tabs去掉默认底部横线并去掉额外间距避免影响右侧个人中心图标 */
.top-tabs .ant-tabs-nav::before { border-bottom: 0 !important; }
.top-tabs .ant-tabs-nav { margin: 0 !important; }
/* Enlarge action buttons in table action columns */
.ant-table .ant-space .ant-btn,
.ant-table .ant-btn {
height: 32px; /* middle size height */
padding: 4px 12px;
font-size: 14px;
}
/* Ensure icon-only buttons are also larger */
.ant-table .ant-btn .anticon { font-size: 16px; }
/* Slightly increase space gap in action columns */
.ant-table .ant-space { gap: 8px !important; }
/* Icon + text action link styles in tables */
.ant-table .action-link { display: inline-flex; align-items: center; gap: 6px; color: #1677ff; cursor: pointer; font-size: 14px; }
.ant-table .action-link .anticon { font-size: 16px; }
.ant-table .action-link:hover { color: #0958d9; }
.ant-table .action-danger { color: #ff4d4f; }
.ant-table .action-danger:hover { color: #d9363e; }

View File

@ -0,0 +1,71 @@
import axios, { type AxiosError, type AxiosInstance, type AxiosRequestHeaders, type AxiosResponse, type InternalAxiosRequestConfig, AxiosHeaders } from 'axios'
import { getToken, setToken, clearToken } from './token'
// 统一的接口返回泛型
export type ApiResp<T> = { code: number; message?: string; data?: T }
// 在请求配置上携带一次性重试标记
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean }
// 使用 Vite 的环境变量类型
const isDev = import.meta.env.DEV
const configuredBase = import.meta.env?.VITE_API_BASE || ''
const baseURL = isDev ? '' : configuredBase
const api: AxiosInstance = axios.create({ baseURL: baseURL ? `${baseURL}/api` : '/api', withCredentials: true })
let isRefreshing = false
let pendingQueue: { resolve: () => void; reject: (e: unknown) => void; config: RetryConfig }[] = []
api.interceptors.request.use((config: RetryConfig) => {
const token = getToken()
if (token) {
const h = config.headers
const value = `Bearer ${token}`
if (h instanceof AxiosHeaders) {
h.set('Authorization', value)
} else {
// 兼容对象形式的 headers
config.headers = { ...(h as Record<string, any>), Authorization: value } as AxiosRequestHeaders
}
}
return config
})
api.interceptors.response.use(
(r: AxiosResponse) => r,
async (error: AxiosError<ApiResp<unknown>>) => {
const original = (error.config || {}) as RetryConfig
if (error.response?.status === 401 && !original._retry) {
original._retry = true
if (!isRefreshing) {
isRefreshing = true
try {
const { data } = await api.get<ApiResp<{ access_token: string }>>('/auth/refresh')
if (data?.code === 0) {
const access = data.data?.access_token
if (access) setToken(access)
pendingQueue.forEach(p => p.resolve())
pendingQueue = []
return api(original)
}
} catch (e) {
pendingQueue.forEach(p => p.reject(e))
pendingQueue = []
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
return Promise.reject(e)
} finally {
isRefreshing = false
}
}
return new Promise<void>((resolve, reject) => {
pendingQueue.push({ resolve: () => resolve(), reject: (e: unknown) => reject(e as unknown), config: original })
}).then(() => api(original))
}
return Promise.reject(error)
}
)
export default api

View File

@ -0,0 +1,31 @@
export function formatDateTime(value: unknown): string {
if (value === null || value === undefined) return ''
try {
if (typeof value === 'string') {
const s = value.trim()
if (s.length >= 19) {
const core = s.replace('T', ' ').slice(0, 19)
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(core)) return core
}
const d = new Date(s)
if (!isNaN(d.getTime())) return formatFromDate(d)
} else if (typeof value === 'number') {
const d = new Date(value)
if (!isNaN(d.getTime())) return formatFromDate(d)
} else if (value instanceof Date) {
if (!isNaN(value.getTime())) return formatFromDate(value)
}
} catch (_) {}
return String(value)
}
function pad(n: number): string { return n < 10 ? '0' + n : '' + n }
function formatFromDate(d: Date): string {
const y = d.getFullYear()
const m = pad(d.getMonth() + 1)
const day = pad(d.getDate())
const hh = pad(d.getHours())
const mm = pad(d.getMinutes())
const ss = pad(d.getSeconds())
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}

View File

@ -0,0 +1,40 @@
import React, { createContext, useContext, useMemo } from 'react'
// 权限上下文,存储后端返回的权限编码集合(如 system:user:create 等)
export type PermissionSet = Set<string>
interface PermissionContextValue {
codes: PermissionSet
}
const PermissionContext = createContext<PermissionContextValue>({ codes: new Set<string>() })
export function PermissionProvider({ codes, children }: { codes: PermissionSet; children: React.ReactNode }) {
// 统一将编码小写化,避免大小写不一致
const normalized = useMemo(() => {
const s = new Set<string>()
codes.forEach((c) => { if (c) s.add(String(c).trim().toLowerCase()) })
return s
}, [codes])
const value = useMemo(() => ({ codes: normalized }), [normalized])
return <PermissionContext.Provider value={value}>{children}</PermissionContext.Provider>
}
export function usePermission() {
const { codes } = useContext(PermissionContext)
const has = (code?: string | null) => {
if (!code) return false
return codes.has(String(code).trim().toLowerCase())
}
const anyOf = (list: (string | undefined | null)[]) => list.some((c) => has(c || undefined))
const allOf = (list: (string | undefined | null)[]) => list.every((c) => has(c || undefined))
return { has, anyOf, allOf, codes }
}
// 便捷组件:具备指定权限才渲染子节点;否则什么也不渲染
export function Perm({ code, children }: { code: string; children: React.ReactNode }) {
const { has } = usePermission()
if (!has(code)) return null
return <>{children}</>
}

View File

@ -0,0 +1,20 @@
const KEY = 'udmin_access_token'
const UKEY = 'udmin_user'
export function getToken(){
return localStorage.getItem(KEY)
}
export function setToken(t: string){
localStorage.setItem(KEY, t)
}
export function clearToken(){
localStorage.removeItem(KEY)
localStorage.removeItem(UKEY)
}
export function setUser(u: any){
localStorage.setItem(UKEY, JSON.stringify(u))
}
export function getUser(){
const s = localStorage.getItem(UKEY)
return s ? JSON.parse(s) : null
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />