feat(asl): Complete Week 4 - Results display and Excel export with hybrid solution
Features: - Backend statistics API (cloud-native Prisma aggregation) - Results page with hybrid solution (AI consensus + human final decision) - Excel export (frontend generation, zero disk write, cloud-native) - PRISMA-style exclusion reason analysis with bar chart - Batch selection and export (3 export methods) - Fixed logic contradiction (inclusion does not show exclusion reason) - Optimized table width (870px, no horizontal scroll) Components: - Backend: screeningController.ts - add getProjectStatistics API - Frontend: ScreeningResults.tsx - complete results page (hybrid solution) - Frontend: excelExport.ts - Excel export utility (40 columns full info) - Frontend: ScreeningWorkbench.tsx - add navigation button - Utils: get-test-projects.mjs - quick test tool Architecture: - Cloud-native: backend aggregation reduces network transfer - Cloud-native: frontend Excel generation (zero file persistence) - Reuse platform: global prisma instance, logger - Performance: statistics API < 500ms, Excel export < 3s (1000 records) Documentation: - Update module status guide (add Week 4 features) - Update task breakdown (mark Week 4 completed) - Update API design spec (add statistics API) - Update database design (add field usage notes) - Create Week 4 development plan - Create Week 4 completion report - Create technical debt list Test: - End-to-end flow test passed - All features verified - Performance test passed - Cloud-native compliance verified Ref: Week 4 Development Plan Scope: ASL Module MVP - Title Abstract Screening Results Cloud-Native: Backend aggregation + Frontend Excel generation
This commit is contained in:
841
docs/03-业务模块/ASL-AI智能文献/04-开发计划/04-Week4-结果展示与导出开发计划.md
Normal file
841
docs/03-业务模块/ASL-AI智能文献/04-开发计划/04-Week4-结果展示与导出开发计划.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# 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
|
||||
**文档状态**:✅ 已确认,可开始开发
|
||||
**开始时间**:待定
|
||||
|
||||
|
||||
Reference in New Issue
Block a user