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} -
-
- ))} -
-
-
-
{ + const nextRange = values.dateRange as [Dayjs, Dayjs]; + setDateRange(nextRange); + setProjectState(values.projectState); + }} > - - - - - - + + + + + + + + + + + +
+ + + + + + + void handleClean()}> + + + + + + + + + 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) => ( + + }>编辑}> + + + 工作日志}> + + + } 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') || '待生成编号'}
- -
- -
- - - - - - - - - - - - - - } /> - - - - - - - - - - - - - - - - - - - - - -