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

@@ -1,44 +1,738 @@
/**
* 标题摘要初筛 - 初筛结果页面
* TODO: Week 2 Day 5 开发
* Week 4 开发统计展示、PRISMA排除分析、结果列表、Excel导出
*
* 功能:
* - 统计卡片(总数/纳入/排除)
* - PRISMA排除原因统计
* - Tab切换纳入/排除)
* - 结果表格
* - 批量操作
* - 导出Excel
* - 统计概览卡片(总数/纳入/排除/待复核
* - PRISMA排除原因统计
* - Tab切换全部/已纳入/排除/待复核
* - 结果表格(单行表格)
* - 批量选择与导出
* - Excel导出(前端生成,云原生)
*/
import { Card, Empty, Alert } from 'antd';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Card, Statistic, Row, Col, Tabs, Table, Button, Alert,
Progress, message, Tooltip, Empty, Spin, Tag, Space
} from 'antd';
import {
DownloadOutlined, CheckCircleOutlined, CloseCircleOutlined,
QuestionCircleOutlined, WarningOutlined, FileExcelOutlined
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import type { TableColumnsType } from 'antd';
import * as aslApi from '../api';
import { exportScreeningResults, exportStatisticsSummary } from '../utils/excelExport';
import ConclusionTag from '../components/ConclusionTag';
import type { ScreeningResult } from '../types';
const ScreeningResults = () => {
// 从URL获取projectId需要从路由状态或URL参数获取
const [searchParams, setSearchParams] = useSearchParams();
const projectId = searchParams.get('projectId') || '';
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, error: statsError } = useQuery({
queryKey: ['projectStatistics', projectId],
queryFn: () => aslApi.getProjectStatistics(projectId),
enabled: !!projectId,
retry: 1,
});
const stats = statsData?.data;
// 2. 获取结果列表(分页)
const { data: resultsData, isLoading: resultsLoading } = useQuery({
queryKey: ['screeningResults', projectId, activeTab, page],
queryFn: () => {
// 将'pending'映射为'all'因为后端API不支持'pending'过滤
const filterMap: Record<string, 'all' | 'conflict' | 'included' | 'excluded' | 'reviewed'> = {
'pending': 'all', // 前端Tab显示pending后端用all然后前端过滤
'all': 'all',
'included': 'included',
'excluded': 'excluded',
'conflict': 'conflict',
'reviewed': 'reviewed',
};
return aslApi.getScreeningResultsList(projectId, {
page,
pageSize,
filter: filterMap[activeTab] || 'all',
});
},
enabled: !!projectId && !!stats,
retry: 1,
});
// 3. 处理Tab切换
const handleTabChange = (key: string) => {
setSearchParams({ projectId, tab: key, page: '1' });
setSelectedRowKeys([]); // 清空选择
};
// 4. 处理分页变化
const handlePageChange = (newPage: number) => {
setSearchParams({ projectId, tab: activeTab, page: String(newPage) });
setSelectedRowKeys([]); // 清空选择
};
// 5. ⭐ 导出Excel前端生成云原生
const handleExport = async (filter: 'all' | 'included' | 'excluded' | 'pending' = 'all') => {
try {
const loadingKey = 'export';
message.loading({ content: '正在生成Excel...', key: loadingKey, duration: 0 });
// 获取全量数据(用于导出)
const { data } = await aslApi.getScreeningResultsList(projectId, {
page: 1,
pageSize: 9999,
filter,
});
if (!data || data.items.length === 0) {
message.warning({ content: '没有可导出的数据', key: loadingKey });
return;
}
// ⭐ 前端生成Excel零文件落盘
exportScreeningResults(data.items, {
filter,
projectName: `项目${projectId.slice(0, 8)}`,
});
message.success({ content: `成功导出 ${data.items.length} 条记录`, key: loadingKey });
} catch (error) {
message.error('导出失败: ' + (error as Error).message);
}
};
// 6. 批量导出选中项
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} 条记录`);
};
// 7. 导出统计摘要
const handleExportSummary = () => {
if (!stats) {
message.warning('统计数据未加载');
return;
}
exportStatisticsSummary(stats, `项目${projectId.slice(0, 8)}`);
message.success('统计摘要导出成功');
};
// 8. ⭐ 混合方案:表格列定义(优化宽度,无需横向滚动)
const columns: TableColumnsType<ScreeningResult> = [
{
title: '#',
width: 50,
render: (_, __, index) => (page - 1) * pageSize + index + 1,
},
{
title: '文献标题',
dataIndex: ['literature', 'title'],
width: 300,
ellipsis: { showTitle: false },
render: (text: string, record) => {
const isExpanded = expandedRowKeys.includes(record.id);
return (
<Tooltip title={`${text}\n💡 点击展开查看详细判断`}>
<span
style={{ cursor: 'pointer', color: '#1890ff' }}
onClick={() => toggleRowExpanded(record.id)}
>
{isExpanded ? '📖' : '📕'} {text}
</span>
</Tooltip>
);
},
},
{
title: 'AI共识',
width: 100,
render: (_, record) => {
const dsDecision = record.dsConclusion;
const qwDecision = record.qwenConclusion;
// AI是否一致
const isAIConsistent = dsDecision === qwDecision;
if (isAIConsistent) {
return (
<div className="text-center">
<ConclusionTag conclusion={dsDecision} />
<div className="text-xs text-gray-500 mt-1">
(DS QW)
</div>
</div>
);
} else {
return (
<div className="text-center">
<Tag color="warning"></Tag>
<div className="text-xs text-gray-500 mt-1">
DS:{dsDecision === 'include' ? '纳入' : '排除'}<br/>
QW:{qwDecision === 'include' ? '纳入' : '排除'}
</div>
</div>
);
}
},
},
{
title: '排除原因',
width: 140,
ellipsis: { showTitle: false },
render: (_, record) => {
// 逻辑根据最终决策或AI决策判断是否显示排除原因
const finalDec = record.finalDecision || record.dsConclusion;
if (finalDec === 'include') {
return <span style={{ color: '#999' }}>-</span>;
}
// 优先显示人工填写的排除原因
const reason = record.exclusionReason || extractAutoReason(record);
return (
<Tooltip title={reason}>
<span className="text-gray-700 text-sm">{reason}</span>
</Tooltip>
);
},
},
{
title: '人工最终决策',
width: 120,
render: (_, record) => {
if (record.finalDecision) {
// 已复核
const isOverride = record.dsConclusion !== record.finalDecision ||
record.qwenConclusion !== record.finalDecision;
return (
<div className="text-center">
<ConclusionTag conclusion={record.finalDecision as any} />
<div className="text-xs text-gray-500 mt-1">
{isOverride ? '(推翻AI)' : '(与AI一致)'}
</div>
</div>
);
} else {
return (
<div className="text-center">
<span style={{ color: '#999' }}></span>
</div>
);
}
},
},
{
title: '状态',
width: 90,
render: (_, record) => {
const dsDecision = record.dsConclusion;
const qwDecision = record.qwenConclusion;
const isAIConsistent = dsDecision === qwDecision;
if (record.finalDecision) {
// 已复核
const isOverride = record.dsConclusion !== record.finalDecision ||
record.qwenConclusion !== record.finalDecision;
if (isOverride) {
return <Tag color="orange">AI</Tag>;
} else {
return <Tag color="success">AI一致</Tag>;
}
} else {
// 未复核
if (!isAIConsistent) {
return <Tag color="warning"></Tag>;
} else {
return <Tag color="default">AI一致</Tag>;
}
}
},
},
{
title: '操作',
width: 70,
render: (_, record) => (
<Button
type="link"
size="small"
onClick={() => toggleRowExpanded(record.id)}
>
{expandedRowKeys.includes(record.id) ? '收起' : '展开'}
</Button>
),
},
];
// 9. 控制展开行
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
const toggleRowExpanded = (key: React.Key) => {
setExpandedRowKeys(prev =>
prev.includes(key)
? prev.filter(k => k !== key)
: [...prev, key]
);
};
// 10. ⭐ 展开行渲染显示详细AI判断
const expandedRowRender = (record: ScreeningResult) => {
return (
<div className="p-4 bg-gray-50">
<Row gutter={24}>
{/* 左侧DeepSeek分析 */}
<Col span={12}>
<Card
size="small"
title={
<div className="flex items-center justify-between">
<span>🤖 DeepSeek-V3</span>
<Tag color={record.dsConclusion === 'include' ? 'success' : 'default'}>
{record.dsConclusion === 'include' ? '纳入' : '排除'} ({(record.dsConfidence! * 100).toFixed(0)}%)
</Tag>
</div>
}
>
<div className="space-y-2">
<div>
<span className="font-semibold">P判断</span>
<Tag color={record.dsPJudgment === 'match' ? 'success' : 'error'}>
{formatJudgment(record.dsPJudgment)}
</Tag>
{record.dsPEvidence && (
<div className="text-xs text-gray-600 mt-1">"{record.dsPEvidence}"</div>
)}
</div>
<div>
<span className="font-semibold">I判断</span>
<Tag color={record.dsIJudgment === 'match' ? 'success' : 'error'}>
{formatJudgment(record.dsIJudgment)}
</Tag>
{record.dsIEvidence && (
<div className="text-xs text-gray-600 mt-1">"{record.dsIEvidence}"</div>
)}
</div>
<div>
<span className="font-semibold">C判断</span>
<Tag color={record.dsCJudgment === 'match' ? 'success' : 'error'}>
{formatJudgment(record.dsCJudgment)}
</Tag>
{record.dsCEvidence && (
<div className="text-xs text-gray-600 mt-1">"{record.dsCEvidence}"</div>
)}
</div>
<div>
<span className="font-semibold">S判断</span>
<Tag color={record.dsSJudgment === 'match' ? 'success' : 'error'}>
{formatJudgment(record.dsSJudgment)}
</Tag>
{record.dsSEvidence && (
<div className="text-xs text-gray-600 mt-1">"{record.dsSEvidence}"</div>
)}
</div>
{record.dsReason && (
<div className="mt-2 p-2 bg-white rounded">
<div className="font-semibold text-xs"></div>
<div className="text-xs text-gray-700">{record.dsReason}</div>
</div>
)}
</div>
</Card>
</Col>
{/* 右侧Qwen分析 */}
<Col span={12}>
<Card
size="small"
title={
<div className="flex items-center justify-between">
<span>🤖 Qwen-Max</span>
<Tag color={record.qwenConclusion === 'include' ? 'success' : 'default'}>
{record.qwenConclusion === 'include' ? '纳入' : '排除'} ({(record.qwenConfidence! * 100).toFixed(0)}%)
</Tag>
</div>
}
>
<div className="space-y-2">
<div>
<span className="font-semibold">P判断</span>
<Tag color={record.qwenPJudgment === 'match' ? 'success' : 'error'}>
{formatJudgment(record.qwenPJudgment)}
</Tag>
{record.qwenPEvidence && (
<div className="text-xs text-gray-600 mt-1">"{record.qwenPEvidence}"</div>
)}
</div>
<div>
<span className="font-semibold">I判断</span>
<Tag color={record.qwenIJudgment === 'match' ? 'success' : 'error'}>
{formatJudgment(record.qwenIJudgment)}
</Tag>
{record.qwenIEvidence && (
<div className="text-xs text-gray-600 mt-1">"{record.qwenIEvidence}"</div>
)}
</div>
<div>
<span className="font-semibold">C判断</span>
<Tag color={record.qwenCJudgment === 'match' ? 'success' : 'error'}>
{formatJudgment(record.qwenCJudgment)}
</Tag>
{record.qwenCEvidence && (
<div className="text-xs text-gray-600 mt-1">"{record.qwenCEvidence}"</div>
)}
</div>
<div>
<span className="font-semibold">S判断</span>
<Tag color={record.qwenSJudgment === 'match' ? 'success' : 'error'}>
{formatJudgment(record.qwenSJudgment)}
</Tag>
{record.qwenSEvidence && (
<div className="text-xs text-gray-600 mt-1">"{record.qwenSEvidence}"</div>
)}
</div>
{record.qwenReason && (
<div className="mt-2 p-2 bg-white rounded">
<div className="font-semibold text-xs"></div>
<div className="text-xs text-gray-700">{record.qwenReason}</div>
</div>
)}
</div>
</Card>
</Col>
</Row>
{/* 人工复核信息 */}
{record.finalDecision && (
<Card size="small" className="mt-4" title="👨‍⚕️ 人工复核">
<div className="space-y-2">
<div>
<span className="font-semibold"></span>
<ConclusionTag conclusion={record.finalDecision as any} />
{(record.dsConclusion !== record.finalDecision || record.qwenConclusion !== record.finalDecision) && (
<Tag color="orange" className="ml-2">AI建议</Tag>
)}
</div>
{record.exclusionReason && (
<div>
<span className="font-semibold"></span>
<span className="text-gray-700">{record.exclusionReason}</span>
</div>
)}
{record.finalDecisionBy && (
<div className="text-xs text-gray-500">
{record.finalDecisionBy} |
{record.finalDecisionAt ? new Date(record.finalDecisionAt).toLocaleString('zh-CN') : '-'}
</div>
)}
</div>
</Card>
)}
</div>
);
};
// 9. Tab配置
const tabItems = [
{
key: 'all',
label: `全部 (${stats?.total || 0})`,
},
{
key: 'included',
label: `已纳入 (${stats?.included || 0})`,
},
{
key: 'excluded',
label: `已排除 (${stats?.excluded || 0})`,
},
{
key: 'pending',
label: `待复核 (${stats?.pending || 0})`,
},
];
// 10. 多选配置
const rowSelection = {
selectedRowKeys,
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys as string[]),
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
Table.SELECTION_NONE,
],
};
// 如果没有projectId
if (!projectId) {
return (
<div className="p-6">
<Alert
message="参数错误"
description="未找到项目ID请从审核工作台进入"
type="error"
showIcon
/>
</div>
);
}
// 加载中
if (statsLoading) {
return (
<div className="flex items-center justify-center h-screen">
<Spin size="large" tip="加载统计数据..." />
</div>
);
}
// 加载错误
if (statsError || !stats) {
return (
<div className="p-6">
<Alert
message="加载失败"
description="无法加载统计数据,请刷新重试"
type="error"
showIcon
/>
</div>
);
}
const TitleScreeningResults = () => {
return (
<div className="p-6">
{/* 标题 */}
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2"> - </h1>
<p className="text-gray-500">
PRISMA流程图
PRISMA排除分析
</p>
</div>
<Card>
{/* 1. 统计概览卡片 */}
<Row gutter={16} className="mb-6">
<Col span={6}>
<Card>
<Statistic
title="总文献数"
value={stats.total}
suffix="篇"
prefix={<FileExcelOutlined style={{ color: '#1890ff' }} />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="已纳入"
value={stats.included}
suffix={`篇 (${stats.includedRate}%)`}
valueStyle={{ color: '#52c41a' }}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="已排除"
value={stats.excluded}
suffix={`篇 (${stats.excludedRate}%)`}
valueStyle={{ color: '#999' }}
prefix={<CloseCircleOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="待复核"
value={stats.pending}
suffix={`篇 (${stats.pendingRate}%)`}
valueStyle={{ color: stats.conflict > 0 ? '#faad14' : '#999' }}
prefix={<QuestionCircleOutlined />}
/>
{stats.conflict > 0 && (
<div className="mt-2 text-xs text-orange-500">
<WarningOutlined /> {stats.conflict}
</div>
)}
</Card>
</Col>
</Row>
{/* 2. ⭐ 待复核提示 */}
{stats.conflict > 0 && (
<Alert
message="功能开发中"
description="Week 2 Day 5 将实现统计卡片、结果表格、批量操作、Excel导出等功能"
type="info"
message="有文献需要人工复核"
description={`还有 ${stats.conflict} 篇文献存在模型判断冲突,建议前往"审核工作台"进行人工复核`}
type="warning"
showIcon
className="mb-4"
icon={<WarningOutlined />}
className="mb-6"
action={
<Button
size="small"
type="primary"
onClick={() => {
window.location.href = `/literature/screening/title/workbench?projectId=${projectId}`;
}}
>
</Button>
}
/>
<Empty
description="初筛结果页(开发中)"
image={Empty.PRESENTED_IMAGE_SIMPLE}
)}
{/* 3. PRISMA排除原因统计 */}
{stats.excluded > 0 && (
<Card title="排除原因分析PRISMA" className="mb-6">
<div className="space-y-3">
{Object.entries(stats.exclusionReasons)
.sort(([, a], [, b]) => b - a) // 按数量降序
.map(([reason, count]) => (
<div key={reason}>
<div className="flex justify-between mb-1">
<span className="font-medium">{reason}</span>
<span className="text-gray-600">
{count} ({((count / stats.excluded) * 100).toFixed(1)}%)
</span>
</div>
<Progress
percent={(count / stats.excluded) * 100}
showInfo={false}
strokeColor="#1890ff"
/>
</div>
))}
</div>
</Card>
)}
{/* 4. 结果列表 */}
<Card>
{/* Tab切换 */}
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
items={tabItems}
tabBarExtraContent={
<Space>
<Button
icon={<DownloadOutlined />}
onClick={handleExportSummary}
>
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={() => handleExport(activeTab as any)}
>
</Button>
{selectedRowKeys.length > 0 && (
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleExportSelected}
>
({selectedRowKeys.length})
</Button>
)}
</Space>
}
/>
{/* 表格 */}
<Table
rowSelection={rowSelection}
columns={columns}
dataSource={resultsData?.data?.items || []}
rowKey="id"
loading={resultsLoading}
expandable={{
expandedRowRender,
expandedRowKeys,
onExpand: (_expanded, record) => toggleRowExpanded(record.id),
expandIcon: () => null, // 隐藏默认展开图标,使用标题点击
}}
pagination={{
current: page,
pageSize,
total: resultsData?.data?.total || 0,
onChange: handlePageChange,
showSizeChanger: false,
showTotal: (total) => `${total} 条记录`,
}}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无数据"
/>
),
}}
scroll={{ x: 870 }}
/>
</Card>
</div>
);
};
export default TitleScreeningResults;
/**
* 辅助函数从AI判断中提取排除原因
*/
function extractAutoReason(result: ScreeningResult): 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 '其他原因';
}
/**
* 辅助函数:格式化判断结果
*/
function formatJudgment(judgment: string | null): string {
switch (judgment) {
case 'match':
return '匹配';
case 'partial':
return '部分匹配';
case 'mismatch':
return '不匹配';
default:
return '-';
}
}
export default ScreeningResults;