Files
ccdi/docs/plans/2026-02-27-project-status-counts-fix-design.md
wkc e17f0bf42a docs: 更新项目状态统计修复设计文档状态为已完成
- 文档状态更新为"已完成"
- 所有验收标准已勾选完成
- 功能验收:后端接口、前端显示、搜索/分页/过滤不影响统计
- 性能验收:响应时间<100ms,页面加载正常
- 代码质量:符合项目规范,添加必要注释
2026-02-28 09:53:47 +08:00

16 KiB
Raw Blame History

项目管理标签页状态统计修复设计文档

文档信息

  • 创建日期: 2026-02-27
  • 作者: Claude Code
  • 状态: 已完成

问题背景

当前问题

项目管理页面的标签页筛选功能中,各状态的项目计数不正确。具体表现为:

  • 现象: 标签页显示的数量远小于实际总数
  • 根本原因: 前端只统计当前页的数据来计算各状态的数量
  • 影响: 用户无法了解各状态的完整项目分布情况

代码位置

前端代码:

  • 页面组件: ruoyi-ui/src/views/ccdiProject/index.vue
  • API 定义: ruoyi-ui/src/api/ccdiProject.js

后端代码:

  • Controller: ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java
  • Service 接口: ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java
  • Service 实现: ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java

当前实现分析

问题代码 (index.vue:136-145):

calculateTabCounts() {
  // 注意这里需要后端API返回所有状态的数量统计
  // 目前暂时使用当前页的数据进行计算
  this.tabCounts = {
    all: this.total,
    '0': this.projectList.filter(p => p.status === '0').length,
    '1': this.projectList.filter(p => p.status === '1').length,
    '2': this.projectList.filter(p => p.status === '2').length
  }
}

问题分析:

  • projectList 只包含当前页的数据默认10条
  • 过滤计算只能得到当前页中各状态的数量
  • 用户看到的是"当前页有5个进行中的项目",而非"总共有30个进行中的项目"

需求说明

功能需求

预期行为:

  • 标签页应显示数据库中所有该状态的项目总数
  • 状态统计不受搜索条件和分页影响
  • 状态统计随列表一起刷新

