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
27 KiB
27 KiB
Week 4:结果展示与导出 - 开发计划(云原生架构)
文档版本: v1.0
创建日期: 2025-11-21
计划周期: 2天(Day 16-17)
架构原则: ✅ 云原生优先
最后更新: 2025-11-21
📋 文档说明
本文档是 Week 4 功能开发的详细计划,遵循云原生开发规范,实现筛选结果的统计展示和Excel导出功能。
核心目标:
- ✅ 统计概览(总数、纳入率、排除率、待复核)
- ✅ PRISMA式排除原因统计
- ✅ 结果列表Tab切换与查看
- ✅ Excel批量导出
- ✅ 完整功能闭环(上传→筛选→复核→统计→导出)
架构原则:
- ✅ 云原生优先:遵循云原生开发规范
- ✅ 复用平台能力:使用全局
prisma、logger等 - ✅ 零文件落盘:Excel前端生成或OSS存储
- ✅ 后端聚合计算:统计数据后端聚合
🎯 一、功能定位
1.1 "审核工作台" vs "初筛结果" 区别
| 维度 | 审核工作台(ScreeningWorkbench) | 初筛结果(ScreeningResults) |
|---|---|---|
| 定位 | 实时监控、冲突处理、人工复核 | 最终结果展示、统计分析、批量导出 |
| 使用时机 | 筛选进行中 | 筛选完成后 |
| 核心功能 | 进度轮询、逐条复核、冲突高亮 | 统计概览、PRISMA总结、批量导出 |
| 表格形式 | 双行表格(DS + Qwen对比) | 单行表格(显示最终决策) |
| 强调重点 | 工作流 | 结果汇总 |
比喻:
审核工作台 = 生产车间(正在筛选、复核)
初筛结果 = 成品仓库(已完成、统计、导出)
1.2 用户流程
设置与启动 → 审核工作台 → 初筛结果
(配置) (实时监控) (结果汇总)
↓ ↓ ↓
填写PICOS 逐条复核 批量导出
上传Excel 冲突处理 统计分析
🏗️ 二、技术架构(云原生)
2.1 核心架构决策
决策1:数据获取策略 → 后端聚合API ✅
方案A(不采用):前端获取全量数据,前端计算
// ❌ 不符合云原生:前端获取全量数据(可能上千条)
const { data } = await aslApi.getScreeningResultsList(projectId, {
pageSize: 9999 // 获取全部
});
// 前端计算统计...
方案B(采用):后端聚合API ✅
// ✅ 符合云原生:后端聚合,减少网络传输
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):前端生成 ✅
// ✅ 符合云原生:零文件落盘,完全在浏览器内存中
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存储 ⏸️
// ⏸️ 技术债务:当数据量>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
2.2 云原生架构检查
基于云原生开发规范,本开发计划遵循:
| 检查项 | 要求 | 本计划实现 | 状态 |
|---|---|---|---|
| 存储 | 使用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
请求参数
无
响应格式
{
"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"
}
}
实现代码
// 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 '其他原因';
}
路由注册
// backend/src/modules/asl/routes/index.ts
// 添加到路由注册
fastify.get(
'/projects/:projectId/statistics',
screeningController.getProjectStatistics
);
💻 五、前端开发
5.1 API客户端
// frontend-v2/src/modules/asl/api/index.ts
/**
* 获取项目统计数据
*/
export async function getProjectStatistics(
projectId: string
): Promise<ApiResponse<ProjectStatistics>> {
return request(`/projects/${projectId}/statistics`);
}
5.2 类型定义
// 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导出工具
// 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 初筛结果页面
// 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小时
任务:
- 在
screeningController.ts中实现getProjectStatistics - 使用Prisma聚合查询(并行查询优化)
- 实现排除原因提取逻辑
extractAutoReason - 在
routes/index.ts中注册路由 - Postman测试API
验收标准:
- ✅ API返回正确统计数据
- ✅ 性能良好(<500ms)
- ✅ 符合云原生原则(后端聚合)
文件清单:
backend/src/modules/asl/controllers/screeningController.tsbackend/src/modules/asl/routes/index.ts
Phase 2:前端API客户端(Day 16上午)⏱️ 30分钟
任务:
- 在
api/index.ts中添加getProjectStatistics - 在
types/index.ts中添加ProjectStatistics类型
验收标准:
- ✅ API调用正常
- ✅ TypeScript类型正确
文件清单:
frontend-v2/src/modules/asl/api/index.tsfrontend-v2/src/modules/asl/types/index.ts
Phase 3:统计概览卡片(Day 16上午)⏱️ 1.5小时
任务:
- 实现统计卡片组件(4个卡片)
- 实现"待复核"提示Alert
- 实现PRISMA排除统计(柱状图)
验收标准:
- ✅ 统计数据正确显示
- ✅ 排除原因柱状图清晰
- ✅ "待复核"提示醒目
文件清单:
frontend-v2/src/modules/asl/pages/ScreeningResults.tsx
Phase 4:结果列表Tab(Day 16下午)⏱️ 3小时
任务:
- 实现Tab切换(全部/已纳入/已排除/待复核)
- 创建单行表格(区别于审核工作台)
- 实现Checkbox多选
- 实现详情查看Modal(复用审核工作台的Drawer)
验收标准:
- ✅ Tab切换正常
- ✅ 表格数据正确
- ✅ 可多选行
- ✅ 可查看详情
文件清单:
frontend-v2/src/modules/asl/pages/ScreeningResults.tsx
Phase 5:Excel导出(Day 17上午)⏱️ 2小时
任务:
- 创建
excelExport.ts工具文件 - 实现前端导出逻辑(使用
xlsx) - 添加导出按钮(导出全部/导出当前页/导出选中项)
- 支持过滤导出(全部/仅纳入/仅排除)
验收标准:
- ✅ 可导出Excel
- ✅ 数据完整
- ✅ 零文件落盘(云原生)
- ✅ 生成速度<3秒(<1000条)
文件清单:
frontend-v2/src/modules/asl/utils/excelExport.tsfrontend-v2/src/modules/asl/pages/ScreeningResults.tsx
Phase 6:集成测试与优化(Day 17下午-Day 18)⏱️ 4小时
任务:
- 完整流程测试(上传→筛选→复核→查看结果→导出)
- 异常场景测试(无数据、网络错误)
- UI/UX优化(加载状态、错误提示)
- 性能测试(统计API、Excel导出)
- 云原生规范检查
验收标准:
- ✅ 流程完整无阻塞
- ✅ 异常处理完善
- ✅ 性能达标
- ✅ 符合云原生规范
✅ 七、验收标准
7.1 功能验收
- [✅] 统计概览卡片正确显示(总数、纳入、排除、待复核)
- [✅] 排除原因统计准确(柱状图)
- [✅] 待复核提示醒目
- [✅] Tab切换正常(全部/已纳入/已排除/待复核)
- [✅] 表格数据正确(单行表格)
- [✅] Checkbox多选正常
- [✅] 可查看详情
- [✅] 可导出Excel(全部/选中)
- [✅] Excel数据完整
7.2 性能验收
- [✅] 统计API响应时间 <500ms
- [✅] Excel导出(<1000条)<3秒
- [✅] 表格分页加载正常
7.3 云原生验收
基于云原生开发规范检查清单:
后端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天 |
🔗 九、相关文档
📝 十、技术债务记录
债务1:Excel后端导出优化
触发条件:
- 单次导出数据量 >5000条
- 需要复杂Excel格式(多Sheet、图表等)
- 用户反馈前端导出卡顿
解决方案:
- 后端生成Excel(使用
ExcelJS) - 上传到OSS(使用
storage.upload()) - 返回下载URL
记录位置:技术债务清单 - 优先级4
预计耗时:1-2天
💡 十一、开发建议
对开发人员
- 先阅读云原生规范:云原生开发规范
- 复用平台能力:使用全局
prisma、logger - 避免文件落盘:Excel前端生成
- 后端聚合计算:统计数据后端完成
- 性能优化:Prisma聚合查询使用并行
对AI助手
- 优先云原生:所有设计优先考虑云原生架构
- 参考现有代码:复用审核工作台的组件
- 注意区别:初筛结果是单行表格,审核工作台是双行表格
- 测试充分:完整流程测试
文档维护者:AI智能文献开发团队
最后更新:2025-11-21
文档状态:✅ 已确认,可开始开发
开始时间:待定