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:
2025-11-21 20:12:38 +08:00
parent 2e8699c217
commit 8eef9e0544
207 changed files with 11142 additions and 531 deletions

View File

@@ -7,6 +7,7 @@ import { ImportLiteratureDto, LiteratureDto } from '../types/index.js';
import { prisma } from '../../../config/database.js';
import { logger } from '../../../common/logging/index.js';
import * as XLSX from 'xlsx';
import { startScreeningTask } from '../services/screeningService.js';
/**
* 导入文献从Excel或JSON
@@ -50,10 +51,27 @@ export async function importLiteratures(
count: created.count,
});
// 自动启动筛选任务MVP版本
let task;
try {
task = await startScreeningTask(projectId, userId);
logger.info('Screening task auto-started', {
taskId: task.id,
projectId,
});
} catch (taskError) {
logger.error('Failed to auto-start screening task', {
error: taskError,
projectId,
});
// 不阻塞导入操作,继续返回成功
}
return reply.status(201).send({
success: true,
data: {
importedCount: created.count,
taskId: task?.id, // 返回任务ID
},
});
} catch (error) {

View File

@@ -0,0 +1,440 @@
/**
* ASL 筛选任务控制器
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '../../../config/database.js';
import { logger } from '../../../common/logging/index.js';
/**
* 获取筛选任务进度
* GET /api/v1/asl/projects/:projectId/screening-task
*/
export async function getScreeningTask(
request: FastifyRequest<{ Params: { projectId: string } }>,
reply: FastifyReply
) {
try {
const userId = (request as any).userId || 'asl-test-user-001';
const { projectId } = request.params;
// 验证项目归属
const project = await prisma.aslScreeningProject.findFirst({
where: { id: projectId, userId },
});
if (!project) {
return reply.status(404).send({
error: 'Project not found',
});
}
// 获取最新的筛选任务
const task = await prisma.aslScreeningTask.findFirst({
where: { projectId },
orderBy: { createdAt: 'desc' },
});
if (!task) {
return reply.status(404).send({
error: 'No screening task found',
});
}
return reply.send({
success: true,
data: task,
});
} catch (error) {
logger.error('Failed to get screening task', { error });
return reply.status(500).send({
error: 'Failed to get screening task',
});
}
}
/**
* 获取筛选结果列表(分页)
* GET /api/v1/asl/projects/:projectId/screening-results
*
* Query参数
* - page: 页码默认1
* - pageSize: 每页数量默认50
* - filter: 筛选条件all/conflict/included/excluded/reviewed
*/
export async function getScreeningResults(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: {
page?: string;
pageSize?: string;
filter?: string;
};
}>,
reply: FastifyReply
) {
try {
const userId = (request as any).userId || 'asl-test-user-001';
const { projectId } = request.params;
const page = parseInt(request.query.page || '1', 10);
const pageSize = parseInt(request.query.pageSize || '50', 10);
const filter = request.query.filter || 'all';
// 验证项目归属
const project = await prisma.aslScreeningProject.findFirst({
where: { id: projectId, userId },
});
if (!project) {
return reply.status(404).send({
error: 'Project not found',
});
}
// 构建筛选条件
const where: any = { projectId };
switch (filter) {
case 'conflict':
where.conflictStatus = 'conflict';
where.finalDecision = null; // 未复核
break;
case 'included':
where.finalDecision = 'include';
break;
case 'excluded':
where.finalDecision = 'exclude';
break;
case 'pending':
// ⭐ Week 4 新增:待复核(所有未人工决策的)
where.finalDecision = null;
break;
case 'reviewed':
where.NOT = {
finalDecision: null,
};
break;
case 'all':
default:
// 不添加额外条件
break;
}
// 查询总数
const total = await prisma.aslScreeningResult.count({ where });
// 分页查询
const results = await prisma.aslScreeningResult.findMany({
where,
include: {
literature: {
select: {
id: true,
title: true,
abstract: true,
authors: true,
journal: true,
publicationYear: true,
pmid: true,
doi: true,
},
},
},
orderBy: [
{ conflictStatus: 'desc' }, // 冲突的排前面conflict > none
{ createdAt: 'asc' }, // 按创建时间升序保持Excel原始顺序
],
skip: (page - 1) * pageSize,
take: pageSize,
});
return reply.send({
success: true,
data: {
items: results,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
});
} catch (error) {
logger.error('Failed to get screening results', { error });
return reply.status(500).send({
error: 'Failed to get screening results',
});
}
}
/**
* 获取单个筛选结果详情
* GET /api/v1/asl/screening-results/:resultId
*/
export async function getScreeningResultDetail(
request: FastifyRequest<{ Params: { resultId: string } }>,
reply: FastifyReply
) {
try {
const userId = (request as any).userId || 'asl-test-user-001';
const { resultId } = request.params;
const result = await prisma.aslScreeningResult.findUnique({
where: { id: resultId },
include: {
literature: true,
project: {
select: {
id: true,
userId: true,
projectName: true,
picoCriteria: true,
inclusionCriteria: true,
exclusionCriteria: true,
},
},
},
});
if (!result) {
return reply.status(404).send({
error: 'Screening result not found',
});
}
// 验证项目归属
if (result.project.userId !== userId) {
return reply.status(403).send({
error: 'Access denied',
});
}
return reply.send({
success: true,
data: result,
});
} catch (error) {
logger.error('Failed to get screening result detail', { error });
return reply.status(500).send({
error: 'Failed to get screening result detail',
});
}
}
/**
* 提交人工复核
* POST /api/v1/asl/screening-results/:resultId/review
*
* Body:
* {
* decision: 'include' | 'exclude',
* note?: string
* }
*/
export async function reviewScreeningResult(
request: FastifyRequest<{
Params: { resultId: string };
Body: {
decision: 'include' | 'exclude';
note?: string;
};
}>,
reply: FastifyReply
) {
try {
const userId = (request as any).userId || 'asl-test-user-001';
const { resultId } = request.params;
const { decision, note } = request.body;
// 验证决策值
if (!decision || !['include', 'exclude'].includes(decision)) {
return reply.status(400).send({
error: 'Invalid decision value',
});
}
// 获取结果并验证归属
const result = await prisma.aslScreeningResult.findUnique({
where: { id: resultId },
include: {
project: {
select: { userId: true },
},
},
});
if (!result) {
return reply.status(404).send({
error: 'Screening result not found',
});
}
if (result.project.userId !== userId) {
return reply.status(403).send({
error: 'Access denied',
});
}
// 更新复核结果
const updated = await prisma.aslScreeningResult.update({
where: { id: resultId },
data: {
finalDecision: decision, // 人工复核的决策作为最终决策
finalDecisionBy: userId,
finalDecisionAt: new Date(),
exclusionReason: note || null, // 使用exclusionReason存储备注
conflictStatus: 'resolved', // 标记冲突已解决
},
include: {
literature: {
select: {
id: true,
title: true,
},
},
},
});
logger.info('Screening result reviewed', {
resultId,
literatureId: updated.literatureId,
decision,
reviewer: userId,
});
return reply.send({
success: true,
data: updated,
});
} catch (error) {
logger.error('Failed to review screening result', { error });
return reply.status(500).send({
error: 'Failed to review screening result',
});
}
}
/**
* 获取项目筛选统计数据(云原生:后端聚合)
* 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. 记录日志
logger.info('Project statistics retrieved', {
projectId,
total,
included: includedCount,
excluded: excludedCount,
pending: pendingCount,
});
// 6. 返回统计数据
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 project statistics', { error });
return reply.status(500).send({
error: 'Failed to get project 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 '其他原因';
}