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

71 KiB
Raw Permalink Blame History

项目管理模块实施计划

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 模块目录结构

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:

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:

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:

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:

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:

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:

import { ModuleConfig } from '@cool-midway/core';

/**
 * 模块配置
 */
export default () => {
  return {
    // 模块名称
    name: '项目管理',
    // 模块描述
    description: '项目进度管理,支持甘特图、日历、看板等视图',
    // 中间件
    middlewares: [],
    // 全局中间件
    globalMiddlewares: [],
    // 模块加载顺序
    order: 0,
  } as ModuleConfig;
};
  • Step 8: 启动后端验证表自动创建
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: 提交
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:

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:

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:

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 层编译通过
cd packages/backend
npx tsc --noEmit

预期:无编译错误。

  • Step 5: 提交
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:

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:

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:

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:

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:

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
cd packages/backend
pnpm dev

预期:启动成功。访问 http://localhost:8003/admin/project/info/page 应返回空数据列表(需要带 token或用 Postman 测试)。

  • Step 7: 提交
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: 安装前端依赖

cd packages/frontend
pnpm add dhtmlx-gantt @fullcalendar/vue3 @fullcalendar/core @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction vuedraggable@next
  • Step 2: 创建模块目录结构
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:

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:

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: 验证前端编译
cd packages/frontend
pnpm dev

预期:前端启动成功,无编译错误。

  • Step 6: 提交
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:

<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: 提交
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:

<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: 提交
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:

<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: 提交
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:

<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: 提交
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:

<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: 提交
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:

<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: 提交
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:

<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:

<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:

<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: 提交
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: 启动后端

cd packages/backend
pnpm dev

预期启动成功5 张表已自动创建。

  • Step 2: 启动前端
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: 提交
git add -A
git commit -m "feat: 项目管理模块完成 - 甘特图/日历/列表/看板四视图"