Files
udmin/frontend/src/layouts/MainLayout.tsx
ayou 2b1308956a feat(布局): 为个人信息菜单项添加图标并简化语言选项显示
为个人信息菜单项添加用户图标,提升视觉一致性
将语言选项的显示从"中文/English"简化为"CN/EN"
2025-08-29 23:46:31 +08:00

566 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
import { APP_CONFIG } from '../utils/config'
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: '个人信息', icon: <UserOutlined /> },
{ 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,
fontSize: leftCollapsed ? '16px' : '18px',
cursor: 'pointer',
userSelect: 'none'
}}
onClick={() => setLeftCollapsed(c => !c)}
title={leftCollapsed ? '展开菜单' : '收起菜单'}
>
{leftCollapsed ? APP_CONFIG.SITE_NAME_SHORT : APP_CONFIG.SITE_NAME}
</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: 'CN' },
{ key: 'en', label: 'EN' },
],
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' ? 'CN' : 'EN'}</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>
)
}