├── 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 |
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 |
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 | }
455 | onClick={handleResetPositions}
456 | >
457 | Reset
458 |
459 | : }
463 | onClick={toggleFullScreen}
464 | >
465 | {isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}
466 |
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 | }
407 | onClick={handleCalculate}
408 | disabled={tasks.length === 0}
409 | >
410 | Generate Network Diagram
411 |
412 |
413 | }
417 | onClick={handleSaveProject}
418 | disabled={tasks.length === 0}
419 | >
420 | Save Project
421 |
422 |
423 | }
427 | onClick={handleLoadProject}
428 | >
429 | Load Project
430 |
431 |
432 | }
436 | onClick={handleManageProjects}
437 | >
438 | Manage Projects
439 |
440 |
441 | }
445 | onClick={handleClearProject}
446 | disabled={tasks.length === 0}
447 | >
448 | Clear Project
449 |
450 |
451 | }
455 | onClick={() => setInfoOpen(true)}
456 | >
457 | Info
458 |
459 |
460 |
461 |
462 |
463 |
464 |
465 | {isCalculated && (
466 | <>
467 |
468 |
469 | >
470 | )}
471 |
472 |
473 | {/* 帮助信息对话框 */}
474 |
509 |
510 | {/* 提示信息 */}
511 | setAlertOpen(false)}
515 | >
516 | setAlertOpen(false)}>
517 | {alertMessage}
518 |
519 |
520 |
521 | {/* 保存项目对话框 */}
522 |
544 |
545 | {/* 加载项目对话框 */}
546 |
574 |
575 | {/* 管理项目对话框 */}
576 |
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 | }
--------------------------------------------------------------------------------