DevOps平台 — 第七篇:我的项目与表格组件抽取

0 阅读17分钟

项目是研发管理的最小执行单元——需求挂在项目下,工作项挂在项目下,缺陷挂在项目下。本篇记录「我的项目」页面的完整实现:列表/卡片双视图、新增项目(编码自动生成 + 创建人强制绑定为负责人)、编辑项目(权限校验 + 字段级更新)、置顶(成员维度个性化)、成员管理(身份分级 + 受保护身份校验),以及前端通用表格组件的抽取逻辑——PageHeader、FilterCard、ListPanel 三件套如何让列表页从「每次复制 200 行模板」变成「几十行组装」。

一、我的项目:列表与卡片双视图

1.1 页面整体结构

「我的项目」页面由四个区域组成:页面头部、筛选区、列表/卡片区、弹窗区。每个区域都使用了抽取后的通用组件:

我的项目页面结构

  ┌──────────────────────────────────────────────────┐
  │  PageHeader                          │
  │  「我的项目」 + 描述 + [新增项目] 按钮              │
  ├──────────────────────────────────────────────────┤
  │  FilterCard(筛选区)                               │
  │  项目状态 | 负责人 | 类型 | 关键词  [重置] [查询]   │
  ├──────────────────────────────────────────────────┤
  │  ListPanel(列表区)                               │
  │  共 N 个项目  [筛选标签]            [列表] [卡片]   │
  │  ┌────────────────────────────────────────┐      │
  │  │  ProjectListView / ProjectCardView      │      │
  │  │  (列表视图 / 卡片视图,按 viewMode 切换)│      │
  │  └────────────────────────────────────────┘      │
  │  上一页  下一页  跳至 __ 页  Go                    │
  ├──────────────────────────────────────────────────┤
  │  弹窗区(按需弹出)                                 │
  │  ├─ ProjectCreateDialog   新增项目                 │
  │  ├─ ProjectEditDialog     编辑项目                 │
  │  ├─ ProjectMemberDrawer   成员管理                 │
  │  └─ ProjectStatusDialog   状态变更(临时)          │
  └──────────────────────────────────────────────────┘

功能截图:

image.png

1.2 数据查询链路

「我的项目」只展示当前用户参与的项目——通过项目成员关联表过滤,而非展示全部项目:

我的项目查询链路

  前端页面
    │
    ├─ 加载条件:状态、负责人、类型、关键词
    │
    ├─ 调用「我的项目分页」接口
    │    └─ 传入:当前用户ID + 筛选条件 + 分页参数
    │
  后端处理
    │
    ├─ 联查项目表 + 项目成员表
    │    ├─ INNER JOIN 项目成员表 ON 用户ID = 当前用户
    │    ├─ LEFT JOIN 部门表
    │    ├─ LEFT JOIN 项目成员表(负责人) ON 身份 = PM
    │    └─ LEFT JOIN 用户表(负责人姓名)
    │
    ├─ 返回字段包含:
    │    ├─ 项目基本信息(名称、编码、状态、模式、描述)
    │    ├─ 我的身份(在该项目中是负责人/管理员/开发/测试...)
    │    ├─ 是否置顶(pinned,成员维度)
    │    ├─ 负责人姓名
    │    ├─ 部门名称
    │    └─ 成员总数(子查询统计)
    │
    ├─ 排序:置顶优先 → 更新时间倒序
    │
    └─ 后置填充:成员头像摘要(前 3 名)
         ├─ 批量查询当前页所有项目的成员摘要
         ├─ 按项目 ID 分组
         └─ 每个项目取前 3 名成员的头像和姓名

置顶排序的关键设计: 排序条件为 置顶 DESC, 更新时间 DESC——置顶的项目永远排在最前面,置顶内部再按更新时间排序。这个排序在后端 SQL 中完成,前端不需要额外处理。

功能截图:

image.png

1.3 列表视图与卡片视图

页面支持两种视图切换,共用同一份数据源,仅展示方式不同:

维度列表视图卡片视图
展示密度高,表格行展示低,卡片网格展示
核心信息项目名称、编码、状态、模式、负责人、成员数、起止日期项目名称、状态、模式、负责人、成员头像堆、进度
适用场景快速浏览、批量对比视觉化管理、关注重点
操作入口行内操作按钮(置顶、状态、编辑、成员)卡片悬浮操作按钮

