From b13a2bfc87e4d1c47e9f0dffec2d95e9f9c4f4db Mon Sep 17 00:00:00 2001
From: kangwenjing <1138819403@qq.com>
Date: Tue, 7 Apr 2026 16:44:06 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8A=9F=E8=83=BD=E7=BC=BA?=
=?UTF-8?q?=E5=A4=B1=E4=B8=8E=E6=80=A7=E8=83=BD=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/App.tsx | 16 +-
src/api/monitor/job.ts | 22 +
src/components/Navbar/index.tsx | 9 +-
src/components/Sidebar/index.tsx | 4 +-
src/contexts/PermissionContext.tsx | 159 +++-
src/layout/MainLayout.tsx | 2 +-
src/main.tsx | 190 ++--
src/pages/Login/index.tsx | 5 +-
src/pages/dashboard/ProjectExecutionPage.tsx | 847 ++++-------------
src/pages/monitor/DruidMonitorPage.tsx | 27 +
src/pages/monitor/JobLogPage.tsx | 312 +++++++
src/pages/monitor/JobMonitorPage.tsx | 6 +-
src/pages/project/ProjectDetailPage.tsx | 921 +++++++++++++++++--
src/pages/project/ProjectPage.tsx | 157 +++-
src/pages/project/project-detail.css | 19 +
src/pages/system/ConfigPage.tsx | 26 +-
src/pages/system/DictDataPage.tsx | 448 +++++++++
src/pages/system/DictPage.tsx | 13 +-
src/pages/system/RoleAuthUserPage.tsx | 458 +++++++++
src/pages/system/RolePage.tsx | 6 +-
src/pages/system/UserAuthRolePage.tsx | 178 ++++
src/pages/system/UserPage.tsx | 25 +-
src/routeMapper.ts | 41 +
vite.config.ts | 16 +
24 files changed, 2978 insertions(+), 929 deletions(-)
create mode 100644 src/pages/monitor/DruidMonitorPage.tsx
create mode 100644 src/pages/monitor/JobLogPage.tsx
create mode 100644 src/pages/system/DictDataPage.tsx
create mode 100644 src/pages/system/RoleAuthUserPage.tsx
create mode 100644 src/pages/system/UserAuthRolePage.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 11ecbc0..4e7cbbd 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,6 @@
import { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
-import { Spin } from 'antd';
+import Spin from 'antd/es/spin';
import './App.css';
import { getToken } from './utils/auth';
import { PermissionProvider, usePermission } from './contexts/PermissionContext';
@@ -14,11 +14,16 @@ const OnlineUserPage = lazy(() => import('./pages/monitor/OnlineUserPage'));
const OperationLogPage = lazy(() => import('./pages/monitor/OperationLogPage'));
const ServerMonitorPage = lazy(() => import('./pages/monitor/ServerMonitorPage'));
const CacheListPage = lazy(() => import('./pages/monitor/CacheListPage'));
+const DruidMonitorPage = lazy(() => import('./pages/monitor/DruidMonitorPage'));
+const JobLogPage = lazy(() => import('./pages/monitor/JobLogPage'));
const UserPage = lazy(() => import('./pages/system/UserPage'));
+const UserAuthRolePage = lazy(() => import('./pages/system/UserAuthRolePage'));
const RolePage = lazy(() => import('./pages/system/RolePage'));
+const RoleAuthUserPage = lazy(() => import('./pages/system/RoleAuthUserPage'));
const MenuPage = lazy(() => import('./pages/system/MenuPage'));
const DeptPage = lazy(() => import('./pages/system/DeptPage'));
const DictPage = lazy(() => import('./pages/system/DictPage'));
+const DictDataPage = lazy(() => import('./pages/system/DictDataPage'));
const ConfigPage = lazy(() => import('./pages/system/ConfigPage'));
const ProjectPage = lazy(() => import('./pages/project/ProjectPage'));
const ProjectDetailPage = lazy(() => import('./pages/project/ProjectDetailPage'));
@@ -47,13 +52,13 @@ const RouteLoading = () => (
const PrivateRoute = () => {
const location = useLocation();
const token = getToken();
- const { ready, loading, canAccessPath, defaultRoutePath } = usePermission();
+ const { ready, canAccessPath, defaultRoutePath } = usePermission();
if (!token) {
return ;
}
- if (!ready || loading) {
+ if (!ready) {
return (
@@ -106,17 +111,22 @@ function App() {
} />
} />
} />
+
} />
} />
+
} />
} />
} />
} />
} />
} />
} />
+
} />
} />
+
} />
} />
} />
} />
+
} />
} />
} />
} />
diff --git a/src/api/monitor/job.ts b/src/api/monitor/job.ts
index 04395e3..cb3a12f 100644
--- a/src/api/monitor/job.ts
+++ b/src/api/monitor/job.ts
@@ -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',
+ });
+}
diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx
index 46731f2..c8641b8 100644
--- a/src/components/Navbar/index.tsx
+++ b/src/components/Navbar/index.tsx
@@ -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
= {
'/user/profile': '个人中心',
'/profile': '个人中心',
'/monitor/cache': '缓存监控',
+ '/monitor/druid': '数据监控',
'/monitor/job': '定时任务',
'/monitor/logininfor': '登录日志',
'/log/logininfor': '登录日志',
diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx
index 9a8cef8..4baac86 100644
--- a/src/components/Sidebar/index.tsx
+++ b/src/components/Sidebar/index.tsx
@@ -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,
diff --git a/src/contexts/PermissionContext.tsx b/src/contexts/PermissionContext.tsx
index d7f0845..3c7bad4 100644
--- a/src/contexts/PermissionContext.tsx
+++ b/src/contexts/PermissionContext.tsx
@@ -43,6 +43,8 @@ const ROUTE_ALIASES: Record = {
'/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 = {
'/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;
+ 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) => {
+ 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({});
- const [roles, setRoles] = useState([]);
- const [permissions, setPermissions] = useState([]);
- const [routers, setRouters] = useState([]);
- const [defaultRoutePath, setDefaultRoutePath] = useState('');
- const [allowedPaths, setAllowedPaths] = useState>(new Set());
- const [routeGuardEnabled, setRouteGuardEnabled] = useState(false);
+ const [ready, setReady] = useState(hasCachedPermissionState);
+ const [userName, setUserName] = useState(cachedPermissionState?.userName ?? '');
+ const [currentUser, setCurrentUser] = useState(cachedPermissionState?.currentUser ?? {});
+ const [roles, setRoles] = useState(cachedPermissionState?.roles ?? []);
+ const [permissions, setPermissions] = useState(cachedPermissionState?.permissions ?? []);
+ const [routers, setRouters] = useState(cachedPermissionState?.routers ?? []);
+ const [defaultRoutePath, setDefaultRoutePath] = useState(cachedPermissionState?.defaultRoutePath ?? '');
+ const [allowedPaths, setAllowedPaths] = useState>(() => 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(
diff --git a/src/layout/MainLayout.tsx b/src/layout/MainLayout.tsx
index f8dcb65..eb9d362 100644
--- a/src/layout/MainLayout.tsx
+++ b/src/layout/MainLayout.tsx
@@ -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';
diff --git a/src/main.tsx b/src/main.tsx
index 727a279..c7dc7f1 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,7 +1,9 @@
-import { StrictMode, useEffect } from 'react'
+import { useEffect } from 'react'
import { createRoot } from 'react-dom/client'
-import { App as AntdApp, ConfigProvider, theme } from 'antd'
-import zhCN from 'antd/locale/zh_CN'
+import AntdApp from 'antd/es/app'
+import ConfigProvider from 'antd/es/config-provider'
+import theme from 'antd/es/theme'
+import zhCN from 'antd/es/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'antd/dist/reset.css';
@@ -22,99 +24,97 @@ const MessageBinder = () => {
};
createRoot(document.getElementById('root')!).render(
-
-
-
-
-
-
-
- ,
+ Input: {
+ borderRadius: 12,
+ controlHeight: 40,
+ activeShadow: '0 0 0 4px rgba(99, 91, 255, 0.12)',
+ },
+ Select: {
+ borderRadius: 12,
+ controlHeight: 40,
+ optionSelectedBg: 'rgba(99, 91, 255, 0.1)',
+ },
+ Table: {
+ borderColor: '#e9eef7',
+ headerBg: '#f7f9fe',
+ headerColor: '#34425f',
+ rowHoverBg: '#f8faff',
+ },
+ Modal: {
+ borderRadiusLG: 22,
+ },
+ Drawer: {
+ borderRadiusLG: 22,
+ },
+ Tag: {
+ borderRadiusSM: 999,
+ },
+ },
+ }}
+ >
+
+
+
+
+ ,
)
diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx
index 2f5c5f3..a79c8ee 100644
--- a/src/pages/Login/index.tsx
+++ b/src/pages/Login/index.tsx
@@ -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 });
diff --git a/src/pages/dashboard/ProjectExecutionPage.tsx b/src/pages/dashboard/ProjectExecutionPage.tsx
index e5b997b..6e87c3c 100644
--- a/src/pages/dashboard/ProjectExecutionPage.tsx
+++ b/src/pages/dashboard/ProjectExecutionPage.tsx
@@ -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;
[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, 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) => {
- 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;
- 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;
- 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;
- 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([]);
const [statusOptions, setStatusOptions] = useState>([]);
- const [rows, setRows] = useState([]);
- const [total, setTotal] = useState(0);
- const fallbackTipShownRef = useRef(false);
- const [queryParams, setQueryParams] = useState({
- 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(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).data)
- ? ((response as Record).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);
- } catch {
- fallbackUsed = true;
- response = await listProject(queryParams as unknown as Record);
- }
- } else {
- fallbackUsed = true;
- response = await listProject(queryParams as unknown as Record);
- }
-
- 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>(() => {
- const baseColumns: TableColumnsType = [
- {
- title: '项目名称',
- key: 'projectName',
- dataIndex: 'projectName',
- width: 260,
- fixed: 'left',
- render: (value: unknown, row) => (
-
- {canViewProjectUser ? (
-
- ) : (
-
{String(value ?? '-') || '-'}
- )}
-
- {row.projectCode || '未配置项目编号'}
-
- {parseTime(row.startDate, 'YYYY-MM-DD') || '--'} 至 {parseTime(row.endDate, 'YYYY-MM-DD') || '--'}
-
-
+ const dynamicColumns = useMemo
>(() => {
+ const days = dateRange[1].diff(dateRange[0], 'day') + 1;
+ const dateColumns: TableColumnsType = Array.from({ length: Math.max(days, 0) }, (_item, index) => {
+ const current = dateRange[0].add(index, 'day');
+ return {
+ title: (
+
+ {`${['周日', '周一', '周二', '周三', '周四', '周五', '周六'][current.day()]}\n${current.format('M/D')}`}
),
+ key: `detail_${index}`,
+ width: 100,
+ align: 'center',
+ render: (_value, row) => {
+ const cellValue = Array.isArray(row.detailList) ? row.detailList[index] : '';
+ return (
+
+ );
+ },
+ };
+ });
+
+ return [
+ {
+ title: '项目',
+ dataIndex: 'projectName',
+ key: 'projectName',
+ fixed: 'left',
+ width: 180,
+ render: (value, row) => (
+
+ ),
},
{
- title: '负责人',
- dataIndex: 'projectLeaderName',
- width: 120,
- fixed: 'left',
- render: (value: unknown) => String(value ?? '-') || '-',
- },
- {
- title: '状态',
+ title: '项目状态',
dataIndex: 'projectState',
- width: 112,
+ key: 'projectState',
fixed: 'left',
- render: (value: unknown, row) => {
- const label = statusMap.get(String(value ?? '')) ?? (String(value ?? '') || '未配置');
- return {label};
- },
+ width: 140,
+ align: 'center',
+ render: (value) => statusOptions.find((item) => String(item.dictValue) === String(value ?? ''))?.dictLabel ?? '-',
},
{
- title: '执行概览',
- key: 'overview',
- width: 240,
+ title: '预计工时(天)',
+ dataIndex: 'budgetDate',
+ key: 'budgetDate',
fixed: 'left',
- render: (_value, row) => {
- const plan = clampPercent(toNumber(row.planProgress, 0));
- const actual = clampPercent(toNumber(row.actualProgress, 0));
- const deviation = toNumber(row.progressDeviation, 0);
- return (
-
-
- 总进度
-
- {`${deviation > 0 ? '+' : ''}${deviation}%`}
-
-
-
-
- 计划
- {formatPercent(plan)}
-
-
-
-
-
- 执行
- {formatPercent(actual)}
-
-
-
-
- {`团队 ${toNumber(row.teamNum, 0)} 人`}
- {`工时 ${String(row.budgetDate ?? '-')}`}
-
-
- );
- },
+ width: 120,
+ align: 'center',
},
+ {
+ title: '总计工时(天)',
+ dataIndex: 'allDateWorkTime',
+ key: 'allDateWorkTime',
+ fixed: 'left',
+ width: 120,
+ align: 'center',
+ },
+ {
+ title: '统计工时(天)',
+ dataIndex: 'allWorkTime',
+ key: 'allWorkTime',
+ fixed: 'left',
+ width: 120,
+ align: 'center',
+ },
+ ...dateColumns,
];
-
- const periodColumns: TableColumnsType = periods.map((period) => ({
- title: (
-
- {period.label}
- {period.subLabel}
-
- ),
- key: period.key,
- width: 164,
- className: period.current ? 'period-column-current' : undefined,
- render: (_value, row) => {
- const metric = getPeriodMetric(row, period);
- if (!metric.active) {
- return (
-
- 无排期
-
- );
- }
-
- return (
-
-
- 计划 {formatPercent(metric.planPercent)}
- 执行 {formatPercent(metric.actualPercent)}
-
-
-
- {metric.delay ? '当前节点延期' : metric.actualPercent >= metric.planPercent ? '执行平稳' : '待追赶'}
-
-
- );
- },
- }));
-
- return [...baseColumns, ...periodColumns];
- }, [canViewProjectUser, openProject, periods, statusMap]);
-
- const stats = useMemo(
- () => [
- {
- key: 'total',
- label: '项目总数',
- value: summary.totalCount,
- extra: '纳入当前筛选范围',
- icon: ,
- tone: 'blue',
- },
- {
- key: 'active',
- label: '进行中项目',
- value: summary.executingCount,
- extra: `总成员 ${summary.totalMembers} 人`,
- icon: ,
- tone: 'orange',
- },
- {
- key: 'delay',
- label: '延期项目',
- value: summary.delayCount,
- extra: '需要重点跟踪',
- icon: ,
- tone: 'red',
- },
- {
- key: 'avg',
- label: '平均执行率',
- value: `${summary.avgActual}%`,
- extra: periods.length > 0 ? `${periods.length} 个阶段窗口` : '暂无阶段',
- icon: ,
- tone: 'teal',
- },
- ],
- [periods.length, summary],
- );
-
- const scrollX = useMemo(() => 260 + 120 + 112 + 240 + periods.length * 164, [periods.length]);
- const viewLabel = periods.length > 0 && rangeEnd.diff(rangeStart, 'day') <= PERIOD_SWITCH_DAYS ? '周视图' : '月视图';
+ }, [dateRange, navigate, statusOptions]);
return (
-
-
-
-
-
项目执行表
-
按项目维度对齐计划与实际执行,保留旧系统横向时间轴查看逻辑,并提升信息层次与可读性。
-
-
-
- {`${rangeStart.format('YYYY.MM.DD')} - ${rangeEnd.format('YYYY.MM.DD')}`}
- {viewLabel}
-
-
-
- {stats.map((item) => (
-
-
{item.icon}
-
- {item.label}
- {item.value}
- {item.extra}
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
+
@@ -699,7 +201,16 @@ const ProjectExecutionPage = () => {
}>
查询
- } onClick={handleReset}>
+ }
+ onClick={() => {
+ const nextRange = getDefaultRange();
+ queryForm.resetFields();
+ queryForm.setFieldsValue({ dateRange: nextRange });
+ setDateRange(nextRange);
+ setProjectState(undefined);
+ }}
+ >
重置
@@ -710,86 +221,44 @@ const ProjectExecutionPage = () => {
-
执行矩阵
-
点击项目名称可进入项目下钻页面;左侧信息固定,右侧阶段横向滚动查看。
+
项目执行表
+
与线上一致,使用真实统计接口展示每日工时明细。
-
- {viewLabel}
-
-
- rowKey={(row) => String(row.projectId ?? row.projectCode ?? row.projectName ?? '')}
+
+ 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: }}
- 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) => (
+
+
+ 合计工时(天)
+
+
+ {Number(pageData.reduce((sum, row) => sum + toNumber(row.budgetDate), 0).toFixed(2))}
+
+
+ {Number(pageData.reduce((sum, row) => sum + toNumber(row.allDateWorkTime), 0).toFixed(2))}
+
+
+ {Number(pageData.reduce((sum, row) => sum + toNumber(row.allWorkTime), 0).toFixed(2))}
+
+ {Array.from({ length: Math.max(dateRange[1].diff(dateRange[0], 'day') + 1, 0) }, (_item, index) => (
+
+ {Number(
+ pageData.reduce((sum, row) => sum + toNumber(Array.isArray(row.detailList) ? row.detailList[index] : 0), 0).toFixed(2),
+ )}
- {`${pageData.length} 项`}
- {`${pageDelay} 延期`}
-
-
- {`平均执行 ${pageAvgActual}%`}
- {`平均计划 ${
- pageData.length
- ? Number(
- (
- pageData.reduce((sum, row) => sum + toNumber(row.planProgress), 0) / pageData.length
- ).toFixed(1),
- )
- : 0
- }%`}
-
-
- {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 (
-
-
- {`计划 ${avgPlan}%`}
- {`执行 ${avgActual}%`}
-
-
- );
- })}
-
-
- );
- }}
+ ))}
+
+
+ )}
/>
diff --git a/src/pages/monitor/DruidMonitorPage.tsx b/src/pages/monitor/DruidMonitorPage.tsx
new file mode 100644
index 0000000..e740569
--- /dev/null
+++ b/src/pages/monitor/DruidMonitorPage.tsx
@@ -0,0 +1,27 @@
+import { Alert } from 'antd';
+
+const DruidMonitorPage = () => {
+ return (
+
+ );
+};
+
+export default DruidMonitorPage;
diff --git a/src/pages/monitor/JobLogPage.tsx b/src/pages/monitor/JobLogPage.tsx
new file mode 100644
index 0000000..a5c85e0
--- /dev/null
+++ b/src/pages/monitor/JobLogPage.tsx
@@ -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();
+ const [loading, setLoading] = useState(false);
+ const [jobName, setJobName] = useState('');
+ const [rows, setRows] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [selectedRowKeys, setSelectedRowKeys] = useState([]);
+ const [queryParams, setQueryParams] = useState(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 = [
+ { 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) => (
+
+ {String(value ?? '') === '0' ? '成功' : '失败'}
+
+ ),
+ },
+ {
+ title: '执行时间',
+ dataIndex: 'createTime',
+ align: 'center',
+ width: 180,
+ render: (value) => parseTime(value),
+ },
+ {
+ title: '异常信息',
+ dataIndex: 'exceptionInfo',
+ align: 'center',
+ ellipsis: true,
+ render: (value) => String(value ?? '-') || '-',
+ },
+ ];
+
+ return (
+
+
+
+
+
+ {jobName ? `${jobName} 调度日志` : '调度日志'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } htmlType="submit">
+ 搜索
+
+ }
+ onClick={() => {
+ queryForm.resetFields();
+ setQueryParams({
+ ...defaultQueryParams,
+ jobId: currentJobId || undefined,
+ });
+ }}
+ style={{ marginLeft: 8 }}
+ >
+ 重置
+
+
+
+
+
+
+ } disabled={selectedRowKeys.length === 0} onClick={() => void handleDelete()}>
+ 删除
+
+
+
+ void handleClean()}>
+ }>清空
+
+
+
+ } onClick={() => void handleExport()}>
+ 导出
+
+
+
+
+ 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 })),
+ }}
+ />
+
+
+ );
+};
+
+export default JobLogPage;
diff --git a/src/pages/monitor/JobMonitorPage.tsx b/src/pages/monitor/JobMonitorPage.tsx
index 8fcbd76..0734435 100644
--- a/src/pages/monitor/JobMonitorPage.tsx
+++ b/src/pages/monitor/JobMonitorPage.tsx
@@ -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();
const [queryForm] = Form.useForm();
const [jobList, setJobList] = useState([]);
@@ -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) => {
diff --git a/src/pages/project/ProjectDetailPage.tsx b/src/pages/project/ProjectDetailPage.tsx
index 550de5f..fe70205 100644
--- a/src/pages/project/ProjectDetailPage.tsx
+++ b/src/pages/project/ProjectDetailPage.tsx
@@ -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;
+ 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 ;
+ }
+ if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
+ return ;
+ }
+ return ;
+};
+
+const extractResponseData = (response: unknown): T | undefined => {
+ if (response && typeof response === 'object' && 'data' in (response as Record)) {
+ return (response as { data?: T }).data;
+ }
+ return response as T | undefined;
+};
+
+const ProjectDetailPage = () => {
+ const { message } = App.useApp();
+ const [form] = Form.useForm();
+ const [memberForm] = Form.useForm<{ userId?: string | number; userName?: string; postId?: string }>();
+ const [userQueryForm] = Form.useForm<{ userName?: string; phonenumber?: string }>();
const { hasPermi } = usePermission();
const { id: pathId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
- const id = pathId ?? searchParams.get('id') ?? undefined;
const navigate = useNavigate();
- const [loading, setLoading] = useState(false);
+ const id = pathId ?? searchParams.get('id') ?? undefined;
const isEdit = !!id;
+ const [loading, setLoading] = useState(false);
+ const [memberLoading, setMemberLoading] = useState(false);
+ const [userLoading, setUserLoading] = useState(false);
+ const [fileUploading, setFileUploading] = useState(false);
+ const [statusOptions, setStatusOptions] = useState>([]);
+ const [postOptions, setPostOptions] = useState>([]);
+ const [memberRows, setMemberRows] = useState([]);
+ const [fileList, setFileList] = useState([]);
+ const [pendingDeletedFileIds, setPendingDeletedFileIds] = useState>([]);
+ const [memberModalOpen, setMemberModalOpen] = useState(false);
+ const [memberModalMode, setMemberModalMode] = useState<'add' | 'edit'>('add');
+ const [currentMember, setCurrentMember] = useState(null);
+ const [userPickerOpen, setUserPickerOpen] = useState(false);
+ const [userPickerMode, setUserPickerMode] = useState<'manager' | 'member'>('manager');
+ const [userPickerRows, setUserPickerRows] = useState([]);
+ 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([]);
+
const canSubmitProject = hasPermi('project:detail:save');
+ const canAddUser = hasPermi('project:detail:addUser');
+ const canEditUser = hasPermi('project:detail:editUser');
+ const canViewWorkLog = hasPermi('project:detail:workLog');
+
+ const loadDictionaries = useCallback(async () => {
+ try {
+ const [positions, projectStates] = await Promise.all([
+ getDicts('business_positions'),
+ getDicts('business_projectstate'),
+ ]);
+ setPostOptions(Array.isArray(positions) ? positions : []);
+ setStatusOptions(Array.isArray(projectStates) ? projectStates : []);
+ } catch (error) {
+ console.error('Failed to load project dictionaries:', error);
+ message.error('获取项目字典失败');
+ }
+ }, [message]);
+
+ const loadProjectMembers = useCallback(async () => {
+ if (!id) {
+ setMemberRows([]);
+ return;
+ }
+
+ setMemberLoading(true);
+ try {
+ const response = await getProjectUser(id);
+ const data = ((response as Record).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).data ?? response) as string });
+ } catch (error) {
+ console.error('Failed to load project code:', error);
+ message.error('获取项目编号失败');
+ }
+ setFileList([]);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const response = await getProjectDetail(id as string);
+ const detail = ((response as Record).data ?? response) as Record;
+ form.setFieldsValue({
+ projectName: String(detail.projectName ?? ''),
+ projectCode: String(detail.projectCode ?? ''),
+ projectLeader: (detail.projectLeader as string | number | undefined) ?? null,
+ projectLeaderName: String(detail.projectLeaderName ?? ''),
+ projectState: String(detail.projectState ?? ''),
+ budgetDate: Number(detail.budgetDate ?? 0),
+ startDate: detail.startDate ? dayjs(String(detail.startDate).split(' ')[0]) : null,
+ endDate: detail.endDate ? dayjs(String(detail.endDate).split(' ')[0]) : null,
+ });
+ setFileList(normalizeFileList(detail.fileList));
+ setPendingDeletedFileIds([]);
+ } catch (error) {
+ console.error('Failed to load project detail:', error);
+ message.error('获取项目详情失败');
+ } finally {
+ setLoading(false);
+ }
+ }, [form, id, isEdit, message]);
+
+ const loadUserPicker = useCallback(async () => {
+ setUserLoading(true);
+ try {
+ const response = await listUser(userPickerQuery);
+ const rows = Array.isArray((response as { rows?: UserRow[] })?.rows) ? (response as { rows?: UserRow[] }).rows ?? [] : [];
+ setUserPickerRows(rows);
+ setUserPickerTotal(Number((response as { total?: number })?.total ?? 0));
+ } catch (error) {
+ console.error('Failed to load users for selector:', error);
+ message.error('获取用户列表失败');
+ } finally {
+ setUserLoading(false);
+ }
+ }, [message, userPickerQuery]);
useEffect(() => {
- const fetchProjectData = async () => {
- if (isEdit) {
- setLoading(true);
- try {
- const response = await getProjectDetail(id as string);
- const projectData = ((response as Record).data ?? response) as Record;
- const startValue = projectData.startDate as dayjs.ConfigType | undefined;
- const endValue = projectData.endDate as dayjs.ConfigType | undefined;
- form.setFieldsValue({
- ...projectData,
- startDate: startValue ? dayjs(startValue) : null,
- endDate: endValue ? dayjs(endValue) : null,
- });
- } catch (error) {
- notify.error('获取项目详情失败');
- } finally {
- setLoading(false);
- }
- } else {
- // Fetch project code for new project
- try {
- const response = await getProjectCode();
- form.setFieldsValue({ projectCode: ((response as Record).data ?? response) as string });
- } catch(error) {
- notify.error('获取项目编号失败');
- }
- }
- };
- fetchProjectData();
- }, [id, isEdit, form]);
+ void loadDictionaries();
+ }, [loadDictionaries]);
- const onFinish = async (values: any) => {
+ useEffect(() => {
+ void loadProjectDetail();
+ }, [loadProjectDetail]);
+
+ useEffect(() => {
+ if (isEdit) {
+ void loadProjectMembers();
+ }
+ }, [isEdit, loadProjectMembers]);
+
+ useEffect(() => {
+ if (userPickerOpen) {
+ void loadUserPicker();
+ }
+ }, [loadUserPicker, userPickerOpen]);
+
+ const submitProject = async (values: ProjectFormValues) => {
if (!canSubmitProject) {
return;
}
+
setLoading(true);
try {
+ const startDateString = values.startDate ? `${values.startDate.format('YYYY-MM-DD')} 00:00:00` : null;
+ const endDateString = values.endDate ? `${values.endDate.format('YYYY-MM-DD')} 23:59:59` : null;
const payload = {
...values,
- startDate: values.startDate ? values.startDate.format('YYYY-MM-DD HH:mm:ss') : null,
- endDate: values.endDate ? values.endDate.format('YYYY-MM-DD HH:mm:ss') : null,
+ startDate: startDateString ? new Date(startDateString).getTime() : null,
+ endDate: endDateString ? new Date(endDateString).getTime() : null,
+ fileList,
};
+
if (isEdit) {
+ const checkResult = await projectHasLogData({
+ projectId: id,
+ startDate: startDateString,
+ endDate: endDateString,
+ });
+ const canUpdateDirectly = Boolean(extractResponseData(checkResult));
+ if (!canUpdateDirectly) {
+ const confirmed = await new Promise((resolve) => {
+ Modal.confirm({
+ title: '确认修改项目时间',
+ content: '检测到项目时间范围外的日志记录,修改项目时间将导致这些日志被删除。请确认是否继续修改?',
+ okText: '确定',
+ cancelText: '取消',
+ onOk: () => resolve(true),
+ onCancel: () => resolve(false),
+ });
+ });
+ if (!confirmed) {
+ return;
+ }
+ }
await updateProject({ ...payload, projectId: id });
- notify.success('修改成功');
} else {
- await addProject(payload);
- notify.success('新增成功');
+ const response = await addProject(payload);
+ const created = extractResponseData>(response);
+ const createdProjectId = created?.projectId;
+ const createdProjectCode = created?.projectCode;
+ message.success('保存成功');
+ if (createdProjectId !== undefined && createdProjectId !== null && createdProjectId !== '') {
+ navigate(`/project/detail?id=${encodeURIComponent(String(createdProjectId))}`, { replace: true });
+ if (createdProjectCode) {
+ form.setFieldsValue({ projectCode: String(createdProjectCode) });
+ }
+ return;
+ }
+
+ navigate('/project/list');
+ return;
}
- navigate('/project/list');
+
+ if (pendingDeletedFileIds.length > 0) {
+ await deleteProjectFileBatch(pendingDeletedFileIds.map(String).join(','));
+ setPendingDeletedFileIds([]);
+ }
+
+ message.success('保存成功');
+
+ await Promise.all([loadProjectDetail(), loadProjectMembers()]);
} catch (error) {
- notify.error(isEdit ? '修改失败' : '新增失败');
+ console.error('Failed to save project detail:', error);
+ message.error('保存失败');
} finally {
setLoading(false);
}
};
+ const openUserPicker = (mode: 'manager' | 'member') => {
+ if (mode === 'manager' && !canSubmitProject) {
+ return;
+ }
+ if (mode === 'member' && !canAddUser && !canEditUser) {
+ return;
+ }
+
+ setUserPickerMode(mode);
+ userQueryForm.resetFields();
+ setUserPickerQuery({
+ pageNum: 1,
+ pageSize: 10,
+ userName: undefined,
+ phonenumber: undefined,
+ });
+
+ if (mode === 'manager') {
+ const currentLeader = form.getFieldValue('projectLeader');
+ setSelectedUserKeys(currentLeader ? [currentLeader] : []);
+ } else {
+ const currentUserId = memberForm.getFieldValue('userId');
+ setSelectedUserKeys(currentUserId ? [currentUserId] : []);
+ }
+
+ setUserPickerOpen(true);
+ };
+
+ const handlePickUser = () => {
+ const target = userPickerRows.find((item) => String(item.userId ?? '') === String(selectedUserKeys[0] ?? ''));
+ if (!target) {
+ message.warning('请选择用户');
+ return;
+ }
+
+ if (userPickerMode === 'manager') {
+ form.setFieldsValue({
+ projectLeader: target.userId,
+ projectLeaderName: String(target.nickName ?? target.userName ?? ''),
+ });
+ } else {
+ const exists = memberRows.some(
+ (item) =>
+ String(item.userId ?? '') === String(target.userId ?? '') &&
+ String(item.teamId ?? '') !== String(currentMember?.teamId ?? ''),
+ );
+ if (exists) {
+ message.warning('该用户已在项目成员中');
+ return;
+ }
+
+ memberForm.setFieldsValue({
+ userId: target.userId,
+ userName: String(target.nickName ?? target.userName ?? ''),
+ });
+ }
+
+ setUserPickerOpen(false);
+ };
+
+ const openMemberModal = (mode: 'add' | 'edit', member?: MemberRow) => {
+ if (mode === 'add' && !canAddUser) {
+ return;
+ }
+ if (mode === 'edit' && !canEditUser) {
+ return;
+ }
+
+ setMemberModalMode(mode);
+ setCurrentMember(member ?? null);
+ memberForm.resetFields();
+ memberForm.setFieldsValue({
+ userId: member?.userId,
+ userName: member?.userName,
+ postId: member?.postId,
+ });
+ setMemberModalOpen(true);
+ };
+
+ const saveMember = async () => {
+ if (!id) {
+ message.warning('请先保存项目再维护成员');
+ return;
+ }
+
+ try {
+ const values = await memberForm.validateFields();
+ await updateProjectUser({
+ projectId: id,
+ userId: values.userId,
+ postId: values.postId,
+ ...(currentMember?.teamId ? { teamId: currentMember.teamId } : {}),
+ });
+ message.success(memberModalMode === 'add' ? '成员添加成功' : '成员更新成功');
+ setMemberModalOpen(false);
+ await loadProjectMembers();
+ } catch (error) {
+ if (error && typeof error === 'object' && 'errorFields' in error) {
+ return;
+ }
+ console.error('Failed to save project member:', error);
+ message.error(memberModalMode === 'add' ? '成员添加失败' : '成员更新失败');
+ }
+ };
+
+ const removeMember = async (member: MemberRow) => {
+ if (member.teamId === undefined) {
+ return;
+ }
+
+ try {
+ await deleteProjectUser(member.teamId);
+ message.success('成员删除成功');
+ await loadProjectMembers();
+ } catch (error) {
+ console.error('Failed to delete project member:', error);
+ message.error('成员删除失败');
+ }
+ };
+
+ const uploadProps: UploadProps = {
+ action: FILE_UPLOAD_URL,
+ multiple: true,
+ showUploadList: false,
+ headers: {
+ Authorization: `Bearer ${Cookies.get(TokenKey) ?? ''}`,
+ },
+ beforeUpload(file) {
+ if (!canSubmitProject) {
+ message.warning('当前无权限上传附件');
+ return Upload.LIST_IGNORE;
+ }
+ if (file.size > 100 * 1024 * 1024) {
+ message.warning('单个文件不能大于100M');
+ return Upload.LIST_IGNORE;
+ }
+ setFileUploading(true);
+ return true;
+ },
+ onChange(info) {
+ const hasUploading = info.fileList.some((item) => item.status === 'uploading');
+ if (!hasUploading) {
+ setFileUploading(false);
+ }
+ if (info.file.status === 'done') {
+ const response = info.file.response as UploadResponse | undefined;
+ if (response?.code === 200) {
+ setFileList((prev) => [
+ ...prev,
+ {
+ fileName: String(response.originalFilename ?? info.file.name),
+ fileNewName: String(response.newFileName ?? info.file.name),
+ filePath: String(response.filePath ?? ''),
+ fileUrl: String(response.url ?? ''),
+ },
+ ]);
+ return;
+ }
+ message.error(String(response?.msg ?? '附件上传失败'));
+ }
+ if (info.file.status === 'error') {
+ setFileUploading(false);
+ message.error('附件上传失败');
+ }
+ },
+ };
+
+ const removeFile = (file: FileRecord) => {
+ if (!canSubmitProject) {
+ return;
+ }
+ if (file.id !== undefined && file.id !== null && file.id !== '') {
+ setPendingDeletedFileIds((prev) => [...prev, file.id as string | number]);
+ }
+ setFileList((prev) =>
+ prev.filter((item) => String(item.fileNewName ?? item.fileName ?? '') !== String(file.fileNewName ?? file.fileName ?? '')),
+ );
+ };
+
+ const memberColumns: TableColumnsType = [
+ { 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) => (
+
+ }>编辑}>
+ } onClick={() => openMemberModal('edit', record)}>
+ 编辑
+
+
+ 工作日志}>
+
+
+ } danger>删除}>
+ void removeMember(record)}>
+ }>
+ 删除
+
+
+
+
+ ),
+ },
+ ];
+
+ const userColumns: TableColumnsType = 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 (
+
PROJECT DETAIL
{isEdit ? '编辑项目' : '新建项目'}
-
维护项目基本信息、负责人、周期与描述,保持项目数据结构统一。
+
与线上保持一致,支持基础信息、附件与项目成员维护。
{isEdit ? '当前模式:编辑' : '当前模式:新建'}
- {projectCode || '待生成编号'}
+ {form.getFieldValue('projectCode') || '待生成编号'}
-
-
-
-
+
+