Summary: - Implement 7 quick action functions (filter, recode, binning, conditional, dropna, compute, pivot) - Refactor to pre-written Python functions architecture (stable and secure) - Add 7 Python operations modules with full type hints - Add 7 frontend Dialog components with user-friendly UI - Fix NaN serialization issues and auto type conversion - Update all related documentation Technical Details: - Python: operations/ module (filter.py, recode.py, binning.py, conditional.py, dropna.py, compute.py, pivot.py) - Backend: QuickActionService.ts with 7 execute methods - Frontend: 7 Dialog components with complete validation - Toolbar: Enable 7 quick action buttons Status: Phase 1-2 completed, basic testing passed, ready for further testing
472 lines
13 KiB
TypeScript
472 lines
13 KiB
TypeScript
/**
|
||
* 全文复筛 - 结果页面
|
||
*
|
||
* 功能:
|
||
* 1. 统计概览卡片(总数/纳入/排除/待复核)
|
||
* 2. PRISMA式排除原因统计
|
||
* 3. Tab切换结果列表
|
||
* 4. Excel导出(前端生成)
|
||
*/
|
||
|
||
import { useState } from 'react';
|
||
import { useParams, useSearchParams } from 'react-router-dom';
|
||
import {
|
||
Card,
|
||
Statistic,
|
||
Row,
|
||
Col,
|
||
Tabs,
|
||
Table,
|
||
Button,
|
||
Alert,
|
||
Progress,
|
||
message,
|
||
Tooltip,
|
||
Empty,
|
||
Spin,
|
||
Tag,
|
||
Space,
|
||
} from 'antd';
|
||
import type { TableColumnsType } from 'antd';
|
||
import {
|
||
DownloadOutlined,
|
||
CheckCircleOutlined,
|
||
CloseCircleOutlined,
|
||
QuestionCircleOutlined,
|
||
WarningOutlined,
|
||
FileExcelOutlined,
|
||
} from '@ant-design/icons';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import ConclusionTag from '../components/ConclusionTag';
|
||
|
||
// 结果类型
|
||
interface FulltextResultItem {
|
||
resultId: string;
|
||
literature: {
|
||
id: string;
|
||
pmid?: string;
|
||
title: string;
|
||
authors?: string;
|
||
journal?: string;
|
||
year?: number;
|
||
};
|
||
aiConsensus: 'agree_include' | 'agree_exclude' | 'conflict';
|
||
fieldsPassRate: string; // "10/12"
|
||
exclusionReason?: string;
|
||
finalDecision: 'include' | 'exclude' | null;
|
||
reviewStatus: 'pending' | 'reviewed' | 'conflict';
|
||
}
|
||
|
||
const FulltextResults = () => {
|
||
const { taskId } = useParams<{ taskId: string }>();
|
||
const [searchParams] = useSearchParams();
|
||
const projectId = searchParams.get('projectId') || '';
|
||
|
||
const [activeTab, setActiveTab] = useState<'all' | 'included' | 'excluded' | 'pending'>('all');
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
|
||
|
||
// 获取统计数据
|
||
const { data: statsData, isLoading: statsLoading } = useQuery({
|
||
queryKey: ['fulltextResultsStats', taskId],
|
||
queryFn: async () => {
|
||
// TODO: 调用API
|
||
return {
|
||
total: 50,
|
||
included: 35,
|
||
excluded: 10,
|
||
pending: 5,
|
||
conflict: 3,
|
||
includedRate: 70,
|
||
excludedRate: 20,
|
||
pendingRate: 10,
|
||
exclusionReasons: {
|
||
'P不匹配(人群)': 2,
|
||
'I不匹配(干预)': 1,
|
||
'S不匹配(研究设计)': 3,
|
||
'12字段不完整': 4,
|
||
},
|
||
};
|
||
},
|
||
enabled: !!taskId,
|
||
});
|
||
|
||
// 获取结果列表
|
||
const { data: resultsData, isLoading: resultsLoading } = useQuery({
|
||
queryKey: ['fulltextResultsList', taskId, activeTab],
|
||
queryFn: async () => {
|
||
// TODO: 调用API
|
||
return {
|
||
items: [] as FulltextResultItem[],
|
||
total: 0,
|
||
};
|
||
},
|
||
enabled: !!taskId,
|
||
});
|
||
|
||
const stats = statsData;
|
||
const results = resultsData?.items || [];
|
||
|
||
// 导出Excel
|
||
const handleExport = async (filter: 'all' | 'included' | 'excluded' | 'pending') => {
|
||
try {
|
||
message.loading({ content: '正在生成Excel...', key: 'export' });
|
||
|
||
// TODO: 前端生成Excel或调用后端API
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
message.success({ content: '导出成功', key: 'export' });
|
||
} catch (error) {
|
||
message.error('导出失败: ' + (error as Error).message);
|
||
}
|
||
};
|
||
|
||
// 导出选中项
|
||
const handleExportSelected = () => {
|
||
if (selectedRowKeys.length === 0) {
|
||
message.warning('请先选择要导出的记录');
|
||
return;
|
||
}
|
||
message.success(`导出 ${selectedRowKeys.length} 条记录成功`);
|
||
};
|
||
|
||
// 表格列定义
|
||
const columns: TableColumnsType<FulltextResultItem> = [
|
||
{
|
||
title: '#',
|
||
width: 50,
|
||
render: (_, __, index) => index + 1,
|
||
},
|
||
{
|
||
title: '文献标题',
|
||
dataIndex: ['literature', 'title'],
|
||
width: 350,
|
||
ellipsis: { showTitle: false },
|
||
render: (text, record) => (
|
||
<Tooltip title={`${text}\n💡 点击展开查看详情`}>
|
||
<span
|
||
className="cursor-pointer text-blue-600 hover:underline"
|
||
onClick={() => toggleRowExpanded(record.resultId)}
|
||
>
|
||
{text}
|
||
</span>
|
||
</Tooltip>
|
||
),
|
||
},
|
||
{
|
||
title: 'AI共识',
|
||
dataIndex: 'aiConsensus',
|
||
width: 120,
|
||
align: 'center',
|
||
render: (value) => {
|
||
if (value === 'agree_include') {
|
||
return (
|
||
<div>
|
||
<Tag color="success">一致纳入</Tag>
|
||
<div className="text-xs text-gray-500">(DS✓ QW✓)</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (value === 'agree_exclude') {
|
||
return (
|
||
<div>
|
||
<Tag color="default">一致排除</Tag>
|
||
<div className="text-xs text-gray-500">(DS✗ QW✗)</div>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div>
|
||
<Tag color="warning">冲突</Tag>
|
||
<div className="text-xs text-gray-500">(DS≠QW)</div>
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '12字段通过率',
|
||
dataIndex: 'fieldsPassRate',
|
||
width: 120,
|
||
align: 'center',
|
||
render: (text) => {
|
||
const [pass, total] = text.split('/').map(Number);
|
||
const rate = (pass / total) * 100;
|
||
const color = rate >= 80 ? 'success' : rate >= 60 ? 'warning' : 'error';
|
||
return <Tag color={color}>{text}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '排除原因',
|
||
dataIndex: 'exclusionReason',
|
||
width: 150,
|
||
ellipsis: true,
|
||
render: (text) => text || '-',
|
||
},
|
||
{
|
||
title: '人工决策',
|
||
dataIndex: 'finalDecision',
|
||
width: 100,
|
||
align: 'center',
|
||
render: (value, record) => {
|
||
if (!value) {
|
||
return <span className="text-gray-400">未复核</span>;
|
||
}
|
||
return (
|
||
<div>
|
||
<ConclusionTag conclusion={value} />
|
||
<div className="text-xs text-gray-500 mt-1">
|
||
{record.aiConsensus === 'conflict' ? '(推翻冲突)' : '(与AI一致)'}
|
||
</div>
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'reviewStatus',
|
||
width: 90,
|
||
align: 'center',
|
||
render: (status) => {
|
||
const statusMap = {
|
||
conflict: { text: '有冲突', color: 'warning' },
|
||
reviewed: { text: '已复核', color: 'success' },
|
||
pending: { text: 'AI一致', color: 'default' },
|
||
};
|
||
const config = statusMap[status as keyof typeof statusMap];
|
||
return <Tag color={config.color}>{config.text}</Tag>;
|
||
},
|
||
},
|
||
];
|
||
|
||
// 切换行展开
|
||
const toggleRowExpanded = (key: React.Key) => {
|
||
setExpandedRowKeys((prev) =>
|
||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
||
);
|
||
};
|
||
|
||
// 展开行渲染
|
||
const expandedRowRender = (record: FulltextResultItem) => {
|
||
return (
|
||
<div className="p-4 bg-gray-50">
|
||
<Row gutter={16}>
|
||
<Col span={8}>
|
||
<div className="text-sm">
|
||
<div className="font-semibold mb-2">文献信息</div>
|
||
<div>PMID: {record.literature.pmid || '-'}</div>
|
||
<div>作者: {record.literature.authors || '-'}</div>
|
||
<div>期刊: {record.literature.journal || '-'} ({record.literature.year || '-'})</div>
|
||
</div>
|
||
</Col>
|
||
<Col span={16}>
|
||
<div className="text-sm">
|
||
<div className="font-semibold mb-2">AI评估摘要</div>
|
||
<div>AI共识: {record.aiConsensus === 'conflict' ? '存在冲突' : '意见一致'}</div>
|
||
<div>12字段通过率: {record.fieldsPassRate}</div>
|
||
{record.exclusionReason && <div>排除原因: {record.exclusionReason}</div>}
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
if (!taskId) {
|
||
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 (!stats) {
|
||
return (
|
||
<div className="p-6">
|
||
<Alert message="加载失败" description="无法加载统计数据" type="error" showIcon />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="p-6 max-w-[1600px] mx-auto">
|
||
{/* 标题 */}
|
||
<div className="mb-6">
|
||
<h1 className="text-2xl font-bold mb-2">全文复筛 - 结果</h1>
|
||
<p className="text-gray-500">12字段评估结果统计、PRISMA排除分析、批量导出</p>
|
||
</div>
|
||
|
||
{/* 统计概览 */}
|
||
<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>
|
||
|
||
{/* 待复核提示 */}
|
||
{stats.conflict > 0 && (
|
||
<Alert
|
||
message="有文献需要人工复核"
|
||
description={`还有 ${stats.conflict} 篇文献存在模型判断冲突,建议前往"审核工作台"进行人工复核`}
|
||
type="warning"
|
||
showIcon
|
||
className="mb-6"
|
||
/>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* 结果列表 */}
|
||
<Card>
|
||
<Tabs
|
||
activeKey={activeTab}
|
||
onChange={(key) => setActiveTab(key as any)}
|
||
items={[
|
||
{ key: 'all', label: `全部 (${stats.total})` },
|
||
{ key: 'included', label: `已纳入 (${stats.included})` },
|
||
{ key: 'excluded', label: `已排除 (${stats.excluded})` },
|
||
{ key: 'pending', label: `待复核 (${stats.pending})` },
|
||
]}
|
||
tabBarExtraContent={
|
||
<Space>
|
||
<Button icon={<DownloadOutlined />} onClick={() => handleExport(activeTab)}>
|
||
导出当前
|
||
</Button>
|
||
{selectedRowKeys.length > 0 && (
|
||
<Button
|
||
type="primary"
|
||
icon={<DownloadOutlined />}
|
||
onClick={handleExportSelected}
|
||
>
|
||
导出选中 ({selectedRowKeys.length})
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
}
|
||
/>
|
||
|
||
<Table
|
||
rowSelection={{
|
||
selectedRowKeys,
|
||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||
}}
|
||
columns={columns}
|
||
dataSource={results}
|
||
rowKey="resultId"
|
||
loading={resultsLoading}
|
||
expandable={{
|
||
expandedRowRender,
|
||
expandedRowKeys,
|
||
onExpand: (expanded, record) => toggleRowExpanded(record.resultId),
|
||
expandIcon: () => null,
|
||
}}
|
||
pagination={{
|
||
pageSize: 20,
|
||
showSizeChanger: false,
|
||
showTotal: (total) => `共 ${total} 条记录`,
|
||
}}
|
||
locale={{
|
||
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />,
|
||
}}
|
||
scroll={{ x: 1100 }}
|
||
bordered
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default FulltextResults;
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|