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

@@ -143,3 +143,7 @@ export const PermissionProvider = ({ children }: PermissionProviderProps) => {

View File

@@ -18,3 +18,7 @@ export { VERSION_LEVEL, checkVersionLevel } from './types'

View File

@@ -90,3 +90,7 @@ export const checkVersionLevel = (

View File

@@ -47,3 +47,7 @@ export type { UserInfo, UserVersion, PermissionContextType } from './types'

View File

@@ -157,3 +157,7 @@ export default PermissionDenied

View File

@@ -146,3 +146,7 @@ export default RouteGuard

View File

@@ -16,3 +16,7 @@ export { default as PermissionDenied } from './PermissionDenied'

View File

@@ -21,3 +21,7 @@ export default AIAModule

View File

@@ -8,7 +8,6 @@ import type {
ScreeningProject,
CreateProjectRequest,
Literature,
ImportLiteraturesRequest,
ScreeningResult,
ScreeningTask,
ApiResponse,
@@ -33,10 +32,20 @@ async function request<T = any>(
});
if (!response.ok) {
const error = await response.json().catch(() => ({
message: 'Network error'
}));
throw new Error(error.message || `HTTP ${response.status}`);
// 尝试解析错误响应
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
} catch (e) {
// 如果响应体不是JSON使用状态文本
const text = await response.text().catch(() => '');
if (text) {
errorMessage = text;
}
}
console.error('❌ API请求失败:', { url: `${API_BASE_URL}${url}`, status: response.status, error: errorMessage });
throw new Error(errorMessage);
}
return response.json();
@@ -251,6 +260,69 @@ export async function getProjectStatistics(
return request(`/projects/${projectId}/statistics`);
}
// ==================== Day 3 新增API ====================
/**
* 获取筛选任务进度(新)
* GET /projects/:projectId/screening-task
*/
export async function getScreeningTask(
projectId: string
): Promise<ApiResponse<ScreeningTask>> {
return request(`/projects/${projectId}/screening-task`);
}
/**
* 获取筛选结果列表(新,支持分页和筛选)
* GET /projects/:projectId/screening-results
*/
export async function getScreeningResultsList(
projectId: string,
params?: {
page?: number;
pageSize?: number;
filter?: 'all' | 'conflict' | 'included' | 'excluded' | 'pending' | 'reviewed';
}
): Promise<ApiResponse<{
items: ScreeningResult[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}>> {
const queryString = new URLSearchParams(
params as Record<string, string>
).toString();
return request(`/projects/${projectId}/screening-results?${queryString}`);
}
/**
* 获取单个筛选结果详情(新)
* GET /screening-results/:resultId
*/
export async function getScreeningResultDetail(
resultId: string
): Promise<ApiResponse<ScreeningResult>> {
return request(`/screening-results/${resultId}`);
}
/**
* 提交人工复核(新)
* POST /screening-results/:resultId/review
*/
export async function reviewScreeningResult(
resultId: string,
data: {
decision: 'include' | 'exclude';
note?: string;
}
): Promise<ApiResponse<ScreeningResult>> {
return request(`/screening-results/${resultId}/review`, {
method: 'POST',
body: JSON.stringify(data),
});
}
// ==================== 健康检查API ====================
/**
@@ -284,11 +356,15 @@ export const aslApi = {
// 筛选任务
startScreening,
getTaskProgress,
getScreeningTask, // Day 3 新增
// 筛选结果
getScreeningResults,
getScreeningResultsList, // Day 3 新增(分页版本)
getScreeningResultDetail, // Day 3 新增
updateScreeningResult,
batchUpdateScreeningResults,
reviewScreeningResult, // Day 3 新增(人工复核)
// 导出
exportScreeningResults,

View File

@@ -151,3 +151,7 @@ const ASLLayout = () => {
export default ASLLayout;

View File

@@ -0,0 +1,74 @@
/**
* 结论标签组件
* 用于显示最终筛选决策(纳入/排除/不确定)
*/
import { Tag } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import type { ConclusionType } from '../types';
interface ConclusionTagProps {
conclusion: ConclusionType;
showIcon?: boolean;
size?: 'small' | 'middle' | 'large';
}
const ConclusionTag: React.FC<ConclusionTagProps> = ({
conclusion,
showIcon = true,
size = 'middle',
}) => {
const getConfig = () => {
switch (conclusion) {
case 'include':
return {
color: 'success',
icon: <CheckCircleOutlined />,
text: '纳入',
};
case 'exclude':
return {
color: 'default',
icon: <CloseCircleOutlined />,
text: '排除',
};
case 'uncertain':
return {
color: 'warning',
icon: <QuestionCircleOutlined />,
text: '不确定',
};
default:
return {
color: 'default',
icon: <QuestionCircleOutlined />,
text: '未处理',
};
}
};
const config = getConfig();
const fontSize = size === 'large' ? 'text-base' : size === 'small' ? 'text-xs' : 'text-sm';
return (
<Tag
color={config.color}
icon={showIcon ? config.icon : undefined}
className={`m-0 font-semibold ${fontSize}`}
>
{config.text}
</Tag>
);
};
export default ConclusionTag;

View File

@@ -0,0 +1,368 @@
/**
* 文献详情与复核 Drawer统一组件
*
* 布局:
* - 左侧70%):文献信息 + 双模型详细对比
* - 右侧30%):人工复核区域(固定,一眼可见)
*/
import { useState } from 'react';
import {
Drawer,
Row,
Col,
Descriptions,
Card,
Tag,
Typography,
Alert,
Radio,
Input,
Button,
Space,
Divider,
} from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import JudgmentBadge from './JudgmentBadge';
import ConclusionTag from './ConclusionTag';
import type { ScreeningResult } from '../types';
const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input;
interface DetailReviewDrawerProps {
visible: boolean;
result: ScreeningResult | null;
onClose: () => void;
onSubmitReview: (resultId: string, decision: 'include' | 'exclude', note?: string) => void;
isReviewing?: boolean;
}
const DetailReviewDrawer: React.FC<DetailReviewDrawerProps> = ({
visible,
result,
onClose,
onSubmitReview,
isReviewing = false,
}) => {
const [decision, setDecision] = useState<'include' | 'exclude' | null>(null);
const [note, setNote] = useState('');
if (!result) return null;
const hasConflict = result.conflictStatus === 'conflict';
const alreadyReviewed = !!result.finalDecision;
const handleSubmit = () => {
if (!decision) return;
onSubmitReview(result.id, decision, note);
// 清空表单
setDecision(null);
setNote('');
};
const handleClose = () => {
setDecision(null);
setNote('');
onClose();
};
return (
<Drawer
title="文献详情与复核"
width={1000}
open={visible}
onClose={handleClose}
destroyOnClose
>
<Row gutter={24}>
{/* 左侧:详情区域 (70%) */}
<Col span={17}>
{/* 文献基本信息 */}
<Card size="small" className="mb-4">
<Title level={5}></Title>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="标题" span={2}>
<Text strong>{result.literature.title}</Text>
</Descriptions.Item>
<Descriptions.Item label="作者">
{result.literature.authors || '-'}
</Descriptions.Item>
<Descriptions.Item label="期刊">
{result.literature.journal || '-'}
</Descriptions.Item>
<Descriptions.Item label="年份">
{result.literature.publicationYear || '-'}
</Descriptions.Item>
<Descriptions.Item label="PMID">
{result.literature.pmid || '-'}
</Descriptions.Item>
<Descriptions.Item label="摘要" span={2}>
<Paragraph
ellipsis={{ rows: 3, expandable: true, symbol: '展开' }}
className="mb-0"
>
{result.literature.abstract}
</Paragraph>
</Descriptions.Item>
</Descriptions>
</Card>
{/* AI判断对比 */}
<Title level={5}>AI判断对比</Title>
{/* DeepSeek */}
<Card
size="small"
className="mb-3"
style={{ borderLeft: '3px solid #1890ff' }}
>
<div className="flex items-center justify-between mb-3">
<Space>
<Tag color="blue">DeepSeek-V3</Tag>
<ConclusionTag conclusion={result.dsConclusion} />
</Space>
{result.dsConfidence !== null && (
<Text type="secondary">
: {(result.dsConfidence * 100).toFixed(0)}%
</Text>
)}
</div>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="P-人群">
<JudgmentBadge judgment={result.dsPJudgment} showIcon={false} />
</Descriptions.Item>
<Descriptions.Item label="证据">
<Text className="text-xs">{result.dsPEvidence || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="I-干预">
<JudgmentBadge judgment={result.dsIJudgment} showIcon={false} />
</Descriptions.Item>
<Descriptions.Item label="证据">
<Text className="text-xs">{result.dsIEvidence || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="C-对照">
<JudgmentBadge judgment={result.dsCJudgment} showIcon={false} />
</Descriptions.Item>
<Descriptions.Item label="证据">
<Text className="text-xs">{result.dsCEvidence || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="S-研究设计">
<JudgmentBadge judgment={result.dsSJudgment} showIcon={false} />
</Descriptions.Item>
<Descriptions.Item label="证据">
<Text className="text-xs">{result.dsSEvidence || '-'}</Text>
</Descriptions.Item>
</Descriptions>
{result.dsReason && (
<div className="mt-3 p-2 bg-blue-50 rounded">
<Text strong className="text-sm">:</Text>
<Paragraph className="mt-1 mb-0 text-sm whitespace-pre-wrap">
{result.dsReason}
</Paragraph>
</div>
)}
</Card>
{/* Qwen */}
<Card
size="small"
style={{ borderLeft: '3px solid #722ed1' }}
>
<div className="flex items-center justify-between mb-3">
<Space>
<Tag color="purple">Qwen-Max</Tag>
<ConclusionTag conclusion={result.qwenConclusion} />
</Space>
{result.qwenConfidence !== null && (
<Text type="secondary">
: {(result.qwenConfidence * 100).toFixed(0)}%
</Text>
)}
</div>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="P-人群">
<JudgmentBadge judgment={result.qwenPJudgment} showIcon={false} />
</Descriptions.Item>
<Descriptions.Item label="证据">
<Text className="text-xs">{result.qwenPEvidence || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="I-干预">
<JudgmentBadge judgment={result.qwenIJudgment} showIcon={false} />
</Descriptions.Item>
<Descriptions.Item label="证据">
<Text className="text-xs">{result.qwenIEvidence || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="C-对照">
<JudgmentBadge judgment={result.qwenCJudgment} showIcon={false} />
</Descriptions.Item>
<Descriptions.Item label="证据">
<Text className="text-xs">{result.qwenCEvidence || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="S-研究设计">
<JudgmentBadge judgment={result.qwenSJudgment} showIcon={false} />
</Descriptions.Item>
<Descriptions.Item label="证据">
<Text className="text-xs">{result.qwenSEvidence || '-'}</Text>
</Descriptions.Item>
</Descriptions>
{result.qwenReason && (
<div className="mt-3 p-2 bg-purple-50 rounded">
<Text strong className="text-sm">:</Text>
<Paragraph className="mt-1 mb-0 text-sm whitespace-pre-wrap">
{result.qwenReason}
</Paragraph>
</div>
)}
</Card>
</Col>
{/* 右侧:人工复核区域 (30%) */}
<Col span={7}>
<div
className="sticky top-0"
style={{
border: '1px solid #d9d9d9',
borderRadius: '4px',
padding: '16px',
backgroundColor: '#fafafa',
}}
>
<Title level={5} className="mt-0">
👉
</Title>
{/* 冲突提示 */}
{hasConflict && (
<Alert
message="检测到冲突"
description="两个模型的结论不一致,建议仔细查看详情后做出决策。"
type="warning"
icon={<WarningOutlined />}
showIcon
className="mb-4"
/>
)}
{/* 已复核提示 */}
{alreadyReviewed && (
<Alert
message="已复核"
description={
<div>
<div>: <ConclusionTag conclusion={result.finalDecision} /></div>
{result.exclusionReason && (
<div className="mt-2">: {result.exclusionReason}</div>
)}
<div className="mt-2 text-xs text-gray-500">
{result.finalDecisionBy} · {result.finalDecisionAt}
</div>
</div>
}
type="success"
showIcon
className="mb-4"
/>
)}
<Divider className="my-4" />
{/* 决策选择 */}
<div className="mb-4">
<Text strong></Text>
<Radio.Group
value={decision}
onChange={(e) => setDecision(e.target.value)}
className="mt-2 w-full"
>
<Space direction="vertical" className="w-full">
<Radio value="include" className="w-full">
<CheckCircleOutlined className="text-green-600 mr-1" />
</Radio>
<Radio value="exclude" className="w-full">
<CloseCircleOutlined className="text-gray-600 mr-1" />
</Radio>
</Space>
</Radio.Group>
</div>
{/* 备注 */}
<div className="mb-4">
<Text strong></Text>
<TextArea
rows={4}
placeholder="请输入复核备注,例如排除原因、特殊说明等..."
value={note}
onChange={(e) => setNote(e.target.value)}
className="mt-2"
/>
</div>
{/* 操作按钮 */}
<Space direction="vertical" className="w-full">
<Button
type="primary"
block
size="large"
disabled={!decision}
loading={isReviewing}
onClick={handleSubmit}
>
</Button>
<Button
block
onClick={handleClose}
>
</Button>
</Space>
{/* AI对比摘要 */}
<Divider className="my-4" />
<Card size="small" className="bg-white">
<Text strong className="text-xs">AI判断摘要</Text>
<div className="mt-2 text-xs">
<div className="flex justify-between mb-1">
<span>DeepSeek:</span>
<ConclusionTag
conclusion={result.dsConclusion}
size="small"
/>
</div>
<div className="flex justify-between">
<span>Qwen:</span>
<ConclusionTag
conclusion={result.qwenConclusion}
size="small"
/>
</div>
</div>
</Card>
</div>
</Col>
</Row>
</Drawer>
);
};
export default DetailReviewDrawer;

View File

@@ -0,0 +1,85 @@
/**
* 判断结果Badge组件
* 用于显示PICOS各维度的判断结果
*/
import { Tag, Tooltip } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import type { JudgmentType } from '../types';
interface JudgmentBadgeProps {
judgment: JudgmentType;
evidence?: string | null;
showIcon?: boolean;
}
const JudgmentBadge: React.FC<JudgmentBadgeProps> = ({
judgment,
evidence,
showIcon = true,
}) => {
// 根据判断结果返回相应的配置
const getConfig = () => {
switch (judgment) {
case 'match':
return {
color: 'success',
icon: <CheckCircleOutlined />,
text: '匹配',
};
case 'partial':
return {
color: 'warning',
icon: <ExclamationCircleOutlined />,
text: '部分',
};
case 'mismatch':
return {
color: 'error',
icon: <CloseCircleOutlined />,
text: '不匹配',
};
default:
return {
color: 'default',
icon: <QuestionCircleOutlined />,
text: '未知',
};
}
};
const config = getConfig();
const badge = (
<Tag
color={config.color}
icon={showIcon ? config.icon : undefined}
className="m-0"
>
{config.text}
</Tag>
);
// 如果有证据显示Tooltip
if (evidence) {
return (
<Tooltip title={`证据: ${evidence}`} placement="top">
{badge}
</Tooltip>
);
}
return badge;
};
export default JudgmentBadge;

View File

@@ -0,0 +1,78 @@
/**
* 筛选结果列表Hook
* 用于获取和管理筛选结果列表(支持分页和筛选)
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { aslApi } from '../api';
interface UseScreeningResultsOptions {
projectId: string;
page?: number;
pageSize?: number;
filter?: 'all' | 'conflict' | 'included' | 'excluded' | 'reviewed';
enabled?: boolean;
}
/**
* 使用筛选结果Hook
*/
export function useScreeningResults({
projectId,
page = 1,
pageSize = 50,
filter = 'all',
enabled = true,
}: UseScreeningResultsOptions) {
const queryClient = useQueryClient();
// 查询筛选结果列表
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['screening-results', projectId, page, pageSize, filter],
queryFn: () => aslApi.getScreeningResultsList(projectId, { page, pageSize, filter }),
enabled: enabled && !!projectId,
staleTime: 1000 * 30, // 30秒内认为数据是新鲜的
keepPreviousData: true, // 切换页面时保留上一页数据,避免闪烁
});
const results = data?.data?.items || [];
const total = data?.data?.total || 0;
const totalPages = data?.data?.totalPages || 0;
// 人工复核Mutation
const reviewMutation = useMutation({
mutationFn: ({ resultId, decision, note }: {
resultId: string;
decision: 'include' | 'exclude';
note?: string;
}) => aslApi.reviewScreeningResult(resultId, { decision, note }),
onSuccess: () => {
// 刷新列表
queryClient.invalidateQueries({ queryKey: ['screening-results', projectId] });
message.success('复核提交成功');
},
onError: (error: any) => {
message.error(`复核失败: ${error.message}`);
},
});
return {
results,
total,
totalPages,
page,
pageSize,
isLoading,
error,
refetch,
// 人工复核方法
review: reviewMutation.mutate,
isReviewing: reviewMutation.isPending,
};
}

View File

@@ -0,0 +1,68 @@
/**
* 筛选任务轮询Hook
* 用于获取和轮询筛选任务进度
*/
import { useQuery } from '@tanstack/react-query';
import { aslApi } from '../api';
interface UseScreeningTaskOptions {
projectId: string;
enabled?: boolean;
pollingInterval?: number; // 轮询间隔毫秒默认1000
}
/**
* 使用筛选任务Hook
*/
export function useScreeningTask({
projectId,
enabled = true,
pollingInterval = 1000,
}: UseScreeningTaskOptions) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['screening-task', projectId],
queryFn: () => aslApi.getScreeningTask(projectId),
enabled: enabled && !!projectId,
refetchInterval: (query) => {
const task = query.state.data?.data;
// 如果任务已完成或失败,停止轮询
if (task?.status === 'completed' || task?.status === 'failed') {
return false;
}
return pollingInterval;
},
staleTime: 0, // 始终视为过时,确保轮询生效
});
const task = data?.data;
// 计算进度百分比
const progress = task
? Math.round((task.processedItems / task.totalItems) * 100)
: 0;
// 判断是否正在运行
const isRunning = task?.status === 'running' || task?.status === 'pending';
// 判断是否已完成
const isCompleted = task?.status === 'completed';
// 判断是否失败
const isFailed = task?.status === 'failed';
return {
task,
progress,
isRunning,
isCompleted,
isFailed,
isLoading,
error,
refetch,
};
}

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;

View File

@@ -1,42 +1,594 @@
/**
* 标题摘要初筛 - 审核工作台页面
* TODO: Week 2 Day 3-4 开发
* 审核工作台页面
*
* 功能:
* - 显示当前PICOS标准折叠面板
* - 双行表格(严格按照原型
* - 点击PICO维度 → 弹出双视图Modal
* - 修改最终决策
* 1. 显示任务进度(轮询
* 2. 双行表格展示筛选结果DeepSeek + Qwen
* 3. 冲突高亮显示
* 4. 人工复核功能
*/
import { Card, Empty, Alert } from 'antd';
import { useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import {
Card,
Table,
Tag,
Button,
Tabs,
Progress,
Row,
Col,
Tooltip,
Empty,
Spin,
} from 'antd';
import {
ReloadOutlined,
EditOutlined,
WarningOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useScreeningTask } from '../hooks/useScreeningTask';
import { useScreeningResults } from '../hooks/useScreeningResults';
import { transformToDoubleRows } from '../utils/tableTransform';
import JudgmentBadge from '../components/JudgmentBadge';
import ConclusionTag from '../components/ConclusionTag';
import DetailReviewDrawer from '../components/DetailReviewDrawer';
import type { DoubleRowData, ScreeningResult } from '../types';
const ScreeningWorkbench = () => {
// 从路由state或URL参数获取projectId
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const projectId =
(location.state as { projectId?: string })?.projectId ||
searchParams.get('projectId') ||
'';
// 状态管理
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20); // 20条文献40行数据
const [filter, setFilter] = useState<'all' | 'conflict' | 'included' | 'excluded' | 'reviewed'>('all');
const [selectedResult, setSelectedResult] = useState<ScreeningResult | null>(null);
const [drawerVisible, setDrawerVisible] = useState(false);
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]); // 展开的行
// 使用Hooks
const {
task,
progress,
isRunning,
isCompleted,
isFailed,
isLoading: taskLoading,
refetch: refetchTask,
} = useScreeningTask({
projectId: projectId || '',
enabled: !!projectId,
});
const {
results,
total,
isLoading: resultsLoading,
refetch: refetchResults,
review,
isReviewing,
} = useScreeningResults({
projectId: projectId || '',
page: currentPage,
pageSize,
filter,
enabled: !!projectId && isCompleted, // 只有任务完成后才查询结果
});
// 转换为双行表格数据
const tableData = transformToDoubleRows(results);
// 如果没有projectId显示错误
if (!projectId) {
return (
<div className="flex items-center justify-center h-full">
<Empty
description="未找到项目ID请从设置页面启动筛选"
/>
</div>
);
}
// 处理提交复核
const handleSubmitReview = (resultId: string, decision: 'include' | 'exclude', note?: string) => {
review({ resultId, decision, note });
setDrawerVisible(false);
setSelectedResult(null);
};
// 处理行展开/收起
const toggleRowExpanded = (recordKey: React.Key) => {
setExpandedRowKeys((prevKeys) => {
if (prevKeys.includes(recordKey)) {
// 如果已展开,则收起
return prevKeys.filter((key) => key !== recordKey);
} else {
// 如果未展开,则展开
return [...prevKeys, recordKey];
}
});
};
// 双行表格列定义
const columns: ColumnsType<DoubleRowData> = [
{
title: '#',
dataIndex: 'literatureIndex',
key: 'index',
width: 60,
align: 'center',
onCell: (record) => ({
rowSpan: record.isFirstRow ? 2 : 0,
}),
render: (value) => value,
},
{
title: '文献标题',
dataIndex: 'literatureTitle',
key: 'title',
width: 280,
ellipsis: { showTitle: false },
onCell: (record) => ({
rowSpan: record.isFirstRow ? 2 : 0,
}),
render: (text: string, record) => {
if (!record.isFirstRow) return null;
const isExpanded = expandedRowKeys.includes(record.key);
return (
<Tooltip
title={
<div>
<div>{text}</div>
<div className="text-xs text-gray-300 mt-1">
💡 AI判断证据
</div>
</div>
}
placement="topLeft"
>
<div
className="flex items-start cursor-pointer hover:text-blue-600 transition-colors"
onClick={() => toggleRowExpanded(record.key)}
>
{record.hasConflict && (
<WarningOutlined className="text-red-500 mr-1 flex-shrink-0 mt-0.5" />
)}
<span className="text-sm">
{isExpanded ? '📖 ' : '📕 '}
{text}
</span>
</div>
</Tooltip>
);
},
},
{
title: '结论',
dataIndex: 'conclusion',
key: 'conclusion',
width: 90,
align: 'center',
render: (value, record) => (
<div>
<ConclusionTag conclusion={value} />
{record.confidence !== null && (
<div className="text-xs text-gray-500 mt-1">
{(record.confidence * 100).toFixed(0)}%
</div>
)}
</div>
),
},
{
title: '操作',
key: 'actions',
width: 80,
align: 'center',
onCell: (record) => ({
rowSpan: record.isFirstRow ? 2 : 0,
}),
render: (_, record) => {
if (!record.isFirstRow) return null;
const hasConflict = record.hasConflict;
return (
<Button
size="small"
type={hasConflict ? 'primary' : 'default'}
icon={<EditOutlined />}
onClick={() => {
setSelectedResult(record.originalResult);
setDrawerVisible(true);
}}
>
</Button>
);
},
},
{
title: '模型',
dataIndex: 'modelName',
key: 'model',
width: 60,
align: 'center',
render: (text: string) => {
const shortName = text === 'DeepSeek-V3' ? 'DS' : 'Qw';
const color = text === 'DeepSeek-V3' ? 'blue' : 'purple';
return (
<Tooltip title={text}>
<Tag color={color} className="cursor-help">{shortName}</Tag>
</Tooltip>
);
},
},
{
title: 'P',
dataIndex: 'P',
key: 'P',
width: 60,
align: 'center',
render: (value) => <JudgmentBadge judgment={value} showIcon={false} />,
},
{
title: 'I',
dataIndex: 'I',
key: 'I',
width: 60,
align: 'center',
render: (value) => <JudgmentBadge judgment={value} showIcon={false} />,
},
{
title: 'C',
dataIndex: 'C',
key: 'C',
width: 60,
align: 'center',
render: (value) => <JudgmentBadge judgment={value} showIcon={false} />,
},
{
title: 'S',
dataIndex: 'S',
key: 'S',
width: 60,
align: 'center',
render: (value) => <JudgmentBadge judgment={value} showIcon={false} />,
},
];
// 设置行样式(冲突行高亮)
const rowClassName = (record: DoubleRowData) => {
if (record.hasConflict) {
return 'bg-red-50';
}
return '';
};
// 展开行渲染:显示双模型的 PICOS 证据详情
const expandedRowRender = (record: DoubleRowData) => {
if (!record.isFirstRow || !record.originalResult) return null;
const result = record.originalResult;
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>
<Tag color="blue">DeepSeek-V3</Tag>
<ConclusionTag conclusion={result.dsConclusion} />
</span>
{result.dsConfidence !== null && (
<span className="text-xs text-gray-500">
: {(result.dsConfidence * 100).toFixed(0)}%
</span>
)}
</div>
}
className="h-full"
>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-600">P - </span>
<JudgmentBadge judgment={result.dsPJudgment} showIcon={false} />
</div>
<p className="text-xs text-gray-700 bg-white p-2 rounded border">
{result.dsPEvidence || '未提供证据'}
</p>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-600">I - </span>
<JudgmentBadge judgment={result.dsIJudgment} showIcon={false} />
</div>
<p className="text-xs text-gray-700 bg-white p-2 rounded border">
{result.dsIEvidence || '未提供证据'}
</p>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-600">C - </span>
<JudgmentBadge judgment={result.dsCJudgment} showIcon={false} />
</div>
<p className="text-xs text-gray-700 bg-white p-2 rounded border">
{result.dsCEvidence || '未提供证据'}
</p>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-600">S - </span>
<JudgmentBadge judgment={result.dsSJudgment} showIcon={false} />
</div>
<p className="text-xs text-gray-700 bg-white p-2 rounded border">
{result.dsSEvidence || '未提供证据'}
</p>
</div>
{result.dsReason && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-semibold text-gray-600 mb-1"></p>
<p className="text-xs text-gray-700 bg-blue-50 p-2 rounded">
{result.dsReason}
</p>
</div>
)}
</div>
</Card>
</Col>
{/* Qwen 证据 */}
<Col span={12}>
<Card
size="small"
title={
<div className="flex items-center justify-between">
<span>
<Tag color="purple">Qwen-Max</Tag>
<ConclusionTag conclusion={result.qwenConclusion} />
</span>
{result.qwenConfidence !== null && (
<span className="text-xs text-gray-500">
: {(result.qwenConfidence * 100).toFixed(0)}%
</span>
)}
</div>
}
className="h-full"
>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-600">P - </span>
<JudgmentBadge judgment={result.qwenPJudgment} showIcon={false} />
</div>
<p className="text-xs text-gray-700 bg-white p-2 rounded border">
{result.qwenPEvidence || '未提供证据'}
</p>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-600">I - </span>
<JudgmentBadge judgment={result.qwenIJudgment} showIcon={false} />
</div>
<p className="text-xs text-gray-700 bg-white p-2 rounded border">
{result.qwenIEvidence || '未提供证据'}
</p>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-600">C - </span>
<JudgmentBadge judgment={result.qwenCJudgment} showIcon={false} />
</div>
<p className="text-xs text-gray-700 bg-white p-2 rounded border">
{result.qwenCEvidence || '未提供证据'}
</p>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-600">S - </span>
<JudgmentBadge judgment={result.qwenSJudgment} showIcon={false} />
</div>
<p className="text-xs text-gray-700 bg-white p-2 rounded border">
{result.qwenSEvidence || '未提供证据'}
</p>
</div>
{result.qwenReason && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-semibold text-gray-600 mb-1"></p>
<p className="text-xs text-gray-700 bg-purple-50 p-2 rounded">
{result.qwenReason}
</p>
</div>
)}
</div>
</Card>
</Col>
</Row>
</div>
);
};
const TitleScreeningWorkbench = () => {
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2"> - </h1>
<p className="text-gray-500">
</p>
<div className="p-6 max-w-[1800px] mx-auto">
{/* 页面标题 */}
<div className="mb-6 flex justify-between items-start">
<div>
<h2 className="text-2xl font-bold"></h2>
<p className="text-gray-500 mt-1"></p>
</div>
{isCompleted && projectId && (
<Button
type="primary"
icon={<BarChartOutlined />}
onClick={() => navigate(`/literature/screening/title/results?projectId=${projectId}`)}
>
</Button>
)}
</div>
<Card>
<Alert
message="功能开发中"
description="Week 2 Day 3-4 将实现双行表格、双视图Modal、人工复核等功能"
type="info"
showIcon
className="mb-4"
/>
<Empty
description="审核工作台(开发中)"
image={Empty.PRESENTED_IMAGE_SIMPLE}
{/* 任务进度卡片 */}
<Card className="mb-6" loading={taskLoading}>
<Row gutter={16} align="middle">
<Col flex="auto">
<div className="mb-2">
<span className="font-semibold mr-2">:</span>
{isRunning && <Tag color="processing"></Tag>}
{isCompleted && <Tag color="success"></Tag>}
{isFailed && <Tag color="error"></Tag>}
</div>
<Progress
percent={progress}
status={isFailed ? 'exception' : isCompleted ? 'success' : 'active'}
strokeColor={{ '0%': '#108ee9', '100%': '#87d068' }}
/>
{task && (
<>
<div className="text-sm text-gray-500 mt-2">
: {task.processedItems} / {task.totalItems} ·
: {task.successItems} ·
: {task.conflictItems} ·
: {task.failedItems}
</div>
<div className="text-xs text-gray-400 mt-1">
<Tag color="blue" className="text-xs">DeepSeek-V3</Tag>
{task.processedItems} ·
<Tag color="purple" className="text-xs">Qwen-Max</Tag>
{task.processedItems}
</div>
</>
)}
</Col>
<Col>
<Button
icon={<ReloadOutlined />}
onClick={() => {
refetchTask();
refetchResults();
}}
>
</Button>
</Col>
</Row>
</Card>
{/* 筛选Tab */}
<Card className="mb-6">
<Tabs
activeKey={filter}
onChange={(key) => {
setFilter(key as any);
setCurrentPage(1);
}}
items={[
{
key: 'all',
label: '全部',
},
{
key: 'conflict',
label: (
<span>
<WarningOutlined className="mr-1" />
</span>
),
},
{
key: 'included',
label: '已纳入',
},
{
key: 'excluded',
label: '已排除',
},
{
key: 'reviewed',
label: '已复核',
},
]}
/>
</Card>
{/* 双行表格 */}
<Card>
{isRunning ? (
<div className="text-center py-12">
<Spin size="large" />
<div className="mt-4 text-gray-500">
...
</div>
</div>
) : (
<Table<DoubleRowData>
columns={columns}
dataSource={tableData}
rowKey="key"
rowClassName={rowClassName}
loading={resultsLoading}
pagination={{
current: currentPage,
pageSize: pageSize * 2, // 每篇文献2行
total: total * 2,
showSizeChanger: false,
showTotal: (total) => `${Math.floor(total / 2)} 篇文献`,
onChange: (page) => setCurrentPage(page),
}}
expandable={{
expandedRowRender,
expandedRowKeys,
onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]),
rowExpandable: (record) => record.isFirstRow, // 只有第一行可以展开
expandRowByClick: false, // 不支持点击行展开,只能点击展开图标或标题
}}
bordered
size="middle"
scroll={{ x: 850 }}
/>
)}
</Card>
{/* 详情与复核 Drawer */}
<DetailReviewDrawer
visible={drawerVisible}
result={selectedResult}
onClose={() => {
setDrawerVisible(false);
setSelectedResult(null);
}}
onSubmitReview={handleSubmitReview}
isReviewing={isReviewing}
/>
</div>
);
};
export default TitleScreeningWorkbench;
export default ScreeningWorkbench;

