Files
ccdi/docs/plans/2026-02-27-project-status-counts-fix-design.md

636 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 项目管理标签页状态统计修复设计文档
## 文档信息
- **创建日期**: 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):
```javascript
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`
```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`):
```java
/**
* 查询各状态的项目总数(不受搜索条件影响)
* @return 状态统计
*/
CcdiProjectStatusCountsVO getStatusCounts();
```
**实现类** (`CcdiProjectServiceImpl.java`):
```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
**优化方案**(可选):
如果性能要求高,可以改为单次查询:
```java
@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 方法:
```java
List<Map<String, Object>> countGroupByStatus();
```
XML SQL:
```xml
<select id="countGroupByStatus" resultType="map">
SELECT
status,
COUNT(*) as count
FROM ccdi_project
GROUP BY status
</select>
```
#### 3. Controller 层
**文件**: `CcdiProjectController.java`
```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`
```javascript
// 查询项目状态统计
export function getStatusCounts() {
return request({
url: '/ccdi/project/statusCounts',
method: 'get'
})
}
```
#### 2. 页面组件改造
**文件**: `ruoyi-ui/src/views/ccdiProject/index.vue`
**修改点 1**: 导入新 API
```javascript
import { listProject, getStatusCounts } from '@/api/ccdiProject'
```
**修改点 2**: 重构 `getList()` 方法
```javascript
/** 查询项目列表 */
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`
```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"
**预期响应**:
```json
{
"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`
### 参考资料
- MyBatis Plus 官方文档: https://baomidou.com/
- Element UI 标签页组件: https://element.eleme.cn/#/zh-CN/component/tabs
- Promise.all 文档: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise/all