init
This commit is contained in:
40
frontend/src/App.tsx
Normal file
40
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/src/assets/logo.svg
Normal file
1
frontend/src/assets/logo.svg
Normal 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 |
21
frontend/src/components/PageHeader.tsx
Normal file
21
frontend/src/components/PageHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
556
frontend/src/layouts/MainLayout.tsx
Normal file
556
frontend/src/layouts/MainLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/src/layouts/layout.css
Normal file
1
frontend/src/layouts/layout.css
Normal 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
12
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
171
frontend/src/pages/Dashboard.tsx
Normal file
171
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
257
frontend/src/pages/Departments.tsx
Normal file
257
frontend/src/pages/Departments.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
frontend/src/pages/Login.tsx
Normal file
47
frontend/src/pages/Login.tsx
Normal 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
172
frontend/src/pages/Logs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
538
frontend/src/pages/Menus.tsx
Normal file
538
frontend/src/pages/Menus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
148
frontend/src/pages/Permissions.tsx
Normal file
148
frontend/src/pages/Permissions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
frontend/src/pages/Positions.tsx
Normal file
169
frontend/src/pages/Positions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
296
frontend/src/pages/Roles.tsx
Normal file
296
frontend/src/pages/Roles.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
482
frontend/src/pages/Users.tsx
Normal file
482
frontend/src/pages/Users.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
frontend/src/pages/login.css
Normal file
2
frontend/src/pages/login.css
Normal 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}
|
||||
27
frontend/src/styles/global.css
Normal file
27
frontend/src/styles/global.css
Normal 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; }
|
||||
71
frontend/src/utils/axios.ts
Normal file
71
frontend/src/utils/axios.ts
Normal 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
|
||||
31
frontend/src/utils/datetime.ts
Normal file
31
frontend/src/utils/datetime.ts
Normal 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}`
|
||||
}
|
||||
40
frontend/src/utils/permission.tsx
Normal file
40
frontend/src/utils/permission.tsx
Normal 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}</>
|
||||
}
|
||||
20
frontend/src/utils/token.ts
Normal file
20
frontend/src/utils/token.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user