web_app.tsx 15 KB

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