两种视图通过 viewMode 状态切换,事件统一冒泡到父组件处理,避免逻辑重复。

二、新增项目

2.1 创建流程

新增项目流程

  用户点击「新增项目」
    │
    ├─ 弹窗打开
    │    ├─ 重置表单为默认值
    │    └─ 头部展示静态文案:「编号将在创建时自动生成(PRJ-yyyy-xxx)」
    │       (前端不预览编码,编码在提交后后端落库时生成)
    │
    ├─ 填写表单
    │    ├─ 项目名称(必填)
    │    ├─ 项目类型(瀑布 / 敏捷,卡片选择)
    │    ├─ 项目负责人(只读,展示当前用户名)
    │    ├─ 计划开始/结束日期(必填,结束须晚于开始)
    │    ├─ 项目成员(多选,排除创建人)
    │    ├─ 项目简介(选填)
    │    └─ 项目标识色(8 色预设选择)
    │
    ├─ 提交 → 后端创建项目
    │    ├─ 校验项目名称非空
    │    ├─ 校验计划结束日期晚于开始日期
    │    ├─ 构建项目实体(编码暂不设置)
    │    ├─ 加锁生成项目编码(ReentrantLock 串行化)
    │    │    └─ 生成后立即落库,锁内完成「查最大编码 → +1 → 格式化 → 保存」
    │    ├─ 默认状态:draft(草稿)
    │    ├─ 组装成员列表:
    │    │    ├─ 移除请求中的受保护身份(负责人/管理员)
    │    │    ├─ 移除请求中的创建人(防重复)
    │    │    ├─ 强制插入创建人 → 身份 = PM
    │    │    └─ 其余成员 → 默认身份 = 研发工程师
    │    ├─ 保存项目实体(含编码)
    │    └─ 逐条插入成员关联
    │
    └─ 成功 → 关闭弹窗 → 刷新列表

功能截图:

image.png

2.2 项目编码自动生成

项目编码遵循 PRJ-年份-序号 格式,全局唯一,自动生成,用户不可手动指定。编码在提交创建请求后、后端落库前生成,前端不预览。

编码生成时机与逻辑

  时机:提交创建请求 → 后端构建项目实体(不含编码)→ 落库前生成编码

  生成步骤(锁内执行):
    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 创建人强制绑定负责人

项目创建时,后端会对成员列表做「清洗」处理:

成员列表清洗逻辑

  原始请求成员列表
    │
    ├─ 移除所有受保护身份(负责人 / 管理员)
    │    └─ 这些身份只能通过后端逻辑赋予,前端不可指定
    │
    ├─ 移除创建人(如果前端误传了)
    │    └─ 防止重复
    │
    ├─ 在列表头部插入创建人
    │    └─ 身份强制设为 PM(项目负责人)
    │
    └─ 其余成员
         └─ 身份默认为「研发工程师」

这个设计确保了一个安全不变量:项目的创建者永远是该项目的负责人,前端无法绕过。

三、编辑项目

3.1 编辑权限校验

只有项目的负责人或管理员可以编辑项目基本信息。编辑前,后端会校验当前用户的身份:

编辑权限校验流程

  用户点击「编辑」
    │
    ├─ 前端按钮可见性:仅 myIdentity 为 PM 或 admin 时展示
    │
    └─ 后端二次校验(requireManagerIdentity)
         ├─ 查询当前用户在该项目中的成员记录
         ├─ 身份为 PM 或 admin → 通过
         └─ 其他身份 → 抛出异常「仅项目负责人或管理员可执行此操作」

3.2 字段级更新

编辑项目采用字段级更新策略——只更新请求中非空的字段,不覆盖未传入的字段:

字段级更新逻辑

  可编辑字段:
    ├─ 项目名称(trim 后更新)
    ├─ 项目类型(瀑布 / 敏捷)
    ├─ 项目描述
    ├─ 项目标识色
    ├─ 计划开始日期
    └─ 计划结束日期

  不可编辑字段:
    ├─ 项目编码(创建后不可变)
    ├─ 项目负责人(不可在此变更,需在成员管理中调整)
    └─ 项目状态(通过状态变更接口操作)

  校验:
    └─ 计划结束日期必须晚于计划开始日期
         └─ 如果只改了一个日期,取另一个的现有值做比较

功能截图:

