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