web_app.tsx 模块化拆分路由、菜单、布局
This commit is contained in:
45
client/admin/components/ErrorPage.tsx
Normal file
45
client/admin/components/ErrorPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { useRouteError } from 'react-router';
|
||||
import { Alert, Button } from 'antd';
|
||||
import { useTheme } from '../hooks_sys.tsx';
|
||||
|
||||
export const ErrorPage = () => {
|
||||
const { isDark } = useTheme();
|
||||
const error = useRouteError() as any;
|
||||
const errorMessage = error?.statusText || error?.message || '未知错误';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4"
|
||||
style={{ color: isDark ? '#fff' : 'inherit' }}
|
||||
>
|
||||
<div className="max-w-3xl w-full">
|
||||
<h1 className="text-2xl font-bold mb-4">发生错误</h1>
|
||||
<Alert
|
||||
type="error"
|
||||
message={error?.message || '未知错误'}
|
||||
description={
|
||||
error?.stack ? (
|
||||
<pre className="text-xs overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
{error.stack}
|
||||
</pre>
|
||||
) : null
|
||||
}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.href = '/admin'}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
222
client/admin/layouts/MainLayout.tsx
Normal file
222
client/admin/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Outlet,
|
||||
useLocation,
|
||||
} from 'react-router';
|
||||
import {
|
||||
Layout, Button, Space, Badge, Avatar, Dropdown, Typography, Input, Menu,
|
||||
} from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
BellOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth, useTheme } from '../hooks_sys.tsx';
|
||||
import { useMenu, useMenuSearch, type MenuItem } from '../menu.tsx';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
/**
|
||||
* 主布局组件
|
||||
* 包含侧边栏、顶部导航和内容区域
|
||||
*/
|
||||
export const MainLayout = () => {
|
||||
const { user } = useAuth();
|
||||
const { isDark } = useTheme();
|
||||
const [showBackTop, setShowBackTop] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// 使用菜单hook
|
||||
const {
|
||||
menuItems,
|
||||
userMenuItems,
|
||||
openKeys,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
handleMenuClick: handleRawMenuClick,
|
||||
onOpenChange
|
||||
} = useMenu();
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = (key: string) => {
|
||||
const item = findMenuItem(menuItems, key);
|
||||
if (item && 'label' in item) {
|
||||
handleRawMenuClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
// 查找菜单项
|
||||
const findMenuItem = (items: MenuItem[], key: string): MenuItem | null => {
|
||||
for (const item of items) {
|
||||
if (!item) continue;
|
||||
if (item.key === key) return item;
|
||||
if (item.children) {
|
||||
const found = findMenuItem(item.children, key);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 使用菜单搜索hook
|
||||
const {
|
||||
searchText,
|
||||
setSearchText,
|
||||
filteredMenuItems
|
||||
} = useMenuSearch(menuItems);
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const selectedKey = useMemo(() => {
|
||||
const findSelectedKey = (items: MenuItem[]): string | null => {
|
||||
for (const item of items) {
|
||||
if (!item) continue;
|
||||
if (item.path === location.pathname) return item.key || null;
|
||||
if (item.children) {
|
||||
const childKey = findSelectedKey(item.children);
|
||||
if (childKey) return childKey;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return findSelectedKey(menuItems) || '';
|
||||
}, [location.pathname, menuItems]);
|
||||
|
||||
// 检测滚动位置,控制回到顶部按钮显示
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShowBackTop(window.pageYOffset > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// 回到顶部
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 应用名称 - 从CONFIG中获取或使用默认值
|
||||
const appName = window.CONFIG?.APP_NAME || '应用Starter';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={240}
|
||||
className="custom-sider"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div className="p-4">
|
||||
<Typography.Title level={2} className="text-xl font-bold truncate">
|
||||
{collapsed ? '应用' : appName}
|
||||
</Typography.Title>
|
||||
|
||||
{/* 菜单搜索框 */}
|
||||
{!collapsed && (
|
||||
<div className="mb-4">
|
||||
<Input.Search
|
||||
placeholder="搜索菜单..."
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 菜单列表 */}
|
||||
<Menu
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
mode="inline"
|
||||
items={filteredMenuItems}
|
||||
openKeys={openKeys}
|
||||
selectedKeys={[selectedKey]}
|
||||
onOpenChange={onOpenChange}
|
||||
onClick={({ key }) => handleMenuClick(key)}
|
||||
inlineCollapsed={collapsed}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
|
||||
<Header className="p-0 flex justify-between items-center"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 99,
|
||||
boxShadow: '0 1px 4px rgba(0,21,41,0.08)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
|
||||
<Space size="middle" className="mr-4">
|
||||
<Badge count={5} offset={[0, 5]}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
<Dropdown menu={{ items: userMenuItems }}>
|
||||
<Space className="cursor-pointer">
|
||||
<Avatar
|
||||
src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
|
||||
icon={!user?.avatar && !navigator.onLine && <UserOutlined />}
|
||||
/>
|
||||
<span>
|
||||
{user?.nickname || user?.username}
|
||||
</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<Content className="m-6" style={{ overflow: 'initial' }}>
|
||||
<div className="site-layout-content p-6 rounded-lg">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
{showBackTop && (
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
size="large"
|
||||
onClick={scrollToTop}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 30,
|
||||
bottom: 30,
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
199
client/admin/menu.tsx
Normal file
199
client/admin/menu.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
FileOutlined,
|
||||
MessageOutlined,
|
||||
InfoCircleOutlined,
|
||||
BarChartOutlined,
|
||||
EnvironmentOutlined,
|
||||
MoonOutlined,
|
||||
SunOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useTheme } from './hooks_sys.tsx';
|
||||
|
||||
export interface MenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: MenuItem[];
|
||||
path?: string;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单搜索 Hook
|
||||
* 封装菜单搜索相关逻辑
|
||||
*/
|
||||
export const useMenuSearch = (menuItems: MenuItem[]) => {
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
|
||||
// 过滤菜单项
|
||||
const filteredMenuItems = React.useMemo(() => {
|
||||
if (!searchText) return menuItems;
|
||||
|
||||
const filterItems = (items: MenuItem[]): MenuItem[] => {
|
||||
return items
|
||||
.map(item => {
|
||||
// 克隆对象避免修改原数据
|
||||
const newItem = { ...item };
|
||||
if (newItem.children) {
|
||||
newItem.children = filterItems(newItem.children);
|
||||
}
|
||||
return newItem;
|
||||
})
|
||||
.filter(item => {
|
||||
// 保留匹配项或其子项匹配的项
|
||||
const match = item.label.toLowerCase().includes(searchText.toLowerCase());
|
||||
if (match) return true;
|
||||
if (item.children?.length) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
return filterItems(menuItems);
|
||||
}, [menuItems, searchText]);
|
||||
|
||||
// 清除搜索
|
||||
const clearSearch = () => {
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
return {
|
||||
searchText,
|
||||
setSearchText,
|
||||
filteredMenuItems,
|
||||
clearSearch
|
||||
};
|
||||
};
|
||||
|
||||
export const useMenu = () => {
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const [openKeys, setOpenKeys] = React.useState<string[]>([]);
|
||||
|
||||
// 基础菜单项配置
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: '控制台',
|
||||
icon: <DashboardOutlined />,
|
||||
path: '/admin/dashboard'
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: '用户管理',
|
||||
icon: <TeamOutlined />,
|
||||
path: '/admin/users',
|
||||
permission: 'user:manage'
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: '系统设置',
|
||||
icon: <SettingOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: 'theme-settings',
|
||||
label: '主题设置',
|
||||
path: '/admin/theme-settings',
|
||||
permission: 'system:settings'
|
||||
},
|
||||
{
|
||||
key: 'system-settings',
|
||||
label: '系统配置',
|
||||
path: '/admin/settings',
|
||||
permission: 'system:settings'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
label: '内容管理',
|
||||
icon: <FileOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: 'know-info',
|
||||
label: '知识库',
|
||||
path: '/admin/know-info',
|
||||
permission: 'content:manage'
|
||||
},
|
||||
{
|
||||
key: 'file-library',
|
||||
label: '文件库',
|
||||
path: '/admin/file-library',
|
||||
permission: 'content:manage'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'messages',
|
||||
label: '消息中心',
|
||||
icon: <MessageOutlined />,
|
||||
path: '/admin/messages',
|
||||
permission: 'message:view'
|
||||
},
|
||||
{
|
||||
key: 'charts',
|
||||
label: '数据图表',
|
||||
icon: <BarChartOutlined />,
|
||||
path: '/admin/chart-dashboard',
|
||||
permission: 'chart:view'
|
||||
},
|
||||
{
|
||||
key: 'maps',
|
||||
label: '地图',
|
||||
icon: <EnvironmentOutlined />,
|
||||
path: '/admin/map-dashboard',
|
||||
permission: 'map:view'
|
||||
}
|
||||
];
|
||||
|
||||
// 用户菜单项
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人资料',
|
||||
icon: <UserOutlined />
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
label: isDark ? '切换到亮色模式' : '切换到暗色模式',
|
||||
icon: isDark ? <SunOutlined /> : <MoonOutlined />,
|
||||
onClick: () => toggleTheme()
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
icon: <InfoCircleOutlined />,
|
||||
danger: true
|
||||
}
|
||||
];
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
if (item.path) {
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单展开变化
|
||||
const onOpenChange = (keys: string[]) => {
|
||||
const latestOpenKey = keys.find(key => openKeys.indexOf(key) === -1);
|
||||
setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
|
||||
};
|
||||
|
||||
return {
|
||||
menuItems,
|
||||
userMenuItems,
|
||||
openKeys,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
handleMenuClick,
|
||||
onOpenChange
|
||||
};
|
||||
};
|
||||
85
client/admin/routes.tsx
Normal file
85
client/admin/routes.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import { ProtectedRoute } from './components_protected_route.tsx';
|
||||
import { MainLayout } from './layouts/MainLayout.tsx';
|
||||
import { ErrorPage } from './components/ErrorPage.tsx';
|
||||
import { DashboardPage } from './pages_dashboard.tsx';
|
||||
import { UsersPage } from './pages_users.tsx';
|
||||
import { FileLibraryPage } from './pages_file_library.tsx';
|
||||
import { KnowInfoPage } from './pages_know_info.tsx';
|
||||
import { MessagesPage } from './pages_messages.tsx';
|
||||
import { SettingsPage } from './pages_settings.tsx';
|
||||
import { ThemeSettingsPage } from './pages_theme_settings.tsx';
|
||||
import { ChartDashboardPage } from './pages_chart.tsx';
|
||||
import { LoginMapPage } from './pages_map.tsx';
|
||||
import { LoginPage } from './pages_login_reg.tsx';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to="/admin" replace />
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
element: <LoginPage />
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/admin/dashboard" />
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <DashboardPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
element: <UsersPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'theme-settings',
|
||||
element: <ThemeSettingsPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'chart-dashboard',
|
||||
element: <ChartDashboardPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'map-dashboard',
|
||||
element: <LoginMapPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'know-info',
|
||||
element: <KnowInfoPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'file-library',
|
||||
element: <FileLibraryPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'messages',
|
||||
element: <MessagesPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1,76 +1,16 @@
|
||||
import React, { useState, useEffect} from 'react';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
RouterProvider,
|
||||
Outlet,
|
||||
useNavigate,
|
||||
useLocation,
|
||||
Navigate,
|
||||
useParams,
|
||||
useRouteError
|
||||
} from 'react-router';
|
||||
import {
|
||||
Layout, Menu, Button, Table, Space,
|
||||
Form, Input, Select, message, Modal,
|
||||
Card, Spin, Row, Col, Breadcrumb, Avatar,
|
||||
Dropdown, ConfigProvider, theme, Typography,
|
||||
Switch, Badge, Image, Upload, Divider, Descriptions,
|
||||
Popconfirm, Tag, Statistic, DatePicker, Radio, Progress, Tabs, List, Alert, Collapse, Empty, Drawer
|
||||
} from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
BellOutlined,
|
||||
BookOutlined,
|
||||
FileOutlined,
|
||||
PieChartOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
CloseOutlined,
|
||||
SearchOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import localeData from 'dayjs/plugin/localeData';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import type {
|
||||
GlobalConfig
|
||||
} from '../share/types.ts';
|
||||
|
||||
|
||||
import {
|
||||
AuthProvider,
|
||||
useAuth,
|
||||
ThemeProvider,
|
||||
useTheme,
|
||||
} from './hooks_sys.tsx';
|
||||
|
||||
import {
|
||||
DashboardPage
|
||||
} from './pages_dashboard.tsx';
|
||||
import {
|
||||
UsersPage
|
||||
} from './pages_users.tsx';
|
||||
import {
|
||||
FileLibraryPage
|
||||
} from './pages_file_library.tsx';
|
||||
import { KnowInfoPage } from './pages_know_info.tsx';
|
||||
import { MessagesPage } from './pages_messages.tsx';
|
||||
import {SettingsPage } from './pages_settings.tsx';
|
||||
import {ThemeSettingsPage} from './pages_theme_settings.tsx'
|
||||
import { ChartDashboardPage } from './pages_chart.tsx';
|
||||
import { LoginMapPage } from './pages_map.tsx';
|
||||
import { LoginPage } from './pages_login_reg.tsx';
|
||||
import { ProtectedRoute } from './components_protected_route.tsx';
|
||||
import { AuthProvider } from './hooks_sys.tsx';
|
||||
import { ThemeProvider } from './hooks_sys.tsx';
|
||||
import { router } from './routes.tsx';
|
||||
import type { GlobalConfig } from '../share/types.ts';
|
||||
|
||||
// 配置 dayjs 插件
|
||||
dayjs.extend(weekday);
|
||||
@@ -79,12 +19,9 @@ dayjs.extend(localeData);
|
||||
// 设置 dayjs 语言
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
// 创建QueryClient实例
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
||||
// 声明全局配置对象类型
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -92,460 +29,8 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 主布局组件
|
||||
const MainLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||
const [showBackTop, setShowBackTop] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filteredMenuItems, setFilteredMenuItems] = useState<any[]>([]);
|
||||
|
||||
// 检测滚动位置,控制回到顶部按钮显示
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShowBackTop(window.pageYOffset > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// 回到顶部
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '仪表盘',
|
||||
},
|
||||
{
|
||||
key: '/analysis',
|
||||
icon: <PieChartOutlined />,
|
||||
label: '数据分析',
|
||||
children: [
|
||||
{
|
||||
key: '/chart-dashboard',
|
||||
label: '图表统计',
|
||||
},
|
||||
{
|
||||
key: '/map-dashboard',
|
||||
label: '地图概览',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/files',
|
||||
icon: <FileOutlined />,
|
||||
label: '文件管理',
|
||||
children: [
|
||||
{
|
||||
key: '/file-library',
|
||||
label: '文件库',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/know-info',
|
||||
icon: <BookOutlined />,
|
||||
label: '知识库',
|
||||
},
|
||||
{
|
||||
key: '/users',
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理',
|
||||
},
|
||||
{
|
||||
key: '/messages',
|
||||
icon: <BellOutlined />,
|
||||
label: '消息管理',
|
||||
},
|
||||
{
|
||||
key: '/settings_group',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置',
|
||||
children: [
|
||||
{
|
||||
key: '/theme-settings',
|
||||
label: '主题设置',
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
label: '基本设置',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 初始化filteredMenuItems
|
||||
useEffect(() => {
|
||||
setFilteredMenuItems(menuItems);
|
||||
}, []);
|
||||
|
||||
// 搜索菜单项
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchText(value);
|
||||
|
||||
if (!value.trim()) {
|
||||
setFilteredMenuItems(menuItems);
|
||||
return;
|
||||
}
|
||||
|
||||
// 搜索功能 - 过滤菜单项
|
||||
const filtered = menuItems.reduce((acc: any[], item) => {
|
||||
// 检查主菜单项是否匹配
|
||||
const mainItemMatch = item.label.toString().toLowerCase().includes(value.toLowerCase());
|
||||
|
||||
// 如果有子菜单,检查子菜单中是否有匹配项
|
||||
if (item.children) {
|
||||
const matchedChildren = item.children.filter(child =>
|
||||
child.label.toString().toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
|
||||
if (matchedChildren.length > 0) {
|
||||
// 如果有匹配的子菜单,创建包含匹配子菜单的副本
|
||||
acc.push({
|
||||
...item,
|
||||
children: matchedChildren
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果主菜单项匹配,添加整个项
|
||||
if (mainItemMatch) {
|
||||
acc.push(item);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
setFilteredMenuItems(filtered);
|
||||
};
|
||||
|
||||
// 清除搜索
|
||||
const clearSearch = () => {
|
||||
setSearchText('');
|
||||
setFilteredMenuItems(menuItems);
|
||||
};
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(`/admin${key}`);
|
||||
// 如果有搜索文本,清除搜索
|
||||
if (searchText) {
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理登出
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
// 处理菜单展开/收起
|
||||
const onOpenChange = (keys: string[]) => {
|
||||
// 当侧边栏折叠时不保存openKeys状态
|
||||
if (!collapsed) {
|
||||
setOpenKeys(keys);
|
||||
}
|
||||
};
|
||||
|
||||
// 当侧边栏折叠状态改变时,控制菜单打开状态
|
||||
useEffect(() => {
|
||||
if (collapsed) {
|
||||
setOpenKeys([]);
|
||||
} else {
|
||||
// 找到当前路径所属的父菜单
|
||||
const currentPath = location.pathname.replace('/admin', '');
|
||||
const parentKeys = menuItems
|
||||
.filter(item => item.children && item.children.some(child => child.key === currentPath))
|
||||
.map(item => item.key);
|
||||
|
||||
// 仅展开当前所在的菜单组
|
||||
if (parentKeys.length > 0) {
|
||||
setOpenKeys(parentKeys);
|
||||
} else {
|
||||
// 初始时可以根据需要设置要打开的菜单组
|
||||
setOpenKeys([]);
|
||||
}
|
||||
}
|
||||
}, [collapsed, location.pathname]);
|
||||
|
||||
// 用户下拉菜单项
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人信息',
|
||||
icon: <UserOutlined />
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
label: isDark ? '切换到亮色模式' : '切换到暗色模式',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: toggleTheme
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
icon: <LogoutOutlined />,
|
||||
onClick: handleLogout
|
||||
}
|
||||
];
|
||||
|
||||
// 应用名称 - 从CONFIG中获取或使用默认值
|
||||
const appName = window.CONFIG?.APP_NAME || '应用Starter';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={240}
|
||||
className="custom-sider"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div className="p-4">
|
||||
<Typography.Title level={2} className="text-xl font-bold truncate">
|
||||
{collapsed ? '应用' : appName}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 - 仅在展开状态下显示 */}
|
||||
{!collapsed && (
|
||||
<div style={{ padding: '0 16px 16px' }}>
|
||||
<Input
|
||||
placeholder="搜索菜单..."
|
||||
value={searchText}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
suffix={
|
||||
searchText ?
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={clearSearch}
|
||||
/> :
|
||||
<SearchOutlined />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
theme={isDark ? "light" : "light"}
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname.replace('/admin', '')]}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={onOpenChange}
|
||||
items={filteredMenuItems}
|
||||
onClick={handleMenuClick}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
|
||||
<Header className="p-0 flex justify-between items-center"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 99,
|
||||
boxShadow: '0 1px 4px rgba(0,21,41,0.08)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
|
||||
<Space size="middle" className="mr-4">
|
||||
<Badge count={5} offset={[0, 5]}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
<Dropdown menu={{ items: userMenuItems }}>
|
||||
<Space className="cursor-pointer">
|
||||
<Avatar
|
||||
src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
|
||||
icon={!user?.avatar && !navigator.onLine && <UserOutlined />}
|
||||
/>
|
||||
<span>
|
||||
{user?.nickname || user?.username}
|
||||
</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<Content className="m-6" style={{ overflow: 'initial' }}>
|
||||
<div className="site-layout-content p-6 rounded-lg">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
{showBackTop && (
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
size="large"
|
||||
onClick={scrollToTop}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 30,
|
||||
bottom: 30,
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 错误页面组件
|
||||
const ErrorPage = () => {
|
||||
const { isDark } = useTheme();
|
||||
const error = useRouteError() as any;
|
||||
const errorMessage = error?.statusText || error?.message || '未知错误';
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4"
|
||||
style={{ color: isDark ? '#fff' : 'inherit' }}
|
||||
>
|
||||
<div className="max-w-3xl w-full">
|
||||
<h1 className="text-2xl font-bold mb-4">发生错误</h1>
|
||||
<Alert
|
||||
type="error"
|
||||
message={error?.message || '未知错误'}
|
||||
description={
|
||||
error?.stack ? (
|
||||
<pre className="text-xs overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
{error.stack}
|
||||
</pre>
|
||||
) : null
|
||||
}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.href = '/admin'}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 应用入口组件
|
||||
const App = () => {
|
||||
// 路由配置
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to="/admin" replace />
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
element: <LoginPage />
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/admin/dashboard" />
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <DashboardPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
element: <UsersPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'theme-settings',
|
||||
element: <ThemeSettingsPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'chart-dashboard',
|
||||
element: <ChartDashboardPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'map-dashboard',
|
||||
element: <LoginMapPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'know-info',
|
||||
element: <KnowInfoPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'file-library',
|
||||
element: <FileLibraryPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
{
|
||||
path: 'messages',
|
||||
element: <MessagesPage />,
|
||||
errorElement: <ErrorPage />
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
return <RouterProvider router={router} />
|
||||
};
|
||||
|
||||
@@ -560,4 +45,3 @@ root.render(
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface User {
|
||||
role: string;
|
||||
avatar?: string;
|
||||
password?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
|
||||
Reference in New Issue
Block a user