Files
AIclinicalresearch/frontend-v2/src/modules/asl/pages/FulltextResults.tsx
HaHafeng f729699510 feat(dc): Complete Tool C quick action buttons Phase 1-2 - 7 functions
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
2025-12-08 17:38:08 +08:00

472 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全文复筛 - 结果页面
*
* 功能:
* 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">(DSQW)</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">12PRISMA排除分析</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;