diff --git a/src/App.tsx b/src/App.tsx index 5e11781..11ecbc0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -108,10 +108,8 @@ function App() { } /> } /> } /> - } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index 45e5d4d..46731f2 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { Avatar, Button, Dropdown, Space } from 'antd'; +import { Avatar, Button, Dropdown, Space, Tabs } from 'antd'; import type { MenuProps } from 'antd'; import { DownOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { removeToken } from '../../utils/auth'; import { usePermission } from '@/contexts/PermissionContext'; import './navbar.css'; @@ -11,9 +11,189 @@ interface AppNavbarProps { collapsed: boolean; onToggle: () => void; } + +interface OpenPageTab { + key: string; + path: string; + pathname: string; + title: string; +} + +const OPEN_PAGE_TABS_STORAGE_KEY = 'pms_open_page_tabs_v1'; +const MAX_OPEN_PAGE_TABS = 12; + +const TAB_TITLE_MAP: Record = { + '/index': '工作日志', + '/dashboard/project-execution': '项目执行表', + '/projectBank/projectProgress': '项目执行表', + '/projectBank/projectUser': '项目人员表', + '/projectBank/userProject': '人员项目表', + '/projectBank/userScore': '人员绩效表', + '/projectBank/userScoreDetail': '绩效详情', + '/user/profile': '个人中心', + '/profile': '个人中心', + '/monitor/cache': '缓存监控', + '/monitor/job': '定时任务', + '/monitor/logininfor': '登录日志', + '/log/logininfor': '登录日志', + '/system/logininfor': '登录日志', + '/system/log/logininfor': '登录日志', + '/monitor/online': '在线用户', + '/monitor/operlog': '操作日志', + '/log/operlog': '操作日志', + '/system/operlog': '操作日志', + '/system/log/operlog': '操作日志', + '/monitor/server': '服务监控', + '/monitor/cacheList': '缓存列表', + '/system/user': '用户管理', + '/system/role': '角色管理', + '/system/menu': '菜单管理', + '/system/dept': '部门管理', + '/system/dict': '字典管理', + '/system/config': '参数设置', + '/project/list': '项目列表', + '/project/detail': '项目详情', + '/project/demandManage': '需求管理', + '/demandManage': '需求管理', + '/workAppraisal/manager': '考核任务', + '/workAppraisal/normalWorker': '考核评分', + '/workAppraisal/managerUser': '考核人员', + '/workAppraisal/detail': '绩效详情', + '/workAppraisal/taskSet': '任务设置', + '/workAppraisal/taskModule': '考核看板', + '/workAppraisal/dashboard': '考核看板', + '/workAppraisal/moduleDetail': '模块详情', + '/workAppraisal/myPerformance': '人员绩效表', +}; + +const CANONICAL_TAB_PATH_MAP: Record = { + '/': '/index', + '/profile': '/user/profile', + '/dashboard/project-execution': '/projectBank/projectProgress', + '/log/logininfor': '/monitor/logininfor', + '/system/logininfor': '/monitor/logininfor', + '/system/log/logininfor': '/monitor/logininfor', + '/log/operlog': '/monitor/operlog', + '/system/operlog': '/monitor/operlog', + '/system/log/operlog': '/monitor/operlog', + '/workAppraisal/dashboard': '/workAppraisal/taskModule', + '/demandManage': '/project/demandManage', +}; + +const normalizePathname = (pathname: string) => { + const normalizedPathname = pathname === '/' ? '/index' : pathname; + return CANONICAL_TAB_PATH_MAP[normalizedPathname] ?? normalizedPathname; +}; + +const normalizeTabSearch = (pathname: string, search: string) => { + if (!search) { + return ''; + } + + const normalizedPathname = normalizePathname(pathname); + const params = new URLSearchParams(search); + + if (normalizedPathname === '/user/profile') { + params.delete('tab'); + } + + const normalizedParams = params.toString(); + return normalizedParams ? `?${normalizedParams}` : ''; +}; + +const getTabTitle = (pathname: string, search: string) => { + const normalizedPathname = normalizePathname(pathname); + const params = new URLSearchParams(search); + const routeTitle = TAB_TITLE_MAP[normalizedPathname] ?? '页面'; + + if (normalizedPathname === '/projectBank/projectUser') { + const projectName = params.get('projectName'); + return projectName ? `${routeTitle} · ${projectName}` : routeTitle; + } + + if (normalizedPathname === '/index') { + const nickName = params.get('nickName'); + return nickName ? `${routeTitle} · ${nickName}` : routeTitle; + } + + if (normalizedPathname === '/project/demandManage' || normalizedPathname === '/demandManage') { + const projectName = params.get('projectName'); + return projectName ? `${routeTitle} · ${projectName}` : routeTitle; + } + + return routeTitle; +}; + +const createOpenPageTab = (pathname: string, search: string): OpenPageTab => { + const normalizedPathname = normalizePathname(pathname); + const normalizedSearch = normalizeTabSearch(normalizedPathname, search || ''); + return { + key: `${normalizedPathname}${normalizedSearch}`, + path: `${normalizedPathname}${search || ''}`, + pathname: normalizedPathname, + title: getTabTitle(normalizedPathname, search || ''), + }; +}; + +const getStoredOpenPageTabs = (canAccessPath: (path: string) => boolean): OpenPageTab[] => { + if (typeof window === 'undefined') { + return []; + } + + try { + const raw = window.sessionStorage.getItem(OPEN_PAGE_TABS_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + const seenKeys = new Set(); + + return parsed.reduce((tabs, item) => { + if ( + !item + || typeof item !== 'object' + || typeof item.path !== 'string' + || typeof item.pathname !== 'string' + ) { + return tabs; + } + + const storedPath = String(item.path); + const [storedPathname, storedSearch = ''] = storedPath.split('?'); + const normalizedTab = createOpenPageTab( + storedPathname, + storedSearch ? `?${storedSearch}` : '', + ); + + if (!canAccessPath(normalizedTab.pathname) || seenKeys.has(normalizedTab.key)) { + return tabs; + } + + seenKeys.add(normalizedTab.key); + tabs.push(normalizedTab); + return tabs; + }, []); + } catch (error) { + console.warn('Failed to restore open page tabs:', error); + return []; + } +}; + +const isPinnedTab = (tab: OpenPageTab) => tab.pathname === '/index'; + const AppNavbar: React.FC = ({ collapsed, onToggle }) => { const navigate = useNavigate(); - const { userName } = usePermission(); + const location = useLocation(); + const { userName, canAccessPath } = usePermission(); + const activeTab = React.useMemo( + () => createOpenPageTab(location.pathname, location.search), + [location.pathname, location.search], + ); + const [openTabs, setOpenTabs] = React.useState(() => getStoredOpenPageTabs(canAccessPath)); const handleLogout = () => { removeToken(); @@ -46,6 +226,78 @@ const AppNavbar: React.FC = ({ collapsed, onToggle }) => { } }; + React.useEffect(() => { + if (!canAccessPath(activeTab.pathname)) { + return; + } + + setOpenTabs((prev) => { + const filteredPrev = prev.filter((item) => canAccessPath(item.pathname)); + const existingIndex = filteredPrev.findIndex((item) => item.key === activeTab.key); + let nextTabs = existingIndex >= 0 + ? filteredPrev.map((item, index) => (index === existingIndex ? activeTab : item)) + : [...filteredPrev, activeTab]; + + if (nextTabs.length > MAX_OPEN_PAGE_TABS) { + const removableIndex = nextTabs.findIndex((item) => item.key !== activeTab.key && !isPinnedTab(item)); + if (removableIndex >= 0) { + nextTabs = nextTabs.filter((_item, index) => index !== removableIndex); + } else { + nextTabs = nextTabs.slice(-MAX_OPEN_PAGE_TABS); + } + } + + return nextTabs; + }); + }, [activeTab, canAccessPath]); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + window.sessionStorage.setItem(OPEN_PAGE_TABS_STORAGE_KEY, JSON.stringify(openTabs)); + }, [openTabs]); + + const handleTabChange = (key: string) => { + const targetTab = openTabs.find((item) => item.key === key); + if (!targetTab) { + return; + } + navigate(targetTab.path); + }; + + const handleTabRemove = (targetKey: string) => { + const targetTab = openTabs.find((item) => item.key === targetKey); + if (!targetTab || isPinnedTab(targetTab) || openTabs.length <= 1) { + return; + } + + const targetIndex = openTabs.findIndex((item) => item.key === targetKey); + const nextTabs = openTabs.filter((item) => item.key !== targetKey); + setOpenTabs(nextTabs); + + if (targetKey === activeTab.key) { + const fallbackTab = nextTabs[targetIndex] ?? nextTabs[targetIndex - 1] ?? nextTabs[0]; + if (fallbackTab) { + navigate(fallbackTab.path); + } + } + }; + + const tabItems = React.useMemo( + () => + openTabs.map((item) => ({ + key: item.key, + closable: !isPinnedTab(item) && openTabs.length > 1, + label: ( + + {item.title} + + ), + })), + [openTabs], + ); + return (
); }; diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index da45ebf..9a8cef8 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -38,15 +38,21 @@ type MenuItemWithChildren = MenuItem & { children?: MenuItem[] }; const BACKEND_PATH_TO_APP_PATH: Record = { '/project': '/project/list', '/projectBank/userScore': '/workAppraisal/myPerformance', + '/logininfor': '/monitor/logininfor', + '/operlog': '/monitor/operlog', + '/log/logininfor': '/monitor/logininfor', + '/log/operlog': '/monitor/operlog', '/system/logininfor': '/monitor/logininfor', '/system/operlog': '/monitor/operlog', + '/system/log/logininfor': '/monitor/logininfor', + '/system/log/operlog': '/monitor/operlog', }; const MENU_ROUTE_ALIASES: Record = { '/projectBank/projectProgress': ['/dashboard/project-execution'], '/workAppraisal/myPerformance': ['/projectBank/userScore'], - '/monitor/logininfor': ['/system/logininfor'], - '/monitor/operlog': ['/system/operlog'], + '/monitor/logininfor': ['/logininfor', '/log/logininfor', '/system/logininfor', '/system/log/logininfor'], + '/monitor/operlog': ['/operlog', '/log/operlog', '/system/operlog', '/system/log/operlog'], }; const ICON_MAP: Record = { diff --git a/src/contexts/PermissionContext.tsx b/src/contexts/PermissionContext.tsx index 86fe792..d7f0845 100644 --- a/src/contexts/PermissionContext.tsx +++ b/src/contexts/PermissionContext.tsx @@ -9,6 +9,11 @@ interface PermissionContextValue { ready: boolean; isAdmin: boolean; userName: string; + currentUser: { + userId?: string | number; + userName?: string; + nickName?: string; + }; roles: string[]; permissions: string[]; routers: RouterNode[]; @@ -25,8 +30,14 @@ const ALWAYS_ALLOW_PATHS = new Set(['/', '/profile', '/user/profile']); const BACKEND_PATH_TO_APP_PATH: Record = { '/project': '/project/list', '/projectBank/userScore': '/workAppraisal/myPerformance', + '/logininfor': '/monitor/logininfor', + '/operlog': '/monitor/operlog', + '/log/logininfor': '/monitor/logininfor', + '/log/operlog': '/monitor/operlog', '/system/logininfor': '/monitor/logininfor', '/system/operlog': '/monitor/operlog', + '/system/log/logininfor': '/monitor/logininfor', + '/system/log/operlog': '/monitor/operlog', }; const ROUTE_ALIASES: Record = { '/profile': ['/user/profile'], @@ -39,8 +50,8 @@ const ROUTE_ALIASES: Record = { '/workAppraisal/myPerformance': ['/projectBank/userScore'], '/projectBank/userScore': ['/workAppraisal/myPerformance'], '/projectBank/userScoreDetail': ['/workAppraisal/myPerformance'], - '/monitor/logininfor': ['/system/logininfor'], - '/monitor/operlog': ['/system/operlog'], + '/monitor/logininfor': ['/logininfor', '/log/logininfor', '/system/logininfor', '/system/log/logininfor'], + '/monitor/operlog': ['/operlog', '/log/operlog', '/system/operlog', '/system/log/operlog'], }; const SUPER_PERMI = '*:*:*'; const ADMIN_ROLE = 'admin'; @@ -198,6 +209,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch const [loading, setLoading] = useState(false); const [ready, setReady] = useState(false); const [userName, setUserName] = useState(''); + const [currentUser, setCurrentUser] = useState({}); const [roles, setRoles] = useState([]); const [permissions, setPermissions] = useState([]); const [routers, setRouters] = useState([]); @@ -207,6 +219,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch const clearPermissionState = useCallback(() => { setUserName(''); + setCurrentUser({}); setRoles([]); setPermissions([]); setRouters([]); @@ -228,6 +241,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch setReady(false); let nextUserName = ''; + let nextCurrentUser: PermissionContextValue['currentUser'] = {}; let nextRoles: string[] = []; let nextPermissions: string[] = []; let nextRouters: RouterNode[] = []; @@ -237,15 +251,29 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch let bootstrapFailed = false; try { - try { - const info = await getInfo(); + const [infoResult, routersResult] = await Promise.allSettled([getInfo(), getRouters()]); + + if (infoResult.status === 'fulfilled') { + const info = infoResult.value; + const infoUser = info.user as Record | undefined; nextRoles = parseStringList(info.roles); nextPermissions = parseStringList(info.permissions); - nextUserName = String((info.user as Record | undefined)?.userName ?? ''); - } catch (error) { + nextCurrentUser = { + userId: infoUser?.userId as string | number | undefined, + userName: infoUser?.userName as string | undefined, + nickName: infoUser?.nickName as string | undefined, + }; + nextUserName = String(nextCurrentUser.userName ?? ''); + } else { + const error = infoResult.reason; console.error('Failed to load /getInfo, fallback to profile:', error); try { const profile = await getUserProfile(); + nextCurrentUser = { + userId: profile.user?.userId, + userName: profile.user?.userName, + nickName: profile.user?.nickName, + }; nextUserName = String(profile.user?.userName ?? ''); const roleGroup = String((profile as Record).roleGroup ?? ''); nextRoles = roleGroup @@ -258,8 +286,8 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch } } - try { - const routersRaw = await getRouters(); + if (routersResult.status === 'fulfilled') { + const routersRaw = routersResult.value; const routes = extractRouteNodes(routersRaw); if (routes.length > 0) { nextRouters = routes; @@ -269,7 +297,8 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch nextDefaultRoutePath = findFirstVisibleRoutePath(routes, '/') || '/index'; nextRouteGuardEnabled = true; } - } catch (routerError) { + } else { + const routerError = routersResult.reason; console.error('Failed to load router permission data:', routerError); bootstrapFailed = true; } @@ -281,6 +310,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch notify.warning('权限信息加载超时,已使用基础访问模式'); } setUserName(nextUserName); + setCurrentUser(nextCurrentUser); setRoles(nextRoles); setPermissions(nextPermissions); setRouters(nextRouters); @@ -371,6 +401,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch ready, isAdmin: isAdmin(), userName, + currentUser, roles, permissions, routers, @@ -380,7 +411,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch hasPermi, canAccessPath, }), - [loading, ready, isAdmin, userName, roles, permissions, routers, defaultRoutePath, refreshPermissions, hasRole, hasPermi, canAccessPath], + [loading, ready, isAdmin, userName, currentUser, roles, permissions, routers, defaultRoutePath, refreshPermissions, hasRole, hasPermi, canAccessPath], ); return {children}; diff --git a/src/pages/Profile/AvatarUploader.tsx b/src/pages/Profile/AvatarUploader.tsx index cff3c7c..9166b94 100644 --- a/src/pages/Profile/AvatarUploader.tsx +++ b/src/pages/Profile/AvatarUploader.tsx @@ -1,7 +1,8 @@ import { CameraOutlined, UserOutlined } from '@ant-design/icons'; -import { Avatar, Upload, message } from 'antd'; +import { Avatar, Upload } from 'antd'; import type { UploadProps } from 'antd'; import { uploadUserAvatar } from '@/api/user'; +import { notify } from '@/utils/notify'; interface AvatarUploaderProps { avatarUrl?: string; @@ -31,21 +32,21 @@ const AvatarUploader = ({ avatarUrl, displayName, onUploaded }: AvatarUploaderPr const response = await uploadUserAvatar(formData); const nextAvatar = String(response.imgUrl ?? ''); onUploaded(nextAvatar); - message.success('修改成功'); + notify.success('修改成功'); onSuccess?.(response); } catch (error) { - message.error('头像上传失败'); + notify.error('头像上传失败'); onError?.(error as Error); } }, beforeUpload: (file) => { if (!file.type.startsWith('image/')) { - message.error('请上传图片文件'); + notify.error('请上传图片文件'); return Upload.LIST_IGNORE; } const isLt5M = file.size / 1024 / 1024 < 5; if (!isLt5M) { - message.error('头像大小不能超过 5MB'); + notify.error('头像大小不能超过 5MB'); return Upload.LIST_IGNORE; } return true; diff --git a/src/pages/Profile/ResetPassword.tsx b/src/pages/Profile/ResetPassword.tsx index 48388f4..2c44187 100644 --- a/src/pages/Profile/ResetPassword.tsx +++ b/src/pages/Profile/ResetPassword.tsx @@ -1,7 +1,8 @@ -import { Button, Form, Input, Space, message } from 'antd'; +import { Button, Form, Input, Space } from 'antd'; import { useNavigate } from 'react-router-dom'; import { updateUserPwd } from '@/api/user'; import { usePermission } from '@/contexts/PermissionContext'; +import { notify } from '@/utils/notify'; interface ResetPasswordValues { oldPassword: string; @@ -25,7 +26,7 @@ const ResetPassword = () => { const onFinish = async (values: ResetPasswordValues) => { try { await updateUserPwd(values.oldPassword, values.newPassword); - message.success('修改成功'); + notify.success('修改成功'); form.resetFields(); } catch (error) { console.error('Failed to update password:', error); diff --git a/src/pages/Profile/UserInfo.tsx b/src/pages/Profile/UserInfo.tsx index 7d200c0..1feffb5 100644 --- a/src/pages/Profile/UserInfo.tsx +++ b/src/pages/Profile/UserInfo.tsx @@ -1,9 +1,10 @@ import { useEffect } from 'react'; -import { Button, Form, Input, Radio, Space, message } from 'antd'; +import { Button, Form, Input, Radio, Space } from 'antd'; import { useNavigate } from 'react-router-dom'; import { updateUserProfile } from '@/api/user'; import { usePermission } from '@/contexts/PermissionContext'; import type { UpdateUserProfilePayload, UserProfileUser } from '@/types/api'; +import { notify } from '@/utils/notify'; interface UserInfoProps { user: UserProfileUser; @@ -35,7 +36,7 @@ const UserInfo = ({ user, onUpdated }: UserInfoProps) => { const onFinish = async (values: UpdateUserProfilePayload) => { try { await updateUserProfile(values); - message.success('修改成功'); + notify.success('修改成功'); onUpdated(values); } catch (error) { console.error('Failed to update profile:', error); diff --git a/src/pages/Profile/index.tsx b/src/pages/Profile/index.tsx index e0af391..689e355 100644 --- a/src/pages/Profile/index.tsx +++ b/src/pages/Profile/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Card, Spin, Tabs, message } from 'antd'; +import { Card, Spin, Tabs } from 'antd'; import type { TabsProps } from 'antd'; import { ApartmentOutlined, @@ -12,6 +12,7 @@ import { import { useSearchParams } from 'react-router-dom'; import { getUserProfile } from '@/api/user'; import type { UserProfileUser } from '@/types/api'; +import { notify } from '@/utils/notify'; import AvatarUploader from './AvatarUploader'; import ResetPassword from './ResetPassword'; import UserInfo from './UserInfo'; @@ -50,7 +51,7 @@ const ProfilePage = () => { }) .catch((error: unknown) => { console.error('Failed to load user profile:', error); - message.error('获取个人中心信息失败'); + notify.error('获取个人中心信息失败'); }) .finally(() => { if (active) { diff --git a/src/pages/dashboard/ProjectExecutionPage.tsx b/src/pages/dashboard/ProjectExecutionPage.tsx index 00cab04..e5b997b 100644 --- a/src/pages/dashboard/ProjectExecutionPage.tsx +++ b/src/pages/dashboard/ProjectExecutionPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Button, DatePicker, Empty, Form, Input, message, Progress, Select, Space, Table, Tooltip } from 'antd'; +import { Button, DatePicker, Empty, Form, Input, Progress, Select, Space, Table, Tooltip } from 'antd'; import type { TableColumnsType } from 'antd'; import { CalendarOutlined, @@ -18,6 +18,7 @@ import { listProject } from '@/api/project'; import { listProjectExecution } from '@/api/projectExecution'; import { getDicts } from '@/api/system/dict'; import { usePermission } from '@/contexts/PermissionContext'; +import { notify } from '@/utils/notify'; import '@/styles/permission-link.css'; import './project-execution.css'; @@ -397,11 +398,11 @@ const ProjectExecutionPage = () => { if (ENABLE_PROJECT_EXECUTION_API && fallbackUsed && !fallbackTipShownRef.current) { fallbackTipShownRef.current = true; - message.info('项目执行表接口未就绪,已自动使用项目列表估算执行进度'); + notify.info('项目执行表接口未就绪,已自动使用项目列表估算执行进度'); } } catch (error) { console.error('Failed to fetch project execution list:', error); - message.error('获取项目执行表失败'); + notify.error('获取项目执行表失败'); setRows([]); setTotal(0); } finally { diff --git a/src/pages/monitor/CacheListPage.tsx b/src/pages/monitor/CacheListPage.tsx index 6bad116..7bd0dc4 100644 --- a/src/pages/monitor/CacheListPage.tsx +++ b/src/pages/monitor/CacheListPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { Card, Col, Row, message, Table, Button, Input, Form, Space } from 'antd'; +import { Card, Col, Row, Table, Button, Input, Form, Space } from 'antd'; import type { TableColumnsType } from 'antd'; import { ReloadOutlined, DeleteOutlined, KeyOutlined, FileTextOutlined, AppstoreOutlined } from '@ant-design/icons'; import { @@ -19,6 +19,7 @@ import type { import Permission from '@/components/Permission'; import { usePermission } from '@/contexts/PermissionContext'; import ReadonlyAction from '@/components/Permission/ReadonlyAction'; +import { notify } from '@/utils/notify'; import './cache-list.css'; interface CacheForm { @@ -126,7 +127,7 @@ const CacheListPage: React.FC = () => { }); } catch (error: unknown) { console.error('Failed to fetch cache keys:', error); - message.error('获取缓存键名列表失败'); + notify.error('获取缓存键名列表失败'); } finally { setSubLoading(false); } @@ -156,7 +157,7 @@ const CacheListPage: React.FC = () => { await getCacheKeys(targetCacheName); } catch (error: unknown) { console.error('Failed to fetch cache names:', error); - message.error('获取缓存名称列表失败'); + notify.error('获取缓存名称列表失败'); } finally { setLoading(false); } @@ -176,7 +177,7 @@ const CacheListPage: React.FC = () => { }); } catch (error: unknown) { console.error('Failed to fetch cache value:', error); - message.error('获取缓存内容失败'); + notify.error('获取缓存内容失败'); } }, [nowCacheName], @@ -193,7 +194,7 @@ const CacheListPage: React.FC = () => { const refreshCacheNames = async () => { await getCacheNames(); - message.success('刷新缓存列表成功'); + notify.success('刷新缓存列表成功'); }; const handleClearCacheName = async (row: CacheNameRecord) => { @@ -202,16 +203,16 @@ const CacheListPage: React.FC = () => { } try { await clearCacheName(row.cacheName); - message.success(`清理缓存名称[${row.cacheName}]成功`); + notify.success(`清理缓存名称[${row.cacheName}]成功`); await getCacheNames(); } catch { - message.error('清理缓存名称失败'); + notify.error('清理缓存名称失败'); } }; const refreshCacheKeys = async () => { await getCacheKeys(); - message.success('刷新键名列表成功'); + notify.success('刷新键名列表成功'); }; const handleClearCacheKey = async (fullCacheKey: string) => { @@ -224,11 +225,11 @@ const CacheListPage: React.FC = () => { try { await clearCacheKey(fullCacheKey); - message.success(`清理缓存键名[${fullCacheKey}]成功`); + notify.success(`清理缓存键名[${fullCacheKey}]成功`); await getCacheKeys(nowCacheName); setCacheForm({ cacheName: nowCacheName, cacheKey: '', cacheValue: '' }); } catch { - message.error('清理缓存键名失败'); + notify.error('清理缓存键名失败'); } }; @@ -238,10 +239,10 @@ const CacheListPage: React.FC = () => { } try { await clearCacheAll(); - message.success('清理全部缓存成功'); + notify.success('清理全部缓存成功'); await getCacheNames(); } catch { - message.error('清理全部缓存失败'); + notify.error('清理全部缓存失败'); } }; diff --git a/src/pages/monitor/CacheMonitorPage.tsx b/src/pages/monitor/CacheMonitorPage.tsx index 175aaa4..66f2cbf 100644 --- a/src/pages/monitor/CacheMonitorPage.tsx +++ b/src/pages/monitor/CacheMonitorPage.tsx @@ -1,6 +1,5 @@ -import { useState, useEffect } from 'react'; -import { Row, Col, Card, Descriptions, Spin, message, Typography } from 'antd'; -import ReactEChartsCore from 'echarts-for-react/lib/core'; +import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { App, Row, Col, Card, Descriptions, Spin, Typography } from 'antd'; import { echarts } from '@/utils/echarts'; import { macarons } from '../../themes/macarons'; import { getCache } from '../../api/monitor/cache'; @@ -9,6 +8,8 @@ import './cache-monitor.css'; const { Text } = Typography; +const ReactEChartsCore = lazy(() => import('echarts-for-react/lib/core')); + echarts.registerTheme('macarons', macarons); const defaultCacheData: CacheMonitorResponse = { @@ -25,6 +26,7 @@ const toDisplayText = (value: string | number | undefined): string => { }; const CacheMonitorPage = () => { + const { message } = App.useApp(); const [loading, setLoading] = useState(true); const [cacheData, setCacheData] = useState(defaultCacheData); const [error, setError] = useState(null); @@ -52,7 +54,9 @@ const CacheMonitorPage = () => { return
{error}
; } - const commandStatsOptions = { + const chartFallback = ; + + const commandStatsOptions = useMemo(() => ({ tooltip: { trigger: 'item', formatter: '{a}
{b} : {c} ({d}%)', @@ -69,9 +73,9 @@ const CacheMonitorPage = () => { animationDuration: 1000, }, ], - }; + }), [cacheData.commandStats]); - const usedMemoryOptions = { + const usedMemoryOptions = useMemo(() => ({ tooltip: { formatter: `{b}
{a} : ${toDisplayText(cacheData.info?.used_memory_human)}`, }, @@ -92,7 +96,7 @@ const CacheMonitorPage = () => { ], }, ], - }; + }), [cacheData.info]); return (
@@ -129,12 +133,26 @@ const CacheMonitorPage = () => { - + + + - + + + diff --git a/src/pages/monitor/JobMonitorPage.tsx b/src/pages/monitor/JobMonitorPage.tsx index 70ff843..8fcbd76 100644 --- a/src/pages/monitor/JobMonitorPage.tsx +++ b/src/pages/monitor/JobMonitorPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { Key } from 'react'; import { + App, Table, Form, Input, @@ -8,7 +9,6 @@ import { Button, Dropdown, Modal, - message, Space, Tag, Switch, @@ -66,7 +66,41 @@ const normalizeRowKey = (value: Key): string | number => { return typeof value === 'bigint' ? value.toString() : value; }; +const inflightJobListRequests = new Map>>>(); +const inflightJobDetailRequests = new Map>>>(); + +const serializeJobQuery = (query: JobQueryParams) => JSON.stringify(query); + +const fetchJobList = async (query: JobQueryParams) => { + const requestKey = serializeJobQuery(query); + const existingRequest = inflightJobListRequests.get(requestKey); + if (existingRequest) { + return existingRequest; + } + + const requestPromise = listJob(query).finally(() => { + inflightJobListRequests.delete(requestKey); + }); + inflightJobListRequests.set(requestKey, requestPromise); + return requestPromise; +}; + +const fetchJobDetail = async (jobId: string | number) => { + const requestKey = String(jobId); + const existingRequest = inflightJobDetailRequests.get(requestKey); + if (existingRequest) { + return existingRequest; + } + + const requestPromise = getJob(jobId).finally(() => { + inflightJobDetailRequests.delete(requestKey); + }); + inflightJobDetailRequests.set(requestKey, requestPromise); + return requestPromise; +}; + const JobMonitorPage = () => { + const { message, modal } = App.useApp(); const [form] = Form.useForm(); const [queryForm] = Form.useForm(); const [jobList, setJobList] = useState([]); @@ -90,7 +124,7 @@ const JobMonitorPage = () => { const getList = useCallback(async () => { setLoading(true); try { - const response = await listJob(queryParams); + const response = await fetchJobList(queryParams); setJobList(response.rows ?? []); setTotal(Number(response.total ?? 0)); } catch (error: unknown) { @@ -99,7 +133,7 @@ const JobMonitorPage = () => { } finally { setLoading(false); } - }, [queryParams]); + }, [message, queryParams]); useEffect(() => { void getList(); @@ -136,7 +170,7 @@ const JobMonitorPage = () => { } try { - const response = await getJob(jobId); + const response = await fetchJobDetail(jobId); setCurrentJobDetail(response); form.setFieldsValue({ ...response, @@ -159,7 +193,7 @@ const JobMonitorPage = () => { return; } - Modal.confirm({ + modal.confirm({ title: '确认删除', content: `是否确认删除定时任务编号为"${jobIds.join(',')}"的数据项?`, onOk: async () => { @@ -208,7 +242,7 @@ const JobMonitorPage = () => { } const { jobId, jobGroup } = record; - Modal.confirm({ + modal.confirm({ title: '确认执行', content: `确认要立即执行一次"${record.jobName ?? ''}"任务吗?`, onOk: async () => { @@ -225,7 +259,7 @@ const JobMonitorPage = () => { const handleExport = async () => { const hide = message.loading('正在导出数据...', 0); try { - const response = await listJob({ ...queryParams, pageNum: undefined, pageSize: undefined }); + const response = await fetchJobList({ ...queryParams, pageNum: undefined, pageSize: undefined }); const header = ['任务编号', '任务名称', '任务组名', '调用目标字符串', 'cron执行表达式', '状态']; const rows = response.rows.map((job) => [ job.jobId, @@ -262,7 +296,7 @@ const JobMonitorPage = () => { } try { - const response = await getJob(record.jobId); + const response = await fetchJobDetail(record.jobId); setCurrentJobDetail(response); setDetailModalVisible(true); } catch { diff --git a/src/pages/monitor/LoginLogPage.tsx b/src/pages/monitor/LoginLogPage.tsx index 9b541d2..edf09f9 100644 --- a/src/pages/monitor/LoginLogPage.tsx +++ b/src/pages/monitor/LoginLogPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Key } from 'react'; import { App, @@ -52,6 +52,8 @@ const defaultQueryParams: LogininforQueryParams = { isAsc: 'descending', }; +const inflightLogininforRequests = new Map>>>(); + const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => { return time ? dayjs(time).format(pattern) : ''; }; @@ -61,6 +63,23 @@ const escapeCsvCell = (value: unknown): string => { return `"${raw.replace(/"/g, '""')}"`; }; +const serializeLogininforQuery = (query: LogininforQueryParams) => JSON.stringify(query); + +const fetchLogininforList = async (query: LogininforQueryParams) => { + const requestKey = serializeLogininforQuery(query); + const existingRequest = inflightLogininforRequests.get(requestKey); + if (existingRequest) { + return existingRequest; + } + + const requestPromise = listLogininfor(query).finally(() => { + inflightLogininforRequests.delete(requestKey); + }); + + inflightLogininforRequests.set(requestKey, requestPromise); + return requestPromise; +}; + const LoginLogPage = () => { const { message, modal } = App.useApp(); const [queryForm] = Form.useForm(); @@ -72,19 +91,22 @@ const LoginLogPage = () => { const [dateRange, setDateRange] = useState(null); const [queryParams, setQueryParams] = useState(defaultQueryParams); + const requestParams = useMemo(() => { + const formattedQueryParams: LogininforQueryParams = { ...queryParams }; + if (dateRange?.[0] && dateRange?.[1]) { + formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD HH:mm:ss'); + formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD HH:mm:ss'); + } else { + formattedQueryParams.beginTime = undefined; + formattedQueryParams.endTime = undefined; + } + return formattedQueryParams; + }, [dateRange, queryParams]); + const getList = useCallback(async () => { setLoading(true); try { - const formattedQueryParams: LogininforQueryParams = { ...queryParams }; - if (dateRange?.[0] && dateRange?.[1]) { - formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD HH:mm:ss'); - formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD HH:mm:ss'); - } else { - formattedQueryParams.beginTime = undefined; - formattedQueryParams.endTime = undefined; - } - - const response = await listLogininfor(formattedQueryParams); + const response = await fetchLogininforList(requestParams); setList(response.rows ?? []); setTotal(Number(response.total ?? 0)); } catch (error: unknown) { @@ -93,7 +115,7 @@ const LoginLogPage = () => { } finally { setLoading(false); } - }, [dateRange, queryParams]); + }, [message, requestParams]); useEffect(() => { void getList(); @@ -186,8 +208,8 @@ const LoginLogPage = () => { const handleExport = async () => { const hide = message.loading('正在导出数据...', 0); try { - const response = await listLogininfor({ - ...queryParams, + const response = await fetchLogininforList({ + ...requestParams, pageNum: undefined, pageSize: undefined, }); diff --git a/src/pages/monitor/OnlineUserPage.tsx b/src/pages/monitor/OnlineUserPage.tsx index 269d0da..caf2a0c 100644 --- a/src/pages/monitor/OnlineUserPage.tsx +++ b/src/pages/monitor/OnlineUserPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { Table, Form, Input, Button, Modal, message } from 'antd'; +import { App, Table, Form, Input, Button } from 'antd'; import type { TableColumnsType } from 'antd'; import { SearchOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons'; import { listOnline, forceLogout } from '../../api/monitor/online'; @@ -17,6 +17,8 @@ const defaultQueryParams: OnlineQueryParams = { userName: undefined, }; +const inflightOnlineRequests = new Map>>>(); + const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => { return time ? dayjs(time).format(pattern) : ''; }; @@ -31,7 +33,24 @@ const normalizeDateValue = (value: unknown): string | number | Date | undefined return undefined; }; +const serializeOnlineQuery = (query: OnlineQueryParams) => JSON.stringify(query); + +const fetchOnlineList = async (query: OnlineQueryParams) => { + const requestKey = serializeOnlineQuery(query); + const existingRequest = inflightOnlineRequests.get(requestKey); + if (existingRequest) { + return existingRequest; + } + + const requestPromise = listOnline(query).finally(() => { + inflightOnlineRequests.delete(requestKey); + }); + inflightOnlineRequests.set(requestKey, requestPromise); + return requestPromise; +}; + const OnlineUserPage = () => { + const { message, modal } = App.useApp(); const { hasPermi } = usePermission(); const [queryForm] = Form.useForm(); const [loading, setLoading] = useState(false); @@ -44,7 +63,7 @@ const OnlineUserPage = () => { const getList = useCallback(async () => { setLoading(true); try { - const response = await listOnline(queryParams); + const response = await fetchOnlineList(queryParams); setList(response.rows ?? []); setTotal(Number(response.total ?? 0)); } catch (error: unknown) { @@ -53,7 +72,7 @@ const OnlineUserPage = () => { } finally { setLoading(false); } - }, [queryParams]); + }, [message, queryParams]); useEffect(() => { void getList(); @@ -79,7 +98,7 @@ const OnlineUserPage = () => { } const tokenId = row.tokenId; - Modal.confirm({ + modal.confirm({ title: '确认强退', content: `是否确认强退名称为"${row.userName ?? ''}"的用户?`, onOk: async () => { diff --git a/src/pages/monitor/OperationLogPage.tsx b/src/pages/monitor/OperationLogPage.tsx index c14e8e4..8994541 100644 --- a/src/pages/monitor/OperationLogPage.tsx +++ b/src/pages/monitor/OperationLogPage.tsx @@ -1,13 +1,13 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Key } from 'react'; import { + App, Table, Form, Input, Select, Button, Modal, - message, Space, Tag, DatePicker, @@ -64,6 +64,8 @@ const defaultQueryParams: OperlogQueryParams = { isAsc: 'descending', }; +const inflightOperlogRequests = new Map>>>(); + const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => { return time ? dayjs(time).format(pattern) : ''; }; @@ -83,7 +85,25 @@ const escapeCsvCell = (value: unknown): string => { return `"${raw.replace(/"/g, '""')}"`; }; +const serializeOperlogQuery = (query: OperlogQueryParams) => JSON.stringify(query); + +const fetchOperlogList = async (query: OperlogQueryParams) => { + const requestKey = serializeOperlogQuery(query); + const existingRequest = inflightOperlogRequests.get(requestKey); + if (existingRequest) { + return existingRequest; + } + + const requestPromise = listOperlog(query).finally(() => { + inflightOperlogRequests.delete(requestKey); + }); + + inflightOperlogRequests.set(requestKey, requestPromise); + return requestPromise; +}; + const OperationLogPage = () => { + const { message, modal } = App.useApp(); const [queryForm] = Form.useForm(); const [loading, setLoading] = useState(false); const [list, setList] = useState([]); @@ -94,19 +114,22 @@ const OperationLogPage = () => { const [currentOperlogDetail, setCurrentOperlogDetail] = useState({}); const [queryParams, setQueryParams] = useState(defaultQueryParams); + const requestParams = useMemo(() => { + const formattedQueryParams: OperlogQueryParams = { ...queryParams }; + if (dateRange?.[0] && dateRange?.[1]) { + formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD HH:mm:ss'); + formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD HH:mm:ss'); + } else { + formattedQueryParams.beginTime = undefined; + formattedQueryParams.endTime = undefined; + } + return formattedQueryParams; + }, [dateRange, queryParams]); + const getList = useCallback(async () => { setLoading(true); try { - const formattedQueryParams: OperlogQueryParams = { ...queryParams }; - if (dateRange?.[0] && dateRange?.[1]) { - formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD HH:mm:ss'); - formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD HH:mm:ss'); - } else { - formattedQueryParams.beginTime = undefined; - formattedQueryParams.endTime = undefined; - } - - const response = await listOperlog(formattedQueryParams); + const response = await fetchOperlogList(requestParams); setList(response.rows ?? []); setTotal(Number(response.total ?? 0)); } catch (error: unknown) { @@ -115,7 +138,7 @@ const OperationLogPage = () => { } finally { setLoading(false); } - }, [dateRange, queryParams]); + }, [message, requestParams]); useEffect(() => { void getList(); @@ -143,7 +166,7 @@ const OperationLogPage = () => { return; } - Modal.confirm({ + modal.confirm({ title: '确认删除', content: `是否确认删除日志编号为"${operIds}"的数据项?`, onOk: async () => { @@ -160,7 +183,7 @@ const OperationLogPage = () => { }; const handleClean = async () => { - Modal.confirm({ + modal.confirm({ title: '确认清空', content: '是否确认清空所有操作日志数据项?', onOk: async () => { @@ -176,12 +199,12 @@ const OperationLogPage = () => { }); }; - const operTypeLabel = (type?: string): string => { + const operTypeLabel = (type?: string | number): string => { const dict = sysOperTypeDict.find((item) => item.value === String(type ?? '')); return dict?.label ?? String(type ?? ''); }; - const operStatusLabel = (status?: string): string => { + const operStatusLabel = (status?: string | number): string => { const dict = sysCommonStatusDict.find((item) => item.value === String(status ?? '')); return dict?.label ?? String(status ?? ''); }; @@ -189,8 +212,8 @@ const OperationLogPage = () => { const handleExport = async () => { const hide = message.loading('正在导出数据...', 0); try { - const response = await listOperlog({ - ...queryParams, + const response = await fetchOperlogList({ + ...requestParams, pageNum: undefined, pageSize: undefined, }); @@ -242,12 +265,12 @@ const OperationLogPage = () => { setDetailModalVisible(true); }; - const operTypeFormat = (type?: string) => { + const operTypeFormat = (type?: string | number) => { const dict = sysOperTypeDict.find((item) => item.value === String(type ?? '')); return dict ? {dict.label} : String(type ?? ''); }; - const operStatusFormat = (status?: string) => { + const operStatusFormat = (status?: string | number) => { const dict = sysCommonStatusDict.find((item) => item.value === String(status ?? '')); return dict ? {dict.label} : String(status ?? ''); }; @@ -277,7 +300,7 @@ const OperationLogPage = () => { title: '操作类型', dataIndex: 'businessType', align: 'center', - render: (text) => operTypeFormat(typeof text === 'string' ? text : undefined), + render: (text) => operTypeFormat(typeof text === 'string' || typeof text === 'number' ? text : undefined), }, { title: '操作人员', dataIndex: 'operName', align: 'center', ellipsis: true, sorter: true }, { title: '操作地址', dataIndex: 'operIp', align: 'center', ellipsis: true }, @@ -286,7 +309,7 @@ const OperationLogPage = () => { title: '操作状态', dataIndex: 'status', align: 'center', - render: (text) => operStatusFormat(typeof text === 'string' ? text : undefined), + render: (text) => operStatusFormat(typeof text === 'string' || typeof text === 'number' ? text : undefined), }, { title: '操作日期', @@ -431,7 +454,9 @@ const OperationLogPage = () => { {currentOperlogDetail.title} /{' '} {operTypeFormat( - typeof currentOperlogDetail.businessType === 'string' ? currentOperlogDetail.businessType : undefined, + typeof currentOperlogDetail.businessType === 'string' || typeof currentOperlogDetail.businessType === 'number' + ? currentOperlogDetail.businessType + : undefined, )} @@ -449,7 +474,11 @@ const OperationLogPage = () => { {currentOperlogDetail.jsonResult} - {operStatusFormat(typeof currentOperlogDetail.status === 'string' ? currentOperlogDetail.status : undefined)} + {operStatusFormat( + typeof currentOperlogDetail.status === 'string' || typeof currentOperlogDetail.status === 'number' + ? currentOperlogDetail.status + : undefined, + )} {typeof currentOperlogDetail.costTime === 'number' ? `${currentOperlogDetail.costTime}毫秒` : ''} diff --git a/src/pages/monitor/ServerMonitorPage.tsx b/src/pages/monitor/ServerMonitorPage.tsx index 67c966e..0416940 100644 --- a/src/pages/monitor/ServerMonitorPage.tsx +++ b/src/pages/monitor/ServerMonitorPage.tsx @@ -23,6 +23,8 @@ const defaultServerInfo: ServerInfoResponse = { sysFiles: [], }; +let inflightServerInfoRequest: Promise | null = null; + const parseTime = (time?: string | number | Date, pattern = 'YYYY-MM-DD HH:mm:ss'): string => { return time ? dayjs(time).format(pattern) : ''; }; @@ -65,7 +67,12 @@ const ServerMonitorPage = () => { setLoading(true); const hide = message.loading('正在加载服务监控数据,请稍候!', 0); try { - const response = await getServerInfo(); + if (!inflightServerInfoRequest) { + inflightServerInfoRequest = getServerInfo().finally(() => { + inflightServerInfoRequest = null; + }); + } + const response = await inflightServerInfoRequest; setServerInfo(response); } catch (error: unknown) { console.error('Failed to fetch server info:', error); @@ -77,7 +84,7 @@ const ServerMonitorPage = () => { }; void getList(); - }, []); + }, [message]); const diskColumns: TableColumnsType = [ { title: '盘符路径', dataIndex: 'dirName', align: 'center' }, diff --git a/src/pages/project/DemandManagePage.tsx b/src/pages/project/DemandManagePage.tsx index 9431086..b0312e8 100644 --- a/src/pages/project/DemandManagePage.tsx +++ b/src/pages/project/DemandManagePage.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + App, Badge, Button, Card, @@ -9,7 +10,6 @@ import { Form, Input, InputNumber, - message, Modal, Pagination, Popconfirm, @@ -265,6 +265,7 @@ const buildEndDate = (start: Dayjs, workHours: number) => { }; const DemandManagePage: React.FC = () => { + const { message, modal } = App.useApp(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [queryForm] = Form.useForm(); @@ -554,8 +555,7 @@ const DemandManagePage: React.FC = () => { }, [pageNum, pageSize, projectId, query, selectedVersion, toDemandRow]); useEffect(() => { - loadDictOptions(); - loadUserOptions(); + void Promise.all([loadDictOptions(), loadUserOptions()]); }, [loadDictOptions, loadUserOptions]); useEffect(() => { @@ -747,8 +747,7 @@ const DemandManagePage: React.FC = () => { setDemandModalOpen(false); setSelectedRowKeys([]); - fetchDemands(); - fetchVersions(); + void Promise.all([fetchDemands(), fetchVersions()]); } catch { // Validate/API error is handled by antd/request interceptor. } finally { @@ -790,7 +789,7 @@ const DemandManagePage: React.FC = () => { ); } catch { message.error('更新失败'); - fetchDemands(); + void fetchDemands(); } }; @@ -799,8 +798,7 @@ const DemandManagePage: React.FC = () => { await deleteDemand(record.id); message.success('删除成功'); setSelectedRowKeys([]); - fetchDemands(); - fetchVersions(); + void Promise.all([fetchDemands(), fetchVersions()]); } catch { message.error('删除失败'); } @@ -815,8 +813,7 @@ const DemandManagePage: React.FC = () => { await deleteDemandBatch(selectedRowKeys.join(',')); message.success('删除成功'); setSelectedRowKeys([]); - fetchDemands(); - fetchVersions(); + void Promise.all([fetchDemands(), fetchVersions()]); } catch { message.error('删除失败'); } @@ -852,8 +849,7 @@ const DemandManagePage: React.FC = () => { } setVersionModalOpen(false); - fetchVersions(); - fetchDemands(); + void Promise.all([fetchVersions(), fetchDemands()]); } catch { // Validate/API error handled by antd/request interceptor. } finally { @@ -868,8 +864,7 @@ const DemandManagePage: React.FC = () => { if (selectedVersion.nodeId === item.nodeId) { setSelectedVersion({ id: projectId, nodeId: 'all', type: 2, title: '全部版本' }); } - fetchVersions(); - fetchDemands(); + void Promise.all([fetchVersions(), fetchDemands()]); } catch { message.error('版本删除失败'); } @@ -885,7 +880,7 @@ const DemandManagePage: React.FC = () => { return; } if (action === 'delete') { - Modal.confirm({ + modal.confirm({ title: '此操作将永久删除该版本号,是否继续?', okText: '确定', cancelText: '取消', @@ -1292,7 +1287,7 @@ const DemandManagePage: React.FC = () => { title="需求列表" extra={ } > diff --git a/src/pages/project/ProjectDetailPage.tsx b/src/pages/project/ProjectDetailPage.tsx index 5926db2..550de5f 100644 --- a/src/pages/project/ProjectDetailPage.tsx +++ b/src/pages/project/ProjectDetailPage.tsx @@ -1,11 +1,12 @@ import React, { useState, useEffect } from 'react'; -import { Form, Input, Button, message, Card, Row, Col, DatePicker, InputNumber } from 'antd'; +import { Form, Input, Button, Card, Row, Col, DatePicker, InputNumber } from 'antd'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { getProjectDetail, addProject, updateProject, getProjectCode } from '../../api/project'; import dayjs from 'dayjs'; import { UserOutlined } from '@ant-design/icons'; import PageBackButton from '@/components/PageBackButton'; import { usePermission } from '@/contexts/PermissionContext'; +import { notify } from '@/utils/notify'; import './project-detail.css'; const { TextArea } = Input; @@ -37,7 +38,7 @@ const ProjectDetailPage: React.FC = () => { endDate: endValue ? dayjs(endValue) : null, }); } catch (error) { - message.error('获取项目详情失败'); + notify.error('获取项目详情失败'); } finally { setLoading(false); } @@ -47,7 +48,7 @@ const ProjectDetailPage: React.FC = () => { const response = await getProjectCode(); form.setFieldsValue({ projectCode: ((response as Record).data ?? response) as string }); } catch(error) { - message.error('获取项目编号失败'); + notify.error('获取项目编号失败'); } } }; @@ -67,14 +68,14 @@ const ProjectDetailPage: React.FC = () => { }; if (isEdit) { await updateProject({ ...payload, projectId: id }); - message.success('修改成功'); + notify.success('修改成功'); } else { await addProject(payload); - message.success('新增成功'); + notify.success('新增成功'); } navigate('/project/list'); } catch (error) { - message.error(isEdit ? '修改失败' : '新增失败'); + notify.error(isEdit ? '修改失败' : '新增失败'); } finally { setLoading(false); } diff --git a/src/pages/projectBank/ProjectUserPage.tsx b/src/pages/projectBank/ProjectUserPage.tsx index 2db9dfc..d0ddf81 100644 --- a/src/pages/projectBank/ProjectUserPage.tsx +++ b/src/pages/projectBank/ProjectUserPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Button, DatePicker, Empty, Select, Spin, Table, message } from 'antd'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button, DatePicker, Empty, Select, Spin, Table } from 'antd'; import type { TableColumnsType } from 'antd'; import { CalendarOutlined } from '@ant-design/icons'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -8,10 +8,11 @@ import type { Dayjs } from 'dayjs'; import zhCN from 'antd/es/date-picker/locale/zh_CN'; import PageBackButton from '@/components/PageBackButton'; import { getProjectDetail, listProject } from '@/api/project'; -import { getProjectExecutionInfo, getProjectWorkInfo } from '@/api/projectExecution'; +import { getProjectWorkInfo } from '@/api/projectExecution'; import { usePermission } from '@/contexts/PermissionContext'; import '@/styles/permission-link.css'; import { parseTime } from '@/utils/ruoyi'; +import { notify } from '@/utils/notify'; import './project-user.css'; const { RangePicker } = DatePicker; @@ -43,6 +44,12 @@ interface ProjectOption { projectName: string; } +type ProjectWorkInfoRequestParams = { + startDate: string; + endDate: string; + projectId: string | number; +}; + const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; @@ -54,22 +61,6 @@ const toNumber = (value: unknown, fallback = 0) => { return Number.isFinite(num) ? num : fallback; }; -const extractProjectList = (payload: unknown): ProjectOption[] => { - const data = normalizeResponseData(payload); - const list = Array.isArray(data) - ? data - : isObject(data) && Array.isArray(data.rows) - ? data.rows - : []; - return list - .filter(isObject) - .map((item) => ({ - projectId: item.projectId as string | number, - projectName: String(item.projectName ?? ''), - })) - .filter((item) => item.projectId !== undefined && item.projectId !== null && item.projectName); -}; - const extractProjectRows = (payload: unknown): Record[] => { const data = normalizeResponseData(payload); if (Array.isArray(data)) { @@ -115,11 +106,58 @@ const normalizeDateParam = (value: string | null) => { return parsed.isValid() ? parsed.format('YYYY-MM-DD') : null; }; +let inflightProjectListRequest: Promise | null = null; +const inflightProjectDetailRequests = new Map>(); +const inflightProjectWorkInfoRequests = new Map>(); + +const fetchProjectListRequest = async () => { + if (inflightProjectListRequest) { + return inflightProjectListRequest; + } + + const requestPromise = listProject({ pageNum: 1, pageSize: 1000 }).finally(() => { + inflightProjectListRequest = null; + }); + inflightProjectListRequest = requestPromise; + return requestPromise; +}; + +const fetchProjectDetailRequest = async (projectId: string | number) => { + const requestKey = String(projectId); + const existingRequest = inflightProjectDetailRequests.get(requestKey); + if (existingRequest) { + return existingRequest; + } + + const requestPromise = getProjectDetail(projectId).finally(() => { + inflightProjectDetailRequests.delete(requestKey); + }); + inflightProjectDetailRequests.set(requestKey, requestPromise); + return requestPromise; +}; + +const serializeProjectWorkInfoRequest = (params: ProjectWorkInfoRequestParams) => JSON.stringify(params); + +const fetchProjectWorkInfoRequest = async (params: ProjectWorkInfoRequestParams) => { + const requestKey = serializeProjectWorkInfoRequest(params); + const existingRequest = inflightProjectWorkInfoRequests.get(requestKey); + if (existingRequest) { + return existingRequest; + } + + const requestPromise = getProjectWorkInfo(params).finally(() => { + inflightProjectWorkInfoRequests.delete(requestKey); + }); + inflightProjectWorkInfoRequests.set(requestKey, requestPromise); + return requestPromise; +}; + const ProjectUserPage = () => { const navigate = useNavigate(); const { canAccessPath } = usePermission(); const [searchParams] = useSearchParams(); const projectId = searchParams.get('projectId') ?? searchParams.get('id') ?? ''; + const queryProjectName = searchParams.get('projectName') ?? ''; const queryStartDate = normalizeDateParam(searchParams.get('startDate')); const queryEndDate = normalizeDateParam(searchParams.get('endDate')); const defaultStartDate = queryStartDate ?? dayjs().startOf('month').format('YYYY-MM-DD'); @@ -135,6 +173,10 @@ const ProjectUserPage = () => { dayjs(defaultEndDate, 'YYYY-MM-DD'), ]); const [detailList, setDetailList] = useState([]); + const [projectMetaReadyKey, setProjectMetaReadyKey] = useState(''); + const projectDetailPendingRef = useRef(''); + const projectListRequestIdRef = useRef(0); + const projectWorkInfoRequestIdRef = useRef(0); const canViewWorkLog = canAccessPath('/index'); const openUserLog = (row: ProjectUserItem, queryDate?: string) => { @@ -164,30 +206,26 @@ const ProjectUserPage = () => { useEffect(() => { const fetchProjectList = async () => { + const requestId = ++projectListRequestIdRef.current; setLoading(true); try { - let nextProjectList: ProjectOption[] = []; - - try { - const response = await getProjectExecutionInfo({ - startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`, - endDate: `${dateRange[1].format('YYYY-MM-DD')} 23:59:59`, - }); - nextProjectList = extractProjectList(response); - } catch (error) { - console.warn('Project execution info timeout, fallback to project list:', error); - const response = await listProject({ pageNum: 1, pageSize: 1000 }); - nextProjectList = extractProjectRows(response).map((item) => ({ + const response = await fetchProjectListRequest(); + if (requestId !== projectListRequestIdRef.current) { + return; + } + const nextProjectList = extractProjectRows(response) + .map((item) => ({ projectId: item.projectId as string | number, projectName: String(item.projectName ?? ''), - })).filter((item) => item.projectId !== undefined && item.projectId !== null && item.projectName); - } + })) + .filter((item) => item.projectId !== undefined && item.projectId !== null && item.projectName); setProjectList(nextProjectList); if (nextProjectList.length === 0) { - setSelectedProjectId(undefined); + setSelectedProjectId(projectId || undefined); setProjectDetail({}); setDetailList([]); + setProjectMetaReadyKey(''); return; } setSelectedProjectId((prev) => { @@ -203,10 +241,16 @@ const ProjectUserPage = () => { return nextProjectList[0].projectId; }); } catch (error) { + if (requestId !== projectListRequestIdRef.current) { + return; + } console.error('Failed to fetch project list:', error); - message.error('获取项目列表失败'); + setProjectList([]); + notify.error('获取项目列表失败'); } finally { - setLoading(false); + if (requestId === projectListRequestIdRef.current) { + setLoading(false); + } } }; void fetchProjectList(); @@ -215,11 +259,18 @@ const ProjectUserPage = () => { useEffect(() => { const fetchProjectData = async () => { if (!selectedProjectId) { + setProjectMetaReadyKey(''); return; } + const activeProjectId = String(selectedProjectId); + projectDetailPendingRef.current = activeProjectId; + setProjectMetaReadyKey(''); setLoading(true); try { - const detailResponse = await getProjectDetail(selectedProjectId); + const detailResponse = await fetchProjectDetailRequest(selectedProjectId); + if (projectDetailPendingRef.current !== activeProjectId) { + return; + } const detail = normalizeProjectDetail(detailResponse); setProjectDetail(detail); @@ -246,9 +297,14 @@ const ProjectUserPage = () => { if (useRoutePreset) { setRoutePresetApplied(true); } + projectDetailPendingRef.current = ''; + setProjectMetaReadyKey(`${activeProjectId}:${nextStart.valueOf()}:${nextEnd.valueOf()}`); } catch (error) { + if (projectDetailPendingRef.current === activeProjectId) { + projectDetailPendingRef.current = ''; + } console.error('Failed to fetch project detail:', error); - message.error('获取项目详情失败'); + notify.error('获取项目详情失败'); } finally { setLoading(false); } @@ -258,27 +314,40 @@ const ProjectUserPage = () => { useEffect(() => { const fetchWorkInfo = async () => { - if (!selectedProjectId) { + if (!selectedProjectId || !projectMetaReadyKey) { return; } + if (projectDetailPendingRef.current === String(selectedProjectId)) { + return; + } + const requestId = ++projectWorkInfoRequestIdRef.current; + const requestParams = { + startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`, + endDate: `${dateRange[1].format('YYYY-MM-DD')} 23:59:59`, + projectId: selectedProjectId, + }; setLoading(true); try { - const response = await getProjectWorkInfo({ - startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`, - endDate: `${dateRange[1].format('YYYY-MM-DD')} 23:59:59`, - projectId: selectedProjectId, - }); + const response = await fetchProjectWorkInfoRequest(requestParams); + if (requestId !== projectWorkInfoRequestIdRef.current) { + return; + } setDetailList(extractDetailList(response)); } catch (error) { + if (requestId !== projectWorkInfoRequestIdRef.current) { + return; + } console.error('Failed to fetch project work info:', error); - message.error('获取项目人员表失败'); + notify.error('获取项目人员表失败'); setDetailList([]); } finally { - setLoading(false); + if (requestId === projectWorkInfoRequestIdRef.current) { + setLoading(false); + } } }; void fetchWorkInfo(); - }, [dateRange, selectedProjectId]); + }, [dateRange, projectMetaReadyKey, selectedProjectId]); const columns = useMemo>>(() => { return dateRange[0] @@ -340,6 +409,34 @@ const ProjectUserPage = () => { return hit?.projectName ?? ''; }, [projectList, selectedProjectId]); + const selectedProjectLabel = useMemo(() => { + return projectDetail.projectName || selectedProjectName || queryProjectName || String(selectedProjectId ?? ''); + }, [projectDetail.projectName, queryProjectName, selectedProjectId, selectedProjectName]); + + const projectSelectOptions = useMemo(() => { + const baseOptions = projectList.map((item) => ({ + label: item.projectName, + value: item.projectId, + })); + + if (selectedProjectId === undefined || selectedProjectId === null || selectedProjectId === '') { + return baseOptions; + } + + const exists = baseOptions.some((item) => String(item.value) === String(selectedProjectId)); + if (exists) { + return baseOptions; + } + + return [ + { + label: selectedProjectLabel, + value: selectedProjectId, + }, + ...baseOptions, + ]; + }, [projectList, selectedProjectId, selectedProjectLabel]); + return (
@@ -352,12 +449,9 @@ const ProjectUserPage = () => {
选择项目