GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-12-project-management.md

2639 lines
71 KiB
Markdown
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
# 项目管理模块实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在系统管理菜单下新增项目管理模块,支持甘特图、日历、表格、看板四种视图管理项目进度
**Architecture:** 后端新增 project 模块5 Entity + 3 Service + 5 Controller前端新增 project 模块2 页面 + 7 组件 + 1 Pinia Store。甘特图使用 dhtmlx-gantt日历使用 FullCalendar看板使用 vuedraggable。
**Tech Stack:** Midway.js + TypeORM (后端), Vue 3 + Element Plus + dhtmlx-gantt + FullCalendar + vuedraggable (前端)
**Spec:** `docs/superpowers/specs/2026-04-12-project-management-design.md`
---
## 文件结构
### 后端新建文件
```
packages/backend/src/modules/project/
├── config.ts
├── entity/
│ ├── info.ts -- ProjectInfoEntity (project_info)
│ ├── phase.ts -- ProjectPhaseEntity (project_phase)
│ ├── task.ts -- ProjectTaskEntity (project_task)
│ ├── task_dependency.ts -- ProjectTaskDependencyEntity (project_task_dependency)
│ └── time_log.ts -- ProjectTimeLogEntity (project_time_log)
├── controller/admin/
│ ├── info.ts -- 项目 CRUD
│ ├── phase.ts -- 阶段 CRUD
│ ├── task.ts -- 任务 CRUD + ganttData/ganttUpdate/kanban/kanbanSort/tree
│ ├── task_dependency.ts -- 依赖关系 add/delete/list
│ └── time_log.ts -- 工时记录 CRUD
└── service/
├── info.ts -- 项目进度汇总
├── task.ts -- 任务树构建、进度计算、看板数据
└── gantt.ts -- 甘特图数据聚合、批量更新
```
### 前端新建文件
```
packages/frontend/src/modules/project/
├── config.ts -- 模块路由配置
├── store/
│ └── project.ts -- Pinia store
├── views/
│ ├── list.vue -- 项目列表页
│ ├── detail.vue -- 项目详情页Tab 容器)
│ └── components/
│ ├── gantt.vue -- 甘特图视图
│ ├── calendar.vue -- 日历视图
│ ├── table.vue -- 表格列表视图
│ ├── kanban.vue -- 看板视图
│ ├── task-drawer.vue -- 任务详情抽屉
│ ├── time-log-dialog.vue -- 工时记录弹窗
│ └── phase-manager.vue -- 阶段管理弹窗
```
---
## Task 1: 后端 Entity 定义5 张表)
**Files:**
- Create: `packages/backend/src/modules/project/entity/info.ts`
- Create: `packages/backend/src/modules/project/entity/phase.ts`
- Create: `packages/backend/src/modules/project/entity/task.ts`
- Create: `packages/backend/src/modules/project/entity/task_dependency.ts`
- Create: `packages/backend/src/modules/project/entity/time_log.ts`
- [ ] **Step 1: 创建 project 模块目录结构**
```bash
cd packages/backend
mkdir -p src/modules/project/entity
mkdir -p src/modules/project/controller/admin
mkdir -p src/modules/project/service
```
- [ ] **Step 2: 创建 ProjectInfoEntity**
创建 `packages/backend/src/modules/project/entity/info.ts`:
```typescript
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';
/**
* 项目信息
*/
@Entity('project_info')
export class ProjectInfoEntity extends BaseEntity {
@Column({ comment: '项目名称', length: 100 })
name: string;
@Column({ comment: '项目描述', type: 'text', nullable: true })
description: string;
@Column({ comment: '状态 0未开始 1进行中 2已完成 3已归档', default: 0 })
status: number;
@Column({ comment: '计划开始日期', type: 'date', nullable: true })
startDate: string;
@Column({ comment: '计划结束日期', type: 'date', nullable: true })
endDate: string;
@Column({ comment: '进度百分比 0-100', default: 0 })
progress: number;
@Index()
@Column({ comment: '项目经理ID', nullable: true })
ownerId: number;
@Column({ comment: '项目经理姓名', length: 50, nullable: true })
ownerName: string;
@Column({ comment: '主题色', length: 20, nullable: true })
color: string;
}
```
- [ ] **Step 3: 创建 ProjectPhaseEntity**
创建 `packages/backend/src/modules/project/entity/phase.ts`:
```typescript
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';
/**
* 项目阶段
*/
@Entity('project_phase')
export class ProjectPhaseEntity extends BaseEntity {
@Index()
@Column({ comment: '所属项目ID' })
projectId: number;
@Column({ comment: '阶段名称', length: 100 })
name: string;
@Column({ comment: '分类', length: 50, nullable: true })
type: string;
@Column({ comment: '状态 0未开始 1进行中 2已完成', default: 0 })
status: number;
@Column({ comment: '开始日期', type: 'date', nullable: true })
startDate: string;
@Column({ comment: '结束日期', type: 'date', nullable: true })
endDate: string;
@Column({ comment: '进度 0-100', default: 0 })
progress: number;
@Column({ comment: '排序序号', default: 0 })
sortOrder: number;
}
```
- [ ] **Step 4: 创建 ProjectTaskEntity**
创建 `packages/backend/src/modules/project/entity/task.ts`:
```typescript
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';
/**
* 项目任务
*/
@Entity('project_task')
export class ProjectTaskEntity extends BaseEntity {
@Index()
@Column({ comment: '所属项目ID' })
projectId: number;
@Index()
@Column({ comment: '所属阶段ID', nullable: true })
phaseId: number;
@Index()
@Column({ comment: '父任务ID', nullable: true })
parentId: number;
@Column({ comment: '任务名称', length: 200 })
name: string;
@Column({ comment: '任务描述', type: 'text', nullable: true })
description: string;
@Column({ comment: '状态 0待办 1进行中 2已完成 3已关闭', default: 0 })
status: number;
@Column({ comment: '优先级 0紧急 1高 2中 3低', default: 2 })
priority: number;
@Column({ comment: '分类', length: 50, nullable: true })
category: string;
@Index()
@Column({ comment: '负责人ID', nullable: true })
assigneeId: number;
@Column({ comment: '负责人姓名', length: 50, nullable: true })
assigneeName: string;
@Column({ comment: '计划开始日期', type: 'date', nullable: true })
startDate: string;
@Column({ comment: '计划结束日期', type: 'date', nullable: true })
endDate: string;
@Column({ comment: '预估工时(小时)', type: 'decimal', precision: 8, scale: 1, default: 0 })
estimatedHours: number;
@Column({ comment: '实际工时(小时)', type: 'decimal', precision: 8, scale: 1, default: 0 })
actualHours: number;
@Column({ comment: '进度 0-100', default: 0 })
progress: number;
@Column({ comment: '排序序号', default: 0 })
sortOrder: number;
@Column({ comment: '自定义颜色', length: 20, nullable: true })
color: string;
}
```
- [ ] **Step 5: 创建 ProjectTaskDependencyEntity**
创建 `packages/backend/src/modules/project/entity/task_dependency.ts`:
```typescript
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';
/**
* 任务依赖关系
*/
@Entity('project_task_dependency')
export class ProjectTaskDependencyEntity extends BaseEntity {
@Index()
@Column({ comment: '当前任务ID' })
taskId: number;
@Index()
@Column({ comment: '前置任务ID' })
dependsOnTaskId: number;
@Column({ comment: '依赖类型 0:FS 1:SS 2:FF 3:SF', default: 0 })
type: number;
}
```
- [ ] **Step 6: 创建 ProjectTimeLogEntity**
创建 `packages/backend/src/modules/project/entity/time_log.ts`:
```typescript
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';
/**
* 工时记录
*/
@Entity('project_time_log')
export class ProjectTimeLogEntity extends BaseEntity {
@Index()
@Column({ comment: '所属任务ID' })
taskId: number;
@Index()
@Column({ comment: '记录人ID' })
userId: number;
@Column({ comment: '记录人姓名', length: 50 })
userName: string;
@Column({ comment: '工作日期', type: 'date' })
logDate: string;
@Column({ comment: '工时(小时)', type: 'decimal', precision: 5, scale: 1 })
hours: number;
@Column({ comment: '工作内容描述', length: 500, nullable: true })
description: string;
}
```
- [ ] **Step 7: 创建模块 config.ts**
创建 `packages/backend/src/modules/project/config.ts`:
```typescript
import { ModuleConfig } from '@cool-midway/core';
/**
* 模块配置
*/
export default () => {
return {
// 模块名称
name: '项目管理',
// 模块描述
description: '项目进度管理,支持甘特图、日历、看板等视图',
// 中间件
middlewares: [],
// 全局中间件
globalMiddlewares: [],
// 模块加载顺序
order: 0,
} as ModuleConfig;
};
```
- [ ] **Step 8: 启动后端验证表自动创建**
```bash
cd packages/backend
pnpm dev
```
预期后端启动成功TypeORM synchronize:true 自动在 neta_test 数据库中创建 5 张表project_info, project_phase, project_task, project_task_dependency, project_time_log。检查日志无报错。
- [ ] **Step 9: 提交**
```bash
git add packages/backend/src/modules/project/
git commit -m "feat(backend): 项目管理模块 - Entity 定义5张表"
```
---
## Task 2: 后端 Service 层
**Files:**
- Create: `packages/backend/src/modules/project/service/info.ts`
- Create: `packages/backend/src/modules/project/service/task.ts`
- Create: `packages/backend/src/modules/project/service/gantt.ts`
- [ ] **Step 1: 创建 ProjectInfoService**
创建 `packages/backend/src/modules/project/service/info.ts`:
```typescript
import { Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { ProjectInfoEntity } from '../entity/info';
import { ProjectPhaseEntity } from '../entity/phase';
import { ProjectTaskEntity } from '../entity/task';
/**
* 项目信息服务
*/
@Provide()
export class ProjectInfoService extends BaseService {
@InjectEntityModel(ProjectInfoEntity)
projectInfoEntity: Repository<ProjectInfoEntity>;
@InjectEntityModel(ProjectPhaseEntity)
projectPhaseEntity: Repository<ProjectPhaseEntity>;
@InjectEntityModel(ProjectTaskEntity)
projectTaskEntity: Repository<ProjectTaskEntity>;
/**
* 重新计算项目进度(由各阶段加权平均)
*/
async recalcProgress(projectId: number) {
const phases = await this.projectPhaseEntity.find({
where: { projectId },
});
if (phases.length === 0) {
await this.projectInfoEntity.update(projectId, { progress: 0 });
return;
}
const total = phases.reduce((sum, p) => sum + p.progress, 0);
const progress = Math.round(total / phases.length);
await this.projectInfoEntity.update(projectId, { progress });
}
/**
* 删除项目时级联删除阶段和任务
*/
async delete(ids: number[]) {
await super.delete(ids);
for (const id of ids) {
await this.projectPhaseEntity.delete({ projectId: id });
await this.projectTaskEntity.delete({ projectId: id });
}
}
}
```
- [ ] **Step 2: 创建 ProjectTaskService**
创建 `packages/backend/src/modules/project/service/task.ts`:
```typescript
import { Inject, Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository, In } from 'typeorm';
import { ProjectTaskEntity } from '../entity/task';
import { ProjectPhaseEntity } from '../entity/phase';
import { ProjectTimeLogEntity } from '../entity/time_log';
import { ProjectTaskDependencyEntity } from '../entity/task_dependency';
import { ProjectInfoService } from './info';
/**
* 任务服务
*/
@Provide()
export class ProjectTaskService extends BaseService {
@InjectEntityModel(ProjectTaskEntity)
projectTaskEntity: Repository<ProjectTaskEntity>;
@InjectEntityModel(ProjectPhaseEntity)
projectPhaseEntity: Repository<ProjectPhaseEntity>;
@InjectEntityModel(ProjectTimeLogEntity)
projectTimeLogEntity: Repository<ProjectTimeLogEntity>;
@InjectEntityModel(ProjectTaskDependencyEntity)
projectTaskDependencyEntity: Repository<ProjectTaskDependencyEntity>;
@Inject()
projectInfoService: ProjectInfoService;
/**
* 获取任务树(项目下所有任务,按阶段分组,含子任务层级)
*/
async tree(projectId: number) {
const tasks = await this.projectTaskEntity.find({
where: { projectId },
order: { sortOrder: 'ASC', createTime: 'ASC' },
});
return this.buildTree(tasks, null);
}
/**
* 构建树形结构
*/
private buildTree(tasks: ProjectTaskEntity[], parentId: number | null) {
return tasks
.filter(t => t.parentId === parentId)
.map(t => ({
...t,
children: this.buildTree(tasks, t.id),
}));
}
/**
* 获取看板数据(按状态分组)
*/
async kanban(projectId: number) {
const tasks = await this.projectTaskEntity.find({
where: { projectId },
order: { sortOrder: 'ASC', createTime: 'ASC' },
});
return {
todo: tasks.filter(t => t.status === 0),
inProgress: tasks.filter(t => t.status === 1),
done: tasks.filter(t => t.status === 2),
closed: tasks.filter(t => t.status === 3),
};
}
/**
* 看板排序/状态变更
*/
async kanbanSort(items: { id: number; status: number; sortOrder: number }[]) {
for (const item of items) {
await this.projectTaskEntity.update(item.id, {
status: item.status,
sortOrder: item.sortOrder,
});
}
}
/**
* 重新计算阶段进度(由下属任务加权平均)
*/
async recalcPhaseProgress(phaseId: number) {
const tasks = await this.projectTaskEntity.find({
where: { phaseId, parentId: null },
});
if (tasks.length === 0) return;
const hasEstimated = tasks.some(t => t.estimatedHours > 0);
let progress: number;
if (hasEstimated) {
const totalWeight = tasks.reduce((sum, t) => sum + (t.estimatedHours || 1), 0);
const weightedSum = tasks.reduce(
(sum, t) => sum + t.progress * (t.estimatedHours || 1),
0
);
progress = Math.round(weightedSum / totalWeight);
} else {
progress = Math.round(
tasks.reduce((sum, t) => sum + t.progress, 0) / tasks.length
);
}
await this.projectPhaseEntity.update(phaseId, { progress });
}
/**
* 重新计算任务工时(汇总工时记录)
*/
async recalcTaskHours(taskId: number) {
const result = await this.projectTimeLogEntity
.createQueryBuilder('tl')
.select('SUM(tl.hours)', 'total')
.where('tl.taskId = :taskId', { taskId })
.getRawOne();
const actualHours = parseFloat(result.total) || 0;
await this.projectTaskEntity.update(taskId, { actualHours });
}
/**
* 任务更新后触发进度向上汇总
*/
async afterUpdate(task: ProjectTaskEntity) {
if (task.phaseId) {
await this.recalcPhaseProgress(task.phaseId);
const phase = await this.projectPhaseEntity.findOneBy({ id: task.phaseId });
if (phase) {
await this.projectInfoService.recalcProgress(phase.projectId);
}
}
}
/**
* 删除任务时级联删除子任务、依赖、工时记录
*/
async delete(ids: number[]) {
await super.delete(ids);
for (const id of ids) {
// 删除子任务
const children = await this.projectTaskEntity.find({ where: { parentId: id } });
if (children.length > 0) {
await this.delete(children.map(c => c.id));
}
// 删除依赖关系
await this.projectTaskDependencyEntity
.createQueryBuilder()
.delete()
.where('taskId = :id OR dependsOnTaskId = :id', { id })
.execute();
// 删除工时记录
await this.projectTimeLogEntity.delete({ taskId: id });
}
}
}
```
- [ ] **Step 3: 创建 ProjectGanttService**
创建 `packages/backend/src/modules/project/service/gantt.ts`:
```typescript
import { Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { ProjectTaskEntity } from '../entity/task';
import { ProjectPhaseEntity } from '../entity/phase';
import { ProjectTaskDependencyEntity } from '../entity/task_dependency';
/**
* 甘特图数据服务
*/
@Provide()
export class ProjectGanttService extends BaseService {
@InjectEntityModel(ProjectTaskEntity)
projectTaskEntity: Repository<ProjectTaskEntity>;
@InjectEntityModel(ProjectPhaseEntity)
projectPhaseEntity: Repository<ProjectPhaseEntity>;
@InjectEntityModel(ProjectTaskDependencyEntity)
projectTaskDependencyEntity: Repository<ProjectTaskDependencyEntity>;
/**
* 获取甘特图数据DHTMLX Gantt 格式)
*/
async ganttData(projectId: number) {
const phases = await this.projectPhaseEntity.find({
where: { projectId },
order: { sortOrder: 'ASC' },
});
const tasks = await this.projectTaskEntity.find({
where: { projectId },
order: { sortOrder: 'ASC', createTime: 'ASC' },
});
const deps = await this.projectTaskDependencyEntity
.createQueryBuilder('d')
.where('d.taskId IN (:...taskIds)', {
taskIds: tasks.length > 0 ? tasks.map(t => t.id) : [0],
})
.orWhere('d.dependsOnTaskId IN (:...depIds)', {
depIds: tasks.length > 0 ? tasks.map(t => t.id) : [0],
})
.getMany();
// 组装 DHTMLX Gantt data 数组
const data = [];
// 阶段作为分组节点
for (const phase of phases) {
data.push({
id: `p_${phase.id}`,
text: phase.name,
start_date: phase.startDate || '',
end_date: phase.endDate || '',
progress: phase.progress / 100,
type: 'project',
open: true,
sortOrder: phase.sortOrder,
status: phase.status,
phaseType: phase.type,
});
}
// 任务
for (const task of tasks) {
let parent = '';
if (task.parentId) {
parent = `t_${task.parentId}`;
} else if (task.phaseId) {
parent = `p_${task.phaseId}`;
}
data.push({
id: `t_${task.id}`,
text: task.name,
start_date: task.startDate || '',
end_date: task.endDate || '',
progress: task.progress / 100,
parent,
priority: task.priority,
status: task.status,
category: task.category,
assignee: task.assigneeName,
assigneeId: task.assigneeId,
estimatedHours: task.estimatedHours,
actualHours: task.actualHours,
color: task.color,
sortOrder: task.sortOrder,
});
}
// 依赖关系 → links
const links = deps.map(d => ({
id: d.id,
source: `t_${d.dependsOnTaskId}`,
target: `t_${d.taskId}`,
type: String(d.type),
}));
return { data, links };
}
/**
* 甘特图拖拽批量更新
*/
async ganttUpdate(
items: { id: string; start_date: string; end_date: string; sortOrder?: number; parent?: string }[]
) {
for (const item of items) {
if (item.id.startsWith('p_')) {
const phaseId = parseInt(item.id.replace('p_', ''));
await this.projectPhaseEntity.update(phaseId, {
startDate: item.start_date,
endDate: item.end_date,
...(item.sortOrder !== undefined ? { sortOrder: item.sortOrder } : {}),
});
} else if (item.id.startsWith('t_')) {
const taskId = parseInt(item.id.replace('t_', ''));
const updateData: any = {
startDate: item.start_date,
endDate: item.end_date,
};
if (item.sortOrder !== undefined) {
updateData.sortOrder = item.sortOrder;
}
if (item.parent) {
if (item.parent.startsWith('p_')) {
updateData.phaseId = parseInt(item.parent.replace('p_', ''));
updateData.parentId = null;
} else if (item.parent.startsWith('t_')) {
updateData.parentId = parseInt(item.parent.replace('t_', ''));
}
}
await this.projectTaskEntity.update(taskId, updateData);
}
}
}
}
```
- [ ] **Step 4: 验证 Service 层编译通过**
```bash
cd packages/backend
npx tsc --noEmit
```
预期:无编译错误。
- [ ] **Step 5: 提交**
```bash
git add packages/backend/src/modules/project/service/
git commit -m "feat(backend): 项目管理模块 - Service 层(进度计算、甘特图数据、看板)"
```
---
## Task 3: 后端 Controller 层
**Files:**
- Create: `packages/backend/src/modules/project/controller/admin/info.ts`
- Create: `packages/backend/src/modules/project/controller/admin/phase.ts`
- Create: `packages/backend/src/modules/project/controller/admin/task.ts`
- Create: `packages/backend/src/modules/project/controller/admin/task_dependency.ts`
- Create: `packages/backend/src/modules/project/controller/admin/time_log.ts`
- [ ] **Step 1: 创建 ProjectInfoController**
创建 `packages/backend/src/modules/project/controller/admin/info.ts`:
```typescript
import { Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { ProjectInfoEntity } from '../../entity/info';
import { ProjectInfoService } from '../../service/info';
/**
* 项目管理
*/
@Provide()
@CoolController({
api: ['add', 'delete', 'update', 'info', 'list', 'page'],
entity: ProjectInfoEntity,
service: ProjectInfoService,
pageQueryOp: {
keyWordLikeFields: ['name', 'ownerName'],
fieldEq: ['status'],
addOrderBy: {
createTime: 'DESC',
},
},
})
export class AdminProjectInfoController extends BaseController {}
```
- [ ] **Step 2: 创建 ProjectPhaseController**
创建 `packages/backend/src/modules/project/controller/admin/phase.ts`:
```typescript
import { Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { ProjectPhaseEntity } from '../../entity/phase';
/**
* 项目阶段
*/
@Provide()
@CoolController({
api: ['add', 'delete', 'update', 'info', 'list', 'page'],
entity: ProjectPhaseEntity,
listQueryOp: {
fieldEq: ['projectId'],
addOrderBy: {
sortOrder: 'ASC',
},
},
})
export class AdminProjectPhaseController extends BaseController {}
```
- [ ] **Step 3: 创建 ProjectTaskController含自定义接口**
创建 `packages/backend/src/modules/project/controller/admin/task.ts`:
```typescript
import { Body, Get, Inject, Post, Provide, Query } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { ProjectTaskEntity } from '../../entity/task';
import { ProjectTaskService } from '../../service/task';
import { ProjectGanttService } from '../../service/gantt';
/**
* 项目任务
*/
@Provide()
@CoolController({
api: ['add', 'delete', 'update', 'info', 'list', 'page'],
entity: ProjectTaskEntity,
service: ProjectTaskService,
pageQueryOp: {
keyWordLikeFields: ['name', 'assigneeName'],
fieldEq: ['projectId', 'phaseId', 'status', 'priority', 'category', 'assigneeId'],
addOrderBy: {
sortOrder: 'ASC',
createTime: 'ASC',
},
},
})
export class AdminProjectTaskController extends BaseController {
@Inject()
projectTaskService: ProjectTaskService;
@Inject()
projectGanttService: ProjectGanttService;
@Get('/tree', { summary: '任务树' })
async tree(@Query('projectId') projectId: number) {
return this.ok(await this.projectTaskService.tree(projectId));
}
@Get('/ganttData', { summary: '甘特图数据' })
async ganttData(@Query('projectId') projectId: number) {
return this.ok(await this.projectGanttService.ganttData(projectId));
}
@Post('/ganttUpdate', { summary: '甘特图拖拽更新' })
async ganttUpdate(@Body('items') items: any[]) {
await this.projectGanttService.ganttUpdate(items);
return this.ok();
}
@Get('/kanban', { summary: '看板数据' })
async kanban(@Query('projectId') projectId: number) {
return this.ok(await this.projectTaskService.kanban(projectId));
}
@Post('/kanbanSort', { summary: '看板排序' })
async kanbanSort(@Body('items') items: any[]) {
await this.projectTaskService.kanbanSort(items);
return this.ok();
}
}
```
- [ ] **Step 4: 创建 ProjectTaskDependencyController**
创建 `packages/backend/src/modules/project/controller/admin/task_dependency.ts`:
```typescript
import { Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { ProjectTaskDependencyEntity } from '../../entity/task_dependency';
/**
* 任务依赖关系
*/
@Provide()
@CoolController({
api: ['add', 'delete', 'list'],
entity: ProjectTaskDependencyEntity,
listQueryOp: {
fieldEq: ['taskId'],
},
})
export class AdminProjectTaskDependencyController extends BaseController {}
```
- [ ] **Step 5: 创建 ProjectTimeLogController**
创建 `packages/backend/src/modules/project/controller/admin/time_log.ts`:
```typescript
import { Inject, Provide } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { ProjectTimeLogEntity } from '../../entity/time_log';
import { ProjectTaskService } from '../../service/task';
/**
* 工时记录
*/
@Provide()
@CoolController({
api: ['add', 'delete', 'page'],
entity: ProjectTimeLogEntity,
pageQueryOp: {
fieldEq: ['taskId', 'userId'],
addOrderBy: {
logDate: 'DESC',
},
},
})
export class AdminProjectTimeLogController extends BaseController {
@Inject()
projectTaskService: ProjectTaskService;
/**
* 新增工时后自动汇总
*/
async modifyAfter(data: any, type: 'delete' | 'update' | 'add') {
if (data.taskId) {
await this.projectTaskService.recalcTaskHours(data.taskId);
}
}
}
```
- [ ] **Step 6: 启动后端验证 API**
```bash
cd packages/backend
pnpm dev
```
预期:启动成功。访问 `http://localhost:8003/admin/project/info/page` 应返回空数据列表(需要带 token或用 Postman 测试)。
- [ ] **Step 7: 提交**
```bash
git add packages/backend/src/modules/project/controller/
git commit -m "feat(backend): 项目管理模块 - Controller 层CRUD + 甘特图/看板接口)"
```
---
## Task 4: 前端依赖安装 + 模块骨架
**Files:**
- Modify: `packages/frontend/package.json`
- Create: `packages/frontend/src/modules/project/config.ts`
- Create: `packages/frontend/src/modules/project/store/project.ts`
- [ ] **Step 1: 安装前端依赖**
```bash
cd packages/frontend
pnpm add dhtmlx-gantt @fullcalendar/vue3 @fullcalendar/core @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction vuedraggable@next
```
- [ ] **Step 2: 创建模块目录结构**
```bash
cd packages/frontend
mkdir -p src/modules/project/views/components
mkdir -p src/modules/project/store
```
- [ ] **Step 3: 创建模块路由配置**
创建 `packages/frontend/src/modules/project/config.ts`:
```typescript
import { type ModuleConfig } from '/@/cool';
export default (): ModuleConfig => {
return {
name: 'project',
label: '项目管理',
order: 80,
views: [
{
path: '/project/list',
meta: { label: '项目列表' },
component: () => import('./views/list.vue')
},
{
path: '/project/detail',
meta: { label: '项目详情' },
component: () => import('./views/detail.vue')
}
]
};
};
```
- [ ] **Step 4: 创建 Pinia Store**
创建 `packages/frontend/src/modules/project/store/project.ts`:
```typescript
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useCool } from '/@/cool';
export const useProjectStore = defineStore('project', () => {
const { service } = useCool();
// 当前项目信息
const currentProject = ref<any>(null);
// 甘特图数据
const ganttData = ref<{ data: any[]; links: any[] }>({ data: [], links: [] });
// 任务列表(平铺)
const tasks = computed(() =>
ganttData.value.data.filter((d: any) => String(d.id).startsWith('t_'))
);
// 阶段列表
const phases = computed(() =>
ganttData.value.data.filter((d: any) => String(d.id).startsWith('p_'))
);
/**
* 加载项目详情
*/
async function loadProject(projectId: number) {
const res = await service.project.info.info({ id: projectId });
currentProject.value = res;
return res;
}
/**
* 加载甘特图数据(所有视图共享)
*/
async function loadGanttData(projectId: number) {
const res = await service.project.task.ganttData({ projectId });
ganttData.value = res;
return res;
}
/**
* 刷新数据
*/
async function refresh() {
if (currentProject.value?.id) {
await loadGanttData(currentProject.value.id);
}
}
/**
* 甘特图拖拽更新
*/
async function ganttUpdate(items: any[]) {
await service.project.task.ganttUpdate({ items });
await refresh();
}
/**
* 看板排序
*/
async function kanbanSort(items: any[]) {
await service.project.task.kanbanSort({ items });
await refresh();
}
/**
* 添加任务
*/
async function addTask(data: any) {
await service.project.task.add(data);
await refresh();
}
/**
* 更新任务
*/
async function updateTask(data: any) {
await service.project.task.update(data);
await refresh();
}
/**
* 删除任务
*/
async function deleteTask(ids: number[]) {
await service.project.task.delete({ ids });
await refresh();
}
/**
* 重置
*/
function reset() {
currentProject.value = null;
ganttData.value = { data: [], links: [] };
}
return {
currentProject,
ganttData,
tasks,
phases,
loadProject,
loadGanttData,
refresh,
ganttUpdate,
kanbanSort,
addTask,
updateTask,
deleteTask,
reset,
};
});
```
- [ ] **Step 5: 验证前端编译**
```bash
cd packages/frontend
pnpm dev
```
预期:前端启动成功,无编译错误。
- [ ] **Step 6: 提交**
```bash
git add packages/frontend/package.json pnpm-lock.yaml packages/frontend/src/modules/project/
git commit -m "feat(frontend): 项目管理模块骨架 - 依赖安装 + 路由配置 + Pinia Store"
```
---
## Task 5: 前端项目列表页
**Files:**
- Create: `packages/frontend/src/modules/project/views/list.vue`
- [ ] **Step 1: 创建项目列表页**
创建 `packages/frontend/src/modules/project/views/list.vue`:
```vue
<template>
<div class="project-list">
<div class="project-list__header">
<div class="project-list__title">项目管理</div>
<el-button type="primary" @click="openAdd">新建项目</el-button>
</div>
<div class="project-list__filter">
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 120px">
<el-option label="未开始" :value="0" />
<el-option label="进行中" :value="1" />
<el-option label="已完成" :value="2" />
<el-option label="已归档" :value="3" />
</el-select>
<el-input
v-model="filters.keyWord"
placeholder="搜索项目名称"
clearable
style="width: 200px; margin-left: 10px"
@clear="loadData"
@keyup.enter="loadData"
/>
<el-button style="margin-left: 10px" @click="loadData">搜索</el-button>
</div>
<div class="project-list__cards">
<div
v-for="item in list"
:key="item.id"
class="project-card"
@click="goDetail(item)"
>
<div class="project-card__header">
<span
class="project-card__color"
:style="{ backgroundColor: item.color || '#409EFF' }"
/>
<span class="project-card__name">{{ item.name }}</span>
<el-tag :type="statusTagType(item.status)" size="small">
{{ statusLabel(item.status) }}
</el-tag>
</div>
<div class="project-card__desc">{{ item.description || '暂无描述' }}</div>
<el-progress :percentage="item.progress" :stroke-width="6" />
<div class="project-card__footer">
<span>{{ item.ownerName || '未分配' }}</span>
<span v-if="item.startDate">
{{ item.startDate }} ~ {{ item.endDate || '未定' }}
</span>
</div>
<div class="project-card__actions" @click.stop>
<el-button link type="primary" size="small" @click="openEdit(item)">
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(item)">
删除
</el-button>
</div>
</div>
</div>
<div class="project-list__pagination">
<el-pagination
v-model:current-page="page.currentPage"
v-model:page-size="page.pageSize"
:total="page.total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form :model="form" label-width="80px">
<el-form-item label="项目名称" required>
<el-input v-model="form.name" placeholder="请输入项目名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="未开始" :value="0" />
<el-option label="进行中" :value="1" />
<el-option label="已完成" :value="2" />
<el-option label="已归档" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="负责人">
<el-input v-model="form.ownerName" placeholder="负责人姓名" />
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker v-model="form.color" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useCool } from '/@/cool';
import { ElMessage, ElMessageBox } from 'element-plus';
const { service } = useCool();
const router = useRouter();
const list = ref<any[]>([]);
const filters = reactive({ status: undefined as number | undefined, keyWord: '' });
const page = reactive({ currentPage: 1, pageSize: 12, total: 0 });
const dialogVisible = ref(false);
const dialogTitle = ref('新建项目');
const saving = ref(false);
const form = reactive({
id: undefined as number | undefined,
name: '',
description: '',
status: 0,
ownerName: '',
color: '#409EFF',
startDate: '',
endDate: '',
});
const dateRange = ref<string[]>([]);
const statusMap: Record<number, string> = { 0: '未开始', 1: '进行中', 2: '已完成', 3: '已归档' };
const statusTagMap: Record<number, string> = { 0: 'info', 1: '', 2: 'success', 3: 'warning' };
function statusLabel(s: number) { return statusMap[s] || '未知'; }
function statusTagType(s: number) { return statusTagMap[s] || 'info'; }
async function loadData() {
const res = await service.project.info.page({
page: page.currentPage,
size: page.pageSize,
status: filters.status,
keyWord: filters.keyWord,
});
list.value = res.list || [];
page.total = res.pagination?.total || 0;
}
function openAdd() {
dialogTitle.value = '新建项目';
Object.assign(form, { id: undefined, name: '', description: '', status: 0, ownerName: '', color: '#409EFF', startDate: '', endDate: '' });
dateRange.value = [];
dialogVisible.value = true;
}
function openEdit(item: any) {
dialogTitle.value = '编辑项目';
Object.assign(form, item);
dateRange.value = item.startDate ? [item.startDate, item.endDate] : [];
dialogVisible.value = true;
}
async function handleSave() {
if (!form.name) { ElMessage.warning('请输入项目名称'); return; }
saving.value = true;
form.startDate = dateRange.value?.[0] || '';
form.endDate = dateRange.value?.[1] || '';
try {
if (form.id) {
await service.project.info.update(form);
} else {
await service.project.info.add(form);
}
ElMessage.success('保存成功');
dialogVisible.value = false;
await loadData();
} finally {
saving.value = false;
}
}
async function handleDelete(item: any) {
await ElMessageBox.confirm(`确定删除项目「${item.name}」?`, '提示', { type: 'warning' });
await service.project.info.delete({ ids: [item.id] });
ElMessage.success('删除成功');
await loadData();
}
function goDetail(item: any) {
router.push({ path: '/project/detail', query: { id: item.id } });
}
onMounted(() => { loadData(); });
</script>
<style lang="scss" scoped>
.project-list {
padding: 20px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
&__title {
font-size: 20px;
font-weight: 600;
}
&__filter {
display: flex;
align-items: center;
margin-bottom: 20px;
}
&__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
&__pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
.project-card {
padding: 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__color {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
&__name {
font-weight: 500;
flex: 1;
}
&__desc {
color: #909399;
font-size: 13px;
margin-bottom: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__footer {
display: flex;
justify-content: space-between;
color: #909399;
font-size: 12px;
margin-top: 12px;
}
&__actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
}
</style>
```
- [ ] **Step 2: 验证页面渲染**
启动前端 `pnpm dev`,在浏览器后台菜单管理中添加项目管理菜单(路径 `/project/list`),验证页面能正确渲染。
- [ ] **Step 3: 提交**
```bash
git add packages/frontend/src/modules/project/views/list.vue
git commit -m "feat(frontend): 项目列表页 - 卡片布局 + 新建/编辑/删除"
```
---
## Task 6: 前端项目详情页Tab 容器)
**Files:**
- Create: `packages/frontend/src/modules/project/views/detail.vue`
- [ ] **Step 1: 创建项目详情页**
创建 `packages/frontend/src/modules/project/views/detail.vue`:
```vue
<template>
<div class="project-detail" v-loading="loading">
<!-- 顶部项目信息栏 -->
<div class="project-detail__header">
<el-page-header @back="goBack">
<template #content>
<div class="project-detail__info">
<span
class="project-detail__color"
:style="{ backgroundColor: project?.color || '#409EFF' }"
/>
<span class="project-detail__name">{{ project?.name }}</span>
<el-tag :type="statusTagType(project?.status)" size="small">
{{ statusLabel(project?.status) }}
</el-tag>
</div>
</template>
<template #extra>
<div class="project-detail__extra">
<span v-if="project?.ownerName" class="project-detail__owner">
负责人: {{ project.ownerName }}
</span>
<el-progress
:percentage="project?.progress || 0"
:stroke-width="8"
style="width: 150px"
/>
<el-button size="small" @click="phaseManagerVisible = true">
阶段管理
</el-button>
</div>
</template>
</el-page-header>
</div>
<!-- Tab 视图切换 -->
<el-tabs v-model="activeTab" class="project-detail__tabs">
<el-tab-pane label="甘特图" name="gantt">
<gantt-view v-if="activeTab === 'gantt'" @open-task="openTaskDrawer" />
</el-tab-pane>
<el-tab-pane label="日历" name="calendar">
<calendar-view v-if="activeTab === 'calendar'" @open-task="openTaskDrawer" />
</el-tab-pane>
<el-tab-pane label="列表" name="table">
<table-view v-if="activeTab === 'table'" @open-task="openTaskDrawer" />
</el-tab-pane>
<el-tab-pane label="看板" name="kanban">
<kanban-view v-if="activeTab === 'kanban'" @open-task="openTaskDrawer" />
</el-tab-pane>
</el-tabs>
<!-- 任务详情抽屉 -->
<task-drawer
v-model="taskDrawerVisible"
:task-id="currentTaskId"
:project-id="projectId"
@saved="store.refresh()"
/>
<!-- 阶段管理弹窗 -->
<phase-manager
v-model="phaseManagerVisible"
:project-id="projectId"
@saved="store.refresh()"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useProjectStore } from '../store/project';
import GanttView from './components/gantt.vue';
import CalendarView from './components/calendar.vue';
import TableView from './components/table.vue';
import KanbanView from './components/kanban.vue';
import TaskDrawer from './components/task-drawer.vue';
import PhaseManager from './components/phase-manager.vue';
const route = useRoute();
const router = useRouter();
const store = useProjectStore();
const projectId = computed(() => Number(route.query.id));
const project = computed(() => store.currentProject);
const loading = ref(true);
const activeTab = ref('gantt');
const taskDrawerVisible = ref(false);
const phaseManagerVisible = ref(false);
const currentTaskId = ref<number | null>(null);
const statusMap: Record<number, string> = { 0: '未开始', 1: '进行中', 2: '已完成', 3: '已归档' };
const statusTagMap: Record<number, string> = { 0: 'info', 1: '', 2: 'success', 3: 'warning' };
function statusLabel(s?: number) { return s !== undefined ? statusMap[s] || '未知' : ''; }
function statusTagType(s?: number) { return s !== undefined ? statusTagMap[s] || 'info' : 'info'; }
function openTaskDrawer(taskId: number) {
currentTaskId.value = taskId;
taskDrawerVisible.value = true;
}
function goBack() {
router.push('/project/list');
}
onMounted(async () => {
loading.value = true;
await store.loadProject(projectId.value);
await store.loadGanttData(projectId.value);
loading.value = false;
});
onUnmounted(() => {
store.reset();
});
</script>
<style lang="scss" scoped>
.project-detail {
padding: 20px;
&__header {
margin-bottom: 16px;
}
&__info {
display: flex;
align-items: center;
gap: 8px;
}
&__color {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
&__name {
font-size: 18px;
font-weight: 600;
}
&__extra {
display: flex;
align-items: center;
gap: 16px;
}
&__owner {
color: #606266;
font-size: 14px;
}
&__tabs {
:deep(.el-tabs__content) {
min-height: calc(100vh - 220px);
}
}
}
</style>
```
- [ ] **Step 2: 提交**
```bash
git add packages/frontend/src/modules/project/views/detail.vue
git commit -m "feat(frontend): 项目详情页 - Tab容器 + 视图切换"
```
---
## Task 7: 甘特图视图组件
**Files:**
- Create: `packages/frontend/src/modules/project/views/components/gantt.vue`
- [ ] **Step 1: 创建甘特图组件**
创建 `packages/frontend/src/modules/project/views/components/gantt.vue`:
```vue
<template>
<div class="gantt-view">
<div ref="ganttContainer" class="gantt-view__chart" />
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { gantt } from 'dhtmlx-gantt';
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css';
import { useProjectStore } from '../../store/project';
const emit = defineEmits<{
(e: 'open-task', taskId: number): void;
}>();
const store = useProjectStore();
const ganttContainer = ref<HTMLElement>();
function initGantt() {
if (!ganttContainer.value) return;
// 基础配置
gantt.config.date_format = '%Y-%m-%d';
gantt.config.scale_unit = 'day';
gantt.config.date_scale = '%m/%d';
gantt.config.min_column_width = 30;
gantt.config.row_height = 36;
gantt.config.drag_resize = true;
gantt.config.drag_move = true;
gantt.config.drag_links = true;
gantt.config.auto_scheduling = false;
gantt.config.open_tree_initially = true;
gantt.config.fit_tasks = true;
// 左侧列配置
gantt.config.columns = [
{ name: 'text', label: '任务名称', width: 200, tree: true },
{ name: 'start_date', label: '开始', align: 'center', width: 80 },
{ name: 'end_date', label: '结束', align: 'center', width: 80 },
{ name: 'assignee', label: '负责人', align: 'center', width: 70 },
];
// 双击打开任务详情
gantt.attachEvent('onTaskDblClick', (id: string) => {
if (id.startsWith('t_')) {
const taskId = parseInt(id.replace('t_', ''));
emit('open-task', taskId);
}
return false; // 阻止默认编辑弹窗
});
// 拖拽完成后保存
gantt.attachEvent('onAfterTaskDrag', (id: string) => {
const task = gantt.getTask(id);
store.ganttUpdate([
{
id,
start_date: gantt.templates.format_date(task.start_date),
end_date: gantt.templates.format_date(task.end_date),
parent: task.parent,
},
]);
});
// 新增依赖连线后保存
gantt.attachEvent('onAfterLinkAdd', (id: any, link: any) => {
// 通过 API 添加依赖
const taskId = parseInt(String(link.target).replace('t_', ''));
const dependsOnTaskId = parseInt(String(link.source).replace('t_', ''));
if (taskId && dependsOnTaskId) {
const { service } = (window as any).__cool || {};
// 直接调用依赖添加接口
fetch('/admin/project/taskDependency/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId, dependsOnTaskId, type: parseInt(link.type) || 0 }),
});
}
});
gantt.init(ganttContainer.value);
}
function renderData() {
const { data, links } = store.ganttData;
gantt.clearAll();
if (data.length > 0) {
gantt.parse({ data, links });
}
}
watch(() => store.ganttData, () => {
renderData();
}, { deep: true });
onMounted(() => {
initGantt();
renderData();
});
onUnmounted(() => {
gantt.clearAll();
});
</script>
<style lang="scss" scoped>
.gantt-view {
&__chart {
width: 100%;
height: calc(100vh - 260px);
}
}
</style>
```
- [ ] **Step 2: 验证甘特图渲染**
在浏览器中进入项目详情页,确认甘特图 Tab 能正常渲染(即使没有数据也不应报错)。
- [ ] **Step 3: 提交**
```bash
git add packages/frontend/src/modules/project/views/components/gantt.vue
git commit -m "feat(frontend): 甘特图视图 - DHTMLX Gantt 集成 + 拖拽更新"
```
---
## Task 8: 日历视图组件
**Files:**
- Create: `packages/frontend/src/modules/project/views/components/calendar.vue`
- [ ] **Step 1: 创建日历视图组件**
创建 `packages/frontend/src/modules/project/views/components/calendar.vue`:
```vue
<template>
<div class="calendar-view">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import { useProjectStore } from '../../store/project';
const emit = defineEmits<{
(e: 'open-task', taskId: number): void;
}>();
const store = useProjectStore();
const calendarRef = ref();
// 将任务数据转为 FullCalendar 事件
const events = computed(() => {
return store.tasks.map((t: any) => {
const taskId = parseInt(String(t.id).replace('t_', ''));
const priorityColor: Record<number, string> = {
0: '#F56C6C',
1: '#E6A23C',
2: '#409EFF',
3: '#909399',
};
return {
id: String(taskId),
title: t.text,
start: t.start_date,
end: t.end_date,
color: t.color || priorityColor[t.priority] || '#409EFF',
extendedProps: { taskId },
};
}).filter((e: any) => e.start);
});
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
locale: 'zh-cn',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek',
},
events: events.value,
editable: false,
eventClick: (info: any) => {
const taskId = info.event.extendedProps.taskId;
if (taskId) {
emit('open-task', taskId);
}
},
height: 'calc(100vh - 260px)',
}));
</script>
<style lang="scss" scoped>
.calendar-view {
padding: 10px;
}
</style>
```
- [ ] **Step 2: 提交**
```bash
git add packages/frontend/src/modules/project/views/components/calendar.vue
git commit -m "feat(frontend): 日历视图 - FullCalendar 月/周切换"
```
---
## Task 9: 表格列表视图组件
**Files:**
- Create: `packages/frontend/src/modules/project/views/components/table.vue`
- [ ] **Step 1: 创建表格视图组件**
创建 `packages/frontend/src/modules/project/views/components/table.vue`:
```vue
<template>
<div class="table-view">
<div class="table-view__filter">
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 100px" @change="loadData">
<el-option label="待办" :value="0" />
<el-option label="进行中" :value="1" />
<el-option label="已完成" :value="2" />
<el-option label="已关闭" :value="3" />
</el-select>
<el-select v-model="filters.priority" placeholder="优先级" clearable style="width: 100px" @change="loadData">
<el-option label="P0 紧急" :value="0" />
<el-option label="P1 高" :value="1" />
<el-option label="P2 中" :value="2" />
<el-option label="P3 低" :value="3" />
</el-select>
<el-input
v-model="filters.keyWord"
placeholder="搜索任务"
clearable
style="width: 180px"
@keyup.enter="loadData"
/>
<el-button @click="loadData">搜索</el-button>
<el-button type="primary" @click="emit('open-task', 0)">新建任务</el-button>
</div>
<el-table :data="tableData" style="width: 100%" row-key="id" default-expand-all>
<el-table-column prop="name" label="任务名称" min-width="200" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="90" align="center">
<template #default="{ row }">
<el-tag :type="priorityTagType(row.priority)" size="small">
{{ priorityLabel(row.priority) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="80" align="center" />
<el-table-column prop="assigneeName" label="负责人" width="80" align="center" />
<el-table-column prop="startDate" label="开始" width="100" align="center" />
<el-table-column prop="endDate" label="结束" width="100" align="center" />
<el-table-column prop="progress" label="进度" width="100" align="center">
<template #default="{ row }">
<el-progress :percentage="row.progress" :stroke-width="6" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="emit('open-task', row.id)">
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 16px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useCool } from '/@/cool';
import { useProjectStore } from '../../store/project';
import { ElMessage, ElMessageBox } from 'element-plus';
const emit = defineEmits<{
(e: 'open-task', taskId: number): void;
}>();
const { service } = useCool();
const route = useRoute();
const store = useProjectStore();
const projectId = computed(() => Number(route.query.id));
const tableData = ref<any[]>([]);
const filters = reactive({ status: undefined as number | undefined, priority: undefined as number | undefined, keyWord: '' });
const pagination = reactive({ page: 1, size: 20, total: 0 });
const statusMap: Record<number, string> = { 0: '待办', 1: '进行中', 2: '已完成', 3: '已关闭' };
const statusTagMap: Record<number, string> = { 0: 'info', 1: '', 2: 'success', 3: 'warning' };
const priorityMap: Record<number, string> = { 0: 'P0 紧急', 1: 'P1 高', 2: 'P2 中', 3: 'P3 低' };
const priorityTagMap: Record<number, string> = { 0: 'danger', 1: 'warning', 2: '', 3: 'info' };
function statusLabel(s: number) { return statusMap[s] || '未知'; }
function statusTagType(s: number) { return statusTagMap[s] || 'info'; }
function priorityLabel(p: number) { return priorityMap[p] || ''; }
function priorityTagType(p: number) { return priorityTagMap[p] || 'info'; }
async function loadData() {
const res = await service.project.task.page({
page: pagination.page,
size: pagination.size,
projectId: projectId.value,
status: filters.status,
priority: filters.priority,
keyWord: filters.keyWord,
});
tableData.value = res.list || [];
pagination.total = res.pagination?.total || 0;
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除任务「${row.name}」?`, '提示', { type: 'warning' });
await store.deleteTask([row.id]);
ElMessage.success('删除成功');
await loadData();
}
onMounted(() => { loadData(); });
</script>
<style lang="scss" scoped>
.table-view {
padding: 10px;
&__filter {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
}
</style>
```
- [ ] **Step 2: 提交**
```bash
git add packages/frontend/src/modules/project/views/components/table.vue
git commit -m "feat(frontend): 表格列表视图 - 筛选 + 分页 + 操作"
```
---
## Task 10: 看板视图组件
**Files:**
- Create: `packages/frontend/src/modules/project/views/components/kanban.vue`
- [ ] **Step 1: 创建看板视图组件**
创建 `packages/frontend/src/modules/project/views/components/kanban.vue`:
```vue
<template>
<div class="kanban-view">
<div v-for="col in columns" :key="col.status" class="kanban-column">
<div class="kanban-column__header">
<span>{{ col.label }}</span>
<el-tag size="small" round>{{ col.items.length }}</el-tag>
</div>
<draggable
v-model="col.items"
group="kanban"
item-key="id"
class="kanban-column__body"
@end="onDragEnd"
>
<template #item="{ element }">
<div class="kanban-card" @click="openTask(element)">
<div class="kanban-card__title">{{ element.text }}</div>
<div class="kanban-card__meta">
<el-tag
:type="priorityTagType(element.priority)"
size="small"
>
{{ priorityLabel(element.priority) }}
</el-tag>
<span v-if="element.assignee" class="kanban-card__assignee">
{{ element.assignee }}
</span>
</div>
<div v-if="element.end_date" class="kanban-card__date">
截止: {{ element.end_date }}
</div>
</div>
</template>
</draggable>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import draggable from 'vuedraggable';
import { useProjectStore } from '../../store/project';
const emit = defineEmits<{
(e: 'open-task', taskId: number): void;
}>();
const store = useProjectStore();
const priorityMap: Record<number, string> = { 0: 'P0 紧急', 1: 'P1 高', 2: 'P2 中', 3: 'P3 低' };
const priorityTagMap: Record<number, string> = { 0: 'danger', 1: 'warning', 2: '', 3: 'info' };
function priorityLabel(p: number) { return priorityMap[p] || ''; }
function priorityTagType(p: number) { return priorityTagMap[p] || 'info'; }
// 按状态分为四列
const columns = ref([
{ status: 0, label: '待办', items: [] as any[] },
{ status: 1, label: '进行中', items: [] as any[] },
{ status: 2, label: '已完成', items: [] as any[] },
{ status: 3, label: '已关闭', items: [] as any[] },
]);
function syncFromStore() {
const allTasks = store.tasks;
columns.value[0].items = allTasks.filter((t: any) => t.status === 0);
columns.value[1].items = allTasks.filter((t: any) => t.status === 1);
columns.value[2].items = allTasks.filter((t: any) => t.status === 2);
columns.value[3].items = allTasks.filter((t: any) => t.status === 3);
}
watch(() => store.ganttData, () => { syncFromStore(); }, { deep: true, immediate: true });
function openTask(element: any) {
const taskId = parseInt(String(element.id).replace('t_', ''));
emit('open-task', taskId);
}
async function onDragEnd() {
const items: any[] = [];
for (const col of columns.value) {
col.items.forEach((item: any, index: number) => {
const taskId = parseInt(String(item.id).replace('t_', ''));
items.push({ id: taskId, status: col.status, sortOrder: index });
});
}
await store.kanbanSort(items);
}
</script>
<style lang="scss" scoped>
.kanban-view {
display: flex;
gap: 16px;
padding: 10px;
overflow-x: auto;
min-height: calc(100vh - 280px);
}
.kanban-column {
flex: 0 0 280px;
background: #f5f7fa;
border-radius: 8px;
display: flex;
flex-direction: column;
&__header {
padding: 12px 16px;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e4e7ed;
}
&__body {
flex: 1;
padding: 8px;
min-height: 100px;
}
}
.kanban-card {
background: #fff;
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
border: 1px solid #e4e7ed;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&__title {
font-weight: 500;
margin-bottom: 8px;
}
&__meta {
display: flex;
align-items: center;
gap: 8px;
}
&__assignee {
color: #606266;
font-size: 12px;
}
&__date {
color: #909399;
font-size: 12px;
margin-top: 6px;
}
}
</style>
```
- [ ] **Step 2: 提交**
```bash
git add packages/frontend/src/modules/project/views/components/kanban.vue
git commit -m "feat(frontend): 看板视图 - vuedraggable 拖拽 + 状态变更"
```
---
## Task 11: 任务详情抽屉 + 工时弹窗 + 阶段管理
**Files:**
- Create: `packages/frontend/src/modules/project/views/components/task-drawer.vue`
- Create: `packages/frontend/src/modules/project/views/components/time-log-dialog.vue`
- Create: `packages/frontend/src/modules/project/views/components/phase-manager.vue`
- [ ] **Step 1: 创建任务详情抽屉**
创建 `packages/frontend/src/modules/project/views/components/task-drawer.vue`:
```vue
<template>
<el-drawer v-model="visible" title="任务详情" size="500px" @close="handleClose">
<el-form v-if="form" :model="form" label-width="80px">
<el-form-item label="任务名称" required>
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="阶段">
<el-select v-model="form.phaseId" placeholder="选择阶段" clearable style="width: 100%">
<el-option
v-for="p in store.phases"
:key="p.id"
:label="p.text"
:value="parseInt(String(p.id).replace('p_', ''))"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="待办" :value="0" />
<el-option label="进行中" :value="1" />
<el-option label="已完成" :value="2" />
<el-option label="已关闭" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="form.priority" style="width: 100%">
<el-option label="P0 紧急" :value="0" />
<el-option label="P1 高" :value="1" />
<el-option label="P2 中" :value="2" />
<el-option label="P3 低" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="分类">
<el-input v-model="form.category" placeholder="如:前端、后端、运营" />
</el-form-item>
<el-form-item label="负责人">
<el-input v-model="form.assigneeName" placeholder="负责人姓名" />
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始"
end-placeholder="结束"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="预估工时">
<el-input-number v-model="form.estimatedHours" :min="0" :step="0.5" />
<span style="margin-left: 8px; color: #909399">
实际: {{ form.actualHours || 0 }}h
</span>
</el-form-item>
<el-form-item label="进度">
<el-slider v-model="form.progress" :max="100" show-input />
</el-form-item>
</el-form>
<!-- 工时记录 -->
<div v-if="taskId" style="padding: 0 20px">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px">
<span style="font-weight: 600">工时记录</span>
<el-button size="small" type="primary" @click="timeLogVisible = true">
添加工时
</el-button>
</div>
<el-table :data="timeLogs" size="small" max-height="200">
<el-table-column prop="logDate" label="日期" width="100" />
<el-table-column prop="hours" label="工时(h)" width="70" align="center" />
<el-table-column prop="userName" label="记录人" width="70" />
<el-table-column prop="description" label="内容" />
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
<time-log-dialog
v-model="timeLogVisible"
:task-id="taskId!"
@saved="loadTimeLogs"
/>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useCool } from '/@/cool';
import { useProjectStore } from '../../store/project';
import { ElMessage } from 'element-plus';
import TimeLogDialog from './time-log-dialog.vue';
const props = defineProps<{
modelValue: boolean;
taskId: number | null;
projectId: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'saved'): void;
}>();
const { service } = useCool();
const store = useProjectStore();
const visible = ref(false);
const saving = ref(false);
const timeLogVisible = ref(false);
const timeLogs = ref<any[]>([]);
const dateRange = ref<string[]>([]);
const form = ref<any>({});
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => { emit('update:modelValue', v); });
watch(() => props.taskId, async (id) => {
if (id && id > 0) {
const res = await service.project.task.info({ id });
form.value = { ...res };
dateRange.value = res.startDate ? [res.startDate, res.endDate] : [];
await loadTimeLogs();
} else if (id === 0) {
// 新建
form.value = {
projectId: props.projectId,
name: '',
description: '',
status: 0,
priority: 2,
category: '',
assigneeName: '',
estimatedHours: 0,
progress: 0,
};
dateRange.value = [];
timeLogs.value = [];
}
});
async function loadTimeLogs() {
if (!props.taskId) return;
const res = await service.project.timeLog.page({
page: 1,
size: 50,
taskId: props.taskId,
});
timeLogs.value = res.list || [];
}
async function handleSave() {
if (!form.value.name) { ElMessage.warning('请输入任务名称'); return; }
saving.value = true;
form.value.startDate = dateRange.value?.[0] || '';
form.value.endDate = dateRange.value?.[1] || '';
try {
if (form.value.id) {
await service.project.task.update(form.value);
} else {
await service.project.task.add(form.value);
}
ElMessage.success('保存成功');
emit('saved');
visible.value = false;
} finally {
saving.value = false;
}
}
function handleClose() {
form.value = {};
}
</script>
```
- [ ] **Step 2: 创建工时记录弹窗**
创建 `packages/frontend/src/modules/project/views/components/time-log-dialog.vue`:
```vue
<template>
<el-dialog v-model="visible" title="添加工时" width="400px">
<el-form :model="form" label-width="70px">
<el-form-item label="日期" required>
<el-date-picker v-model="form.logDate" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="工时" required>
<el-input-number v-model="form.hours" :min="0.5" :step="0.5" :max="24" />
<span style="margin-left: 8px">小时</span>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch, reactive } from 'vue';
import { useCool } from '/@/cool';
import { ElMessage } from 'element-plus';
const props = defineProps<{
modelValue: boolean;
taskId: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'saved'): void;
}>();
const { service } = useCool();
const visible = ref(false);
const saving = ref(false);
const form = reactive({
logDate: '',
hours: 1,
description: '',
});
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => { emit('update:modelValue', v); });
async function handleSave() {
if (!form.logDate) { ElMessage.warning('请选择日期'); return; }
saving.value = true;
try {
await service.project.timeLog.add({
taskId: props.taskId,
logDate: form.logDate,
hours: form.hours,
description: form.description,
userName: '当前用户', // 后续可从用户 store 获取
});
ElMessage.success('工时已记录');
emit('saved');
visible.value = false;
// 重置
form.logDate = '';
form.hours = 1;
form.description = '';
} finally {
saving.value = false;
}
}
</script>
```
- [ ] **Step 3: 创建阶段管理弹窗**
创建 `packages/frontend/src/modules/project/views/components/phase-manager.vue`:
```vue
<template>
<el-dialog v-model="visible" title="阶段管理" width="600px">
<div style="margin-bottom: 12px">
<el-button type="primary" size="small" @click="addPhase">添加阶段</el-button>
</div>
<el-table :data="phases" size="small">
<el-table-column prop="name" label="阶段名称">
<template #default="{ row }">
<el-input v-if="row.editing" v-model="row.name" size="small" />
<span v-else>{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="type" label="分类" width="100">
<template #default="{ row }">
<el-input v-if="row.editing" v-model="row.type" size="small" />
<span v-else>{{ row.type || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" align="center">
<template #default="{ row }">
<el-input-number v-if="row.editing" v-model="row.sortOrder" size="small" :min="0" controls-position="right" style="width: 60px" />
<span v-else>{{ row.sortOrder }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<template v-if="row.editing">
<el-button link type="primary" size="small" @click="savePhase(row)">保存</el-button>
<el-button link size="small" @click="cancelEdit(row)">取消</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" @click="row.editing = true">编辑</el-button>
<el-button link type="danger" size="small" @click="deletePhase(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useCool } from '/@/cool';
import { ElMessage, ElMessageBox } from 'element-plus';
const props = defineProps<{
modelValue: boolean;
projectId: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'saved'): void;
}>();
const { service } = useCool();
const visible = ref(false);
const phases = ref<any[]>([]);
watch(() => props.modelValue, (v) => {
visible.value = v;
if (v) loadPhases();
});
watch(visible, (v) => { emit('update:modelValue', v); });
async function loadPhases() {
const res = await service.project.phase.list({ projectId: props.projectId });
phases.value = (res || []).map((p: any) => ({ ...p, editing: false }));
}
function addPhase() {
phases.value.push({
id: undefined,
projectId: props.projectId,
name: '',
type: '',
sortOrder: phases.value.length,
editing: true,
});
}
async function savePhase(row: any) {
if (!row.name) { ElMessage.warning('请输入阶段名称'); return; }
if (row.id) {
await service.project.phase.update(row);
} else {
await service.project.phase.add(row);
}
ElMessage.success('保存成功');
row.editing = false;
emit('saved');
await loadPhases();
}
function cancelEdit(row: any) {
if (!row.id) {
phases.value = phases.value.filter(p => p !== row);
} else {
row.editing = false;
loadPhases();
}
}
async function deletePhase(row: any) {
await ElMessageBox.confirm(`确定删除阶段「${row.name}」?`, '提示', { type: 'warning' });
await service.project.phase.delete({ ids: [row.id] });
ElMessage.success('删除成功');
emit('saved');
await loadPhases();
}
</script>
```
- [ ] **Step 4: 提交**
```bash
git add packages/frontend/src/modules/project/views/components/task-drawer.vue packages/frontend/src/modules/project/views/components/time-log-dialog.vue packages/frontend/src/modules/project/views/components/phase-manager.vue
git commit -m "feat(frontend): 任务详情抽屉 + 工时记录弹窗 + 阶段管理"
```
---
## Task 12: 端到端验证 + 菜单配置
**Files:**
- 无新建文件,在系统中配置菜单
- [ ] **Step 1: 启动后端**
```bash
cd packages/backend
pnpm dev
```
预期启动成功5 张表已自动创建。
- [ ] **Step 2: 启动前端**
```bash
cd packages/frontend
pnpm dev
```
预期:编译成功,无错误。
- [ ] **Step 3: 在系统中配置菜单**
登录后台 → 系统管理 → 菜单管理,添加:
1. 目录:名称「项目管理」,图标自选,排序设置在系统管理下
2. 菜单:名称「项目列表」,路由 `/project/list`,父级「项目管理」
3. 菜单:名称「项目详情」,路由 `/project/detail`,父级「项目管理」,设置为隐藏菜单(不在侧边栏显示)
- [ ] **Step 4: 验证核心流程**
1. 项目列表页:新建一个项目 → 显示在卡片列表中
2. 进入项目详情 → 阶段管理:添加 2 个阶段
3. 甘特图 Tab显示阶段分组即使暂无任务
4. 列表 Tab新建一个任务 → 显示在表格中
5. 看板 Tab任务卡片出现在「待办」列
6. 日历 Tab任务显示在对应日期
7. 任务抽屉:编辑任务 → 添加工时记录
- [ ] **Step 5: 提交**
```bash
git add -A
git commit -m "feat: 项目管理模块完成 - 甘特图/日历/列表/看板四视图"
```