View File

@@ -96,12 +96,8 @@ const TitleScreeningSettings = () => {
originFileObj: file as any,
}]);
// 检查是否可以启动筛选
const formValid = await form.validateFields()
.then(() => true)
.catch(() => false);
setCanStart(formValid && valid.length > 0);
// 简化启动条件判断:只要有有效文献就可以启动(表单验证在提交时进行)
setCanStart(valid.length > 0);
message.success({
content: `Excel解析成功${statistics.total} 条,有效 ${statistics.afterDedup}`,
@@ -170,7 +166,8 @@ const TitleScreeningSettings = () => {
});
if (!createProjectResponse.success || !createProjectResponse.data) {
throw new Error('项目创建失败');
const errorMsg = (createProjectResponse as any).error || '项目创建失败';
throw new Error(errorMsg);
}
const projectId = createProjectResponse.data.id;
@@ -183,11 +180,12 @@ const TitleScreeningSettings = () => {
literatures: literatures.map(lit => ({
title: lit.title,
abstract: lit.abstract,
pmid: lit.pmid,
authors: lit.authors,
publicationYear: lit.year,
journal: lit.journal,
publicationYear: lit.publicationYear,
authors: lit.authors,
pmid: lit.pmid,
doi: lit.doi,
// 注意后端目前只支持上述字段key和language暂存在前端或future扩展
})),
});
@@ -223,20 +221,37 @@ const TitleScreeningSettings = () => {
/**
* 文献预览表格列定义
* 优先级title和abstract最宽authors最窄
*/
const literatureColumns = [
{
title: '#',
dataIndex: 'tempId',
key: 'index',
width: 60,
width: 50,
align: 'center' as const,
render: (_: any, __: any, index: number) => index + 1,
},
{
title: '文献ID',
dataIndex: 'key',
key: 'key',
width: 80,
ellipsis: { showTitle: false },
render: (text: string) => {
const value = text || '-';
return (
<Tooltip title={value}>
<span>{value}</span>
</Tooltip>
);
},
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: '35%',
width: 400, // 增加宽度,重要信息
ellipsis: { showTitle: false },
render: (text: string) => (
<Tooltip title={text} placement="topLeft">
@@ -244,40 +259,69 @@ const TitleScreeningSettings = () => {
</Tooltip>
),
},
{
title: '摘要',
dataIndex: 'abstract',
key: 'abstract',
width: '30%',
ellipsis: { showTitle: false },
render: (text: string) => (
<Tooltip title={text} placement="topLeft">
<span>{text.substring(0, 100)}...</span>
</Tooltip>
),
},
{
title: 'PMID',
dataIndex: 'pmid',
key: 'pmid',
width: 100,
render: (text: string) => text || '-',
},
{
title: '年份',
dataIndex: 'publicationYear',
dataIndex: 'year',
key: 'year',
width: 80,
width: 60,
align: 'center' as const,
render: (year: number) => year || '-',
},
{
title: '期刊',
dataIndex: 'journal',
key: 'journal',
width: 140, // 缩短
ellipsis: { showTitle: false },
render: (text: string) => {
const value = text || '-';
return (
<Tooltip title={value}>
<span>{value}</span>
</Tooltip>
);
},
},
{
title: '作者',
dataIndex: 'authors',
key: 'authors',
width: 120, // 大幅缩短,最不重要的列
ellipsis: { showTitle: false },
render: (text: string) => {
const value = text || '-';
return (
<Tooltip title={value} placement="topLeft">
<span>{value}</span>
</Tooltip>
);
},
},
{
title: '语言',
dataIndex: 'language',
key: 'language',
width: 70, // 缩短
align: 'center' as const,
ellipsis: { showTitle: false },
render: (text: string) => {
const value = text || '-';
return (
<Tooltip title={value}>
<span>{value}</span>
</Tooltip>
);
},
},
{
title: '摘要',
dataIndex: 'abstract',
key: 'abstract',
width: 450, // 增加宽度,重要信息
ellipsis: { showTitle: false },
render: (text: string) => (
<Tooltip title={text}>
<span>{text || '-'}</span>
<Tooltip title={text} placement="topLeft">
<span>{text}</span>
</Tooltip>
),
},
@@ -629,7 +673,8 @@ const TitleScreeningSettings = () => {
showTotal: (total) => `${total} 篇文献`,
}}
size="small"
scroll={{ x: 'max-content' }}
scroll={{ x: 1370 }}
bordered
/>
</div>
)}

