性能优化

main
kangwenjing 2026-03-17 17:47:31 +08:00
parent 9a6d9dca8a
commit 19fef2ce3a
38 changed files with 1414 additions and 377 deletions

View File

@ -108,10 +108,8 @@ function App() {
<Route path="/monitor/cache" element={<CacheMonitorPage />} />
<Route path="/monitor/job" element={<JobMonitorPage />} />
<Route path="/monitor/logininfor" element={<LoginLogPage />} />
<Route path="/system/logininfor" element={<LoginLogPage />} />
<Route path="/monitor/online" element={<OnlineUserPage />} />
<Route path="/monitor/operlog" element={<OperationLogPage />} />
<Route path="/system/operlog" element={<OperationLogPage />} />
<Route path="/monitor/server" element={<ServerMonitorPage />} />
<Route path="/monitor/cacheList" element={<CacheListPage />} />
<Route path="/system/user" element={<UserPage />} />

View File

@ -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<string, string> = {
'/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<string, string> = {
'/': '/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<string>();
return parsed.reduce<OpenPageTab[]>((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<AppNavbarProps> = ({ 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<OpenPageTab[]>(() => getStoredOpenPageTabs(canAccessPath));
const handleLogout = () => {
removeToken();
@ -46,6 +226,78 @@ const AppNavbar: React.FC<AppNavbarProps> = ({ 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: (
<span className="app-navbar-tab-label" title={item.title}>
{item.title}
</span>
),
})),
[openTabs],
);
return (
<div className="app-navbar">
<Button
@ -54,6 +306,21 @@ const AppNavbar: React.FC<AppNavbarProps> = ({ collapsed, onToggle }) => {
onClick={onToggle}
className="app-navbar-toggle"
/>
<div className="app-navbar-tabs">
<Tabs
hideAdd
size="small"
type="editable-card"
activeKey={activeTab.key}
items={tabItems}
onChange={handleTabChange}
onEdit={(targetKey, action) => {
if (action === 'remove' && typeof targetKey === 'string') {
handleTabRemove(targetKey);
}
}}
/>
</div>
<div className="app-navbar-meta">
<Dropdown menu={{ items: menuItems, onClick: handleMenuClick }} trigger={['click']}>
<Button type="text" className="app-navbar-user">

View File

@ -1,7 +1,6 @@
.app-navbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 74px;
padding: 10px 16px;
@ -24,6 +23,67 @@
border: 1px solid rgba(219, 228, 243, 0.9);
}
.app-navbar-tabs {
flex: 1;
min-width: 0;
}
.app-navbar-tabs .ant-tabs {
min-width: 0;
}
.app-navbar-tabs .ant-tabs-nav {
margin: 0;
}
.app-navbar-tabs .ant-tabs-nav::before {
display: none;
}
.app-navbar-tabs .ant-tabs-nav-wrap {
min-width: 0;
}
.app-navbar-tabs .ant-tabs-tab {
max-width: 260px;
padding: 8px 12px !important;
border: 1px solid rgba(219, 228, 243, 0.9) !important;
border-radius: 14px !important;
background: linear-gradient(180deg, rgba(247, 249, 255, 0.96), rgba(255, 255, 255, 0.96)) !important;
transition: all 0.2s ease;
}
.app-navbar-tabs .ant-tabs-tab-active {
background: linear-gradient(135deg, rgba(99, 91, 255, 0.12), rgba(79, 70, 229, 0.08)) !important;
box-shadow: 0 8px 18px rgba(79, 70, 229, 0.12);
}
.app-navbar-tabs .ant-tabs-tab-btn {
color: #30415f;
font-weight: 600;
}
.app-navbar-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #4338ca;
}
.app-navbar-tabs .ant-tabs-tab-remove {
color: #7d8cab;
}
.app-navbar-tabs .ant-tabs-ink-bar,
.app-navbar-tabs .ant-tabs-content-holder {
display: none;
}
.app-navbar-tab-label {
display: inline-block;
max-width: 190px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-navbar-meta {
display: flex;
align-items: center;
@ -81,6 +141,10 @@
padding: 8px 10px;
}
.app-navbar-tabs {
display: none;
}
.app-navbar-copy {
display: none;
}

View File

@ -8,7 +8,7 @@ interface PageBackButtonProps {
className?: string;
}
const PageBackButton = ({ text = '返回', fallbackPath = '/', className }: PageBackButtonProps) => {
const PageBackButton = ({ text: _text = '返回', fallbackPath = '/', className }: PageBackButtonProps) => {
const navigate = useNavigate();
const handleBack = () => {
@ -21,7 +21,7 @@ const PageBackButton = ({ text = '返回', fallbackPath = '/', className }: Page
return (
<Button icon={<ArrowLeftOutlined />} onClick={handleBack} className={className}>
{text}
</Button>
);
};

View File

@ -38,15 +38,21 @@ type MenuItemWithChildren = MenuItem & { children?: MenuItem[] };
const BACKEND_PATH_TO_APP_PATH: Record<string, string> = {
'/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<string, string[]> = {
'/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<string, React.ReactNode> = {

View File

@ -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<string, string> = {
'/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<string, string[]> = {
'/profile': ['/user/profile'],
@ -39,8 +50,8 @@ const ROUTE_ALIASES: Record<string, string[]> = {
'/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<PermissionContextValue['currentUser']>({});
const [roles, setRoles] = useState<string[]>([]);
const [permissions, setPermissions] = useState<string[]>([]);
const [routers, setRouters] = useState<RouterNode[]>([]);
@ -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<string, unknown> | undefined;
nextRoles = parseStringList(info.roles);
nextPermissions = parseStringList(info.permissions);
nextUserName = String((info.user as Record<string, unknown> | 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<string, unknown>).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 <PermissionContext.Provider value={value}>{children}</PermissionContext.Provider>;

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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) {

View File

@ -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 {

View File

@ -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('清理全部缓存失败');
}
};

View File

@ -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<CacheMonitorResponse>(defaultCacheData);
const [error, setError] = useState<string | null>(null);
@ -52,7 +54,9 @@ const CacheMonitorPage = () => {
return <div style={{ textAlign: 'center', marginTop: '50px' }}><Text type="danger">{error}</Text></div>;
}
const commandStatsOptions = {
const chartFallback = <Spin spinning={true} style={{ display: 'block', marginTop: '120px' }} />;
const commandStatsOptions = useMemo(() => ({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
@ -69,9 +73,9 @@ const CacheMonitorPage = () => {
animationDuration: 1000,
},
],
};
}), [cacheData.commandStats]);
const usedMemoryOptions = {
const usedMemoryOptions = useMemo(() => ({
tooltip: {
formatter: `{b} <br/>{a} : ${toDisplayText(cacheData.info?.used_memory_human)}`,
},
@ -92,7 +96,7 @@ const CacheMonitorPage = () => {
],
},
],
};
}), [cacheData.info]);
return (
<div className="app-container cache-monitor-container">
@ -129,12 +133,26 @@ const CacheMonitorPage = () => {
</Col>
<Col span={12} className="card-box">
<Card title="命令统计">
<ReactEChartsCore echarts={echarts} theme="macarons" option={commandStatsOptions} style={{ height: '420px' }} />
<Suspense fallback={chartFallback}>
<ReactEChartsCore
echarts={echarts}
theme="macarons"
option={commandStatsOptions}
style={{ height: '420px' }}
/>
</Suspense>
</Card>
</Col>
<Col span={12} className="card-box">
<Card title="内存信息">
<ReactEChartsCore echarts={echarts} theme="macarons" option={usedMemoryOptions} style={{ height: '420px' }} />
<Suspense fallback={chartFallback}>
<ReactEChartsCore
echarts={echarts}
theme="macarons"
option={usedMemoryOptions}
style={{ height: '420px' }}
/>
</Suspense>
</Card>
</Col>
</Row>

View File

@ -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<string, Promise<Awaited<ReturnType<typeof listJob>>>>();
const inflightJobDetailRequests = new Map<string, Promise<Awaited<ReturnType<typeof getJob>>>>();
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<JobRecord>();
const [queryForm] = Form.useForm<JobQueryParams>();
const [jobList, setJobList] = useState<JobRecord[]>([]);
@ -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 {

View File

@ -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<string, Promise<Awaited<ReturnType<typeof listLogininfor>>>>();
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<LogininforQueryParams>();
@ -72,19 +91,22 @@ const LoginLogPage = () => {
const [dateRange, setDateRange] = useState<DateRange>(null);
const [queryParams, setQueryParams] = useState<LogininforQueryParams>(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,
});

View File

@ -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<string, Promise<Awaited<ReturnType<typeof listOnline>>>>();
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<OnlineQueryParams>();
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 () => {

View File

@ -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<string, Promise<Awaited<ReturnType<typeof listOperlog>>>>();
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<OperlogQueryParams>();
const [loading, setLoading] = useState(false);
const [list, setList] = useState<OperlogRecord[]>([]);
@ -94,19 +114,22 @@ const OperationLogPage = () => {
const [currentOperlogDetail, setCurrentOperlogDetail] = useState<OperlogRecord>({});
const [queryParams, setQueryParams] = useState<OperlogQueryParams>(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 ? <Tag color={dict.color}>{dict.label}</Tag> : String(type ?? '');
};
const operStatusFormat = (status?: string) => {
const operStatusFormat = (status?: string | number) => {
const dict = sysCommonStatusDict.find((item) => item.value === String(status ?? ''));
return dict ? <Tag color={dict.color}>{dict.label}</Tag> : 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 = () => {
<Descriptions.Item label="操作模块">
{currentOperlogDetail.title} /{' '}
{operTypeFormat(
typeof currentOperlogDetail.businessType === 'string' ? currentOperlogDetail.businessType : undefined,
typeof currentOperlogDetail.businessType === 'string' || typeof currentOperlogDetail.businessType === 'number'
? currentOperlogDetail.businessType
: undefined,
)}
</Descriptions.Item>
<Descriptions.Item label="登录信息">
@ -449,7 +474,11 @@ const OperationLogPage = () => {
{currentOperlogDetail.jsonResult}
</Descriptions.Item>
<Descriptions.Item label="操作状态">
{operStatusFormat(typeof currentOperlogDetail.status === 'string' ? currentOperlogDetail.status : undefined)}
{operStatusFormat(
typeof currentOperlogDetail.status === 'string' || typeof currentOperlogDetail.status === 'number'
? currentOperlogDetail.status
: undefined,
)}
</Descriptions.Item>
<Descriptions.Item label="消耗时间">
{typeof currentOperlogDetail.costTime === 'number' ? `${currentOperlogDetail.costTime}毫秒` : ''}

View File

@ -23,6 +23,8 @@ const defaultServerInfo: ServerInfoResponse = {
sysFiles: [],
};
let inflightServerInfoRequest: Promise<ServerInfoResponse> | 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<ServerDiskInfo> = [
{ title: '盘符路径', dataIndex: 'dirName', align: 'center' },

View File

@ -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={
<Button icon={<LeftOutlined />} onClick={() => navigate('/project/list')}>
</Button>
}
>

View File

@ -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<string, unknown>).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);
}

View File

@ -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<string, unknown> =>
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<string, unknown>[] => {
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<unknown> | null = null;
const inflightProjectDetailRequests = new Map<string, Promise<unknown>>();
const inflightProjectWorkInfoRequests = new Map<string, Promise<unknown>>();
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<ProjectUserItem[][]>([]);
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<TableColumnsType<Record<string, unknown>>>(() => {
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 (
<div className="project-user-page">
<div className="project-user-back-row">
@ -352,12 +449,9 @@ const ProjectUserPage = () => {
<div className="project-user-info-item">
<span className="project-user-info-label"></span>
<Select
value={selectedProjectId}
value={selectedProjectId !== undefined && selectedProjectId !== null && selectedProjectId !== '' ? selectedProjectId : undefined}
placeholder="请选择项目"
options={projectList.map((item) => ({
label: item.projectName,
value: item.projectId,
}))}
options={projectSelectOptions}
onChange={(value) => setSelectedProjectId(value)}
showSearch
optionFilterProp="label"
@ -401,7 +495,7 @@ const ProjectUserPage = () => {
let nextEnd = values[1];
if (values[1].endOf('day').diff(values[0].startOf('day'), 'day') > MAX_RANGE_DAYS) {
nextEnd = values[0].add(MAX_RANGE_DAYS, 'day');
message.warning('统计时间最多选择 3 个月,已自动调整结束日期');
notify.warning('统计时间最多选择 3 个月,已自动调整结束日期');
}
setDateRange([values[0], nextEnd]);
}}

View File

@ -1,5 +1,5 @@
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
import { Button, DatePicker, Empty, Input, Modal, Spin, Table, Tree, message } from 'antd';
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { App, Button, DatePicker, Empty, Input, Modal, Spin, Table, Tree } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { UserOutlined } from '@ant-design/icons';
import zhCN from 'antd/es/date-picker/locale/zh_CN';
@ -59,6 +59,23 @@ const toNumber = (value: unknown, fallback = 0) => {
const getDefaultRange = (): [Dayjs, Dayjs] => [dayjs().startOf('month'), dayjs().endOf('month')];
type UserListRequestParams = {
pageNum: number;
pageSize: number;
deptId?: string;
};
type UserProjectRequestParams = {
startDate: string;
endDate: string;
userId: string | number;
};
const inflightUserListRequests = new Map<string, Promise<unknown>>();
const inflightUserProjectRequests = new Map<string, Promise<unknown>>();
let inflightCurrentUserRequest: Promise<unknown> | null = null;
let inflightDeptTreeRequest: Promise<unknown> | null = null;
const normalizeExecutionRows = (payload: unknown): ProjectExecutionRow[] => {
const data = normalizeResponseData(payload);
const rows = Array.isArray(data)
@ -129,7 +146,64 @@ const matchesUserKeyword = (user: UserRow, keyword: string) => {
);
};
const serializeUserListRequest = (params: UserListRequestParams) => JSON.stringify(params);
const serializeUserProjectRequest = (params: UserProjectRequestParams) => JSON.stringify(params);
const fetchUserListRequest = async (params: UserListRequestParams) => {
const requestKey = serializeUserListRequest(params);
const existingRequest = inflightUserListRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = listUser(params).finally(() => {
inflightUserListRequests.delete(requestKey);
});
inflightUserListRequests.set(requestKey, requestPromise);
return requestPromise;
};
const fetchCurrentUserRequest = async () => {
if (inflightCurrentUserRequest) {
return inflightCurrentUserRequest;
}
const requestPromise = getUserProfile().finally(() => {
inflightCurrentUserRequest = null;
});
inflightCurrentUserRequest = requestPromise;
return requestPromise;
};
const fetchUserProjectRequest = async (params: UserProjectRequestParams) => {
const requestKey = serializeUserProjectRequest(params);
const existingRequest = inflightUserProjectRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = getProjectExecutionInfo(params).finally(() => {
inflightUserProjectRequests.delete(requestKey);
});
inflightUserProjectRequests.set(requestKey, requestPromise);
return requestPromise;
};
const fetchDeptTreeRequest = async () => {
if (inflightDeptTreeRequest) {
return inflightDeptTreeRequest;
}
const requestPromise = deptTreeSelect().finally(() => {
inflightDeptTreeRequest = null;
});
inflightDeptTreeRequest = requestPromise;
return requestPromise;
};
const UserProjectPage = () => {
const { message } = App.useApp();
const navigate = useNavigate();
const { canAccessPath } = usePermission();
const [loading, setLoading] = useState(false);
@ -151,79 +225,122 @@ const UserProjectPage = () => {
const [userPageSize, setUserPageSize] = useState(10);
const [userTotal, setUserTotal] = useState(0);
const deferredUserKeyword = useDeferredValue(userKeyword);
const deptTreeLoadedRef = useRef(false);
const skipNextProjectFetchRef = useRef(false);
const userProjectRequestIdRef = useRef(0);
const canViewProjectDetail = canAccessPath('/project/detail');
useEffect(() => {
const fetchCurrentUser = async () => {
try {
const response = await getUserProfile();
const payload = normalizeResponseData(response);
const user = isObject(payload) && isObject(payload.user) ? payload.user : payload;
if (!isObject(user)) {
return;
}
const nextUser: UserRow = {
userId: user.userId as string | number | undefined,
nickName: user.nickName as string | undefined,
userName: user.userName as string | undefined,
};
setSelectedUser(nextUser);
setSelectedUserId(nextUser.userId ?? '');
setPendingUserId(nextUser.userId ?? '');
setSelectedUserName(getUserDisplayName(nextUser));
} catch (error) {
console.error('Failed to fetch current user profile for user project page:', error);
message.error('获取当前用户失败');
const fetchCurrentUser = async () => {
try {
const response = await fetchCurrentUserRequest();
const payload = normalizeResponseData(response);
const user = isObject(payload) && isObject(payload.user) ? payload.user : payload;
if (!isObject(user)) {
return null;
}
const nextUser: UserRow = {
userId: user.userId as string | number | undefined,
nickName: user.nickName as string | undefined,
userName: user.userName as string | undefined,
};
setSelectedUser(nextUser);
setSelectedUserId(nextUser.userId ?? '');
setPendingUserId(nextUser.userId ?? '');
setSelectedUserName(getUserDisplayName(nextUser));
return nextUser;
} catch (error) {
console.error('Failed to fetch current user profile for user project page:', error);
message.error('获取当前用户失败');
return null;
}
};
const fetchUserProject = async (targetUserId?: string | number) => {
const effectiveUserId = targetUserId ?? selectedUserId;
if (!effectiveUserId || !dateRange[0] || !dateRange[1]) {
return;
}
const requestId = ++userProjectRequestIdRef.current;
const requestParams = {
startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`,
endDate: `${dateRange[1].format('YYYY-MM-DD')} 00:00:00`,
userId: effectiveUserId,
};
void fetchCurrentUser();
setLoading(true);
try {
const response = await fetchUserProjectRequest(requestParams);
if (requestId !== userProjectRequestIdRef.current) {
return;
}
setExecutionData(normalizeExecutionRows(response));
} catch (error) {
if (requestId !== userProjectRequestIdRef.current) {
return;
}
console.error('Failed to fetch user project table:', error);
message.error('获取人员项目表失败');
setExecutionData([]);
} finally {
if (requestId === userProjectRequestIdRef.current) {
setLoading(false);
}
}
};
const fetchDeptTree = async () => {
if (deptTreeLoadedRef.current) {
return;
}
setDeptLoading(true);
try {
const response = await fetchDeptTreeRequest();
const treeNodes = normalizeDeptTreeNodes(response);
setDeptTree(treeNodes);
setExpandedDeptKeys(collectDeptKeys(treeNodes));
deptTreeLoadedRef.current = true;
} catch (error) {
console.error('Failed to fetch department tree for user project page:', error);
setDeptTree([]);
setExpandedDeptKeys([]);
} finally {
setDeptLoading(false);
}
};
useEffect(() => {
let cancelled = false;
const bootstrap = async () => {
const user = await fetchCurrentUser();
if (cancelled || !user?.userId) {
return;
}
skipNextProjectFetchRef.current = true;
await fetchUserProject(user.userId);
};
void bootstrap();
return () => {
cancelled = true;
};
// dateRange intentionally excluded: initial bootstrap only.
// subsequent range/user changes are handled by the dedicated effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const fetchUserProject = async () => {
if (!selectedUserId || !dateRange[0] || !dateRange[1]) {
return;
}
setLoading(true);
try {
const response = await getProjectExecutionInfo({
startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`,
endDate: `${dateRange[1].format('YYYY-MM-DD')} 00:00:00`,
userId: selectedUserId,
});
setExecutionData(normalizeExecutionRows(response));
} catch (error) {
console.error('Failed to fetch user project table:', error);
message.error('获取人员项目表失败');
setExecutionData([]);
} finally {
setLoading(false);
}
};
void fetchUserProject();
}, [dateRange, selectedUserId]);
useEffect(() => {
if (!userModalOpen) {
if (!selectedUserId || !dateRange[0] || !dateRange[1]) {
return;
}
const fetchDeptTree = async () => {
setDeptLoading(true);
try {
const response = await deptTreeSelect();
const treeNodes = normalizeDeptTreeNodes(response);
setDeptTree(treeNodes);
setExpandedDeptKeys(collectDeptKeys(treeNodes));
} catch (error) {
console.error('Failed to fetch department tree for user project page:', error);
setDeptTree([]);
setExpandedDeptKeys([]);
} finally {
setDeptLoading(false);
}
};
void fetchDeptTree();
}, [userModalOpen]);
if (skipNextProjectFetchRef.current) {
skipNextProjectFetchRef.current = false;
return;
}
void fetchUserProject();
// fetchUserProject is intentionally excluded to avoid recreating effect on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateRange, selectedUserId]);
useEffect(() => {
if (!userModalOpen) {
@ -233,7 +350,7 @@ const UserProjectPage = () => {
setUserLoading(true);
try {
const normalizedKeyword = deferredUserKeyword.trim();
const response = await listUser({
const response = await fetchUserListRequest({
pageNum: userPageNum,
pageSize: normalizedKeyword ? 1000 : userPageSize,
deptId: selectedDeptId || undefined,
@ -251,7 +368,13 @@ const UserProjectPage = () => {
setUserLoading(false);
}
};
void fetchUserList();
if (deptTreeLoadedRef.current) {
void fetchUserList();
return;
}
void Promise.all([fetchDeptTree(), fetchUserList()]);
}, [deferredUserKeyword, selectedDeptId, userModalOpen, userPageNum, userPageSize]);
const pendingUser = useMemo(
@ -268,11 +391,19 @@ const UserProjectPage = () => {
};
const applySelectedUser = (user?: UserRow | null) => {
skipNextProjectFetchRef.current = true;
setSelectedUser(user ?? null);
setSelectedUserId(user?.userId ?? '');
setSelectedUserName(getUserDisplayName(user));
setPendingUserId(user?.userId ?? '');
setUserModalOpen(false);
if (user?.userId !== undefined && user?.userId !== null && user.userId !== '') {
void fetchUserProject(user.userId);
return;
}
setExecutionData([]);
};
const dynamicColumns = useMemo<ColumnsType<ProjectExecutionRow>>(() => {
@ -283,19 +414,29 @@ const UserProjectPage = () => {
key: 'projectName',
fixed: 'left',
width: 180,
render: (value: unknown, row) => (
canViewProjectDetail ? (
ellipsis: { showTitle: false },
render: (value: unknown, row) => {
const projectName = String(value ?? '-');
const projectLabel = (
<span className="user-project-link-text" title={projectName}>
{projectName}
</span>
);
return canViewProjectDetail ? (
<Button
type="link"
className="user-project-link"
onClick={() => navigate(`/project/detail?id=${String(row.projectId ?? '')}`)}
>
{String(value ?? '-')}
{projectLabel}
</Button>
) : (
<span className="user-project-link is-disabled permission-link-disabled">{String(value ?? '-')}</span>
)
),
<span className="user-project-link is-disabled permission-link-disabled" title={projectName}>
{projectLabel}
</span>
);
},
},
{
title: '统计工时\n',

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, Empty, Spin, Table, Tabs, Tag, Typography, message } from 'antd';
import { Alert, Empty, Spin, Table, Tabs, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import PageBackButton from '@/components/PageBackButton';
import { getTaskScoreDetail } from '@/api/appraisal';
import { notify } from '@/utils/notify';
import '@/pages/workAppraisal/appraisal-detail.css';
import './user-score.css';
@ -190,7 +191,7 @@ const UserScoreDetailPage = () => {
}
} catch (error) {
console.error('Failed to fetch user score detail:', error);
message.error('获取绩效详情失败');
notify.error('获取绩效详情失败');
setSelfDataset({ groups: [], examineTask: {}, examineUser: {} });
setManageDataset({ groups: [], examineTask: {}, examineUser: {} });
} finally {

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Empty, Select, Spin, Table, Tag, Tree, message } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import { App, Button, Empty, Select, Spin, Table, Tag, Tree } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageBackButton from '@/components/PageBackButton';
@ -53,6 +53,20 @@ interface UserRow {
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
type TaskListRequestParams = { pageNum: number; pageSize: number };
type TaskUserListRequestParams = {
isAsc: string;
sortFiled: string;
taskId: string;
pageNum: number;
pageSize: number;
};
type DeptUserRequestParams = { pageNum: number; pageSize: number; deptId: string };
const inflightTaskListRequests = new Map<string, Promise<unknown>>();
const inflightTaskUserListRequests = new Map<string, Promise<unknown>>();
const inflightDeptTreeRequests = new Map<string, Promise<unknown>>();
const inflightDeptUserRequests = new Map<string, Promise<unknown>>();
const normalizeResponseData = (response: unknown) =>
isObject(response) && response.data !== undefined ? response.data : response;
@ -127,6 +141,64 @@ const normalizeUserRows = (payload: unknown): UserRow[] => {
return rows.filter(isObject) as UserRow[];
};
const serializeRequestParams = (params: Record<string, unknown>) => JSON.stringify(params);
const fetchTaskListRequest = async (params: TaskListRequestParams) => {
const requestKey = serializeRequestParams(params);
const existingRequest = inflightTaskListRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = getTaskList(params).finally(() => {
inflightTaskListRequests.delete(requestKey);
});
inflightTaskListRequests.set(requestKey, requestPromise);
return requestPromise;
};
const fetchTaskUserListRequest = async (params: TaskUserListRequestParams) => {
const requestKey = serializeRequestParams(params);
const existingRequest = inflightTaskUserListRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = getTaskUserList(params).finally(() => {
inflightTaskUserListRequests.delete(requestKey);
});
inflightTaskUserListRequests.set(requestKey, requestPromise);
return requestPromise;
};
const fetchDeptTreeRequest = async () => {
const requestKey = 'deptTree';
const existingRequest = inflightDeptTreeRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = deptTreeSelect().finally(() => {
inflightDeptTreeRequests.delete(requestKey);
});
inflightDeptTreeRequests.set(requestKey, requestPromise);
return requestPromise;
};
const fetchDeptUserRequest = async (params: DeptUserRequestParams) => {
const requestKey = serializeRequestParams(params);
const existingRequest = inflightDeptUserRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = listUser(params).finally(() => {
inflightDeptUserRequests.delete(requestKey);
});
inflightDeptUserRequests.set(requestKey, requestPromise);
return requestPromise;
};
const getStatusMeta = (row: UserScoreRow) => {
const selfStatus = String(row.examineStatusSelf ?? '').trim();
const manageStatus = String(row.examineStatus ?? row.status ?? '').trim();
@ -143,6 +215,7 @@ const getStatusMeta = (row: UserScoreRow) => {
const getScoreValue = (row: UserScoreRow) => row.score ?? row.manageScore ?? row.selfScore ?? '-';
const UserScorePage = () => {
const { message } = App.useApp();
const navigate = useNavigate();
const { canAccessPath } = usePermission();
const [searchParams] = useSearchParams();
@ -163,29 +236,41 @@ const UserScorePage = () => {
const [deptUserIds, setDeptUserIds] = useState<Set<string>>(new Set());
const [pageNum, setPageNum] = useState(routePageNum);
const [pageSize, setPageSize] = useState(routePageSize);
const deptUserCacheRef = useRef<Map<string, Set<string>>>(new Map());
const canViewScoreDetail = canAccessPath('/projectBank/userScoreDetail');
useEffect(() => {
const loadBaseData = async () => {
setDeptLoading(true);
try {
const [taskResponse, deptResponse] = await Promise.all([
getTaskList({ pageNum: 1, pageSize: 100000 }),
deptTreeSelect(),
const [taskResult, deptResult] = await Promise.allSettled([
fetchTaskListRequest({ pageNum: 1, pageSize: 100000 }),
fetchDeptTreeRequest(),
]);
const tasks = normalizeTaskRows(taskResponse).sort((left, right) =>
String(right.endTime ?? right.createTime ?? '').localeCompare(String(left.endTime ?? left.createTime ?? '')),
);
const treeNodes = normalizeDeptTreeNodes(deptResponse);
setTaskList(tasks);
setDeptTree(treeNodes);
setExpandedDeptKeys(collectDeptKeys(treeNodes));
if (!routeTaskId && tasks[0]?.id !== undefined) {
setSelectedTaskId(String(tasks[0].id));
if (taskResult.status === 'fulfilled') {
const tasks = normalizeTaskRows(taskResult.value).sort((left, right) =>
String(right.endTime ?? right.createTime ?? '').localeCompare(String(left.endTime ?? left.createTime ?? '')),
);
setTaskList(tasks);
if (!routeTaskId && tasks[0]?.id !== undefined) {
setSelectedTaskId(String(tasks[0].id));
}
} else {
console.error('Failed to fetch task list for user score page:', taskResult.reason);
}
if (deptResult.status === 'fulfilled') {
const treeNodes = normalizeDeptTreeNodes(deptResult.value);
setDeptTree(treeNodes);
setExpandedDeptKeys(collectDeptKeys(treeNodes));
} else {
console.error('Failed to fetch department tree for user score page:', deptResult.reason);
}
if (taskResult.status === 'rejected' && deptResult.status === 'rejected') {
message.error('获取人员绩效基础数据失败');
}
} catch (error) {
console.error('Failed to fetch base data for user score page:', error);
message.error('获取人员绩效基础数据失败');
} finally {
setDeptLoading(false);
}
@ -201,7 +286,7 @@ const UserScorePage = () => {
setLoading(true);
try {
const currentTask = taskList.find((task) => String(task.id ?? '') === selectedTaskId);
const response = await getTaskUserList({
const response = await fetchTaskUserListRequest({
isAsc: 'desc',
sortFiled: 'all',
taskId: selectedTaskId,
@ -234,14 +319,21 @@ const UserScorePage = () => {
return;
}
const loadDeptUsers = async () => {
const cached = deptUserCacheRef.current.get(selectedDeptId);
if (cached) {
setDeptUserIds(cached);
return;
}
try {
const response = await listUser({
const response = await fetchDeptUserRequest({
pageNum: 1,
pageSize: 1000,
deptId: selectedDeptId,
});
const rows = normalizeUserRows(response);
setDeptUserIds(new Set(rows.map((row) => String(row.userId ?? '')).filter(Boolean)));
const nextIds = new Set(rows.map((row) => String(row.userId ?? '')).filter(Boolean));
deptUserCacheRef.current.set(selectedDeptId, nextIds);
setDeptUserIds(nextIds);
} catch (error) {
console.error('Failed to fetch department users for user score page:', error);
setDeptUserIds(new Set());

View File

@ -75,6 +75,11 @@
}
.user-project-link.ant-btn-link {
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-width: 0;
padding: 0;
font-weight: 600;
}
@ -82,6 +87,9 @@
.user-project-link.is-disabled {
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-width: 0;
padding: 0;
font-weight: 600;
color: #98a2b3;
@ -90,6 +98,14 @@
user-select: none;
}
.user-project-link-text {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-select-modal {
display: flex;

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Form, Input, Select, Button, Modal, message, Space, Tag, DatePicker, Popconfirm, Radio
App, Table, Form, Input, Select, Button, Modal, Space, Tag, DatePicker, Popconfirm, Radio
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
@ -46,6 +46,7 @@ type ConfigRecord = {
};
const ConfigPage: React.FC = () => {
const { message, modal } = App.useApp();
const { canAccessPath } = usePermission();
const canOperateConfig = canAccessPath('/system/config');
const [queryForm] = Form.useForm();
@ -152,7 +153,7 @@ const ConfigPage: React.FC = () => {
message.warning('请选择要删除的参数');
return;
}
Modal.confirm({
modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: `是否确认删除参数编号为"${configIds.join(',')}"的数据项?`,

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Form, Input, Select, Button, Modal, message, Space, Tag, InputNumber, TreeSelect, Popconfirm, Radio
App, Table, Form, Input, Select, Button, Modal, Space, Tag, InputNumber, TreeSelect, Popconfirm, Radio
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
@ -20,6 +20,7 @@ const sysNormalDisableDict = [
];
const DeptPage: React.FC = () => {
const { message, modal } = App.useApp();
const [queryForm] = Form.useForm();
const [deptForm] = Form.useForm();
const [loading, setLoading] = useState(false);
@ -174,7 +175,7 @@ const DeptPage: React.FC = () => {
};
const handleDelete = (row: any) => {
Modal.confirm({
modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: `是否确认删除名称为"${row.deptName}"的数据项?`,

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Form, Input, Select, Button, Modal, message, Space, Tag, DatePicker, Popconfirm, Radio
App, Table, Form, Input, Select, Button, Modal, Space, Tag, DatePicker, Popconfirm, Radio
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
@ -49,6 +49,7 @@ type DictTypeRecord = {
};
const DictPage: React.FC = () => {
const { message, modal } = App.useApp();
const { canAccessPath } = usePermission();
const [queryForm] = Form.useForm();
const [dictForm] = Form.useForm();
@ -154,7 +155,7 @@ const DictPage: React.FC = () => {
message.warning('请选择要删除的字典类型');
return;
}
Modal.confirm({
modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: `是否确认删除字典编号为"${dictIds.join(',')}"的数据项?`,

View File

@ -1,13 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import type { Key } from 'react';
import {
App,
Table,
Form,
Input,
Select,
Button,
Modal,
message,
Space,
Switch,
Tag,
@ -108,6 +108,7 @@ const getStatusTag = (status?: string) => {
};
const UserPage = () => {
const { message, modal } = App.useApp();
const [queryForm] = Form.useForm<UserQueryParams>();
const [userForm] = Form.useForm<UserFormValues>();
const [loading, setLoading] = useState(false);
@ -197,7 +198,7 @@ const UserPage = () => {
return;
}
Modal.confirm({
modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: `是否确认删除用户编号为"${ids.join(',')}"的数据项?`,
@ -224,7 +225,7 @@ const UserPage = () => {
const nextStatus = originalStatus === '0' ? '1' : '0';
const actionText = nextStatus === '0' ? '启用' : '停用';
Modal.confirm({
modal.confirm({
title: '确认操作',
icon: <ExclamationCircleOutlined />,
content: `确认要${actionText}用户"${record.userName ?? ''}"吗?`,
@ -257,7 +258,7 @@ const UserPage = () => {
}
const userId = record.userId;
Modal.confirm({
modal.confirm({
title: '重置密码',
icon: <ExclamationCircleOutlined />,
content: `是否确认重置用户"${record.userName ?? ''}"的密码?`,

View File

@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
App,
Alert,
Button,
Empty,
Input,
message,
Modal,
Space,
Spin,
@ -178,6 +178,7 @@ const ScoreBar = ({ value, editable, onChange }: ScoreBarProps) => {
};
const AppraisalDetailPage = () => {
const { message, modal } = App.useApp();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { canAccessPath } = usePermission();
@ -475,7 +476,7 @@ const AppraisalDetailPage = () => {
};
if (submitStatus === 1) {
Modal.confirm({
modal.confirm({
title: '确认提交绩效评分',
content: '提交后将无法修改,该操作不可逆,请确认后再试',
okText: '确定',

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card, Row, Col, message, Tabs, Tag, Empty } from 'antd';
import { Card, Row, Col, Tabs, Tag, Empty } from 'antd';
import { getTaskListSelf } from '../../api/appraisal';
import { useNavigate } from 'react-router-dom';
import { usePermission } from '@/contexts/PermissionContext';
import { notify } from '@/utils/notify';
import '@/styles/permission-link.css';
import './appraisal.css'; // Assuming a shared CSS for appraisal pages
@ -21,7 +22,7 @@ const AppraisalManagerPage: React.FC = () => {
setTaskList(response.data || {});
} catch (error) {
console.error('Failed to fetch task list:', error);
message.error('获取考核任务列表失败');
notify.error('获取考核任务列表失败');
}
}, []);
@ -34,7 +35,7 @@ const AppraisalManagerPage: React.FC = () => {
return;
}
if (!task.taskEditFlag) {
message.warning('分数正在计算中,请等待计算完成');
notify.warning('分数正在计算中,请等待计算完成');
return;
}
navigate(`/workAppraisal/managerUser?taskId=${task.id}&isEdit=${isEdit}`);

View File

@ -1,11 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Table, Form, Input, Select, Button, message, Space, Tag } from 'antd';
import { Table, Form, Input, Select, Button, Space, Tag } from 'antd';
import type { TableColumnsType } from 'antd';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { getTaskUserList } from '../../api/appraisal';
import { useLocation, useNavigate } from 'react-router-dom';
import Permission from '@/components/Permission';
import { usePermission } from '@/contexts/PermissionContext';
import { notify } from '@/utils/notify';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
const AppraisalManagerUserPage: React.FC = () => {
@ -46,7 +47,7 @@ const AppraisalManagerUserPage: React.FC = () => {
setTotal(response.total ?? 0);
} catch (error) {
console.error('Failed to fetch task user list:', error);
message.error('获取考核用户列表失败');
notify.error('获取考核用户列表失败');
} finally {
setLoading(false);
}

View File

@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Collapse, Empty, Form, Input, Select, Spin, message } from 'antd';
import { Alert, Collapse, Empty, Form, Input, Select, Spin } from 'antd';
import type { CollapseProps } from 'antd';
import { getTaskModelSet } from '@/api/appraisal';
import { useSearchParams } from 'react-router-dom';
import PageBackButton from '@/components/PageBackButton';
import { notify } from '@/utils/notify';
import './appraisal-module-detail.css';
interface ScoreConfigItem {
@ -185,7 +186,7 @@ const AppraisalModuleDetailPage = () => {
}
} catch (error) {
console.error('Failed to fetch appraisal module detail:', error);
message.error('获取考核看板详情失败');
notify.error('获取考核看板详情失败');
setScoreList([]);
setActiveGroupTitle('');
setSelectedCategoryKey('');

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Empty, message, Spin, Tabs, Tag } from 'antd';
import { Button, Empty, Spin, Tabs, Tag } from 'antd';
import { CarryOutOutlined, RightOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { getTaskListSelf } from '@/api/appraisal';
import { usePermission } from '@/contexts/PermissionContext';
import { notify } from '@/utils/notify';
import '@/styles/permission-link.css';
import './manager.css';
@ -112,7 +113,7 @@ const ManagerPage = () => {
setTaskBuckets(normalizeTaskBuckets(response));
} catch (error) {
console.error('Failed to fetch appraisal tasks:', error);
message.error('获取考核任务失败');
notify.error('获取考核任务失败');
setTaskBuckets(EMPTY_BUCKETS);
} finally {
setLoading(false);
@ -136,13 +137,13 @@ const ManagerPage = () => {
return;
}
if (!isTaskEditable(task.taskEditFlag)) {
message.warning('分数正在计算中,请等待计算完成');
notify.warning('分数正在计算中,请等待计算完成');
return;
}
const taskId = task.id;
if (taskId === undefined || taskId === null) {
message.warning('任务ID缺失无法打开详情');
notify.warning('任务ID缺失无法打开详情');
return;
}

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Empty, message, Spin, Tabs, Tag } from 'antd';
import { Button, Empty, Spin, Tabs, Tag } from 'antd';
import { CarryOutOutlined, RightOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { getTaskListSelfNormal } from '@/api/appraisal';
import { usePermission } from '@/contexts/PermissionContext';
import { notify } from '@/utils/notify';
import '@/styles/permission-link.css';
import './manager.css';
@ -112,7 +113,7 @@ const NormalWorkerPage = () => {
setTaskBuckets(normalizeTaskBuckets(response));
} catch (error) {
console.error('Failed to fetch normal worker tasks:', error);
message.error('获取考核评分任务失败');
notify.error('获取考核评分任务失败');
setTaskBuckets(EMPTY_BUCKETS);
} finally {
setLoading(false);
@ -136,12 +137,12 @@ const NormalWorkerPage = () => {
return;
}
if (!isTaskEditable(task.taskEditFlag)) {
message.warning('分数正在计算中,请等待计算完成');
notify.warning('分数正在计算中,请等待计算完成');
return;
}
if (task.id === undefined || task.id === null) {
message.warning('任务ID缺失无法进入评分页');
notify.warning('任务ID缺失无法进入评分页');
return;
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Collapse,
@ -391,6 +391,7 @@ const TaskSetPage = () => {
const [scoreList, setScoreList] = useState<ScoreGroup[]>([]);
const [activeCollapseKey, setActiveCollapseKey] = useState('');
const [selectedSubItemKey, setSelectedSubItemKey] = useState('');
const deptTreeLoadedRef = useRef(false);
const canAddTask = canAccessPath('/workAppraisal/taskSet');
const canEditTask = canAccessPath('/workAppraisal/taskSet');
const canRemoveTask = canAccessPath('/workAppraisal/taskSet');
@ -425,10 +426,14 @@ const TaskSetPage = () => {
}, [getList]);
const loadDeptTree = useCallback(async () => {
if (deptTreeLoadedRef.current) {
return;
}
setDeptLoading(true);
try {
const response = await deptTreeSelect();
setDeptTree(normalizeDeptTreeNodes(response));
deptTreeLoadedRef.current = true;
} catch (error) {
console.error('Failed to load department tree:', error);
message.error('获取部门树失败');
@ -465,15 +470,17 @@ const TaskSetPage = () => {
}, [selectedDeptId, userModalOpen, userNameKeyword, userPageNum, userPageSize]);
useEffect(() => {
if (!userModalOpen || deptTree.length > 0) {
if (!userModalOpen) {
return;
}
void loadDeptTree();
}, [deptTree.length, loadDeptTree, userModalOpen]);
useEffect(() => {
void loadUserList();
}, [loadUserList]);
if (deptTreeLoadedRef.current) {
void loadUserList();
return;
}
void Promise.all([loadDeptTree(), loadUserList()]);
}, [loadDeptTree, loadUserList, userModalOpen]);
const totalWeight = useMemo(
() => scoreList.reduce((sum, group) => sum + toNumber(group.weight, 0), 0),

View File

@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
App,
Button,
DatePicker,
Input,
message,
Modal,
Popconfirm,
Popover,
@ -39,7 +39,6 @@ import {
userProject,
} from '@/api/worklog';
import { listProject } from '@/api/project';
import { getUserProfile } from '@/api/user';
import { TokenKey } from '@/utils/auth';
import PageBackButton from '@/components/PageBackButton';
import { usePermission } from '@/contexts/PermissionContext';
@ -50,12 +49,6 @@ const { TextArea } = Input;
const DAY_VALUE_FORMAT = 'YYYY-MM-DD 00:00:00';
const FILE_UPLOAD_URL = '/api/common/upload';
interface UserInfo {
userId?: string | number;
userName?: string;
nickName?: string;
}
interface CalendarLogItem {
date?: string;
state?: number;
@ -196,7 +189,82 @@ const buildWorkLogPayload = (row: WorkLogRow): Record<string, unknown> => ({
fileList: normalizeFileList(row.fileList),
});
let inflightFallbackProjectListRequest: Promise<unknown> | null = null;
const inflightWorklogUserProjectRequests = new Map<string, Promise<unknown>>();
const inflightCalendarRequests = new Map<string, Promise<unknown>>();
const inflightDayListRequests = new Map<string, Promise<unknown>>();
const inflightDayRemainingRequests = new Map<string, Promise<unknown>>();
const fetchUserProjectListRequest = async (userId: string) => {
const requestKey = userId;
const existingRequest = inflightWorklogUserProjectRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = userProject(userId).finally(() => {
inflightWorklogUserProjectRequests.delete(requestKey);
});
inflightWorklogUserProjectRequests.set(requestKey, requestPromise);
return requestPromise;
};
const fetchFallbackProjectListRequest = async () => {
if (inflightFallbackProjectListRequest) {
return inflightFallbackProjectListRequest;
}
const requestPromise = listProject({ pageNum: 1, pageSize: 10000 }).finally(() => {
inflightFallbackProjectListRequest = null;
});
inflightFallbackProjectListRequest = requestPromise;
return requestPromise;
};
const fetchCalendarRequest = async (params: Record<string, unknown>) => {
const requestKey = JSON.stringify(params);
const existingRequest = inflightCalendarRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = getLogData(params).finally(() => {
inflightCalendarRequests.delete(requestKey);
});
inflightCalendarRequests.set(requestKey, requestPromise);
return requestPromise;
};
const fetchDayListRequest = async (params: Record<string, unknown>) => {
const requestKey = JSON.stringify(params);
const existingRequest = inflightDayListRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = getLogDataDetail(params).finally(() => {
inflightDayListRequests.delete(requestKey);
});
inflightDayListRequests.set(requestKey, requestPromise);
return requestPromise;
};
const fetchDayRemainingRequest = async (params: Record<string, unknown>) => {
const requestKey = JSON.stringify(params);
const existingRequest = inflightDayRemainingRequests.get(requestKey);
if (existingRequest) {
return existingRequest;
}
const requestPromise = getDayTime(params).finally(() => {
inflightDayRemainingRequests.delete(requestKey);
});
inflightDayRemainingRequests.set(requestKey, requestPromise);
return requestPromise;
};
const WorkLogPage = () => {
const { message } = App.useApp();
const [searchParams] = useSearchParams();
const queryUserId = searchParams.get('userId') ?? '';
const queryProjectId = searchParams.get('projectId') ?? '';
@ -207,7 +275,6 @@ const WorkLogPage = () => {
const [loading, setLoading] = useState(false);
const [monthLoading, setMonthLoading] = useState(false);
const [currentUser, setCurrentUser] = useState<UserInfo>({});
const [projectList, setProjectList] = useState<ProjectRow[]>([]);
const [calendarList, setCalendarList] = useState<CalendarLogItem[]>([]);
@ -228,7 +295,13 @@ const WorkLogPage = () => {
const previousContentRef = useRef('');
const seedRef = useRef(0);
const { hasPermi } = usePermission();
const bootstrapLoadedRef = useRef(false);
const calendarStripRef = useRef<HTMLDivElement | null>(null);
const selectedDayButtonRef = useRef<HTMLButtonElement | null>(null);
const projectListRequestIdRef = useRef(0);
const calendarRequestIdRef = useRef(0);
const dayListRequestIdRef = useRef(0);
const { hasPermi, ready: permissionReady, currentUser } = usePermission();
const token = Cookies.get(TokenKey) ?? '';
@ -295,7 +368,7 @@ const WorkLogPage = () => {
const getCurrentDayTime = useCallback(
async (mode: 'add' | 'edit', row?: WorkLogRow) => {
try {
const response = await getDayTime({ loggerDate: selectedDay });
const response = await fetchDayRemainingRequest({ loggerDate: selectedDay });
const remaining = toNumber(normalizeResponseData(response), 0);
if (mode === 'add') {
@ -321,7 +394,21 @@ const WorkLogPage = () => {
showContent: false,
fileList: [],
};
setTableData((prev) => [...prev, newRow]);
setTableData((prev) => {
const hasPendingRow = prev.some((item) =>
!item.loggerId
&& item.edit
&& String(item.loggerDate ?? '') === selectedDay
&& String(item.userId ?? '') === String(viewUserId)
&& String(item.projectId ?? '') === String(queryProjectId ?? ''),
);
if (hasPendingRow) {
return prev;
}
return [...prev, newRow];
});
return;
}
@ -334,34 +421,19 @@ const WorkLogPage = () => {
[computeWorkTimeOptions, queryProjectId, selectedDay, selectedDayDate, viewUserId],
);
const fetchUserInfo = useCallback(async () => {
try {
const response = await getUserProfile();
const payload = normalizeResponseData(response);
const user = isObject(payload) && isObject(payload.user) ? payload.user : payload;
if (!isObject(user)) {
return;
}
setCurrentUser({
userId: user.userId as string | number | undefined,
userName: user.userName as string | undefined,
nickName: user.nickName as string | undefined,
});
} catch (error) {
console.error('Failed to fetch current user profile:', error);
message.error('获取当前用户信息失败');
}
}, []);
const fetchAllProjects = useCallback(async () => {
if (!viewUserId) {
const fetchAllProjects = useCallback(async (targetUserId?: string) => {
const effectiveUserId = String(targetUserId ?? viewUserId).trim();
const requestId = ++projectListRequestIdRef.current;
if (!effectiveUserId) {
setProjectList([]);
return;
}
try {
const response = await userProject(viewUserId);
const response = await fetchUserProjectListRequest(effectiveUserId);
if (requestId !== projectListRequestIdRef.current) {
return;
}
const payload = normalizeResponseData(response);
const rows = isObject(payload) && Array.isArray(payload.rows) ? payload.rows : Array.isArray(payload) ? payload : [];
setProjectList(normalizeProjectRows(rows));
@ -371,44 +443,63 @@ const WorkLogPage = () => {
}
try {
const response = await listProject({ pageNum: 1, pageSize: 10000 });
const response = await fetchFallbackProjectListRequest();
if (requestId !== projectListRequestIdRef.current) {
return;
}
const payload = normalizeResponseData(response);
const rows = isObject(payload) && Array.isArray(payload.rows) ? payload.rows : Array.isArray(payload) ? payload : [];
setProjectList(normalizeProjectRows(rows));
} catch (error) {
if (requestId !== projectListRequestIdRef.current) {
return;
}
console.error('Failed to fetch all projects:', error);
message.error('获取项目列表失败');
setProjectList([]);
}
}, [viewUserId]);
const fetchCalendarList = useCallback(async () => {
if (!viewUserId) {
const fetchCalendarList = useCallback(async (targetUserId?: string) => {
const effectiveUserId = String(targetUserId ?? viewUserId).trim();
const requestId = ++calendarRequestIdRef.current;
if (!effectiveUserId) {
setCalendarList([]);
return;
}
setMonthLoading(true);
try {
const response = await getLogData({
const requestParams = {
startDate: selectedMonth.startOf('month').format('YYYY-MM-DD 00:00:00'),
endDate: selectedMonth.endOf('month').format('YYYY-MM-DD 23:59:59'),
userId: viewUserId,
});
userId: effectiveUserId,
};
const response = await fetchCalendarRequest(requestParams);
if (requestId !== calendarRequestIdRef.current) {
return;
}
const data = normalizeResponseData(response);
setCalendarList(Array.isArray(data) ? (data as CalendarLogItem[]) : []);
} catch (error) {
if (requestId !== calendarRequestIdRef.current) {
return;
}
console.error('Failed to fetch month log list:', error);
message.error('获取月度日志失败');
setCalendarList([]);
} finally {
setMonthLoading(false);
if (requestId === calendarRequestIdRef.current) {
setMonthLoading(false);
}
}
}, [selectedMonth, viewUserId]);
const fetchDayList = useCallback(async () => {
if (!viewUserId) {
const fetchDayList = useCallback(async (targetUserId?: string) => {
const effectiveUserId = String(targetUserId ?? viewUserId).trim();
const requestId = ++dayListRequestIdRef.current;
if (!effectiveUserId) {
setTableData([]);
return;
}
@ -416,7 +507,7 @@ const WorkLogPage = () => {
setLoading(true);
try {
const params: Record<string, unknown> = {
userId: viewUserId,
userId: effectiveUserId,
loggerDate: selectedDay,
};
@ -424,7 +515,10 @@ const WorkLogPage = () => {
params.projectId = queryProjectId;
}
const response = await getLogDataDetail(params);
const response = await fetchDayListRequest(params);
if (requestId !== dayListRequestIdRef.current) {
return;
}
const payload = normalizeResponseData(response);
const rows = Array.isArray(payload)
? payload
@ -451,32 +545,84 @@ const WorkLogPage = () => {
await getCurrentDayTime('add');
}
} catch (error) {
if (requestId !== dayListRequestIdRef.current) {
return;
}
console.error('Failed to fetch day log list:', error);
message.error('获取当日日志失败');
setTableData([]);
} finally {
setLoading(false);
if (requestId === dayListRequestIdRef.current) {
setLoading(false);
}
}
}, [disableTable, getCurrentDayTime, queryProjectId, selectedDay, viewUserId]);
useEffect(() => {
void fetchUserInfo();
}, [fetchUserInfo]);
useEffect(() => {
void fetchAllProjects();
}, [fetchAllProjects]);
let cancelled = false;
bootstrapLoadedRef.current = false;
const bootstrap = async () => {
if (!permissionReady) {
return;
}
const directUserId = queryUserId.trim();
if (directUserId) {
await Promise.all([
fetchAllProjects(directUserId),
fetchCalendarList(directUserId),
fetchDayList(directUserId),
]);
if (!cancelled) {
bootstrapLoadedRef.current = true;
}
return;
}
const resolvedUserId = String(currentUser?.userId ?? '').trim();
if (!resolvedUserId) {
if (!cancelled) {
bootstrapLoadedRef.current = true;
}
return;
}
await Promise.all([
fetchAllProjects(resolvedUserId),
fetchCalendarList(resolvedUserId),
fetchDayList(resolvedUserId),
]);
if (!cancelled) {
bootstrapLoadedRef.current = true;
}
};
void bootstrap();
return () => {
cancelled = true;
bootstrapLoadedRef.current = false;
};
}, [currentUser?.userId, fetchAllProjects, fetchCalendarList, fetchDayList, permissionReady, queryUserId]);
useEffect(() => {
if (!bootstrapLoadedRef.current) {
return;
}
void fetchCalendarList();
}, [fetchCalendarList]);
useEffect(() => {
if (!bootstrapLoadedRef.current) {
return;
}
void fetchDayList();
}, [fetchDayList]);
const projectListFilter = useMemo(() => {
const matched = projectList.filter((item) => {
return projectList.filter((item) => {
const start = pickProjectBoundaryDate(item, ['startDate', 'beginDate', 'planStartDate']);
const end = pickProjectBoundaryDate(item, ['endDate', 'finishDate', 'planEndDate']);
if (!start && !end) {
@ -490,8 +636,6 @@ const WorkLogPage = () => {
}
return true;
});
return matched.length > 0 ? matched : projectList;
}, [projectList, selectedDayDate]);
const totalWorkTime = useMemo(() => {
@ -742,8 +886,7 @@ const WorkLogPage = () => {
}
message.success('操作成功');
await fetchDayList();
await fetchCalendarList();
await Promise.all([fetchDayList(), fetchCalendarList()]);
} catch (error) {
console.error('Failed to save work log row:', error);
message.error('保存失败');
@ -758,8 +901,7 @@ const WorkLogPage = () => {
try {
await delLog(row.loggerId);
message.success('删除成功');
await fetchDayList();
await fetchCalendarList();
await Promise.all([fetchDayList(), fetchCalendarList()]);
} catch (error) {
console.error('Failed to delete work log row:', error);
message.error('删除失败');
@ -977,6 +1119,25 @@ const WorkLogPage = () => {
});
}, [monthCellMap, selectedDayOnly, selectedMonth]);
useEffect(() => {
const strip = calendarStripRef.current;
const selectedButton = selectedDayButtonRef.current;
if (!strip || !selectedButton) {
return;
}
const frameId = window.requestAnimationFrame(() => {
const targetLeft = selectedButton.offsetLeft - (strip.clientWidth - selectedButton.clientWidth) / 2;
const maxScrollLeft = Math.max(strip.scrollWidth - strip.clientWidth, 0);
const nextScrollLeft = Math.min(Math.max(targetLeft, 0), maxScrollLeft);
strip.scrollTo({ left: nextScrollLeft, behavior: 'auto' });
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [monthDayList, selectedDayOnly]);
const columns: TableColumnsType<WorkLogRow> = [
{
title: '序号',
@ -1227,7 +1388,7 @@ const WorkLogPage = () => {
</div>
<Spin spinning={monthLoading}>
<div className="worklog-calendar-strip">
<div ref={calendarStripRef} className="worklog-calendar-strip">
{monthDayList.map((item) => {
const className = [
'worklog-strip-day',
@ -1244,6 +1405,7 @@ const WorkLogPage = () => {
key={item.key}
type="button"
className={className}
ref={item.isSelected ? selectedDayButtonRef : null}
onClick={() => handlePickDay(item.current)}
disabled={item.isFuture}
>

View File

@ -5,7 +5,12 @@ import path from 'path' // Import path module
const inferChunkName = (moduleIds: string[]) => {
const joined = moduleIds.join('\n')
if (joined.includes('/node_modules/antd/es/table') || joined.includes('/node_modules/rc-table/')) {
if (
joined.includes('/node_modules/rc-pagination/') ||
joined.includes('/node_modules/rc-table/') ||
joined.includes('/node_modules/rc-resize-observer/') ||
joined.includes('/node_modules/rc-virtual-list/')
) {
return 'antd-table'
}
if (joined.includes('/node_modules/antd/es/input') || joined.includes('/node_modules/rc-input/')) {
@ -26,7 +31,7 @@ const inferChunkName = (moduleIds: string[]) => {
if (joined.includes('/node_modules/antd/es/locale') || joined.includes('/node_modules/antd/locale/')) {
return 'antd-locale'
}
if (joined.includes('/node_modules/@ant-design/icons/')) {
if (joined.includes('/node_modules/@ant-design/icons/') || joined.includes('/node_modules/@ant-design/icons-svg/')) {
return 'antd-icons'
}
if (joined.includes('/src/components/')) {
@ -63,7 +68,19 @@ export default defineConfig({
if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) {
return 'react-vendor';
}
if (/node_modules\/(echarts|echarts-for-react)\//.test(id)) {
if (/node_modules\/(@ant-design\/icons|@ant-design\/icons-svg)\//.test(id)) {
return 'antd-icons';
}
if (/node_modules\/(rc-table|rc-pagination|rc-resize-observer|rc-virtual-list)\//.test(id)) {
return 'antd-table';
}
if (/node_modules\/echarts-for-react\//.test(id)) {
return 'echarts-react';
}
if (/node_modules\/zrender\//.test(id)) {
return 'zrender-vendor';
}
if (/node_modules\/echarts\//.test(id)) {
return 'echarts-vendor';
}
if (/node_modules\/(react-router|react-router-dom)\//.test(id)) {