修复功能缺失与性能问题

main
kangwenjing 2026-04-07 16:44:06 +08:00
parent 023d0677f8
commit b13a2bfc87
24 changed files with 2978 additions and 929 deletions

View File

@ -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 />} />

View File

@ -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',
});
}

View File

@ -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': '登录日志',

View File

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

View File

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

View File

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

View File

@ -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,7 +24,6 @@ const MessageBinder = () => {
};
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfigProvider
locale={zhCN}
theme={{
@ -115,6 +116,5 @@ createRoot(document.getElementById('root')!).render(
<MessageBinder />
<App />
</AntdApp>
</ConfigProvider>
</StrictMode>,
</ConfigProvider>,
)

View File

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

View File

@ -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>
</div>
),
},
{
title: '负责人',
dataIndex: 'projectLeaderName',
width: 120,
fixed: 'left',
render: (value: unknown) => String(value ?? '-') || '-',
},
{
title: '状态',
dataIndex: 'projectState',
width: 112,
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>;
},
},
{
title: '执行概览',
key: 'overview',
width: 240,
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>
);
},
},
];
const periodColumns: TableColumnsType<BoardRow> = periods.map((period) => ({
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 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 style={{ whiteSpace: 'pre-line', textAlign: 'center' }}>
{`${['周日', '周一', '周二', '周三', '周四', '周五', '周六'][current.day()]}\n${current.format('M/D')}`}
</div>
),
key: period.key,
width: 164,
className: period.current ? 'period-column-current' : undefined,
key: `detail_${index}`,
width: 100,
align: 'center',
render: (_value, row) => {
const metric = getPeriodMetric(row, period);
if (!metric.active) {
const cellValue = Array.isArray(row.detailList) ? row.detailList[index] : '';
return (
<div className="period-cell is-empty">
<span className="period-cell-tip"></span>
</div>
);
<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'))}`,
)
}
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>
>
{String(cellValue ?? '')}
</Button>
);
},
}));
};
});
return [...baseColumns, ...periodColumns];
}, [canViewProjectUser, openProject, periods, statusMap]);
const stats = useMemo(
() => [
return [
{
key: 'total',
label: '项目总数',
value: summary.totalCount,
extra: '纳入当前筛选范围',
icon: <ProjectOutlined />,
tone: 'blue',
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>
),
},
{
key: 'active',
label: '进行中项目',
value: summary.executingCount,
extra: `总成员 ${summary.totalMembers}`,
icon: <RiseOutlined />,
tone: 'orange',
title: '项目状态',
dataIndex: 'projectState',
key: 'projectState',
fixed: 'left',
width: 140,
align: 'center',
render: (value) => statusOptions.find((item) => String(item.dictValue) === String(value ?? ''))?.dictLabel ?? '-',
},
{
key: 'delay',
label: '延期项目',
value: summary.delayCount,
extra: '需要重点跟踪',
icon: <FieldTimeOutlined />,
tone: 'red',
title: '预计工时(天)',
dataIndex: 'budgetDate',
key: 'budgetDate',
fixed: 'left',
width: 120,
align: 'center',
},
{
key: 'avg',
label: '平均执行率',
value: `${summary.avgActual}%`,
extra: periods.length > 0 ? `${periods.length} 个阶段窗口` : '暂无阶段',
icon: <TeamOutlined />,
tone: 'teal',
title: '总计工时(天)',
dataIndex: 'allDateWorkTime',
key: 'allDateWorkTime',
fixed: 'left',
width: 120,
align: 'center',
},
],
[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 ? '周视图' : '月视图';
{
title: '统计工时(天)',
dataIndex: 'allWorkTime',
key: 'allWorkTime',
fixed: 'left',
width: 120,
align: 'center',
},
...dateColumns,
];
}, [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 (
summary={(pageData) => (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<span className="project-progress-summary-title"></span>
<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={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>
{Number(pageData.reduce((sum, row) => sum + toNumber(row.allDateWorkTime), 0).toFixed(2))}
</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 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.Row>
</Table.Summary>
);
}}
)}
/>
</section>
</div>

View File

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

View File

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

View File

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

View File

@ -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 canSubmitProject = hasPermi('project:detail:save');
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,
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) {
notify.error('获取项目详情失败');
} finally {
setLoading(false);
console.error('Failed to load project dictionaries:', error);
message.error('获取项目字典失败');
}
} else {
// Fetch project code for new project
}, [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) {
notify.error('获取项目编号失败');
} catch (error) {
console.error('Failed to load project code:', error);
message.error('获取项目编号失败');
}
setFileList([]);
return;
}
};
fetchProjectData();
}, [id, isEdit, form]);
const onFinish = async (values: any) => {
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(() => {
void loadDictionaries();
}, [loadDictionaries]);
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) {
await updateProject({ ...payload, projectId: id });
notify.success('修改成功');
} else {
await addProject(payload);
notify.success('新增成功');
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 });
} else {
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;
}
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}>
<Form<ProjectFormValues> form={form} layout="vertical" onFinish={submitProject}>
<Card className="project-detail-card" title="基础信息" loading={loading}>
<Row gutter={16}>
<Col span={12}>
<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="请输入项目编号" />
</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 label="项目编码" name="projectCode" rules={[{ required: true, message: '请输入项目编码' }]}>
<Input placeholder="请输入项目编码" disabled={isEdit} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="预计工时(天)" name="budgetDate">
<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>
</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
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: '请选择结束时间' }]}>
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
<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>
<Form.Item label="项目描述" name="description">
<TextArea rows={4} placeholder="请输入项目描述" />
</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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}
}
}