View File

@@ -92,34 +92,22 @@ export interface ImportLiteraturesRequest {
// ==================== 筛选结果类型 ====================
/**
* PICO判断
* 判断类型(匹配后端)
*/
export interface PICOJudgment {
P: 'match' | 'mismatch' | 'unclear';
I: 'match' | 'mismatch' | 'unclear';
C: 'match' | 'mismatch' | 'unclear';
S: 'match' | 'mismatch' | 'unclear';
}
export type JudgmentType = 'match' | 'partial' | 'mismatch' | null;
/**
* 模型判断结果
* 结论类型
*/
export interface ModelResult {
modelName: string; // 'DeepSeek-V3' | 'Qwen-Max'
conclusion: 'include' | 'exclude';
confidence: number; // 0-1
reason: string; // 完整的判断理由
judgment: PICOJudgment;
evidence?: { // 提取的证据短语
P?: string;
I?: string;
C?: string;
S?: string;
};
}
export type ConclusionType = 'include' | 'exclude' | 'uncertain' | null;
/**
* 筛选结果
* 冲突状态
*/
export type ConflictStatus = 'none' | 'conflict' | 'resolved';
/**
* 筛选结果完整匹配后端Schema
*/
export interface ScreeningResult {
id: string;
@@ -129,42 +117,108 @@ export interface ScreeningResult {
// 文献信息
literature: Literature;
// 双模型结果
model1Result: ModelResult;
model2Result: ModelResult;
// DeepSeek模型判断
dsModelName: string;
dsPJudgment: JudgmentType;
dsIJudgment: JudgmentType;
dsCJudgment: JudgmentType;
dsSJudgment: JudgmentType;
dsConclusion: ConclusionType;
dsConfidence: number | null;
// DeepSeek模型证据
dsPEvidence: string | null;
dsIEvidence: string | null;
dsCEvidence: string | null;
dsSEvidence: string | null;
dsReason: string | null;
// Qwen模型判断
qwenModelName: string;
qwenPJudgment: JudgmentType;
qwenIJudgment: JudgmentType;
qwenCJudgment: JudgmentType;
qwenSJudgment: JudgmentType;
qwenConclusion: ConclusionType;
qwenConfidence: number | null;
// Qwen模型证据
qwenPEvidence: string | null;
qwenIEvidence: string | null;
qwenCEvidence: string | null;
qwenSEvidence: string | null;
qwenReason: string | null;
// 冲突状态
conflictStatus: ConflictStatus;
conflictFields: string[] | null;
// 最终决策
finalDecision: 'include' | 'exclude' | 'pending';
decisionMethod: 'auto_agree' | 'auto_strict' | 'manual' | 'manual_batch';
finalDecision: 'include' | 'exclude' | 'pending' | null;
finalDecisionBy: string | null;
finalDecisionAt: string | null;
exclusionReason: string | null;
// 冲突信息
hasConflict: boolean;
conflictFields: string[]; // ['conclusion', 'P', 'I', 'C', 'S']
// AI处理状态
aiProcessingStatus: 'pending' | 'processing' | 'completed' | 'failed';
aiProcessedAt: string | null;
aiErrorMessage: string | null;
// 人工复核
reviewedAt?: string;
reviewedBy?: string;
reviewComment?: string;
// 可追溯信息
promptVersion: string;
rawOutput: any;
createdAt: string;
updatedAt: string;
}
/**
* 筛选任务
* 双行表格数据用于UI展示
*/
export interface DoubleRowData {
key: string;
literatureIndex: number;
literatureId: string;
literatureTitle: string;
isFirstRow: boolean;
modelName: string;
P: JudgmentType;
I: JudgmentType;
C: JudgmentType;
O: JudgmentType;
S: JudgmentType;
conclusion: ConclusionType;
confidence: number | null;
hasConflict: boolean;
originalResult: ScreeningResult;
}
/**
* 筛选任务匹配后端Schema
*/
export interface ScreeningTask {
id: string;
projectId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
taskType: 'title_abstract' | 'full_text';
status: 'pending' | 'running' | 'completed' | 'failed';
// 进度统计
totalItems: number;
processedItems: number;
successItems: number;
failedItems: number;
progress: number; // 0-100
startedAt?: string;
completedAt?: string;
errorMessage?: string;
conflictItems: number;
// 时间信息
startedAt: string | null;
completedAt: string | null;
estimatedEndAt: string | null;
// 错误信息
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
// ==================== API响应类型 ====================
@@ -199,18 +253,24 @@ export interface PaginatedResponse<T = any> {
/**
* 项目统计
*/
/**
* 项目统计数据Week 4 更新匹配后端API
*/
export interface ProjectStatistics {
totalLiteratures: number;
screenedCount: number;
includedCount: number;
excludedCount: number;
pendingCount: number;
conflictCount: number;
reviewedCount: number;
total: number;
included: number;
excluded: number;
pending: number;
conflict: number;
reviewed: number;
exclusionReasons: Record<string, number>;
includedRate: string;
excludedRate: string;
pendingRate: string;
}
/**
* 排除原因统计
* 排除原因统计(保留用于兼容性)
*/
export interface ExclusionReasons {
[key: string]: number;

View File

@@ -0,0 +1,234 @@
/**
* Excel导出工具云原生前端生成零文件落盘
*
* 使用 xlsx 库在浏览器内存中生成Excel文件完全避免文件落盘。
* 符合云原生架构原则。
*/
import * as XLSX from 'xlsx';
import { ScreeningResult } from '../types';
/**
* 导出筛选结果到Excel混合方案格式
*
* @param results 筛选结果数组
* @param options 导出选项
* @param options.filter 筛选类型(用于文件名)
* @param options.projectName 项目名称(用于文件名)
*/
export function exportScreeningResults(
results: ScreeningResult[],
options: {
filter?: 'all' | 'included' | 'excluded' | 'pending';
projectName?: string;
} = {}
) {
// 1. ⭐ 混合方案:准备完整的导出数据(一个文献一行,信息全面)
const exportData = results.map((r, idx) => {
// AI共识状态
const isAIConsistent = r.dsConclusion === r.qwenConclusion;
const aiConsensus = isAIConsistent
? `${r.dsConclusion === 'include' ? '纳入' : '排除'}(一致)`
: `冲突(DS:${r.dsConclusion === 'include' ? '纳入' : '排除'}, QW:${r.qwenConclusion === 'include' ? '纳入' : '排除'})`;
// 人工决策状态
let humanDecision = '未复核';
let reviewStatus = '待复核';
if (r.finalDecision) {
humanDecision = r.finalDecision === 'include' ? '纳入' : '排除';
const isOverride = r.dsConclusion !== r.finalDecision || r.qwenConclusion !== r.finalDecision;
reviewStatus = isOverride ? '已复核-推翻AI' : '已复核-与AI一致';
} else {
reviewStatus = isAIConsistent ? '待复核-AI一致' : '待复核-有冲突';
}
return {
// 基础信息
'序号': idx + 1,
'文献标题': r.literature.title,
'摘要': r.literature.abstract || '',
'作者': r.literature.authors || '',
'期刊': r.literature.journal || '',
'发表年份': r.literature.publicationYear || '',
'PMID': r.literature.pmid || '',
'DOI': r.literature.doi || '',
// ⭐ 混合方案AI共识
'AI共识': aiConsensus,
'AI是否一致': isAIConsistent ? '是' : '否',
// DeepSeek完整分析
'DeepSeek决策': r.dsConclusion === 'include' ? '纳入' : '排除',
'DeepSeek置信度': r.dsConfidence ? `${(r.dsConfidence * 100).toFixed(0)}%` : '',
'DeepSeek-P判断': formatJudgment(r.dsPJudgment),
'DeepSeek-P证据': r.dsPEvidence || '',
'DeepSeek-I判断': formatJudgment(r.dsIJudgment),
'DeepSeek-I证据': r.dsIEvidence || '',
'DeepSeek-C判断': formatJudgment(r.dsCJudgment),
'DeepSeek-C证据': r.dsCEvidence || '',
'DeepSeek-S判断': formatJudgment(r.dsSJudgment),
'DeepSeek-S证据': r.dsSEvidence || '',
'DeepSeek排除理由': r.dsReason || '',
// Qwen完整分析
'Qwen决策': r.qwenConclusion === 'include' ? '纳入' : '排除',
'Qwen置信度': r.qwenConfidence ? `${(r.qwenConfidence * 100).toFixed(0)}%` : '',
'Qwen-P判断': formatJudgment(r.qwenPJudgment),
'Qwen-P证据': r.qwenPEvidence || '',
'Qwen-I判断': formatJudgment(r.qwenIJudgment),
'Qwen-I证据': r.qwenIEvidence || '',
'Qwen-C判断': formatJudgment(r.qwenCJudgment),
'Qwen-C证据': r.qwenCEvidence || '',
'Qwen-S判断': formatJudgment(r.qwenSJudgment),
'Qwen-S证据': r.qwenSEvidence || '',
'Qwen排除理由': r.qwenReason || '',
// ⭐ 混合方案:人工决策
'人工决策': humanDecision,
'人工排除原因': r.exclusionReason || '',
'复核人': r.finalDecisionBy || '',
'复核时间': r.finalDecisionAt ? new Date(r.finalDecisionAt).toLocaleString('zh-CN') : '',
// ⭐ 混合方案:状态
'状态': reviewStatus,
'冲突状态': r.conflictStatus === 'conflict' ? '冲突' : '无冲突',
};
});
// 2. ⭐ 生成Excel完全在内存中零文件落盘
const ws = XLSX.utils.json_to_sheet(exportData);
// 3. ⭐ 混合方案:设置列宽(信息全面,列可以很长)
ws['!cols'] = [
// 基础信息
{ wch: 6 }, // 序号
{ wch: 50 }, // 文献标题
{ wch: 80 }, // 摘要
{ wch: 30 }, // 作者
{ wch: 30 }, // 期刊
{ wch: 10 }, // 发表年份
{ wch: 12 }, // PMID
{ wch: 25 }, // DOI
// AI共识
{ wch: 25 }, // AI共识
{ wch: 12 }, // AI是否一致
// DeepSeek完整分析
{ wch: 12 }, // DeepSeek决策
{ wch: 12 }, // DeepSeek置信度
{ wch: 12 }, // DeepSeek-P判断
{ wch: 50 }, // DeepSeek-P证据
{ wch: 12 }, // DeepSeek-I判断
{ wch: 50 }, // DeepSeek-I证据
{ wch: 12 }, // DeepSeek-C判断
{ wch: 50 }, // DeepSeek-C证据
{ wch: 12 }, // DeepSeek-S判断
{ wch: 50 }, // DeepSeek-S证据
{ wch: 60 }, // DeepSeek排除理由
// Qwen完整分析
{ wch: 12 }, // Qwen决策
{ wch: 12 }, // Qwen置信度
{ wch: 12 }, // Qwen-P判断
{ wch: 50 }, // Qwen-P证据
{ wch: 12 }, // Qwen-I判断
{ wch: 50 }, // Qwen-I证据
{ wch: 12 }, // Qwen-C判断
{ wch: 50 }, // Qwen-C证据
{ wch: 12 }, // Qwen-S判断
{ wch: 50 }, // Qwen-S证据
{ wch: 60 }, // Qwen排除理由
// 人工决策
{ wch: 12 }, // 人工决策
{ wch: 40 }, // 人工排除原因
{ wch: 15 }, // 复核人
{ wch: 20 }, // 复核时间
// 状态
{ wch: 18 }, // 状态
{ wch: 12 }, // 冲突状态
];
// 4. 创建工作簿并添加工作表
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '筛选结果');
// 5. 生成文件名
const timestamp = new Date().toISOString().slice(0, 10);
const filterSuffix = options.filter && options.filter !== 'all' ? `_${options.filter}` : '';
const filename = `${options.projectName || '筛选结果'}${filterSuffix}_${timestamp}.xlsx`;
// 6. ⭐ 触发浏览器下载(零文件落盘)
XLSX.writeFile(wb, filename);
}
/**
* 格式化判断结果为中文
*/
function formatJudgment(judgment: string | null): string {
switch (judgment) {
case 'match':
return '匹配';
case 'partial':
return '部分匹配';
case 'mismatch':
return '不匹配';
default:
return '';
}
}
/**
* 导出统计摘要到Excel额外功能可选
*
* @param stats 统计数据
* @param projectName 项目名称
*/
export function exportStatisticsSummary(
stats: {
total: number;
included: number;
excluded: number;
pending: number;
conflict: number;
exclusionReasons: Record<string, number>;
},
projectName?: string
) {
// 准备统计摘要数据
const summaryData = [
{ '项目': '统计项', '数量': '值', '百分比': '比例' },
{ '项目': '总文献数', '数量': stats.total, '百分比': '100%' },
{ '项目': '已纳入', '数量': stats.included, '百分比': `${((stats.included / stats.total) * 100).toFixed(1)}%` },
{ '项目': '已排除', '数量': stats.excluded, '百分比': `${((stats.excluded / stats.total) * 100).toFixed(1)}%` },
{ '项目': '待复核', '数量': stats.pending, '百分比': `${((stats.pending / stats.total) * 100).toFixed(1)}%` },
{ '项目': '有冲突', '数量': stats.conflict, '百分比': `${((stats.conflict / stats.total) * 100).toFixed(1)}%` },
];
// 准备排除原因数据
const reasonsData = Object.entries(stats.exclusionReasons).map(([reason, count]) => ({
'排除原因': reason,
'数量': count,
'占排除总数比例': `${((count / stats.excluded) * 100).toFixed(1)}%`,
}));
const wb = XLSX.utils.book_new();
// Sheet 1: 统计摘要
const ws1 = XLSX.utils.json_to_sheet(summaryData);
XLSX.utils.book_append_sheet(wb, ws1, '统计摘要');
// Sheet 2: 排除原因
const ws2 = XLSX.utils.json_to_sheet(reasonsData);
XLSX.utils.book_append_sheet(wb, ws2, '排除原因分析');
// 生成文件名
const timestamp = new Date().toISOString().slice(0, 10);
const filename = `${projectName || '项目'}_统计摘要_${timestamp}.xlsx`;
// 触发下载
XLSX.writeFile(wb, filename);
}

View File

@@ -10,13 +10,20 @@ import * as XLSX from 'xlsx';
*/
export interface LiteratureData {
tempId?: string;
title: string;
abstract: string;
pmid?: string;
authors?: string;
journal?: string;
publicationYear?: number;
doi?: string;
key?: string; // 文献ID编号本项目的文献编号
title: string; // 标题(必填)✅
year?: number; // 发表年份
journal?: string; // 期刊名称
authors?: string; // 作者列表
language?: string; // 语言English, Chinese
abstract: string; // 摘要(必填)✅
pmid?: string; // PubMed ID可选
doi?: string; // DOI编号可选用于去重
// 注意以下字段是输出字段AI筛选结果导入时不需要
// decision?: 'Included' | 'Excluded';
// reasonForExcluded?: string;
// notes?: string;
}
/**
@@ -37,33 +44,39 @@ export function downloadExcelTemplate(): void {
// 创建工作簿
const wb = XLSX.utils.book_new();
// 模板数据(包含示例)
// 模板数据(包含示例)- 按照测试数据的列顺序
const templateData = [
{
'Title': 'Effect of Empagliflozin on Cardiovascular Outcomes in Type 2 Diabetes',
'Abstract': 'Background: The effects of empagliflozin, a sodium-glucose cotransporter 2 inhibitor, in addition to standard care, on cardiovascular morbidity and mortality in patients with type 2 diabetes at high cardiovascular risk are not known. Methods: We randomly assigned patients...',
'key': 'LIT001',
'title': 'Effect of Empagliflozin on Cardiovascular Outcomes in Type 2 Diabetes',
'year': 2015,
'journal': 'N Engl J Med',
'authors': 'Zinman B, Wanner C, Lachin JM, et al',
'language': 'English',
'abstract': 'Background: The effects of empagliflozin, a sodium-glucose cotransporter 2 inhibitor, in addition to standard care, on cardiovascular morbidity and mortality in patients with type 2 diabetes at high cardiovascular risk are not known. Methods: We randomly assigned patients with type 2 diabetes and established cardiovascular disease...',
'PMID': '26378978',
'Authors': 'Zinman B, Wanner C, Lachin JM, et al',
'Journal': 'N Engl J Med',
'Year': 2015,
'DOI': '10.1056/NEJMoa1504720'
},
{
'Title': 'Dapagliflozin and Cardiovascular Outcomes in Type 2 Diabetes',
'Abstract': 'Background: Additional therapeutic interventions are needed to reduce the risk of cardiovascular events in patients with type 2 diabetes mellitus. Methods: We randomly assigned patients with type 2 diabetes...',
'key': 'LIT002',
'title': 'Dapagliflozin and Cardiovascular Outcomes in Type 2 Diabetes',
'year': 2019,
'journal': 'N Engl J Med',
'authors': 'Wiviott SD, Raz I, Bonaca MP, et al',
'language': 'English',
'abstract': 'Background: Additional therapeutic interventions are needed to reduce the risk of cardiovascular events in patients with type 2 diabetes mellitus. Methods: We randomly assigned patients with type 2 diabetes who had or were at risk for atherosclerotic cardiovascular disease...',
'PMID': '30415602',
'Authors': 'Wiviott SD, Raz I, Bonaca MP, et al',
'Journal': 'N Engl J Med',
'Year': 2019,
'DOI': '10.1056/NEJMoa1812389'
},
{
'Title': '请删除此示例行,并填写您自己的文献数据',
'Abstract': '摘要至少需要50个字符。Title和Abstract是必填字段其他字段可选。系统会自动根据DOI和Title去重。',
'key': 'LIT003',
'title': '请删除此示例行,并填写您自己的文献数据',
'year': '',
'journal': '',
'authors': '',
'language': '',
'abstract': '摘要至少需要50个字符。title和abstract是必填字段其他字段可选。系统会自动根据DOI和title去重。',
'PMID': '',
'Authors': '',
'Journal': '',
'Year': '',
'DOI': ''
}
];
@@ -73,12 +86,14 @@ export function downloadExcelTemplate(): void {
// 设置列宽
ws['!cols'] = [
{ wch: 60 }, // Title
{ wch: 80 }, // Abstract
{ wch: 10 }, // key
{ wch: 60 }, // title
{ wch: 8 }, // year
{ wch: 30 }, // journal
{ wch: 40 }, // authors
{ wch: 12 }, // language
{ wch: 80 }, // abstract
{ wch: 12 }, // PMID
{ wch: 40 }, // Authors
{ wch: 30 }, // Journal
{ wch: 8 }, // Year
{ wch: 25 }, // DOI
];
@@ -87,20 +102,22 @@ export function downloadExcelTemplate(): void {
// 添加说明工作表
const instructionData = [
{ '字段名': 'Title', '是否必填': '✅ 是', '说明': '文献标题至少10个字符' },
{ '字段名': 'Abstract', '是否必填': '✅ 是', '说明': '文献摘要,至少50个字符' },
{ '字段名': 'PMID', '是否必填': '❌ 否', '说明': 'PubMed ID' },
{ '字段名': 'Authors', '是否必填': '❌ 否', '说明': '作者列表' },
{ '字段名': 'Journal', '是否必填': '❌ 否', '说明': '期刊名称' },
{ '字段名': 'Year', '是否必填': '❌ 否', '说明': '发表年份' },
{ '字段名': 'DOI', '是否必填': '❌ 否', '说明': 'DOI编号用于去重' },
{ '字段名': 'key', '是否必填': '❌ 否', '说明': '文献ID编号本项目的文献编号LIT001' },
{ '字段名': 'title', '是否必填': '✅ 是', '说明': '文献标题,至少10个字符' },
{ '字段名': 'year', '是否必填': '❌ 否', '说明': '发表年份2019' },
{ '字段名': 'journal', '是否必填': '❌ 否', '说明': '期刊名称N Engl J Med' },
{ '字段名': 'authors', '是否必填': '❌ 否', '说明': '作者列表Smith A, Li B, et al' },
{ '字段名': 'language', '是否必填': '❌ 否', '说明': '语言English, Chinese' },
{ '字段名': 'abstract', '是否必填': '✅ 是', '说明': '文献摘要至少50个字符' },
{ '字段名': 'PMID', '是否必填': '❌ 否', '说明': 'PubMed ID可选' },
{ '字段名': 'DOI', '是否必填': '❌ 否', '说明': 'DOI编号可选用于去重' },
];
const wsInstruction = XLSX.utils.json_to_sheet(instructionData);
wsInstruction['!cols'] = [
{ wch: 15 },
{ wch: 12 },
{ wch: 50 },
{ wch: 60 },
];
XLSX.utils.book_append_sheet(wb, wsInstruction, '字段说明');
@@ -125,11 +142,11 @@ export async function parseExcelFile(file: File): Promise<LiteratureData[]> {
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json<any>(worksheet);
// 字段映射(支持中英文)
// 字段映射(支持中英文列名
const literatures: LiteratureData[] = jsonData.map((row, index) => {
// 处理年份字段
let year: number | undefined = undefined;
const yearValue = row.Year || row.year || row['年份'];
const yearValue = row.year || row.Year || row['年份'];
if (yearValue) {
const parsed = parseInt(String(yearValue));
if (!isNaN(parsed)) {
@@ -139,12 +156,14 @@ export async function parseExcelFile(file: File): Promise<LiteratureData[]> {
return {
tempId: `temp-${Date.now()}-${index}`,
title: String(row.Title || row.title || row['标题'] || '').trim(),
abstract: String(row.Abstract || row.abstract || row['摘要'] || '').trim(),
key: String(row.key || row.Key || row['文献ID'] || '').trim() || undefined,
title: String(row.title || row.Title || row['标题'] || '').trim(),
year: year,
journal: String(row.journal || row.Journal || row['期刊'] || '').trim() || undefined,
authors: String(row.authors || row.Authors || row['作者'] || '').trim() || undefined,
language: String(row.language || row.Language || row['语言'] || '').trim() || undefined,
abstract: String(row.abstract || row.Abstract || row['摘要'] || '').trim(),
pmid: String(row.PMID || row.pmid || row['PMID编号'] || '').trim() || undefined,
authors: String(row.Authors || row.authors || row['作者'] || '').trim() || undefined,
journal: String(row.Journal || row.journal || row['期刊'] || '').trim() || undefined,
publicationYear: year,
doi: String(row.DOI || row.doi || '').trim() || undefined,
};
});

View File

@@ -0,0 +1,101 @@
/**
* 表格数据转换工具
* 将后端返回的ScreeningResult转换为双行表格展示格式
*/
import type { ScreeningResult, DoubleRowData } from '../types';
/**
* 将筛选结果数组转换为双行表格数据
* 每篇文献生成两行第一行是DeepSeek结果第二行是Qwen结果
*/
export function transformToDoubleRows(results: ScreeningResult[]): DoubleRowData[] {
const rows: DoubleRowData[] = [];
results.forEach((result, index) => {
const hasConflict = result.conflictStatus === 'conflict';
const literatureIndex = index + 1;
// 第1行DeepSeek结果
rows.push({
key: `${result.id}-ds`,
literatureIndex,
literatureId: result.literatureId,
literatureTitle: result.literature.title,
isFirstRow: true,
modelName: 'DeepSeek-V3',
P: result.dsPJudgment,
I: result.dsIJudgment,
C: result.dsCJudgment,
O: null, // O维度没有单独判断
S: result.dsSJudgment,
conclusion: result.dsConclusion,
confidence: result.dsConfidence,
hasConflict,
originalResult: result,
});
// 第2行Qwen结果
rows.push({
key: `${result.id}-qw`,
literatureIndex,
literatureId: result.literatureId,
literatureTitle: result.literature.title,
isFirstRow: false,
modelName: 'Qwen-Max',
P: result.qwenPJudgment,
I: result.qwenIJudgment,
C: result.qwenCJudgment,
O: null,
S: result.qwenSJudgment,
conclusion: result.qwenConclusion,
confidence: result.qwenConfidence,
hasConflict,
originalResult: result,
});
});
return rows;
}
/**
* 判断是否有冲突(仅当两个模型的结论不一致时)
*/
export function hasConflict(result: ScreeningResult): boolean {
return result.dsConclusion !== result.qwenConclusion;
}
/**
* 获取最终决策
* 优先使用人工复核结果,否则根据冲突情况自动决策
*/
export function getFinalDecision(result: ScreeningResult): string {
// 如果有人工复核结果,优先使用
if (result.finalDecision) {
return result.finalDecision;
}
// 如果两个模型都同意,使用一致的结论
if (result.dsConclusion === result.qwenConclusion) {
return result.dsConclusion || 'pending';
}
// 如果有冲突,标记为待复核
return 'pending';
}
/**
* 计算进度百分比
*/
export function calculateProgress(
processedItems: number,
totalItems: number
): number {
if (totalItems === 0) return 0;
return Math.round((processedItems / totalItems) * 100);
}

View File

@@ -21,3 +21,7 @@ export default DCModule

View File

@@ -21,3 +21,7 @@ export default PKBModule

View File

@@ -25,3 +25,7 @@ export default SSAModule

View File

@@ -25,3 +25,7 @@ export default STModule

View File

@@ -51,3 +51,7 @@ export default Placeholder