非功能性需求:

  • 性能: 统计查询应快速响应(< 100ms
  • 准确性: 统计数字必须与数据库一致
  • 实时性: 每次刷新列表时同步更新统计

设计方案

整体架构

采用独立统计接口方案:

  • 后端新增 /ccdi/project/statusCounts 接口
  • 前端在加载列表时并行调用统计接口
  • 两个请求独立,数据互不影响

后端设计

1. 新增 VO 类

文件: ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectStatusCountsVO.java

package com.ruoyi.ccdi.project.domain.vo;

import lombok.Data;

/**
 * 项目状态统计VO
 */
@Data
public class CcdiProjectStatusCountsVO {
    /** 全部项目总数 */
    private Long all;
    /** 进行中项目数状态0 */
    private Long status0;
    /** 已完成项目数状态1 */
    private Long status1;
    /** 已归档项目数状态2 */
    private Long status2;
}

设计说明:

  • 使用 Long 类型支持大数据量
  • 字段命名清晰,便于前端使用
  • 不包含搜索条件,始终统计全量数据

2. Service 层

接口定义 (ICcdiProjectService.java):

/**
 * 查询各状态的项目总数(不受搜索条件影响)
 * @return 状态统计
 */
CcdiProjectStatusCountsVO getStatusCounts();

实现类 (CcdiProjectServiceImpl.java):

@Override
public CcdiProjectStatusCountsVO getStatusCounts() {
    CcdiProjectStatusCountsVO vo = new CcdiProjectStatusCountsVO();

    // 统计全部项目
    vo.setAll(ccdiProjectMapper.selectCount(null));

    // 统计各状态项目
    vo.setStatus0(ccdiProjectMapper.selectCount(
        new LambdaQueryWrapper<CcdiProject>()
            .eq(CcdiProject::getStatus, "0")
    ));
    vo.setStatus1(ccdiProjectMapper.selectCount(
        new LambdaQueryWrapper<CcdiProject>()
            .eq(CcdiProject::getStatus, "1")
    ));
    vo.setStatus2(ccdiProjectMapper.selectCount(
        new LambdaQueryWrapper<CcdiProject>()
            .eq(CcdiProject::getStatus, "2")
    ));

    return vo;
}

实现要点:

  • 使用 MyBatis Plus 的 selectCount 方法
  • 不添加任何过滤条件
  • 可以优化为一次查询返回所有统计(使用 GROUP BY

优化方案(可选):

如果性能要求高,可以改为单次查询:

@Override
public CcdiProjectStatusCountsVO getStatusCounts() {
    // 查询各状态的统计
    List<Map<String, Object>> counts = ccdiProjectMapper.countGroupByStatus();

    CcdiProjectStatusCountsVO vo = new CcdiProjectStatusCountsVO();
    vo.setAll(0L);
    vo.setStatus0(0L);
    vo.setStatus1(0L);
    vo.setStatus2(0L);

    for (Map<String, Object> item : counts) {
        String status = (String) item.get("status");
        Long count = (Long) item.get("count");

        vo.setAll(vo.getAll() + count);
        if ("0".equals(status)) {
            vo.setStatus0(count);
        } else if ("1".equals(status)) {
            vo.setStatus1(count);
        } else if ("2".equals(status)) {
            vo.setStatus2(count);
        }
    }

    return vo;
}

Mapper 方法:

List<Map<String, Object>> countGroupByStatus();

XML SQL:

<select id="countGroupByStatus" resultType="map">
    SELECT
        status,
        COUNT(*) as count
    FROM ccdi_project
    GROUP BY status
</select>

3. Controller 层

文件: CcdiProjectController.java

/**
 * 查询项目状态统计
 */
@GetMapping("/statusCounts")
@Operation(summary = "查询项目状态统计")
@PreAuthorize("@ss.hasPermi('ccdi:project:list')")
public AjaxResult getStatusCounts() {
    CcdiProjectStatusCountsVO counts = projectService.getStatusCounts();
    return AjaxResult.success(counts);
}

设计说明:

  • 不接收任何查询参数
  • 使用与列表接口相同的权限注解
  • 返回标准的 AjaxResult 格式

前端设计

1. API 层

文件: ruoyi-ui/src/api/ccdiProject.js

// 查询项目状态统计
export function getStatusCounts() {
  return request({
    url: '/ccdi/project/statusCounts',
    method: 'get'
  })
}

2. 页面组件改造

文件: ruoyi-ui/src/views/ccdiProject/index.vue

修改点 1: 导入新 API

import { listProject, getStatusCounts } from '@/api/ccdiProject'

修改点 2: 重构 getList() 方法

/** 查询项目列表 */
getList() {
  this.loading = true

  // 并行请求列表数据和状态统计
  Promise.all([
    listProject(this.queryParams),
    getStatusCounts()
  ]).then(([listResponse, countsResponse]) => {
    // 处理列表数据
    this.projectList = listResponse.rows
    this.total = listResponse.total

    // 处理状态统计
    const counts = countsResponse.data
    this.tabCounts = {
      all: counts.all,
      '0': counts.status0,
      '1': counts.status1,
      '2': counts.status2
    }

    this.loading = false
  }).catch(() => {
    this.loading = false
  })
}

修改点 3: 删除旧方法

删除 calculateTabCounts() 方法及其调用(第 136-145 行)。

设计说明:

  • 使用 Promise.all 并行请求,提高性能
  • 两个请求独立失败互不影响(可以优化错误处理)
  • 状态统计使用后端返回的准确数据

数据流设计

用户操作(打开页面/搜索/切换标签/翻页)
    ↓
前端调用 getList() 方法
    ↓
并行发送两个请求:
  ┌─────────────────────────┬────────────────────────┐
  │ listProject(queryParams)│ getStatusCounts()      │
  │ - 带搜索条件             │ - 无参数               │
  │ - 带分页参数             │ - 返回全量统计         │
  │ - 返回当前页数据         │                        │
  └─────────────────────────┴────────────────────────┘
    ↓                           ↓
后端处理                       后端处理
- 根据条件过滤                 - COUNT 查询全部
- 分页返回结果                 - 按 status 分组统计
    ↓                           ↓
前端接收响应 ←─────────────────┘
    ↓
更新状态:
- projectList: 列表数据(受搜索/分页影响)
- total: 当前筛选条件的总数
- tabCounts: 各状态的完整统计(不受搜索影响)
    ↓
页面渲染:
- 表格: 显示当前页项目
- 标签: 显示固定统计数字

关键特性:

  1. 并行请求: 两个请求同时发出,不阻塞
  2. 数据独立: 列表数据和统计数据来源不同
  3. 统计固定: 标签页数字不随搜索/分页变化
  4. 列表过滤: 表格内容根据搜索条件正确过滤

测试方案

后端测试

1. 单元测试

测试文件: CcdiProjectServiceTest.java

@Test
void testGetStatusCounts() {
    // 准备测试数据
    // 创建 3 个进行中项目
    // 创建 2 个已完成项目
    // 创建 1 个已归档项目

    // 执行测试
    CcdiProjectStatusCountsVO result = projectService.getStatusCounts();

    // 验证结果
    assertEquals(6L, result.getAll());
    assertEquals(3L, result.getStatus0());
    assertEquals(2L, result.getStatus1());
    assertEquals(1L, result.getStatus2());
}

2. 接口测试

使用 Swagger UI 测试:

  1. 访问: http://localhost:8080/swagger-ui/index.html
  2. 找到: 纪检初核项目管理 → GET /ccdi/project/statusCounts
  3. 点击 "Try it out" → "Execute"

预期响应:

{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "all": 100,
    "status0": 30,
    "status1": 50,
    "status2": 20
  }
}

测试用例:

用例编号 场景 预期结果
TC01 数据库无项目 all=0, status0=0, status1=0, status2=0
TC02 只有进行中项目 all=N, status0=N, status1=0, status2=0
TC03 混合状态项目 各状态数字之和等于 all
TC04 大数据量1000+ 查询时间 < 100ms

前端测试

1. 页面加载测试

步骤:

  1. 打开项目管理页面
  2. 观察标签页数字

预期结果:

  • 标签页显示正确的总数(例如:全部项目(100)
  • 数字不随分页变化

2. 搜索测试

步骤:

  1. 在搜索框输入项目名称
  2. 点击搜索

预期结果:

  • 列表正确过滤
  • 标签页数字保持不变(显示总数)

3. 分页测试

步骤:

  1. 切换到第 2 页

预期结果:

  • 列表切换到第 2 页
  • 标签页数字保持不变

4. 状态切换测试

步骤:

  1. 点击"进行中"标签

预期结果:

  • 列表只显示进行中的项目
  • 标签页数字保持不变(仍显示总数)

5. 网络错误测试

步骤:

  1. 模拟统计接口失败

预期结果:

  • 列表正常显示
  • 标签页显示 0 或保持上次数据
  • 不阻塞用户操作

集成测试

端到端测试流程:

  1. 准备数据: 在数据库中插入测试项目

    • 10 个进行中项目
    • 15 个已完成项目
    • 5 个已归档项目
  2. 执行测试:

    a. 打开项目管理页面
    b. 验证标签显示: 全部(30), 进行中(10), 已完成(15), 已归档(5)
    c. 搜索项目名称(匹配 5 个)
    d. 验证列表显示 5 个项目
    e. 验证标签仍显示: 全部(30), 进行中(10), 已完成(15), 已归档(5)
    f. 切换到第 2 页
    g. 验证列表切换,标签数字不变
    h. 点击"进行中"标签
    i. 验证列表只显示进行中项目
    j. 验证标签仍显示: 全部(30), 进行中(10), 已完成(15), 已归档(5)
    
  3. 预期结果: 所有验证点通过

性能考虑

后端性能

统计查询优化:

  • 使用 COUNT(*) 查询,性能较好
  • 考虑为 status 字段添加索引(如果项目数量 > 10000
  • 单次 GROUP BY 查询优于多次 COUNT 查询

预估性能:

  • 项目数 < 1000: 响应时间 < 50ms
  • 项目数 1000-10000: 响应时间 50-100ms
  • 项目数 > 10000: 考虑添加缓存或索引

前端性能

并行请求:

  • Promise.all 同时发起两个请求
  • 总等待时间 = max(listTime, countsTime)
  • 不影响用户体验

请求频率:

  • 仅在页面加载、搜索、翻页时请求
  • 不会产生过多网络流量

风险与应对

风险 1: 统计数据不一致

场景: 用户在查看页面时,后台数据被其他用户修改

影响: 标签页数字与实际不符

应对方案:

  • 轻微问题,可接受
  • 用户刷新页面后同步
  • 不需要额外处理

风险 2: 统计接口失败

场景: 统计接口异常或超时

影响: 标签页显示 0 或空白

应对方案:

  • 前端增加错误处理
  • 列表接口不受影响
  • 控制台记录错误日志

风险 3: 大数据量性能

场景: 项目数量超过 10000

影响: 统计查询变慢

应对方案:

  • 为 status 字段添加索引
  • 使用单次 GROUP BY 查询
  • 考虑添加缓存Redis

实施计划

后端开发 (30 分钟)

  1. 创建 VO 类 (5 分钟)

    • 新建 CcdiProjectStatusCountsVO.java
    • 添加字段和 Lombok 注解
  2. Service 层开发 (15 分钟)

    • 接口添加方法声明
    • 实现类编写统计逻辑
    • (可选) Mapper 添加 GROUP BY 查询
  3. Controller 层开发 (5 分钟)

    • 添加 /statusCounts 接口
    • 添加 Swagger 注释
  4. 本地测试 (5 分钟)

    • 启动项目
    • 使用 Swagger 测试接口

前端开发 (20 分钟)

  1. API 层修改 (5 分钟)

    • 添加 getStatusCounts() 函数
  2. 页面组件修改 (10 分钟)

    • 导入新 API
    • 修改 getList() 方法
    • 删除 calculateTabCounts() 方法
  3. 本地测试 (5 分钟)

    • 启动前端
    • 验证功能

测试验证 (15 分钟)

  1. 后端接口测试 (5 分钟)

    • Swagger 测试各场景
  2. 前端功能测试 (10 分钟)

    • 执行测试方案中的所有用例

总计时间: 65 分钟

验收标准

功能验收

  • 后端 /statusCounts 接口返回正确的统计数字
  • 前端标签页显示数据库中的完整统计
  • 搜索不影响标签页统计数字
  • 分页不影响标签页统计数字
  • 状态过滤不影响标签页统计数字

性能验收

  • 统计接口响应时间 < 100ms
  • 页面加载时间无明显增加

代码质量

  • 后端代码符合项目规范
  • 前端代码符合项目规范
  • 添加必要的注释和文档

后续优化

短期优化 (可选)

  1. 错误处理增强

    • 前端对统计接口失败进行友好提示
    • 后端添加异常捕获和日志
  2. 性能优化

    • 使用单次 GROUP BY 查询替代多次 COUNT
    • 为 status 字段添加索引

长期优化 (可选)

  1. 缓存机制

    • 使用 Redis 缓存统计结果
    • 设置 5 分钟过期时间
    • 项目变更时清除缓存
  2. 实时更新

    • 使用 WebSocket 推送统计更新
    • 减少轮询请求

附录

相关文件清单

后端新增文件:

  • ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectStatusCountsVO.java

后端修改文件:

  • ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java
  • ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java
  • ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java
  • ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectMapper.java (可选)
  • ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectMapper.xml (可选)

前端修改文件:

  • ruoyi-ui/src/api/ccdiProject.js
  • ruoyi-ui/src/views/ccdiProject/index.vue

参考资料