image.png

3.3 操作日志 Diff

编辑项目时,Controller 会在更新前后分别落库快照,供异步 Diff 计算字段变更:

操作日志 Diff 流程

  Controller 层
    │
    ├─ 更新前:查询项目快照 → 存入 LogRecordContext
    ├─ 执行更新
    └─ 更新后:查询项目快照 → 存入 LogRecordContext

  异步 Diff 计算
    │
    ├─ 对比前快照 vs 后快照
    ├─ 项目实体字段标注了 @DiffLogField 注解
    │    └─ 自动识别:项目名称、项目编码、项目模式、项目状态等
    ├─ 字典字段标注了 @Dict 注解
    │    └─ 自动翻译:waterfall → 瀑布,draft → 项目草稿
    └─ 生成 Diff 列表 → 落入操作日志

四、置顶

4.1 置顶的设计思路

置顶是成员维度的个性化设置——同一个项目,张三置顶了,李四不会受影响。置顶状态存储在项目成员关联表的 pinned 字段中。

置顶数据模型

  项目成员关联表
    ├─ projectId     项目ID
    ├─ userId        用户ID
    ├─ identity      身份
    └─ pinned        是否置顶(布尔,按用户维度)

  张三 → 项目A → pinned = true
  李四 → 项目A → pinned = false
  → 项目A 在张三的列表中排最前,在李四的列表中按正常排序

4.2 置顶操作流程

置顶切换流程

  用户点击置顶/取消置顶按钮
    │
    ├─ 二次确认(ElMessageBox)
    │    ├─ 置顶:确认要置顶「项目名称」?
    │    └─ 取消:确认取消置顶「项目名称」?
    │
    ├─ 调用「切换置顶」接口
    │    └─ 后端 togglePin(projectId, userId)
    │         ├─ 查询当前用户在该项目中的成员记录
    │         ├─ 校验:必须是项目成员
    │         ├─ 翻转 pinned 字段(truefalse / falsetrue)
    │         └─ 更新记录
    │
    └─ 成功 → 提示 → 刷新列表(置顶项自动排到最前)

置顶权限设计: 任何项目成员都可以置顶/取消置顶自己的项目——这不是管理操作,而是个人偏好。因此 togglePin 不需要 requireManagerIdentity 校验,只需要确认当前用户是项目成员即可。

功能截图:

image.png

五、成员管理

5.1 项目成员身份体系

项目成员的身份分为两个层级:受保护身份(负责人、管理员)和普通身份(研发、测试等)。不同操作对每种身份的限制不同:

身份 \ 操作新增时指定修改身份移除成员被置顶
负责人(PM)
管理员(admin)仅负责人可指定仅负责人可修改仅负责人可移除
研发工程师
测试工程师
项目经理
产品经理
安全工程师
运维工程师

说明:

  • 「✗」表示该操作不允许作用于此身份的成员
  • 「✓」表示负责人或管理员均可操作
  • 「仅负责人可xxx」表示需要负责人(PM)身份才能执行,管理员无权
  • 负责人(PM)在创建项目时自动绑定,不可通过任何操作新增/修改/移除
  • 新增项目时选择成员,入组后默认为「研发工程师」,后续可在成员管理中调整
  • 任何成员(含普通身份)都可以置顶/取消置顶自己的项目,无需管理权限

5.2 成员管理操作流程

成员管理操作流程

  打开成员管理抽屉
    │
    ├─ 加载成员列表(联查用户姓名、部门名称、部门聚合)
    │    └─ 多部门聚合展示:STRING_AGG(部门名, '、')
    │
    ├─ 成员统计:按身份分组计数
    │    └─ 如:负责人 1、研发 5、测试 2
    │
    ├─ 添加成员
    │    ├─ 下拉选择用户(排除已是成员的)
    │    ├─ 选择身份(下拉,不含负责人/管理员)
    │    ├─ 后端校验:
    │    │    ├─ 操作者须为负责人/管理员
    │    │    ├─ 目标用户须属于当前企业(企业隔离校验)
    │    │    ├─ 不可指定为负责人
    │    │    ├─ 管理员仅负责人可指定
    │    │    └─ 不可重复添加已有成员
    │    └─ 成功 → 刷新成员列表
    │
    ├─ 修改成员身份
    │    ├─ 受保护身份(负责人/管理员)的成员不可修改
    │    ├─ 新身份不可与当前身份相同
    │    ├─ 后端校验:
    │    │    ├─ 操作者须为负责人/管理员
    │    │    ├─ 不可修改负责人身份
    │    │    ├─ 管理员身份仅负责人可修改
    │    │    └─ 不可指定为负责人
    │    └─ 成功 → 刷新成员列表
    │
    └─ 移除成员
         ├─ 受保护身份不可移除
         ├─ 管理员仅负责人可移除
         ├─ 后端校验:
         │    ├─ 操作者须为负责人/管理员
         │    ├─ 不可移除负责人
         │    └─ 管理员仅负责人可移除
         └─ 成功 → 刷新成员列表

