├── src ├── types │ └── file-saver.d.ts ├── models │ ├── Task.ts │ └── Dependency.ts ├── index.tsx ├── App.tsx ├── hooks │ ├── ProjectContext.tsx │ ├── ProjectCrashingContext.tsx │ └── useProjectData.ts ├── components │ ├── PathAnalysis.tsx │ ├── CrashingIterationSlider.tsx │ ├── TaskForm.tsx │ ├── CrashingPathAnalysis.tsx │ ├── CrashingTaskEditDialog.tsx │ ├── CrashingCostAnalysis.tsx │ ├── CrashingTaskTable.tsx │ ├── TaskEditDialog.tsx │ ├── CrashingTaskForm.tsx │ ├── TaskTable.tsx │ ├── NetworkDiagramForCrashing.tsx │ └── Workspace.tsx └── services │ ├── ProjectScheduler.ts │ └── ProjectCrashingService.ts ├── .gitignore ├── public ├── manifest.json └── index.html ├── tsconfig.json ├── LICENSE ├── package.json ├── CHANGELOG.md ├── INSTALLATION_GUIDE.md └── README.md /src/types/file-saver.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'file-saver' { 2 | export function saveAs(data: Blob | File, filename?: string, options?: FileSaverOptions): void; 3 | 4 | interface FileSaverOptions { 5 | type?: string; 6 | } 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # IDE 25 | .idea 26 | .vscode 27 | *.swp 28 | *.swo -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "PM Tool", 3 | "name": "Project Management Tool", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } -------------------------------------------------------------------------------- /src/models/Task.ts: -------------------------------------------------------------------------------- 1 | import { DependencyType } from '../models/Dependency'; 2 | 3 | export interface Task { 4 | id: string; 5 | description: string; 6 | duration: number; 7 | predecessors: Predecessor[]; 8 | 9 | // 计算的属性 10 | earlyStart?: number; 11 | earlyFinish?: number; 12 | lateStart?: number; 13 | lateFinish?: number; 14 | slack?: number; 15 | isCritical?: boolean; 16 | } 17 | 18 | export interface Predecessor { 19 | taskId: string; 20 | type: DependencyType; 21 | lag: number; 22 | } 23 | 24 | // 用于内部存储所有路径 25 | export interface Path { 26 | tasks: string[]; // 任务ID序列 27 | duration: number; // 路径总持续时间 28 | isCritical: boolean; 29 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import CssBaseline from '@mui/material/CssBaseline'; 4 | import { ThemeProvider, createTheme } from '@mui/material/styles'; 5 | import App from './App'; 6 | 7 | // 创建主题 8 | const theme = createTheme({ 9 | palette: { 10 | primary: { 11 | main: '#1976d2', 12 | }, 13 | secondary: { 14 | main: '#dc004e', 15 | }, 16 | }, 17 | typography: { 18 | fontFamily: [ 19 | 'Roboto', 20 | '"Helvetica Neue"', 21 | 'Arial', 22 | 'sans-serif' 23 | ].join(','), 24 | }, 25 | }); 26 | 27 | // 渲染应用 28 | const root = ReactDOM.createRoot( 29 | document.getElementById('root') as HTMLElement 30 | ); 31 | 32 | root.render( 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 mbarryyy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 26 | 27 | Project Management Tool 28 | 29 | 30 | 31 |
32 | 33 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { ProjectProvider } from './hooks/ProjectContext'; 3 | import { ProjectCrashingProvider } from './hooks/ProjectCrashingContext'; 4 | import Workspace from './components/Workspace'; 5 | import ProjectCrashing from './components/ProjectCrashing'; 6 | import { Box, Tabs, Tab, AppBar, Toolbar, Typography } from '@mui/material'; 7 | 8 | const App: React.FC = () => { 9 | const [currentTab, setCurrentTab] = useState(0); 10 | 11 | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { 12 | setCurrentTab(newValue); 13 | }; 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | Project Management Tool 21 | 22 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {currentTab === 0 && ( 36 | 37 | 38 | 39 | )} 40 | {currentTab === 1 && ( 41 | 42 | 43 | 44 | )} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default App; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "project-management-tool", 3 | "version": "1.1.2", 4 | "private": true, 5 | "dependencies": { 6 | "@dnd-kit/core": "^6.3.1", 7 | "@dnd-kit/modifiers": "^9.0.0", 8 | "@dnd-kit/sortable": "^10.0.0", 9 | "@dnd-kit/utilities": "^3.2.2", 10 | "@emotion/react": "^11.10.6", 11 | "@emotion/styled": "^11.10.6", 12 | "@mui/icons-material": "^5.11.11", 13 | "@mui/material": "^5.11.11", 14 | "@testing-library/jest-dom": "^5.16.5", 15 | "@testing-library/react": "^13.4.0", 16 | "@testing-library/user-event": "^13.5.0", 17 | "@types/jest": "^27.5.2", 18 | "@types/node": "^16.18.14", 19 | "@types/react": "^18.0.28", 20 | "@types/react-beautiful-dnd": "^13.1.8", 21 | "@types/react-dom": "^18.0.11", 22 | "file-saver": "^2.0.5", 23 | "react": "^18.2.0", 24 | "react-beautiful-dnd": "^13.1.1", 25 | "react-dom": "^18.2.0", 26 | "react-scripts": "5.0.1", 27 | "reactflow": "^11.5.6", 28 | "typescript": "^4.9.5", 29 | "web-vitals": "^2.1.4", 30 | "xlsx": "^0.18.5" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "build": "react-scripts build", 35 | "test": "react-scripts test", 36 | "eject": "react-scripts eject" 37 | }, 38 | "eslintConfig": { 39 | "extends": [ 40 | "react-app", 41 | "react-app/jest" 42 | ] 43 | }, 44 | "browserslist": { 45 | "production": [ 46 | ">0.2%", 47 | "not dead", 48 | "not op_mini all" 49 | ], 50 | "development": [ 51 | "last 1 chrome version", 52 | "last 1 firefox version", 53 | "last 1 safari version" 54 | ] 55 | }, 56 | "devDependencies": { 57 | "@types/file-saver": "^2.0.7" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/models/Dependency.ts: -------------------------------------------------------------------------------- 1 | // 依赖关系类型 2 | export enum DependencyType { 3 | FS = 'Finish-to-Start', // 前置任务完成后,后续任务才能开始(最常见) 4 | FF = 'Finish-to-Finish', // 前置任务完成后,后续任务才能完成 5 | SS = 'Start-to-Start', // 前置任务开始后,后续任务才能开始 6 | SF = 'Start-to-Finish' // 前置任务开始后,后续任务才能完成(最少见) 7 | } 8 | 9 | // 当前系统仅支持FS类型,其他类型为未来扩展预留 10 | export interface Dependency { 11 | fromTaskId: string; 12 | toTaskId: string; 13 | type: DependencyType; 14 | lag: number; // 延迟天数,正数表示延迟,负数表示提前 15 | } 16 | 17 | // 依赖关系计算策略接口 - 为支持不同依赖类型的计算提供扩展点 18 | export interface DependencyCalculationStrategy { 19 | calculateEarlyDates(fromTask: any, toTask: any, lag: number): void; 20 | calculateLateDates(fromTask: any, toTask: any, lag: number): void; 21 | } 22 | 23 | // FS依赖关系计算策略(当前版本实现) 24 | export class FSCalculationStrategy implements DependencyCalculationStrategy { 25 | calculateEarlyDates(fromTask: any, toTask: any, lag: number): void { 26 | // 前置任务结束后 + 延迟 = 后续任务最早开始 27 | const possibleStart = fromTask.earlyFinish + lag; 28 | if (toTask.earlyStart === undefined || possibleStart > toTask.earlyStart) { 29 | toTask.earlyStart = possibleStart; 30 | toTask.earlyFinish = toTask.earlyStart + toTask.duration; 31 | } 32 | } 33 | 34 | calculateLateDates(fromTask: any, toTask: any, lag: number): void { 35 | // 后续任务最晚开始 - 延迟 = 前置任务最晚结束 36 | const possibleFinish = toTask.lateStart - lag; 37 | if (fromTask.lateFinish === undefined || possibleFinish < fromTask.lateFinish) { 38 | fromTask.lateFinish = possibleFinish; 39 | fromTask.lateStart = fromTask.lateFinish - fromTask.duration; 40 | } 41 | } 42 | } 43 | 44 | // 工厂方法 - 根据依赖类型创建相应的计算策略 45 | export function createDependencyStrategy(type: DependencyType): DependencyCalculationStrategy { 46 | // 当前版本只支持FS,其他类型为未来扩展预留 47 | return new FSCalculationStrategy(); 48 | } -------------------------------------------------------------------------------- /src/hooks/ProjectContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, ReactNode } from 'react'; 2 | import { Task, Path } from '../models/Task'; 3 | import { useProjectData, SavedProjectData } from './useProjectData'; 4 | 5 | // Define the context type 6 | interface ProjectContextType { 7 | projectName: string; 8 | setProjectName: (name: string) => void; 9 | tasks: Task[]; 10 | paths: Path[]; 11 | criticalPaths: Path[]; 12 | projectDuration: number; 13 | isCalculated: boolean; 14 | error: string | null; 15 | selectedTaskId: string | null; 16 | setSelectedTaskId: (id: string | null) => void; 17 | addTask: (task: any) => void; 18 | updateTask: (task: Task) => void; 19 | updateTasks: (tasks: Task[]) => void; 20 | insertTaskBefore: (targetTaskId: string, newTask: any) => void; 21 | renumberTasks: (startIndex: number, increment?: number) => void; 22 | deleteTask: (taskId: string) => void; 23 | calculateSchedule: () => void; 24 | clearProject: () => void; 25 | saveProject: (projectName: string) => boolean; 26 | loadProject: (projectId: string) => boolean; 27 | reorderTasks: (oldIndex: number, newIndex: number) => void; 28 | updateTaskWithNewId: (originalId: string, updatedTask: Task) => void; 29 | getSavedProjects: () => SavedProjectData[]; 30 | deleteProject: (projectId: string) => boolean; 31 | updateProjectName: (projectId: string, newName: string) => boolean; 32 | } 33 | 34 | // Create the context 35 | const ProjectContext = createContext(undefined); 36 | 37 | // Create the provider component 38 | export const ProjectProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 39 | const projectData = useProjectData(); 40 | 41 | return ( 42 | 43 | {children} 44 | 45 | ); 46 | }; 47 | 48 | // Custom hook to use the Project context 49 | export const useProject = (): ProjectContextType => { 50 | const context = useContext(ProjectContext); 51 | 52 | if (context === undefined) { 53 | throw new Error('useProject must be used within a ProjectProvider'); 54 | } 55 | 56 | return context; 57 | }; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.1.2] - 2023-08-05 6 | 7 | ### Added 8 | - Fullscreen capability for main Network Diagram component 9 | - Reset button for reverting to original diagram layout in all views 10 | 11 | ### Changed 12 | - Standardized UI control positioning across all diagram components 13 | - Moved diagram controls to top-right corner for better consistency 14 | - Improved button labeling and icon usage 15 | 16 | ### Fixed 17 | - Diagram rendering and responsiveness in fullscreen mode 18 | - Button interaction and state management for fullscreen toggle 19 | 20 | ## [1.1.1] - 2023-07-30 21 | 22 | ### Added 23 | - Excel import functionality for Project Crashing module 24 | - Excel import functionality for Network Diagram module 25 | - Excel export feature with proper formatting for both modules 26 | - Position persistence for Network Diagram nodes 27 | 28 | ### Changed 29 | - Unified UI styles across all components 30 | - Improved button styling in management dialogs 31 | - Enhanced user interaction for loading projects 32 | 33 | ### Fixed 34 | - Restored Project Crashing import functionality 35 | - Fixed Network Diagram conditional rendering issues 36 | - Improved error handling for file imports 37 | - Better validation for imported Excel data 38 | 39 | ## [1.1.0] - 2023-07-28 40 | 41 | ### Added 42 | - Tooltip guidance for complex features 43 | - Improved error messaging 44 | - Additional validation for task inputs 45 | 46 | ### Changed 47 | - Enhanced UI/UX for better usability 48 | - Improved Project Crashing algorithm with better optimization 49 | - Reorganized component structure for maintainability 50 | - Updated dependencies to latest versions 51 | 52 | ### Fixed 53 | - Fixed bug in critical path calculation for complex networks 54 | - Resolved issue with task dependency visualization 55 | - Fixed crashing algorithm edge cases 56 | - Performance improvements for large project calculations 57 | - UI rendering issues on different screen sizes 58 | 59 | ## [1.0.0] - 2023-03-15 60 | 61 | ### Added 62 | - Initial release 63 | - Task management (create, edit, delete) 64 | - Critical Path Method (CPM) calculations 65 | - Network diagram visualization 66 | - Project Crashing feature 67 | - Cost slope calculations 68 | - Path analysis 69 | - Multiple crashing iterations support -------------------------------------------------------------------------------- /src/components/PathAnalysis.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Paper, 4 | Typography, 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableContainer, 9 | TableHead, 10 | TableRow, 11 | Chip, 12 | Box 13 | } from '@mui/material'; 14 | import { useProject } from '../hooks/ProjectContext'; 15 | 16 | // 路径分析组件 - 显示所有可能路径,标识关键路径 17 | const PathAnalysis: React.FC = () => { 18 | const { paths, criticalPaths, projectDuration, isCalculated, tasks } = useProject(); 19 | 20 | // 将任务ID列表转换为描述性文本 21 | const getPathText = (taskIds: string[]) => { 22 | return taskIds.map(id => { 23 | const task = tasks.find(t => t.id === id); 24 | return task ? `${id} (${task.duration})` : id; 25 | }).join(' → '); 26 | }; 27 | 28 | if (!isCalculated) { 29 | return ( 30 | 31 | 32 | Path Analysis 33 | 34 | 35 | Click "Generate Network Diagram" to calculate and analyze all possible paths through the project network. 36 | 37 | 38 | ); 39 | } 40 | 41 | return ( 42 | 43 | 44 | Path Analysis 45 | 46 | 47 | 48 | 49 | Project Duration: {projectDuration} days 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | Path 58 | Tasks 59 | Duration (days) 60 | Critical 61 | 62 | 63 | 64 | {paths.map((path, index) => ( 65 | 71 | {index + 1} 72 | {getPathText(path.tasks)} 73 | {path.duration} 74 | 75 | {path.isCritical && } 76 | 77 | 78 | ))} 79 | 80 |
81 |
82 | 83 | {criticalPaths.length > 0 && ( 84 | 85 | 86 | Critical Path{criticalPaths.length > 1 ? 's' : ''}: 87 | 88 | {criticalPaths.map((path, index) => ( 89 | 90 | {getPathText(path.tasks)} = {path.duration} days 91 | 92 | ))} 93 | 94 | )} 95 |
96 | ); 97 | }; 98 | 99 | export default PathAnalysis; -------------------------------------------------------------------------------- /INSTALLATION_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Project Management Tool - Installation Guide for Non-Technical Users 2 | 3 | This guide is designed for users who want to run the Project Management Tool but don't have programming experience. Follow these step-by-step instructions to get the application running on your Mac. 4 | 5 | ## Prerequisites 6 | 7 | You need to install Node.js, which is the environment that runs the application. 8 | 9 | ## Step 1: Install Node.js 10 | 11 | 1. Go to [https://nodejs.org/](https://nodejs.org/) 12 | 2. Download the "LTS" (Long Term Support) version for macOS 13 | 3. Open the downloaded file (it will be named something like `node-v22.x.x.pkg`) 14 | 4. Follow the installation wizard, keeping all default settings 15 | 5. Click "Install" when prompted (you may need to enter your Mac password) 16 | 6. Wait for the installation to complete and click "Close" 17 | 18 | ## Step 2: Download and Extract the Project 19 | 20 | ### Option A: Download from the Main Branch 21 | 1. Go to the GitHub repository page 22 | 2. Click the green "Code" button 23 | 3. Select "Download ZIP" 24 | 4. Once downloaded, locate the ZIP file in your Downloads folder 25 | 5. Double-click the ZIP file to extract it 26 | 6. Drag the extracted folder to your Desktop for easy access 27 | 28 | ### Option B: Download from a Specific Branch (Latest Updates) 29 | 1. Go to the GitHub repository page 30 | 2. Click on the dropdown menu that says "main" (it's located near the top-left, just above the file list) 31 | 3. From the dropdown, select the branch you want to download (e.g., "V1.1.2-release") 32 | 4. Once you've switched to the desired branch, click the green "Code" button 33 | 5. Select "Download ZIP" 34 | 6. Once downloaded, locate the ZIP file in your Downloads folder 35 | 7. Double-click the ZIP file to extract it 36 | 8. Drag the extracted folder to your Desktop for easy access 37 | 38 | ## Step 3: Run the Application Using Terminal 39 | 40 | 1. Open Terminal: 41 | - Press `Command (⌘) + Space` to open Spotlight 42 | - Type "Terminal" and press Enter 43 | 44 | 2. Navigate to your project folder: 45 | - Type or copy-paste the following command (replace `Project-Management-Tool-1.1.2-release` with your actual folder name if different): 46 | ``` 47 | cd ~/Desktop/Project-Management-Tool-1.1.2-release 48 | ``` 49 | - Press Enter 50 | 51 | 3. Install project dependencies: 52 | - Copy-paste this command: 53 | ``` 54 | npm install 55 | ``` 56 | - Press Enter 57 | - Wait for the installation to complete (this may take several minutes) 58 | 59 | 4. Start the application: 60 | - Copy-paste this command: 61 | ``` 62 | npm start 63 | ``` 64 | - Press Enter 65 | - The application will start and automatically open in your web browser 66 | - If the browser doesn't open automatically, open any web browser and go to: http://localhost:3000 67 | 68 | ## Opening the Application Again Later 69 | 70 | To open and run the application again in the future: 71 | 72 | 1. Open Terminal (Command + Space, type "Terminal", press Enter) 73 | 2. Type or copy-paste: 74 | ``` 75 | cd ~/Desktop/Project-Management-Tool-1.1.2-release 76 | npm start 77 | ``` 78 | 3. Wait for the browser to open with the application 79 | 80 | ## Troubleshooting 81 | 82 | ### If you see "command not found: npm" error: 83 | - Make sure you've completed Step 1 (installing Node.js) 84 | - Try closing and reopening Terminal 85 | - Restart your computer and try again 86 | 87 | ### If you see "Error: Cannot find module..." or similar: 88 | - Make sure you're in the correct folder 89 | - Try running `npm install` again 90 | 91 | ### If the application doesn't open in your browser: 92 | - Open your web browser manually 93 | - Go to the address: http://localhost:3000 94 | 95 | ### If you see "Error: EACCES: permission denied": 96 | - Try running the command with sudo: 97 | ``` 98 | sudo npm install 99 | ``` 100 | - You'll be asked for your Mac password 101 | 102 | ## Need More Help? 103 | 104 | If you're still having trouble getting the application to run, please contact me for assistance. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project Management Tool - Version 1.1.2 2 | 3 | A comprehensive project management application with Critical Path Method (CPM) and Project Crashing features. 4 | 5 | > **New to programming?** Follow our [detailed installation guide for non-technical users](INSTALLATION_GUIDE.md). 6 | 7 | ## What's New in Version 1.1.2 8 | 9 | - **Enhanced UI Controls**: Consistent fullscreen and reset functionality across all diagram components 10 | - **Improved User Experience**: Standardized button positioning in top-right corner for all diagrams 11 | - **Fullscreen Support**: Added fullscreen viewing capability to main Network Diagram 12 | - **Control Unification**: Aligned UI patterns between different diagram components 13 | 14 | ## What's New in Version 1.1.1 15 | 16 | - **Excel Import/Export**: Import and export project data using Excel files for both Network Diagram and Project Crashing 17 | - **Position Persistence**: Node positions in Network Diagram are now preserved across iterations 18 | - **Unified UI**: Consistent styling across all components for better user experience 19 | - **Improved Error Handling**: Better validation and error reporting for data imports 20 | 21 | ## Features 22 | 23 | - **Task Management**: Create, edit, and organize project tasks 24 | - **Critical Path Analysis**: Calculate project critical path 25 | - **Network Diagram**: Visualize project network using interactive graphs 26 | - **Project Crashing**: Reduce project duration with cost-effective task crashing 27 | - Calculate optimal task selection for crashing 28 | - Analyze cost impact of crashing activities 29 | - Visualize project paths and critical paths 30 | - Manage multiple crashing iterations 31 | - **Data Import/Export**: Save and load project data using Excel files 32 | 33 | ## Technologies 34 | 35 | - React 36 | - TypeScript 37 | - Material-UI 38 | - Context API for state management 39 | - Excel integration (XLSX) 40 | 41 | ## Installation 42 | 43 | ```bash 44 | # Install dependencies 45 | npm install 46 | 47 | # Start development server 48 | npm start 49 | 50 | # Build for production 51 | npm run build 52 | ``` 53 | 54 | ## Project Structure 55 | 56 | - `/src/components`: UI components 57 | - `/src/hooks`: Context providers and custom hooks 58 | - `/src/models`: TypeScript interfaces and models 59 | - `/src/services`: Business logic and algorithms 60 | 61 | ## Project Crashing Feature 62 | 63 | The project includes a complete implementation of the Project Crashing algorithm, which helps determine how to shorten project duration while minimizing costs. It includes: 64 | 65 | - Input of normal and crash parameters for tasks 66 | - Calculation of cost-slope values 67 | - Identification of critical paths 68 | - Step-by-step crashing process 69 | - Cost analysis for each crashing iteration 70 | - Import/export functionality with Excel 71 | 72 | 73 | 74 | 75 | ## Tech Stack 76 | 77 | - **Frontend**: 78 | - React (v18.x) with TypeScript 79 | - Material UI (MUI) v5 for UI components and styling 80 | - React Flow (v11.x) for network diagram visualization 81 | - @dnd-kit for accessible and performant drag-and-drop 82 | - **Development**: 83 | - Create React App (react-scripts v5.x) 84 | - ESLint for code linting 85 | 86 | ## Prerequisites 87 | 88 | Before you begin, ensure you have the following installed: 89 | - [Node.js](https://nodejs.org/) (LTS version recommended, e.g., v18.x or later) 90 | - [npm](https://www.npmjs.com/) (comes with Node.js) or [Yarn](https://yarnpkg.com/) 91 | 92 | ## Running the Project 93 | 94 | Once the dependencies are installed, you can start the development server: 95 | 96 | Using npm: 97 | ```bash 98 | npm start 99 | ``` 100 | Or using Yarn: 101 | ```bash 102 | yarn start 103 | ``` 104 | This will run the app in development mode. Open [http://localhost:3000](http://localhost:3000) to view it in your browser. The page will reload if you make edits. 105 | 106 | ## Available Scripts 107 | 108 | In the project directory, you can run: 109 | 110 | - `npm start` or `yarn start`: Runs the app in development mode. 111 | - `npm run build` or `yarn build`: Builds the app for production to the `build` folder. 112 | - `npm test` or `yarn test`: Launches the test runner in interactive watch mode. 113 | - `npm run eject` or `yarn eject`: Removes the single dependency configuration (Create React App's `react-scripts`). **Note: this is a one-way operation. Once you `eject`, you can't go back!** 114 | 115 | ## License 116 | 117 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 118 | 119 | ## Release History 120 | 121 | - **v1.1.2** - : Enhanced diagram controls with consistent UI patterns and fullscreen capability 122 | - **v1.1.1** - : Added Excel import/export, position persistence, unified UI styling 123 | - **v1.1.0** - : Enhanced UI/UX, performance improvements, bug fixes, improved crashing algorithm 124 | - **v1.0.0** - : Initial release with basic CPM and Project Crashing features 125 | 126 | --- 127 | 128 | Happy Project Managing! 129 | -------------------------------------------------------------------------------- /src/components/CrashingIterationSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Slider, 4 | Typography, 5 | Box, 6 | Paper, 7 | Grid, 8 | IconButton, 9 | Tooltip 10 | } from '@mui/material'; 11 | import PlayArrowIcon from '@mui/icons-material/PlayArrow'; 12 | import SkipPreviousIcon from '@mui/icons-material/SkipPrevious'; 13 | import SkipNextIcon from '@mui/icons-material/SkipNext'; 14 | import RestartAltIcon from '@mui/icons-material/RestartAlt'; 15 | import { useProjectCrashing } from '../hooks/ProjectCrashingContext'; 16 | 17 | interface CrashingIterationSliderProps { 18 | autoPlayEnabled?: boolean; 19 | } 20 | 21 | // Project Crashing迭代滑块组件 - 允许用户在不同迭代之间切换 22 | const CrashingIterationSlider: React.FC = ({ autoPlayEnabled = false }) => { 23 | const { 24 | isCrashed, 25 | currentIteration, 26 | setCurrentIteration, 27 | totalIterations, 28 | costAnalysis 29 | } = useProjectCrashing(); 30 | const [isAutoPlaying, setIsAutoPlaying] = React.useState(false); 31 | 32 | // 如果项目未压缩,不显示滑块 33 | if (!isCrashed || totalIterations === 0) { 34 | return null; 35 | } 36 | 37 | // 处理滑块值变化 38 | const handleSliderChange = (event: Event, newValue: number | number[]) => { 39 | setCurrentIteration(newValue as number); 40 | }; 41 | 42 | // 切换到上一个迭代 43 | const handlePrevious = () => { 44 | if (currentIteration > 0) { 45 | setCurrentIteration(currentIteration - 1); 46 | } 47 | }; 48 | 49 | // 切换到下一个迭代 50 | const handleNext = () => { 51 | if (currentIteration < totalIterations) { 52 | setCurrentIteration(currentIteration + 1); 53 | } 54 | }; 55 | 56 | // 重置到初始状态 57 | const handleReset = () => { 58 | setCurrentIteration(0); 59 | setIsAutoPlaying(false); 60 | }; 61 | 62 | // 自动播放功能 63 | const handleAutoPlay = () => { 64 | if (isAutoPlaying) { 65 | setIsAutoPlaying(false); 66 | return; 67 | } 68 | 69 | setIsAutoPlaying(true); 70 | let current = currentIteration; 71 | 72 | const interval = setInterval(() => { 73 | if (current < totalIterations) { 74 | current += 1; 75 | setCurrentIteration(current); 76 | } else { 77 | clearInterval(interval); 78 | setIsAutoPlaying(false); 79 | } 80 | }, 1500); // 每1.5秒前进一步 81 | 82 | // 当组件卸载时清除计时器 83 | return () => clearInterval(interval); 84 | }; 85 | 86 | // 获取当前迭代的状态文本 87 | const getStatusText = () => { 88 | const currentResult = costAnalysis[currentIteration]; 89 | if (currentIteration === 0) { 90 | return "Initial state (before crashing)"; 91 | } else if (currentResult.isOptimum) { 92 | return `Iteration ${currentIteration}: Optimum Point (Minimum Total Cost)`; 93 | } else if (currentResult.isCrashPoint) { 94 | return `Iteration ${currentIteration}: Crash Point (Cannot crash further)`; 95 | } else { 96 | return `Iteration ${currentIteration} of ${totalIterations}`; 97 | } 98 | }; 99 | 100 | return ( 101 | 102 | 103 | Crashing Timeline 104 | 105 | 106 | 107 | 108 | 109 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 136 | 137 | 138 | 139 | 140 | 141 | {autoPlayEnabled && ( 142 | 143 | 144 | 145 | 146 | 147 | )} 148 | 149 | 150 | 151 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 169 | 170 | {getStatusText()} 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | ); 179 | }; 180 | 181 | export default CrashingIterationSlider; -------------------------------------------------------------------------------- /src/components/TaskForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Box, 4 | TextField, 5 | Button, 6 | Typography, 7 | FormControl, 8 | InputLabel, 9 | Select, 10 | MenuItem, 11 | Chip, 12 | SelectChangeEvent, 13 | Paper, 14 | Snackbar, 15 | Alert 16 | } from '@mui/material'; 17 | import { Task } from '../models/Task'; 18 | import { useProject } from '../hooks/ProjectContext'; 19 | 20 | // 任务表单组件 - 用于添加新任务和编辑现有任务 21 | const TaskForm: React.FC = () => { 22 | const { tasks, addTask, isCalculated } = useProject(); 23 | 24 | // 表单状态 25 | const [id, setId] = useState(''); 26 | const [description, setDescription] = useState(''); 27 | const [duration, setDuration] = useState(''); 28 | const [predecessorIds, setPredecessorIds] = useState([]); 29 | const [error, setError] = useState(null); 30 | 31 | // 通知状态 32 | const [notificationOpen, setNotificationOpen] = useState(false); 33 | const [notificationMessage, setNotificationMessage] = useState(''); 34 | 35 | // 重置表单 36 | const resetForm = () => { 37 | setId(''); 38 | setDescription(''); 39 | setDuration(''); 40 | setPredecessorIds([]); 41 | setError(null); 42 | }; 43 | 44 | // 验证表单 45 | const validateForm = (): boolean => { 46 | if (!id.trim()) { 47 | setError('Task ID is required'); 48 | return false; 49 | } 50 | 51 | if (tasks.some(task => task.id === id.trim())) { 52 | setError('Task ID must be unique'); 53 | return false; 54 | } 55 | 56 | if (duration === '' || duration < 0) { 57 | setError('Duration must be a positive number'); 58 | return false; 59 | } 60 | 61 | // 检查循环依赖 62 | if (predecessorIds.includes(id)) { 63 | setError('A task cannot be its own predecessor'); 64 | return false; 65 | } 66 | 67 | setError(null); 68 | return true; 69 | }; 70 | 71 | // 提交表单 72 | const handleSubmit = (e: React.FormEvent) => { 73 | e.preventDefault(); 74 | 75 | if (!validateForm()) return; 76 | 77 | const newTask = { 78 | id: id.trim(), 79 | description: description.trim(), 80 | duration: Number(duration), 81 | predecessorIds 82 | }; 83 | 84 | addTask(newTask); 85 | 86 | // 显示通知 87 | setNotificationMessage(`Task "${newTask.id}" added successfully`); 88 | setNotificationOpen(true); 89 | 90 | resetForm(); 91 | }; 92 | 93 | // 处理前置任务选择变化 94 | const handlePredecessorChange = (event: SelectChangeEvent) => { 95 | const value = event.target.value; 96 | setPredecessorIds(typeof value === 'string' ? value.split(',') : value); 97 | }; 98 | 99 | // 关闭通知 100 | const handleCloseNotification = () => { 101 | setNotificationOpen(false); 102 | }; 103 | 104 | return ( 105 | 106 | 107 | Add New Task 108 | 109 | 110 | 111 | 112 | setId(e.target.value)} 117 | size="small" 118 | disabled={isCalculated} 119 | error={!!error && error.includes('ID')} 120 | sx={{ width: '30%' }} 121 | /> 122 | 123 | setDescription(e.target.value)} 127 | size="small" 128 | disabled={isCalculated} 129 | error={!!error && error.includes('Description')} 130 | sx={{ width: '70%' }} 131 | /> 132 | 133 | 134 | 135 | setDuration(e.target.value === '' ? '' : Number(e.target.value))} 141 | size="small" 142 | inputProps={{ min: 0 }} 143 | disabled={isCalculated} 144 | error={!!error && error.includes('Duration')} 145 | sx={{ width: '30%' }} 146 | /> 147 | 148 | 149 | Predecessors 150 | 169 | 170 | 171 | 172 | {error && ( 173 | 174 | {error} 175 | 176 | )} 177 | 178 | 186 | 187 | 188 | {/* 成功添加任务的通知 */} 189 | 195 | 196 | {notificationMessage} 197 | 198 | 199 | 200 | ); 201 | }; 202 | 203 | export default TaskForm; -------------------------------------------------------------------------------- /src/components/CrashingPathAnalysis.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { 3 | Paper, 4 | Typography, 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableContainer, 9 | TableHead, 10 | TableRow, 11 | Box, 12 | IconButton, 13 | Tooltip 14 | } from '@mui/material'; 15 | import InfoIcon from '@mui/icons-material/Info'; 16 | import { useProjectCrashing } from '../hooks/ProjectCrashingContext'; 17 | 18 | // Project Crashing路径分析组件 - 显示项目中的所有路径及其在各个迭代中的持续时间 19 | const CrashingPathAnalysis: React.FC = () => { 20 | const { 21 | crashPaths, 22 | isCrashed, 23 | currentIteration, 24 | totalIterations, 25 | projectDuration 26 | } = useProjectCrashing(); 27 | 28 | // 计算每个迭代中的最大持续时间 - 移到条件检查之前 29 | const maxDurationsByIteration = useMemo(() => { 30 | const result: number[] = []; 31 | 32 | // 对于每个迭代,找出所有路径中的最大持续时间 33 | for (let i = 0; i <= totalIterations; i++) { 34 | let maxDuration = 0; 35 | 36 | crashPaths.forEach(path => { 37 | if (path.durations[i] !== undefined && path.durations[i] > maxDuration) { 38 | maxDuration = path.durations[i]; 39 | } 40 | }); 41 | 42 | result[i] = maxDuration; 43 | } 44 | 45 | return result; 46 | }, [crashPaths, totalIterations]); 47 | 48 | // 计算最长路径的宽度,用于设置 Path 列的宽度 49 | const longestPathWidth = useMemo(() => { 50 | if (!crashPaths || crashPaths.length === 0) return 200; // 默认宽度 51 | 52 | // 获取每个路径的文本长度 53 | const pathLengths = crashPaths.map(path => { 54 | const pathText = path.tasks.join(' → '); 55 | return pathText.length; 56 | }); 57 | 58 | // 找出最长的路径长度 59 | const maxLength = Math.max(...pathLengths); 60 | 61 | // 基于最长路径长度计算列宽,每个字符大约 8px,并添加一些额外空间 62 | return Math.max(200, maxLength * 8 + 40); // 确保至少有 200px 宽度 63 | }, [crashPaths]); 64 | 65 | if (!isCrashed) { 66 | return ( 67 | 68 | 69 | Path Analysis 70 | 71 | 72 | Click "Crashing Project" to perform project crashing analysis and view all paths through the network. 73 | 74 | 75 | ); 76 | } 77 | 78 | // 定义迭代列的宽度 79 | const iterationColumnWidth = 80; // 增大迭代列宽度为80px 80 | 81 | // 生成表头(显示所有可能的迭代) 82 | const iterationHeaders = []; 83 | for (let i = 0; i <= totalIterations; i++) { 84 | iterationHeaders.push( 85 | 94 | I{i} 95 | 96 | ); 97 | } 98 | 99 | // 将任务ID列表转换为路径文本 100 | const getPathText = (taskIds: string[]): string => { 101 | return taskIds.join(' → '); 102 | }; 103 | 104 | // 判断某个路径在某个迭代是否是关键路径 105 | const isCriticalInIteration = (path: typeof crashPaths[0], iteration: number): boolean => { 106 | return path.durations[iteration] !== undefined && 107 | path.durations[iteration] === maxDurationsByIteration[iteration] && 108 | maxDurationsByIteration[iteration] > 0; // 防止所有路径持续时间都是0 109 | }; 110 | 111 | return ( 112 | 113 | 114 | 115 | Path Analysis 116 | 117 | 120 | 121 | I0 represents the initial path durations before crashing, I1 is the first iteration, and so on. 122 | The paths with the longest duration at each iteration (highlighted in red) determine the project duration. 123 | Slide the iteration slider to see how paths change with each crash. 124 | 125 | 126 | } 127 | arrow 128 | placement="right" 129 | > 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | Project Duration: {projectDuration} days 139 | 140 | 141 | {currentIteration > 0 142 | ? `Iteration ${currentIteration} of ${totalIterations}` 143 | : 'Initial state (before crashing)'} 144 | 145 | 146 | 147 | 157 | 158 | 159 | 160 | 171 | Path 172 | 173 | {iterationHeaders} 174 | 175 | 176 | 177 | {crashPaths.map((path, index) => ( 178 | 179 | 193 | {getPathText(path.tasks)} 194 | 195 | {/* 显示所有迭代列,但只有当前迭代及之前的有实际数据 */} 196 | {Array.from({ length: totalIterations + 1 }, (_, i) => { 197 | // 判断是否应该高亮显示(是关键路径且不超过当前迭代) 198 | const shouldHighlight = i <= currentIteration && isCriticalInIteration(path, i); 199 | 200 | return ( 201 | 212 | {i <= currentIteration && path.durations[i] !== undefined ? path.durations[i] : '-'} 213 | 214 | ); 215 | })} 216 | 217 | ))} 218 | 219 |
220 |
221 |
222 | ); 223 | }; 224 | 225 | export default CrashingPathAnalysis; -------------------------------------------------------------------------------- /src/components/CrashingTaskEditDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Dialog, 4 | DialogTitle, 5 | DialogContent, 6 | DialogActions, 7 | TextField, 8 | Button, 9 | FormControl, 10 | InputLabel, 11 | Select, 12 | MenuItem, 13 | Chip, 14 | Box, 15 | Typography, 16 | SelectChangeEvent 17 | } from '@mui/material'; 18 | import { useProjectCrashing, CrashTask } from '../hooks/ProjectCrashingContext'; 19 | import { DependencyType } from '../models/Dependency'; 20 | 21 | interface CrashingTaskEditDialogProps { 22 | open: boolean; 23 | onClose: () => void; 24 | selectedTaskId: string; 25 | } 26 | 27 | const CrashingTaskEditDialog: React.FC = ({ 28 | open, 29 | onClose, 30 | selectedTaskId 31 | }) => { 32 | const { 33 | crashTasks, 34 | updateCrashTask 35 | } = useProjectCrashing(); 36 | 37 | // 当前编辑的任务 38 | const selectedTask = crashTasks.find(task => task.id === selectedTaskId); 39 | 40 | // 表单字段状态 41 | const [id, setId] = useState(''); 42 | const [description, setDescription] = useState(''); 43 | const [normalTime, setNormalTime] = useState(''); 44 | const [normalCost, setNormalCost] = useState(''); 45 | const [crashTime, setCrashTime] = useState(''); 46 | const [crashCost, setCrashCost] = useState(''); 47 | const [predecessors, setPredecessors] = useState([]); 48 | const [availablePredecessors, setAvailablePredecessors] = useState([]); 49 | const [originalId, setOriginalId] = useState(''); 50 | 51 | // 错误状态 52 | const [errors, setErrors] = useState>({}); 53 | 54 | // 当选中的任务变化时,更新表单字段 55 | useEffect(() => { 56 | if (selectedTask) { 57 | setId(selectedTask.id); 58 | setOriginalId(selectedTask.id); 59 | setDescription(selectedTask.description || ''); 60 | setNormalTime(selectedTask.normalTime.toString()); 61 | setNormalCost(selectedTask.normalCost.toString()); 62 | setCrashTime(selectedTask.crashTime.toString()); 63 | setCrashCost(selectedTask.crashCost.toString()); 64 | setPredecessors(selectedTask.predecessors.map(pred => pred.taskId)); 65 | 66 | // 更新可用的前置任务列表(不包括自己和可能导致循环依赖的任务) 67 | const possiblePredecessors = crashTasks 68 | .filter(task => task.id !== selectedTask.id) 69 | .map(task => task.id); 70 | setAvailablePredecessors(possiblePredecessors); 71 | } 72 | }, [selectedTask, crashTasks]); 73 | 74 | // 处理前置任务变化 75 | const handlePredecessorsChange = (event: SelectChangeEvent) => { 76 | const value = event.target.value; 77 | setPredecessors(typeof value === 'string' ? value.split(',') : value); 78 | }; 79 | 80 | // 验证表单 81 | const validateForm = () => { 82 | const newErrors: Record = {}; 83 | 84 | // 验证ID 85 | if (!id.trim()) { 86 | newErrors.id = 'Task ID is required'; 87 | } else if (id.trim() !== originalId && crashTasks.some(task => task.id === id.trim())) { 88 | newErrors.id = 'Task ID must be unique'; 89 | } 90 | 91 | // 验证时间和成本字段 92 | if (!normalTime.trim() || isNaN(Number(normalTime)) || Number(normalTime) <= 0) { 93 | newErrors.normalTime = 'Normal time must be a positive number'; 94 | } 95 | 96 | if (!normalCost.trim() || isNaN(Number(normalCost)) || Number(normalCost) < 0) { 97 | newErrors.normalCost = 'Normal cost must be a non-negative number'; 98 | } 99 | 100 | if (!crashTime.trim() || isNaN(Number(crashTime)) || Number(crashTime) <= 0) { 101 | newErrors.crashTime = 'Crash time must be a positive number'; 102 | } else if (Number(crashTime) > Number(normalTime)) { 103 | newErrors.crashTime = 'Crash time cannot be greater than normal time'; 104 | } 105 | 106 | if (!crashCost.trim() || isNaN(Number(crashCost)) || Number(crashCost) < 0) { 107 | newErrors.crashCost = 'Crash cost must be a non-negative number'; 108 | } 109 | 110 | setErrors(newErrors); 111 | return Object.keys(newErrors).length === 0; 112 | }; 113 | 114 | // 处理保存 115 | const handleSave = () => { 116 | if (!validateForm() || !selectedTask) return; 117 | 118 | // 如果ID已更改,需要更新所有引用到此任务的前置关系 119 | const idChanged = id.trim() !== originalId; 120 | 121 | // 创建更新后的任务对象 122 | const updatedTask: CrashTask = { 123 | ...selectedTask, 124 | id: id.trim(), 125 | description: description.trim(), 126 | normalTime: Number(normalTime), 127 | normalCost: Number(normalCost), 128 | crashTime: Number(crashTime), 129 | crashCost: Number(crashCost), 130 | predecessors: predecessors.map(predId => ({ 131 | taskId: predId, 132 | type: DependencyType.FS, 133 | lag: 0 // 添加必需的lag属性 134 | })) 135 | }; 136 | 137 | // 更新任务 138 | updateCrashTask(selectedTask.id, updatedTask); 139 | onClose(); 140 | }; 141 | 142 | // 处理取消 143 | const handleCancel = () => { 144 | onClose(); 145 | }; 146 | 147 | return ( 148 | 149 | Edit Task 150 | 151 | 152 | setId(e.target.value)} 156 | error={!!errors.id} 157 | helperText={errors.id || ''} 158 | fullWidth 159 | margin="dense" 160 | /> 161 | 162 | setDescription(e.target.value)} 166 | fullWidth 167 | margin="dense" 168 | /> 169 | 170 | 171 | setNormalTime(e.target.value)} 176 | error={!!errors.normalTime} 177 | helperText={errors.normalTime || ''} 178 | fullWidth 179 | margin="dense" 180 | /> 181 | 182 | setNormalCost(e.target.value)} 187 | error={!!errors.normalCost} 188 | helperText={errors.normalCost || ''} 189 | fullWidth 190 | margin="dense" 191 | /> 192 | 193 | 194 | 195 | setCrashTime(e.target.value)} 200 | error={!!errors.crashTime} 201 | helperText={errors.crashTime || ''} 202 | fullWidth 203 | margin="dense" 204 | /> 205 | 206 | setCrashCost(e.target.value)} 211 | error={!!errors.crashCost} 212 | helperText={errors.crashCost || ''} 213 | fullWidth 214 | margin="dense" 215 | /> 216 | 217 | 218 | 219 | Predecessors 220 | 239 | 240 | 241 | 242 | 243 | 244 | 247 | 248 | 249 | ); 250 | }; 251 | 252 | export default CrashingTaskEditDialog; -------------------------------------------------------------------------------- /src/components/CrashingCostAnalysis.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Paper, 4 | Typography, 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableContainer, 9 | TableHead, 10 | TableRow, 11 | Box, 12 | Tooltip, 13 | Chip, 14 | IconButton 15 | } from '@mui/material'; 16 | import InfoIcon from '@mui/icons-material/Info'; 17 | import CheckCircleIcon from '@mui/icons-material/CheckCircle'; 18 | import FlagIcon from '@mui/icons-material/Flag'; 19 | import { useProjectCrashing } from '../hooks/ProjectCrashingContext'; 20 | 21 | // Project Crashing成本分析组件 - 显示每次迭代的成本分析结果 22 | const CrashingCostAnalysis: React.FC = () => { 23 | const { 24 | costAnalysis, 25 | isCrashed, 26 | currentIteration, 27 | indirectCost, 28 | reductionPerUnit 29 | } = useProjectCrashing(); 30 | 31 | if (!isCrashed) { 32 | return ( 33 | 34 | 35 | Cost Analysis 36 | 37 | 38 | Click "Crashing Project" to perform project crashing analysis and view the cost analysis results. 39 | 40 | 41 | ); 42 | } 43 | 44 | // 格式化金额为货币格式 45 | const formatCurrency = (amount: number): string => { 46 | return amount.toLocaleString('en-US', { 47 | style: 'currency', 48 | currency: 'USD', 49 | minimumFractionDigits: 2, 50 | maximumFractionDigits: 2 51 | }); 52 | }; 53 | 54 | // 格式化活动列表 55 | const formatActivities = (activities: string[]): string => { 56 | return activities.length > 0 ? activities.join(', ') : '-'; 57 | }; 58 | 59 | // 格式化活动列表和相关成本 - 更明显地显示 60 | const formatActivitiesWithCost = (activities: string[], crashCost: number, index: number): React.ReactNode => { 61 | if (index === 0 || activities.length === 0) return '-'; 62 | 63 | // 只显示活动列表,不显示组合成本 64 | return formatActivities(activities); 65 | }; 66 | 67 | // 定义固定宽度的样式 68 | const columnWidths = { 69 | iteration: { width: '80px', minWidth: '80px' }, 70 | duration: { width: '120px', minWidth: '120px' }, 71 | activities: { width: '180px', minWidth: '180px' }, 72 | crashCost: { width: '110px', minWidth: '110px' }, 73 | directCost: { width: '110px', minWidth: '110px' }, 74 | indirectCost: { width: '110px', minWidth: '110px' }, 75 | totalCost: { width: '110px', minWidth: '110px' }, 76 | status: { width: '120px', minWidth: '120px' } 77 | }; 78 | 79 | // 表头单元格通用样式 80 | const headerCellStyle = { 81 | whiteSpace: 'nowrap', 82 | fontWeight: 'bold', 83 | backgroundColor: 'rgba(0, 0, 0, 0.04)' 84 | }; 85 | 86 | return ( 87 | 88 | 89 | 90 | Cost Analysis 91 | 92 | 95 | 96 | Optimum Point represents the iteration with the minimum total cost. 97 | 98 | 99 | Crash Point represents the iteration where the project cannot be crashed further. 100 | 101 | 102 | For each time unit shortened, direct cost increases based on crash cost, while indirect cost decreases by {formatCurrency(reductionPerUnit)}. 103 | 104 | 105 | } 106 | arrow 107 | placement="right" 108 | > 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | Total Indirect Cost: {formatCurrency(indirectCost)} 118 | 119 | 120 | Cost Reduction: {formatCurrency(reductionPerUnit)} per time unit shortened 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | Iteration 129 | Project Duration 130 | Crashed Activities 131 | Crash Cost 132 | Direct Cost 133 | Indirect Cost 134 | Total Cost 135 | Status 136 | 137 | 138 | 139 | {costAnalysis.slice(0, currentIteration + 1).map((result, index) => ( 140 | 152 | 153 | {index === 0 ? 'Initial' : index} 154 | 155 | {result.projectDuration} 156 | 157 | {formatActivitiesWithCost(result.crashedActivities, result.crashCost, index)} 158 | 159 | {index === 0 ? '-' : formatCurrency(result.crashCost)} 160 | {formatCurrency(result.directCost)} 161 | {formatCurrency(result.indirectCost)} 162 | 163 | {formatCurrency(result.totalCost)} 164 | 165 | 166 | 172 | {result.isOptimum && ( 173 | 174 | } 176 | label="Optimum" 177 | size="small" 178 | color="success" 179 | variant="outlined" 180 | sx={{ width: '90px' }} 181 | /> 182 | 183 | )} 184 | {result.isCrashPoint && ( 185 | 186 | } 188 | label="Crash Point" 189 | size="small" 190 | color="warning" 191 | variant="outlined" 192 | sx={{ width: '90px' }} 193 | /> 194 | 195 | )} 196 | {index === currentIteration && !result.isOptimum && !result.isCrashPoint && ( 197 | 198 | 205 | 206 | )} 207 | 208 | 209 | 210 | ))} 211 | 212 |
213 |
214 |
215 | ); 216 | }; 217 | 218 | export default CrashingCostAnalysis; -------------------------------------------------------------------------------- /src/services/ProjectScheduler.ts: -------------------------------------------------------------------------------- 1 | import { Task, Path, Predecessor } from '../models/Task'; 2 | import { DependencyType, createDependencyStrategy } from '../models/Dependency'; 3 | 4 | export class ProjectScheduler { 5 | private tasks: Task[] = []; 6 | private paths: Path[] = []; 7 | private criticalPaths: Path[] = []; 8 | 9 | constructor(tasks: Task[]) { 10 | // 创建任务的深拷贝,避免修改原始数据 11 | this.tasks = JSON.parse(JSON.stringify(tasks)); 12 | } 13 | 14 | /** 15 | * 计算项目调度,包括关键路径、各任务的ES、EF、LS、LF和浮动时间 16 | */ 17 | public calculateSchedule(): void { 18 | if (this.tasks.length === 0) return; 19 | 20 | // 检查项目图是否有循环依赖 21 | this.validateNoCyclicDependencies(); 22 | 23 | // 计算早期日期 (Early Start, Early Finish) 24 | this.calculateEarlyDates(); 25 | 26 | // 计算晚期日期 (Late Start, Late Finish) 27 | this.calculateLateDates(); 28 | 29 | // 计算浮动时间和标记关键路径 30 | this.calculateSlackAndMarkCriticalPath(); 31 | 32 | // 找出所有路径 33 | this.findAllPaths(); 34 | } 35 | 36 | /** 37 | * 获取计算后的任务列表 38 | */ 39 | public getCalculatedTasks(): Task[] { 40 | return this.tasks; 41 | } 42 | 43 | /** 44 | * 获取所有路径 45 | */ 46 | public getAllPaths(): Path[] { 47 | return this.paths; 48 | } 49 | 50 | /** 51 | * 获取关键路径 52 | */ 53 | public getCriticalPaths(): Path[] { 54 | return this.criticalPaths; 55 | } 56 | 57 | /** 58 | * 获取项目总持续时间 59 | */ 60 | public getProjectDuration(): number { 61 | const lastTasks = this.findEndTasks(); 62 | if (lastTasks.length === 0) return 0; 63 | 64 | // 项目持续时间是所有结束节点中最大的earlyFinish 65 | return Math.max(...lastTasks.map(task => task.earlyFinish || 0)); 66 | } 67 | 68 | /** 69 | * 验证项目图中没有循环依赖 70 | */ 71 | private validateNoCyclicDependencies(): void { 72 | const visited = new Set(); 73 | const recStack = new Set(); 74 | 75 | const isCyclic = (taskId: string): boolean => { 76 | if (!visited.has(taskId)) { 77 | visited.add(taskId); 78 | recStack.add(taskId); 79 | 80 | const task = this.findTaskById(taskId); 81 | if (task) { 82 | for (const pred of task.predecessors) { 83 | if (!visited.has(pred.taskId) && isCyclic(pred.taskId)) { 84 | return true; 85 | } else if (recStack.has(pred.taskId)) { 86 | return true; 87 | } 88 | } 89 | } 90 | } 91 | recStack.delete(taskId); 92 | return false; 93 | }; 94 | 95 | for (const task of this.tasks) { 96 | if (!visited.has(task.id) && isCyclic(task.id)) { 97 | throw new Error('Cyclic dependency detected in project graph'); 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * 计算所有任务的早期开始和早期结束日期 104 | */ 105 | private calculateEarlyDates(): void { 106 | // 找出起始任务(没有前置任务的任务) 107 | const startTasks = this.findStartTasks(); 108 | 109 | // 初始化所有任务的早期日期 110 | this.tasks.forEach(task => { 111 | task.earlyStart = undefined; 112 | task.earlyFinish = undefined; 113 | }); 114 | 115 | // 将起始任务的早期开始设为0 116 | startTasks.forEach(task => { 117 | task.earlyStart = 0; 118 | task.earlyFinish = task.duration; 119 | }); 120 | 121 | // 拓扑排序 122 | const sortedTasks = this.topologicalSort(); 123 | 124 | // 根据拓扑顺序计算每个任务的早期日期 125 | for (const task of sortedTasks) { 126 | // 寻找进入当前任务的所有任务 127 | this.tasks.forEach(fromTask => { 128 | const dependency = fromTask.predecessors.find(p => p.taskId === task.id); 129 | if (dependency) { 130 | const strategy = createDependencyStrategy(dependency.type); 131 | strategy.calculateEarlyDates(task, fromTask, dependency.lag); 132 | } 133 | }); 134 | 135 | // 如果此任务有前置任务,则计算其早期开始和结束 136 | if (task.predecessors.length > 0) { 137 | let maxEarlyStart = 0; 138 | 139 | for (const pred of task.predecessors) { 140 | const predTask = this.findTaskById(pred.taskId); 141 | if (predTask && predTask.earlyFinish !== undefined) { 142 | const earlyStart = predTask.earlyFinish + (pred.lag || 0); 143 | maxEarlyStart = Math.max(maxEarlyStart, earlyStart); 144 | } 145 | } 146 | 147 | task.earlyStart = maxEarlyStart; 148 | task.earlyFinish = maxEarlyStart + task.duration; 149 | } 150 | } 151 | } 152 | 153 | /** 154 | * 计算所有任务的晚期开始和晚期结束日期 155 | */ 156 | private calculateLateDates(): void { 157 | // 找出结束任务(没有后续任务的任务) 158 | const endTasks = this.findEndTasks(); 159 | 160 | // 项目总持续时间 161 | const projectDuration = Math.max(...endTasks.map(task => task.earlyFinish || 0)); 162 | 163 | // 初始化所有任务的晚期日期 164 | this.tasks.forEach(task => { 165 | task.lateStart = undefined; 166 | task.lateFinish = undefined; 167 | }); 168 | 169 | // 设置结束任务的晚期结束为项目总持续时间 170 | endTasks.forEach(task => { 171 | task.lateFinish = projectDuration; 172 | task.lateStart = task.lateFinish - task.duration; 173 | }); 174 | 175 | // 逆拓扑排序 176 | const reversedSortedTasks = this.topologicalSort().reverse(); 177 | 178 | // 根据逆拓扑顺序计算每个任务的晚期日期 179 | for (const task of reversedSortedTasks) { 180 | // 找出所有以此任务为前置任务的任务 181 | const successors = this.tasks.filter(t => 182 | t.predecessors.some(p => p.taskId === task.id) 183 | ); 184 | 185 | if (successors.length > 0) { 186 | let minLateFinish = Number.MAX_VALUE; 187 | 188 | for (const successor of successors) { 189 | if (successor.lateStart !== undefined) { 190 | const pred = successor.predecessors.find(p => p.taskId === task.id); 191 | const lag = pred ? pred.lag : 0; 192 | const lateFinish = successor.lateStart - lag; 193 | minLateFinish = Math.min(minLateFinish, lateFinish); 194 | } 195 | } 196 | 197 | if (minLateFinish !== Number.MAX_VALUE) { 198 | task.lateFinish = minLateFinish; 199 | task.lateStart = task.lateFinish - task.duration; 200 | } 201 | } 202 | } 203 | } 204 | 205 | /** 206 | * 计算浮动时间并标记关键路径上的任务 207 | */ 208 | private calculateSlackAndMarkCriticalPath(): void { 209 | this.tasks.forEach(task => { 210 | if (task.earlyStart !== undefined && task.lateStart !== undefined) { 211 | task.slack = task.lateStart - task.earlyStart; 212 | task.isCritical = task.slack === 0; 213 | } 214 | }); 215 | } 216 | 217 | /** 218 | * 拓扑排序算法 219 | */ 220 | private topologicalSort(): Task[] { 221 | const visited = new Set(); 222 | const result: Task[] = []; 223 | 224 | const visit = (taskId: string) => { 225 | if (visited.has(taskId)) return; 226 | 227 | visited.add(taskId); 228 | 229 | const task = this.findTaskById(taskId); 230 | if (!task) return; 231 | 232 | for (const pred of task.predecessors) { 233 | visit(pred.taskId); 234 | } 235 | 236 | result.push(task); 237 | }; 238 | 239 | for (const task of this.tasks) { 240 | if (!visited.has(task.id)) { 241 | visit(task.id); 242 | } 243 | } 244 | 245 | return result; 246 | } 247 | 248 | /** 249 | * 找出所有起始任务(没有前置任务的任务) 250 | */ 251 | private findStartTasks(): Task[] { 252 | return this.tasks.filter(task => task.predecessors.length === 0); 253 | } 254 | 255 | /** 256 | * 找出所有结束任务(没有后续任务的任务) 257 | */ 258 | private findEndTasks(): Task[] { 259 | const successorIds = new Set(); 260 | 261 | this.tasks.forEach(task => { 262 | task.predecessors.forEach(pred => { 263 | successorIds.add(pred.taskId); 264 | }); 265 | }); 266 | 267 | return this.tasks.filter(task => !successorIds.has(task.id)); 268 | } 269 | 270 | /** 271 | * 根据ID查找任务 272 | */ 273 | private findTaskById(id: string): Task | undefined { 274 | return this.tasks.find(task => task.id === id); 275 | } 276 | 277 | /** 278 | * 找出所有可能的路径 279 | */ 280 | private findAllPaths(): void { 281 | this.paths = []; 282 | const startTasks = this.findStartTasks(); 283 | const endTasks = this.findEndTasks(); 284 | 285 | for (const startTask of startTasks) { 286 | for (const endTask of endTasks) { 287 | this.findPathsHelper(startTask, endTask, [startTask.id], 0); 288 | } 289 | } 290 | 291 | // 标记关键路径 292 | this.criticalPaths = this.paths.filter(path => { 293 | return path.duration === this.getProjectDuration(); 294 | }); 295 | 296 | // 确保所有路径上的关键标记正确性 297 | this.criticalPaths.forEach(path => { 298 | path.isCritical = true; 299 | }); 300 | } 301 | 302 | /** 303 | * 递归查找路径的辅助函数 304 | */ 305 | private findPathsHelper(currentTask: Task, endTask: Task, currentPath: string[], currentDuration: number): void { 306 | if (currentTask.id === endTask.id) { 307 | // 找到一条从起始到结束的路径 308 | this.paths.push({ 309 | tasks: [...currentPath], 310 | duration: currentDuration + currentTask.duration, 311 | isCritical: false // 暂时设为false,后续会更新 312 | }); 313 | return; 314 | } 315 | 316 | // 找出所有以currentTask为前置任务的任务 317 | const successors = this.tasks.filter(task => 318 | task.predecessors.some(pred => pred.taskId === currentTask.id) 319 | ); 320 | 321 | for (const successor of successors) { 322 | // 避免循环 323 | if (!currentPath.includes(successor.id)) { 324 | const newPath = [...currentPath, successor.id]; 325 | this.findPathsHelper(successor, endTask, newPath, currentDuration + currentTask.duration); 326 | } 327 | } 328 | } 329 | } -------------------------------------------------------------------------------- /src/components/CrashingTaskTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Paper, 4 | Table, 5 | TableBody, 6 | TableCell, 7 | TableContainer, 8 | TableHead, 9 | TableRow, 10 | Typography, 11 | IconButton, 12 | Chip, 13 | Box, 14 | Tooltip, 15 | Alert 16 | } from '@mui/material'; 17 | import DeleteIcon from '@mui/icons-material/Delete'; 18 | import EditIcon from '@mui/icons-material/Edit'; 19 | import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; 20 | import InfoIcon from '@mui/icons-material/Info'; 21 | import { useProjectCrashing } from '../hooks/ProjectCrashingContext'; 22 | import { CrashTask } from '../hooks/ProjectCrashingContext'; 23 | import CrashingTaskEditDialog from './CrashingTaskEditDialog'; 24 | 25 | // Project Crashing 任务表格组件 - 显示任务列表及相关信息 26 | const CrashingTaskTable: React.FC = () => { 27 | const { crashTasks, deleteCrashTask, isCrashed } = useProjectCrashing(); 28 | const [editDialogOpen, setEditDialogOpen] = useState(false); 29 | const [selectedTaskId, setSelectedTaskId] = useState(''); 30 | 31 | // 格式化浮点数,整数直接返回,浮点数保留2位小数 32 | const formatNumber = (num: number | undefined): string => { 33 | if (num === undefined) return '-'; 34 | if (num === Number.MAX_SAFE_INTEGER) return 'N/A'; 35 | return Number.isInteger(num) ? num.toString() : num.toFixed(2); 36 | }; 37 | 38 | // 处理删除任务 39 | const handleDelete = (taskId: string) => { 40 | if (!isCrashed && window.confirm(`Are you sure you want to delete task ${taskId}?`)) { 41 | deleteCrashTask(taskId); 42 | } 43 | }; 44 | 45 | // 处理编辑任务 46 | const handleEdit = (taskId: string) => { 47 | setSelectedTaskId(taskId); 48 | setEditDialogOpen(true); 49 | }; 50 | 51 | // 关闭编辑对话框 52 | const handleCloseDialog = () => { 53 | setEditDialogOpen(false); 54 | }; 55 | 56 | // 定义固定宽度的样式 57 | const columnWidths = { 58 | id: { width: '80px', minWidth: '80px' }, 59 | predecessors: { width: '120px', minWidth: '120px' }, 60 | slope: { width: '100px', minWidth: '100px' }, 61 | maxCrashTime: { width: '140px', minWidth: '140px' }, 62 | normalTime: { width: '130px', minWidth: '130px' }, 63 | normalCost: { width: '130px', minWidth: '130px' }, 64 | crashTime: { width: '130px', minWidth: '130px' }, 65 | crashCost: { width: '130px', minWidth: '130px' }, 66 | actions: { width: '100px', minWidth: '100px' } 67 | }; 68 | 69 | // 如果没有任务,显示提示信息 70 | if (crashTasks.length === 0) { 71 | return ( 72 | 73 | 74 | Task List 75 | 76 | 77 | No tasks added yet. Use the form above to add new tasks. 78 | 79 | 80 | ); 81 | } 82 | 83 | return ( 84 | 85 | 86 | 87 | Task List 88 | 89 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | ID 105 | Predecessors 106 | 107 | 108 | Slope 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | Max Crash Time 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | Normal Time 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | Normal Cost 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | Crash Time 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | Crash Cost 149 | 150 | 151 | 152 | 153 | 154 | Actions 155 | 156 | 157 | 158 | {crashTasks.map((task) => ( 159 | 165 | {task.id} 166 | 167 | 168 | {task.predecessors.map((pred) => ( 169 | 175 | ))} 176 | 177 | 178 | {formatNumber(task.slope)} 179 | {formatNumber(task.maxCrashTime)} 180 | {task.normalTime} 181 | {task.normalCost} 182 | {task.crashTime} 183 | {task.crashCost} 184 | 185 | 186 | handleEdit(task.id)} 189 | disabled={isCrashed} 190 | color="primary" 191 | sx={{ mr: 1 }} 192 | > 193 | 194 | 195 | handleDelete(task.id)} 198 | disabled={isCrashed} 199 | color="error" 200 | > 201 | 202 | 203 | 204 | 205 | 206 | ))} 207 | 208 |
209 |
210 | 211 | 216 |
217 | ); 218 | }; 219 | 220 | export default CrashingTaskTable; -------------------------------------------------------------------------------- /src/components/TaskEditDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Dialog, 4 | DialogTitle, 5 | DialogContent, 6 | DialogActions, 7 | TextField, 8 | Button, 9 | FormControl, 10 | InputLabel, 11 | Select, 12 | MenuItem, 13 | Chip, 14 | Box, 15 | FormControlLabel, 16 | Switch, 17 | Typography, 18 | SelectChangeEvent 19 | } from '@mui/material'; 20 | import { Task } from '../models/Task'; 21 | import { useProject } from '../hooks/ProjectContext'; 22 | import { DependencyType } from '../models/Dependency'; 23 | 24 | interface TaskEditDialogProps { 25 | open: boolean; 26 | onClose: () => void; 27 | } 28 | 29 | const TaskEditDialog: React.FC = ({ open, onClose }) => { 30 | const { 31 | tasks, 32 | selectedTaskId, 33 | updateTask, 34 | insertTaskBefore, 35 | renumberTasks, 36 | updateTasks, 37 | updateTaskWithNewId 38 | } = useProject(); 39 | 40 | // 当前编辑的任务 41 | const selectedTask = tasks.find(task => task.id === selectedTaskId); 42 | 43 | // 表单状态 44 | const [id, setId] = useState(''); 45 | const [originalId, setOriginalId] = useState(''); // 保存原始ID用于任务更新 46 | const [description, setDescription] = useState(''); 47 | const [duration, setDuration] = useState(''); 48 | const [predecessorIds, setPredecessorIds] = useState([]); 49 | const [error, setError] = useState(null); 50 | const [isInsertingTask, setIsInsertingTask] = useState(false); 51 | const [newTaskId, setNewTaskId] = useState(''); 52 | const [newTaskDescription, setNewTaskDescription] = useState(''); 53 | const [renumberSubsequent, setRenumberSubsequent] = useState(true); 54 | 55 | // 当选中的任务变化时更新表单 56 | useEffect(() => { 57 | if (selectedTask) { 58 | setId(selectedTask.id); 59 | setOriginalId(selectedTask.id); // 保存原始ID 60 | setDescription(selectedTask.description || ''); 61 | setDuration(selectedTask.duration); 62 | setPredecessorIds(selectedTask.predecessors.map(p => p.taskId)); 63 | setError(null); 64 | setIsInsertingTask(false); 65 | setNewTaskId(''); 66 | setNewTaskDescription(''); 67 | } 68 | }, [selectedTask]); 69 | 70 | // 验证表单 71 | const validateForm = (): boolean => { 72 | // 基本验证 73 | if (!id.trim()) { 74 | setError('Task ID is required'); 75 | return false; 76 | } 77 | 78 | if (id !== originalId && tasks.some(task => task.id === id.trim())) { 79 | setError('Task ID must be unique'); 80 | return false; 81 | } 82 | 83 | if (duration === '' || duration < 0) { 84 | setError('Duration must be a positive number'); 85 | return false; 86 | } 87 | 88 | // 验证前置任务不包含自己 89 | if (predecessorIds.includes(id)) { 90 | setError('A task cannot be its own predecessor'); 91 | return false; 92 | } 93 | 94 | // 插入任务相关验证 95 | if (isInsertingTask) { 96 | if (!newTaskId.trim()) { 97 | setError('New task ID is required'); 98 | return false; 99 | } 100 | 101 | // 只有在不自动重编号时才验证新任务ID的唯一性 102 | if (!renumberSubsequent && tasks.some(task => task.id === newTaskId.trim())) { 103 | setError('New task ID must be unique'); 104 | return false; 105 | } 106 | } 107 | 108 | setError(null); 109 | return true; 110 | }; 111 | 112 | // 处理保存 113 | const handleSave = () => { 114 | if (!validateForm() || !selectedTask) return; 115 | 116 | // 如果ID已更改,需要更新所有引用到此任务的前置关系 117 | const idChanged = id.trim() !== originalId; 118 | 119 | if (idChanged) { 120 | // 创建修改后的任务 121 | const updatedTask = { 122 | ...selectedTask, 123 | id: id.trim(), 124 | description: description.trim(), 125 | duration: Number(duration), 126 | predecessors: predecessorIds.map(predId => ({ 127 | taskId: predId, 128 | type: selectedTask.predecessors.find(p => p.taskId === predId)?.type || DependencyType.FS, 129 | lag: selectedTask.predecessors.find(p => p.taskId === predId)?.lag || 0 130 | })) 131 | }; 132 | 133 | // 使用专门的函数处理ID变更情况 134 | updateTaskWithNewId(originalId, updatedTask); 135 | } else { 136 | // ID没有更改,正常更新任务 137 | updateTask({ 138 | ...selectedTask, 139 | id: id.trim(), 140 | description: description.trim(), 141 | duration: Number(duration), 142 | predecessors: predecessorIds.map(predId => ({ 143 | taskId: predId, 144 | type: selectedTask.predecessors.find(p => p.taskId === predId)?.type || DependencyType.FS, 145 | lag: selectedTask.predecessors.find(p => p.taskId === predId)?.lag || 0 146 | })) 147 | }); 148 | } 149 | 150 | onClose(); 151 | }; 152 | 153 | // 处理插入新任务 154 | const handleInsertTask = () => { 155 | if (!validateForm() || !selectedTask) return; 156 | 157 | // 插入新任务,传递 autoRenumber 参数 158 | insertTaskBefore(selectedTask.id, { 159 | id: newTaskId.trim(), 160 | description: newTaskDescription.trim(), 161 | duration: 1, 162 | predecessorIds: selectedTask.predecessors.map(p => p.taskId), 163 | autoRenumber: renumberSubsequent // 新增,指示是否自动重编号 164 | }); 165 | 166 | // 不再需要单独调用 renumberTasks,因为 insertTaskBefore 现在会处理 167 | // 删除以前的重编号代码 168 | 169 | onClose(); 170 | }; 171 | 172 | // 处理前置任务选择变化 173 | const handlePredecessorChange = (event: SelectChangeEvent) => { 174 | const value = event.target.value; 175 | setPredecessorIds(typeof value === 'string' ? value.split(',') : value); 176 | }; 177 | 178 | // 若无选中任务,不显示对话框 179 | if (!selectedTask) return null; 180 | 181 | return ( 182 | 183 | 184 | {isInsertingTask ? 'Insert New Task' : 'Edit Task'} 185 | 186 | 187 | 188 | {!isInsertingTask ? ( 189 | // 编辑现有任务表单 190 | <> 191 | 192 | setId(e.target.value)} 197 | fullWidth 198 | error={!!error && error.includes('ID')} 199 | /> 200 | 201 | setDescription(e.target.value)} 205 | fullWidth 206 | /> 207 | 208 | 209 | 210 | setDuration(e.target.value === '' ? '' : Number(e.target.value))} 216 | inputProps={{ min: 0 }} 217 | fullWidth 218 | error={!!error && error.includes('Duration')} 219 | /> 220 | 221 | 222 | Predecessors 223 | 245 | 246 | 247 | 248 | 249 | setIsInsertingTask(e.target.checked)} 254 | /> 255 | } 256 | label="Insert new task before this task" 257 | /> 258 | 259 | 260 | ) : ( 261 | // 插入新任务表单 262 | <> 263 | 264 | You are inserting a new task before task {selectedTaskId}. 265 | The new task will become a predecessor of task {selectedTaskId}, 266 | and will inherit all predecessors of task {selectedTaskId}. 267 | 268 | 269 | setNewTaskId(e.target.value)} 274 | fullWidth 275 | error={!!error && error.includes('New task ID')} 276 | sx={{ mb: 2 }} 277 | /> 278 | 279 | setNewTaskDescription(e.target.value)} 283 | fullWidth 284 | sx={{ mb: 2 }} 285 | /> 286 | 287 | setRenumberSubsequent(e.target.checked)} 292 | /> 293 | } 294 | label="Auto-renumber subsequent tasks" 295 | /> 296 | 297 | 298 | 305 | 306 | 307 | )} 308 | 309 | {error && ( 310 | 311 | {error} 312 | 313 | )} 314 | 315 | 316 | 317 | 318 | {isInsertingTask ? ( 319 | 327 | ) : ( 328 | 335 | )} 336 | 337 | 338 | ); 339 | }; 340 | 341 | export default TaskEditDialog; -------------------------------------------------------------------------------- /src/components/CrashingTaskForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Box, 4 | TextField, 5 | Button, 6 | Typography, 7 | FormControl, 8 | InputLabel, 9 | Select, 10 | MenuItem, 11 | Chip, 12 | SelectChangeEvent, 13 | Paper, 14 | Grid, 15 | Alert, 16 | IconButton, 17 | Tooltip, 18 | Snackbar 19 | } from '@mui/material'; 20 | import InfoIcon from '@mui/icons-material/Info'; 21 | import { useProjectCrashing, CrashTask } from '../hooks/ProjectCrashingContext'; 22 | import { DependencyType } from '../models/Dependency'; 23 | 24 | // 任务表单组件 - 用于添加Project Crashing所需的任务 25 | const CrashingTaskForm: React.FC = () => { 26 | const { crashTasks, addCrashTask, isCrashed } = useProjectCrashing(); 27 | 28 | // 表单状态 29 | const [id, setId] = useState(''); 30 | const [description, setDescription] = useState(''); 31 | const [normalTime, setNormalTime] = useState(''); 32 | const [normalCost, setNormalCost] = useState(''); 33 | const [crashTime, setCrashTime] = useState(''); 34 | const [crashCost, setCrashCost] = useState(''); 35 | const [predecessorIds, setPredecessorIds] = useState([]); 36 | const [error, setError] = useState(null); 37 | 38 | // 通知状态 39 | const [notificationOpen, setNotificationOpen] = useState(false); 40 | const [notificationMessage, setNotificationMessage] = useState(''); 41 | 42 | // 重置表单 43 | const resetForm = () => { 44 | setId(''); 45 | setDescription(''); 46 | setNormalTime(''); 47 | setNormalCost(''); 48 | setCrashTime(''); 49 | setCrashCost(''); 50 | setPredecessorIds([]); 51 | setError(null); 52 | }; 53 | 54 | // 验证表单 55 | const validateForm = (): boolean => { 56 | if (!id.trim()) { 57 | setError('Task ID is required'); 58 | return false; 59 | } 60 | 61 | if (crashTasks.some(task => task.id === id.trim())) { 62 | setError('Task ID must be unique'); 63 | return false; 64 | } 65 | 66 | if (normalTime === '') { 67 | setError('Normal Time is required'); 68 | return false; 69 | } else if (normalTime < 0) { 70 | setError('Normal Time must be a positive number'); 71 | return false; 72 | } 73 | 74 | if (normalCost === '') { 75 | setError('Normal Cost is required'); 76 | return false; 77 | } else if (normalCost < 0) { 78 | setError('Normal Cost must be a positive number'); 79 | return false; 80 | } 81 | 82 | if (crashTime === '') { 83 | setError('Crash Time is required'); 84 | return false; 85 | } else if (crashTime < 0) { 86 | setError('Crash Time must be a positive number'); 87 | return false; 88 | } 89 | 90 | if (crashCost === '') { 91 | setError('Crash Cost is required'); 92 | return false; 93 | } else if (crashCost < 0) { 94 | setError('Crash Cost must be a positive number'); 95 | return false; 96 | } 97 | 98 | if (Number(crashTime) > Number(normalTime)) { 99 | setError('Crash Time cannot be greater than Normal Time'); 100 | return false; 101 | } 102 | 103 | // 检查循环依赖 104 | if (predecessorIds.includes(id)) { 105 | setError('A task cannot be its own predecessor'); 106 | return false; 107 | } 108 | 109 | setError(null); 110 | return true; 111 | }; 112 | 113 | // 提交表单 114 | const handleSubmit = (e: React.FormEvent) => { 115 | e.preventDefault(); 116 | 117 | if (!validateForm()) return; 118 | 119 | const newTask: CrashTask = { 120 | id: id.trim(), 121 | description: description.trim(), 122 | duration: Number(normalTime), // Default duration is the normal time 123 | normalTime: Number(normalTime), 124 | normalCost: Number(normalCost), 125 | crashTime: Number(crashTime), 126 | crashCost: Number(crashCost), 127 | predecessors: predecessorIds.map(predId => ({ 128 | taskId: predId, 129 | type: DependencyType.FS, 130 | lag: 0 131 | })) 132 | }; 133 | 134 | addCrashTask(newTask); 135 | 136 | // 显示通知 137 | setNotificationMessage(`Task "${newTask.id}" added successfully`); 138 | setNotificationOpen(true); 139 | 140 | resetForm(); 141 | }; 142 | 143 | // 处理前置任务选择变化 144 | const handlePredecessorChange = (event: SelectChangeEvent) => { 145 | const value = event.target.value; 146 | setPredecessorIds(typeof value === 'string' ? value.split(',') : value); 147 | }; 148 | 149 | // 关闭通知 150 | const handleCloseNotification = () => { 151 | setNotificationOpen(false); 152 | }; 153 | 154 | return ( 155 | 156 | 157 | 158 | Add New Task 159 | 160 | 163 | 164 | - Normal Time must be greater than or equal to Crash Time. When they are equal, it means the task cannot be crashed. 165 | 166 | 167 | - Crash Cost can be greater than, equal to, or less than Normal Cost, depending on the specific task. 168 | 169 | 170 | } 171 | arrow 172 | placement="right" 173 | enterDelay={1000} 174 | > 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | setId(e.target.value)} 189 | size="small" 190 | disabled={isCrashed} 191 | error={!!error && error.includes('ID')} 192 | fullWidth 193 | /> 194 | 195 | 196 | 197 | setDescription(e.target.value)} 201 | size="small" 202 | disabled={isCrashed} 203 | error={!!error && error.includes('Description')} 204 | fullWidth 205 | /> 206 | 207 | 208 | 209 | 210 | setNormalTime(e.target.value === '' ? '' : Number(e.target.value))} 216 | size="small" 217 | inputProps={{ min: 0 }} 218 | disabled={isCrashed} 219 | error={!!error && error.includes('Normal Time')} 220 | fullWidth 221 | /> 222 | 223 | 224 | 225 | 226 | 227 | setNormalCost(e.target.value === '' ? '' : Number(e.target.value))} 233 | size="small" 234 | inputProps={{ min: 0 }} 235 | disabled={isCrashed} 236 | error={!!error && error.includes('Normal Cost')} 237 | fullWidth 238 | /> 239 | 240 | 241 | 242 | 243 | 244 | setCrashTime(e.target.value === '' ? '' : Number(e.target.value))} 250 | size="small" 251 | inputProps={{ min: 0 }} 252 | disabled={isCrashed} 253 | error={!!error && error.includes('Crash Time')} 254 | fullWidth 255 | /> 256 | 257 | 258 | 259 | 260 | 261 | setCrashCost(e.target.value === '' ? '' : Number(e.target.value))} 267 | size="small" 268 | inputProps={{ min: 0 }} 269 | disabled={isCrashed} 270 | error={!!error && error.includes('Crash Cost')} 271 | fullWidth 272 | /> 273 | 274 | 275 | 276 | 277 | 278 | Predecessors 279 | 298 | 299 | 300 | 301 | 302 | {error && ( 303 | 304 | {error} 305 | 306 | )} 307 | 308 | 317 | 318 | 319 | {/* 成功添加任务的通知 */} 320 | 326 | 327 | {notificationMessage} 328 | 329 | 330 | 331 | ); 332 | }; 333 | 334 | export default CrashingTaskForm; -------------------------------------------------------------------------------- /src/components/TaskTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Paper, 4 | Table, 5 | TableBody, 6 | TableCell, 7 | TableContainer, 8 | TableHead, 9 | TableRow, 10 | Typography, 11 | IconButton, 12 | Chip, 13 | Box 14 | } from '@mui/material'; 15 | import DeleteIcon from '@mui/icons-material/Delete'; 16 | import EditIcon from '@mui/icons-material/Edit'; 17 | import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; 18 | import { useProject } from '../hooks/ProjectContext'; 19 | import { Task } from '../models/Task'; 20 | import TaskEditDialog from './TaskEditDialog'; 21 | import { 22 | DndContext, 23 | closestCenter, 24 | KeyboardSensor, 25 | PointerSensor, 26 | useSensor, 27 | useSensors, 28 | DragEndEvent 29 | } from '@dnd-kit/core'; 30 | import { 31 | arrayMove, 32 | SortableContext, 33 | sortableKeyboardCoordinates, 34 | useSortable, 35 | verticalListSortingStrategy 36 | } from '@dnd-kit/sortable'; 37 | import { CSS } from '@dnd-kit/utilities'; 38 | import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers'; 39 | 40 | // 可排序的表格行组件 41 | interface SortableTableRowProps { 42 | id: string; 43 | task: Task; 44 | isCalculated: boolean; 45 | onEdit: (taskId: string) => void; 46 | onDelete: (taskId: string) => void; 47 | } 48 | 49 | const SortableTableRow: React.FC = ({ id, task, isCalculated, onEdit, onDelete }) => { 50 | const { 51 | attributes, 52 | listeners, 53 | setNodeRef, 54 | transform, 55 | transition, 56 | isDragging, 57 | } = useSortable({ id }); 58 | 59 | const style = { 60 | transform: CSS.Transform.toString(transform), 61 | transition, 62 | opacity: isDragging ? 0.6 : 1, 63 | backgroundColor: isDragging 64 | ? '#f5f5f5' 65 | : task.isCritical 66 | ? 'rgba(255, 0, 0, 0.1)' 67 | : 'inherit', 68 | cursor: isCalculated ? 'default' : 'grab', 69 | userSelect: 'none' as const, 70 | }; 71 | 72 | return ( 73 | 77 | 78 | 79 | {task.id} 80 | 81 | 82 | {task.description || '-'} 83 | {task.duration} 84 | 85 | 86 | {task.predecessors.map((pred) => ( 87 | 93 | ))} 94 | 95 | 96 | {isCalculated && ( 97 | <> 98 | {task.earlyStart} 99 | {task.earlyFinish} 100 | {task.lateStart} 101 | {task.lateFinish} 102 | {task.slack} 103 | 104 | {task.isCritical && } 105 | 106 | 107 | )} 108 | 109 | 110 | onEdit(task.id)} 113 | disabled={isCalculated} 114 | color="primary" 115 | sx={{ mr: 1 }} 116 | > 117 | 118 | 119 | onDelete(task.id)} 122 | disabled={isCalculated} 123 | color="error" 124 | sx={{ mr: !isCalculated ? 1 : 0 }} 125 | > 126 | 127 | 128 | {!isCalculated && ( 129 | 140 | 141 | 142 | )} 143 | 144 | 145 | 146 | ); 147 | }; 148 | 149 | // 任务表格组件 - 显示已添加的任务及其计算结果 150 | const TaskTable: React.FC = () => { 151 | const { tasks, deleteTask, isCalculated, setSelectedTaskId, reorderTasks } = useProject(); 152 | const [editDialogOpen, setEditDialogOpen] = useState(false); 153 | 154 | const sensors = useSensors( 155 | useSensor(PointerSensor, { 156 | activationConstraint: { 157 | distance: 5, 158 | }, 159 | }), 160 | useSensor(KeyboardSensor, { 161 | coordinateGetter: sortableKeyboardCoordinates, 162 | }) 163 | ); 164 | 165 | const handleDelete = (taskId: string) => { 166 | if (!isCalculated && window.confirm(`Are you sure you want to delete task ${taskId}?`)) { 167 | deleteTask(taskId); 168 | } 169 | }; 170 | 171 | const handleEdit = (taskId: string) => { 172 | if (!isCalculated) { 173 | setSelectedTaskId(taskId); 174 | setEditDialogOpen(true); 175 | } 176 | }; 177 | 178 | const handleCloseDialog = () => { 179 | setEditDialogOpen(false); 180 | setSelectedTaskId(null); 181 | }; 182 | 183 | // 处理拖拽结束事件 184 | const handleDragEnd = (event: DragEndEvent) => { 185 | const { active, over } = event; 186 | 187 | if (over && active.id !== over.id) { 188 | const oldIndex = tasks.findIndex(task => task.id === active.id); 189 | const newIndex = tasks.findIndex(task => task.id === over.id); 190 | 191 | if (oldIndex !== -1 && newIndex !== -1) { 192 | reorderTasks(oldIndex, newIndex); 193 | } 194 | } 195 | }; 196 | 197 | if (tasks.length === 0) { 198 | return ( 199 | 200 | 201 | Task List 202 | 203 | 204 | No tasks added yet. Use the form above to add tasks to your project. 205 | 206 | 207 | ); 208 | } 209 | 210 | return ( 211 | <> 212 | 213 | 214 | Task List 215 | 216 | 217 | 218 | 219 | 220 | 221 | ID 222 | Description 223 | Duration 224 | Predecessors 225 | {isCalculated && ( 226 | <> 227 | ES 228 | EF 229 | LS 230 | LF 231 | Slack 232 | Critical 233 | 234 | )} 235 | Actions 236 | 237 | 238 | {!isCalculated ? ( 239 | 245 | task.id)} 247 | strategy={verticalListSortingStrategy} 248 | > 249 | 250 | {tasks.map((task) => ( 251 | 259 | ))} 260 | 261 | 262 | 263 | ) : ( 264 | 265 | {tasks.map((task) => ( 266 | 273 | 274 | {task.id} 275 | 276 | {task.description || '-'} 277 | {task.duration} 278 | 279 | 280 | {task.predecessors.map((pred) => ( 281 | 287 | ))} 288 | 289 | 290 | {task.earlyStart} 291 | {task.earlyFinish} 292 | {task.lateStart} 293 | {task.lateFinish} 294 | {task.slack} 295 | 296 | {task.isCritical && } 297 | 298 | 299 | 300 | handleEdit(task.id)} 303 | disabled={true} 304 | color="primary" 305 | sx={{ mr: 1 }} 306 | > 307 | 308 | 309 | handleDelete(task.id)} 312 | disabled={true} 313 | color="error" 314 | > 315 | 316 | 317 | 318 | 319 | 320 | ))} 321 | 322 | )} 323 |
324 |
325 |
326 | 327 | 331 | 332 | ); 333 | }; 334 | 335 | export default TaskTable; -------------------------------------------------------------------------------- /src/hooks/ProjectCrashingContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, ReactNode, useState, useCallback } from 'react'; 2 | import { Task, Predecessor } from '../models/Task'; 3 | import { DependencyType } from '../models/Dependency'; 4 | import { ProjectCrashingService } from '../services/ProjectCrashingService'; 5 | 6 | // Define the structure of the crash-specific task information 7 | export interface CrashTask extends Task { 8 | normalTime: number; 9 | normalCost: number; 10 | crashTime: number; 11 | crashCost: number; 12 | slope?: number; 13 | maxCrashTime?: number; 14 | // 添加松弛时间和早开始/晚开始等属性 15 | earlyStart?: number; 16 | earlyFinish?: number; 17 | lateStart?: number; 18 | lateFinish?: number; 19 | slack?: number; 20 | isCritical?: boolean; 21 | } 22 | 23 | // Define the interface for path analysis in project crashing 24 | export interface CrashPath { 25 | tasks: string[]; 26 | durations: number[]; // Array of durations for each iteration 27 | isCritical: boolean; 28 | } 29 | 30 | // Define the interface for cost analysis results 31 | export interface CostAnalysisResult { 32 | projectDuration: number; 33 | crashedActivities: string[]; 34 | crashCost: number; 35 | directCost: number; 36 | indirectCost: number; 37 | totalCost: number; 38 | isOptimum: boolean; 39 | isCrashPoint: boolean; 40 | } 41 | 42 | // Define the interface for saved project crashing data 43 | export interface SavedProjectCrashingData { 44 | id: string; // 唯一标识符 45 | name: string; // 项目名称 46 | tasks: CrashTask[]; 47 | indirectCost: number; 48 | reductionPerUnit: number; 49 | lastUpdated: string; 50 | } 51 | 52 | // 保存的项目列表 53 | export interface SavedProjectsList { 54 | projects: SavedProjectCrashingData[]; 55 | } 56 | 57 | // Node position type 58 | export interface NodePosition { 59 | id: string; 60 | x: number; 61 | y: number; 62 | } 63 | 64 | // Define the context type 65 | interface ProjectCrashingContextType { 66 | indirectCost: number; 67 | setIndirectCost: (cost: number) => void; 68 | reductionPerUnit: number; 69 | setReductionPerUnit: (reduction: number) => void; 70 | crashTasks: CrashTask[]; 71 | setCrashTasks: (tasks: CrashTask[]) => void; 72 | addCrashTask: (task: CrashTask) => void; 73 | updateCrashTask: (taskId: string, updatedTask: Partial) => void; 74 | deleteCrashTask: (taskId: string) => void; 75 | crashPaths: CrashPath[]; 76 | criticalCrashPaths: CrashPath[]; 77 | costAnalysis: CostAnalysisResult[]; 78 | crashedTasksHistory: CrashTask[][]; 79 | isCrashed: boolean; 80 | currentIteration: number; 81 | setCurrentIteration: (iteration: number) => void; 82 | totalIterations: number; 83 | performCrashing: () => void; 84 | clearCrashingData: () => void; 85 | projectDuration: number; 86 | error: string | null; 87 | saveProjectCrashingData: (projectName: string) => boolean; 88 | loadProjectCrashingData: (projectId: string) => boolean; 89 | getSavedProjects: () => SavedProjectCrashingData[]; 90 | deleteProject: (projectId: string) => boolean; 91 | updateProjectName: (projectId: string, newName: string) => boolean; 92 | nodePositions: NodePosition[]; 93 | setNodePositions: React.Dispatch>; 94 | resetNodePositions: () => void; 95 | } 96 | 97 | // Create the context 98 | const ProjectCrashingContext = createContext(undefined); 99 | 100 | // Create the provider component 101 | export const ProjectCrashingProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 102 | // State for indirect cost and reduction per unit 103 | const [indirectCost, setIndirectCost] = useState(0); 104 | const [reductionPerUnit, setReductionPerUnit] = useState(0); 105 | 106 | // State for tasks 107 | const [crashTasks, setCrashTasks] = useState([]); 108 | 109 | // State for analysis results 110 | const [crashPaths, setCrashPaths] = useState([]); 111 | const [criticalCrashPaths, setCriticalCrashPaths] = useState([]); 112 | const [costAnalysis, setCostAnalysis] = useState([]); 113 | const [crashedTasksHistory, setCrashedTasksHistory] = useState([]); 114 | 115 | // State for UI control 116 | const [isCrashed, setIsCrashed] = useState(false); 117 | const [currentIteration, setCurrentIterationInternal] = useState(0); 118 | const [totalIterations, setTotalIterations] = useState(0); 119 | const [projectDuration, setProjectDuration] = useState(0); 120 | const [error, setError] = useState(null); 121 | 122 | // State for node positions 123 | const [nodePositions, setNodePositions] = useState([]); 124 | 125 | // Wrapper for setCurrentIteration that also updates projectDuration 126 | const setCurrentIteration = (iteration: number) => { 127 | setCurrentIterationInternal(iteration); 128 | 129 | // Update the project duration based on the selected iteration's data 130 | if (costAnalysis.length > iteration) { 131 | setProjectDuration(costAnalysis[iteration].projectDuration); 132 | } 133 | }; 134 | 135 | // Function to reset node positions 136 | const resetNodePositions = () => { 137 | setNodePositions([]); 138 | }; 139 | 140 | // Function to add a new task 141 | const addCrashTask = useCallback((task: CrashTask) => { 142 | // Calculate maxCrashTime and slope before adding the task 143 | const maxCrashTime = task.normalTime - task.crashTime; 144 | const slope = maxCrashTime > 0 145 | ? (task.crashCost - task.normalCost) / maxCrashTime 146 | : Number.MAX_SAFE_INTEGER; // If task can't be crashed, set slope to infinity 147 | 148 | // Add the task with calculated properties 149 | setCrashTasks(prev => [...prev, { 150 | ...task, 151 | maxCrashTime, 152 | slope 153 | }]); 154 | }, []); 155 | 156 | // Function to update a task 157 | const updateCrashTask = useCallback((taskId: string, updatedTask: Partial) => { 158 | setCrashTasks(prev => 159 | prev.map(task => { 160 | if (task.id !== taskId) return task; 161 | 162 | // Create the updated task by merging old and new properties 163 | const updated = { ...task, ...updatedTask }; 164 | 165 | // Recalculate maxCrashTime and slope if normalTime, crashTime, normalCost, or crashCost changed 166 | if ( 167 | updatedTask.normalTime !== undefined || 168 | updatedTask.crashTime !== undefined || 169 | updatedTask.normalCost !== undefined || 170 | updatedTask.crashCost !== undefined 171 | ) { 172 | const maxCrashTime = updated.normalTime - updated.crashTime; 173 | const slope = maxCrashTime > 0 174 | ? (updated.crashCost - updated.normalCost) / maxCrashTime 175 | : Number.MAX_SAFE_INTEGER; // If task can't be crashed, set slope to infinity 176 | 177 | return { ...updated, maxCrashTime, slope }; 178 | } 179 | 180 | return updated; 181 | }) 182 | ); 183 | }, []); 184 | 185 | // Function to delete a task 186 | const deleteCrashTask = useCallback((taskId: string) => { 187 | setCrashTasks(prev => prev.filter(task => task.id !== taskId)); 188 | }, []); 189 | 190 | // Function to perform the crashing analysis 191 | const performCrashing = () => { 192 | try { 193 | setError(null); 194 | 195 | // 验证输入数据 196 | if (crashTasks.length === 0) { 197 | throw new Error('No tasks to crash'); 198 | } 199 | 200 | if (indirectCost < 0 || reductionPerUnit < 0) { 201 | throw new Error('Indirect cost and reduction per unit must be non-negative'); 202 | } 203 | 204 | // 检查是否有没有前置任务的任务 205 | const startTasks = crashTasks.filter(task => task.predecessors.length === 0); 206 | if (startTasks.length === 0) { 207 | throw new Error('No start tasks found. At least one task must have no predecessors.'); 208 | } 209 | 210 | // 检查是否有没有后续任务的任务 211 | const endTasks = crashTasks.filter(task => { 212 | return !crashTasks.some(t => t.predecessors.some(p => p.taskId === task.id)); 213 | }); 214 | if (endTasks.length === 0) { 215 | throw new Error('No end tasks found. At least one task must not be a predecessor for any task.'); 216 | } 217 | 218 | // --- DETAILED LOGGING BEFORE SERVICE CALL --- 219 | console.log('[CONTEXT] performCrashing: Tasks being sent to service:'); 220 | crashTasks.forEach(task => { 221 | console.log(`[CONTEXT] Task ${task.id}: normalTime=${task.normalTime}, duration=${task.duration}, crashTime=${task.crashTime}`); 222 | }); 223 | // --- END LOGGING --- 224 | 225 | // 执行项目压缩算法 226 | const crashingResults = ProjectCrashingService.crashProject( 227 | crashTasks, 228 | indirectCost, 229 | reductionPerUnit 230 | ); 231 | 232 | // --- DETAILED LOGGING AFTER SERVICE CALL --- 233 | if (crashingResults.crashedTasks && crashingResults.crashedTasks.length > 0) { 234 | console.log('[CONTEXT] performCrashing: Service returned crashedTasks. Iteration 0 data:'); 235 | crashingResults.crashedTasks[0].forEach(task => { 236 | console.log(`[CONTEXT] Iteration 0 Task ${task.id}: normalTime=${task.normalTime}, duration=${task.duration}, crashTime=${task.crashTime}`); 237 | }); 238 | } else { 239 | console.error('[CONTEXT] performCrashing: Service returned no crashedTasks or empty array!'); 240 | } 241 | // --- END LOGGING --- 242 | 243 | // 计算每个迭代的松弛时间 244 | const processingTasksHistory = [...crashingResults.crashedTasks]; 245 | for (let i = 0; i < processingTasksHistory.length; i++) { 246 | // 为当前迭代的任务计算松弛时间 247 | const currentCriticalPaths = crashingResults.paths.filter(path => { 248 | return path.durations[i] === Math.max(...crashingResults.paths.map(p => p.durations[i])); 249 | }); 250 | 251 | processingTasksHistory[i] = ProjectCrashingService.calculateSlackTimes( 252 | processingTasksHistory[i], 253 | currentCriticalPaths 254 | ); 255 | } 256 | 257 | // 更新状态 258 | setCrashPaths(crashingResults.paths); 259 | setCriticalCrashPaths(crashingResults.criticalPaths); 260 | setCostAnalysis(crashingResults.costAnalysis); 261 | setCrashedTasksHistory(processingTasksHistory); 262 | 263 | // 设置总迭代次数 264 | setTotalIterations(crashingResults.crashedTasks.length - 1); 265 | 266 | // 设置为已压缩状态 267 | setIsCrashed(true); 268 | 269 | // 设置初始项目持续时间并初始化当前迭代为0 270 | setProjectDuration(crashingResults.costAnalysis[0].projectDuration); 271 | setCurrentIterationInternal(0); 272 | } catch (err) { 273 | const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred'; 274 | setError(errorMessage); 275 | console.error('[CONTEXT] Error while crashing project:', errorMessage); 276 | } 277 | }; 278 | 279 | // Function to clear all crashing data 280 | const clearCrashingData = () => { 281 | setCrashTasks([]); 282 | setCrashPaths([]); 283 | setCriticalCrashPaths([]); 284 | setCostAnalysis([]); 285 | setCrashedTasksHistory([]); 286 | setIsCrashed(false); 287 | setCurrentIteration(0); 288 | setTotalIterations(0); 289 | setProjectDuration(0); 290 | setError(null); 291 | }; 292 | 293 | // Function to save project crashing data to localStorage 294 | const saveProjectCrashingData = (projectName: string): boolean => { 295 | try { 296 | // 生成唯一ID 297 | const projectId = Date.now().toString(); 298 | 299 | const projectData: SavedProjectCrashingData = { 300 | id: projectId, 301 | name: projectName.trim() || `Project ${projectId.slice(-4)}`, 302 | tasks: crashTasks, 303 | indirectCost, 304 | reductionPerUnit, 305 | lastUpdated: new Date().toISOString() 306 | }; 307 | 308 | // 获取现有的项目列表 309 | let savedProjects: SavedProjectsList = {projects: []}; 310 | const savedProjectsString = localStorage.getItem('projectCrashingList'); 311 | 312 | if (savedProjectsString) { 313 | savedProjects = JSON.parse(savedProjectsString); 314 | } 315 | 316 | // 添加新项目到列表 317 | savedProjects.projects.push(projectData); 318 | 319 | // 保存更新后的项目列表 320 | localStorage.setItem('projectCrashingList', JSON.stringify(savedProjects)); 321 | 322 | console.log(`Saving crashing project "${projectData.name}" with ID: ${projectId}, ${crashTasks.length} tasks`); 323 | return true; 324 | } catch (err) { 325 | console.error('Failed to save project crashing data:', err); 326 | return false; 327 | } 328 | }; 329 | 330 | // 获取保存的项目列表 331 | const getSavedProjects = (): SavedProjectCrashingData[] => { 332 | try { 333 | const savedProjectsString = localStorage.getItem('projectCrashingList'); 334 | if (!savedProjectsString) { 335 | return []; 336 | } 337 | 338 | const savedProjects = JSON.parse(savedProjectsString) as SavedProjectsList; 339 | return savedProjects.projects; 340 | } catch (err) { 341 | console.error('Failed to get saved projects list:', err); 342 | return []; 343 | } 344 | }; 345 | 346 | // 从列表中加载特定项目 347 | const loadProjectCrashingData = (projectId: string): boolean => { 348 | try { 349 | const savedProjects = getSavedProjects(); 350 | const projectToLoad = savedProjects.find(project => project.id === projectId); 351 | 352 | if (!projectToLoad) { 353 | setError(`Project with ID ${projectId} not found`); 354 | return false; 355 | } 356 | 357 | setIndirectCost(projectToLoad.indirectCost); 358 | setReductionPerUnit(projectToLoad.reductionPerUnit); 359 | setCrashTasks(projectToLoad.tasks); 360 | 361 | // Reset analysis data since we need to re-run the crashing algorithm 362 | setCrashPaths([]); 363 | setCriticalCrashPaths([]); 364 | setCostAnalysis([]); 365 | setCrashedTasksHistory([]); 366 | setIsCrashed(false); 367 | setCurrentIteration(0); 368 | setTotalIterations(0); 369 | setProjectDuration(0); 370 | setError(null); 371 | 372 | return true; 373 | } catch (err) { 374 | setError('Failed to load project crashing data'); 375 | console.error('Failed to load project crashing data:', err); 376 | return false; 377 | } 378 | }; 379 | 380 | // 删除保存的项目 381 | const deleteProject = (projectId: string): boolean => { 382 | try { 383 | const savedProjectsString = localStorage.getItem('projectCrashingList'); 384 | if (!savedProjectsString) { 385 | return false; 386 | } 387 | 388 | const savedProjects = JSON.parse(savedProjectsString) as SavedProjectsList; 389 | 390 | // 过滤掉要删除的项目 391 | savedProjects.projects = savedProjects.projects.filter(project => project.id !== projectId); 392 | 393 | // 保存更新后的项目列表 394 | localStorage.setItem('projectCrashingList', JSON.stringify(savedProjects)); 395 | 396 | return true; 397 | } catch (err) { 398 | console.error('Failed to delete project:', err); 399 | return false; 400 | } 401 | }; 402 | 403 | // 更新项目名称 404 | const updateProjectName = (projectId: string, newName: string): boolean => { 405 | try { 406 | const savedProjectsString = localStorage.getItem('projectCrashingList'); 407 | if (!savedProjectsString) { 408 | return false; 409 | } 410 | 411 | const savedProjects = JSON.parse(savedProjectsString) as SavedProjectsList; 412 | 413 | // 查找并更新项目名称 414 | const projectToUpdate = savedProjects.projects.find(project => project.id === projectId); 415 | if (!projectToUpdate) { 416 | return false; 417 | } 418 | 419 | projectToUpdate.name = newName.trim() || projectToUpdate.name; 420 | 421 | // 保存更新后的项目列表 422 | localStorage.setItem('projectCrashingList', JSON.stringify(savedProjects)); 423 | 424 | return true; 425 | } catch (err) { 426 | console.error('Failed to update project name:', err); 427 | return false; 428 | } 429 | }; 430 | 431 | // The context value 432 | const contextValue: ProjectCrashingContextType = { 433 | indirectCost, 434 | setIndirectCost, 435 | reductionPerUnit, 436 | setReductionPerUnit, 437 | crashTasks, 438 | setCrashTasks, 439 | addCrashTask, 440 | updateCrashTask, 441 | deleteCrashTask, 442 | crashPaths, 443 | criticalCrashPaths, 444 | costAnalysis, 445 | crashedTasksHistory, 446 | isCrashed, 447 | currentIteration, 448 | setCurrentIteration, 449 | totalIterations, 450 | performCrashing, 451 | clearCrashingData, 452 | projectDuration, 453 | error, 454 | saveProjectCrashingData, 455 | loadProjectCrashingData, 456 | getSavedProjects, 457 | deleteProject, 458 | updateProjectName, 459 | nodePositions, 460 | setNodePositions, 461 | resetNodePositions 462 | }; 463 | 464 | return ( 465 | 466 | {children} 467 | 468 | ); 469 | }; 470 | 471 | // Custom hook to use the Project Crashing context 472 | export const useProjectCrashing = (): ProjectCrashingContextType => { 473 | const context = useContext(ProjectCrashingContext); 474 | 475 | if (context === undefined) { 476 | throw new Error('useProjectCrashing must be used within a ProjectCrashingProvider'); 477 | } 478 | 479 | return context; 480 | }; -------------------------------------------------------------------------------- /src/hooks/useProjectData.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react'; 2 | import { Task, Path } from '../models/Task'; 3 | import { DependencyType } from '../models/Dependency'; 4 | import { ProjectScheduler } from '../services/ProjectScheduler'; 5 | 6 | // 初始项目状态 7 | const defaultProject = { 8 | name: 'New Project', 9 | tasks: [] as Task[], 10 | paths: [] as Path[], 11 | criticalPaths: [] as Path[], 12 | projectDuration: 0 13 | }; 14 | 15 | // 定义保存的项目数据接口 16 | export interface SavedProjectData { 17 | id: string; // 唯一标识符 18 | name: string; // 项目名称 19 | tasks: Task[]; 20 | lastUpdated: string; 21 | } 22 | 23 | // 保存的项目列表 24 | export interface SavedProjectsList { 25 | projects: SavedProjectData[]; 26 | } 27 | 28 | export const useProjectData = () => { 29 | // 项目基本信息 30 | const [projectName, setProjectName] = useState(defaultProject.name); 31 | // 任务列表 32 | const [tasks, setTasks] = useState(defaultProject.tasks); 33 | // 计算后的路径 34 | const [paths, setPaths] = useState(defaultProject.paths); 35 | // 关键路径 36 | const [criticalPaths, setCriticalPaths] = useState(defaultProject.criticalPaths); 37 | // 项目总持续时间 38 | const [projectDuration, setProjectDuration] = useState(defaultProject.projectDuration); 39 | // 是否已计算 40 | const [isCalculated, setIsCalculated] = useState(false); 41 | // 错误消息 42 | const [error, setError] = useState(null); 43 | // 当前选中用于编辑的任务ID 44 | const [selectedTaskId, setSelectedTaskId] = useState(null); 45 | 46 | // 添加新任务 47 | const addTask = useCallback((task: Omit & { predecessorIds?: string[] }) => { 48 | const newTask: Task = { 49 | ...task, 50 | predecessors: (task.predecessorIds || []).map(id => ({ 51 | taskId: id, 52 | type: DependencyType.FS, 53 | lag: 0 54 | })), 55 | isCritical: false 56 | }; 57 | 58 | setTasks(prev => [...prev, newTask]); 59 | setIsCalculated(false); 60 | }, []); 61 | 62 | // 更新任务 63 | const updateTask = useCallback((updatedTask: Task) => { 64 | setTasks(prev => 65 | prev.map(task => task.id === updatedTask.id ? updatedTask : task) 66 | ); 67 | setIsCalculated(false); 68 | }, []); 69 | 70 | // 批量更新多个任务 71 | const updateTasks = useCallback((updatedTasks: Task[]) => { 72 | setTasks(prev => { 73 | const taskMap = new Map(prev.map(task => [task.id, task])); 74 | updatedTasks.forEach(task => { 75 | taskMap.set(task.id, task); 76 | }); 77 | return Array.from(taskMap.values()); 78 | }); 79 | setIsCalculated(false); 80 | }, []); 81 | 82 | // 插入任务到特定任务之前 83 | const insertTaskBefore = useCallback((targetTaskId: string, newTask: Omit & { predecessorIds?: string[] } & { autoRenumber?: boolean }) => { 84 | setTasks(prev => { 85 | // 找到目标任务 86 | const targetTask = prev.find(task => task.id === targetTaskId); 87 | if (!targetTask) return prev; 88 | 89 | let tasksToUpdate = [...prev]; 90 | const targetTaskOriginalId = targetTask.id; 91 | 92 | // 如果启用了自动重编号 93 | if (newTask.autoRenumber) { 94 | // 1. 创建ID映射表 - 为所有ID >= targetTaskId的任务创建新ID 95 | const idMap: Record = {}; 96 | 97 | // 识别需要重编号的任务 98 | const tasksToRenumber = tasksToUpdate.filter(task => { 99 | const taskNum = Number(task.id); 100 | const targetNum = Number(targetTaskId); 101 | 102 | if (!isNaN(taskNum) && !isNaN(targetNum)) { 103 | // 数字ID比较 104 | return taskNum >= targetNum; 105 | } else { 106 | // 字母ID比较 107 | return task.id >= targetTaskId; 108 | } 109 | }); 110 | 111 | // 按ID从大到小排序,确保从最高ID开始重新编号 112 | const sortedTasksToRenumber = [...tasksToRenumber].sort((a, b) => { 113 | const aNum = Number(a.id); 114 | const bNum = Number(b.id); 115 | if (!isNaN(aNum) && !isNaN(bNum)) { 116 | return bNum - aNum; // 从大到小 117 | } else { 118 | return b.id.localeCompare(a.id); // 从大到小 119 | } 120 | }); 121 | 122 | // 从高ID到低ID重编号 123 | sortedTasksToRenumber.forEach(task => { 124 | const taskNum = Number(task.id); 125 | const targetNum = Number(targetTaskId); 126 | 127 | if (!isNaN(taskNum) && !isNaN(targetNum)) { 128 | // 数字ID递增 129 | idMap[task.id] = String(taskNum + 1); 130 | } else if (task.id.length === 1) { 131 | // 单字符字母ID递增 132 | const charCode = task.id.charCodeAt(0); 133 | if ((charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122)) { 134 | idMap[task.id] = String.fromCharCode(charCode + 1); 135 | } 136 | } else { 137 | // 多字符ID处理 138 | idMap[task.id] = task.id + "1"; // 简单处理,可以根据需要完善 139 | } 140 | }); 141 | 142 | // 更新所有受影响任务的ID和前置任务引用 143 | tasksToUpdate = tasksToUpdate.map(task => { 144 | const newId = idMap[task.id]; 145 | if (!newId) return task; 146 | 147 | return { 148 | ...task, 149 | id: newId, 150 | predecessors: task.predecessors.map(pred => ({ 151 | ...pred, 152 | taskId: idMap[pred.taskId] || pred.taskId 153 | })) 154 | }; 155 | }); 156 | 157 | // 2. 新任务将使用目标任务的原始ID 158 | newTask.id = targetTaskOriginalId; 159 | } 160 | 161 | // 准备新任务 162 | const taskToInsert: Task = { 163 | ...newTask, 164 | predecessors: (newTask.predecessorIds || []).map(id => ({ 165 | taskId: id, 166 | type: DependencyType.FS, 167 | lag: 0 168 | })), 169 | isCritical: false 170 | }; 171 | 172 | // 找到更新后的目标任务(ID可能已经改变) 173 | const updatedTargetTask = tasksToUpdate.find(task => { 174 | // 如果启用了自动重编号,目标任务的ID已变更,我们需要找到它的新ID 175 | if (newTask.autoRenumber) { 176 | return task.id === incrementId(targetTaskOriginalId); 177 | } else { 178 | return task.id === targetTaskId; 179 | } 180 | }); 181 | 182 | if (!updatedTargetTask) return tasksToUpdate.concat([taskToInsert]); 183 | 184 | // 更新目标任务的前置任务列表 - 现在它的前置任务是新插入的任务 185 | const finalTargetTask: Task = { 186 | ...updatedTargetTask, 187 | predecessors: [ 188 | { 189 | taskId: taskToInsert.id, 190 | type: DependencyType.FS, 191 | lag: 0 192 | } 193 | ] 194 | }; 195 | 196 | // 将目标任务的原始前置任务设置为新任务的前置任务 197 | taskToInsert.predecessors = targetTask.predecessors; 198 | 199 | // 创建最终更新的任务列表 200 | return tasksToUpdate 201 | .map(task => task.id === finalTargetTask.id ? finalTargetTask : task) 202 | .concat([taskToInsert]); 203 | }); 204 | 205 | setIsCalculated(false); 206 | }, []); 207 | 208 | // 递增ID的辅助函数(支持数字和字母) 209 | const incrementId = (id: string): string => { 210 | const num = Number(id); 211 | if (!isNaN(num)) { 212 | return String(num + 1); 213 | } else if (id.length === 1) { 214 | const charCode = id.charCodeAt(0); 215 | if ((charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122)) { 216 | return String.fromCharCode(charCode + 1); 217 | } 218 | } 219 | return id + "1"; // 默认处理 220 | }; 221 | 222 | // 重新编号任务(递增) 223 | const renumberTasks = useCallback((startIndex: number, increment: number = 1) => { 224 | setTasks(prev => { 225 | // 创建ID映射表 226 | const idMap: Record = {}; 227 | 228 | // 识别需要重新编号的任务(ID大于等于startIndex的所有任务) 229 | const tasksToRenumber = prev.filter(task => { 230 | // 支持数字ID和字母ID 231 | const taskNum = Number(task.id); 232 | if (!isNaN(taskNum)) { 233 | return taskNum >= startIndex; 234 | } else { 235 | // 字母ID的比较,例如:'B' >= 'B' 或 'C' >= 'B' 236 | return task.id >= String(startIndex); 237 | } 238 | }); 239 | 240 | // 按ID从大到小排序,确保从最高ID开始重编号,避免冲突 241 | const sortedTasksToRenumber = [...tasksToRenumber].sort((a, b) => { 242 | const aNum = Number(a.id); 243 | const bNum = Number(b.id); 244 | if (!isNaN(aNum) && !isNaN(bNum)) { 245 | return bNum - aNum; // 数字ID,从大到小 246 | } else { 247 | return b.id.localeCompare(a.id); // 字母ID,从大到小 248 | } 249 | }); 250 | 251 | // 从高ID到低ID进行重编号 252 | sortedTasksToRenumber.forEach(task => { 253 | const taskNum = Number(task.id); 254 | if (!isNaN(taskNum)) { 255 | // 数字ID递增 256 | idMap[task.id] = String(taskNum + increment); 257 | } else { 258 | // 字母ID递增(例如:'B' -> 'C') 259 | // 处理单个字符的字母 260 | if (task.id.length === 1) { 261 | const charCode = task.id.charCodeAt(0); 262 | // 确保在有效范围内 263 | if ((charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122)) { 264 | idMap[task.id] = String.fromCharCode(charCode + increment); 265 | } 266 | } else { 267 | // 多字符ID的处理(如果需要,可以实现更复杂的逻辑) 268 | // 此处仅为简单实现,实际可能需要更复杂的序列处理 269 | idMap[task.id] = task.id + increment; 270 | } 271 | } 272 | }); 273 | 274 | // 更新任务ID和前置任务引用 275 | const updatedTasks = prev.map(task => { 276 | const newId = idMap[task.id]; 277 | if (!newId) return task; 278 | 279 | return { 280 | ...task, 281 | id: newId, 282 | predecessors: task.predecessors.map(pred => ({ 283 | ...pred, 284 | taskId: idMap[pred.taskId] || pred.taskId 285 | })) 286 | }; 287 | }); 288 | 289 | // 全局更新:确保所有任务中引用到重编号任务的地方都被更新 290 | // 这已经在上面的代码中处理了前置任务的引用,实际项目可能需要处理更多关系 291 | 292 | return updatedTasks; 293 | }); 294 | setIsCalculated(false); 295 | }, []); 296 | 297 | // 删除任务 298 | const deleteTask = useCallback((taskId: string) => { 299 | // 删除任务本身 300 | setTasks(prev => prev.filter(task => task.id !== taskId)); 301 | 302 | // 从其他任务的前置任务列表中移除此任务 303 | setTasks(prev => 304 | prev.map(task => ({ 305 | ...task, 306 | predecessors: task.predecessors.filter(p => p.taskId !== taskId) 307 | })) 308 | ); 309 | 310 | setIsCalculated(false); 311 | }, []); 312 | 313 | // 计算项目调度 314 | const calculateSchedule = useCallback(() => { 315 | try { 316 | if (tasks.length === 0) { 317 | setError('No tasks to calculate'); 318 | return; 319 | } 320 | 321 | setError(null); 322 | 323 | const scheduler = new ProjectScheduler(tasks); 324 | scheduler.calculateSchedule(); 325 | 326 | // 更新计算后的任务 327 | setTasks(scheduler.getCalculatedTasks()); 328 | 329 | // 更新路径和关键路径 330 | setPaths(scheduler.getAllPaths()); 331 | setCriticalPaths(scheduler.getCriticalPaths()); 332 | 333 | // 更新项目持续时间 334 | setProjectDuration(scheduler.getProjectDuration()); 335 | 336 | setIsCalculated(true); 337 | } catch (err) { 338 | setError(err instanceof Error ? err.message : 'An unknown error occurred'); 339 | setIsCalculated(false); 340 | } 341 | }, [tasks]); 342 | 343 | // 清空项目 344 | const clearProject = useCallback(() => { 345 | setProjectName(defaultProject.name); 346 | setTasks(defaultProject.tasks); 347 | setPaths(defaultProject.paths); 348 | setCriticalPaths(defaultProject.criticalPaths); 349 | setProjectDuration(defaultProject.projectDuration); 350 | setIsCalculated(false); 351 | setError(null); 352 | setSelectedTaskId(null); 353 | }, []); 354 | 355 | // 保存项目到localStorage,添加项目命名功能 356 | const saveProject = useCallback((projectName: string) => { 357 | try { 358 | // 生成唯一ID 359 | const projectId = Date.now().toString(); 360 | 361 | const projectData: SavedProjectData = { 362 | id: projectId, 363 | name: projectName.trim() || `Project ${projectId.slice(-4)}`, 364 | tasks, 365 | lastUpdated: new Date().toISOString() 366 | }; 367 | 368 | // 获取现有的项目列表 369 | let savedProjects: SavedProjectsList = {projects: []}; 370 | const savedProjectsString = localStorage.getItem('networkDiagramProjectsList'); 371 | 372 | if (savedProjectsString) { 373 | savedProjects = JSON.parse(savedProjectsString); 374 | } 375 | 376 | // 添加新项目到列表 377 | savedProjects.projects.push(projectData); 378 | 379 | // 保存更新后的项目列表 380 | localStorage.setItem('networkDiagramProjectsList', JSON.stringify(savedProjects)); 381 | 382 | console.log(`Saving network diagram project "${projectData.name}" with ID: ${projectId}, ${tasks.length} tasks`); 383 | return true; 384 | } catch (err) { 385 | console.error('Failed to save project:', err); 386 | return false; 387 | } 388 | }, [tasks]); 389 | 390 | // 获取保存的项目列表 391 | const getSavedProjects = useCallback(() => { 392 | try { 393 | const savedProjectsString = localStorage.getItem('networkDiagramProjectsList'); 394 | if (!savedProjectsString) { 395 | return []; 396 | } 397 | 398 | const savedProjects = JSON.parse(savedProjectsString) as SavedProjectsList; 399 | return savedProjects.projects; 400 | } catch (err) { 401 | console.error('Failed to get saved projects list:', err); 402 | return []; 403 | } 404 | }, []); 405 | 406 | // 从列表中加载特定项目 407 | const loadProject = useCallback((projectId: string) => { 408 | try { 409 | const savedProjects = getSavedProjects(); 410 | const projectToLoad = savedProjects.find(project => project.id === projectId); 411 | 412 | if (!projectToLoad) { 413 | setError(`Project with ID ${projectId} not found`); 414 | return false; 415 | } 416 | 417 | setProjectName(projectToLoad.name || defaultProject.name); 418 | setTasks(projectToLoad.tasks || []); 419 | setIsCalculated(false); 420 | setError(null); 421 | 422 | return true; 423 | } catch (err) { 424 | setError('Failed to load project'); 425 | console.error('Failed to load project:', err); 426 | return false; 427 | } 428 | }, [getSavedProjects]); 429 | 430 | // 删除保存的项目 431 | const deleteProject = useCallback((projectId: string) => { 432 | try { 433 | const savedProjectsString = localStorage.getItem('networkDiagramProjectsList'); 434 | if (!savedProjectsString) { 435 | return false; 436 | } 437 | 438 | const savedProjects = JSON.parse(savedProjectsString) as SavedProjectsList; 439 | 440 | // 过滤掉要删除的项目 441 | savedProjects.projects = savedProjects.projects.filter(project => project.id !== projectId); 442 | 443 | // 保存更新后的项目列表 444 | localStorage.setItem('networkDiagramProjectsList', JSON.stringify(savedProjects)); 445 | 446 | return true; 447 | } catch (err) { 448 | console.error('Failed to delete project:', err); 449 | return false; 450 | } 451 | }, []); 452 | 453 | // 更新项目名称 454 | const updateProjectName = useCallback((projectId: string, newName: string) => { 455 | try { 456 | const savedProjectsString = localStorage.getItem('networkDiagramProjectsList'); 457 | if (!savedProjectsString) { 458 | return false; 459 | } 460 | 461 | const savedProjects = JSON.parse(savedProjectsString) as SavedProjectsList; 462 | 463 | // 查找并更新项目名称 464 | const projectToUpdate = savedProjects.projects.find(project => project.id === projectId); 465 | if (!projectToUpdate) { 466 | return false; 467 | } 468 | 469 | projectToUpdate.name = newName.trim() || projectToUpdate.name; 470 | 471 | // 保存更新后的项目列表 472 | localStorage.setItem('networkDiagramProjectsList', JSON.stringify(savedProjects)); 473 | 474 | return true; 475 | } catch (err) { 476 | console.error('Failed to update project name:', err); 477 | return false; 478 | } 479 | }, []); 480 | 481 | // 重新排序任务(通过拖拽) 482 | const reorderTasks = useCallback((oldIndex: number, newIndex: number) => { 483 | setTasks(prev => { 484 | const result = Array.from(prev); 485 | const [removed] = result.splice(oldIndex, 1); 486 | result.splice(newIndex, 0, removed); 487 | return result; 488 | }); 489 | setIsCalculated(false); 490 | }, []); 491 | 492 | // 更新任务ID(专门处理ID变更的情况) 493 | const updateTaskWithNewId = useCallback((originalId: string, updatedTask: Task) => { 494 | setTasks(prev => { 495 | // 1. 移除原ID的任务 496 | const tasksWithoutOriginal = prev.filter(task => task.id !== originalId); 497 | 498 | // 2. 更新所有引用了原ID的任务的前置任务 499 | const updatedTasks = tasksWithoutOriginal.map(task => { 500 | // 检查任务是否引用了原ID作为前置任务 501 | const hasOriginalIdAsPredecessor = task.predecessors.some(p => p.taskId === originalId); 502 | 503 | if (hasOriginalIdAsPredecessor) { 504 | // 更新引用 505 | return { 506 | ...task, 507 | predecessors: task.predecessors.map(p => ({ 508 | ...p, 509 | taskId: p.taskId === originalId ? updatedTask.id : p.taskId 510 | })) 511 | }; 512 | } 513 | 514 | return task; 515 | }); 516 | 517 | // 3. 添加更新后的任务 518 | return [...updatedTasks, updatedTask]; 519 | }); 520 | 521 | setIsCalculated(false); 522 | }, []); 523 | 524 | return { 525 | projectName, 526 | setProjectName, 527 | tasks, 528 | paths, 529 | criticalPaths, 530 | projectDuration, 531 | isCalculated, 532 | error, 533 | selectedTaskId, 534 | setSelectedTaskId, 535 | addTask, 536 | updateTask, 537 | updateTasks, 538 | insertTaskBefore, 539 | renumberTasks, 540 | deleteTask, 541 | calculateSchedule, 542 | clearProject, 543 | saveProject, 544 | loadProject, 545 | reorderTasks, 546 | updateTaskWithNewId, 547 | getSavedProjects, 548 | deleteProject, 549 | updateProjectName 550 | }; 551 | }; -------------------------------------------------------------------------------- /src/components/NetworkDiagramForCrashing.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState, useRef } from 'react'; 2 | import ReactFlow, { 3 | Node, 4 | Edge, 5 | Controls, 6 | Background, 7 | useNodesState, 8 | useEdgesState, 9 | MarkerType, 10 | Position, 11 | ReactFlowProvider, 12 | Panel, 13 | Handle, 14 | NodeChange, 15 | NodePositionChange 16 | } from 'reactflow'; 17 | import 'reactflow/dist/style.css'; 18 | import { Box, Paper, Typography, Divider, Button } from '@mui/material'; 19 | import { useProjectCrashing, CrashTask, NodePosition } from '../hooks/ProjectCrashingContext'; 20 | import RestartAltIcon from '@mui/icons-material/RestartAlt'; 21 | import FullscreenIcon from '@mui/icons-material/Fullscreen'; 22 | import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; 23 | 24 | // 自定义节点样式 25 | const customNodeStyles = { 26 | default: { 27 | background: '#ffffff', 28 | color: '#333', 29 | border: '1px solid #ccc' 30 | }, 31 | critical: { 32 | background: '#ffe6e6', 33 | color: '#d32f2f', 34 | border: '1px solid #d32f2f' 35 | }, 36 | highlight: { 37 | color: '#1976d2', 38 | fontWeight: 'bold' 39 | } 40 | }; 41 | 42 | // 自定义节点组件 - 显示任务节点 43 | const CustomTaskNode = ({ data }: { data: any }) => { 44 | return ( 45 | 57 | 58 | 59 | {data.label} 60 | 61 | 62 | 68 | 72 | Duration: {data.duration} days 73 | {data.originalDuration !== undefined && data.originalDuration !== data.duration && 74 | ` (original: ${data.originalDuration})`} 75 | 76 | 77 | 78 | ES: {data.earlyStart !== undefined ? data.earlyStart : '-'} | 79 | EF: {data.earlyFinish !== undefined ? data.earlyFinish : '-'} 80 | 81 | 82 | LS: {data.lateStart !== undefined ? data.lateStart : '-'} | 83 | LF: {data.lateFinish !== undefined ? data.lateFinish : '-'} 84 | 85 | 86 | Slack: {data.slack !== undefined ? data.slack : '-'} 87 | 88 | 89 | Crash Info: {data.maxCrashTime > 0 ? `${data.maxCrashTime} units left` : 'Max crashed'} 90 | 91 | 92 | 93 | ); 94 | }; 95 | 96 | // 节点类型映射 97 | const nodeTypes = { 98 | taskNode: CustomTaskNode 99 | }; 100 | 101 | // 网络图组件 102 | const DiagramCanvas = () => { 103 | const { 104 | crashTasks, 105 | crashedTasksHistory, 106 | isCrashed, 107 | currentIteration, 108 | costAnalysis, 109 | nodePositions, 110 | setNodePositions, 111 | resetNodePositions 112 | } = useProjectCrashing(); 113 | const [nodes, setNodes, onNodesChange] = useNodesState([]); 114 | const [edges, setEdges, onEdgesChange] = useEdgesState([]); 115 | const [isFullScreen, setIsFullScreen] = useState(false); 116 | const diagramContainerRef = useRef(null); 117 | 118 | // Toggle full screen mode using the Fullscreen API 119 | const toggleFullScreen = () => { 120 | if (!isFullScreen) { 121 | if (diagramContainerRef.current?.requestFullscreen) { 122 | diagramContainerRef.current.requestFullscreen() 123 | .then(() => setIsFullScreen(true)) 124 | .catch(err => console.error("Error attempting to enable fullscreen:", err)); 125 | } 126 | } else { 127 | if (document.exitFullscreen) { 128 | document.exitFullscreen() 129 | .then(() => setIsFullScreen(false)) 130 | .catch(err => console.error("Error attempting to exit fullscreen:", err)); 131 | } 132 | } 133 | }; 134 | 135 | // Listen for fullscreen change events 136 | useEffect(() => { 137 | const handleFullscreenChange = () => { 138 | setIsFullScreen(!!document.fullscreenElement); 139 | }; 140 | 141 | document.addEventListener('fullscreenchange', handleFullscreenChange); 142 | return () => { 143 | document.removeEventListener('fullscreenchange', handleFullscreenChange); 144 | }; 145 | }, []); 146 | 147 | // Consolidated logic for getting tasks based on current state 148 | const getCurrentTasks = useCallback((): CrashTask[] => { 149 | if (!isCrashed) { 150 | // NOT CRASHED: Display tasks from context, ensuring duration is normalTime. 151 | // This state is for when tasks are being added/edited BEFORE performCrashing. 152 | return crashTasks.map(task => ({ 153 | ...task, 154 | duration: task.normalTime, 155 | })); 156 | } else { 157 | // CRASHED: Use history. Iteration 0 should have duration = normalTime. 158 | if (crashedTasksHistory.length > 0) { 159 | const iterationIndex = Math.min(currentIteration, crashedTasksHistory.length - 1); 160 | return crashedTasksHistory[iterationIndex]; 161 | } else { 162 | // Fallback if history is unexpectedly empty after crashing. 163 | return crashTasks.map(task => ({ ...task, duration: task.normalTime })); 164 | } 165 | } 166 | }, [isCrashed, crashTasks, crashedTasksHistory, currentIteration]); 167 | 168 | const getCrashedActivities = useCallback(() => { 169 | if (!isCrashed || currentIteration === 0 || !costAnalysis[currentIteration]) { 170 | return []; 171 | } 172 | 173 | // 收集从第1次迭代到当前迭代的所有被crash的任务 174 | const allCrashedActivities: string[] = []; 175 | for (let i = 1; i <= currentIteration; i++) { 176 | if (costAnalysis[i] && costAnalysis[i].crashedActivities) { 177 | costAnalysis[i].crashedActivities.forEach(activity => { 178 | if (!allCrashedActivities.includes(activity)) { 179 | allCrashedActivities.push(activity); 180 | } 181 | }); 182 | } 183 | } 184 | 185 | return allCrashedActivities; 186 | }, [isCrashed, currentIteration, costAnalysis]); 187 | 188 | // Reset node positions to original layout 189 | const handleResetPositions = () => { 190 | resetNodePositions(); 191 | // Force re-render with recalculated positions 192 | const { nodes: newNodes, edges: newEdges } = createFlowElements(); 193 | setNodes(newNodes); 194 | setEdges(newEdges); 195 | }; 196 | 197 | // Handle when nodes change positions 198 | const handleNodesChange = (changes: NodeChange[]) => { 199 | onNodesChange(changes); 200 | 201 | // Save position changes 202 | const positionChanges = changes.filter((change): change is NodePositionChange => 203 | change.type === 'position' && !change.dragging 204 | ); 205 | 206 | if (positionChanges.length > 0) { 207 | positionChanges.forEach(change => { 208 | const nodeId = change.id; 209 | const updatedNode = nodes.find(n => n.id === nodeId); 210 | if (updatedNode) { 211 | setNodePositions((prevPositions: NodePosition[]) => { 212 | // Remove old position if exists 213 | const filteredPositions = prevPositions.filter(pos => pos.id !== nodeId); 214 | // Add new position 215 | return [...filteredPositions, { 216 | id: nodeId, 217 | x: updatedNode.position.x, 218 | y: updatedNode.position.y 219 | }]; 220 | }); 221 | } 222 | }); 223 | } 224 | }; 225 | 226 | const calculatePositions = useCallback((tasks: CrashTask[]) => { 227 | // First check if we have stored positions 228 | if (nodePositions.length > 0) { 229 | const positionMap: { [key: string]: { x: number, y: number } } = {}; 230 | nodePositions.forEach(pos => { 231 | positionMap[pos.id] = { x: pos.x, y: pos.y }; 232 | }); 233 | 234 | // Check if we need to calculate positions for any new tasks 235 | const existingIds = nodePositions.map(pos => pos.id); 236 | const allTaskIds = tasks.map(task => task.id); 237 | const needsCalculation = allTaskIds.some(id => !existingIds.includes(id)); 238 | 239 | if (!needsCalculation) { 240 | // All tasks have stored positions 241 | return positionMap; 242 | } else { 243 | // Calculate positions only for new tasks 244 | const calculatedPositions = calculateDefaultPositions(tasks); 245 | // Merge with existing positions 246 | return { ...calculatedPositions, ...positionMap }; 247 | } 248 | } else { 249 | // No stored positions, calculate all 250 | return calculateDefaultPositions(tasks); 251 | } 252 | }, [nodePositions]); 253 | 254 | // Original position calculation logic moved to a separate function 255 | const calculateDefaultPositions = (tasks: CrashTask[]) => { 256 | const taskLevels: { [key: string]: number } = {}; 257 | const maxLevel = { value: 0 }; 258 | const calculateLevel = (taskId: string, level: number, visited: Set) => { 259 | if (visited.has(taskId)) return; 260 | visited.add(taskId); 261 | taskLevels[taskId] = Math.max(taskLevels[taskId] || 0, level); 262 | maxLevel.value = Math.max(maxLevel.value, level); 263 | const task = tasks.find(t => t.id === taskId); 264 | if (task) { 265 | const successors = tasks.filter(t => t.predecessors.some(p => p.taskId === taskId)); 266 | successors.forEach(s => calculateLevel(s.id, level + 1, visited)); 267 | } 268 | }; 269 | const startTasks = tasks.filter(task => task.predecessors.length === 0); 270 | startTasks.forEach(task => calculateLevel(task.id, 0, new Set())); 271 | const levelCounts: { [key: number]: number } = {}; 272 | for (const taskId in taskLevels) { 273 | const level = taskLevels[taskId]; 274 | levelCounts[level] = (levelCounts[level] || 0) + 1; 275 | } 276 | const sortedTasks = [...tasks].sort((a, b) => { 277 | const levelA = taskLevels[a.id] || 0; 278 | const levelB = taskLevels[b.id] || 0; 279 | if (levelA !== levelB) return levelA - levelB; 280 | return a.id.localeCompare(b.id); 281 | }); 282 | const levelPositions: { [key: number]: number } = {}; 283 | const taskPositions: { [key: string]: { x: number, y: number } } = {}; 284 | for (const task of sortedTasks) { 285 | const level = taskLevels[task.id] || 0; 286 | const position = levelPositions[level] || 0; 287 | const x = level * 300 + 100; 288 | const y = position * 150 + 50; 289 | taskPositions[task.id] = { x, y }; 290 | levelPositions[level] = position + 1; 291 | } 292 | return taskPositions; 293 | }; 294 | 295 | // Create nodes and edges 296 | const createFlowElements = useCallback(() => { 297 | const tasksToDisplay = getCurrentTasks(); 298 | 299 | if (!tasksToDisplay || tasksToDisplay.length === 0) { 300 | return { nodes: [], edges: [] }; 301 | } 302 | 303 | const positions = calculatePositions(tasksToDisplay); 304 | const currentCrashedActivities = getCrashedActivities(); 305 | 306 | const newNodes: Node[] = tasksToDisplay.map(task => { 307 | let nodeOriginalDuration; 308 | // 仅当项目已压缩、不是初始迭代且持续时间已改变时才显示原始持续时间 309 | if (isCrashed && currentIteration > 0 && crashedTasksHistory.length > 0 && crashedTasksHistory[0]) { 310 | const initialTaskState = crashedTasksHistory[0].find(t => t.id === task.id); 311 | if (initialTaskState && initialTaskState.duration !== task.duration) { 312 | nodeOriginalDuration = initialTaskState.duration; 313 | } 314 | } 315 | 316 | return { 317 | id: task.id, 318 | type: 'taskNode', 319 | position: positions[task.id] || { x: 0, y: 0 }, 320 | data: { 321 | label: `${task.id}${task.description ? ': ' + task.description : ''}`, 322 | duration: task.duration, 323 | originalDuration: nodeOriginalDuration, 324 | normalTime: task.normalTime, 325 | normalCost: task.normalCost, 326 | crashTime: task.crashTime, 327 | crashCost: task.crashCost, 328 | maxCrashTime: task.maxCrashTime, 329 | slope: task.slope, 330 | earlyStart: task.earlyStart, 331 | earlyFinish: task.earlyFinish, 332 | lateStart: task.lateStart, 333 | lateFinish: task.lateFinish, 334 | slack: task.slack, 335 | isCritical: task.isCritical, 336 | highlighted: currentCrashedActivities.includes(task.id) 337 | }, 338 | draggable: true, 339 | }; 340 | }); 341 | 342 | const newEdges: Edge[] = []; 343 | tasksToDisplay.forEach(task => { 344 | task.predecessors.forEach(pred => { 345 | const predTask = tasksToDisplay.find(t => t.id === pred.taskId); 346 | if (!predTask) return; 347 | const isCriticalEdge = task.isCritical && predTask.isCritical; 348 | newEdges.push({ 349 | id: `edge-${pred.taskId}-to-${task.id}`, 350 | source: pred.taskId, 351 | target: task.id, 352 | type: 'straight', 353 | style: { stroke: isCriticalEdge ? '#FF0000' : '#1a365d', strokeWidth: 2 }, 354 | markerEnd: { type: MarkerType.ArrowClosed, width: 24, height: 24, color: isCriticalEdge ? '#FF0000' : '#1a365d' }, 355 | animated: isCriticalEdge, 356 | }); 357 | }); 358 | }); 359 | return { nodes: newNodes, edges: newEdges }; 360 | }, [getCurrentTasks, calculatePositions, getCrashedActivities, isCrashed, currentIteration, crashedTasksHistory]); 361 | 362 | // useEffect to update diagram when relevant data changes 363 | useEffect(() => { 364 | const { nodes: newNodes, edges: newEdges } = createFlowElements(); 365 | setNodes(newNodes); 366 | setEdges(newEdges); 367 | }, [isCrashed, currentIteration, crashTasks, crashedTasksHistory, createFlowElements, setNodes, setEdges]); 368 | 369 | // Conditional rendering based on whether there are nodes to display 370 | if (nodes.length === 0) { 371 | return ( 372 |
373 | 374 | {isCrashed ? "No tasks data for current iteration." : "Add tasks to see the network diagram."} 375 | 376 |
377 | ); 378 | } 379 | 380 | return ( 381 |
389 | 399 | 400 | 401 | 402 |
414 |
Legend:
415 |
416 |
422 | Critical Path 423 |
424 |
425 |
432 | Critical Task 433 |
434 |
435 |
442 | Crashed Task 443 |
444 |
445 | Current Iteration: {currentIteration} 446 |
447 |
448 |
449 | 450 | 451 | 459 | 467 | 468 | 469 |
470 |
471 | ); 472 | }; 473 | 474 | // Network Diagram for Project Crashing 475 | const NetworkDiagramForCrashing: React.FC = () => { 476 | const { isCrashed, costAnalysis, currentIteration } = useProjectCrashing(); 477 | 478 | // 根据当前迭代动态获取项目持续时间 479 | const getCurrentProjectDuration = () => { 480 | if (!isCrashed || costAnalysis.length === 0) { 481 | return 0; 482 | } 483 | 484 | // 确保不超出costAnalysis的范围 485 | const iterIndex = Math.min(currentIteration, costAnalysis.length - 1); 486 | return costAnalysis[iterIndex].projectDuration; 487 | }; 488 | 489 | const currentProjectDuration = getCurrentProjectDuration(); 490 | 491 | return ( 492 | 493 | 494 | Network Diagram 495 | 496 | 497 | 498 | {/* 使用当前迭代的项目持续时间 */} 499 | {isCrashed && currentProjectDuration > 0 && `Project Duration: ${currentProjectDuration} days`} 500 | 501 | 502 | {isCrashed 503 | ? currentIteration > 0 504 | ? `Iteration ${currentIteration} - Use the slider to navigate through different iterations` 505 | : 'Initial state (Iteration 0 - before any crashing)' 506 | : 'Add tasks and click "Crashing Project" to view the diagram and analysis.' 507 | } 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | ); 517 | }; 518 | 519 | export default NetworkDiagramForCrashing; -------------------------------------------------------------------------------- /src/components/Workspace.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import { 3 | Container, 4 | Box, 5 | Button, 6 | Typography, 7 | TextField, 8 | Dialog, 9 | DialogTitle, 10 | DialogContent, 11 | DialogActions, 12 | Snackbar, 13 | Alert, 14 | List, 15 | ListItem, 16 | ListItemText, 17 | ListItemSecondaryAction, 18 | IconButton, 19 | DialogContentText, 20 | Grid 21 | } from '@mui/material'; 22 | import SaveIcon from '@mui/icons-material/Save'; 23 | import CalculateIcon from '@mui/icons-material/Calculate'; 24 | import ClearIcon from '@mui/icons-material/Clear'; 25 | import FolderOpenIcon from '@mui/icons-material/FolderOpen'; 26 | import InfoIcon from '@mui/icons-material/Info'; 27 | import EditIcon from '@mui/icons-material/Edit'; 28 | import DeleteIcon from '@mui/icons-material/Delete'; 29 | import SettingsIcon from '@mui/icons-material/Settings'; 30 | import FileDownloadIcon from '@mui/icons-material/FileDownload'; 31 | import * as XLSX from 'xlsx'; 32 | // @ts-ignore 33 | import { saveAs } from 'file-saver'; 34 | 35 | import TaskForm from './TaskForm'; 36 | import TaskTable from './TaskTable'; 37 | import NetworkDiagram from './NetworkDiagram'; 38 | import PathAnalysis from './PathAnalysis'; 39 | import { useProject } from '../hooks/ProjectContext'; 40 | import { SavedProjectData } from '../hooks/useProjectData'; 41 | 42 | // 工作区组件 - 应用程序的主容器 43 | const Workspace: React.FC = () => { 44 | const { 45 | projectName, 46 | setProjectName, 47 | tasks, 48 | calculateSchedule, 49 | clearProject, 50 | saveProject, 51 | loadProject, 52 | isCalculated, 53 | error, 54 | selectedTaskId, 55 | getSavedProjects, 56 | deleteProject, 57 | updateProjectName, 58 | addTask 59 | } = useProject(); 60 | 61 | // UI状态 62 | const [infoOpen, setInfoOpen] = useState(false); 63 | const [alertOpen, setAlertOpen] = useState(false); 64 | const [alertMessage, setAlertMessage] = useState(''); 65 | const [alertSeverity, setAlertSeverity] = useState<'success' | 'error'>('success'); 66 | 67 | // 添加状态管理保存和加载对话框 68 | const [saveDialogOpen, setSaveDialogOpen] = useState(false); 69 | const [loadDialogOpen, setLoadDialogOpen] = useState(false); 70 | const [manageDialogOpen, setManageDialogOpen] = useState(false); 71 | const [projectNameInput, setProjectNameInput] = useState(''); 72 | const [savedProjects, setSavedProjects] = useState([]); 73 | const [editingProject, setEditingProject] = useState<{id: string, name: string} | null>(null); 74 | const [importError, setImportError] = useState(null); 75 | const fileInputRef = useRef(null); 76 | 77 | // 显示提示信息 78 | const showAlert = (message: string, severity: 'success' | 'error') => { 79 | setAlertMessage(message); 80 | setAlertSeverity(severity); 81 | setAlertOpen(true); 82 | }; 83 | 84 | // 清空项目前确认 85 | const handleClearProject = () => { 86 | if (window.confirm('Are you sure you want to clear all project data?')) { 87 | clearProject(); 88 | showAlert('Project cleared successfully', 'success'); 89 | } 90 | }; 91 | 92 | // 处理保存项目 93 | const handleSaveProject = () => { 94 | if (tasks.length === 0) { 95 | showAlert('No tasks to save', 'error'); 96 | return; 97 | } 98 | 99 | setProjectNameInput(projectName); 100 | setSaveDialogOpen(true); 101 | }; 102 | 103 | // 处理保存对话框确认 104 | const handleConfirmSave = () => { 105 | const success = saveProject(projectNameInput); 106 | if (success) { 107 | showAlert(`Project "${projectNameInput || 'Untitled'}" saved successfully`, 'success'); 108 | setSaveDialogOpen(false); 109 | // 更新当前项目名称 110 | setProjectName(projectNameInput); 111 | } else { 112 | showAlert('Failed to save project', 'error'); 113 | } 114 | }; 115 | 116 | // 处理加载项目 117 | const handleLoadProject = () => { 118 | try { 119 | // 获取保存的项目列表 120 | const projects = getSavedProjects(); 121 | 122 | if (projects.length === 0) { 123 | showAlert('No saved projects found', 'error'); 124 | return; 125 | } 126 | 127 | setSavedProjects(projects); 128 | setLoadDialogOpen(true); 129 | } catch (err) { 130 | showAlert('Failed to load project list', 'error'); 131 | console.error('Failed to load project list:', err); 132 | } 133 | }; 134 | 135 | // 处理加载特定项目 136 | const handleLoadSpecificProject = (projectId: string) => { 137 | try { 138 | const success = loadProject(projectId); 139 | 140 | if (success) { 141 | const project = savedProjects.find(p => p.id === projectId); 142 | showAlert(`Project "${project?.name || 'Unknown'}" loaded successfully`, 'success'); 143 | setLoadDialogOpen(false); 144 | } else { 145 | showAlert('Failed to load project', 'error'); 146 | } 147 | } catch (err) { 148 | showAlert('Failed to load project: Invalid format', 'error'); 149 | console.error('Failed to load project:', err); 150 | } 151 | }; 152 | 153 | // 处理管理项目 154 | const handleManageProjects = () => { 155 | const projects = getSavedProjects(); 156 | setSavedProjects(projects); 157 | setManageDialogOpen(true); 158 | }; 159 | 160 | // 处理删除项目 161 | const handleDeleteProject = (projectId: string) => { 162 | if (window.confirm('Are you sure you want to delete this project?')) { 163 | const success = deleteProject(projectId); 164 | if (success) { 165 | setSavedProjects(savedProjects.filter(p => p.id !== projectId)); 166 | showAlert('Project deleted successfully', 'success'); 167 | } else { 168 | showAlert('Failed to delete project', 'error'); 169 | } 170 | } 171 | }; 172 | 173 | // 处理编辑项目名称 174 | const handleEditProjectName = (project: SavedProjectData) => { 175 | setEditingProject({ id: project.id, name: project.name }); 176 | }; 177 | 178 | // 处理保存项目名称 179 | const handleSaveProjectName = () => { 180 | if (editingProject) { 181 | const success = updateProjectName(editingProject.id, editingProject.name); 182 | if (success) { 183 | setSavedProjects(savedProjects.map(p => 184 | p.id === editingProject.id 185 | ? { ...p, name: editingProject.name } 186 | : p 187 | )); 188 | setEditingProject(null); 189 | showAlert('Project name updated', 'success'); 190 | } else { 191 | showAlert('Failed to update project name', 'error'); 192 | } 193 | } 194 | }; 195 | 196 | // 计算网络图 197 | const handleCalculate = () => { 198 | try { 199 | calculateSchedule(); 200 | if (!error) { 201 | showAlert('Network diagram generated successfully', 'success'); 202 | } else { 203 | showAlert(`Error: ${error}`, 'error'); 204 | } 205 | } catch (err) { 206 | const errorMessage = err instanceof Error ? err.message : 'Failed to generate network diagram'; 207 | showAlert(`Error: ${errorMessage}`, 'error'); 208 | } 209 | }; 210 | 211 | // 在现有函数之后添加导出Excel功能 212 | const handleExportToExcel = (projectToExport?: SavedProjectData) => { 213 | const tasksToExport = projectToExport ? projectToExport.tasks : tasks; 214 | const projectNameToExport = projectToExport ? projectToExport.name : projectName; 215 | 216 | // 创建工作簿 217 | const workbook = XLSX.utils.book_new(); 218 | 219 | // 创建任务表格 220 | const tasksHeaders = ['Task ID', 'Description', 'Predecessors', 'Duration']; 221 | const tasksData = tasksToExport.map(task => [ 222 | task.id, 223 | task.description, 224 | task.predecessors.map(p => p.taskId).join(','), 225 | task.duration 226 | ]); 227 | 228 | // 添加标题行 229 | const titleRow = [`Task List - ${projectNameToExport || 'Project'}`]; 230 | const completeTasksData = [titleRow, [], tasksHeaders, ...tasksData]; 231 | 232 | const tasksSheet = XLSX.utils.aoa_to_sheet(completeTasksData); 233 | 234 | // 设置列宽 235 | const wscols = [ 236 | { wch: 10 }, // Task ID 237 | { wch: 30 }, // Description 238 | { wch: 15 }, // Predecessors 239 | { wch: 15 }, // Duration 240 | ]; 241 | 242 | tasksSheet['!cols'] = wscols; 243 | 244 | // 添加到工作簿 245 | XLSX.utils.book_append_sheet(workbook, tasksSheet, 'Task List'); 246 | 247 | // 生成Excel文件 248 | const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); 249 | const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); 250 | 251 | // 文件名包含日期和时间 252 | const dateStr = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19); 253 | const fileName = `${projectNameToExport || 'Project'}_TaskList_${dateStr}.xlsx`; 254 | 255 | // 保存文件 256 | saveAs(data, fileName); 257 | 258 | // 显示成功信息 259 | showAlert(`Task List exported as "${fileName}"`, 'success'); 260 | }; 261 | 262 | // 添加导入Excel功能 263 | const handleImportFromExcel = (event: React.ChangeEvent) => { 264 | const file = event.target.files?.[0]; 265 | if (!file) return; 266 | 267 | // 重置文件输入字段 268 | event.target.value = ''; 269 | 270 | // 读取Excel文件 271 | const reader = new FileReader(); 272 | reader.onload = (e) => { 273 | try { 274 | const data = new Uint8Array(e.target?.result as ArrayBuffer); 275 | const workbook = XLSX.read(data, { type: 'array' }); 276 | 277 | // 获取第一个工作表 278 | const worksheet = workbook.Sheets[workbook.SheetNames[0]]; 279 | 280 | // 转换为JSON 281 | const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1, blankrows: false }); 282 | 283 | // 验证格式 - 查找表头行 284 | let headerRowIndex = -1; 285 | for (let i = 0; i < jsonData.length; i++) { 286 | const row = jsonData[i]; 287 | if (Array.isArray(row) && 288 | row.includes('Task ID') && 289 | row.includes('Description') && 290 | row.includes('Predecessors') && 291 | row.includes('Duration')) { 292 | headerRowIndex = i; 293 | break; 294 | } 295 | } 296 | 297 | if (headerRowIndex === -1) { 298 | setImportError('Invalid file format: Headers not found'); 299 | return; 300 | } 301 | 302 | // 获取列索引 303 | const headers = jsonData[headerRowIndex]; 304 | const taskIdIndex = headers.indexOf('Task ID'); 305 | const descriptionIndex = headers.indexOf('Description'); 306 | const predecessorsIndex = headers.indexOf('Predecessors'); 307 | const durationIndex = headers.indexOf('Duration'); 308 | 309 | // 验证必要列是否存在 310 | if (taskIdIndex === -1 || durationIndex === -1) { 311 | setImportError('Invalid file format: Required columns missing'); 312 | return; 313 | } 314 | 315 | // 解析任务数据并添加到项目 316 | const importedTasks = []; 317 | let invalidRowFound = false; 318 | 319 | for (let i = headerRowIndex + 1; i < jsonData.length; i++) { 320 | const row = jsonData[i]; 321 | 322 | // 确保行有效 323 | if (!row || !row[taskIdIndex] || row[taskIdIndex] === '') continue; 324 | 325 | // 检查必填字段 326 | if (row[durationIndex] === undefined) { 327 | invalidRowFound = true; 328 | continue; 329 | } 330 | 331 | // 处理前置任务 332 | let predecessorIds = []; 333 | if (predecessorsIndex !== -1 && row[predecessorsIndex]) { 334 | predecessorIds = row[predecessorsIndex].toString().split(',').map((id: string) => id.trim()); 335 | } 336 | 337 | // 添加到导入任务数组 338 | importedTasks.push({ 339 | id: row[taskIdIndex].toString(), 340 | description: descriptionIndex !== -1 ? (row[descriptionIndex]?.toString() || '') : '', 341 | duration: parseFloat(row[durationIndex]), 342 | predecessorIds 343 | }); 344 | } 345 | 346 | if (importedTasks.length === 0) { 347 | setImportError('No valid tasks found in the file'); 348 | return; 349 | } 350 | 351 | // 清除现有数据 352 | clearProject(); 353 | 354 | // 添加导入的任务 355 | importedTasks.forEach(task => { 356 | // 直接使用TaskForm中的字段格式添加 357 | addTask({ 358 | id: task.id, 359 | description: task.description, 360 | duration: task.duration, 361 | predecessorIds: task.predecessorIds 362 | }); 363 | }); 364 | 365 | // 成功导入 366 | setManageDialogOpen(false); 367 | showAlert(`Successfully imported ${importedTasks.length} tasks${invalidRowFound ? ' (some invalid rows were skipped)' : ''}`, 'success'); 368 | 369 | } catch (err) { 370 | console.error('Error importing file:', err); 371 | setImportError('Failed to import file. Please check the file format.'); 372 | } 373 | }; 374 | 375 | reader.onerror = () => { 376 | setImportError('Error reading the file'); 377 | }; 378 | 379 | reader.readAsArrayBuffer(file); 380 | }; 381 | 382 | // 添加触发文件选择的函数 383 | const handleImportButtonClick = () => { 384 | if (fileInputRef.current) { 385 | fileInputRef.current.click(); 386 | } 387 | }; 388 | 389 | return ( 390 | <> 391 | 392 | 393 | setProjectName(e.target.value)} 397 | fullWidth 398 | margin="normal" 399 | variant="outlined" 400 | /> 401 | 402 | 403 | 412 | 413 | 422 | 423 | 431 | 432 | 440 | 441 | 450 | 451 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | {isCalculated && ( 466 | <> 467 | 468 | 469 | 470 | )} 471 | 472 | 473 | {/* 帮助信息对话框 */} 474 | setInfoOpen(false)}> 475 | About Project Management Tool 476 | 477 | 478 | This tool allows you to create a project network diagram and analyze critical paths using the Critical Path Method (CPM). 479 | 480 | 481 | 482 | Instructions: 483 | 484 | 485 | 486 | 1. Add tasks with their ID, description (optional), duration, and predecessors. 487 | 488 | 489 | 490 | 2. Edit tasks or insert new tasks between existing ones using the edit button. 491 | 492 | 493 | 494 | 3. Click "Generate Network Diagram" to calculate early/late dates and identify the critical path. 495 | 496 | 497 | 498 | 4. View the network diagram and path analysis results. 499 | 500 | 501 | 502 | 5. Save your project to load it later. 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | {/* 提示信息 */} 511 | setAlertOpen(false)} 515 | > 516 | setAlertOpen(false)}> 517 | {alertMessage} 518 | 519 | 520 | 521 | {/* 保存项目对话框 */} 522 | setSaveDialogOpen(false)}> 523 | Save Project 524 | 525 | 526 | Enter a name for your project: 527 | 528 | setProjectNameInput(e.target.value)} 537 | /> 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | {/* 加载项目对话框 */} 546 | setLoadDialogOpen(false)} 549 | maxWidth="md" 550 | fullWidth 551 | > 552 | Load Project 553 | 554 | 555 | {savedProjects.map((project) => ( 556 | handleLoadSpecificProject(project.id)} 560 | divider 561 | > 562 | 566 | 567 | ))} 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | {/* 管理项目对话框 */} 576 | setManageDialogOpen(false)} 579 | maxWidth="md" 580 | fullWidth 581 | > 582 | Manage Projects 583 | 584 | {savedProjects.length === 0 ? ( 585 | 586 | No saved projects found 587 | 588 | ) : ( 589 | 590 | {savedProjects.map((project) => ( 591 | 592 | {editingProject && editingProject.id === project.id ? ( 593 | 594 | 595 | setEditingProject({...editingProject, name: e.target.value})} 599 | size="small" 600 | autoFocus 601 | /> 602 | 603 | 604 | 607 | 610 | 611 | 612 | ) : ( 613 | <> 614 | 618 | 619 | handleExportToExcel(project)} 622 | title="Export to Excel" 623 | size="small" 624 | sx={{ color: 'primary.main' }} 625 | > 626 | 627 | 628 | handleEditProjectName(project)} 631 | title="Edit Project Name" 632 | size="small" 633 | sx={{ color: 'primary.main' }} 634 | > 635 | 636 | 637 | handleDeleteProject(project.id)} 640 | title="Delete Project" 641 | size="small" 642 | sx={{ color: 'error.main' }} 643 | > 644 | 645 | 646 | 647 | 648 | )} 649 | 650 | ))} 651 | 652 | )} 653 | 654 | {importError && ( 655 | 656 | {importError} 657 | 658 | )} 659 | 660 | 661 | 669 | 675 | 676 | 677 | 678 | {/* 隐藏的文件输入 */} 679 | 686 | 687 | ); 688 | }; 689 | 690 | export default Workspace; -------------------------------------------------------------------------------- /src/services/ProjectCrashingService.ts: -------------------------------------------------------------------------------- 1 | import { CrashTask, CrashPath, CostAnalysisResult } from '../hooks/ProjectCrashingContext'; 2 | 3 | export class ProjectCrashingService { 4 | /** 5 | * 计算所有可能的路径 6 | */ 7 | static calculateAllPaths(tasks: CrashTask[]): CrashPath[] { 8 | const taskMap = new Map(); 9 | tasks.forEach(task => taskMap.set(task.id, task)); 10 | 11 | const startTasks = tasks.filter(task => task.predecessors.length === 0); 12 | if (startTasks.length === 0) return []; 13 | 14 | const allPaths: CrashPath[] = []; 15 | startTasks.forEach(startTask => { 16 | this.findPaths( 17 | startTask.id, 18 | [], 19 | new Set(), 20 | taskMap, 21 | tasks, 22 | allPaths 23 | ); 24 | }); 25 | return this.calculatePathDurations(allPaths, taskMap); 26 | } 27 | 28 | /** 29 | * 深度优先搜索找出所有路径 30 | */ 31 | private static findPaths( 32 | currentTaskId: string, 33 | currentPathNodes: string[], 34 | visitedOnThisPath: Set, 35 | taskMap: Map, 36 | allTasks: CrashTask[], 37 | allPaths: CrashPath[] 38 | ): void { 39 | if (visitedOnThisPath.has(currentTaskId)) { 40 | console.warn(`Cycle detected at task ${currentTaskId} during path finding, path ignored.`); 41 | return; 42 | } 43 | const newPathNodes = [...currentPathNodes, currentTaskId]; 44 | visitedOnThisPath.add(currentTaskId); 45 | 46 | const successorTasks = allTasks.filter(task => 47 | task.predecessors.some(pred => pred.taskId === currentTaskId) 48 | ); 49 | 50 | if (successorTasks.length === 0) { 51 | allPaths.push({ 52 | tasks: newPathNodes, 53 | durations: [0], 54 | isCritical: false 55 | }); 56 | } else { 57 | successorTasks.forEach(successor => { 58 | this.findPaths( 59 | successor.id, 60 | newPathNodes, 61 | new Set(visitedOnThisPath), 62 | taskMap, 63 | allTasks, 64 | allPaths 65 | ); 66 | }); 67 | } 68 | } 69 | 70 | /** 71 | * 计算每条路径的持续时间并确定关键路径 72 | */ 73 | private static calculatePathDurations( 74 | paths: CrashPath[], 75 | taskMap: Map 76 | ): CrashPath[] { 77 | if (paths.length === 0) return []; 78 | const EPSILON = 0.000001; 79 | paths.forEach(path => { 80 | const duration = path.tasks.reduce((total, taskId) => { 81 | const task = taskMap.get(taskId); 82 | return total + (task ? task.duration : 0); 83 | }, 0); 84 | path.durations = [duration]; 85 | }); 86 | 87 | const maxDuration = Math.max(0, ...paths.map(path => path.durations[0])); 88 | paths.forEach(path => { 89 | path.isCritical = Math.abs(path.durations[0] - maxDuration) < EPSILON; 90 | }); 91 | return paths; 92 | } 93 | 94 | /** 95 | * 执行项目压缩算法,返回所有迭代的结果 96 | */ 97 | static crashProject( 98 | tasks: CrashTask[], 99 | indirectCostInput: number, 100 | reductionPerUnitInput: number 101 | ): { 102 | crashedTasks: CrashTask[][], 103 | paths: CrashPath[], 104 | criticalPaths: CrashPath[], 105 | costAnalysis: CostAnalysisResult[] 106 | } { 107 | const EPSILON = 0.000001; 108 | const initialTasksState: CrashTask[] = JSON.parse(JSON.stringify(tasks)).map((task: CrashTask) => ({ 109 | ...task, 110 | duration: task.normalTime 111 | })); 112 | 113 | let historicalPaths = this.calculateAllPaths(initialTasksState); 114 | const initialTaskMapForDurations = new Map(initialTasksState.map((t: CrashTask) => [t.id, t])); 115 | historicalPaths.forEach(p => { 116 | const initialDuration = p.tasks.reduce((sum, tid) => sum + (initialTaskMapForDurations.get(tid)?.duration || 0), 0); 117 | p.durations = [initialDuration]; 118 | }); 119 | 120 | let currentCriticalPaths = historicalPaths.filter(path => path.isCritical); 121 | const initialMaxDuration = currentCriticalPaths.length > 0 ? currentCriticalPaths[0].durations[0] : 122 | (historicalPaths.length > 0 ? Math.max(0,...historicalPaths.map(p => p.durations[0])) :0) ; 123 | const initialDirectCost = initialTasksState.reduce((sum: number, task: CrashTask) => sum + task.normalCost, 0); 124 | 125 | const costAnalysis: CostAnalysisResult[] = [{ 126 | projectDuration: initialMaxDuration, 127 | crashedActivities: [], 128 | crashCost: 0, 129 | directCost: initialDirectCost, 130 | indirectCost: indirectCostInput, 131 | totalCost: initialDirectCost + indirectCostInput, 132 | isOptimum: false, 133 | isCrashPoint: false 134 | }]; 135 | 136 | const crashedTasksHistory: CrashTask[][] = [JSON.parse(JSON.stringify(initialTasksState))]; 137 | let currentTasks: CrashTask[] = JSON.parse(JSON.stringify(initialTasksState)); 138 | let iteration = 0; 139 | let canContinueCrashing = true; 140 | let previousCommittedMaxDuration = initialMaxDuration; 141 | console.log(`[CrashProject] Initial Max Duration: ${initialMaxDuration}`); 142 | 143 | while (canContinueCrashing) { 144 | console.log(`[CrashProject] Starting iteration attempt for logical iteration ${iteration + 1}. Previous committed max duration: ${previousCommittedMaxDuration}`); 145 | currentCriticalPaths = historicalPaths.filter(p => 146 | p.durations.length > iteration && 147 | Math.abs(p.durations[iteration] - previousCommittedMaxDuration) < EPSILON 148 | ); 149 | console.log(`[CrashProject] Iteration ${iteration + 1}: Found ${currentCriticalPaths.length} critical paths based on duration ${previousCommittedMaxDuration}.`); 150 | 151 | let crashableTasksOnCurrentCriticalPaths: CrashTask[] = []; 152 | currentCriticalPaths.forEach(path => { 153 | path.tasks.forEach(taskId => { 154 | const task = currentTasks.find(t => t.id === taskId); 155 | if (task && (task.maxCrashTime || 0) > 0 && task.duration > task.crashTime) { 156 | if (!crashableTasksOnCurrentCriticalPaths.some(ct => ct.id === task.id)) { 157 | crashableTasksOnCurrentCriticalPaths.push(JSON.parse(JSON.stringify(task))); 158 | } 159 | } 160 | }); 161 | }); 162 | 163 | if (crashableTasksOnCurrentCriticalPaths.length === 0) { 164 | console.log(`[CrashProject] Iteration ${iteration + 1}: No crashable tasks on critical paths. Stopping.`); 165 | if (costAnalysis.length > 0) costAnalysis[costAnalysis.length - 1].isCrashPoint = true; 166 | canContinueCrashing = false; break; 167 | } 168 | 169 | let tasksToCompressFromSelection: CrashTask[] = []; 170 | let currentIterationSelectedTaskIds: string[] = []; 171 | 172 | // CRITICAL: REPLACE THIS WITH YOUR FULL TASK SELECTION LOGIC for multiple critical paths 173 | // This placeholder is INSUFFICIENT for complex scenarios. 174 | // Your logic should populate `tasksToCompressFromSelection` with all tasks that need 175 | // to be crashed *simultaneously* in this iteration according to your strategy. 176 | console.log(`[CrashProject] Iteration ${iteration + 1}: Selecting tasks to crash... (Using standard crashing algorithm)`); 177 | 178 | // 获取每个任务所在的关键路径 179 | const taskToPathsMap = new Map(); 180 | crashableTasksOnCurrentCriticalPaths.forEach(task => { 181 | const pathIndices: number[] = []; 182 | currentCriticalPaths.forEach((path, index) => { 183 | if (path.tasks.includes(task.id)) { 184 | pathIndices.push(index); 185 | } 186 | }); 187 | taskToPathsMap.set(task.id, pathIndices); 188 | console.log(`[CrashProject] Task ${task.id} is on paths: ${pathIndices.join(', ')}, slope: ${task.slope}`); 189 | }); 190 | 191 | // 输出当前关键路径信息 192 | currentCriticalPaths.forEach((path, idx) => { 193 | const crashableTasks = path.tasks.filter(tid => { 194 | const task = crashableTasksOnCurrentCriticalPaths.find(t => t.id === tid); 195 | return !!task; 196 | }); 197 | console.log(`[CrashProject] Critical Path ${idx}: ${path.tasks.join('->')} 可压缩任务: ${crashableTasks.join(',')}`); 198 | }); 199 | 200 | // 1. 当只有一条关键路径时 201 | if (currentCriticalPaths.length === 1) { 202 | console.log(`[CrashProject] 只有一条关键路径,选择斜率最小的任务`); 203 | // 按斜率排序任务 204 | crashableTasksOnCurrentCriticalPaths.sort((a, b) => (a.slope || Infinity) - (b.slope || Infinity)); 205 | // 选择斜率最小的任务 206 | if (crashableTasksOnCurrentCriticalPaths.length > 0) { 207 | const bestTask = crashableTasksOnCurrentCriticalPaths[0]; 208 | tasksToCompressFromSelection = [bestTask]; 209 | console.log(`[CrashProject] 选择单任务 ${bestTask.id},斜率=${bestTask.slope}`); 210 | } 211 | } 212 | // 2. 当有多条关键路径时 213 | else { 214 | console.log(`[CrashProject] 有 ${currentCriticalPaths.length} 条关键路径,寻找共同任务或最佳组合`); 215 | 216 | // 创建一个候选列表,包含所有可能的单任务和任务组合 217 | let allCandidates: { 218 | tasks: CrashTask[], 219 | taskCount: number, 220 | totalSlope: number, 221 | reduction?: number 222 | }[] = []; 223 | 224 | // 2.1 查找共同任务并添加到候选列表 225 | const criticalTaskSets = currentCriticalPaths.map(path => new Set(path.tasks)); 226 | const commonTasks = crashableTasksOnCurrentCriticalPaths.filter(task => 227 | criticalTaskSets.every(taskSet => taskSet.has(task.id)) 228 | ); 229 | 230 | if (commonTasks.length > 0) { 231 | console.log(`[CrashProject] 找到 ${commonTasks.length} 个共同任务,添加到候选列表`); 232 | // 将共同任务添加到候选列表(作为单任务"组合") 233 | commonTasks.forEach(task => { 234 | allCandidates.push({ 235 | tasks: [task], 236 | taskCount: 1, 237 | totalSlope: task.slope || Infinity 238 | }); 239 | }); 240 | } 241 | 242 | // 2.2 生成所有可能的任务组合并添加到候选列表 243 | console.log(`[CrashProject] 生成所有可能的任务组合添加到候选列表`); 244 | 245 | // 生成所有可能的任务组合 246 | const generateAllPossibleCombinations = () => { 247 | const allTasks = crashableTasksOnCurrentCriticalPaths; 248 | let allCombinations: CrashTask[][] = []; 249 | 250 | // 添加所有单个任务(不在共同任务中的) 251 | allTasks.forEach(task => { 252 | if (!commonTasks.some(ct => ct.id === task.id)) { 253 | allCombinations.push([task]); 254 | } 255 | }); 256 | 257 | // 添加所有2个任务的组合 258 | for (let i = 0; i < allTasks.length; i++) { 259 | for (let j = i + 1; j < allTasks.length; j++) { 260 | allCombinations.push([allTasks[i], allTasks[j]]); 261 | } 262 | } 263 | 264 | // 添加所有3个任务的组合(如果需要) 265 | if (currentCriticalPaths.length >= 3) { 266 | for (let i = 0; i < allTasks.length; i++) { 267 | for (let j = i + 1; j < allTasks.length; j++) { 268 | for (let k = j + 1; k < allTasks.length; k++) { 269 | allCombinations.push([allTasks[i], allTasks[j], allTasks[k]]); 270 | } 271 | } 272 | } 273 | } 274 | 275 | return allCombinations; 276 | }; 277 | 278 | // 将所有组合添加到候选列表 279 | const allCombinations = generateAllPossibleCombinations(); 280 | allCombinations.forEach(combo => { 281 | // 计算组合的总斜率 282 | const totalSlope = combo.reduce((sum, task) => sum + (task.slope || Infinity), 0); 283 | allCandidates.push({ 284 | tasks: combo, 285 | taskCount: combo.length, 286 | totalSlope 287 | }); 288 | }); 289 | 290 | console.log(`[CrashProject] 总共生成了 ${allCandidates.length} 个候选方案(包括共同任务和组合)`); 291 | 292 | // 2.3 评估所有候选方案 293 | // 测试每个候选方案的效果(是否能减少项目持续时间,减少多少) 294 | const evaluatedCandidates = allCandidates.map(candidate => { 295 | // 创建任务副本进行模拟 296 | let simTasks = JSON.parse(JSON.stringify(currentTasks)); 297 | 298 | // 应用压缩 299 | candidate.tasks.forEach(task => { 300 | const simTask = simTasks.find((t: CrashTask) => t.id === task.id); 301 | if (simTask && simTask.duration > simTask.crashTime) { 302 | simTask.duration -= 1; 303 | } 304 | }); 305 | 306 | // 计算新的路径持续时间 307 | const simPaths = this.calculateAllPaths(simTasks); 308 | const newPathDurations = simPaths.map(p => p.durations[0]); 309 | const newMaxDuration = newPathDurations.length > 0 ? Math.max(0, ...newPathDurations) : 0; 310 | 311 | // 计算减少的持续时间 312 | const reduction = previousCommittedMaxDuration - newMaxDuration; 313 | 314 | // 验证是否覆盖所有关键路径 315 | const coveredPathIndices = new Set(); 316 | simPaths.forEach((path) => { 317 | if (path.durations[0] < previousCommittedMaxDuration) { 318 | currentCriticalPaths.forEach((cPath, cIdx) => { 319 | // 如果路径任务序列相同,认为是同一条路径 320 | if (pathsAreEqual(path.tasks, cPath.tasks)) { 321 | coveredPathIndices.add(cIdx); 322 | } 323 | }); 324 | } 325 | }); 326 | 327 | // 辅助函数:比较两个路径是否相同 328 | function pathsAreEqual(path1: string[], path2: string[]): boolean { 329 | if (path1.length !== path2.length) return false; 330 | for (let i = 0; i < path1.length; i++) { 331 | if (path1[i] !== path2[i]) return false; 332 | } 333 | return true; 334 | } 335 | 336 | return { 337 | ...candidate, 338 | reduction, 339 | coveredPathsCount: coveredPathIndices.size, 340 | allPathsCovered: coveredPathIndices.size === currentCriticalPaths.length 341 | }; 342 | }); 343 | 344 | // 2.4 筛选有效的候选方案(能减少项目持续时间且覆盖所有关键路径的) 345 | const effectiveCandidates = evaluatedCandidates.filter(c => 346 | c.reduction > 0 && c.allPathsCovered 347 | ); 348 | 349 | console.log(`[CrashProject] 找到 ${effectiveCandidates.length} 个有效的候选方案`); 350 | 351 | // 2.5 选择最优的候选方案 352 | if (effectiveCandidates.length > 0) { 353 | // 先按总斜率排序(升序)- 修改这里的排序依据,按斜率排序 354 | effectiveCandidates.sort((a, b) => a.totalSlope - b.totalSlope); 355 | 356 | // 找到总斜率最小的所有候选方案 357 | const minSlope = effectiveCandidates[0].totalSlope; 358 | const minSlopeCandidates = effectiveCandidates.filter(c => c.totalSlope === minSlope); 359 | 360 | console.log(`[CrashProject] 最小总斜率: ${minSlope}, 有 ${minSlopeCandidates.length} 个候选方案能达到此效果`); 361 | 362 | // 从总斜率最小的候选方案中,选择能减少最多时间的 363 | minSlopeCandidates.sort((a, b) => b.reduction - a.reduction); 364 | const maxReduction = minSlopeCandidates[0].reduction; 365 | const maxReductionCandidates = minSlopeCandidates.filter(c => c.reduction === maxReduction); 366 | 367 | console.log(`[CrashProject] 最大减少时间: ${maxReduction}, 有 ${maxReductionCandidates.length} 个候选方案满足条件`); 368 | 369 | // 从能减少最多时间的候选方案中,选择任务数量最少的 370 | maxReductionCandidates.sort((a, b) => a.taskCount - b.taskCount); 371 | const bestCandidate = maxReductionCandidates[0]; 372 | 373 | // 选定最优方案 374 | tasksToCompressFromSelection = bestCandidate.tasks; 375 | 376 | // 输出最优方案信息 377 | console.log(`[CrashProject] 最优方案: ${bestCandidate.tasks.map(t => t.id).join('+')} | 减少=${bestCandidate.reduction}, 任务数=${bestCandidate.taskCount}, 斜率=${bestCandidate.totalSlope.toFixed(2)}`); 378 | 379 | // 打印所有有效候选方案详情(用于调试) 380 | console.log(`[CrashProject] 所有有效候选方案详情(按总斜率升序排列):`); 381 | effectiveCandidates 382 | .sort((a, b) => a.totalSlope - b.totalSlope) 383 | .forEach(c => { 384 | console.log(` - ${c.tasks.map(t => t.id).join('+')} | 减少=${c.reduction}, 任务数=${c.taskCount}, 斜率=${c.totalSlope.toFixed(2)}, 是共同任务=${commonTasks.some(ct => c.tasks.length === 1 && ct.id === c.tasks[0].id) ? '是' : '否'}`); 385 | }); 386 | } else { 387 | // 如果没有找到有效的候选方案,尝试备选策略 388 | // 如果没有找到有效组合,回退到之前的策略 389 | if (taskToPathsMap.size > 0) { 390 | // 从Map的键中获取任务ID,然后找到对应的任务对象 391 | tasksToCompressFromSelection = Array.from(taskToPathsMap.keys()) 392 | .map(taskId => crashableTasksOnCurrentCriticalPaths.find(t => t.id === taskId)) 393 | .filter((task): task is CrashTask => task !== undefined); 394 | 395 | const totalSlope = tasksToCompressFromSelection.reduce((sum, task) => sum + (task.slope || Infinity), 0); 396 | console.log(`[CrashProject] 使用每条路径上斜率最小的任务组合: ${tasksToCompressFromSelection.map(t => t.id).join('+')}, 总斜率=${totalSlope}`); 397 | } 398 | } 399 | } 400 | 401 | // 为了调试,添加模拟测试,确认选择的任务是否能减少项目持续时间 402 | if (tasksToCompressFromSelection.length > 0) { 403 | // 创建任务副本进行模拟 404 | let simTasks = JSON.parse(JSON.stringify(currentTasks)); 405 | 406 | // 应用压缩 407 | tasksToCompressFromSelection.forEach(task => { 408 | const simTask = simTasks.find((t: CrashTask) => t.id === task.id); 409 | if (simTask && simTask.duration > simTask.crashTime) { 410 | simTask.duration -= 1; 411 | } 412 | }); 413 | 414 | // 计算新的路径持续时间 415 | const simPaths = this.calculateAllPaths(simTasks); 416 | const newMaxDuration = simPaths.length > 0 ? 417 | Math.max(0, ...simPaths.map(p => p.durations[0])) : 0; 418 | 419 | const expectedReduction = previousCommittedMaxDuration - newMaxDuration; 420 | const totalSlope = tasksToCompressFromSelection.reduce((sum, task) => sum + (task.slope || Infinity), 0); 421 | 422 | console.log(`[CrashProject] 选定的任务组合 ${tasksToCompressFromSelection.map(t => t.id).join('+')} 预计减少时间: ${expectedReduction}, 总斜率: ${totalSlope}`); 423 | 424 | // 最终确认选择的任务 425 | currentIterationSelectedTaskIds = tasksToCompressFromSelection.map(t => t.id); 426 | console.log(`[CrashProject] Iteration ${iteration + 1}: 最终选择任务: ${currentIterationSelectedTaskIds.join(', ')}`); 427 | } else { 428 | console.log(`[CrashProject] Iteration ${iteration + 1}: 没有选到任何任务,停止压缩。`); 429 | } 430 | 431 | if (tasksToCompressFromSelection.length === 0) { 432 | if (costAnalysis.length > 0) costAnalysis[costAnalysis.length - 1].isCrashPoint = true; 433 | canContinueCrashing = false; break; 434 | } 435 | 436 | let hypotheticalNextTasksState: CrashTask[] = JSON.parse(JSON.stringify(currentTasks)); 437 | const crashTimeReduction = 1; 438 | let actualTasksModifiedCount = 0; 439 | let actualCrashCostForThisStep = 0; 440 | 441 | tasksToCompressFromSelection.forEach(taskSpec => { 442 | const taskInHypo = hypotheticalNextTasksState.find(t => t.id === taskSpec.id); 443 | if (taskInHypo && taskInHypo.duration > taskInHypo.crashTime && (taskInHypo.maxCrashTime || 0) >= crashTimeReduction) { 444 | taskInHypo.duration -= crashTimeReduction; 445 | taskInHypo.maxCrashTime = (taskInHypo.maxCrashTime || 0) - crashTimeReduction; 446 | actualTasksModifiedCount++; 447 | actualCrashCostForThisStep += taskSpec.slope || 0; 448 | } 449 | }); 450 | 451 | if (actualTasksModifiedCount === 0) { 452 | console.log(`[CrashProject] Iteration ${iteration + 1}: Selected task(s) could not actually be crashed further. Stopping.`); 453 | if (costAnalysis.length > 0) costAnalysis[costAnalysis.length - 1].isCrashPoint = true; 454 | canContinueCrashing = false; break; 455 | } 456 | 457 | const tempHypotheticalPaths = this.calculateAllPaths(hypotheticalNextTasksState); 458 | const hypotheticalNewMaxDuration = tempHypotheticalPaths.length > 0 ? Math.max(0, ...tempHypotheticalPaths.map(p => p.durations[0])) : 0; 459 | console.log(`[CrashProject] Iteration ${iteration + 1}: Hypothetical new max duration: ${hypotheticalNewMaxDuration}. Previous committed: ${previousCommittedMaxDuration}`); 460 | // Log all hypothetical path durations 461 | // tempHypotheticalPaths.forEach(p => console.log(` Hypo Path ${p.tasks.join('->')}: ${p.durations[0]}`)); 462 | 463 | if (hypotheticalNewMaxDuration < previousCommittedMaxDuration - EPSILON) { 464 | console.log(`[CrashProject] Iteration ${iteration + 1}: Duration REDUCED. Committing iteration.`); 465 | iteration++; // This is a valid new iteration number (e.g., 1st crash is iteration 1) 466 | currentTasks = hypotheticalNextTasksState; 467 | 468 | const currentTaskMap = new Map(currentTasks.map((t: CrashTask) => [t.id, t])); 469 | historicalPaths.forEach(pDetail => { 470 | const newDurationForPath = pDetail.tasks.reduce((sum, taskId) => sum + (currentTaskMap.get(taskId)?.duration || 0), 0); 471 | while (pDetail.durations.length <= iteration) { 472 | // Pad with previous duration if iteration was skipped or for new paths 473 | pDetail.durations.push(pDetail.durations.length > 0 ? pDetail.durations[pDetail.durations.length -1] : 0); 474 | } 475 | pDetail.durations[iteration] = newDurationForPath; 476 | pDetail.isCritical = Math.abs(newDurationForPath - hypotheticalNewMaxDuration) < EPSILON; 477 | }); 478 | 479 | // Update currentCriticalPaths for the next loop's selection phase based on newly committed state 480 | // This was missing, leading to potentially stale critical path analysis for task selection. 481 | currentCriticalPaths = historicalPaths.filter(p => p.isCritical && p.durations.length > iteration && Math.abs(p.durations[iteration] - hypotheticalNewMaxDuration) < EPSILON); 482 | 483 | const prevCostItem = costAnalysis[costAnalysis.length - 1]; 484 | const newDirectCost = prevCostItem.directCost + actualCrashCostForThisStep; 485 | const daysReducedTotal = initialMaxDuration - hypotheticalNewMaxDuration; 486 | const newIndirectCostValue = indirectCostInput - (daysReducedTotal * reductionPerUnitInput); 487 | 488 | costAnalysis.push({ 489 | projectDuration: hypotheticalNewMaxDuration, 490 | crashedActivities: [...currentIterationSelectedTaskIds], 491 | crashCost: actualCrashCostForThisStep, 492 | directCost: newDirectCost, 493 | indirectCost: newIndirectCostValue, 494 | totalCost: newDirectCost + newIndirectCostValue, 495 | isOptimum: false, 496 | isCrashPoint: false 497 | }); 498 | crashedTasksHistory.push(JSON.parse(JSON.stringify(currentTasks))); 499 | previousCommittedMaxDuration = hypotheticalNewMaxDuration; 500 | } else { 501 | console.log(`[CrashProject] Iteration ${iteration + 1}: Duration NOT reduced or increased (${hypotheticalNewMaxDuration} vs ${previousCommittedMaxDuration}). Stopping.`); 502 | if (costAnalysis.length > 0) costAnalysis[costAnalysis.length - 1].isCrashPoint = true; 503 | canContinueCrashing = false; 504 | } 505 | } 506 | console.log(`[CrashProject] Loop finished. Total committed iterations: ${iteration}`); 507 | 508 | if (costAnalysis.length > 0) { 509 | const minCostIndex = costAnalysis.reduce((minIndex, item, index, arr) => 510 | item.totalCost < arr[minIndex].totalCost ? index : minIndex, 0); 511 | if (costAnalysis[minCostIndex]) costAnalysis[minCostIndex].isOptimum = true; 512 | } 513 | 514 | // Ensure the returned criticalPaths are from the very last COMMITTED state. 515 | // `iteration` here is the count of committed crashes (e.g., if 1 crash, iteration is 1). 516 | // So, durations[iteration] is the correct index for the last committed state. 517 | const finalCommittedDuration = previousCommittedMaxDuration; // This holds the duration of the last validly committed iteration 518 | const finalCriticalPaths = historicalPaths.filter(p => 519 | p.durations.length > iteration && 520 | Math.abs(p.durations[iteration] - finalCommittedDuration) < EPSILON 521 | ); 522 | 523 | return { 524 | crashedTasks: crashedTasksHistory, 525 | paths: historicalPaths, 526 | criticalPaths: finalCriticalPaths, 527 | costAnalysis 528 | }; 529 | } 530 | 531 | /** 532 | * 计算任务的松弛时间 533 | */ 534 | static calculateSlackTimes(tasks: CrashTask[], _criticalPathsForSlack?: CrashPath[]): CrashTask[] { 535 | const taskMap = new Map(); 536 | tasks.forEach(task => taskMap.set(task.id, { ...task })); 537 | const EPSILON = 0.000001; 538 | 539 | const earlyTimes: { [key: string]: { earlyStart: number, earlyFinish: number } } = {}; 540 | const startNodes = tasks.filter(task => task.predecessors.length === 0); 541 | startNodes.forEach(task => { 542 | earlyTimes[task.id] = { earlyStart: 0, earlyFinish: task.duration }; 543 | }); 544 | 545 | const sortedTasks = this.topologicalSort(tasks); 546 | for (const task of sortedTasks) { 547 | if (earlyTimes[task.id]) continue; 548 | let maxPredEF = 0; 549 | task.predecessors.forEach(pred => { 550 | if (earlyTimes[pred.taskId]) { 551 | maxPredEF = Math.max(maxPredEF, earlyTimes[pred.taskId].earlyFinish); 552 | } 553 | }); 554 | earlyTimes[task.id] = { earlyStart: maxPredEF, earlyFinish: maxPredEF + task.duration }; 555 | } 556 | 557 | const projectDurationFromESEF = Math.max(0, ...Object.values(earlyTimes).map(time => time.earlyFinish)); 558 | const lateTimes: { [key: string]: { lateStart: number, lateFinish: number } } = {}; 559 | const endNodes = tasks.filter(task => !tasks.some(t => t.predecessors.some(p => p.taskId === task.id))); 560 | 561 | endNodes.forEach(task => { 562 | lateTimes[task.id] = { lateFinish: projectDurationFromESEF, lateStart: projectDurationFromESEF - task.duration }; 563 | }); 564 | 565 | for (const task of [...sortedTasks].reverse()) { 566 | if (lateTimes[task.id]) continue; 567 | const successors = tasks.filter(t => t.predecessors.some(p => p.taskId === task.id)); 568 | let minSuccLS = projectDurationFromESEF; 569 | if (successors.length > 0) { 570 | minSuccLS = Infinity; 571 | successors.forEach(succ => { 572 | if (lateTimes[succ.id]) { 573 | minSuccLS = Math.min(minSuccLS, lateTimes[succ.id].lateStart); 574 | } 575 | }); 576 | } 577 | lateTimes[task.id] = { lateFinish: minSuccLS, lateStart: minSuccLS - task.duration }; 578 | } 579 | 580 | return tasks.map(originalTask => { 581 | const taskFromMap = taskMap.get(originalTask.id)!; 582 | const early = earlyTimes[taskFromMap.id] || { earlyStart: 0, earlyFinish: taskFromMap.duration }; 583 | const late = lateTimes[taskFromMap.id] || { lateStart: projectDurationFromESEF - taskFromMap.duration, lateFinish: projectDurationFromESEF }; 584 | const slack = late.lateStart - early.earlyStart; 585 | return { 586 | ...originalTask, 587 | earlyStart: early.earlyStart, 588 | earlyFinish: early.earlyFinish, 589 | lateStart: late.lateStart, 590 | lateFinish: late.lateFinish, 591 | slack: Math.abs(slack) < EPSILON ? 0 : slack, 592 | isCritical: Math.abs(slack) < EPSILON 593 | }; 594 | }); 595 | } 596 | 597 | /** 598 | * 使用拓扑排序获取任务列表 599 | */ 600 | private static topologicalSort(tasks: CrashTask[]): CrashTask[] { 601 | const adj = new Map(); 602 | const inDegree = new Map(); 603 | const taskMap = new Map(); 604 | 605 | tasks.forEach(task => { 606 | taskMap.set(task.id, task); 607 | adj.set(task.id, []); 608 | inDegree.set(task.id, 0); 609 | }); 610 | 611 | tasks.forEach(task => { 612 | task.predecessors.forEach(pred => { 613 | if (adj.has(pred.taskId)) { 614 | adj.get(pred.taskId)!.push(task.id); 615 | inDegree.set(task.id, (inDegree.get(task.id) || 0) + 1); 616 | } else { 617 | console.warn(`Task ${task.id} lists predecessor ${pred.taskId} which is not found in the tasks list during sort graph construction.`); 618 | } 619 | }); 620 | }); 621 | 622 | const queue: string[] = []; 623 | tasks.forEach(task => { 624 | if (inDegree.get(task.id) === 0) { 625 | queue.push(task.id); 626 | } 627 | }); 628 | 629 | const sortedResult: CrashTask[] = []; 630 | while (queue.length > 0) { 631 | const u = queue.shift()!; 632 | sortedResult.push(taskMap.get(u)!); 633 | (adj.get(u) || []).forEach(v => { 634 | inDegree.set(v, (inDegree.get(v) || 1) - 1); 635 | if (inDegree.get(v) === 0) { 636 | queue.push(v); 637 | } 638 | }); 639 | } 640 | if (sortedResult.length !== tasks.length) { 641 | console.warn("Topological sort result length mismatch. Possible cycle or orphaned tasks."); 642 | } 643 | return sortedResult; 644 | } 645 | } --------------------------------------------------------------------------------