web_app.tsx 15 KB

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