功能截图:

image.png

5.3 核心实现伪代码 — 身份校验

// 校验当前用户为负责人/管理员,并返回其身份编码
function requireManagerIdentity(projectId) {
    userId = 获取当前用户ID()
    if (userId 为空) 抛出异常("用户未登录")

    member = 查询项目成员(projectId, userId)
    if (member 为空 或 非管理身份(member.identity)) {
        抛出异常("仅项目负责人或管理员可执行此操作")
    }
    return member.identity
}

// 校验目标身份是否允许由当前操作者指定
function assertAssignableIdentity(操作者身份, 目标身份) {
    if (目标身份 == 负责人) {
        抛出异常("不可指定为项目负责人")
    }
    if (目标身份 == 管理员 且 操作者身份 != 负责人) {
        抛出异常("仅项目负责人可指定管理员")
    }
}

5.4 核心实现伪代码 — 添加成员

// 添加项目成员
@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 状态管理
  • 维护困难:改一个通用样式要逐页排查

因此,抽取了四个通用组件,统一所有列表页的骨架:

通用表格组件体系

  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 是最简单的组件,只负责标题、描述和右侧操作区的布局:

PageHeader 组件设计

  Props:
    ├─ title       页面标题(必填)
    └─ description 副标题描述(可选)

  Slots:
    └─ #actions    右侧操作区(如新增按钮)

  布局:
    左侧:标题(22px 加粗)+ 描述(13px 浅色)
    右侧:操作按钮区(自动右对齐,flex-wrap)
    响应式:窄屏时变为上下排列

6.3 FilterCard:筛选区

FilterCard 提供筛选条件的容器和统一的查询/重置按钮,筛选项通过默认插槽注入:

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 是最核心的组件,它整合了计数、筛选标签、工具栏、内容区、空状态和分页:

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 遮罩 + 内容插槽 / 空状态插槽
    └─ FootTextPagination 分页

筛选标签的设计意图: 当用户设置了筛选条件后,ListPanel 会在头部展示标签(如 状态:进行中负责人:张明),每个标签可独立关闭。关闭标签时触发 clear-filter 事件,父组件清除对应的筛选条件并重新查询。这比在筛选区逐个清除更高效。

6.5 TextPagination:文本分页

TextPagination 替代了 Element Plus 默认的分页组件,使用更紧凑的文本风格:

TextPagination 组件设计

  Props:
    ├─ total     总条数
    ├─ pageNum   当前页码
    └─ pageSize  每页条数(固定,不可改)

  Events:
    ├─ @update:pageNum   页码同步
    └─ @change           页码变更后通知父级重新请求

  展示内容:
    左侧:共 N 条 · 每页 N 条 · 第 X / Y 页
    右侧:[上一页] [下一页] 跳至 [输入框][Go]

  防呆设计:
    ├─ 输入框只接受数字
    ├─ 越界输入失焦时自动回退到当前页
    ├─ Go 按钮:输入与当前页相同时禁用
    └─ 上一页/下一页:边界自动禁用

6.6 组件抽取的价值

抽取前 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 后续计划

项目模块后续计划

  Phase 1:状态工作流
    ├─ 对接工作流引擎,替换临时状态变更
    ├─ 状态流转可视化(状态机图)
    └─ 审批节点配置

  Phase 2:项目详情页
    ├─ 需求列表、工作项列表、缺陷列表
    ├─ 项目里程碑管理
    └─ 项目看板(敏捷模式)

  Phase 3:项目统计
    ├─ 进度自动计算(基于工作项完成率)
    ├─ 燃尽图(敏捷模式)
    └─ 质量度量(缺陷率、延期率)