修复功能缺失与性能问题
parent
023d0677f8
commit
b13a2bfc87
16
src/App.tsx
16
src/App.tsx
|
|
@ -1,6 +1,6 @@
|
|||
import { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import Spin from 'antd/es/spin';
|
||||
import './App.css';
|
||||
import { getToken } from './utils/auth';
|
||||
import { PermissionProvider, usePermission } from './contexts/PermissionContext';
|
||||
|
|
@ -14,11 +14,16 @@ const OnlineUserPage = lazy(() => import('./pages/monitor/OnlineUserPage'));
|
|||
const OperationLogPage = lazy(() => import('./pages/monitor/OperationLogPage'));
|
||||
const ServerMonitorPage = lazy(() => import('./pages/monitor/ServerMonitorPage'));
|
||||
const CacheListPage = lazy(() => import('./pages/monitor/CacheListPage'));
|
||||
const DruidMonitorPage = lazy(() => import('./pages/monitor/DruidMonitorPage'));
|
||||
const JobLogPage = lazy(() => import('./pages/monitor/JobLogPage'));
|
||||
const UserPage = lazy(() => import('./pages/system/UserPage'));
|
||||
const UserAuthRolePage = lazy(() => import('./pages/system/UserAuthRolePage'));
|
||||
const RolePage = lazy(() => import('./pages/system/RolePage'));
|
||||
const RoleAuthUserPage = lazy(() => import('./pages/system/RoleAuthUserPage'));
|
||||
const MenuPage = lazy(() => import('./pages/system/MenuPage'));
|
||||
const DeptPage = lazy(() => import('./pages/system/DeptPage'));
|
||||
const DictPage = lazy(() => import('./pages/system/DictPage'));
|
||||
const DictDataPage = lazy(() => import('./pages/system/DictDataPage'));
|
||||
const ConfigPage = lazy(() => import('./pages/system/ConfigPage'));
|
||||
const ProjectPage = lazy(() => import('./pages/project/ProjectPage'));
|
||||
const ProjectDetailPage = lazy(() => import('./pages/project/ProjectDetailPage'));
|
||||
|
|
@ -47,13 +52,13 @@ const RouteLoading = () => (
|
|||
const PrivateRoute = () => {
|
||||
const location = useLocation();
|
||||
const token = getToken();
|
||||
const { ready, loading, canAccessPath, defaultRoutePath } = usePermission();
|
||||
const { ready, canAccessPath, defaultRoutePath } = usePermission();
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (!ready || loading) {
|
||||
if (!ready) {
|
||||
return (
|
||||
<div style={{ width: '100%', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
|
|
@ -106,17 +111,22 @@ function App() {
|
|||
<Route path="/user/profile" element={<ProfilePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/monitor/cache" element={<CacheMonitorPage />} />
|
||||
<Route path="/monitor/druid" element={<DruidMonitorPage />} />
|
||||
<Route path="/monitor/job" element={<JobMonitorPage />} />
|
||||
<Route path="/monitor/job-log/index/:jobId" element={<JobLogPage />} />
|
||||
<Route path="/monitor/logininfor" element={<LoginLogPage />} />
|
||||
<Route path="/monitor/online" element={<OnlineUserPage />} />
|
||||
<Route path="/monitor/operlog" element={<OperationLogPage />} />
|
||||
<Route path="/monitor/server" element={<ServerMonitorPage />} />
|
||||
<Route path="/monitor/cacheList" element={<CacheListPage />} />
|
||||
<Route path="/system/user" element={<UserPage />} />
|
||||
<Route path="/system/user-auth/role/:userId" element={<UserAuthRolePage />} />
|
||||
<Route path="/system/role" element={<RolePage />} />
|
||||
<Route path="/system/role-auth/user/:roleId" element={<RoleAuthUserPage />} />
|
||||
<Route path="/system/menu" element={<MenuPage />} />
|
||||
<Route path="/system/dept" element={<DeptPage />} />
|
||||
<Route path="/system/dict" element={<DictPage />} />
|
||||
<Route path="/system/dict-data/index/:dictId" element={<DictDataPage />} />
|
||||
<Route path="/system/config" element={<ConfigPage />} />
|
||||
<Route path="/project/list" element={<ProjectPage />} />
|
||||
<Route path="/project/detail" element={<ProjectDetailPage />} />
|
||||
|
|
|
|||
|
|
@ -62,3 +62,25 @@ export function runJob(jobId: JobRecord['jobId'], jobGroup: string) {
|
|||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function listJobLog(query: any) {
|
||||
return request({
|
||||
url: '/monitor/jobLog/list',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function delJobLog(jobLogId: string | number) {
|
||||
return request({
|
||||
url: `/monitor/jobLog/${jobLogId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanJobLog() {
|
||||
return request({
|
||||
url: '/monitor/jobLog/clean',
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React from 'react';
|
||||
import { Avatar, Button, Dropdown, Space, Tabs } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import Avatar from 'antd/es/avatar';
|
||||
import Button from 'antd/es/button';
|
||||
import Dropdown from 'antd/es/dropdown';
|
||||
import Space from 'antd/es/space';
|
||||
import Tabs from 'antd/es/tabs';
|
||||
import type { MenuProps } from 'antd/es/menu';
|
||||
import { DownOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { removeToken } from '../../utils/auth';
|
||||
|
|
@ -33,6 +37,7 @@ const TAB_TITLE_MAP: Record<string, string> = {
|
|||
'/user/profile': '个人中心',
|
||||
'/profile': '个人中心',
|
||||
'/monitor/cache': '缓存监控',
|
||||
'/monitor/druid': '数据监控',
|
||||
'/monitor/job': '定时任务',
|
||||
'/monitor/logininfor': '登录日志',
|
||||
'/log/logininfor': '登录日志',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import Menu from 'antd/es/menu';
|
||||
import type { MenuProps } from 'antd/es/menu';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AreaChartOutlined,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ const ROUTE_ALIASES: Record<string, string[]> = {
|
|||
'/profile': ['/user/profile'],
|
||||
'/user/profile': ['/profile'],
|
||||
'/demandManage': ['/project/demandManage'],
|
||||
'/project/demandManage': ['/demandManage'],
|
||||
'/project/detail': ['/project/list', '/projectBank/projectProgress', '/projectBank/userProject'],
|
||||
'/workAppraisal/dashboard': ['/workAppraisal/taskModule'],
|
||||
'/dashboard/project-execution': ['/projectBank/projectProgress'],
|
||||
'/projectBank/projectProgress': ['/dashboard/project-execution'],
|
||||
|
|
@ -53,8 +55,119 @@ const ROUTE_ALIASES: Record<string, string[]> = {
|
|||
'/monitor/logininfor': ['/logininfor', '/log/logininfor', '/system/logininfor', '/system/log/logininfor'],
|
||||
'/monitor/operlog': ['/operlog', '/log/operlog', '/system/operlog', '/system/log/operlog'],
|
||||
};
|
||||
const EXTRA_PERMISSION_PATHS: Array<{ path: string; permissions: string[] }> = [
|
||||
{ path: '/system/user-auth/role/:userId', permissions: ['system:user:edit'] },
|
||||
{ path: '/system/role-auth/user/:roleId', permissions: ['system:role:edit'] },
|
||||
{ path: '/system/dict-data/index/:dictId', permissions: ['system:dict:list'] },
|
||||
{ path: '/monitor/job-log/index/:jobId', permissions: ['monitor:job:list'] },
|
||||
{
|
||||
path: '/project/detail',
|
||||
permissions: [
|
||||
'project:list:eidt',
|
||||
'project:list:edit',
|
||||
'project:list:detail',
|
||||
'project:detail:save',
|
||||
'project:detail:addUser',
|
||||
'project:detail:editUser',
|
||||
'project:detail:workLog',
|
||||
'project:detail:deleteUser',
|
||||
],
|
||||
},
|
||||
{ path: '/project/demandManage', permissions: ['project:list:demand'] },
|
||||
];
|
||||
const SUPER_PERMI = '*:*:*';
|
||||
const ADMIN_ROLE = 'admin';
|
||||
const PERMISSION_CACHE_KEY = 'pms_permission_cache_v1';
|
||||
const PERMISSION_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface PermissionCacheState {
|
||||
tokenFingerprint: string;
|
||||
cachedAt: number;
|
||||
userName: string;
|
||||
currentUser: PermissionContextValue['currentUser'];
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
routers: RouterNode[];
|
||||
defaultRoutePath: string;
|
||||
allowedPaths: string[];
|
||||
routeGuardEnabled: boolean;
|
||||
}
|
||||
|
||||
const getTokenFingerprint = (token: string) => token.slice(-24);
|
||||
|
||||
const readPermissionCache = (): PermissionCacheState | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(PERMISSION_CACHE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const cached = JSON.parse(raw) as Partial<PermissionCacheState>;
|
||||
if (
|
||||
cached.tokenFingerprint !== getTokenFingerprint(token)
|
||||
|| !cached.cachedAt
|
||||
|| Date.now() - cached.cachedAt > PERMISSION_CACHE_TTL_MS
|
||||
|| !Array.isArray(cached.routers)
|
||||
|| !Array.isArray(cached.allowedPaths)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tokenFingerprint: cached.tokenFingerprint,
|
||||
cachedAt: cached.cachedAt,
|
||||
userName: String(cached.userName ?? ''),
|
||||
currentUser: cached.currentUser ?? {},
|
||||
roles: parseStringList(cached.roles),
|
||||
permissions: parseStringList(cached.permissions),
|
||||
routers: cached.routers,
|
||||
defaultRoutePath: String(cached.defaultRoutePath ?? '/index'),
|
||||
allowedPaths: cached.allowedPaths.map(String),
|
||||
routeGuardEnabled: Boolean(cached.routeGuardEnabled),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore permission cache:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writePermissionCache = (state: Omit<PermissionCacheState, 'tokenFingerprint' | 'cachedAt'>) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
PERMISSION_CACHE_KEY,
|
||||
JSON.stringify({
|
||||
...state,
|
||||
tokenFingerprint: getTokenFingerprint(token),
|
||||
cachedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist permission cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearPermissionCache = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.sessionStorage.removeItem(PERMISSION_CACHE_KEY);
|
||||
};
|
||||
|
||||
const normalizePath = (rawPath: string) => {
|
||||
const path = rawPath.split('?')[0]?.split('#')[0] ?? '';
|
||||
|
|
@ -206,18 +319,21 @@ const matchPathPattern = (allowedPath: string, actualPath: string) => {
|
|||
};
|
||||
|
||||
export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const cachedPermissionState = useMemo(() => readPermissionCache(), []);
|
||||
const hasCachedPermissionState = Boolean(cachedPermissionState);
|
||||
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[]>([]);
|
||||
const [defaultRoutePath, setDefaultRoutePath] = useState('');
|
||||
const [allowedPaths, setAllowedPaths] = useState<Set<string>>(new Set());
|
||||
const [routeGuardEnabled, setRouteGuardEnabled] = useState(false);
|
||||
const [ready, setReady] = useState(hasCachedPermissionState);
|
||||
const [userName, setUserName] = useState(cachedPermissionState?.userName ?? '');
|
||||
const [currentUser, setCurrentUser] = useState<PermissionContextValue['currentUser']>(cachedPermissionState?.currentUser ?? {});
|
||||
const [roles, setRoles] = useState<string[]>(cachedPermissionState?.roles ?? []);
|
||||
const [permissions, setPermissions] = useState<string[]>(cachedPermissionState?.permissions ?? []);
|
||||
const [routers, setRouters] = useState<RouterNode[]>(cachedPermissionState?.routers ?? []);
|
||||
const [defaultRoutePath, setDefaultRoutePath] = useState(cachedPermissionState?.defaultRoutePath ?? '');
|
||||
const [allowedPaths, setAllowedPaths] = useState<Set<string>>(() => new Set(cachedPermissionState?.allowedPaths ?? []));
|
||||
const [routeGuardEnabled, setRouteGuardEnabled] = useState(cachedPermissionState?.routeGuardEnabled ?? false);
|
||||
|
||||
const clearPermissionState = useCallback(() => {
|
||||
clearPermissionCache();
|
||||
setUserName('');
|
||||
setCurrentUser({});
|
||||
setRoles([]);
|
||||
|
|
@ -238,7 +354,6 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
|||
}
|
||||
|
||||
setLoading(true);
|
||||
setReady(false);
|
||||
|
||||
let nextUserName = '';
|
||||
let nextCurrentUser: PermissionContextValue['currentUser'] = {};
|
||||
|
|
@ -317,6 +432,18 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
|||
setDefaultRoutePath(nextDefaultRoutePath);
|
||||
setAllowedPaths(nextAllowedPaths);
|
||||
setRouteGuardEnabled(nextRouteGuardEnabled);
|
||||
if (!bootstrapFailed && nextRouters.length > 0) {
|
||||
writePermissionCache({
|
||||
userName: nextUserName,
|
||||
currentUser: nextCurrentUser,
|
||||
roles: nextRoles,
|
||||
permissions: nextPermissions,
|
||||
routers: nextRouters,
|
||||
defaultRoutePath: nextDefaultRoutePath,
|
||||
allowedPaths: Array.from(nextAllowedPaths),
|
||||
routeGuardEnabled: nextRouteGuardEnabled,
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
setReady(true);
|
||||
}
|
||||
|
|
@ -380,6 +507,16 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
|||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
EXTRA_PERMISSION_PATHS.some(
|
||||
(item) =>
|
||||
matchPathPattern(item.path, normalizedPath) &&
|
||||
item.permissions.some((permission) => permissionSet.has(permission) || permissionSet.has(SUPER_PERMI)),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedPath === '/' && allowedPaths.has('/index')) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -392,7 +529,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
|||
|
||||
return false;
|
||||
},
|
||||
[allowedPaths, isAdmin, permissionSet, roleSet, routeGuardEnabled],
|
||||
[allowedPaths, isAdmin, permissionSet, routeGuardEnabled],
|
||||
);
|
||||
|
||||
const value = useMemo<PermissionContextValue>(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import Layout from 'antd/es/layout';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import AppSidebar from '../components/Sidebar/index';
|
||||
import AppNavbar from '../components/Navbar';
|
||||
|
|
|
|||
190
src/main.tsx
190
src/main.tsx
|
|
@ -1,7 +1,9 @@
|
|||
import { StrictMode, useEffect } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App as AntdApp, ConfigProvider, theme } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import AntdApp from 'antd/es/app'
|
||||
import ConfigProvider from 'antd/es/config-provider'
|
||||
import theme from 'antd/es/theme'
|
||||
import zhCN from 'antd/es/locale/zh_CN'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'antd/dist/reset.css';
|
||||
|
|
@ -22,99 +24,97 @@ const MessageBinder = () => {
|
|||
};
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#635bff',
|
||||
colorSuccess: '#10b981',
|
||||
colorWarning: '#f59e0b',
|
||||
colorError: '#ef4444',
|
||||
colorInfo: '#4f7cff',
|
||||
colorBgBase: '#f6f8fc',
|
||||
colorBgLayout: '#f6f8fc',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorText: '#162033',
|
||||
colorTextSecondary: '#5f6f92',
|
||||
colorTextTertiary: '#7d8cab',
|
||||
colorBorder: '#dbe4f3',
|
||||
colorBorderSecondary: '#e9eef7',
|
||||
borderRadius: 14,
|
||||
borderRadiusLG: 20,
|
||||
borderRadiusSM: 10,
|
||||
fontFamily: '"Plus Jakarta Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
fontSizeLG: 16,
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#635bff',
|
||||
colorSuccess: '#10b981',
|
||||
colorWarning: '#f59e0b',
|
||||
colorError: '#ef4444',
|
||||
colorInfo: '#4f7cff',
|
||||
colorBgBase: '#f6f8fc',
|
||||
colorBgLayout: '#f6f8fc',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorText: '#162033',
|
||||
colorTextSecondary: '#5f6f92',
|
||||
colorTextTertiary: '#7d8cab',
|
||||
colorBorder: '#dbe4f3',
|
||||
colorBorderSecondary: '#e9eef7',
|
||||
borderRadius: 14,
|
||||
borderRadiusLG: 20,
|
||||
borderRadiusSM: 10,
|
||||
fontFamily: '"Plus Jakarta Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
fontSize: 14,
|
||||
fontSizeLG: 16,
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 46,
|
||||
lineWidth: 1,
|
||||
boxShadow: '0 18px 45px rgba(22, 32, 51, 0.08)',
|
||||
boxShadowSecondary: '0 10px 24px rgba(99, 91, 255, 0.10)',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
headerBg: 'rgba(255,255,255,0.86)',
|
||||
siderBg: '#ffffff',
|
||||
bodyBg: '#f6f8fc',
|
||||
triggerBg: '#ffffff',
|
||||
triggerColor: '#162033',
|
||||
},
|
||||
Menu: {
|
||||
itemBg: 'transparent',
|
||||
itemColor: '#5f6f92',
|
||||
itemHoverColor: '#162033',
|
||||
itemSelectedColor: '#4f46e5',
|
||||
itemSelectedBg: 'rgba(99, 91, 255, 0.12)',
|
||||
subMenuItemBg: 'transparent',
|
||||
iconSize: 16,
|
||||
collapsedIconSize: 16,
|
||||
itemBorderRadius: 12,
|
||||
},
|
||||
Button: {
|
||||
borderRadius: 12,
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 46,
|
||||
lineWidth: 1,
|
||||
boxShadow: '0 18px 45px rgba(22, 32, 51, 0.08)',
|
||||
boxShadowSecondary: '0 10px 24px rgba(99, 91, 255, 0.10)',
|
||||
controlHeightLG: 44,
|
||||
fontWeight: 600,
|
||||
primaryShadow: '0 12px 24px rgba(99, 91, 255, 0.24)',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
headerBg: 'rgba(255,255,255,0.86)',
|
||||
siderBg: '#ffffff',
|
||||
bodyBg: '#f6f8fc',
|
||||
triggerBg: '#ffffff',
|
||||
triggerColor: '#162033',
|
||||
},
|
||||
Menu: {
|
||||
itemBg: 'transparent',
|
||||
itemColor: '#5f6f92',
|
||||
itemHoverColor: '#162033',
|
||||
itemSelectedColor: '#4f46e5',
|
||||
itemSelectedBg: 'rgba(99, 91, 255, 0.12)',
|
||||
subMenuItemBg: 'transparent',
|
||||
iconSize: 16,
|
||||
collapsedIconSize: 16,
|
||||
itemBorderRadius: 12,
|
||||
},
|
||||
Button: {
|
||||
borderRadius: 12,
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 44,
|
||||
fontWeight: 600,
|
||||
primaryShadow: '0 12px 24px rgba(99, 91, 255, 0.24)',
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 20,
|
||||
boxShadowTertiary: '0 12px 32px rgba(22, 32, 51, 0.06)',
|
||||
},
|
||||
Input: {
|
||||
borderRadius: 12,
|
||||
controlHeight: 40,
|
||||
activeShadow: '0 0 0 4px rgba(99, 91, 255, 0.12)',
|
||||
},
|
||||
Select: {
|
||||
borderRadius: 12,
|
||||
controlHeight: 40,
|
||||
optionSelectedBg: 'rgba(99, 91, 255, 0.1)',
|
||||
},
|
||||
Table: {
|
||||
borderColor: '#e9eef7',
|
||||
headerBg: '#f7f9fe',
|
||||
headerColor: '#34425f',
|
||||
rowHoverBg: '#f8faff',
|
||||
},
|
||||
Modal: {
|
||||
borderRadiusLG: 22,
|
||||
},
|
||||
Drawer: {
|
||||
borderRadiusLG: 22,
|
||||
},
|
||||
Tag: {
|
||||
borderRadiusSM: 999,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 20,
|
||||
boxShadowTertiary: '0 12px 32px rgba(22, 32, 51, 0.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<MessageBinder />
|
||||
<App />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</StrictMode>,
|
||||
Input: {
|
||||
borderRadius: 12,
|
||||
controlHeight: 40,
|
||||
activeShadow: '0 0 0 4px rgba(99, 91, 255, 0.12)',
|
||||
},
|
||||
Select: {
|
||||
borderRadius: 12,
|
||||
controlHeight: 40,
|
||||
optionSelectedBg: 'rgba(99, 91, 255, 0.1)',
|
||||
},
|
||||
Table: {
|
||||
borderColor: '#e9eef7',
|
||||
headerBg: '#f7f9fe',
|
||||
headerColor: '#34425f',
|
||||
rowHoverBg: '#f8faff',
|
||||
},
|
||||
Modal: {
|
||||
borderRadiusLG: 22,
|
||||
},
|
||||
Drawer: {
|
||||
borderRadiusLG: 22,
|
||||
},
|
||||
Tag: {
|
||||
borderRadiusSM: 999,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<MessageBinder />
|
||||
<App />
|
||||
</AntdApp>
|
||||
</ConfigProvider>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -82,7 +82,10 @@ const LoginPage = () => {
|
|||
login(data)
|
||||
.then((res) => {
|
||||
notify.success('Login successful!');
|
||||
const tokenToSet = res.token ?? 'mock_token';
|
||||
const tokenToSet = res.token;
|
||||
if (!tokenToSet) {
|
||||
throw new Error('登录返回缺少 token');
|
||||
}
|
||||
Cookies.set(TokenKey, tokenToSet);
|
||||
if (values.rememberMe) {
|
||||
Cookies.set(REMEMBER_ME_KEY, 'true', { expires: 30 });
|
||||
|
|
|
|||
|
|
@ -1,685 +1,187 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, DatePicker, Empty, Form, Input, Progress, Select, Space, Table, Tooltip } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, DatePicker, Empty, Form, Select, Space, Table } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
FieldTimeOutlined,
|
||||
ProjectOutlined,
|
||||
ReloadOutlined,
|
||||
RiseOutlined,
|
||||
SearchOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import zhCN from 'antd/es/date-picker/locale/zh_CN';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import { listProject } from '@/api/project';
|
||||
import { listProjectExecution } from '@/api/projectExecution';
|
||||
import { getProjectExecutionInfo } 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';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Back-end endpoint /projectBank/projectProgress/list may be unavailable in some environments.
|
||||
// Keep it disabled by default to avoid 404 noise in browser/devtools.
|
||||
const ENABLE_PROJECT_EXECUTION_API = false;
|
||||
const PERIOD_SWITCH_DAYS = 62;
|
||||
|
||||
interface BoardRow {
|
||||
interface ExecutionRow {
|
||||
projectId?: string | number;
|
||||
projectName?: string;
|
||||
projectCode?: string;
|
||||
projectLeaderName?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
projectState?: string;
|
||||
teamNum?: number;
|
||||
budgetDate?: number | string;
|
||||
planProgress?: number;
|
||||
actualProgress?: number;
|
||||
progressDeviation?: number;
|
||||
delayDays?: number;
|
||||
riskLevel?: 'normal' | 'warning' | 'delay';
|
||||
allDateWorkTime?: number | string;
|
||||
allWorkTime?: number | string;
|
||||
detailList?: Array<number | string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
projectName?: string;
|
||||
projectLeaderName?: string;
|
||||
projectState?: string;
|
||||
beginTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
const getDefaultRange = (): [Dayjs, Dayjs] => [dayjs().startOf('month'), dayjs().endOf('month')];
|
||||
|
||||
interface PeriodMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
subLabel: string;
|
||||
start: Dayjs;
|
||||
end: Dayjs;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
interface PeriodMetric {
|
||||
active: boolean;
|
||||
planPercent: number;
|
||||
actualPercent: number;
|
||||
delay: boolean;
|
||||
}
|
||||
|
||||
const getDefaultRange = (): [Dayjs, Dayjs] => [dayjs().startOf('year'), dayjs().endOf('year')];
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
const toNumber = (value: unknown) => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
};
|
||||
|
||||
const clampPercent = (value: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
if (value < 0) return 0;
|
||||
if (value > 100) return 100;
|
||||
return Number(value.toFixed(1));
|
||||
};
|
||||
|
||||
const toDate = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = dayjs(value as dayjs.ConfigType);
|
||||
return parsed.isValid() ? parsed.startOf('day') : null;
|
||||
};
|
||||
|
||||
const laterDate = (left: Dayjs, right: Dayjs) => (left.isAfter(right) ? left : right);
|
||||
|
||||
const earlierDate = (left: Dayjs, right: Dayjs) => (left.isBefore(right) ? left : right);
|
||||
|
||||
const calcPlanProgress = (startDate?: string, endDate?: string) => {
|
||||
const start = toDate(startDate);
|
||||
const end = toDate(endDate);
|
||||
if (!start || !end || end.isBefore(start, 'day')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalDays = end.diff(start, 'day') + 1;
|
||||
const today = dayjs().startOf('day');
|
||||
const elapsedDays = today.isBefore(start, 'day') ? 0 : today.diff(start, 'day') + 1;
|
||||
return clampPercent((elapsedDays / Math.max(totalDays, 1)) * 100);
|
||||
};
|
||||
|
||||
const inferActualProgress = (row: Record<string, unknown>, planProgress: number) => {
|
||||
const directFields = [
|
||||
'actualProgress',
|
||||
'execProgress',
|
||||
'projectProgress',
|
||||
'progress',
|
||||
'finishRate',
|
||||
'completeRate',
|
||||
];
|
||||
for (const key of directFields) {
|
||||
if (row[key] !== undefined && row[key] !== null && row[key] !== '') {
|
||||
return clampPercent(toNumber(row[key], 0));
|
||||
}
|
||||
}
|
||||
|
||||
const rawState = String(row.projectState ?? '').toLowerCase();
|
||||
if (rawState === '2' || rawState.includes('完成')) return 100;
|
||||
if (rawState === '0' || rawState.includes('待')) return 0;
|
||||
if (rawState === '1' || rawState.includes('进行')) return clampPercent(planProgress * 0.9);
|
||||
return clampPercent(planProgress * 0.75);
|
||||
};
|
||||
|
||||
const calcDelayDays = (row: Record<string, unknown>) => {
|
||||
const end = toDate(row.endDate);
|
||||
const rawState = String(row.projectState ?? '').toLowerCase();
|
||||
if (!end) {
|
||||
return 0;
|
||||
}
|
||||
if (rawState === '2' || rawState.includes('完成')) {
|
||||
return 0;
|
||||
}
|
||||
if (dayjs().isAfter(end, 'day')) {
|
||||
return dayjs().startOf('day').diff(end, 'day');
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const normalizeRows = (rows: unknown[]): BoardRow[] => {
|
||||
return rows
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => {
|
||||
const row = item as Record<string, unknown>;
|
||||
const planProgress = clampPercent(
|
||||
row.planProgress !== undefined
|
||||
? toNumber(row.planProgress)
|
||||
: calcPlanProgress(String(row.startDate ?? ''), String(row.endDate ?? '')),
|
||||
);
|
||||
const actualProgress = inferActualProgress(row, planProgress);
|
||||
const progressDeviation = Number((actualProgress - planProgress).toFixed(1));
|
||||
const delayDays = row.delayDays !== undefined ? toNumber(row.delayDays) : calcDelayDays(row);
|
||||
const riskLevel: BoardRow['riskLevel'] =
|
||||
delayDays > 0 ? 'delay' : progressDeviation < -15 ? 'warning' : 'normal';
|
||||
|
||||
return {
|
||||
...(row as BoardRow),
|
||||
projectId: row.projectId as string | number | undefined,
|
||||
projectName: String(row.projectName ?? ''),
|
||||
projectCode: String(row.projectCode ?? ''),
|
||||
projectLeaderName: String(row.projectLeaderName ?? row.projectLeader ?? ''),
|
||||
startDate: row.startDate ? String(row.startDate) : undefined,
|
||||
endDate: row.endDate ? String(row.endDate) : undefined,
|
||||
planProgress,
|
||||
actualProgress,
|
||||
progressDeviation,
|
||||
delayDays,
|
||||
riskLevel,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const extractRows = (response: unknown): { rows: unknown[]; total: number } => {
|
||||
if (response && typeof response === 'object') {
|
||||
const payload = response as Record<string, unknown>;
|
||||
if (Array.isArray(payload.rows)) {
|
||||
return { rows: payload.rows, total: toNumber(payload.total, payload.rows.length) };
|
||||
}
|
||||
if (payload.data && typeof payload.data === 'object') {
|
||||
const dataObj = payload.data as Record<string, unknown>;
|
||||
if (Array.isArray(dataObj.rows)) {
|
||||
return { rows: dataObj.rows, total: toNumber(dataObj.total, dataObj.rows.length) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
return { rows: response, total: response.length };
|
||||
}
|
||||
return { rows: [], total: 0 };
|
||||
};
|
||||
|
||||
const overlapDays = (rangeStart: Dayjs, rangeEnd: Dayjs, blockStart: Dayjs, blockEnd: Dayjs) => {
|
||||
const start = laterDate(rangeStart, blockStart);
|
||||
const end = earlierDate(rangeEnd, blockEnd);
|
||||
if (end.isBefore(start, 'day')) {
|
||||
return 0;
|
||||
}
|
||||
return end.diff(start, 'day') + 1;
|
||||
};
|
||||
|
||||
const buildPeriods = (rangeStart: Dayjs, rangeEnd: Dayjs): PeriodMeta[] => {
|
||||
if (!rangeStart.isValid() || !rangeEnd.isValid() || rangeEnd.isBefore(rangeStart, 'day')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const periods: PeriodMeta[] = [];
|
||||
const totalDays = rangeEnd.diff(rangeStart, 'day');
|
||||
const useWeekView = totalDays <= PERIOD_SWITCH_DAYS;
|
||||
const today = dayjs();
|
||||
|
||||
if (useWeekView) {
|
||||
let cursor = rangeStart.startOf('day');
|
||||
let index = 0;
|
||||
while (!cursor.isAfter(rangeEnd, 'day')) {
|
||||
const start = cursor;
|
||||
const end = earlierDate(cursor.add(6, 'day').endOf('day'), rangeEnd.endOf('day'));
|
||||
periods.push({
|
||||
key: `week-${index}`,
|
||||
label: `${start.format('MM/DD')}-${end.format('MM/DD')}`,
|
||||
subLabel: `${start.format('YYYY')} 第${index + 1}段`,
|
||||
start,
|
||||
end,
|
||||
current: !today.isBefore(start.startOf('day')) && !today.isAfter(end.endOf('day')),
|
||||
});
|
||||
cursor = end.add(1, 'day').startOf('day');
|
||||
index += 1;
|
||||
}
|
||||
return periods;
|
||||
}
|
||||
|
||||
let cursor = rangeStart.startOf('month');
|
||||
while (!cursor.isAfter(rangeEnd, 'day')) {
|
||||
const start = laterDate(cursor.startOf('month'), rangeStart.startOf('day'));
|
||||
const end = earlierDate(cursor.endOf('month'), rangeEnd.endOf('day'));
|
||||
periods.push({
|
||||
key: cursor.format('YYYY-MM'),
|
||||
label: cursor.format('MM月'),
|
||||
subLabel: cursor.format('YYYY'),
|
||||
start,
|
||||
end,
|
||||
current: !today.isBefore(start.startOf('day')) && !today.isAfter(end.endOf('day')),
|
||||
});
|
||||
cursor = cursor.add(1, 'month').startOf('month');
|
||||
}
|
||||
return periods;
|
||||
};
|
||||
|
||||
const getPeriodMetric = (row: BoardRow, period: PeriodMeta): PeriodMetric => {
|
||||
const start = toDate(row.startDate);
|
||||
const end = toDate(row.endDate);
|
||||
if (!start || !end || end.isBefore(start, 'day')) {
|
||||
return { active: false, planPercent: 0, actualPercent: 0, delay: false };
|
||||
}
|
||||
|
||||
const totalDays = end.diff(start, 'day') + 1;
|
||||
const planDays = overlapDays(start, end, period.start, period.end);
|
||||
const planPercent = clampPercent((planDays / totalDays) * 100);
|
||||
const actualDaysTotal = Math.round((clampPercent(toNumber(row.actualProgress, 0)) / 100) * totalDays);
|
||||
const actualEnd = actualDaysTotal > 0 ? start.add(actualDaysTotal - 1, 'day') : start.subtract(1, 'day');
|
||||
const actualDays = actualDaysTotal > 0 ? overlapDays(start, actualEnd, period.start, period.end) : 0;
|
||||
const actualPercent = clampPercent((actualDays / totalDays) * 100);
|
||||
|
||||
return {
|
||||
active: planDays > 0,
|
||||
planPercent,
|
||||
actualPercent,
|
||||
delay: toNumber(row.delayDays, 0) > 0 && period.current,
|
||||
};
|
||||
};
|
||||
|
||||
const getStateTone = (label: string, riskLevel: BoardRow['riskLevel']) => {
|
||||
if (riskLevel === 'delay') {
|
||||
return 'is-delay';
|
||||
}
|
||||
if (riskLevel === 'warning') {
|
||||
return 'is-warning';
|
||||
}
|
||||
if (label.includes('完成')) {
|
||||
return 'is-finished';
|
||||
}
|
||||
if (label.includes('进行')) {
|
||||
return 'is-active';
|
||||
}
|
||||
return 'is-pending';
|
||||
};
|
||||
|
||||
const getProgressTone = (deviation: number) => {
|
||||
if (deviation < -15) {
|
||||
return '#ff4d4f';
|
||||
}
|
||||
if (deviation < 0) {
|
||||
return '#fa8c16';
|
||||
}
|
||||
return '#16a34a';
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => `${clampPercent(value)}%`;
|
||||
|
||||
const ProjectExecutionPage = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const { canAccessPath } = usePermission();
|
||||
const navigate = useNavigate();
|
||||
const defaultRange = useMemo(() => getDefaultRange(), []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rows, setRows] = useState<ExecutionRow[]>([]);
|
||||
const [statusOptions, setStatusOptions] = useState<Array<{ dictValue: string; dictLabel: string }>>([]);
|
||||
const [rows, setRows] = useState<BoardRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const fallbackTipShownRef = useRef(false);
|
||||
const [queryParams, setQueryParams] = useState<QueryParams>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
projectName: undefined,
|
||||
projectLeaderName: undefined,
|
||||
projectState: undefined,
|
||||
beginTime: defaultRange[0].format('YYYY-MM-DD'),
|
||||
endTime: defaultRange[1].format('YYYY-MM-DD'),
|
||||
});
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>(defaultRange);
|
||||
const [projectState, setProjectState] = useState<string | undefined>(undefined);
|
||||
|
||||
const rangeStart = useMemo(
|
||||
() => toDate(queryParams.beginTime) ?? defaultRange[0],
|
||||
[defaultRange, queryParams.beginTime],
|
||||
);
|
||||
const rangeEnd = useMemo(
|
||||
() => toDate(queryParams.endTime) ?? defaultRange[1],
|
||||
[defaultRange, queryParams.endTime],
|
||||
);
|
||||
const periods = useMemo(() => buildPeriods(rangeStart, rangeEnd), [rangeEnd, rangeStart]);
|
||||
const canViewProjectUser = canAccessPath('/projectBank/projectUser');
|
||||
const statusMap = useMemo(
|
||||
() => new Map(statusOptions.map((item) => [String(item.dictValue), item.dictLabel])),
|
||||
[statusOptions],
|
||||
);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalCount = rows.length;
|
||||
const executingCount = rows.filter((row) => {
|
||||
const statusLabel = statusMap.get(String(row.projectState ?? '')) ?? String(row.projectState ?? '');
|
||||
return statusLabel.includes('进行') || String(row.projectState ?? '') === '1';
|
||||
}).length;
|
||||
const delayCount = rows.filter((row) => toNumber(row.delayDays, 0) > 0).length;
|
||||
const avgActual = rows.length
|
||||
? Number((rows.reduce((sum, row) => sum + toNumber(row.actualProgress), 0) / rows.length).toFixed(1))
|
||||
: 0;
|
||||
const totalMembers = rows.reduce((sum, row) => sum + toNumber(row.teamNum, 0), 0);
|
||||
|
||||
return { totalCount, executingCount, delayCount, avgActual, totalMembers };
|
||||
}, [rows, statusMap]);
|
||||
|
||||
const fetchStatusDict = useCallback(async () => {
|
||||
const loadStatusDict = useCallback(async () => {
|
||||
try {
|
||||
const response = await getDicts('business_projectstate');
|
||||
const list = Array.isArray(response)
|
||||
? response
|
||||
: response && typeof response === 'object' && Array.isArray((response as Record<string, unknown>).data)
|
||||
? ((response as Record<string, unknown>).data as Array<{ dictValue: string; dictLabel: string }>)
|
||||
: [];
|
||||
setStatusOptions(list);
|
||||
setStatusOptions(Array.isArray(response) ? response : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project state dict:', error);
|
||||
setStatusOptions([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let response: unknown;
|
||||
let fallbackUsed = false;
|
||||
if (ENABLE_PROJECT_EXECUTION_API) {
|
||||
try {
|
||||
response = await listProjectExecution(queryParams as unknown as Record<string, unknown>);
|
||||
} catch {
|
||||
fallbackUsed = true;
|
||||
response = await listProject(queryParams as unknown as Record<string, unknown>);
|
||||
}
|
||||
} else {
|
||||
fallbackUsed = true;
|
||||
response = await listProject(queryParams as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const { rows: rawRows, total: rawTotal } = extractRows(response);
|
||||
setRows(normalizeRows(rawRows));
|
||||
setTotal(rawTotal);
|
||||
|
||||
if (ENABLE_PROJECT_EXECUTION_API && fallbackUsed && !fallbackTipShownRef.current) {
|
||||
fallbackTipShownRef.current = true;
|
||||
notify.info('项目执行表接口未就绪,已自动使用项目列表估算执行进度');
|
||||
}
|
||||
const response = await getProjectExecutionInfo({
|
||||
startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`,
|
||||
endDate: `${dateRange[1].format('YYYY-MM-DD')} 00:00:00`,
|
||||
projectState: projectState ?? '',
|
||||
});
|
||||
const list = Array.isArray((response as { data?: ExecutionRow[] })?.data)
|
||||
? (response as { data?: ExecutionRow[] }).data ?? []
|
||||
: Array.isArray(response)
|
||||
? response
|
||||
: [];
|
||||
setRows(list);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project execution list:', error);
|
||||
notify.error('获取项目执行表失败');
|
||||
console.error('Failed to fetch project execution info:', error);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryParams]);
|
||||
}, [dateRange, projectState]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatusDict();
|
||||
}, [fetchStatusDict]);
|
||||
void loadStatusDict();
|
||||
}, [loadStatusDict]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchList();
|
||||
}, [fetchList]);
|
||||
void loadRows();
|
||||
}, [loadRows]);
|
||||
|
||||
const openProject = useCallback(
|
||||
(row: BoardRow) => {
|
||||
if (!canViewProjectUser) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('projectId', String(row.projectId ?? ''));
|
||||
params.set('projectName', row.projectName ?? '');
|
||||
|
||||
const rowStart = toDate(row.startDate);
|
||||
const rowEnd = toDate(row.endDate);
|
||||
if (rowStart) {
|
||||
params.set('startDate', String(rowStart.valueOf()));
|
||||
}
|
||||
if (rowEnd) {
|
||||
params.set('endDate', String(rowEnd.endOf('day').valueOf()));
|
||||
}
|
||||
|
||||
navigate(`/projectBank/projectUser?${params.toString()}`);
|
||||
},
|
||||
[canViewProjectUser, navigate],
|
||||
);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
const selectedRange = values.dateRange as [Dayjs, Dayjs] | undefined;
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
projectName: values.projectName ?? undefined,
|
||||
projectLeaderName: values.projectLeaderName ?? undefined,
|
||||
projectState: values.projectState ?? undefined,
|
||||
beginTime: selectedRange?.[0]?.format('YYYY-MM-DD') ?? defaultRange[0].format('YYYY-MM-DD'),
|
||||
endTime: selectedRange?.[1]?.format('YYYY-MM-DD') ?? defaultRange[1].format('YYYY-MM-DD'),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const nextRange = getDefaultRange();
|
||||
queryForm.resetFields();
|
||||
queryForm.setFieldsValue({ dateRange: nextRange });
|
||||
setQueryParams({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
projectName: undefined,
|
||||
projectLeaderName: undefined,
|
||||
projectState: undefined,
|
||||
beginTime: nextRange[0].format('YYYY-MM-DD'),
|
||||
endTime: nextRange[1].format('YYYY-MM-DD'),
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo<TableColumnsType<BoardRow>>(() => {
|
||||
const baseColumns: TableColumnsType<BoardRow> = [
|
||||
{
|
||||
title: '项目名称',
|
||||
key: 'projectName',
|
||||
dataIndex: 'projectName',
|
||||
width: 260,
|
||||
fixed: 'left',
|
||||
render: (value: unknown, row) => (
|
||||
<div className="project-name-cell">
|
||||
{canViewProjectUser ? (
|
||||
<Button type="link" className="project-name-link" onClick={() => openProject(row)}>
|
||||
{String(value ?? '-') || '-'}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="project-name-link is-disabled permission-link-disabled">{String(value ?? '-') || '-'}</span>
|
||||
)}
|
||||
<div className="project-name-meta">
|
||||
<span>{row.projectCode || '未配置项目编号'}</span>
|
||||
<span>
|
||||
{parseTime(row.startDate, 'YYYY-MM-DD') || '--'} 至 {parseTime(row.endDate, 'YYYY-MM-DD') || '--'}
|
||||
</span>
|
||||
</div>
|
||||
const dynamicColumns = useMemo<TableColumnsType<ExecutionRow>>(() => {
|
||||
const days = dateRange[1].diff(dateRange[0], 'day') + 1;
|
||||
const dateColumns: TableColumnsType<ExecutionRow> = Array.from({ length: Math.max(days, 0) }, (_item, index) => {
|
||||
const current = dateRange[0].add(index, 'day');
|
||||
return {
|
||||
title: (
|
||||
<div style={{ whiteSpace: 'pre-line', textAlign: 'center' }}>
|
||||
{`${['周日', '周一', '周二', '周三', '周四', '周五', '周六'][current.day()]}\n${current.format('M/D')}`}
|
||||
</div>
|
||||
),
|
||||
key: `detail_${index}`,
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (_value, row) => {
|
||||
const cellValue = Array.isArray(row.detailList) ? row.detailList[index] : '';
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
className="project-name-link"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/projectBank/projectUser?projectId=${encodeURIComponent(String(row.projectId ?? ''))}&startDate=${encodeURIComponent(
|
||||
dateRange[0].format('YYYY-MM-DD'),
|
||||
)}&endDate=${encodeURIComponent(dateRange[1].format('YYYY-MM-DD'))}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{String(cellValue ?? '')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title: '项目',
|
||||
dataIndex: 'projectName',
|
||||
key: 'projectName',
|
||||
fixed: 'left',
|
||||
width: 180,
|
||||
render: (value, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
className="project-name-link"
|
||||
onClick={() => navigate(`/project/detail?id=${encodeURIComponent(String(row.projectId ?? ''))}`)}
|
||||
>
|
||||
{String(value ?? '-')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'projectLeaderName',
|
||||
width: 120,
|
||||
fixed: 'left',
|
||||
render: (value: unknown) => String(value ?? '-') || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
title: '项目状态',
|
||||
dataIndex: 'projectState',
|
||||
width: 112,
|
||||
key: 'projectState',
|
||||
fixed: 'left',
|
||||
render: (value: unknown, row) => {
|
||||
const label = statusMap.get(String(value ?? '')) ?? (String(value ?? '') || '未配置');
|
||||
return <span className={`project-state-pill ${getStateTone(label, row.riskLevel)}`}>{label}</span>;
|
||||
},
|
||||
width: 140,
|
||||
align: 'center',
|
||||
render: (value) => statusOptions.find((item) => String(item.dictValue) === String(value ?? ''))?.dictLabel ?? '-',
|
||||
},
|
||||
{
|
||||
title: '执行概览',
|
||||
key: 'overview',
|
||||
width: 240,
|
||||
title: '预计工时(天)',
|
||||
dataIndex: 'budgetDate',
|
||||
key: 'budgetDate',
|
||||
fixed: 'left',
|
||||
render: (_value, row) => {
|
||||
const plan = clampPercent(toNumber(row.planProgress, 0));
|
||||
const actual = clampPercent(toNumber(row.actualProgress, 0));
|
||||
const deviation = toNumber(row.progressDeviation, 0);
|
||||
return (
|
||||
<div className="project-overview-card">
|
||||
<div className="project-overview-head">
|
||||
<span className="project-overview-title">总进度</span>
|
||||
<span className="project-overview-diff" style={{ color: getProgressTone(deviation) }}>
|
||||
{`${deviation > 0 ? '+' : ''}${deviation}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="project-overview-bar-item">
|
||||
<div className="project-overview-bar-label">
|
||||
<span>计划</span>
|
||||
<span>{formatPercent(plan)}</span>
|
||||
</div>
|
||||
<Progress percent={plan} showInfo={false} size="small" strokeColor="#8fbef8" railColor="#edf3fb" />
|
||||
</div>
|
||||
<div className="project-overview-bar-item">
|
||||
<div className="project-overview-bar-label">
|
||||
<span>执行</span>
|
||||
<span>{formatPercent(actual)}</span>
|
||||
</div>
|
||||
<Progress percent={actual} showInfo={false} size="small" strokeColor="#ff9e3d" railColor="#fdf0df" />
|
||||
</div>
|
||||
<div className="project-overview-footer">
|
||||
<span>{`团队 ${toNumber(row.teamNum, 0)} 人`}</span>
|
||||
<span>{`工时 ${String(row.budgetDate ?? '-')}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '总计工时(天)',
|
||||
dataIndex: 'allDateWorkTime',
|
||||
key: 'allDateWorkTime',
|
||||
fixed: 'left',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '统计工时(天)',
|
||||
dataIndex: 'allWorkTime',
|
||||
key: 'allWorkTime',
|
||||
fixed: 'left',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
...dateColumns,
|
||||
];
|
||||
|
||||
const periodColumns: TableColumnsType<BoardRow> = periods.map((period) => ({
|
||||
title: (
|
||||
<div className={`period-header${period.current ? ' is-current' : ''}`}>
|
||||
<span className="period-header-label">{period.label}</span>
|
||||
<span className="period-header-sub-label">{period.subLabel}</span>
|
||||
</div>
|
||||
),
|
||||
key: period.key,
|
||||
width: 164,
|
||||
className: period.current ? 'period-column-current' : undefined,
|
||||
render: (_value, row) => {
|
||||
const metric = getPeriodMetric(row, period);
|
||||
if (!metric.active) {
|
||||
return (
|
||||
<div className="period-cell is-empty">
|
||||
<span className="period-cell-tip">无排期</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`period-cell${period.current ? ' is-current' : ''}`}>
|
||||
<div className="period-cell-values">
|
||||
<span>计划 {formatPercent(metric.planPercent)}</span>
|
||||
<span>执行 {formatPercent(metric.actualPercent)}</span>
|
||||
</div>
|
||||
<div className="period-bar-track">
|
||||
<div className="period-bar-plan" style={{ width: `${metric.planPercent}%` }} />
|
||||
<div className="period-bar-actual" style={{ width: `${metric.actualPercent}%` }} />
|
||||
</div>
|
||||
<span className={`period-cell-tip${metric.delay ? ' is-delay' : ''}`}>
|
||||
{metric.delay ? '当前节点延期' : metric.actualPercent >= metric.planPercent ? '执行平稳' : '待追赶'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
return [...baseColumns, ...periodColumns];
|
||||
}, [canViewProjectUser, openProject, periods, statusMap]);
|
||||
|
||||
const stats = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'total',
|
||||
label: '项目总数',
|
||||
value: summary.totalCount,
|
||||
extra: '纳入当前筛选范围',
|
||||
icon: <ProjectOutlined />,
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: '进行中项目',
|
||||
value: summary.executingCount,
|
||||
extra: `总成员 ${summary.totalMembers} 人`,
|
||||
icon: <RiseOutlined />,
|
||||
tone: 'orange',
|
||||
},
|
||||
{
|
||||
key: 'delay',
|
||||
label: '延期项目',
|
||||
value: summary.delayCount,
|
||||
extra: '需要重点跟踪',
|
||||
icon: <FieldTimeOutlined />,
|
||||
tone: 'red',
|
||||
},
|
||||
{
|
||||
key: 'avg',
|
||||
label: '平均执行率',
|
||||
value: `${summary.avgActual}%`,
|
||||
extra: periods.length > 0 ? `${periods.length} 个阶段窗口` : '暂无阶段',
|
||||
icon: <TeamOutlined />,
|
||||
tone: 'teal',
|
||||
},
|
||||
],
|
||||
[periods.length, summary],
|
||||
);
|
||||
|
||||
const scrollX = useMemo(() => 260 + 120 + 112 + 240 + periods.length * 164, [periods.length]);
|
||||
const viewLabel = periods.length > 0 && rangeEnd.diff(rangeStart, 'day') <= PERIOD_SWITCH_DAYS ? '周视图' : '月视图';
|
||||
}, [dateRange, navigate, statusOptions]);
|
||||
|
||||
return (
|
||||
<div className="project-progress-page">
|
||||
<section className="project-progress-hero">
|
||||
<div className="project-progress-hero-main">
|
||||
<div className="project-progress-title-row">
|
||||
<div>
|
||||
<h2>项目执行表</h2>
|
||||
<p>按项目维度对齐计划与实际执行,保留旧系统横向时间轴查看逻辑,并提升信息层次与可读性。</p>
|
||||
</div>
|
||||
<div className="project-progress-range-chip">
|
||||
<CalendarOutlined />
|
||||
<span>{`${rangeStart.format('YYYY.MM.DD')} - ${rangeEnd.format('YYYY.MM.DD')}`}</span>
|
||||
<strong>{viewLabel}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-progress-stats">
|
||||
{stats.map((item) => (
|
||||
<div key={item.key} className={`project-progress-stat-card is-${item.tone}`}>
|
||||
<div className="project-progress-stat-icon">{item.icon}</div>
|
||||
<div className="project-progress-stat-body">
|
||||
<span className="project-progress-stat-label">{item.label}</span>
|
||||
<strong className="project-progress-stat-value">{item.value}</strong>
|
||||
<span className="project-progress-stat-extra">{item.extra}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="project-progress-filter-card">
|
||||
<Form
|
||||
form={queryForm}
|
||||
layout="inline"
|
||||
className="project-progress-filter-form"
|
||||
initialValues={{ dateRange: defaultRange }}
|
||||
onFinish={handleQuery}
|
||||
initialValues={{ dateRange: defaultRange, projectState: undefined }}
|
||||
onFinish={(values) => {
|
||||
const nextRange = values.dateRange as [Dayjs, Dayjs];
|
||||
setDateRange(nextRange);
|
||||
setProjectState(values.projectState);
|
||||
}}
|
||||
>
|
||||
<Form.Item label="项目名称" name="projectName">
|
||||
<Input placeholder="请输入项目名称" allowClear style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="负责人" name="projectLeaderName">
|
||||
<Input placeholder="请输入负责人" allowClear style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="项目状态" name="projectState">
|
||||
<Select
|
||||
placeholder="请选择状态"
|
||||
|
|
@ -691,7 +193,7 @@ const ProjectExecutionPage = () => {
|
|||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="统计周期" name="dateRange">
|
||||
<Form.Item label="统计时间" name="dateRange">
|
||||
<RangePicker locale={zhCN} allowClear={false} style={{ width: 280 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
|
|
@ -699,7 +201,16 @@ const ProjectExecutionPage = () => {
|
|||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
const nextRange = getDefaultRange();
|
||||
queryForm.resetFields();
|
||||
queryForm.setFieldsValue({ dateRange: nextRange });
|
||||
setDateRange(nextRange);
|
||||
setProjectState(undefined);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
|
|
@ -710,86 +221,44 @@ const ProjectExecutionPage = () => {
|
|||
<section className="project-progress-table-card">
|
||||
<div className="project-progress-table-head">
|
||||
<div>
|
||||
<h3>执行矩阵</h3>
|
||||
<p>点击项目名称可进入项目下钻页面;左侧信息固定,右侧阶段横向滚动查看。</p>
|
||||
<h3>项目执行表</h3>
|
||||
<p>与线上一致,使用真实统计接口展示每日工时明细。</p>
|
||||
</div>
|
||||
<Tooltip title="日期跨度 62 天以内自动切换为周视图,超过则按月汇总。">
|
||||
<span className="project-progress-view-tip">{viewLabel}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Table<BoardRow>
|
||||
rowKey={(row) => String(row.projectId ?? row.projectCode ?? row.projectName ?? '')}
|
||||
<Table<ExecutionRow>
|
||||
rowKey={(row) => String(row.projectId ?? row.projectName ?? '')}
|
||||
className="project-progress-table"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
columns={dynamicColumns}
|
||||
dataSource={rows}
|
||||
scroll={{ x: scrollX }}
|
||||
scroll={{ x: 660 + Math.max(dateRange[1].diff(dateRange[0], 'day') + 1, 0) * 100 }}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="当前条件下暂无项目执行数据" /> }}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: page,
|
||||
pageSize,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
summary={(pageData) => {
|
||||
const pageAvgActual = pageData.length
|
||||
? Number((pageData.reduce((sum, row) => sum + toNumber(row.actualProgress), 0) / pageData.length).toFixed(1))
|
||||
: 0;
|
||||
const pageDelay = pageData.filter((row) => toNumber(row.delayDays, 0) > 0).length;
|
||||
|
||||
return (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<span className="project-progress-summary-title">当前页汇总</span>
|
||||
summary={(pageData) => (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>合计工时(天)</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} />
|
||||
<Table.Summary.Cell index={2}>
|
||||
{Number(pageData.reduce((sum, row) => sum + toNumber(row.budgetDate), 0).toFixed(2))}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3}>
|
||||
{Number(pageData.reduce((sum, row) => sum + toNumber(row.allDateWorkTime), 0).toFixed(2))}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4}>
|
||||
{Number(pageData.reduce((sum, row) => sum + toNumber(row.allWorkTime), 0).toFixed(2))}
|
||||
</Table.Summary.Cell>
|
||||
{Array.from({ length: Math.max(dateRange[1].diff(dateRange[0], 'day') + 1, 0) }, (_item, index) => (
|
||||
<Table.Summary.Cell key={`sum_${index}`} index={5 + index}>
|
||||
{Number(
|
||||
pageData.reduce((sum, row) => sum + toNumber(Array.isArray(row.detailList) ? row.detailList[index] : 0), 0).toFixed(2),
|
||||
)}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>{`${pageData.length} 项`}</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2}>{`${pageDelay} 延期`}</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3}>
|
||||
<div className="project-progress-summary-overview">
|
||||
<span>{`平均执行 ${pageAvgActual}%`}</span>
|
||||
<span>{`平均计划 ${
|
||||
pageData.length
|
||||
? Number(
|
||||
(
|
||||
pageData.reduce((sum, row) => sum + toNumber(row.planProgress), 0) / pageData.length
|
||||
).toFixed(1),
|
||||
)
|
||||
: 0
|
||||
}%`}</span>
|
||||
</div>
|
||||
</Table.Summary.Cell>
|
||||
{periods.map((period, index) => {
|
||||
const metrics = pageData.map((row) => getPeriodMetric(row, period)).filter((item) => item.active);
|
||||
const avgPlan = metrics.length
|
||||
? Number((metrics.reduce((sum, item) => sum + item.planPercent, 0) / metrics.length).toFixed(1))
|
||||
: 0;
|
||||
const avgActual = metrics.length
|
||||
? Number((metrics.reduce((sum, item) => sum + item.actualPercent, 0) / metrics.length).toFixed(1))
|
||||
: 0;
|
||||
return (
|
||||
<Table.Summary.Cell key={period.key} index={4 + index}>
|
||||
<div className="project-progress-summary-cell">
|
||||
<span>{`计划 ${avgPlan}%`}</span>
|
||||
<span>{`执行 ${avgActual}%`}</span>
|
||||
</div>
|
||||
</Table.Summary.Cell>
|
||||
);
|
||||
})}
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
);
|
||||
}}
|
||||
))}
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { Alert } from 'antd';
|
||||
|
||||
const DruidMonitorPage = () => {
|
||||
return (
|
||||
<div className="app-container" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="Druid 数据监控"
|
||||
description="本地开发环境通过 `/druid` 代理嵌入后端监控页面。如果页面没有显示,请确认本地后端服务已启动。"
|
||||
/>
|
||||
<iframe
|
||||
title="Druid Monitor"
|
||||
src="/druid/index.html"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 'calc(100vh - 240px)',
|
||||
border: 0,
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DruidMonitorPage;
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { saveAs } from 'file-saver';
|
||||
import dayjs from 'dayjs';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import Permission from '@/components/Permission';
|
||||
import {
|
||||
cleanJobLog,
|
||||
delJobLog,
|
||||
getJob,
|
||||
listJobLog,
|
||||
} from '@/api/monitor/job';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
|
||||
interface JobLogRecord {
|
||||
jobLogId?: number | string;
|
||||
jobName?: string;
|
||||
jobGroup?: string;
|
||||
invokeTarget?: string;
|
||||
jobMessage?: string;
|
||||
status?: string;
|
||||
exceptionInfo?: string;
|
||||
createTime?: string | number | Date;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface JobLogQueryParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
jobName?: string;
|
||||
jobGroup?: string;
|
||||
status?: string;
|
||||
jobId?: string | number;
|
||||
}
|
||||
|
||||
const defaultQueryParams: JobLogQueryParams = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
jobName: undefined,
|
||||
jobGroup: undefined,
|
||||
status: undefined,
|
||||
};
|
||||
|
||||
const JobLogPage = () => {
|
||||
const { message } = App.useApp();
|
||||
const { jobId = '0' } = useParams();
|
||||
const [queryForm] = Form.useForm<JobLogQueryParams>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [jobName, setJobName] = useState('');
|
||||
const [rows, setRows] = useState<JobLogRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [queryParams, setQueryParams] = useState<JobLogQueryParams>(defaultQueryParams);
|
||||
|
||||
const currentJobId = useMemo(() => Number(jobId) || 0, [jobId]);
|
||||
|
||||
const loadJobMeta = useCallback(async () => {
|
||||
if (!currentJobId) {
|
||||
setJobName('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await getJob(currentJobId);
|
||||
setJobName(String(response.jobName ?? ''));
|
||||
queryForm.setFieldsValue({
|
||||
jobName: String(response.jobName ?? ''),
|
||||
jobGroup: String(response.jobGroup ?? ''),
|
||||
});
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
jobId: currentJobId,
|
||||
jobName: String(response.jobName ?? '') || undefined,
|
||||
jobGroup: String(response.jobGroup ?? '') || undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load job detail for logs:', error);
|
||||
}
|
||||
}, [currentJobId, queryForm]);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listJobLog(queryParams) as { rows?: JobLogRecord[]; total?: number };
|
||||
setRows(response.rows ?? []);
|
||||
setTotal(Number(response.total ?? 0));
|
||||
} catch (error) {
|
||||
console.error('Failed to load job logs:', error);
|
||||
message.error('获取任务日志失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [message, queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobMeta();
|
||||
}, [loadJobMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const handleDelete = async (record?: JobLogRecord) => {
|
||||
const ids = record?.jobLogId !== undefined ? [record.jobLogId] : selectedRowKeys;
|
||||
if (ids.length === 0) {
|
||||
message.warning('请选择要删除的日志');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await delJobLog(ids.join(','));
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
await loadList();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete job logs:', error);
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClean = async () => {
|
||||
try {
|
||||
await cleanJobLog();
|
||||
message.success('清空成功');
|
||||
setSelectedRowKeys([]);
|
||||
await loadList();
|
||||
} catch (error) {
|
||||
console.error('Failed to clean job logs:', error);
|
||||
message.error('清空失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const hide = message.loading('正在导出数据...', 0);
|
||||
try {
|
||||
const response = await listJobLog({
|
||||
...queryParams,
|
||||
pageNum: undefined,
|
||||
pageSize: undefined,
|
||||
}) as { rows?: JobLogRecord[] };
|
||||
const csvContent = [
|
||||
['日志编号', '任务名称', '任务组名', '调用目标', '日志信息', '执行状态', '执行时间'],
|
||||
...(response.rows ?? []).map((item) => [
|
||||
item.jobLogId,
|
||||
item.jobName,
|
||||
item.jobGroup,
|
||||
item.invokeTarget,
|
||||
item.jobMessage,
|
||||
String(item.status ?? '') === '0' ? '成功' : '失败',
|
||||
parseTime(item.createTime),
|
||||
]),
|
||||
]
|
||||
.map((row) => row.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
saveAs(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }), `job_log_${dayjs().format('YYYYMMDDHHmmss')}.csv`);
|
||||
hide();
|
||||
message.success('导出成功');
|
||||
} catch (error) {
|
||||
hide();
|
||||
console.error('Failed to export job logs:', error);
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<JobLogRecord> = [
|
||||
{ title: '日志编号', dataIndex: 'jobLogId', align: 'center', width: 100 },
|
||||
{ title: '任务名称', dataIndex: 'jobName', align: 'center' },
|
||||
{ title: '任务组名', dataIndex: 'jobGroup', align: 'center', width: 120 },
|
||||
{ title: '调用目标', dataIndex: 'invokeTarget', align: 'center', ellipsis: true },
|
||||
{ title: '日志信息', dataIndex: 'jobMessage', align: 'center', ellipsis: true },
|
||||
{
|
||||
title: '执行状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (value) => (
|
||||
<Tag color={String(value ?? '') === '0' ? 'green' : 'red'}>
|
||||
{String(value ?? '') === '0' ? '成功' : '失败'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '执行时间',
|
||||
dataIndex: 'createTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (value) => parseTime(value),
|
||||
},
|
||||
{
|
||||
title: '异常信息',
|
||||
dataIndex: 'exceptionInfo',
|
||||
align: 'center',
|
||||
ellipsis: true,
|
||||
render: (value) => String(value ?? '-') || '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space wrap style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Space wrap>
|
||||
<PageBackButton fallbackPath="/monitor/job" />
|
||||
<strong>{jobName ? `${jobName} 调度日志` : '调度日志'}</strong>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={queryForm}
|
||||
layout="inline"
|
||||
className="search-form"
|
||||
onFinish={() =>
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
jobId: currentJobId || undefined,
|
||||
...queryForm.getFieldsValue(),
|
||||
pageNum: 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Form.Item label="任务名称" name="jobName">
|
||||
<Input placeholder="请输入任务名称" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="任务组名" name="jobGroup">
|
||||
<Input placeholder="请输入任务组名" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="请选择状态" allowClear style={{ width: 160 }}>
|
||||
<Select.Option value="0">成功</Select.Option>
|
||||
<Select.Option value="1">失败</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams({
|
||||
...defaultQueryParams,
|
||||
jobId: currentJobId || undefined,
|
||||
});
|
||||
}}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Permission permissions="monitor:job:remove">
|
||||
<Button danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => void handleDelete()}>
|
||||
删除
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="monitor:job:remove">
|
||||
<Popconfirm title="确认清空全部调度日志吗?" onConfirm={() => void handleClean()}>
|
||||
<Button ghost icon={<DeleteOutlined />}>清空</Button>
|
||||
</Popconfirm>
|
||||
</Permission>
|
||||
<Permission permissions="monitor:job:export">
|
||||
<Button ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
|
||||
导出
|
||||
</Button>
|
||||
</Permission>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="jobLogId"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) => setQueryParams((prev) => ({ ...prev, pageNum: page, pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobLogPage;
|
||||
|
|
@ -42,6 +42,7 @@ import type { JobQueryParams, JobRecord } from '@/types/api';
|
|||
import './job-monitor.css';
|
||||
import Permission from '@/components/Permission';
|
||||
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const sysJobGroupDict = [
|
||||
{ value: 'DEFAULT', label: '默认' },
|
||||
|
|
@ -101,6 +102,7 @@ const fetchJobDetail = async (jobId: string | number) => {
|
|||
|
||||
const JobMonitorPage = () => {
|
||||
const { message, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<JobRecord>();
|
||||
const [queryForm] = Form.useForm<JobQueryParams>();
|
||||
const [jobList, setJobList] = useState<JobRecord[]>([]);
|
||||
|
|
@ -285,8 +287,8 @@ const JobMonitorPage = () => {
|
|||
};
|
||||
|
||||
const handleJobLog = (record?: JobRecord) => {
|
||||
const jobId = record?.jobId ?? 0;
|
||||
message.info(`跳转到任务日志页面 (Job ID: ${jobId})`);
|
||||
const jobId = record?.jobId ?? selectedRowKeys[0] ?? 0;
|
||||
navigate(`/monitor/job-log/index/${jobId}`);
|
||||
};
|
||||
|
||||
const handleView = async (record: JobRecord) => {
|
||||
|
|
|
|||
|
|
@ -1,153 +1,874 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType, UploadProps } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileImageOutlined,
|
||||
FileTextOutlined,
|
||||
FileZipOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
UploadOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Cookies from 'js-cookie';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
addProject,
|
||||
deleteProjectUser,
|
||||
getProjectCode,
|
||||
getProjectDetail,
|
||||
getProjectUser,
|
||||
projectHasLogData,
|
||||
updateProject,
|
||||
updateProjectUser,
|
||||
} from '@/api/project';
|
||||
import { listUser } from '@/api/system/user';
|
||||
import { getDicts } from '@/api/system/dict';
|
||||
import { deleteProjectFileBatch } from '@/api/worklog';
|
||||
import Permission from '@/components/Permission';
|
||||
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import { usePermission } from '@/contexts/PermissionContext';
|
||||
import { notify } from '@/utils/notify';
|
||||
import { TokenKey } from '@/utils/auth';
|
||||
import './project-detail.css';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const FILE_UPLOAD_URL = '/api/common/upload';
|
||||
|
||||
const ProjectDetailPage: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const projectCode = Form.useWatch('projectCode', form);
|
||||
interface FileRecord {
|
||||
id?: string | number;
|
||||
fileName?: string;
|
||||
fileNewName?: string;
|
||||
filePath?: string;
|
||||
fileUrl?: string;
|
||||
url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProjectFormValues {
|
||||
projectName?: string;
|
||||
projectCode?: string;
|
||||
projectLeader?: string | number | null;
|
||||
projectLeaderName?: string;
|
||||
projectState?: string;
|
||||
budgetDate?: number;
|
||||
startDate?: Dayjs | null;
|
||||
endDate?: Dayjs | null;
|
||||
}
|
||||
|
||||
interface MemberRow {
|
||||
teamId?: string | number;
|
||||
userId?: string | number;
|
||||
userName?: string;
|
||||
postId?: string;
|
||||
workTime?: number | string;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
userId?: string | number;
|
||||
userName?: string;
|
||||
nickName?: string;
|
||||
phonenumber?: string;
|
||||
dept?: {
|
||||
deptName?: string;
|
||||
};
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
originalFilename?: string;
|
||||
filePath?: string;
|
||||
newFileName?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const normalizeFileList = (value: unknown): FileRecord[] =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => {
|
||||
const file = item as Record<string, unknown>;
|
||||
return {
|
||||
...file,
|
||||
fileUrl: String(file.fileUrl ?? file.url ?? ''),
|
||||
fileNewName: String(file.fileNewName ?? file.fileName ?? ''),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const getFileIcon = (fileName: string) => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
|
||||
return <FileImageOutlined />;
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
|
||||
return <FileZipOutlined />;
|
||||
}
|
||||
return <FileTextOutlined />;
|
||||
};
|
||||
|
||||
const extractResponseData = <T,>(response: unknown): T | undefined => {
|
||||
if (response && typeof response === 'object' && 'data' in (response as Record<string, unknown>)) {
|
||||
return (response as { data?: T }).data;
|
||||
}
|
||||
return response as T | undefined;
|
||||
};
|
||||
|
||||
const ProjectDetailPage = () => {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<ProjectFormValues>();
|
||||
const [memberForm] = Form.useForm<{ userId?: string | number; userName?: string; postId?: string }>();
|
||||
const [userQueryForm] = Form.useForm<{ userName?: string; phonenumber?: string }>();
|
||||
const { hasPermi } = usePermission();
|
||||
const { id: pathId } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const id = pathId ?? searchParams.get('id') ?? undefined;
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const id = pathId ?? searchParams.get('id') ?? undefined;
|
||||
const isEdit = !!id;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [memberLoading, setMemberLoading] = useState(false);
|
||||
const [userLoading, setUserLoading] = useState(false);
|
||||
const [fileUploading, setFileUploading] = useState(false);
|
||||
const [statusOptions, setStatusOptions] = useState<Array<{ dictValue: string; dictLabel: string }>>([]);
|
||||
const [postOptions, setPostOptions] = useState<Array<{ dictValue: string; dictLabel: string }>>([]);
|
||||
const [memberRows, setMemberRows] = useState<MemberRow[]>([]);
|
||||
const [fileList, setFileList] = useState<FileRecord[]>([]);
|
||||
const [pendingDeletedFileIds, setPendingDeletedFileIds] = useState<Array<string | number>>([]);
|
||||
const [memberModalOpen, setMemberModalOpen] = useState(false);
|
||||
const [memberModalMode, setMemberModalMode] = useState<'add' | 'edit'>('add');
|
||||
const [currentMember, setCurrentMember] = useState<MemberRow | null>(null);
|
||||
const [userPickerOpen, setUserPickerOpen] = useState(false);
|
||||
const [userPickerMode, setUserPickerMode] = useState<'manager' | 'member'>('manager');
|
||||
const [userPickerRows, setUserPickerRows] = useState<UserRow[]>([]);
|
||||
const [userPickerTotal, setUserPickerTotal] = useState(0);
|
||||
const [userPickerQuery, setUserPickerQuery] = useState({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
userName: undefined as string | undefined,
|
||||
phonenumber: undefined as string | undefined,
|
||||
});
|
||||
const [selectedUserKeys, setSelectedUserKeys] = useState<React.Key[]>([]);
|
||||
|
||||
const canSubmitProject = hasPermi('project:detail:save');
|
||||
const canAddUser = hasPermi('project:detail:addUser');
|
||||
const canEditUser = hasPermi('project:detail:editUser');
|
||||
const canViewWorkLog = hasPermi('project:detail:workLog');
|
||||
|
||||
const loadDictionaries = useCallback(async () => {
|
||||
try {
|
||||
const [positions, projectStates] = await Promise.all([
|
||||
getDicts('business_positions'),
|
||||
getDicts('business_projectstate'),
|
||||
]);
|
||||
setPostOptions(Array.isArray(positions) ? positions : []);
|
||||
setStatusOptions(Array.isArray(projectStates) ? projectStates : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load project dictionaries:', error);
|
||||
message.error('获取项目字典失败');
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const loadProjectMembers = useCallback(async () => {
|
||||
if (!id) {
|
||||
setMemberRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberLoading(true);
|
||||
try {
|
||||
const response = await getProjectUser(id);
|
||||
const data = ((response as Record<string, unknown>).data ?? response) as MemberRow[] | undefined;
|
||||
setMemberRows(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load project members:', error);
|
||||
message.error('获取项目成员失败');
|
||||
} finally {
|
||||
setMemberLoading(false);
|
||||
}
|
||||
}, [id, message]);
|
||||
|
||||
const loadProjectDetail = useCallback(async () => {
|
||||
if (!isEdit) {
|
||||
try {
|
||||
const response = await getProjectCode();
|
||||
form.setFieldsValue({ projectCode: ((response as Record<string, unknown>).data ?? response) as string });
|
||||
} catch (error) {
|
||||
console.error('Failed to load project code:', error);
|
||||
message.error('获取项目编号失败');
|
||||
}
|
||||
setFileList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getProjectDetail(id as string);
|
||||
const detail = ((response as Record<string, unknown>).data ?? response) as Record<string, unknown>;
|
||||
form.setFieldsValue({
|
||||
projectName: String(detail.projectName ?? ''),
|
||||
projectCode: String(detail.projectCode ?? ''),
|
||||
projectLeader: (detail.projectLeader as string | number | undefined) ?? null,
|
||||
projectLeaderName: String(detail.projectLeaderName ?? ''),
|
||||
projectState: String(detail.projectState ?? ''),
|
||||
budgetDate: Number(detail.budgetDate ?? 0),
|
||||
startDate: detail.startDate ? dayjs(String(detail.startDate).split(' ')[0]) : null,
|
||||
endDate: detail.endDate ? dayjs(String(detail.endDate).split(' ')[0]) : null,
|
||||
});
|
||||
setFileList(normalizeFileList(detail.fileList));
|
||||
setPendingDeletedFileIds([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load project detail:', error);
|
||||
message.error('获取项目详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, id, isEdit, message]);
|
||||
|
||||
const loadUserPicker = useCallback(async () => {
|
||||
setUserLoading(true);
|
||||
try {
|
||||
const response = await listUser(userPickerQuery);
|
||||
const rows = Array.isArray((response as { rows?: UserRow[] })?.rows) ? (response as { rows?: UserRow[] }).rows ?? [] : [];
|
||||
setUserPickerRows(rows);
|
||||
setUserPickerTotal(Number((response as { total?: number })?.total ?? 0));
|
||||
} catch (error) {
|
||||
console.error('Failed to load users for selector:', error);
|
||||
message.error('获取用户列表失败');
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
}
|
||||
}, [message, userPickerQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjectData = async () => {
|
||||
if (isEdit) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getProjectDetail(id as string);
|
||||
const projectData = ((response as Record<string, unknown>).data ?? response) as Record<string, unknown>;
|
||||
const startValue = projectData.startDate as dayjs.ConfigType | undefined;
|
||||
const endValue = projectData.endDate as dayjs.ConfigType | undefined;
|
||||
form.setFieldsValue({
|
||||
...projectData,
|
||||
startDate: startValue ? dayjs(startValue) : null,
|
||||
endDate: endValue ? dayjs(endValue) : null,
|
||||
});
|
||||
} catch (error) {
|
||||
notify.error('获取项目详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Fetch project code for new project
|
||||
try {
|
||||
const response = await getProjectCode();
|
||||
form.setFieldsValue({ projectCode: ((response as Record<string, unknown>).data ?? response) as string });
|
||||
} catch(error) {
|
||||
notify.error('获取项目编号失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchProjectData();
|
||||
}, [id, isEdit, form]);
|
||||
void loadDictionaries();
|
||||
}, [loadDictionaries]);
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
useEffect(() => {
|
||||
void loadProjectDetail();
|
||||
}, [loadProjectDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
void loadProjectMembers();
|
||||
}
|
||||
}, [isEdit, loadProjectMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userPickerOpen) {
|
||||
void loadUserPicker();
|
||||
}
|
||||
}, [loadUserPicker, userPickerOpen]);
|
||||
|
||||
const submitProject = async (values: ProjectFormValues) => {
|
||||
if (!canSubmitProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const startDateString = values.startDate ? `${values.startDate.format('YYYY-MM-DD')} 00:00:00` : null;
|
||||
const endDateString = values.endDate ? `${values.endDate.format('YYYY-MM-DD')} 23:59:59` : null;
|
||||
const payload = {
|
||||
...values,
|
||||
startDate: values.startDate ? values.startDate.format('YYYY-MM-DD HH:mm:ss') : null,
|
||||
endDate: values.endDate ? values.endDate.format('YYYY-MM-DD HH:mm:ss') : null,
|
||||
startDate: startDateString ? new Date(startDateString).getTime() : null,
|
||||
endDate: endDateString ? new Date(endDateString).getTime() : null,
|
||||
fileList,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
const checkResult = await projectHasLogData({
|
||||
projectId: id,
|
||||
startDate: startDateString,
|
||||
endDate: endDateString,
|
||||
});
|
||||
const canUpdateDirectly = Boolean(extractResponseData<boolean>(checkResult));
|
||||
if (!canUpdateDirectly) {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '确认修改项目时间',
|
||||
content: '检测到项目时间范围外的日志记录,修改项目时间将导致这些日志被删除。请确认是否继续修改?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await updateProject({ ...payload, projectId: id });
|
||||
notify.success('修改成功');
|
||||
} else {
|
||||
await addProject(payload);
|
||||
notify.success('新增成功');
|
||||
const response = await addProject(payload);
|
||||
const created = extractResponseData<Record<string, unknown>>(response);
|
||||
const createdProjectId = created?.projectId;
|
||||
const createdProjectCode = created?.projectCode;
|
||||
message.success('保存成功');
|
||||
if (createdProjectId !== undefined && createdProjectId !== null && createdProjectId !== '') {
|
||||
navigate(`/project/detail?id=${encodeURIComponent(String(createdProjectId))}`, { replace: true });
|
||||
if (createdProjectCode) {
|
||||
form.setFieldsValue({ projectCode: String(createdProjectCode) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/project/list');
|
||||
return;
|
||||
}
|
||||
navigate('/project/list');
|
||||
|
||||
if (pendingDeletedFileIds.length > 0) {
|
||||
await deleteProjectFileBatch(pendingDeletedFileIds.map(String).join(','));
|
||||
setPendingDeletedFileIds([]);
|
||||
}
|
||||
|
||||
message.success('保存成功');
|
||||
|
||||
await Promise.all([loadProjectDetail(), loadProjectMembers()]);
|
||||
} catch (error) {
|
||||
notify.error(isEdit ? '修改失败' : '新增失败');
|
||||
console.error('Failed to save project detail:', error);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openUserPicker = (mode: 'manager' | 'member') => {
|
||||
if (mode === 'manager' && !canSubmitProject) {
|
||||
return;
|
||||
}
|
||||
if (mode === 'member' && !canAddUser && !canEditUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserPickerMode(mode);
|
||||
userQueryForm.resetFields();
|
||||
setUserPickerQuery({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
userName: undefined,
|
||||
phonenumber: undefined,
|
||||
});
|
||||
|
||||
if (mode === 'manager') {
|
||||
const currentLeader = form.getFieldValue('projectLeader');
|
||||
setSelectedUserKeys(currentLeader ? [currentLeader] : []);
|
||||
} else {
|
||||
const currentUserId = memberForm.getFieldValue('userId');
|
||||
setSelectedUserKeys(currentUserId ? [currentUserId] : []);
|
||||
}
|
||||
|
||||
setUserPickerOpen(true);
|
||||
};
|
||||
|
||||
const handlePickUser = () => {
|
||||
const target = userPickerRows.find((item) => String(item.userId ?? '') === String(selectedUserKeys[0] ?? ''));
|
||||
if (!target) {
|
||||
message.warning('请选择用户');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userPickerMode === 'manager') {
|
||||
form.setFieldsValue({
|
||||
projectLeader: target.userId,
|
||||
projectLeaderName: String(target.nickName ?? target.userName ?? ''),
|
||||
});
|
||||
} else {
|
||||
const exists = memberRows.some(
|
||||
(item) =>
|
||||
String(item.userId ?? '') === String(target.userId ?? '') &&
|
||||
String(item.teamId ?? '') !== String(currentMember?.teamId ?? ''),
|
||||
);
|
||||
if (exists) {
|
||||
message.warning('该用户已在项目成员中');
|
||||
return;
|
||||
}
|
||||
|
||||
memberForm.setFieldsValue({
|
||||
userId: target.userId,
|
||||
userName: String(target.nickName ?? target.userName ?? ''),
|
||||
});
|
||||
}
|
||||
|
||||
setUserPickerOpen(false);
|
||||
};
|
||||
|
||||
const openMemberModal = (mode: 'add' | 'edit', member?: MemberRow) => {
|
||||
if (mode === 'add' && !canAddUser) {
|
||||
return;
|
||||
}
|
||||
if (mode === 'edit' && !canEditUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberModalMode(mode);
|
||||
setCurrentMember(member ?? null);
|
||||
memberForm.resetFields();
|
||||
memberForm.setFieldsValue({
|
||||
userId: member?.userId,
|
||||
userName: member?.userName,
|
||||
postId: member?.postId,
|
||||
});
|
||||
setMemberModalOpen(true);
|
||||
};
|
||||
|
||||
const saveMember = async () => {
|
||||
if (!id) {
|
||||
message.warning('请先保存项目再维护成员');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await memberForm.validateFields();
|
||||
await updateProjectUser({
|
||||
projectId: id,
|
||||
userId: values.userId,
|
||||
postId: values.postId,
|
||||
...(currentMember?.teamId ? { teamId: currentMember.teamId } : {}),
|
||||
});
|
||||
message.success(memberModalMode === 'add' ? '成员添加成功' : '成员更新成功');
|
||||
setMemberModalOpen(false);
|
||||
await loadProjectMembers();
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'errorFields' in error) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to save project member:', error);
|
||||
message.error(memberModalMode === 'add' ? '成员添加失败' : '成员更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (member: MemberRow) => {
|
||||
if (member.teamId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteProjectUser(member.teamId);
|
||||
message.success('成员删除成功');
|
||||
await loadProjectMembers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project member:', error);
|
||||
message.error('成员删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
action: FILE_UPLOAD_URL,
|
||||
multiple: true,
|
||||
showUploadList: false,
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get(TokenKey) ?? ''}`,
|
||||
},
|
||||
beforeUpload(file) {
|
||||
if (!canSubmitProject) {
|
||||
message.warning('当前无权限上传附件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
message.warning('单个文件不能大于100M');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
setFileUploading(true);
|
||||
return true;
|
||||
},
|
||||
onChange(info) {
|
||||
const hasUploading = info.fileList.some((item) => item.status === 'uploading');
|
||||
if (!hasUploading) {
|
||||
setFileUploading(false);
|
||||
}
|
||||
if (info.file.status === 'done') {
|
||||
const response = info.file.response as UploadResponse | undefined;
|
||||
if (response?.code === 200) {
|
||||
setFileList((prev) => [
|
||||
...prev,
|
||||
{
|
||||
fileName: String(response.originalFilename ?? info.file.name),
|
||||
fileNewName: String(response.newFileName ?? info.file.name),
|
||||
filePath: String(response.filePath ?? ''),
|
||||
fileUrl: String(response.url ?? ''),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
message.error(String(response?.msg ?? '附件上传失败'));
|
||||
}
|
||||
if (info.file.status === 'error') {
|
||||
setFileUploading(false);
|
||||
message.error('附件上传失败');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const removeFile = (file: FileRecord) => {
|
||||
if (!canSubmitProject) {
|
||||
return;
|
||||
}
|
||||
if (file.id !== undefined && file.id !== null && file.id !== '') {
|
||||
setPendingDeletedFileIds((prev) => [...prev, file.id as string | number]);
|
||||
}
|
||||
setFileList((prev) =>
|
||||
prev.filter((item) => String(item.fileNewName ?? item.fileName ?? '') !== String(file.fileNewName ?? file.fileName ?? '')),
|
||||
);
|
||||
};
|
||||
|
||||
const memberColumns: TableColumnsType<MemberRow> = [
|
||||
{ title: '姓名', dataIndex: 'userName', align: 'center' },
|
||||
{
|
||||
title: '项目职位',
|
||||
dataIndex: 'postId',
|
||||
align: 'center',
|
||||
render: (value) => postOptions.find((item) => String(item.dictValue) === String(value ?? ''))?.dictLabel ?? '-',
|
||||
},
|
||||
{ title: '工作天数', dataIndex: 'workTime', align: 'center', width: 120 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 260,
|
||||
render: (_value, record) => (
|
||||
<Space size="small">
|
||||
<Permission permissions="project:detail:editUser" fallback={<ReadonlyAction icon={<EditOutlined />}>编辑</ReadonlyAction>}>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openMemberModal('edit', record)}>
|
||||
编辑
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="project:detail:workLog" fallback={<ReadonlyAction>工作日志</ReadonlyAction>}>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() =>
|
||||
navigate(`/?userId=${encodeURIComponent(String(record.userId ?? ''))}&projectId=${encodeURIComponent(String(id ?? ''))}&nickName=${encodeURIComponent(String(record.userName ?? ''))}`)
|
||||
}
|
||||
disabled={!canViewWorkLog}
|
||||
>
|
||||
工作日志
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="project:detail:deleteUser" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger>删除</ReadonlyAction>}>
|
||||
<Popconfirm title="确定要删除该成员吗?" onConfirm={() => void removeMember(record)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Permission>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const userColumns: TableColumnsType<UserRow> = useMemo(
|
||||
() => [
|
||||
{ title: '姓名', dataIndex: 'nickName', align: 'center' },
|
||||
{ title: '账号', dataIndex: 'userName', align: 'center' },
|
||||
{
|
||||
title: '部门',
|
||||
dataIndex: 'dept',
|
||||
align: 'center',
|
||||
render: (_value, row) => row.dept?.deptName ?? '-',
|
||||
},
|
||||
{ title: '手机号', dataIndex: 'phonenumber', align: 'center' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-container project-detail-page">
|
||||
<div className="project-detail-back">
|
||||
<PageBackButton text="返回项目列表" fallbackPath="/project/list" />
|
||||
</div>
|
||||
|
||||
<div className="project-detail-hero">
|
||||
<div>
|
||||
<div className="project-detail-kicker">PROJECT DETAIL</div>
|
||||
<div className="project-detail-title">{isEdit ? '编辑项目' : '新建项目'}</div>
|
||||
<div className="project-detail-subtitle">维护项目基本信息、负责人、周期与描述,保持项目数据结构统一。</div>
|
||||
<div className="project-detail-subtitle">与线上保持一致,支持基础信息、附件与项目成员维护。</div>
|
||||
</div>
|
||||
<div className="project-detail-summary">
|
||||
<span>{isEdit ? '当前模式:编辑' : '当前模式:新建'}</span>
|
||||
<strong>{projectCode || '待生成编号'}</strong>
|
||||
<strong>{form.getFieldValue('projectCode') || '待生成编号'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="project-detail-card" title={isEdit ? '编辑项目' : '新建项目'}>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="项目名称" name="projectName" rules={[{ required: true, message: '请输入项目名称' }]}>
|
||||
<Input placeholder="请输入项目名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="项目编号" name="projectCode" rules={[{ required: true, message: '请输入项目编号' }]}>
|
||||
<Input placeholder="请输入项目编号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="负责人" name="projectLeaderName" rules={[{ required: true, message: '请选择负责人' }]}>
|
||||
<Input placeholder="请选择负责人" suffix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="预计工时(天)" name="budgetDate">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="开始时间" name="startDate" rules={[{ required: true, message: '请选择开始时间' }]}>
|
||||
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="结束时间" name="endDate" rules={[{ required: true, message: '请选择结束时间' }]}>
|
||||
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="项目描述" name="description">
|
||||
<TextArea rows={4} placeholder="请输入项目描述" />
|
||||
</Form.Item>
|
||||
|
||||
<Form<ProjectFormValues> form={form} layout="vertical" onFinish={submitProject}>
|
||||
<Card className="project-detail-card" title="基础信息" loading={loading}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item label="项目名称" name="projectName" rules={[{ required: true, message: '请输入项目名称' }]}>
|
||||
<Input placeholder="请输入项目名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="项目编码" name="projectCode" rules={[{ required: true, message: '请输入项目编码' }]}>
|
||||
<Input placeholder="请输入项目编码" disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="项目负责人"
|
||||
required
|
||||
validateStatus={form.getFieldError('projectLeader').length ? 'error' : ''}
|
||||
help={form.getFieldError('projectLeader')[0]}
|
||||
>
|
||||
<Input
|
||||
placeholder="选择项目负责人"
|
||||
readOnly
|
||||
value={form.getFieldValue('projectLeaderName') ?? ''}
|
||||
suffix={<UserOutlined />}
|
||||
onClick={() => openUserPicker('manager')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="projectLeader" hidden rules={[{ required: true, message: '请选择项目负责人' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="projectLeaderName" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="项目状态" name="projectState" rules={[{ required: true, message: '请选择项目状态' }]}>
|
||||
<Select
|
||||
placeholder="请选择项目状态"
|
||||
options={statusOptions.map((item) => ({ label: item.dictLabel, value: item.dictValue }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="预计人天" name="budgetDate">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="开始日期"
|
||||
name="startDate"
|
||||
rules={[
|
||||
{ required: true, message: '请选择开始日期' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value: Dayjs | null) {
|
||||
const endDate = getFieldValue('endDate') as Dayjs | null;
|
||||
if (!value || !endDate || !value.isAfter(endDate, 'day')) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('开始日期不能晚于结束日期'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="结束日期"
|
||||
name="endDate"
|
||||
rules={[
|
||||
{ required: true, message: '请选择结束日期' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value: Dayjs | null) {
|
||||
const startDate = getFieldValue('startDate') as Dayjs | null;
|
||||
if (!value || !startDate || !value.isBefore(startDate, 'day')) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('结束日期不能早于开始日期'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="project-detail-card project-detail-upload-card" title="附件">
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Upload {...uploadProps} disabled={!canSubmitProject}>
|
||||
<Button icon={<UploadOutlined />} loading={fileUploading} disabled={!canSubmitProject}>
|
||||
上传附件
|
||||
</Button>
|
||||
</Upload>
|
||||
<div className="project-detail-file-tip">单个附件限制100M</div>
|
||||
<Table<FileRecord>
|
||||
rowKey={(item, index) => String(item.id ?? item.fileNewName ?? item.fileName ?? `file_${index}`)}
|
||||
dataSource={fileList}
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: '附件名称',
|
||||
dataIndex: 'fileNewName',
|
||||
render: (_value, file) => {
|
||||
const fileName = String(file.fileNewName ?? file.fileName ?? '-');
|
||||
const fileUrl = String(file.fileUrl ?? file.url ?? '');
|
||||
return (
|
||||
<Space>
|
||||
{getFileIcon(fileName)}
|
||||
<Button type="link" onClick={() => fileUrl && window.open(fileUrl)}>
|
||||
{fileName}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'fileNewName',
|
||||
width: 120,
|
||||
render: (value, file) => {
|
||||
const fileName = String(value ?? file.fileName ?? '');
|
||||
return fileName.includes('.') ? fileName.split('.').pop() : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (_value, file) => (
|
||||
<Button type="link" danger disabled={!canSubmitProject} onClick={() => removeFile(file)}>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="project-detail-card" title="项目成员">
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Permission permissions="project:detail:addUser">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openMemberModal('add')}>
|
||||
新增成员
|
||||
</Button>
|
||||
</Permission>
|
||||
</Space>
|
||||
<Table<MemberRow>
|
||||
rowKey={(record) => String(record.teamId ?? record.userId ?? '')}
|
||||
columns={memberColumns}
|
||||
dataSource={memberRows}
|
||||
loading={memberLoading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<div className="project-detail-actions">
|
||||
<Button type="primary" htmlType="submit" loading={loading} disabled={!canSubmitProject}>
|
||||
保存
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/project/list')}>取消</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<Modal
|
||||
className="system-admin-modal"
|
||||
title={memberModalMode === 'add' ? '新增项目成员' : '编辑项目成员'}
|
||||
open={memberModalOpen}
|
||||
onOk={() => void saveMember()}
|
||||
onCancel={() => setMemberModalOpen(false)}
|
||||
width={520}
|
||||
forceRender
|
||||
>
|
||||
<Form form={memberForm} layout="vertical">
|
||||
<Form.Item name="userId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="成员" name="userName" rules={[{ required: true, message: '请选择成员' }]}>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="选择成员"
|
||||
suffix={<UserOutlined />}
|
||||
onClick={() => openUserPicker('member')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="项目职位" name="postId" rules={[{ required: true, message: '请选择项目职位' }]}>
|
||||
<Select
|
||||
placeholder="请选择项目职位"
|
||||
options={postOptions.map((item) => ({ label: item.dictLabel, value: item.dictValue }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
className="system-admin-modal"
|
||||
title={userPickerMode === 'manager' ? '选择项目负责人' : '选择项目成员'}
|
||||
open={userPickerOpen}
|
||||
onOk={handlePickUser}
|
||||
onCancel={() => setUserPickerOpen(false)}
|
||||
width={860}
|
||||
forceRender
|
||||
>
|
||||
<Form
|
||||
form={userQueryForm}
|
||||
layout="inline"
|
||||
className="search-form"
|
||||
onFinish={() =>
|
||||
setUserPickerQuery((prev) => ({
|
||||
...prev,
|
||||
...userQueryForm.getFieldsValue(),
|
||||
pageNum: 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Form.Item label="用户名称" name="userName">
|
||||
<Input placeholder="请输入用户名称" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号" name="phonenumber">
|
||||
<Input placeholder="请输入手机号" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} disabled={!canSubmitProject}>
|
||||
{isEdit ? '保 存' : '创 建'}
|
||||
</Button>
|
||||
<Button style={{ marginLeft: 8 }} onClick={() => navigate('/project/list')}>
|
||||
返 回
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Table<UserRow>
|
||||
rowKey="userId"
|
||||
columns={userColumns}
|
||||
dataSource={userPickerRows}
|
||||
loading={userLoading}
|
||||
style={{ marginTop: 12 }}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedUserKeys,
|
||||
onChange: (keys) => setSelectedUserKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: userPickerQuery.pageNum,
|
||||
pageSize: userPickerQuery.pageSize,
|
||||
total: userPickerTotal,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) =>
|
||||
setUserPickerQuery((prev) => ({ ...prev, pageNum: page, pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Form, Input, Select, Button, message, Space, Tag, Popconfirm
|
||||
Table, Form, Input, Select, Button, message, Space, Tag, Popconfirm, Modal
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import {
|
||||
listProject, deleteProject
|
||||
} from '../../api/project';
|
||||
import { listUser } from '@/api/system/user';
|
||||
import { getDicts } from '../../api/system/dict'; // To get dict data for project status
|
||||
import { parseTime } from '../../utils/ruoyi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
|
@ -18,10 +19,22 @@ import './project.css';
|
|||
|
||||
const ProjectPage: React.FC = () => {
|
||||
const [queryForm] = Form.useForm();
|
||||
const [userQueryForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userLoading, setUserLoading] = useState(false);
|
||||
const [projectList, setProjectList] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusList, setStatusList] = useState<any[]>([]);
|
||||
const [userPickerOpen, setUserPickerOpen] = useState(false);
|
||||
const [userRows, setUserRows] = useState<any[]>([]);
|
||||
const [userTotal, setUserTotal] = useState(0);
|
||||
const [selectedUserKeys, setSelectedUserKeys] = useState<React.Key[]>([]);
|
||||
const [userQueryParams, setUserQueryParams] = useState({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
userName: undefined as string | undefined,
|
||||
phonenumber: undefined as string | undefined,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const { hasPermi } = usePermission();
|
||||
|
||||
|
|
@ -84,11 +97,32 @@ const ProjectPage: React.FC = () => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const getUserList = useCallback(async () => {
|
||||
setUserLoading(true);
|
||||
try {
|
||||
const response = await listUser(userQueryParams);
|
||||
const rows = Array.isArray((response as { rows?: any[] }).rows) ? (response as { rows?: any[] }).rows ?? [] : [];
|
||||
setUserRows(rows);
|
||||
setUserTotal(Number((response as { total?: number }).total ?? rows.length));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users for project leader selector:', error);
|
||||
message.error('获取用户列表失败');
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
}
|
||||
}, [userQueryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
getDictData();
|
||||
getList();
|
||||
}, [getDictData, getList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userPickerOpen) {
|
||||
void getUserList();
|
||||
}
|
||||
}, [getUserList, userPickerOpen]);
|
||||
|
||||
const handleQuery = () => {
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({
|
||||
|
|
@ -96,14 +130,15 @@ const ProjectPage: React.FC = () => {
|
|||
pageNum: 1,
|
||||
projectName: values.projectName ?? undefined,
|
||||
projectLeaderName: values.projectLeaderName ?? undefined,
|
||||
// Backward compatibility: some backends use projectLeader as query field.
|
||||
projectLeader: values.projectLeaderName ?? undefined,
|
||||
projectLeader: values.projectLeader ?? undefined,
|
||||
projectState: values.projectState ?? undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.resetFields();
|
||||
userQueryForm.resetFields();
|
||||
setSelectedUserKeys([]);
|
||||
setQueryParams({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
|
|
@ -114,6 +149,41 @@ const ProjectPage: React.FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const openUserPicker = () => {
|
||||
const currentLeader = queryForm.getFieldValue('projectLeader');
|
||||
setSelectedUserKeys(currentLeader ? [currentLeader] : []);
|
||||
userQueryForm.resetFields();
|
||||
setUserQueryParams({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
userName: undefined,
|
||||
phonenumber: undefined,
|
||||
});
|
||||
setUserPickerOpen(true);
|
||||
};
|
||||
|
||||
const handlePickUser = () => {
|
||||
const target = userRows.find((item) => String(item.userId ?? '') === String(selectedUserKeys[0] ?? ''));
|
||||
if (!target) {
|
||||
message.warning('请选择负责人');
|
||||
return;
|
||||
}
|
||||
|
||||
queryForm.setFieldsValue({
|
||||
projectLeader: target.userId,
|
||||
projectLeaderName: String(target.nickName ?? target.userName ?? ''),
|
||||
});
|
||||
setUserPickerOpen(false);
|
||||
};
|
||||
|
||||
const clearProjectLeader = () => {
|
||||
queryForm.setFieldsValue({
|
||||
projectLeader: undefined,
|
||||
projectLeaderName: undefined,
|
||||
});
|
||||
setSelectedUserKeys([]);
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
navigate('/project/detail');
|
||||
};
|
||||
|
|
@ -178,7 +248,7 @@ const ProjectPage: React.FC = () => {
|
|||
) : (
|
||||
<ReadonlyAction icon={<UnorderedListOutlined />}>需求管理</ReadonlyAction>
|
||||
)}
|
||||
{hasPermi('project:list:eidt') ? (
|
||||
{hasPermi(['project:list:edit', 'project:list:eidt']) ? (
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
) : (
|
||||
<ReadonlyAction icon={<EditOutlined />}>编辑</ReadonlyAction>
|
||||
|
|
@ -207,7 +277,17 @@ const ProjectPage: React.FC = () => {
|
|||
<Input placeholder="项目名称" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="负责人" name="projectLeaderName">
|
||||
<Input placeholder="负责人" allowClear suffix={<UserOutlined />} />
|
||||
<Input
|
||||
placeholder="负责人"
|
||||
readOnly
|
||||
allowClear
|
||||
suffix={<UserOutlined />}
|
||||
onClick={openUserPicker}
|
||||
onClear={clearProjectLeader}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="projectLeader" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="项目状态" name="projectState">
|
||||
<Select placeholder="项目状态" allowClear>
|
||||
|
|
@ -246,6 +326,73 @@ const ProjectPage: React.FC = () => {
|
|||
}}
|
||||
scroll={{ x: 1500 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="选择负责人"
|
||||
open={userPickerOpen}
|
||||
onOk={handlePickUser}
|
||||
onCancel={() => setUserPickerOpen(false)}
|
||||
width={860}
|
||||
forceRender
|
||||
>
|
||||
<Form
|
||||
form={userQueryForm}
|
||||
layout="inline"
|
||||
className="search-form"
|
||||
onFinish={() =>
|
||||
setUserQueryParams((prev) => ({
|
||||
...prev,
|
||||
...userQueryForm.getFieldsValue(),
|
||||
pageNum: 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Form.Item label="用户名称" name="userName">
|
||||
<Input placeholder="请输入用户名称" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号" name="phonenumber">
|
||||
<Input placeholder="请输入手机号" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="userId"
|
||||
style={{ marginTop: 12 }}
|
||||
loading={userLoading}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'nickName', align: 'center' },
|
||||
{ title: '账号', dataIndex: 'userName', align: 'center' },
|
||||
{
|
||||
title: '部门',
|
||||
dataIndex: 'dept',
|
||||
align: 'center',
|
||||
render: (_value, row) => row.dept?.deptName ?? '-',
|
||||
},
|
||||
{ title: '手机号', dataIndex: 'phonenumber', align: 'center' },
|
||||
]}
|
||||
dataSource={userRows}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedUserKeys,
|
||||
onChange: (keys) => setSelectedUserKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: userQueryParams.pageNum,
|
||||
pageSize: userQueryParams.pageSize,
|
||||
total: userTotal,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) =>
|
||||
setUserQueryParams((prev) => ({ ...prev, pageNum: page, pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -69,6 +69,25 @@
|
|||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.project-detail-upload-card .ant-card-body,
|
||||
.project-detail-card .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-detail-file-tip {
|
||||
color: #7d8cab;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-detail-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.project-detail-hero {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import dayjs from 'dayjs';
|
|||
import { parseTime } from '../../utils/ruoyi';
|
||||
import Permission from '@/components/Permission';
|
||||
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
|
||||
import { usePermission } from '@/contexts/PermissionContext';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
|
|
@ -47,8 +46,6 @@ type ConfigRecord = {
|
|||
|
||||
const ConfigPage: React.FC = () => {
|
||||
const { message, modal } = App.useApp();
|
||||
const { canAccessPath } = usePermission();
|
||||
const canOperateConfig = canAccessPath('/system/config');
|
||||
const [queryForm] = Form.useForm();
|
||||
const [configForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -96,7 +93,14 @@ const ConfigPage: React.FC = () => {
|
|||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: 1 }));
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
configName: values.configName || undefined,
|
||||
configKey: values.configKey || undefined,
|
||||
configType: values.configType || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
|
|
@ -237,10 +241,10 @@ const ConfigPage: React.FC = () => {
|
|||
title: '操作', key: 'operation', align: 'center', width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'} fallback={<ReadonlyAction icon={<EditOutlined />}>修改</ReadonlyAction>}>
|
||||
<Permission permissions="system:config:edit" fallback={<ReadonlyAction icon={<EditOutlined />}>修改</ReadonlyAction>}>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}>修改</Button>
|
||||
</Permission>
|
||||
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'} fallback={<ReadonlyAction icon={<DeleteOutlined />} danger>删除</ReadonlyAction>}>
|
||||
<Permission permissions="system:config:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger>删除</ReadonlyAction>}>
|
||||
<Popconfirm
|
||||
title="是否确认删除该参数?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
|
|
@ -286,19 +290,19 @@ const ConfigPage: React.FC = () => {
|
|||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
|
||||
<Permission permissions="system:config:add">
|
||||
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>新增</Button>
|
||||
</Permission>
|
||||
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
|
||||
<Permission permissions="system:config:edit">
|
||||
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate(selectedRowKeys)}>修改</Button>
|
||||
</Permission>
|
||||
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
|
||||
<Permission permissions="system:config:remove">
|
||||
<Button danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}>删除</Button>
|
||||
</Permission>
|
||||
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
|
||||
<Permission permissions="system:config:export">
|
||||
<Button ghost icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
</Permission>
|
||||
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
|
||||
<Permission permissions="system:config:remove">
|
||||
<Button danger ghost icon={<SyncOutlined />} onClick={handleRefreshCache}>刷新缓存</Button>
|
||||
</Permission>
|
||||
</Space>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,448 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { saveAs } from 'file-saver';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
addDictData,
|
||||
delDictData,
|
||||
getDictData,
|
||||
getDictType,
|
||||
listDictData,
|
||||
updateDictData,
|
||||
} from '@/api/system/dict';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import Permission from '@/components/Permission';
|
||||
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import './system-admin.css';
|
||||
|
||||
interface DictDataRecord {
|
||||
dictCode?: number | string;
|
||||
dictLabel?: string;
|
||||
dictValue?: string;
|
||||
dictType?: string;
|
||||
dictSort?: number | string;
|
||||
cssClass?: string;
|
||||
listClass?: string;
|
||||
isDefault?: string;
|
||||
status?: string;
|
||||
remark?: string;
|
||||
createTime?: string | number | Date;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '0', label: '正常' },
|
||||
{ value: '1', label: '停用' },
|
||||
];
|
||||
|
||||
const defaultQueryParams = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
dictLabel: undefined as string | undefined,
|
||||
status: undefined as string | undefined,
|
||||
};
|
||||
|
||||
const DictDataPage = () => {
|
||||
const { message } = App.useApp();
|
||||
const { dictId = '' } = useParams();
|
||||
const [queryForm] = Form.useForm();
|
||||
const [dictDataForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
const [dictTypeName, setDictTypeName] = useState('');
|
||||
const [dictType, setDictType] = useState('');
|
||||
const [dictDataList, setDictDataList] = useState<DictDataRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [currentRecord, setCurrentRecord] = useState<DictDataRecord>({});
|
||||
const [queryParams, setQueryParams] = useState(defaultQueryParams);
|
||||
|
||||
const currentDictId = useMemo(() => Number(dictId) || dictId, [dictId]);
|
||||
|
||||
const loadDictType = useCallback(async () => {
|
||||
if (!currentDictId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getDictType(currentDictId);
|
||||
const detail = (response && typeof response === 'object' && 'data' in response
|
||||
? response.data
|
||||
: response) as { dictType?: string; dictName?: string } | undefined;
|
||||
setDictType(detail?.dictType ?? '');
|
||||
setDictTypeName(detail?.dictName ?? '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load dict type detail:', error);
|
||||
message.error('获取字典类型信息失败');
|
||||
}
|
||||
}, [currentDictId, message]);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
if (!dictType) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listDictData({
|
||||
...queryParams,
|
||||
dictType,
|
||||
}) as { rows?: DictDataRecord[]; total?: number };
|
||||
setDictDataList(response.rows ?? []);
|
||||
setTotal(Number(response.total ?? 0));
|
||||
} catch (error) {
|
||||
console.error('Failed to load dict data list:', error);
|
||||
message.error('获取字典数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dictType, message, queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDictType();
|
||||
}, [loadDictType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dictType) {
|
||||
void loadList();
|
||||
}
|
||||
}, [dictType, loadList]);
|
||||
|
||||
const handleAdd = () => {
|
||||
setCurrentRecord({});
|
||||
dictDataForm.resetFields();
|
||||
dictDataForm.setFieldsValue({
|
||||
dictType,
|
||||
status: '0',
|
||||
isDefault: 'N',
|
||||
dictSort: 0,
|
||||
});
|
||||
setModalTitle('添加字典数据');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = async (record?: DictDataRecord) => {
|
||||
const targetId = record?.dictCode ?? selectedRowKeys[0];
|
||||
if (targetId === undefined) {
|
||||
message.warning('请选择要修改的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getDictData(targetId as string | number);
|
||||
const detail = (response && typeof response === 'object' && 'data' in response
|
||||
? response.data
|
||||
: response) as DictDataRecord | undefined;
|
||||
setCurrentRecord(detail ?? {});
|
||||
dictDataForm.setFieldsValue({
|
||||
...detail,
|
||||
status: String(detail?.status ?? '0'),
|
||||
isDefault: String(detail?.isDefault ?? 'N'),
|
||||
});
|
||||
setModalTitle('修改字典数据');
|
||||
setModalVisible(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dict data detail:', error);
|
||||
message.error('获取字典数据详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record?: DictDataRecord) => {
|
||||
const ids = record?.dictCode !== undefined ? [record.dictCode] : selectedRowKeys;
|
||||
if (ids.length === 0) {
|
||||
message.warning('请选择要删除的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await delDictData(ids.join(','));
|
||||
message.success('删除成功');
|
||||
setSelectedRowKeys([]);
|
||||
await loadList();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete dict data:', error);
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const hide = message.loading('正在导出数据...', 0);
|
||||
try {
|
||||
const response = await listDictData({
|
||||
...queryParams,
|
||||
dictType,
|
||||
pageNum: undefined,
|
||||
pageSize: undefined,
|
||||
}) as { rows?: DictDataRecord[] };
|
||||
const rows = response.rows ?? [];
|
||||
const csvContent = [
|
||||
['字典编码', '字典标签', '字典键值', '字典排序', '状态', '默认', '创建时间'],
|
||||
...rows.map((item) => [
|
||||
item.dictCode,
|
||||
item.dictLabel,
|
||||
item.dictValue,
|
||||
item.dictSort,
|
||||
String(item.status ?? '') === '0' ? '正常' : '停用',
|
||||
item.isDefault,
|
||||
parseTime(item.createTime),
|
||||
]),
|
||||
]
|
||||
.map((row) => row.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
saveAs(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }), `dict_data_${dayjs().format('YYYYMMDDHHmmss')}.csv`);
|
||||
hide();
|
||||
message.success('导出成功');
|
||||
} catch (error) {
|
||||
hide();
|
||||
console.error('Failed to export dict data:', error);
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
const values = await dictDataForm.validateFields();
|
||||
const payload = {
|
||||
...currentRecord,
|
||||
...values,
|
||||
dictType,
|
||||
};
|
||||
if (payload.dictCode !== undefined) {
|
||||
await updateDictData(payload);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await addDictData(payload);
|
||||
message.success('新增成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
await loadList();
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'errorFields' in error) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to submit dict data form:', error);
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<DictDataRecord> = [
|
||||
{ title: '字典编码', dataIndex: 'dictCode', align: 'center', width: 120 },
|
||||
{ title: '字典标签', dataIndex: 'dictLabel', align: 'center' },
|
||||
{ title: '字典键值', dataIndex: 'dictValue', align: 'center' },
|
||||
{ title: '排序', dataIndex: 'dictSort', align: 'center', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (value) => (
|
||||
<Tag color={String(value ?? '') === '0' ? 'green' : 'red'}>
|
||||
{String(value ?? '') === '0' ? '正常' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '默认', dataIndex: 'isDefault', align: 'center', width: 100 },
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (value) => parseTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (_value, record) => (
|
||||
<Space size="small">
|
||||
<Permission permissions="system:dict:edit" fallback={<ReadonlyAction icon={<EditOutlined />}>修改</ReadonlyAction>}>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => void handleEdit(record)}>
|
||||
修改
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="system:dict:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger>删除</ReadonlyAction>}>
|
||||
<Popconfirm title="确认删除该字典数据吗?" onConfirm={() => void handleDelete(record)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Permission>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container dict-page-container">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space wrap style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Space wrap>
|
||||
<PageBackButton fallbackPath="/system/dict" />
|
||||
<strong>{dictTypeName || '字典数据'}</strong>
|
||||
<Tag>{dictType || '-'}</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={queryForm}
|
||||
layout="inline"
|
||||
className="search-form"
|
||||
onFinish={() =>
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
...queryForm.getFieldsValue(),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Form.Item label="字典标签" name="dictLabel">
|
||||
<Input placeholder="请输入字典标签" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="请选择状态" allowClear style={{ width: 160 }}>
|
||||
{statusOptions.map((item) => (
|
||||
<Select.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
queryForm.resetFields();
|
||||
setQueryParams(defaultQueryParams);
|
||||
}}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Permission permissions="system:dict:add">
|
||||
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="system:dict:edit">
|
||||
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => void handleEdit()}>
|
||||
修改
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="system:dict:remove">
|
||||
<Button danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => void handleDelete()}>
|
||||
删除
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="system:dict:export">
|
||||
<Button ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
|
||||
导出
|
||||
</Button>
|
||||
</Permission>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="dictCode"
|
||||
columns={columns}
|
||||
dataSource={dictDataList}
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) => setQueryParams((prev) => ({ ...prev, pageNum: page, pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
className="system-admin-modal"
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onOk={() => void submitForm()}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={560}
|
||||
forceRender
|
||||
>
|
||||
<Form form={dictDataForm} labelCol={{ span: 5 }} wrapperCol={{ span: 16 }}>
|
||||
<Form.Item name="dictCode" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="字典标签" name="dictLabel" rules={[{ required: true, message: '请输入字典标签' }]}>
|
||||
<Input placeholder="请输入字典标签" />
|
||||
</Form.Item>
|
||||
<Form.Item label="字典键值" name="dictValue" rules={[{ required: true, message: '请输入字典键值' }]}>
|
||||
<Input placeholder="请输入字典键值" />
|
||||
</Form.Item>
|
||||
<Form.Item label="字典排序" name="dictSort" rules={[{ required: true, message: '请输入字典排序' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Radio.Group>
|
||||
{statusOptions.map((item) => (
|
||||
<Radio key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="默认" name="isDefault">
|
||||
<Radio.Group>
|
||||
<Radio value="Y">是</Radio>
|
||||
<Radio value="N">否</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="样式属性" name="cssClass">
|
||||
<Input placeholder="请输入样式属性" />
|
||||
</Form.Item>
|
||||
<Form.Item label="回显样式" name="listClass">
|
||||
<Input placeholder="请输入回显样式" />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<Input.TextArea placeholder="请输入备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DictDataPage;
|
||||
|
|
@ -17,7 +17,6 @@ import { parseTime } from '../../utils/ruoyi';
|
|||
import { Link } from 'react-router-dom';
|
||||
import Permission from '@/components/Permission';
|
||||
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
|
||||
import { usePermission } from '@/contexts/PermissionContext';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
dayjs.locale('zh-cn');
|
||||
|
|
@ -50,7 +49,6 @@ type DictTypeRecord = {
|
|||
|
||||
const DictPage: React.FC = () => {
|
||||
const { message, modal } = App.useApp();
|
||||
const { canAccessPath } = usePermission();
|
||||
const [queryForm] = Form.useForm();
|
||||
const [dictForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -98,7 +96,14 @@ const DictPage: React.FC = () => {
|
|||
}, [getList]);
|
||||
|
||||
const handleQuery = () => {
|
||||
setQueryParams(prev => ({ ...prev, pageNum: 1 }));
|
||||
const values = queryForm.getFieldsValue();
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
pageNum: 1,
|
||||
dictName: values.dictName || undefined,
|
||||
dictType: values.dictType || undefined,
|
||||
status: values.status || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
|
|
@ -306,7 +311,7 @@ const DictPage: React.FC = () => {
|
|||
<Permission permissions="system:dict:export">
|
||||
<Button ghost icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
</Permission>
|
||||
<Permission permissions={canAccessPath('/system/dict') ? undefined : '__forbidden__'}>
|
||||
<Permission permissions="system:dict:remove">
|
||||
<Button danger ghost icon={<SyncOutlined />} onClick={handleRefreshCache}>刷新缓存</Button>
|
||||
</Permission>
|
||||
</Space>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,458 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
allocatedUserList,
|
||||
authUserCancel,
|
||||
authUserCancelAll,
|
||||
authUserSelectAll,
|
||||
getRole,
|
||||
unallocatedUserList,
|
||||
} from '@/api/system/role';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import './system-admin.css';
|
||||
|
||||
interface UserRecord {
|
||||
userId?: number | string;
|
||||
userName?: string;
|
||||
nickName?: string;
|
||||
phonenumber?: string;
|
||||
status?: string;
|
||||
dept?: {
|
||||
deptName?: string;
|
||||
};
|
||||
createTime?: string | number | Date;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RoleRecord {
|
||||
roleId?: number | string;
|
||||
roleName?: string;
|
||||
roleKey?: string;
|
||||
dataScope?: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UserQueryParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
roleId?: number | string;
|
||||
userName?: string;
|
||||
phonenumber?: string;
|
||||
}
|
||||
|
||||
const defaultQueryParams: UserQueryParams = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
userName: undefined,
|
||||
phonenumber: undefined,
|
||||
};
|
||||
|
||||
const statusTag = (value?: string) => (
|
||||
<Tag color={String(value ?? '') === '0' ? 'green' : 'red'}>
|
||||
{String(value ?? '') === '0' ? '正常' : '停用'}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
const RoleAuthUserPage = () => {
|
||||
const { message, modal } = App.useApp();
|
||||
const { roleId = '' } = useParams();
|
||||
const [allocatedForm] = Form.useForm<UserQueryParams>();
|
||||
const [unallocatedForm] = Form.useForm<UserQueryParams>();
|
||||
const [activeTab, setActiveTab] = useState<'allocated' | 'unallocated'>('allocated');
|
||||
const [roleInfo, setRoleInfo] = useState<RoleRecord>({});
|
||||
const [allocatedLoading, setAllocatedLoading] = useState(false);
|
||||
const [unallocatedLoading, setUnallocatedLoading] = useState(false);
|
||||
const [allocatedRows, setAllocatedRows] = useState<UserRecord[]>([]);
|
||||
const [unallocatedRows, setUnallocatedRows] = useState<UserRecord[]>([]);
|
||||
const [allocatedTotal, setAllocatedTotal] = useState(0);
|
||||
const [unallocatedTotal, setUnallocatedTotal] = useState(0);
|
||||
const [selectedAllocatedKeys, setSelectedAllocatedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedUnallocatedKeys, setSelectedUnallocatedKeys] = useState<React.Key[]>([]);
|
||||
const [allocatedQuery, setAllocatedQuery] = useState<UserQueryParams>(defaultQueryParams);
|
||||
const [unallocatedQuery, setUnallocatedQuery] = useState<UserQueryParams>(defaultQueryParams);
|
||||
|
||||
const currentRoleId = useMemo(() => Number(roleId) || roleId, [roleId]);
|
||||
|
||||
const loadRoleInfo = useCallback(async () => {
|
||||
if (!currentRoleId) {
|
||||
message.warning('角色编号缺失');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await getRole(currentRoleId);
|
||||
const detail = (response && typeof response === 'object' && 'data' in response
|
||||
? response.data
|
||||
: response) as RoleRecord | undefined;
|
||||
setRoleInfo(detail ?? {});
|
||||
} catch (error) {
|
||||
console.error('Failed to load role detail:', error);
|
||||
message.error('获取角色信息失败');
|
||||
}
|
||||
}, [currentRoleId, message]);
|
||||
|
||||
const loadAllocated = useCallback(async () => {
|
||||
if (!currentRoleId) {
|
||||
return;
|
||||
}
|
||||
setAllocatedLoading(true);
|
||||
try {
|
||||
const response = await allocatedUserList({
|
||||
...allocatedQuery,
|
||||
roleId: currentRoleId,
|
||||
}) as { rows?: UserRecord[]; total?: number };
|
||||
setAllocatedRows(response.rows ?? []);
|
||||
setAllocatedTotal(Number(response.total ?? 0));
|
||||
} catch (error) {
|
||||
console.error('Failed to load allocated users:', error);
|
||||
message.error('获取已授权用户失败');
|
||||
} finally {
|
||||
setAllocatedLoading(false);
|
||||
}
|
||||
}, [allocatedQuery, currentRoleId, message]);
|
||||
|
||||
const loadUnallocated = useCallback(async () => {
|
||||
if (!currentRoleId) {
|
||||
return;
|
||||
}
|
||||
setUnallocatedLoading(true);
|
||||
try {
|
||||
const response = await unallocatedUserList({
|
||||
...unallocatedQuery,
|
||||
roleId: currentRoleId,
|
||||
}) as { rows?: UserRecord[]; total?: number };
|
||||
setUnallocatedRows(response.rows ?? []);
|
||||
setUnallocatedTotal(Number(response.total ?? 0));
|
||||
} catch (error) {
|
||||
console.error('Failed to load unallocated users:', error);
|
||||
message.error('获取未授权用户失败');
|
||||
} finally {
|
||||
setUnallocatedLoading(false);
|
||||
}
|
||||
}, [currentRoleId, message, unallocatedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoleInfo();
|
||||
}, [loadRoleInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAllocated();
|
||||
}, [loadAllocated]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUnallocated();
|
||||
}, [loadUnallocated]);
|
||||
|
||||
const refreshBoth = async () => {
|
||||
await Promise.all([loadAllocated(), loadUnallocated(), loadRoleInfo()]);
|
||||
};
|
||||
|
||||
const handleCancelOne = (record: UserRecord) => {
|
||||
if (record.userId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.confirm({
|
||||
title: '确认取消授权',
|
||||
content: `确认取消用户"${record.userName ?? ''}"的角色授权吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await authUserCancel({
|
||||
roleId: currentRoleId,
|
||||
userId: record.userId,
|
||||
});
|
||||
message.success('取消授权成功');
|
||||
await refreshBoth();
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel role auth:', error);
|
||||
message.error('取消授权失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelBatch = async () => {
|
||||
if (selectedAllocatedKeys.length === 0) {
|
||||
message.warning('请选择要取消授权的用户');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authUserCancelAll({
|
||||
roleId: currentRoleId,
|
||||
userIds: selectedAllocatedKeys.join(','),
|
||||
});
|
||||
message.success('批量取消授权成功');
|
||||
setSelectedAllocatedKeys([]);
|
||||
await refreshBoth();
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel users in batch:', error);
|
||||
message.error('批量取消授权失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignSelected = async () => {
|
||||
if (selectedUnallocatedKeys.length === 0) {
|
||||
message.warning('请选择要分配的用户');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authUserSelectAll({
|
||||
roleId: currentRoleId,
|
||||
userIds: selectedUnallocatedKeys.join(','),
|
||||
});
|
||||
message.success('分配用户成功');
|
||||
setSelectedUnallocatedKeys([]);
|
||||
setActiveTab('allocated');
|
||||
await refreshBoth();
|
||||
} catch (error) {
|
||||
console.error('Failed to assign users:', error);
|
||||
message.error('分配用户失败');
|
||||
}
|
||||
};
|
||||
|
||||
const userColumns: TableColumnsType<UserRecord> = [
|
||||
{ title: '用户编号', dataIndex: 'userId', align: 'center', width: 100 },
|
||||
{ title: '用户名称', dataIndex: 'userName', align: 'center' },
|
||||
{ title: '用户昵称', dataIndex: 'nickName', align: 'center' },
|
||||
{
|
||||
title: '部门',
|
||||
dataIndex: 'dept',
|
||||
align: 'center',
|
||||
render: (_value, record) => record.dept?.deptName ?? '-',
|
||||
},
|
||||
{ title: '手机号码', dataIndex: 'phonenumber', align: 'center' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (value) => statusTag(String(value ?? '')),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (value) => parseTime(value),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container role-page-container">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space wrap style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Space wrap>
|
||||
<PageBackButton fallbackPath="/system/role" />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void refreshBoth()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Card title="角色信息">
|
||||
<Space wrap size={[24, 12]}>
|
||||
<span>角色名称:{roleInfo.roleName ?? '-'}</span>
|
||||
<span>权限字符:{roleInfo.roleKey ?? '-'}</span>
|
||||
<span>数据范围:{roleInfo.dataScope ?? '-'}</span>
|
||||
<span>状态:{statusTag(roleInfo.status)}</span>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as 'allocated' | 'unallocated')}
|
||||
items={[
|
||||
{
|
||||
key: 'allocated',
|
||||
label: '已授权用户',
|
||||
children: (
|
||||
<Card>
|
||||
<Form
|
||||
form={allocatedForm}
|
||||
layout="inline"
|
||||
className="search-form"
|
||||
onFinish={() =>
|
||||
setAllocatedQuery((prev) => ({
|
||||
...prev,
|
||||
...allocatedForm.getFieldsValue(),
|
||||
pageNum: 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Form.Item label="用户名称" name="userName">
|
||||
<Input placeholder="请输入用户名称" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号码" name="phonenumber">
|
||||
<Input placeholder="请输入手机号码" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
allocatedForm.resetFields();
|
||||
setAllocatedQuery(defaultQueryParams);
|
||||
}}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button icon={<PlusOutlined />} onClick={() => setActiveTab('unallocated')}>
|
||||
添加用户
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedAllocatedKeys.length === 0}
|
||||
onClick={() => void handleCancelBatch()}
|
||||
>
|
||||
批量取消授权
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="userId"
|
||||
columns={[
|
||||
...userColumns,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
render: (_value, record) => (
|
||||
<Button type="link" danger onClick={() => handleCancelOne(record)}>
|
||||
取消授权
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
dataSource={allocatedRows}
|
||||
loading={allocatedLoading}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedAllocatedKeys,
|
||||
onChange: (keys) => setSelectedAllocatedKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: allocatedQuery.pageNum,
|
||||
pageSize: allocatedQuery.pageSize,
|
||||
total: allocatedTotal,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) =>
|
||||
setAllocatedQuery((prev) => ({ ...prev, pageNum: page, pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'unallocated',
|
||||
label: '可分配用户',
|
||||
children: (
|
||||
<Card>
|
||||
<Form
|
||||
form={unallocatedForm}
|
||||
layout="inline"
|
||||
className="search-form"
|
||||
onFinish={() =>
|
||||
setUnallocatedQuery((prev) => ({
|
||||
...prev,
|
||||
...unallocatedForm.getFieldsValue(),
|
||||
pageNum: 1,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Form.Item label="用户名称" name="userName">
|
||||
<Input placeholder="请输入用户名称" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号码" name="phonenumber">
|
||||
<Input placeholder="请输入手机号码" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
unallocatedForm.resetFields();
|
||||
setUnallocatedQuery(defaultQueryParams);
|
||||
}}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space className="mb8">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
disabled={selectedUnallocatedKeys.length === 0}
|
||||
onClick={() => void handleAssignSelected()}
|
||||
>
|
||||
分配选中用户
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="userId"
|
||||
columns={userColumns}
|
||||
dataSource={unallocatedRows}
|
||||
loading={unallocatedLoading}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedUnallocatedKeys,
|
||||
onChange: (keys) => setSelectedUnallocatedKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: unallocatedQuery.pageNum,
|
||||
pageSize: unallocatedQuery.pageSize,
|
||||
total: unallocatedTotal,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
onChange: (page, pageSize) =>
|
||||
setUnallocatedQuery((prev) => ({ ...prev, pageNum: page, pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleAuthUserPage;
|
||||
|
|
@ -16,6 +16,8 @@ import dayjs from 'dayjs';
|
|||
import { parseTime } from '../../utils/ruoyi'; // Custom utility
|
||||
import Permission from '@/components/Permission';
|
||||
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './system-admin.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
|
|
@ -55,6 +57,7 @@ type RoleRecord = {
|
|||
|
||||
const RolePage: React.FC = () => {
|
||||
const { message, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [queryForm] = Form.useForm();
|
||||
const [roleForm] = Form.useForm();
|
||||
const [dataScopeForm] = Form.useForm();
|
||||
|
|
@ -400,8 +403,7 @@ const RolePage: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleAuthUser = (record: any) => {
|
||||
message.info(`跳转到角色"${record.roleName}"的用户分配页面`);
|
||||
// navigate(`/system/role-auth/user/${record.roleId}`); // Uncomment when this page is migrated
|
||||
navigate(`/system/role-auth/user/${record.roleId}`);
|
||||
};
|
||||
|
||||
const submitRoleForm = async (values: any) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { App, Button, Card, Descriptions, Space, Table, Tag } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getAuthRole, getUser, updateAuthRole } from '@/api/system/user';
|
||||
import PageBackButton from '@/components/PageBackButton';
|
||||
import { parseTime } from '@/utils/ruoyi';
|
||||
import './system-admin.css';
|
||||
|
||||
interface RoleRecord {
|
||||
roleId?: number | string;
|
||||
roleName?: string;
|
||||
roleKey?: string;
|
||||
roleSort?: number | string;
|
||||
status?: string;
|
||||
createTime?: string | number | Date;
|
||||
flag?: boolean;
|
||||
admin?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UserRecord {
|
||||
userId?: number | string;
|
||||
userName?: string;
|
||||
nickName?: string;
|
||||
dept?: {
|
||||
deptName?: string;
|
||||
};
|
||||
phonenumber?: string;
|
||||
email?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const UserAuthRolePage = () => {
|
||||
const { message } = App.useApp();
|
||||
const { userId = '' } = useParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<UserRecord>({});
|
||||
const [roleList, setRoleList] = useState<RoleRecord[]>([]);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<React.Key[]>([]);
|
||||
|
||||
const currentUserId = useMemo(() => Number(userId) || userId, [userId]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!currentUserId) {
|
||||
message.warning('用户编号缺失');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [userResponse, roleResponse] = await Promise.all([
|
||||
getUser(currentUserId),
|
||||
getAuthRole(currentUserId),
|
||||
]);
|
||||
|
||||
const detail = (userResponse && typeof userResponse === 'object' && 'data' in userResponse
|
||||
? userResponse.data
|
||||
: userResponse) as UserRecord | undefined;
|
||||
const roles = Array.isArray((roleResponse as { roles?: RoleRecord[] })?.roles)
|
||||
? (roleResponse as { roles?: RoleRecord[] }).roles ?? []
|
||||
: [];
|
||||
|
||||
setUserInfo(detail ?? {});
|
||||
setRoleList(roles);
|
||||
setSelectedRoleIds(
|
||||
roles
|
||||
.filter((item) => item.flag && item.roleId !== undefined)
|
||||
.map((item) => item.roleId as React.Key),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user auth role data:', error);
|
||||
message.error('加载用户角色信息失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentUserId, message]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAuthRole({
|
||||
userId: currentUserId,
|
||||
roleIds: selectedRoleIds.join(','),
|
||||
});
|
||||
message.success('分配角色成功');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to save user roles:', error);
|
||||
message.error('分配角色失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<RoleRecord> = [
|
||||
{ title: '角色编号', dataIndex: 'roleId', align: 'center', width: 100 },
|
||||
{ title: '角色名称', dataIndex: 'roleName', align: 'center' },
|
||||
{ title: '权限字符', dataIndex: 'roleKey', align: 'center' },
|
||||
{ title: '显示顺序', dataIndex: 'roleSort', align: 'center', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (value) => (
|
||||
<Tag color={String(value ?? '') === '0' ? 'green' : 'red'}>
|
||||
{String(value ?? '') === '0' ? '正常' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (value) => parseTime(value),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container user-page-container">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space wrap style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Space wrap>
|
||||
<PageBackButton fallbackPath="/system/user" />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
<Button type="primary" icon={<CheckOutlined />} onClick={() => void handleSave()} loading={saving}>
|
||||
保存角色分配
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Card title="用户信息" loading={loading}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="用户名称">{userInfo.userName ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户昵称">{userInfo.nickName ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="归属部门">{userInfo.dept?.deptName ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号码">{userInfo.phonenumber ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{userInfo.email ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户编号">{userInfo.userId ?? '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="角色分配" loading={loading}>
|
||||
<Table
|
||||
rowKey="roleId"
|
||||
columns={columns}
|
||||
dataSource={roleList}
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRoleIds,
|
||||
onChange: (keys) => setSelectedRoleIds(keys),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: String(record.status ?? '') !== '0',
|
||||
}),
|
||||
}}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAuthRolePage;
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listUser,
|
||||
|
|
@ -34,6 +35,8 @@ import {
|
|||
import dayjs from 'dayjs';
|
||||
import Permission from '@/components/Permission';
|
||||
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './system-admin.css';
|
||||
|
||||
interface UserDept {
|
||||
deptName?: string;
|
||||
|
|
@ -109,6 +112,7 @@ const getStatusTag = (status?: string) => {
|
|||
|
||||
const UserPage = () => {
|
||||
const { message, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [queryForm] = Form.useForm<UserQueryParams>();
|
||||
const [userForm] = Form.useForm<UserFormValues>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -274,6 +278,15 @@ const UserPage = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleAuthRole = (record?: UserRow) => {
|
||||
const targetUserId = normalizeRowKeyToId(record?.userId ?? selectedRowKeys[0]);
|
||||
if (targetUserId === undefined) {
|
||||
message.warning('请选择要分配角色的用户');
|
||||
return;
|
||||
}
|
||||
navigate(`/system/user-auth/role/${targetUserId}`);
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
const values = await userForm.validateFields();
|
||||
|
|
@ -350,7 +363,7 @@ const UserPage = () => {
|
|||
title: '操作',
|
||||
key: 'operation',
|
||||
align: 'center',
|
||||
width: 280,
|
||||
width: 360,
|
||||
render: (_value, record) => (
|
||||
<Space size="small">
|
||||
<Permission permissions="system:user:edit" fallback={<ReadonlyAction icon={<EditOutlined />}>修改</ReadonlyAction>}>
|
||||
|
|
@ -368,6 +381,11 @@ const UserPage = () => {
|
|||
重置密码
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="system:user:edit" fallback={<ReadonlyAction icon={<SafetyCertificateOutlined />}>分配角色</ReadonlyAction>}>
|
||||
<Button type="link" icon={<SafetyCertificateOutlined />} onClick={() => handleAuthRole(record)}>
|
||||
分配角色
|
||||
</Button>
|
||||
</Permission>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
|
@ -417,6 +435,11 @@ const UserPage = () => {
|
|||
删除
|
||||
</Button>
|
||||
</Permission>
|
||||
<Permission permissions="system:user:edit">
|
||||
<Button type="primary" ghost icon={<SafetyCertificateOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleAuthRole()}>
|
||||
分配角色
|
||||
</Button>
|
||||
</Permission>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
|
|
|
|||
|
|
@ -3,17 +3,22 @@
|
|||
import LoginPage from './pages/Login';
|
||||
|
||||
import JobMonitorPage from './pages/monitor/JobMonitorPage';
|
||||
import JobLogPage from './pages/monitor/JobLogPage';
|
||||
import CacheMonitorPage from './pages/monitor/CacheMonitorPage';
|
||||
import DruidMonitorPage from './pages/monitor/DruidMonitorPage';
|
||||
import LoginLogPage from './pages/monitor/LoginLogPage'; // Import LoginLogPage
|
||||
import OnlineUserPage from './pages/monitor/OnlineUserPage'; // Import OnlineUserPage
|
||||
import OperationLogPage from './pages/monitor/OperationLogPage'; // Import OperationLogPage
|
||||
import ServerMonitorPage from './pages/monitor/ServerMonitorPage'; // Import ServerMonitorPage
|
||||
import CacheListPage from './pages/monitor/CacheListPage'; // Import CacheListPage
|
||||
import UserPage from './pages/system/UserPage'; // Import UserPage
|
||||
import UserAuthRolePage from './pages/system/UserAuthRolePage';
|
||||
import RolePage from './pages/system/RolePage'; // Import RolePage
|
||||
import RoleAuthUserPage from './pages/system/RoleAuthUserPage';
|
||||
import MenuPage from './pages/system/MenuPage'; // Import MenuPage
|
||||
import DeptPage from './pages/system/DeptPage'; // Import DeptPage
|
||||
import DictPage from './pages/system/DictPage'; // Import DictPage
|
||||
import DictDataPage from './pages/system/DictDataPage';
|
||||
import ConfigPage from './pages/system/ConfigPage'; // Import ConfigPage
|
||||
import ProjectPage from './pages/project/ProjectPage'; // Import ProjectPage
|
||||
import ProjectDetailPage from './pages/project/ProjectDetailPage'; // Import ProjectDetailPage
|
||||
|
|
@ -47,20 +52,26 @@ export const routeMapper = {
|
|||
'/user/profile': ProfilePage,
|
||||
'/profile': ProfilePage,
|
||||
'/monitor/cache': CacheMonitorPage,
|
||||
'/monitor/druid': DruidMonitorPage,
|
||||
'/monitor/job': JobMonitorPage,
|
||||
'/monitor/job-log/index/:jobId': JobLogPage,
|
||||
'/monitor/logininfor': LoginLogPage, // Add LoginLogPage route
|
||||
'/monitor/online': OnlineUserPage, // Add OnlineUserPage route
|
||||
'/monitor/operlog': OperationLogPage, // Add OperationLogPage route
|
||||
'/monitor/server': ServerMonitorPage, // Add ServerMonitorPage route
|
||||
'/monitor/cacheList': CacheListPage, // Add CacheListPage route
|
||||
'/system/user': UserPage, // Add UserPage route
|
||||
'/system/user-auth/role/:userId': UserAuthRolePage,
|
||||
'/system/role': RolePage, // Add RolePage route
|
||||
'/system/role-auth/user/:roleId': RoleAuthUserPage,
|
||||
'/system/menu': MenuPage, // Add MenuPage route
|
||||
'/system/dept': DeptPage, // Add DeptPage route
|
||||
'/system/dict': DictPage, // Add DictPage route
|
||||
'/system/dict-data/index/:dictId': DictDataPage,
|
||||
'/system/config': ConfigPage, // Add ConfigPage route
|
||||
'/project/list': ProjectPage, // Add ProjectPage route
|
||||
'/project/detail': ProjectDetailPage, // Add ProjectDetailPage route
|
||||
'/project/demandManage': DemandManagePage, // Add DemandManagePage route
|
||||
'/demandManage': DemandManagePage, // Add DemandManagePage route
|
||||
'/workAppraisal/manager': ManagerPage,
|
||||
'/workAppraisal/normalWorker': NormalWorkerPage,
|
||||
|
|
@ -135,11 +146,21 @@ export const routes = [
|
|||
component: CacheMonitorPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/monitor/druid',
|
||||
component: DruidMonitorPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/monitor/job',
|
||||
component: JobMonitorPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/monitor/job-log/index/:jobId',
|
||||
component: JobLogPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/monitor/logininfor',
|
||||
component: LoginLogPage,
|
||||
|
|
@ -170,11 +191,21 @@ export const routes = [
|
|||
component: UserPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/system/user-auth/role/:userId',
|
||||
component: UserAuthRolePage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/system/role',
|
||||
component: RolePage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/system/role-auth/user/:roleId',
|
||||
component: RoleAuthUserPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/system/menu',
|
||||
component: MenuPage,
|
||||
|
|
@ -190,6 +221,11 @@ export const routes = [
|
|||
component: DictPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/system/dict-data/index/:dictId',
|
||||
component: DictDataPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/system/config',
|
||||
component: ConfigPage,
|
||||
|
|
@ -205,6 +241,11 @@ export const routes = [
|
|||
component: ProjectDetailPage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/project/demandManage',
|
||||
component: DemandManagePage,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/demandManage',
|
||||
component: DemandManagePage,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,18 @@ export default defineConfig({
|
|||
'@': path.resolve(__dirname, './src'), // Alias @ to ./src
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'@ant-design/icons',
|
||||
'antd',
|
||||
'axios',
|
||||
'dayjs',
|
||||
'js-cookie',
|
||||
'react',
|
||||
'react-dom/client',
|
||||
'react-router-dom',
|
||||
],
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
|
@ -100,6 +112,10 @@ export default defineConfig({
|
|||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
},
|
||||
'/druid': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue