项目是研发管理的最小执行单元------需求挂在项目下,工作项挂在项目下,缺陷挂在项目下。本篇记录「我的项目」页面的完整实现:列表/卡片双视图、新增项目(编码自动生成 + 创建人强制绑定为负责人)、编辑项目(权限校验 + 字段级更新)、置顶(成员维度个性化)、成员管理(身份分级 + 受保护身份校验),以及前端通用表格组件的抽取逻辑------PageHeader、FilterCard、ListPanel 三件套如何让列表页从「每次复制 200 行模板」变成「几十行组装」。
一、我的项目:列表与卡片双视图
1.1 页面整体结构
「我的项目」页面由四个区域组成:页面头部、筛选区、列表/卡片区、弹窗区。每个区域都使用了抽取后的通用组件:
css
我的项目页面结构
┌──────────────────────────────────────────────────┐
│ PageHeader │
│ 「我的项目」 + 描述 + [新增项目] 按钮 │
├──────────────────────────────────────────────────┤
│ FilterCard(筛选区) │
│ 项目状态 | 负责人 | 类型 | 关键词 [重置] [查询] │
├──────────────────────────────────────────────────┤
│ ListPanel(列表区) │
│ 共 N 个项目 [筛选标签] [列表] [卡片] │
│ ┌────────────────────────────────────────┐ │
│ │ ProjectListView / ProjectCardView │ │
│ │ (列表视图 / 卡片视图,按 viewMode 切换)│ │
│ └────────────────────────────────────────┘ │
│ 上一页 下一页 跳至 __ 页 Go │
├──────────────────────────────────────────────────┤
│ 弹窗区(按需弹出) │
│ ├─ ProjectCreateDialog 新增项目 │
│ ├─ ProjectEditDialog 编辑项目 │
│ ├─ ProjectMemberDrawer 成员管理 │
│ └─ ProjectStatusDialog 状态变更(临时) │
└──────────────────────────────────────────────────┘
功能截图:

1.2 数据查询链路
「我的项目」只展示当前用户参与的项目------通过项目成员关联表过滤,而非展示全部项目:
sql
我的项目查询链路
前端页面
│
├─ 加载条件:状态、负责人、类型、关键词
│
├─ 调用「我的项目分页」接口
│ └─ 传入:当前用户ID + 筛选条件 + 分页参数
│
后端处理
│
├─ 联查项目表 + 项目成员表
│ ├─ INNER JOIN 项目成员表 ON 用户ID = 当前用户
│ ├─ LEFT JOIN 部门表
│ ├─ LEFT JOIN 项目成员表(负责人) ON 身份 = PM
│ └─ LEFT JOIN 用户表(负责人姓名)
│
├─ 返回字段包含:
│ ├─ 项目基本信息(名称、编码、状态、模式、描述)
│ ├─ 我的身份(在该项目中是负责人/管理员/开发/测试...)
│ ├─ 是否置顶(pinned,成员维度)
│ ├─ 负责人姓名
│ ├─ 部门名称
│ └─ 成员总数(子查询统计)
│
├─ 排序:置顶优先 → 更新时间倒序
│
└─ 后置填充:成员头像摘要(前 3 名)
├─ 批量查询当前页所有项目的成员摘要
├─ 按项目 ID 分组
└─ 每个项目取前 3 名成员的头像和姓名
置顶排序的关键设计: 排序条件为 置顶 DESC, 更新时间 DESC------置顶的项目永远排在最前面,置顶内部再按更新时间排序。这个排序在后端 SQL 中完成,前端不需要额外处理。
功能截图:

1.3 列表视图与卡片视图
页面支持两种视图切换,共用同一份数据源,仅展示方式不同:
| 维度 | 列表视图 | 卡片视图 |
|---|---|---|
| 展示密度 | 高,表格行展示 | 低,卡片网格展示 |
| 核心信息 | 项目名称、编码、状态、模式、负责人、成员数、起止日期 | 项目名称、状态、模式、负责人、成员头像堆、进度 |
| 适用场景 | 快速浏览、批量对比 | 视觉化管理、关注重点 |
| 操作入口 | 行内操作按钮(置顶、状态、编辑、成员) | 卡片悬浮操作按钮 |
两种视图通过 viewMode 状态切换,事件统一冒泡到父组件处理,避免逻辑重复。
二、新增项目
2.1 创建流程
markdown
新增项目流程
用户点击「新增项目」
│
├─ 弹窗打开
│ ├─ 重置表单为默认值
│ └─ 头部展示静态文案:「编号将在创建时自动生成(PRJ-yyyy-xxx)」
│ (前端不预览编码,编码在提交后后端落库时生成)
│
├─ 填写表单
│ ├─ 项目名称(必填)
│ ├─ 项目类型(瀑布 / 敏捷,卡片选择)
│ ├─ 项目负责人(只读,展示当前用户名)
│ ├─ 计划开始/结束日期(必填,结束须晚于开始)
│ ├─ 项目成员(多选,排除创建人)
│ ├─ 项目简介(选填)
│ └─ 项目标识色(8 色预设选择)
│
├─ 提交 → 后端创建项目
│ ├─ 校验项目名称非空
│ ├─ 校验计划结束日期晚于开始日期
│ ├─ 构建项目实体(编码暂不设置)
│ ├─ 加锁生成项目编码(ReentrantLock 串行化)
│ │ └─ 生成后立即落库,锁内完成「查最大编码 → +1 → 格式化 → 保存」
│ ├─ 默认状态:draft(草稿)
│ ├─ 组装成员列表:
│ │ ├─ 移除请求中的受保护身份(负责人/管理员)
│ │ ├─ 移除请求中的创建人(防重复)
│ │ ├─ 强制插入创建人 → 身份 = PM
│ │ └─ 其余成员 → 默认身份 = 研发工程师
│ ├─ 保存项目实体(含编码)
│ └─ 逐条插入成员关联
│
└─ 成功 → 关闭弹窗 → 刷新列表
功能截图:

2.2 项目编码自动生成
项目编码遵循 PRJ-年份-序号 格式,全局唯一,自动生成,用户不可手动指定。编码在提交创建请求后、后端落库前生成,前端不预览。
ini
编码生成时机与逻辑
时机:提交创建请求 → 后端构建项目实体(不含编码)→ 落库前生成编码
生成步骤(锁内执行):
1. 取当前年份 → prefix = "PRJ-2026-"
2. 查询数据库中所有以 prefix 开头的编码,取最大值
├─ 如最大值为 PRJ-2026-008 → next = 009
└─ 无记录 → next = 001
3. 格式化为 PRJ-2026-009
4. 写入项目实体的 projectCode 字段
5. 保存到数据库
6. 释放锁
并发控制:
└─ 使用 ReentrantLock 串行化
├─ 同一 JVM 内,同一时刻只有一个线程能执行编码生成
├─ 线程 A 查到最大值 008 → 生成 009 → 落库 → 释放锁
└─ 线程 B 获得锁 → 查到最大值 009 → 生成 010 → 落库 → 释放锁
为什么用锁而不是冲突重试? 查询最大值和落库之间存在时间窗口,如果两个线程同时查到最大值 008,都会生成 009,落库时一个成功一个失败。用 ReentrantLock 在生成+落库这段临界区内串行化,从源头避免冲突,比失败后重试更可靠。
为什么不在前端生成编码? 编码是全局唯一的,前端无法感知其他用户并发创建的项目。后端在落库时生成,确保编码与数据同时持久化,不存在「编码分配了但项目没创建成功」的空洞。
2.3 创建人强制绑定负责人
项目创建时,后端会对成员列表做「清洗」处理:
markdown
成员列表清洗逻辑
原始请求成员列表
│
├─ 移除所有受保护身份(负责人 / 管理员)
│ └─ 这些身份只能通过后端逻辑赋予,前端不可指定
│
├─ 移除创建人(如果前端误传了)
│ └─ 防止重复
│
├─ 在列表头部插入创建人
│ └─ 身份强制设为 PM(项目负责人)
│
└─ 其余成员
└─ 身份默认为「研发工程师」
这个设计确保了一个安全不变量:项目的创建者永远是该项目的负责人,前端无法绕过。
三、编辑项目
3.1 编辑权限校验
只有项目的负责人或管理员可以编辑项目基本信息。编辑前,后端会校验当前用户的身份:
markdown
编辑权限校验流程
用户点击「编辑」
│
├─ 前端按钮可见性:仅 myIdentity 为 PM 或 admin 时展示
│
└─ 后端二次校验(requireManagerIdentity)
├─ 查询当前用户在该项目中的成员记录
├─ 身份为 PM 或 admin → 通过
└─ 其他身份 → 抛出异常「仅项目负责人或管理员可执行此操作」
3.2 字段级更新
编辑项目采用字段级更新策略------只更新请求中非空的字段,不覆盖未传入的字段:
markdown
字段级更新逻辑
可编辑字段:
├─ 项目名称(trim 后更新)
├─ 项目类型(瀑布 / 敏捷)
├─ 项目描述
├─ 项目标识色
├─ 计划开始日期
└─ 计划结束日期
不可编辑字段:
├─ 项目编码(创建后不可变)
├─ 项目负责人(不可在此变更,需在成员管理中调整)
└─ 项目状态(通过状态变更接口操作)
校验:
└─ 计划结束日期必须晚于计划开始日期
└─ 如果只改了一个日期,取另一个的现有值做比较
功能截图:

3.3 操作日志 Diff
编辑项目时,Controller 会在更新前后分别落库快照,供异步 Diff 计算字段变更:
java
操作日志 Diff 流程
Controller 层
│
├─ 更新前:查询项目快照 → 存入 LogRecordContext
├─ 执行更新
└─ 更新后:查询项目快照 → 存入 LogRecordContext
异步 Diff 计算
│
├─ 对比前快照 vs 后快照
├─ 项目实体字段标注了 @DiffLogField 注解
│ └─ 自动识别:项目名称、项目编码、项目模式、项目状态等
├─ 字典字段标注了 @Dict 注解
│ └─ 自动翻译:waterfall → 瀑布,draft → 项目草稿
└─ 生成 Diff 列表 → 落入操作日志
四、置顶
4.1 置顶的设计思路
置顶是成员维度的个性化设置 ------同一个项目,张三置顶了,李四不会受影响。置顶状态存储在项目成员关联表的 pinned 字段中。
ini
置顶数据模型
项目成员关联表
├─ projectId 项目ID
├─ userId 用户ID
├─ identity 身份
└─ pinned 是否置顶(布尔,按用户维度)
张三 → 项目A → pinned = true
李四 → 项目A → pinned = false
→ 项目A 在张三的列表中排最前,在李四的列表中按正常排序
4.2 置顶操作流程
arduino
置顶切换流程
用户点击置顶/取消置顶按钮
│
├─ 二次确认(ElMessageBox)
│ ├─ 置顶:确认要置顶「项目名称」?
│ └─ 取消:确认取消置顶「项目名称」?
│
├─ 调用「切换置顶」接口
│ └─ 后端 togglePin(projectId, userId)
│ ├─ 查询当前用户在该项目中的成员记录
│ ├─ 校验:必须是项目成员
│ ├─ 翻转 pinned 字段(true → false / false → true)
│ └─ 更新记录
│
└─ 成功 → 提示 → 刷新列表(置顶项自动排到最前)
置顶权限设计: 任何项目成员都可以置顶/取消置顶自己的项目------这不是管理操作,而是个人偏好。因此 togglePin 不需要 requireManagerIdentity 校验,只需要确认当前用户是项目成员即可。
功能截图:

五、成员管理
5.1 项目成员身份体系
项目成员的身份分为两个层级:受保护身份 (负责人、管理员)和普通身份(研发、测试等)。不同操作对每种身份的限制不同:
| 身份 \ 操作 | 新增时指定 | 修改身份 | 移除成员 | 被置顶 |
|---|---|---|---|---|
| 负责人(PM) | ✗ | ✗ | ✗ | ✓ |
| 管理员(admin) | 仅负责人可指定 | 仅负责人可修改 | 仅负责人可移除 | ✓ |
| 研发工程师 | ✓ | ✓ | ✓ | ✓ |
| 测试工程师 | ✓ | ✓ | ✓ | ✓ |
| 项目经理 | ✓ | ✓ | ✓ | ✓ |
| 产品经理 | ✓ | ✓ | ✓ | ✓ |
| 安全工程师 | ✓ | ✓ | ✓ | ✓ |
| 运维工程师 | ✓ | ✓ | ✓ | ✓ |
说明:
- 「✗」表示该操作不允许作用于此身份的成员
- 「✓」表示负责人或管理员均可操作
- 「仅负责人可xxx」表示需要负责人(PM)身份才能执行,管理员无权
- 负责人(PM)在创建项目时自动绑定,不可通过任何操作新增/修改/移除
- 新增项目时选择成员,入组后默认为「研发工程师」,后续可在成员管理中调整
- 任何成员(含普通身份)都可以置顶/取消置顶自己的项目,无需管理权限
5.2 成员管理操作流程
scss
成员管理操作流程
打开成员管理抽屉
│
├─ 加载成员列表(联查用户姓名、部门名称、部门聚合)
│ └─ 多部门聚合展示:STRING_AGG(部门名, '、')
│
├─ 成员统计:按身份分组计数
│ └─ 如:负责人 1、研发 5、测试 2
│
├─ 添加成员
│ ├─ 下拉选择用户(排除已是成员的)
│ ├─ 选择身份(下拉,不含负责人/管理员)
│ ├─ 后端校验:
│ │ ├─ 操作者须为负责人/管理员
│ │ ├─ 目标用户须属于当前企业(企业隔离校验)
│ │ ├─ 不可指定为负责人
│ │ ├─ 管理员仅负责人可指定
│ │ └─ 不可重复添加已有成员
│ └─ 成功 → 刷新成员列表
│
├─ 修改成员身份
│ ├─ 受保护身份(负责人/管理员)的成员不可修改
│ ├─ 新身份不可与当前身份相同
│ ├─ 后端校验:
│ │ ├─ 操作者须为负责人/管理员
│ │ ├─ 不可修改负责人身份
│ │ ├─ 管理员身份仅负责人可修改
│ │ └─ 不可指定为负责人
│ └─ 成功 → 刷新成员列表
│
└─ 移除成员
├─ 受保护身份不可移除
├─ 管理员仅负责人可移除
├─ 后端校验:
│ ├─ 操作者须为负责人/管理员
│ ├─ 不可移除负责人
│ └─ 管理员仅负责人可移除
└─ 成功 → 刷新成员列表
功能截图:

5.3 核心实现伪代码 --- 身份校验
scss
// 校验当前用户为负责人/管理员,并返回其身份编码
function requireManagerIdentity(projectId) {
userId = 获取当前用户ID()
if (userId 为空) 抛出异常("用户未登录")
member = 查询项目成员(projectId, userId)
if (member 为空 或 非管理身份(member.identity)) {
抛出异常("仅项目负责人或管理员可执行此操作")
}
return member.identity
}
// 校验目标身份是否允许由当前操作者指定
function assertAssignableIdentity(操作者身份, 目标身份) {
if (目标身份 == 负责人) {
抛出异常("不可指定为项目负责人")
}
if (目标身份 == 管理员 且 操作者身份 != 负责人) {
抛出异常("仅项目负责人可指定管理员")
}
}
5.4 核心实现伪代码 --- 添加成员
scss
// 添加项目成员
@Transactional
function addMember(projectId, 成员DTO) {
// 1. 校验操作者权限
操作者身份 = requireManagerIdentity(projectId)
// 2. 校验是否已存在
existing = 查询成员记录(projectId, 成员DTO.userId)
if (existing != null) 抛出异常("该用户已是项目成员")
// 3. 校验身份可分配性
assertAssignableIdentity(操作者身份, 成员DTO.identity)
// 4. 写入成员记录
member = 新建成员记录()
member.projectId = projectId
member.userId = 成员DTO.userId
member.identity = 成员DTO.identity 或 默认研发工程师
member.pinned = false
插入成员表(member)
}
六、前端表格组件抽取
6.1 为什么要抽取
在「我的项目」之前,平台的列表页(成员管理、操作日志、角色管理等)都各自实现了一套「页面头部 + 筛选区 + 列表容器 + 分页」的结构。这些结构的 HTML 和 CSS 高度相似,但每写一个页面就要复制一遍,导致:
- 样式不一致:间距、圆角、字号细微偏差
- 逻辑重复:分页跳转、筛选标签清除、loading 状态管理
- 维护困难:改一个通用样式要逐页排查
因此,抽取了四个通用组件,统一所有列表页的骨架:
xml
通用表格组件体系
components/table/
├─ PageHeader.vue 页面头部(标题 + 描述 + 操作按钮插槽)
├─ FilterCard.vue 筛选区(条件插槽 + 查询/重置按钮)
├─ ListPanel.vue 列表容器(计数 + 筛选标签 + 工具栏插槽 + 内容插槽 + 空状态插槽 + 分页)
└─ TextPagination.vue 文本分页(总数/页码/上一页/下一页/跳转)
组装方式(以「我的项目」为例)
├─ <PageHeader title="我的项目" description="...">
│ <template #actions> 新增项目按钮 </template>
│
├─ <FilterCard @search @reset>
│ <div class="fitem"> 状态筛选 </div>
│ <div class="fitem"> 负责人筛选 </div>
│ <div class="fitem fitem-grow"> 关键词 </div>
│
└─ <ListPanel v-model:page-num :total :loading :filter-tags>
<template #toolbar> 视图切换按钮 </template>
<ProjectListView /> 或 <ProjectCardView />
<template #empty> 空状态 </template>
6.2 PageHeader:页面头部
PageHeader 是最简单的组件,只负责标题、描述和右侧操作区的布局:
markdown
PageHeader 组件设计
Props:
├─ title 页面标题(必填)
└─ description 副标题描述(可选)
Slots:
└─ #actions 右侧操作区(如新增按钮)
布局:
左侧:标题(22px 加粗)+ 描述(13px 浅色)
右侧:操作按钮区(自动右对齐,flex-wrap)
响应式:窄屏时变为上下排列
6.3 FilterCard:筛选区
FilterCard 提供筛选条件的容器和统一的查询/重置按钮,筛选项通过默认插槽注入:
sql
FilterCard 组件设计
Props:
├─ searchText 查询按钮文案(默认"查询")
└─ resetText 重置按钮文案(默认"重置")
Events:
├─ @search 查询按钮点击
└─ @reset 重置按钮点击
Slots:
├─ 默认插槽 筛选项(约定使用 .fitem / .fitem-grow / .fitem-date 类名)
└─ #extra 额外操作按钮(插在查询/重置之前)
约定的 CSS 类名:
.fitem 固定宽度筛选项(180px)
.fitem-grow 弹性宽度筛选项(关键词输入框,200-360px)
.fitem-date 日期范围筛选项(280px)
.flabel 筛选项标签(12px 浅色)
关键设计: FilterCard 不关心筛选项的内容------它只提供容器、布局和按钮。每个页面通过插槽传入自己的筛选项(下拉框、输入框、日期选择器等),用约定的 CSS 类名控制宽度。这样既统一了外观,又保留了灵活性。
6.4 ListPanel:列表容器
ListPanel 是最核心的组件,它整合了计数、筛选标签、工具栏、内容区、空状态和分页:
ruby
ListPanel 组件设计
Props:
├─ total 总条数
├─ loading 加载中状态
├─ countLabel 数量单位文案(如"个项目"、"人"、"条")
├─ filterTags 当前筛选标签数组 [{ key, label }]
├─ pageNum 当前页码(v-model:page-num 双向绑定)
└─ pageSize 每页条数
Events:
├─ @update:page-num 页码变更(v-model 同步)
├─ @page-change 页码变更后通知父级重新请求
└─ @clear-filter 关闭单个筛选标签
Slots:
├─ #toolbar 工具栏(如视图切换、批量操作按钮)
├─ 默认插槽 列表内容(表格/卡片)
└─ #empty 空状态(有筛选条件时展示"未找到",无筛选时展示"暂无数据")
内部结构:
├─ Head:共 N 个项目 + 筛选标签 + 工具栏
├─ Body:loading 遮罩 + 内容插槽 / 空状态插槽
└─ Foot:TextPagination 分页
筛选标签的设计意图: 当用户设置了筛选条件后,ListPanel 会在头部展示标签(如 状态:进行中、负责人:张明),每个标签可独立关闭。关闭标签时触发 clear-filter 事件,父组件清除对应的筛选条件并重新查询。这比在筛选区逐个清除更高效。
6.5 TextPagination:文本分页
TextPagination 替代了 Element Plus 默认的分页组件,使用更紧凑的文本风格:
ini
TextPagination 组件设计
Props:
├─ total 总条数
├─ pageNum 当前页码
└─ pageSize 每页条数(固定,不可改)
Events:
├─ @update:pageNum 页码同步
└─ @change 页码变更后通知父级重新请求
展示内容:
左侧:共 N 条 · 每页 N 条 · 第 X / Y 页
右侧:[上一页] [下一页] 跳至 [输入框] 页 [Go]
防呆设计:
├─ 输入框只接受数字
├─ 越界输入失焦时自动回退到当前页
├─ Go 按钮:输入与当前页相同时禁用
└─ 上一页/下一页:边界自动禁用
6.6 组件抽取的价值
markdown
抽取前 vs 抽取后
抽取前(每个列表页)
├─ HTML:~80 行(头部 + 筛选 + 列表容器 + 分页)
├─ CSS:~60 行(间距、圆角、响应式)
└─ 逻辑:~30 行(分页跳转、loading、筛选标签)
→ 总计 ~170 行重复模板
抽取后(每个列表页)
├─ HTML:~30 行(组装组件 + 传入插槽内容)
├─ CSS:~0 行(组件内部已处理)
└─ 逻辑:~10 行(search/reset/page-change 回调)
→ 总计 ~40 行组装代码
收益:
├─ 开发效率提升 ~4 倍
├─ 样式全局一致(改组件 = 改所有页面)
└─ 新页面只需关注业务逻辑,不操心骨架布局
七、总结
7.1 当前已实现
- 我的项目:列表/卡片双视图、分页查询、筛选(状态/负责人/类型/关键词)、置顶
- 新增项目:编码自动生成、创建人强制绑定负责人、成员批量入组、标识色选择
- 编辑项目:字段级更新、权限校验、操作日志 Diff
- 成员管理:添加/移除/修改身份、受保护身份校验、身份统计
- 通用组件:PageHeader、FilterCard、ListPanel、TextPagination 四件套
7.2 后续计划
markdown
项目模块后续计划
Phase 1:状态工作流
├─ 对接工作流引擎,替换临时状态变更
├─ 状态流转可视化(状态机图)
└─ 审批节点配置
Phase 2:项目详情页
├─ 需求列表、工作项列表、缺陷列表
├─ 项目里程碑管理
└─ 项目看板(敏捷模式)
Phase 3:项目统计
├─ 进度自动计算(基于工作项完成率)
├─ 燃尽图(敏捷模式)
└─ 质量度量(缺陷率、延期率)