Files
AIclinicalresearch/docs/03-业务模块/ASL-AI智能文献/04-开发计划/04-Week4-结果展示与导出开发计划.md
HaHafeng 66255368b7 feat(admin): Add user management and upgrade to module permission system
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
2026-01-16 13:42:10 +08:00

845 lines
27 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# 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级
- ✅ 可扩展性强(支持更复杂的统计)
- ✅ 符合云原生"计算靠近数据"原则
---
#### 决策2Excel导出策略 → 前端生成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后端统计APIDay 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结果列表TabDay 16下午 3小时
**任务**
1. 实现Tab切换全部/已纳入/已排除/待复核)
2. 创建单行表格(区别于审核工作台)
3. 实现Checkbox多选
4. 实现详情查看Modal复用审核工作台的Drawer
**验收标准**
- ✅ Tab切换正常
- ✅ 表格数据正确
- ✅ 可多选行
- ✅ 可查看详情
**文件清单**
- `frontend-v2/src/modules/asl/pages/ScreeningResults.tsx`
---
### Phase 5Excel导出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规范
---
## 📝 十、技术债务记录
### 债务1Excel后端导出优化
**触发条件**
- 单次导出数据量 >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
**文档状态**:✅ 已确认,可开始开发
**开始时间**:待定