GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-12-project-management.md
2026-05-20 21:39:12 +08:00

2639 lines
71 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 项目管理模块实施计划
> **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: 项目管理模块完成 - 甘特图/日历/列表/看板四视图"
```