web_app.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import React, { useState, useEffect, createContext, useContext } from 'react';
  2. import { createRoot } from 'react-dom/client';
  3. import {
  4. createBrowserRouter,
  5. RouterProvider,
  6. Outlet,
  7. useNavigate,
  8. useLocation,
  9. Navigate,
  10. useParams
  11. } from 'react-router';
  12. import {
  13. Layout, Menu, Button, Table, Space,
  14. Form, Input, Select, message, Modal,
  15. Card, Spin, Row, Col, Breadcrumb, Avatar,
  16. Dropdown, ConfigProvider, theme, Typography,
  17. Switch, Badge, Image, Upload, Divider, Descriptions,
  18. Popconfirm, Tag, Statistic, DatePicker, Radio, Progress, Tabs, List, Alert, Collapse, Empty, Drawer
  19. } from 'antd';
  20. import zhCN from "antd/locale/zh_CN";
  21. import {
  22. MenuFoldOutlined,
  23. MenuUnfoldOutlined,
  24. UserOutlined,
  25. DashboardOutlined,
  26. TeamOutlined,
  27. SettingOutlined,
  28. LogoutOutlined,
  29. BellOutlined,
  30. BookOutlined,
  31. FileOutlined,
  32. PieChartOutlined,
  33. UploadOutlined,
  34. GlobalOutlined,
  35. VerticalAlignTopOutlined,
  36. CloseOutlined,
  37. SearchOutlined
  38. } from '@ant-design/icons';
  39. import {
  40. QueryClient,
  41. QueryClientProvider,
  42. useQuery,
  43. useMutation,
  44. useQueryClient
  45. } from '@tanstack/react-query';
  46. import axios from 'axios';
  47. import dayjs from 'dayjs';
  48. import weekday from 'dayjs/plugin/weekday';
  49. import localeData from 'dayjs/plugin/localeData';
  50. import { uploadMinIOWithPolicy } from '@d8d-appcontainer/api';
  51. import type { MinioUploadPolicy } from '@d8d-appcontainer/types';
  52. import { Line, Pie, Column } from "@ant-design/plots";
  53. import 'dayjs/locale/zh-cn';
  54. import type {
  55. GlobalConfig
  56. } from '../share/types.ts';
  57. import {
  58. EnableStatus, DeleteStatus, ThemeMode, FontSize, CompactMode
  59. } from '../share/types.ts';
  60. import { getEnumOptions } from './utils.ts';
  61. import {
  62. AuthProvider,
  63. useAuth,
  64. ThemeProvider,
  65. useTheme,
  66. } from './hooks_sys.tsx';
  67. import {
  68. DashboardPage,
  69. UsersPage,
  70. KnowInfoPage,
  71. FileLibraryPage
  72. } from './pages_sys.tsx';
  73. import {
  74. SettingsPage,
  75. ThemeSettingsPage,
  76. } from './pages_settings.tsx';
  77. import {
  78. ChartDashboardPage,
  79. } from './pages_chart.tsx';
  80. import {
  81. LoginMapPage
  82. } from './pages_map.tsx';
  83. import {
  84. LoginPage,
  85. } from './pages_login_reg.tsx';
  86. // 配置 dayjs 插件
  87. dayjs.extend(weekday);
  88. dayjs.extend(localeData);
  89. // 设置 dayjs 语言
  90. dayjs.locale('zh-cn');
  91. const { Header, Sider, Content } = Layout;
  92. // 创建QueryClient实例
  93. const queryClient = new QueryClient();
  94. // 声明全局配置对象类型
  95. declare global {
  96. interface Window {
  97. CONFIG?: GlobalConfig;
  98. }
  99. }
  100. // 主布局组件
  101. const MainLayout = () => {
  102. const [collapsed, setCollapsed] = useState(false);
  103. const { user, logout } = useAuth();
  104. const { isDark, toggleTheme } = useTheme();
  105. const navigate = useNavigate();
  106. const location = useLocation();
  107. const [openKeys, setOpenKeys] = useState<string[]>([]);
  108. const [showBackTop, setShowBackTop] = useState(false);
  109. const [searchText, setSearchText] = useState('');
  110. const [filteredMenuItems, setFilteredMenuItems] = useState<any[]>([]);
  111. // 检测滚动位置,控制回到顶部按钮显示
  112. useEffect(() => {
  113. const handleScroll = () => {
  114. setShowBackTop(window.pageYOffset > 300);
  115. };
  116. window.addEventListener('scroll', handleScroll);
  117. return () => window.removeEventListener('scroll', handleScroll);
  118. }, []);
  119. // 回到顶部
  120. const scrollToTop = () => {
  121. window.scrollTo({
  122. top: 0,
  123. behavior: 'smooth'
  124. });
  125. };
  126. // 菜单项配置
  127. const menuItems = [
  128. {
  129. key: '/dashboard',
  130. icon: <DashboardOutlined />,
  131. label: '仪表盘',
  132. },
  133. {
  134. key: '/analysis',
  135. icon: <PieChartOutlined />,
  136. label: '数据分析',
  137. children: [
  138. {
  139. key: '/chart-dashboard',
  140. label: '图表统计',
  141. },
  142. {
  143. key: '/map-dashboard',
  144. label: '地图概览',
  145. },
  146. ],
  147. },
  148. {
  149. key: '/files',
  150. icon: <FileOutlined />,
  151. label: '文件管理',
  152. children: [
  153. {
  154. key: '/file-library',
  155. label: '文件库',
  156. },
  157. ],
  158. },
  159. {
  160. key: '/know-info',
  161. icon: <BookOutlined />,
  162. label: '知识库',
  163. },
  164. {
  165. key: '/users',
  166. icon: <TeamOutlined />,
  167. label: '用户管理',
  168. },
  169. {
  170. key: '/settings',
  171. icon: <SettingOutlined />,
  172. label: '系统设置',
  173. children: [
  174. {
  175. key: '/theme-settings',
  176. label: '主题设置',
  177. },
  178. {
  179. key: '/settings',
  180. label: '基本设置',
  181. },
  182. ],
  183. },
  184. ];
  185. // 初始化filteredMenuItems
  186. useEffect(() => {
  187. setFilteredMenuItems(menuItems);
  188. }, []);
  189. // 搜索菜单项
  190. const handleSearch = (value: string) => {
  191. setSearchText(value);
  192. if (!value.trim()) {
  193. setFilteredMenuItems(menuItems);
  194. return;
  195. }
  196. // 搜索功能 - 过滤菜单项
  197. const filtered = menuItems.reduce((acc: any[], item) => {
  198. // 检查主菜单项是否匹配
  199. const mainItemMatch = item.label.toString().toLowerCase().includes(value.toLowerCase());
  200. // 如果有子菜单,检查子菜单中是否有匹配项
  201. if (item.children) {
  202. const matchedChildren = item.children.filter(child =>
  203. child.label.toString().toLowerCase().includes(value.toLowerCase())
  204. );
  205. if (matchedChildren.length > 0) {
  206. // 如果有匹配的子菜单,创建包含匹配子菜单的副本
  207. acc.push({
  208. ...item,
  209. children: matchedChildren
  210. });
  211. return acc;
  212. }
  213. }
  214. // 如果主菜单项匹配,添加整个项
  215. if (mainItemMatch) {
  216. acc.push(item);
  217. }
  218. return acc;
  219. }, []);
  220. setFilteredMenuItems(filtered);
  221. };
  222. // 清除搜索
  223. const clearSearch = () => {
  224. setSearchText('');
  225. setFilteredMenuItems(menuItems);
  226. };
  227. const handleMenuClick = ({ key }: { key: string }) => {
  228. navigate(`/admin${key}`);
  229. // 如果有搜索文本,清除搜索
  230. if (searchText) {
  231. clearSearch();
  232. }
  233. };
  234. // 处理登出
  235. const handleLogout = async () => {
  236. await logout();
  237. navigate('/admin/login');
  238. };
  239. // 处理菜单展开/收起
  240. const onOpenChange = (keys: string[]) => {
  241. // 当侧边栏折叠时不保存openKeys状态
  242. if (!collapsed) {
  243. setOpenKeys(keys);
  244. }
  245. };
  246. // 当侧边栏折叠状态改变时,控制菜单打开状态
  247. useEffect(() => {
  248. if (collapsed) {
  249. setOpenKeys([]);
  250. } else {
  251. // 找到当前路径所属的父菜单
  252. const currentPath = location.pathname.replace('/admin', '');
  253. const parentKeys = menuItems
  254. .filter(item => item.children && item.children.some(child => child.key === currentPath))
  255. .map(item => item.key);
  256. // 仅展开当前所在的菜单组
  257. if (parentKeys.length > 0) {
  258. setOpenKeys(parentKeys);
  259. } else {
  260. // 初始时可以根据需要设置要打开的菜单组
  261. setOpenKeys([]);
  262. }
  263. }
  264. }, [collapsed, location.pathname]);
  265. // 用户下拉菜单项
  266. const userMenuItems = [
  267. {
  268. key: 'profile',
  269. label: '个人信息',
  270. icon: <UserOutlined />
  271. },
  272. {
  273. key: 'theme',
  274. label: isDark ? '切换到亮色模式' : '切换到暗色模式',
  275. icon: <SettingOutlined />,
  276. onClick: toggleTheme
  277. },
  278. {
  279. key: 'logout',
  280. label: '退出登录',
  281. icon: <LogoutOutlined />,
  282. onClick: handleLogout
  283. }
  284. ];
  285. // 应用名称 - 从CONFIG中获取或使用默认值
  286. const appName = window.CONFIG?.APP_NAME || '应用Starter';
  287. return (
  288. <Layout style={{ minHeight: '100vh' }}>
  289. <Sider
  290. trigger={null}
  291. collapsible
  292. collapsed={collapsed}
  293. width={240}
  294. className="custom-sider"
  295. style={{
  296. overflow: 'auto',
  297. height: '100vh',
  298. position: 'fixed',
  299. left: 0,
  300. top: 0,
  301. bottom: 0,
  302. zIndex: 100,
  303. }}
  304. >
  305. <div className="p-4">
  306. <Typography.Title level={2} className="text-xl font-bold truncate">
  307. {collapsed ? '应用' : appName}
  308. </Typography.Title>
  309. </div>
  310. {/* 搜索框 - 仅在展开状态下显示 */}
  311. {!collapsed && (
  312. <div style={{ padding: '0 16px 16px' }}>
  313. <Input
  314. placeholder="搜索菜单..."
  315. value={searchText}
  316. onChange={(e) => handleSearch(e.target.value)}
  317. suffix={
  318. searchText ?
  319. <Button
  320. type="text"
  321. size="small"
  322. icon={<CloseOutlined />}
  323. onClick={clearSearch}
  324. /> :
  325. <SearchOutlined />
  326. }
  327. />
  328. </div>
  329. )}
  330. <Menu
  331. theme={isDark ? "light" : "light"}
  332. mode="inline"
  333. selectedKeys={[location.pathname.replace('/admin', '')]}
  334. openKeys={openKeys}
  335. onOpenChange={onOpenChange}
  336. items={filteredMenuItems}
  337. onClick={handleMenuClick}
  338. />
  339. </Sider>
  340. <Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
  341. <Header className="p-0 flex justify-between items-center"
  342. style={{
  343. position: 'sticky',
  344. top: 0,
  345. zIndex: 99,
  346. boxShadow: '0 1px 4px rgba(0,21,41,0.08)',
  347. }}
  348. >
  349. <Button
  350. type="text"
  351. icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
  352. onClick={() => setCollapsed(!collapsed)}
  353. className="w-16 h-16"
  354. />
  355. <Space size="middle" className="mr-4">
  356. <Badge count={5} offset={[0, 5]}>
  357. <Button
  358. type="text"
  359. icon={<BellOutlined />}
  360. />
  361. </Badge>
  362. <Dropdown menu={{ items: userMenuItems }}>
  363. <Space className="cursor-pointer">
  364. <Avatar
  365. src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
  366. icon={!user?.avatar && !navigator.onLine && <UserOutlined />}
  367. />
  368. <span>
  369. {user?.nickname || user?.username}
  370. </span>
  371. </Space>
  372. </Dropdown>
  373. </Space>
  374. </Header>
  375. <Content className="m-6" style={{ overflow: 'initial' }}>
  376. <div className="site-layout-content p-6 rounded-lg">
  377. <Outlet />
  378. </div>
  379. {/* 回到顶部按钮 */}
  380. {showBackTop && (
  381. <Button
  382. type="primary"
  383. shape="circle"
  384. icon={<VerticalAlignTopOutlined />}
  385. size="large"
  386. onClick={scrollToTop}
  387. style={{
  388. position: 'fixed',
  389. right: 30,
  390. bottom: 30,
  391. zIndex: 1000,
  392. boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
  393. }}
  394. />
  395. )}
  396. </Content>
  397. </Layout>
  398. </Layout>
  399. );
  400. };
  401. // 受保护的路由组件
  402. const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
  403. const { isAuthenticated, isLoading } = useAuth();
  404. const navigate = useNavigate();
  405. useEffect(() => {
  406. // 只有在加载完成且未认证时才重定向
  407. if (!isLoading && !isAuthenticated) {
  408. navigate('/admin/login', { replace: true });
  409. }
  410. }, [isAuthenticated, isLoading, navigate]);
  411. // 显示加载状态,直到认证检查完成
  412. if (isLoading) {
  413. return (
  414. <div className="flex justify-center items-center h-screen">
  415. <div className="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div>
  416. </div>
  417. );
  418. }
  419. // 如果未认证且不再加载中,不显示任何内容(等待重定向)
  420. if (!isAuthenticated) {
  421. return null;
  422. }
  423. return children;
  424. };
  425. // 错误页面组件
  426. const ErrorPage = ({ error }: { error?: Error }) => {
  427. const { isDark } = useTheme();
  428. return (
  429. <div className="flex flex-col items-center justify-center h-screen"
  430. style={{ color: isDark ? '#fff' : 'inherit' }}
  431. >
  432. <h1 className="text-2xl font-bold mb-4">发生错误</h1>
  433. <p className="mb-4">{error?.message || '抱歉,应用程序遇到了一些问题。'}</p>
  434. <Button
  435. type="primary"
  436. onClick={() => window.location.reload()}
  437. >
  438. 重新加载
  439. </Button>
  440. </div>
  441. );
  442. };
  443. // 应用入口组件
  444. const App = () => {
  445. // 路由配置
  446. const router = createBrowserRouter([
  447. {
  448. path: '/',
  449. element: <Navigate to="/admin" replace />
  450. },
  451. {
  452. path: '/admin/login',
  453. element: <LoginPage />
  454. },
  455. {
  456. path: '/admin',
  457. element: (
  458. <ProtectedRoute>
  459. <MainLayout />
  460. </ProtectedRoute>
  461. ),
  462. children: [
  463. {
  464. index: true,
  465. element: <Navigate to="/admin/dashboard" />
  466. },
  467. {
  468. path: 'dashboard',
  469. element: <DashboardPage />,
  470. errorElement: <ErrorPage />
  471. },
  472. {
  473. path: 'users',
  474. element: <UsersPage />,
  475. errorElement: <ErrorPage />
  476. },
  477. {
  478. path: 'settings',
  479. element: <SettingsPage />,
  480. errorElement: <ErrorPage />
  481. },
  482. {
  483. path: 'theme-settings',
  484. element: <ThemeSettingsPage />,
  485. errorElement: <ErrorPage />
  486. },
  487. {
  488. path: 'chart-dashboard',
  489. element: <ChartDashboardPage />,
  490. errorElement: <ErrorPage />
  491. },
  492. {
  493. path: 'map-dashboard',
  494. element: <LoginMapPage />,
  495. errorElement: <ErrorPage />
  496. },
  497. {
  498. path: 'know-info',
  499. element: <KnowInfoPage />,
  500. errorElement: <ErrorPage />
  501. },
  502. {
  503. path: 'file-library',
  504. element: <FileLibraryPage />,
  505. errorElement: <ErrorPage />
  506. },
  507. ],
  508. },
  509. ]);
  510. return <RouterProvider router={router} />
  511. };
  512. // 渲染应用
  513. const root = createRoot(document.getElementById('root') as HTMLElement);
  514. root.render(
  515. <QueryClientProvider client={queryClient}>
  516. <ThemeProvider>
  517. <AuthProvider>
  518. <App />
  519. </AuthProvider>
  520. </ThemeProvider>
  521. </QueryClientProvider>
  522. );