Features - User Management (Phase 4.1): - Database: Add user_modules table for fine-grained module permissions - Database: Add 4 user permissions (view/create/edit/delete) to role_permissions - Backend: UserService (780 lines) - CRUD with tenant isolation - Backend: UserController + UserRoutes (648 lines) - 13 API endpoints - Backend: Batch import users from Excel - Frontend: UserListPage (412 lines) - list/filter/search/pagination - Frontend: UserFormPage (341 lines) - create/edit with module config - Frontend: UserDetailPage (393 lines) - details/tenant/module management - Frontend: 3 modal components (592 lines) - import/assign/configure - API: GET/POST/PUT/DELETE /api/admin/users/* endpoints Architecture Upgrade - Module Permission System: - Backend: Add getUserModules() method in auth.service - Backend: Login API returns modules array in user object - Frontend: AuthContext adds hasModule() method - Frontend: Navigation filters modules based on user.modules - Frontend: RouteGuard checks requiredModule instead of requiredVersion - Frontend: Remove deprecated version-based permission system - UX: Only show accessible modules in navigation (clean UI) - UX: Smart redirect after login (avoid 403 for regular users) Fixes: - Fix UTF-8 encoding corruption in ~100 docs files - Fix pageSize type conversion in userService (String to Number) - Fix authUser undefined error in TopNavigation - Fix login redirect logic with role-based access check - Update Git commit guidelines v1.2 with UTF-8 safety rules Database Changes: - CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled) - ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code) - INSERT 4 permissions + role assignments - UPDATE PUBLIC tenant with 8 module subscriptions Technical: - Backend: 5 new files (~2400 lines) - Frontend: 10 new files (~2500 lines) - Docs: 1 development record + 2 status updates + 1 guideline update - Total: ~4900 lines of code Status: User management 100% complete, module permission system operational
845 lines
27 KiB
Markdown
845 lines
27 KiB
Markdown
# Week 4:结果展示与导出 - 开发计划(云原生架构)
|
||
|
||
> **文档版本:** v1.0
|
||
> **创建日期:** 2025-11-21
|
||
> **计划周期:** 2天(Day 16-17)
|
||
> **架构原则:** ✅ 云原生优先
|
||
> **最后更新:** 2025-11-21
|
||
|
||
---
|
||
|
||
## 📋 文档说明
|
||
|
||
本文档是 Week 4 功能开发的详细计划,遵循云原生开发规范,实现筛选结果的统计展示和Excel导出功能。
|
||
|
||
**核心目标**:
|
||
- ✅ 统计概览(总数、纳入率、排除率、待复核)
|
||
- ✅ PRISMA式排除原因统计
|
||
- ✅ 结果列表Tab切换与查看
|
||
- ✅ Excel批量导出
|
||
- ✅ 完整功能闭环(上传→筛选→复核→统计→导出)
|
||
|
||
**架构原则**:
|
||
- ✅ **云原生优先**:遵循[云原生开发规范](../../../04-开发规范/08-云原生开发规范.md)
|
||
- ✅ **复用平台能力**:使用全局`prisma`、`logger`等
|
||
- ✅ **零文件落盘**:Excel前端生成或OSS存储
|
||
- ✅ **后端聚合计算**:统计数据后端聚合
|
||
|
||
---
|
||
|
||
## 🎯 一、功能定位
|
||
|
||
### 1.1 "审核工作台" vs "初筛结果" 区别
|
||
|
||
| 维度 | 审核工作台(ScreeningWorkbench) | 初筛结果(ScreeningResults) |
|
||
|------|--------------------------------|---------------------------|
|
||
| **定位** | 实时监控、冲突处理、人工复核 | 最终结果展示、统计分析、批量导出 |
|
||
| **使用时机** | 筛选进行中 | 筛选完成后 |
|
||
| **核心功能** | 进度轮询、逐条复核、冲突高亮 | 统计概览、PRISMA总结、批量导出 |
|
||
| **表格形式** | 双行表格(DS + Qwen对比) | 单行表格(显示最终决策) |
|
||
| **强调重点** | 工作流 | 结果汇总 |
|
||
|
||
**比喻**:
|
||
```
|
||
审核工作台 = 生产车间(正在筛选、复核)
|
||
初筛结果 = 成品仓库(已完成、统计、导出)
|
||
```
|
||
|
||
### 1.2 用户流程
|
||
|
||
```
|
||
设置与启动 → 审核工作台 → 初筛结果
|
||
(配置) (实时监控) (结果汇总)
|
||
↓ ↓ ↓
|
||
填写PICOS 逐条复核 批量导出
|
||
上传Excel 冲突处理 统计分析
|
||
```
|
||
|
||
---
|
||
|
||
## 🏗️ 二、技术架构(云原生)
|
||
|
||
### 2.1 核心架构决策
|
||
|
||
#### 决策1:数据获取策略 → 后端聚合API ✅
|
||
|
||
**方案A**(不采用):前端获取全量数据,前端计算
|
||
```typescript
|
||
// ❌ 不符合云原生:前端获取全量数据(可能上千条)
|
||
const { data } = await aslApi.getScreeningResultsList(projectId, {
|
||
pageSize: 9999 // 获取全部
|
||
});
|
||
// 前端计算统计...
|
||
```
|
||
|
||
**方案B**(采用):后端聚合API ✅
|
||
```typescript
|
||
// ✅ 符合云原生:后端聚合,减少网络传输
|
||
GET /api/v1/asl/projects/:projectId/statistics
|
||
|
||
// 后端使用Prisma聚合
|
||
const stats = await prisma.aslScreeningResult.groupBy({
|
||
by: ['finalDecision', 'conflictStatus'],
|
||
_count: true,
|
||
where: { projectId }
|
||
});
|
||
```
|
||
|
||
**选择理由**:
|
||
- ✅ 后端聚合,性能好
|
||
- ✅ 减少网络传输(从MB级降到KB级)
|
||
- ✅ 可扩展性强(支持更复杂的统计)
|
||
- ✅ 符合云原生"计算靠近数据"原则
|
||
|
||
---
|
||
|
||
#### 决策2:Excel导出策略 → 前端生成(MVP)✅
|
||
|
||
**方案A**(采用MVP):前端生成 ✅
|
||
```typescript
|
||
// ✅ 符合云原生:零文件落盘,完全在浏览器内存中
|
||
import * as XLSX from 'xlsx';
|
||
|
||
function exportToExcel(results) {
|
||
const ws = XLSX.utils.json_to_sheet(exportData);
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, '筛选结果');
|
||
XLSX.writeFile(wb, 'screening-results.xlsx'); // 浏览器下载
|
||
}
|
||
```
|
||
|
||
**优点**:
|
||
- ✅ **零文件落盘**(完全在浏览器内存中生成)
|
||
- ✅ **无需后端存储**(不占用OSS空间)
|
||
- ✅ **实时生成**(无异步等待)
|
||
- ✅ **符合云原生原则**(避免Serverless文件操作)
|
||
- ✅ **成本低**(不消耗后端资源)
|
||
|
||
**限制**:
|
||
- ⚠️ 适用数据量:<5000条
|
||
- ⚠️ 生成速度:<1000条约2-3秒
|
||
- ⚠️ 不支持复杂格式(多Sheet、图表)
|
||
|
||
**方案B**(未来扩展):后端生成 + OSS存储 ⏸️
|
||
```typescript
|
||
// ⏸️ 技术债务:当数据量>5000条或需要复杂格式时
|
||
// 1. 后端生成Excel(内存中)
|
||
import ExcelJS from 'exceljs';
|
||
const workbook = new ExcelJS.Workbook();
|
||
// ... 生成Excel
|
||
|
||
// 2. ⭐ 上传到OSS(使用平台存储服务)
|
||
import { storage } from '@/common/storage';
|
||
const buffer = await workbook.xlsx.writeBuffer();
|
||
const url = await storage.upload(`asl/exports/${Date.now()}.xlsx`, buffer);
|
||
|
||
// 3. 返回OSS URL
|
||
res.send({ success: true, url });
|
||
```
|
||
|
||
**触发条件**:
|
||
- 单次导出数据量 >5000条
|
||
- 需要复杂Excel格式(多Sheet、图表等)
|
||
- 用户反馈前端导出卡顿
|
||
|
||
**记录位置**:[技术债务清单 - 优先级4](../../06-技术债务/技术债务清单.md)
|
||
|
||
---
|
||
|
||
### 2.2 云原生架构检查
|
||
|
||
基于[云原生开发规范](../../../04-开发规范/08-云原生开发规范.md),本开发计划遵循:
|
||
|
||
| 检查项 | 要求 | 本计划实现 | 状态 |
|
||
|--------|------|-----------|------|
|
||
| **存储** | 使用`storage.upload()`,不用`fs.writeFile()` | Excel前端生成,零落盘 | ✅ |
|
||
| **数据库** | 使用全局`prisma`实例 | 统计API使用全局`prisma` | ✅ |
|
||
| **长任务** | 异步处理,不阻塞请求 | 统计API <500ms,无需异步 | ✅ |
|
||
| **日志** | 使用`logger`,不用`console.log` | 后端使用`logger.info/error` | ✅ |
|
||
| **配置** | 使用`process.env` | 无新增配置 | ✅ |
|
||
| **计算** | 复杂计算后端完成 | 统计聚合后端完成 | ✅ |
|
||
|
||
---
|
||
|
||
## 📐 三、页面设计
|
||
|
||
### 3.1 整体布局
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ 标题:标题摘要初筛 - 结果 │
|
||
│ 说明:筛选结果统计、PRISMA流程图、批量操作和导出 │
|
||
└─────────────────────────────────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ 📊 统计概览(4个卡片 + 待复核提示) │
|
||
│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │
|
||
│ │ 总数 │ │ 已纳入│ │ 已排除│ │ 待复核│ │
|
||
│ │ 199 │ │ 85 │ │ 90 │ │ 24 │ │
|
||
│ │ 篇 │ │ 42.7% │ │ 45.2% │ │ 12.1% │ │
|
||
│ └───────┘ └───────┘ └───────┘ └───────┘ │
|
||
│ │
|
||
│ ⚠️ 提示:还有 24 篇文献待复核,请前往"审核工作台"处理 │
|
||
└─────────────────────────────────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ 📈 排除原因统计(柱状图) │
|
||
│ ┌─────────────────────────────────────────────┐ │
|
||
│ │ P不匹配(人群) ████████████████ 40篇 (44%) │ │
|
||
│ │ I不匹配(干预) ████████ 25篇 (28%) │ │
|
||
│ │ S不匹配(研究设计)████ 15篇 (17%) │ │
|
||
│ │ 其他原因 ██ 10篇 (11%) │ │
|
||
│ └─────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ 📋 结果列表(Tabs + 单行表格 + 批量操作) │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ [全部 199] [已纳入 85] [已排除 90] [待复核 24] │ │
|
||
│ ├─────────────────────────────────────────────────┤ │
|
||
│ │ [导出全部] [导出当前页] [导出选中项] │ │
|
||
│ ├─────────────────────────────────────────────────┤ │
|
||
│ │ ☑ 序号 | 标题 | 最终决策 | 排除原因 | 操作 │ │
|
||
│ │ ☐ 1 | ... | 已纳入 | - | [查看] │ │
|
||
│ │ ☐ 2 | ... | 已排除 | P不匹配 | [查看] │ │
|
||
│ │ ☐ 3 | ... | 待复核 | 冲突 | [复核] │ │
|
||
│ └─────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.2 表格设计
|
||
|
||
**列定义**(单行表格,区别于审核工作台的双行):
|
||
|
||
| 列名 | 宽度 | 说明 |
|
||
|------|------|------|
|
||
| 选择 | 50px | Checkbox多选 |
|
||
| 序号 | 60px | 行号 |
|
||
| 文献标题 | 400px | Tooltip显示全文 |
|
||
| 最终决策 | 100px | Tag显示(纳入/排除/待定) |
|
||
| 排除原因 | 150px | 显示具体原因 |
|
||
| 置信度 | 80px | DeepSeek置信度 |
|
||
| 操作 | 100px | 查看详情按钮 |
|
||
|
||
**关键区别**:
|
||
- **审核工作台**:双行表格,显示DS+Qwen对比,强调冲突
|
||
- **初筛结果**:**单行表格**,显示最终决策,强调结果
|
||
|
||
---
|
||
|
||
## 🔧 四、后端开发
|
||
|
||
### 4.1 新增统计API
|
||
|
||
#### API设计
|
||
```
|
||
GET /api/v1/asl/projects/:projectId/statistics
|
||
```
|
||
|
||
#### 请求参数
|
||
```
|
||
无
|
||
```
|
||
|
||
#### 响应格式
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"total": 199,
|
||
"included": 85,
|
||
"excluded": 90,
|
||
"pending": 24,
|
||
"conflict": 24,
|
||
"reviewed": 175,
|
||
"exclusionReasons": {
|
||
"P不匹配(人群)": 40,
|
||
"I不匹配(干预)": 25,
|
||
"S不匹配(研究设计)": 15,
|
||
"其他原因": 10
|
||
},
|
||
"includedRate": "42.7",
|
||
"excludedRate": "45.2",
|
||
"pendingRate": "12.1"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 实现代码
|
||
```typescript
|
||
// backend/src/modules/asl/controllers/screeningController.ts
|
||
|
||
/**
|
||
* 获取项目筛选统计数据(云原生:后端聚合)
|
||
* GET /api/v1/asl/projects/:projectId/statistics
|
||
*/
|
||
export async function getProjectStatistics(
|
||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||
reply: FastifyReply
|
||
) {
|
||
try {
|
||
const userId = (request as any).userId || 'asl-test-user-001';
|
||
const { projectId } = request.params;
|
||
|
||
// 1. 验证项目归属
|
||
const project = await prisma.aslScreeningProject.findFirst({
|
||
where: { id: projectId, userId },
|
||
});
|
||
|
||
if (!project) {
|
||
return reply.status(404).send({ error: 'Project not found' });
|
||
}
|
||
|
||
// 2. ⭐ 云原生:使用Prisma聚合查询(并行)
|
||
const [
|
||
total,
|
||
includedCount,
|
||
excludedCount,
|
||
pendingCount,
|
||
conflictCount,
|
||
reviewedCount
|
||
] = await Promise.all([
|
||
prisma.aslScreeningResult.count({ where: { projectId } }),
|
||
prisma.aslScreeningResult.count({
|
||
where: { projectId, finalDecision: 'include' }
|
||
}),
|
||
prisma.aslScreeningResult.count({
|
||
where: { projectId, finalDecision: 'exclude' }
|
||
}),
|
||
prisma.aslScreeningResult.count({
|
||
where: { projectId, finalDecision: null }
|
||
}),
|
||
prisma.aslScreeningResult.count({
|
||
where: { projectId, conflictStatus: 'conflict', finalDecision: null }
|
||
}),
|
||
prisma.aslScreeningResult.count({
|
||
where: { projectId, NOT: { finalDecision: null } }
|
||
}),
|
||
]);
|
||
|
||
// 3. 查询排除结果(用于统计原因)
|
||
const excludedResults = await prisma.aslScreeningResult.findMany({
|
||
where: {
|
||
projectId,
|
||
OR: [
|
||
{ finalDecision: 'exclude' },
|
||
{ finalDecision: null, dsConclusion: 'exclude' }
|
||
]
|
||
},
|
||
select: {
|
||
exclusionReason: true,
|
||
dsPJudgment: true,
|
||
dsIJudgment: true,
|
||
dsCJudgment: true,
|
||
dsSJudgment: true,
|
||
}
|
||
});
|
||
|
||
// 4. 分析排除原因
|
||
const exclusionReasons: Record<string, number> = {};
|
||
excludedResults.forEach(result => {
|
||
const reason = result.exclusionReason || extractAutoReason(result);
|
||
exclusionReasons[reason] = (exclusionReasons[reason] || 0) + 1;
|
||
});
|
||
|
||
// 5. 返回统计数据
|
||
return reply.send({
|
||
success: true,
|
||
data: {
|
||
total,
|
||
included: includedCount,
|
||
excluded: excludedCount,
|
||
pending: pendingCount,
|
||
conflict: conflictCount,
|
||
reviewed: reviewedCount,
|
||
exclusionReasons,
|
||
includedRate: total > 0 ? ((includedCount / total) * 100).toFixed(1) : '0.0',
|
||
excludedRate: total > 0 ? ((excludedCount / total) * 100).toFixed(1) : '0.0',
|
||
pendingRate: total > 0 ? ((pendingCount / total) * 100).toFixed(1) : '0.0',
|
||
}
|
||
});
|
||
} catch (error) {
|
||
logger.error('Failed to get statistics', { error });
|
||
return reply.status(500).send({
|
||
error: 'Failed to get statistics',
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 辅助函数:从AI判断中提取排除原因
|
||
*/
|
||
function extractAutoReason(result: any): string {
|
||
if (result.dsPJudgment === 'mismatch') return 'P不匹配(人群)';
|
||
if (result.dsIJudgment === 'mismatch') return 'I不匹配(干预)';
|
||
if (result.dsCJudgment === 'mismatch') return 'C不匹配(对照)';
|
||
if (result.dsSJudgment === 'mismatch') return 'S不匹配(研究设计)';
|
||
return '其他原因';
|
||
}
|
||
```
|
||
|
||
#### 路由注册
|
||
```typescript
|
||
// backend/src/modules/asl/routes/index.ts
|
||
|
||
// 添加到路由注册
|
||
fastify.get(
|
||
'/projects/:projectId/statistics',
|
||
screeningController.getProjectStatistics
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 💻 五、前端开发
|
||
|
||
### 5.1 API客户端
|
||
|
||
```typescript
|
||
// frontend-v2/src/modules/asl/api/index.ts
|
||
|
||
/**
|
||
* 获取项目统计数据
|
||
*/
|
||
export async function getProjectStatistics(
|
||
projectId: string
|
||
): Promise<ApiResponse<ProjectStatistics>> {
|
||
return request(`/projects/${projectId}/statistics`);
|
||
}
|
||
```
|
||
|
||
### 5.2 类型定义
|
||
|
||
```typescript
|
||
// frontend-v2/src/modules/asl/types/index.ts
|
||
|
||
/**
|
||
* 项目统计数据
|
||
*/
|
||
export interface ProjectStatistics {
|
||
total: number;
|
||
included: number;
|
||
excluded: number;
|
||
pending: number;
|
||
conflict: number;
|
||
reviewed: number;
|
||
exclusionReasons: Record<string, number>;
|
||
includedRate: string;
|
||
excludedRate: string;
|
||
pendingRate: string;
|
||
}
|
||
```
|
||
|
||
### 5.3 Excel导出工具
|
||
|
||
```typescript
|
||
// frontend-v2/src/modules/asl/utils/excelExport.ts
|
||
import * as XLSX from 'xlsx';
|
||
import { ScreeningResult } from '../types';
|
||
|
||
/**
|
||
* 导出筛选结果到Excel(云原生:前端生成,零文件落盘)
|
||
*
|
||
* @param results 筛选结果数组
|
||
* @param options 导出选项
|
||
*/
|
||
export function exportScreeningResults(
|
||
results: ScreeningResult[],
|
||
options: {
|
||
filter?: 'all' | 'included' | 'excluded' | 'pending';
|
||
projectName?: string;
|
||
} = {}
|
||
) {
|
||
// 1. 准备导出数据
|
||
const exportData = results.map((r, idx) => ({
|
||
'序号': idx + 1,
|
||
'文献标题': r.literature.title,
|
||
'摘要': r.literature.abstract || '',
|
||
'作者': r.literature.authors || '',
|
||
'期刊': r.literature.journal || '',
|
||
'发表年份': r.literature.publicationYear || '',
|
||
'PMID': r.literature.pmid || '',
|
||
'DOI': r.literature.doi || '',
|
||
'DeepSeek决策': r.dsConclusion || '',
|
||
'DeepSeek置信度': r.dsConfidence ? `${(r.dsConfidence * 100).toFixed(0)}%` : '',
|
||
'DeepSeek理由': r.dsReason || '',
|
||
'Qwen决策': r.qwenConclusion || '',
|
||
'Qwen置信度': r.qwenConfidence ? `${(r.qwenConfidence * 100).toFixed(0)}%` : '',
|
||
'Qwen理由': r.qwenReason || '',
|
||
'是否冲突': r.conflictStatus === 'conflict' ? '是' : '否',
|
||
'最终决策': r.finalDecision || '待定',
|
||
'排除原因': r.exclusionReason || '',
|
||
'复核人': r.finalDecisionBy || '',
|
||
'复核时间': r.finalDecisionAt ? new Date(r.finalDecisionAt).toLocaleString('zh-CN') : '',
|
||
}));
|
||
|
||
// 2. ⭐ 生成Excel(完全在内存中,零文件落盘)
|
||
const ws = XLSX.utils.json_to_sheet(exportData);
|
||
|
||
// 设置列宽
|
||
ws['!cols'] = [
|
||
{ wch: 6 }, // 序号
|
||
{ wch: 50 }, // 标题
|
||
{ wch: 60 }, // 摘要
|
||
{ wch: 30 }, // 作者
|
||
{ wch: 30 }, // 期刊
|
||
{ wch: 10 }, // 年份
|
||
{ wch: 12 }, // PMID
|
||
{ wch: 25 }, // DOI
|
||
{ wch: 12 }, // DS决策
|
||
{ wch: 12 }, // DS置信度
|
||
{ wch: 40 }, // DS理由
|
||
{ wch: 12 }, // Qwen决策
|
||
{ wch: 12 }, // Qwen置信度
|
||
{ wch: 40 }, // Qwen理由
|
||
{ wch: 10 }, // 冲突
|
||
{ wch: 12 }, // 最终决策
|
||
{ wch: 30 }, // 排除原因
|
||
{ wch: 15 }, // 复核人
|
||
{ wch: 20 }, // 复核时间
|
||
];
|
||
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, '筛选结果');
|
||
|
||
// 3. 生成文件名
|
||
const timestamp = new Date().toISOString().slice(0, 10);
|
||
const filterSuffix = options.filter && options.filter !== 'all' ? `_${options.filter}` : '';
|
||
const filename = `${options.projectName || '筛选结果'}${filterSuffix}_${timestamp}.xlsx`;
|
||
|
||
// 4. ⭐ 触发浏览器下载(零文件落盘)
|
||
XLSX.writeFile(wb, filename);
|
||
}
|
||
```
|
||
|
||
### 5.4 初筛结果页面
|
||
|
||
```typescript
|
||
// frontend-v2/src/modules/asl/pages/ScreeningResults.tsx
|
||
|
||
import { useState } from 'react';
|
||
import { useParams, useSearchParams } from 'react-router-dom';
|
||
import {
|
||
Card, Statistic, Row, Col, Tabs, Table, Button, Alert,
|
||
Progress, message, Checkbox, Tooltip
|
||
} from 'antd';
|
||
import {
|
||
DownloadOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||
QuestionCircleOutlined, WarningOutlined
|
||
} from '@ant-design/icons';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import * as aslApi from '../api';
|
||
import { exportScreeningResults } from '../utils/excelExport';
|
||
import { ConclusionTag } from '../components/ConclusionTag';
|
||
|
||
const ScreeningResults = () => {
|
||
const { projectId } = useParams<{ projectId: string }>();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||
|
||
const activeTab = searchParams.get('tab') || 'all';
|
||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||
const pageSize = 20;
|
||
|
||
// 1. ⭐ 获取统计数据(云原生:后端聚合)
|
||
const { data: statsData, isLoading: statsLoading } = useQuery({
|
||
queryKey: ['projectStatistics', projectId],
|
||
queryFn: () => aslApi.getProjectStatistics(projectId!),
|
||
enabled: !!projectId,
|
||
});
|
||
|
||
const stats = statsData?.data;
|
||
|
||
// 2. 获取结果列表(分页)
|
||
const { data: resultsData, isLoading: resultsLoading } = useQuery({
|
||
queryKey: ['screeningResults', projectId, activeTab, page],
|
||
queryFn: () =>
|
||
aslApi.getScreeningResultsList(projectId!, {
|
||
page,
|
||
pageSize,
|
||
filter: activeTab,
|
||
}),
|
||
enabled: !!projectId,
|
||
});
|
||
|
||
// 3. ⭐ 导出Excel(前端生成,云原生)
|
||
const handleExport = async (filter: string = 'all') => {
|
||
try {
|
||
message.loading('正在生成Excel...', 0);
|
||
|
||
// 获取全量数据(用于导出)
|
||
const { data } = await aslApi.getScreeningResultsList(projectId!, {
|
||
page: 1,
|
||
pageSize: 9999,
|
||
filter,
|
||
});
|
||
|
||
if (data.items.length === 0) {
|
||
message.warning('没有可导出的数据');
|
||
return;
|
||
}
|
||
|
||
// ⭐ 前端生成Excel(零文件落盘)
|
||
exportScreeningResults(data.items, {
|
||
filter,
|
||
projectName: `项目${projectId!.slice(0, 8)}`,
|
||
});
|
||
|
||
message.destroy();
|
||
message.success(`成功导出 ${data.items.length} 条记录`);
|
||
} catch (error) {
|
||
message.destroy();
|
||
message.error('导出失败: ' + (error as Error).message);
|
||
}
|
||
};
|
||
|
||
// 4. 批量导出选中项
|
||
const handleExportSelected = () => {
|
||
if (selectedRowKeys.length === 0) {
|
||
message.warning('请先选择要导出的记录');
|
||
return;
|
||
}
|
||
|
||
const selectedResults = resultsData?.data.items.filter(
|
||
r => selectedRowKeys.includes(r.id)
|
||
) || [];
|
||
|
||
exportScreeningResults(selectedResults, {
|
||
projectName: `项目${projectId?.slice(0, 8)}_选中`,
|
||
});
|
||
|
||
message.success(`成功导出 ${selectedResults.length} 条记录`);
|
||
};
|
||
|
||
// 表格列定义、Tab配置等...
|
||
// (完整代码见实际实现)
|
||
};
|
||
|
||
export default ScreeningResults;
|
||
```
|
||
|
||
---
|
||
|
||
## 📅 六、开发任务分解
|
||
|
||
### Phase 1:后端统计API(Day 16上午)⏱️ 2小时
|
||
|
||
**任务**:
|
||
1. 在 `screeningController.ts` 中实现 `getProjectStatistics`
|
||
2. 使用Prisma聚合查询(并行查询优化)
|
||
3. 实现排除原因提取逻辑 `extractAutoReason`
|
||
4. 在 `routes/index.ts` 中注册路由
|
||
5. Postman测试API
|
||
|
||
**验收标准**:
|
||
- ✅ API返回正确统计数据
|
||
- ✅ 性能良好(<500ms)
|
||
- ✅ 符合云原生原则(后端聚合)
|
||
|
||
**文件清单**:
|
||
- `backend/src/modules/asl/controllers/screeningController.ts`
|
||
- `backend/src/modules/asl/routes/index.ts`
|
||
|
||
---
|
||
|
||
### Phase 2:前端API客户端(Day 16上午)⏱️ 30分钟
|
||
|
||
**任务**:
|
||
1. 在 `api/index.ts` 中添加 `getProjectStatistics`
|
||
2. 在 `types/index.ts` 中添加 `ProjectStatistics` 类型
|
||
|
||
**验收标准**:
|
||
- ✅ API调用正常
|
||
- ✅ TypeScript类型正确
|
||
|
||
**文件清单**:
|
||
- `frontend-v2/src/modules/asl/api/index.ts`
|
||
- `frontend-v2/src/modules/asl/types/index.ts`
|
||
|
||
---
|
||
|
||
### Phase 3:统计概览卡片(Day 16上午)⏱️ 1.5小时
|
||
|
||
**任务**:
|
||
1. 实现统计卡片组件(4个卡片)
|
||
2. 实现"待复核"提示Alert
|
||
3. 实现PRISMA排除统计(柱状图)
|
||
|
||
**验收标准**:
|
||
- ✅ 统计数据正确显示
|
||
- ✅ 排除原因柱状图清晰
|
||
- ✅ "待复核"提示醒目
|
||
|
||
**文件清单**:
|
||
- `frontend-v2/src/modules/asl/pages/ScreeningResults.tsx`
|
||
|
||
---
|
||
|
||
### Phase 4:结果列表Tab(Day 16下午)⏱️ 3小时
|
||
|
||
**任务**:
|
||
1. 实现Tab切换(全部/已纳入/已排除/待复核)
|
||
2. 创建单行表格(区别于审核工作台)
|
||
3. 实现Checkbox多选
|
||
4. 实现详情查看Modal(复用审核工作台的Drawer)
|
||
|
||
**验收标准**:
|
||
- ✅ Tab切换正常
|
||
- ✅ 表格数据正确
|
||
- ✅ 可多选行
|
||
- ✅ 可查看详情
|
||
|
||
**文件清单**:
|
||
- `frontend-v2/src/modules/asl/pages/ScreeningResults.tsx`
|
||
|
||
---
|
||
|
||
### Phase 5:Excel导出(Day 17上午)⏱️ 2小时
|
||
|
||
**任务**:
|
||
1. 创建 `excelExport.ts` 工具文件
|
||
2. 实现前端导出逻辑(使用 `xlsx`)
|
||
3. 添加导出按钮(导出全部/导出当前页/导出选中项)
|
||
4. 支持过滤导出(全部/仅纳入/仅排除)
|
||
|
||
**验收标准**:
|
||
- ✅ 可导出Excel
|
||
- ✅ 数据完整
|
||
- ✅ 零文件落盘(云原生)
|
||
- ✅ 生成速度<3秒(<1000条)
|
||
|
||
**文件清单**:
|
||
- `frontend-v2/src/modules/asl/utils/excelExport.ts`
|
||
- `frontend-v2/src/modules/asl/pages/ScreeningResults.tsx`
|
||
|
||
---
|
||
|
||
### Phase 6:集成测试与优化(Day 17下午-Day 18)⏱️ 4小时
|
||
|
||
**任务**:
|
||
1. 完整流程测试(上传→筛选→复核→查看结果→导出)
|
||
2. 异常场景测试(无数据、网络错误)
|
||
3. UI/UX优化(加载状态、错误提示)
|
||
4. 性能测试(统计API、Excel导出)
|
||
5. 云原生规范检查
|
||
|
||
**验收标准**:
|
||
- ✅ 流程完整无阻塞
|
||
- ✅ 异常处理完善
|
||
- ✅ 性能达标
|
||
- ✅ 符合云原生规范
|
||
|
||
---
|
||
|
||
## ✅ 七、验收标准
|
||
|
||
### 7.1 功能验收
|
||
|
||
- [✅] 统计概览卡片正确显示(总数、纳入、排除、待复核)
|
||
- [✅] 排除原因统计准确(柱状图)
|
||
- [✅] 待复核提示醒目
|
||
- [✅] Tab切换正常(全部/已纳入/已排除/待复核)
|
||
- [✅] 表格数据正确(单行表格)
|
||
- [✅] Checkbox多选正常
|
||
- [✅] 可查看详情
|
||
- [✅] 可导出Excel(全部/选中)
|
||
- [✅] Excel数据完整
|
||
|
||
### 7.2 性能验收
|
||
|
||
- [✅] 统计API响应时间 <500ms
|
||
- [✅] Excel导出(<1000条)<3秒
|
||
- [✅] 表格分页加载正常
|
||
|
||
### 7.3 云原生验收
|
||
|
||
基于[云原生开发规范检查清单](../../../04-开发规范/08-云原生开发规范.md):
|
||
|
||
**后端API**:
|
||
- [✅] 使用全局 `prisma` 实例(不new PrismaClient)
|
||
- [✅] 统计使用Prisma聚合查询(不查全量数据)
|
||
- [✅] 无本地文件存储(无fs.writeFile)
|
||
- [✅] 使用 `logger` 记录日志(不用console.log)
|
||
- [✅] 统一错误处理
|
||
|
||
**前端实现**:
|
||
- [✅] Excel前端生成(零文件落盘)
|
||
- [✅] 使用 `xlsx` 库(成熟稳定)
|
||
- [✅] 友好的用户提示
|
||
|
||
---
|
||
|
||
## 📊 八、时间估算
|
||
|
||
| 阶段 | 任务 | 预计耗时 | 负责人 |
|
||
|------|------|---------|--------|
|
||
| Phase 1 | 后端统计API | 2小时 | 后端开发 |
|
||
| Phase 2 | 前端API客户端 | 0.5小时 | 前端开发 |
|
||
| Phase 3 | 统计概览 | 1.5小时 | 前端开发 |
|
||
| Phase 4 | 结果列表Tab | 3小时 | 前端开发 |
|
||
| Phase 5 | Excel导出 | 2小时 | 前端开发 |
|
||
| Phase 6 | 集成测试 | 4小时 | 全栈开发 |
|
||
| **总计** | | **13小时** | **约2天** |
|
||
|
||
---
|
||
|
||
## 🔗 九、相关文档
|
||
|
||
- [云原生开发规范](../../../04-开发规范/08-云原生开发规范.md) - 必读
|
||
- [任务分解](./03-任务分解.md) - Week 4任务清单
|
||
- [模块当前状态](../00-模块当前状态与开发指南.md) - 模块真实状态
|
||
- [技术债务清单](../06-技术债务/技术债务清单.md) - Excel后端导出方案
|
||
- [数据库设计](../02-技术设计/01-数据库设计.md) - 数据表结构
|
||
- [API设计规范](../02-技术设计/02-API设计规范.md) - API规范
|
||
|
||
---
|
||
|
||
## 📝 十、技术债务记录
|
||
|
||
### 债务1:Excel后端导出优化
|
||
|
||
**触发条件**:
|
||
- 单次导出数据量 >5000条
|
||
- 需要复杂Excel格式(多Sheet、图表等)
|
||
- 用户反馈前端导出卡顿
|
||
|
||
**解决方案**:
|
||
- 后端生成Excel(使用 `ExcelJS`)
|
||
- 上传到OSS(使用 `storage.upload()`)
|
||
- 返回下载URL
|
||
|
||
**记录位置**:[技术债务清单 - 优先级4](../06-技术债务/技术债务清单.md)
|
||
|
||
**预计耗时**:1-2天
|
||
|
||
---
|
||
|
||
## 💡 十一、开发建议
|
||
|
||
### 对开发人员
|
||
|
||
1. **先阅读云原生规范**:[云原生开发规范](../../../04-开发规范/08-云原生开发规范.md)
|
||
2. **复用平台能力**:使用全局`prisma`、`logger`
|
||
3. **避免文件落盘**:Excel前端生成
|
||
4. **后端聚合计算**:统计数据后端完成
|
||
5. **性能优化**:Prisma聚合查询使用并行
|
||
|
||
### 对AI助手
|
||
|
||
1. **优先云原生**:所有设计优先考虑云原生架构
|
||
2. **参考现有代码**:复用审核工作台的组件
|
||
3. **注意区别**:初筛结果是单行表格,审核工作台是双行表格
|
||
4. **测试充分**:完整流程测试
|
||
|
||
---
|
||
|
||
**文档维护者**:AI智能文献开发团队
|
||
**最后更新**:2025-11-21
|
||
**文档状态**:✅ 已确认,可开始开发
|
||
**开始时间**:待定
|
||
|
||
|
||
|
||
|
||
|