/** * 标题摘要初筛 - 初筛结果页面 * Week 4 开发:统计展示、PRISMA排除分析、结果列表、Excel导出 * * 功能: * - 统计概览卡片(总数/纳入/排除/待复核) * - PRISMA式排除原因统计 * - Tab切换(全部/已纳入/已排除/待复核) * - 结果表格(单行表格) * - 批量选择与导出 * - Excel导出(前端生成,云原生) */ 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([]); 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 = { '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 = [ { 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 ( toggleRowExpanded(record.id)} > {isExpanded ? '📖' : '📕'} {text} ); }, }, { title: 'AI共识', width: 100, render: (_, record) => { const dsDecision = record.dsConclusion; const qwDecision = record.qwenConclusion; // AI是否一致 const isAIConsistent = dsDecision === qwDecision; if (isAIConsistent) { return (
(DS✓ QW✓)
); } else { return (
冲突
DS:{dsDecision === 'include' ? '纳入' : '排除'}
QW:{qwDecision === 'include' ? '纳入' : '排除'}
); } }, }, { title: '排除原因', width: 140, ellipsis: { showTitle: false }, render: (_, record) => { // 逻辑:根据最终决策或AI决策判断是否显示排除原因 const finalDec = record.finalDecision || record.dsConclusion; if (finalDec === 'include') { return -; } // 优先显示人工填写的排除原因 const reason = record.exclusionReason || extractAutoReason(record); return ( {reason} ); }, }, { title: '人工最终决策', width: 120, render: (_, record) => { if (record.finalDecision) { // 已复核 const isOverride = record.dsConclusion !== record.finalDecision || record.qwenConclusion !== record.finalDecision; return (
{isOverride ? '(推翻AI)' : '(与AI一致)'}
); } else { return (
未复核
); } }, }, { 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 推翻AI; } else { return 与AI一致; } } else { // 未复核 if (!isAIConsistent) { return 有冲突; } else { return AI一致; } } }, }, { title: '操作', width: 70, render: (_, record) => ( ), }, ]; // 9. 控制展开行 const [expandedRowKeys, setExpandedRowKeys] = useState([]); const toggleRowExpanded = (key: React.Key) => { setExpandedRowKeys(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] ); }; // 10. ⭐ 展开行渲染:显示详细AI判断 const expandedRowRender = (record: ScreeningResult) => { return (
{/* 左侧:DeepSeek分析 */} 🤖 DeepSeek-V3 {record.dsConclusion === 'include' ? '纳入' : '排除'} ({(record.dsConfidence! * 100).toFixed(0)}%)
} >
P判断: {formatJudgment(record.dsPJudgment)} {record.dsPEvidence && (
"{record.dsPEvidence}"
)}
I判断: {formatJudgment(record.dsIJudgment)} {record.dsIEvidence && (
"{record.dsIEvidence}"
)}
C判断: {formatJudgment(record.dsCJudgment)} {record.dsCEvidence && (
"{record.dsCEvidence}"
)}
S判断: {formatJudgment(record.dsSJudgment)} {record.dsSEvidence && (
"{record.dsSEvidence}"
)}
{record.dsReason && (
排除理由:
{record.dsReason}
)}
{/* 右侧:Qwen分析 */} 🤖 Qwen-Max {record.qwenConclusion === 'include' ? '纳入' : '排除'} ({(record.qwenConfidence! * 100).toFixed(0)}%) } >
P判断: {formatJudgment(record.qwenPJudgment)} {record.qwenPEvidence && (
"{record.qwenPEvidence}"
)}
I判断: {formatJudgment(record.qwenIJudgment)} {record.qwenIEvidence && (
"{record.qwenIEvidence}"
)}
C判断: {formatJudgment(record.qwenCJudgment)} {record.qwenCEvidence && (
"{record.qwenCEvidence}"
)}
S判断: {formatJudgment(record.qwenSJudgment)} {record.qwenSEvidence && (
"{record.qwenSEvidence}"
)}
{record.qwenReason && (
排除理由:
{record.qwenReason}
)}
{/* 人工复核信息 */} {record.finalDecision && (
复核决策: {(record.dsConclusion !== record.finalDecision || record.qwenConclusion !== record.finalDecision) && ( 推翻AI建议 )}
{record.exclusionReason && (
排除原因: {record.exclusionReason}
)} {record.finalDecisionBy && (
复核人:{record.finalDecisionBy} | 时间:{record.finalDecisionAt ? new Date(record.finalDecisionAt).toLocaleString('zh-CN') : '-'}
)}
)} ); }; // 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 (
); } // 加载中 if (statsLoading) { return (
); } // 加载错误 if (statsError || !stats) { return (
); } return (
{/* 标题 */}

标题摘要初筛 - 结果

筛选结果统计、PRISMA排除分析、批量操作和导出

{/* 1. 统计概览卡片 */} } /> } /> } /> 0 ? '#faad14' : '#999' }} prefix={} /> {stats.conflict > 0 && (
其中 {stats.conflict} 篇有冲突
)}
{/* 2. ⭐ 待复核提示 */} {stats.conflict > 0 && ( } className="mb-6" action={ } /> )} {/* 3. PRISMA排除原因统计 */} {stats.excluded > 0 && (
{Object.entries(stats.exclusionReasons) .sort(([, a], [, b]) => b - a) // 按数量降序 .map(([reason, count]) => (
{reason} {count}篇 ({((count / stats.excluded) * 100).toFixed(1)}%)
))}
)} {/* 4. 结果列表 */} {/* Tab切换 */} {selectedRowKeys.length > 0 && ( )} } /> {/* 表格 */} toggleRowExpanded(record.id), expandIcon: () => null, // 隐藏默认展开图标,使用标题点击 }} pagination={{ current: page, pageSize, total: resultsData?.data?.total || 0, onChange: handlePageChange, showSizeChanger: false, showTotal: (total) => `共 ${total} 条记录`, }} locale={{ emptyText: ( ), }} scroll={{ x: 870 }} /> ); }; /** * 辅助函数:从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;