web_app.tsx 15 KB

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