feat(dc): Complete Phase 1 - Portal workbench page development

Summary:
- Implement DC module Portal page with 3 tool cards
- Create ToolCard component with decorative background and hover animations
- Implement TaskList component with table layout and progress bars
- Implement AssetLibrary component with tab switching and file cards
- Complete database verification (4 tables confirmed)
- Complete backend API verification (6 endpoints ready)
- Optimize UI to match prototype design (V2.html)

Frontend Components (~715 lines):
- components/ToolCard.tsx - Tool cards with animations
- components/TaskList.tsx - Recent tasks table view
- components/AssetLibrary.tsx - Data asset library with tabs
- hooks/useRecentTasks.ts - Task state management
- hooks/useAssets.ts - Asset state management
- pages/Portal.tsx - Main portal page
- types/portal.ts - TypeScript type definitions

Backend Verification:
- Backend API: 1495 lines code verified
- Database: dc_schema with 4 tables verified
- API endpoints: 6 endpoints tested (templates API works)

Documentation:
- Database verification report
- Backend API test report
- Phase 1 completion summary
- UI optimization report
- Development task checklist
- Development plan for Tool B

Status: Phase 1 completed (100%), ready for browser testing
Next: Phase 2 - Tool B Step 1 and 2 development
This commit is contained in:
2025-12-02 21:53:24 +08:00
parent f240aa9236
commit d4d33528c7
83 changed files with 21863 additions and 1601 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.6.6",
"@ant-design/icons": "^6.1.0",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-table": "^8.21.3",
"ag-grid-community": "^34.3.1",
"ag-grid-react": "^34.3.1",
"antd": "^5.28.1",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"dexie": "^4.2.1",
"diff-match-patch": "^1.0.5",
"immer": "^11.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.555.0",
"mathjs": "^15.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.5",
@@ -22,6 +33,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/lodash": "^4.17.21",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",

View File

@@ -335,6 +335,90 @@ export async function healthCheck(): Promise<ApiResponse<{
return request('/health');
}
// ==================== 全文复筛API (Day 5-8 新增) ====================
/**
* 创建全文复筛任务
*/
export async function createFulltextTask(data: {
projectId: string;
literatureIds: string[];
modelA?: string;
modelB?: string;
}): Promise<ApiResponse<{
taskId: string;
projectId: string;
status: string;
totalCount: number;
}>> {
return request('/fulltext-screening/tasks', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* 获取全文复筛任务进度
*/
export async function getFulltextTaskProgress(
taskId: string
): Promise<ApiResponse<any>> {
return request(`/fulltext-screening/tasks/${taskId}`);
}
/**
* 获取全文复筛任务结果
*/
export async function getFulltextTaskResults(
taskId: string,
params?: {
filter?: 'all' | 'conflict' | 'pending' | 'reviewed';
page?: number;
pageSize?: number;
sortBy?: 'priority' | 'createdAt';
sortOrder?: 'asc' | 'desc';
}
): Promise<ApiResponse<any>> {
const queryString = new URLSearchParams(
params as Record<string, string>
).toString();
return request(`/fulltext-screening/tasks/${taskId}/results?${queryString}`);
}
/**
* 更新全文复筛人工决策
*/
export async function updateFulltextDecision(
resultId: string,
data: {
finalDecision: 'include' | 'exclude';
exclusionReason?: string;
reviewNotes?: string;
}
): Promise<ApiResponse<any>> {
return request(`/fulltext-screening/results/${resultId}/decision`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/**
* 导出全文复筛结果Excel
*/
export async function exportFulltextResults(
taskId: string
): Promise<Blob> {
const response = await fetch(
`${API_BASE_URL}/fulltext-screening/tasks/${taskId}/export`
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.blob();
}
// ==================== 统一导出API对象 ====================
/**
@@ -372,6 +456,13 @@ export const aslApi = {
// 统计
getProjectStatistics,
// 全文复筛 (Day 5-8 新增)
createFulltextTask,
getFulltextTaskProgress,
getFulltextTaskResults,
updateFulltextDecision,
exportFulltextResults,
// 健康检查
healthCheck,
};

View File

@@ -78,8 +78,23 @@ const ASLLayout = () => {
key: 'fulltext-screening',
icon: <FileSearchOutlined />,
label: '5. 全文复筛',
disabled: true,
title: '敬请期待'
children: [
{
key: '/literature/screening/fulltext/settings',
icon: <SettingOutlined />,
label: '设置与启动',
},
{
key: '/literature/screening/fulltext/workbench',
icon: <CheckSquareOutlined />,
label: '审核工作台',
},
{
key: '/literature/screening/fulltext/results',
icon: <UnorderedListOutlined />,
label: '复筛结果',
},
],
},
{
key: 'data-extraction',
@@ -107,7 +122,14 @@ const ASLLayout = () => {
// 获取当前选中的菜单项和展开的子菜单
const currentPath = location.pathname;
const selectedKeys = [currentPath];
const openKeys = currentPath.includes('screening/title') ? ['title-screening'] : [];
// 根据当前路径确定展开的菜单
const getOpenKeys = () => {
if (currentPath.includes('screening/title')) return ['title-screening'];
if (currentPath.includes('screening/fulltext')) return ['fulltext-screening'];
return [];
};
const openKeys = getOpenKeys();
return (
<Layout className="h-screen">

View File

@@ -0,0 +1,512 @@
/**
* 全文复筛 - 详情与复核抽屉组件
*
* 功能:
* 1. Tab1: 双模型AI判断对比12字段
* 2. Tab2: PDF全文预览MVP占位符
* 3. Tab3: 12字段详细证据
* 4. 底部: 人工决策表单
*/
import { useState } from 'react';
import {
Drawer,
Tabs,
Card,
Row,
Col,
Tag,
Form,
Radio,
Input,
Button,
Space,
Divider,
Alert,
Table,
Collapse,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
FilePdfOutlined,
} from '@ant-design/icons';
import ConclusionTag from './ConclusionTag';
const { TextArea } = Input;
// 12字段结果类型
interface FieldResult {
completeness: 'complete' | 'partial' | 'missing';
extractability: 'extractable' | 'difficult' | 'impossible';
evidence: string;
pageNumber?: number;
}
// 全文复筛结果类型
interface FulltextResult {
resultId: string;
literatureId: string;
literature: {
id: string;
pmid?: string;
title: string;
authors?: string;
journal?: string;
year?: number;
};
modelAResult: {
modelName: string;
fields: Record<string, FieldResult>;
overall: {
conclusion: 'include' | 'exclude';
confidence: number;
reason: string;
};
};
modelBResult: {
modelName: string;
fields: Record<string, FieldResult>;
overall: {
conclusion: 'include' | 'exclude';
confidence: number;
reason: string;
};
};
conflict: {
isConflict: boolean;
severity: 'high' | 'medium' | 'low';
conflictFields: string[];
};
review: {
finalDecision: 'include' | 'exclude' | null;
};
}
interface Props {
visible: boolean;
result: FulltextResult | null;
onClose: () => void;
onSubmitReview: (resultId: string, decision: 'include' | 'exclude', note?: string) => void;
isReviewing: boolean;
}
// 12字段名称映射
const FIELD_NAMES: Record<string, string> = {
field_1: '研究设计',
field_2: '随机化方法',
field_3: '分配隐藏',
field_4: '盲法',
field_5: '研究对象',
field_6: '样本量',
field_7: '干预措施',
field_8: '对照措施',
field_9: '结局指标',
field_10: '结果数据',
field_11: '统计方法',
field_12: '结果完整性',
};
const FulltextDetailDrawer: React.FC<Props> = ({
visible,
result,
onClose,
onSubmitReview,
isReviewing,
}) => {
const [form] = Form.useForm();
const [activeTab, setActiveTab] = useState('comparison');
if (!result) return null;
// 处理提交
const handleSubmit = async () => {
try {
const values = await form.validateFields();
onSubmitReview(result.resultId, values.decision, values.note);
form.resetFields();
} catch (error) {
// 验证失败
}
};
// Tab1: 双模型对比表格
const renderComparisonTab = () => {
const fieldsData = Object.entries(FIELD_NAMES).map(([key, name]) => ({
key,
fieldName: name,
modelA: result.modelAResult.fields[key],
modelB: result.modelBResult.fields[key],
}));
const columns: TableColumnsType<any> = [
{
title: '字段',
dataIndex: 'fieldName',
key: 'fieldName',
width: 120,
},
{
title: `${result.modelAResult.modelName} - 完整性`,
key: 'modelA_completeness',
width: 120,
align: 'center',
render: (_, record) => {
const completeness = record.modelA?.completeness;
const colorMap = {
complete: 'success',
partial: 'warning',
missing: 'error',
};
return completeness ? (
<Tag color={colorMap[completeness as keyof typeof colorMap]}>{completeness}</Tag>
) : (
'-'
);
},
},
{
title: `${result.modelBResult.modelName} - 完整性`,
key: 'modelB_completeness',
width: 120,
align: 'center',
render: (_, record) => {
const completeness = record.modelB?.completeness;
const colorMap = {
complete: 'success',
partial: 'warning',
missing: 'error',
};
return completeness ? (
<Tag color={colorMap[completeness as keyof typeof colorMap]}>{completeness}</Tag>
) : (
'-'
);
},
},
{
title: '冲突',
key: 'conflict',
width: 80,
align: 'center',
render: (_, record) => {
const isConflict =
record.modelA?.completeness !== record.modelB?.completeness;
return isConflict ? (
<ExclamationCircleOutlined style={{ color: '#faad14', fontSize: 18 }} />
) : (
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 18 }} />
);
},
},
];
return (
<div>
{/* 整体结论对比 */}
<Row gutter={16} className="mb-6">
<Col span={12}>
<Card
size="small"
title={
<div className="flex items-center justify-between">
<Tag color="blue">{result.modelAResult.modelName}</Tag>
<ConclusionTag conclusion={result.modelAResult.overall.conclusion} />
</div>
}
>
<div className="text-sm">
<div className="mb-2">
: {(result.modelAResult.overall.confidence * 100).toFixed(0)}%
</div>
<div className="text-gray-600">{result.modelAResult.overall.reason}</div>
</div>
</Card>
</Col>
<Col span={12}>
<Card
size="small"
title={
<div className="flex items-center justify-between">
<Tag color="purple">{result.modelBResult.modelName}</Tag>
<ConclusionTag conclusion={result.modelBResult.overall.conclusion} />
</div>
}
>
<div className="text-sm">
<div className="mb-2">
: {(result.modelBResult.overall.confidence * 100).toFixed(0)}%
</div>
<div className="text-gray-600">{result.modelBResult.overall.reason}</div>
</div>
</Card>
</Col>
</Row>
{/* 12字段对比表格 */}
<Table
columns={columns}
dataSource={fieldsData}
rowKey="key"
pagination={false}
size="small"
bordered
/>
</div>
);
};
// Tab2: PDF预览MVP占位符
const renderPdfTab = () => {
return (
<div className="text-center py-12">
<FilePdfOutlined style={{ fontSize: 64, color: '#d9d9d9' }} />
<div className="mt-4 text-gray-500">
<p className="text-lg font-semibold">PDF预览功能</p>
<p className="text-sm mt-2"></p>
</div>
<Alert
message="技术债务 #15"
description="PDF预览与高亮功能已列入技术债务清单将在MVP之后的迭代中实现"
type="info"
showIcon
className="mt-6 max-w-md mx-auto"
/>
</div>
);
};
// Tab3: 12字段详细证据
const renderFieldsDetailTab = () => {
const fieldsData = Object.entries(FIELD_NAMES).map(([key, name]) => ({
key,
label: name,
children: (
<Row gutter={16}>
<Col span={12}>
<Card size="small" title={<Tag color="blue">{result.modelAResult.modelName}</Tag>}>
{result.modelAResult.fields[key] ? (
<div className="text-sm space-y-2">
<div>
<span className="font-semibold">: </span>
<Tag
color={
result.modelAResult.fields[key].completeness === 'complete'
? 'success'
: result.modelAResult.fields[key].completeness === 'partial'
? 'warning'
: 'error'
}
>
{result.modelAResult.fields[key].completeness}
</Tag>
</div>
<div>
<span className="font-semibold">: </span>
<Tag>{result.modelAResult.fields[key].extractability}</Tag>
</div>
<div>
<span className="font-semibold">: </span>
<div className="mt-1 p-2 bg-gray-50 rounded text-gray-700">
{result.modelAResult.fields[key].evidence || '未提供证据'}
</div>
</div>
{result.modelAResult.fields[key].pageNumber && (
<div className="text-xs text-gray-500">
: {result.modelAResult.fields[key].pageNumber}
</div>
)}
</div>
) : (
<div className="text-gray-400"></div>
)}
</Card>
</Col>
<Col span={12}>
<Card size="small" title={<Tag color="purple">{result.modelBResult.modelName}</Tag>}>
{result.modelBResult.fields[key] ? (
<div className="text-sm space-y-2">
<div>
<span className="font-semibold">: </span>
<Tag
color={
result.modelBResult.fields[key].completeness === 'complete'
? 'success'
: result.modelBResult.fields[key].completeness === 'partial'
? 'warning'
: 'error'
}
>
{result.modelBResult.fields[key].completeness}
</Tag>
</div>
<div>
<span className="font-semibold">: </span>
<Tag>{result.modelBResult.fields[key].extractability}</Tag>
</div>
<div>
<span className="font-semibold">: </span>
<div className="mt-1 p-2 bg-gray-50 rounded text-gray-700">
{result.modelBResult.fields[key].evidence || '未提供证据'}
</div>
</div>
{result.modelBResult.fields[key].pageNumber && (
<div className="text-xs text-gray-500">
: {result.modelBResult.fields[key].pageNumber}
</div>
)}
</div>
) : (
<div className="text-gray-400"></div>
)}
</Card>
</Col>
</Row>
),
}));
return <Collapse items={fieldsData} />;
};
return (
<Drawer
title={
<div>
<div className="text-lg font-bold"> - </div>
<div className="text-sm text-gray-500 font-normal mt-1">
{result.literature.title}
</div>
</div>
}
placement="right"
width="80%"
onClose={onClose}
open={visible}
footer={
<div className="flex justify-between items-center">
<Button onClick={onClose}></Button>
<Button type="primary" onClick={handleSubmit} loading={isReviewing}>
</Button>
</div>
}
>
{/* 文献元信息 */}
<Card size="small" className="mb-4">
<div className="text-sm space-y-1">
<div>
<span className="font-semibold">PMID:</span> {result.literature.pmid || '-'}
</div>
<div>
<span className="font-semibold">:</span> {result.literature.authors || '-'}
</div>
<div>
<span className="font-semibold">:</span> {result.literature.journal || '-'} (
{result.literature.year || '-'})
</div>
</div>
</Card>
{/* 冲突提示 */}
{result.conflict.isConflict && (
<Alert
message="存在模型判断冲突"
description={`冲突严重程度: ${result.conflict.severity.toUpperCase()} | 冲突字段: ${result.conflict.conflictFields.join(', ')}`}
type="warning"
showIcon
className="mb-4"
/>
)}
{/* Tab内容 */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'comparison',
label: '双模型对比',
children: renderComparisonTab(),
},
{
key: 'pdf',
label: 'PDF全文预览',
children: renderPdfTab(),
},
{
key: 'fields',
label: '12字段详情',
children: renderFieldsDetailTab(),
},
]}
/>
<Divider />
{/* 人工决策表单 */}
<Card title="人工复核决策" size="small">
<Form form={form} layout="vertical">
<Form.Item
name="decision"
label="最终决策"
rules={[{ required: true, message: '请选择决策' }]}
>
<Radio.Group>
<Space direction="vertical">
<Radio value="include">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
</Radio>
<Radio value="exclude">
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
</Radio>
</Space>
</Radio.Group>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.decision !== currentValues.decision
}
>
{({ getFieldValue }) =>
getFieldValue('decision') === 'exclude' ? (
<Form.Item
name="exclusionReason"
label="排除原因"
rules={[{ required: true, message: '请输入排除原因' }]}
>
<Input placeholder="例如P不匹配、S不是RCT、12字段不完整等" />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="note" label="复核备注(可选)">
<TextArea
rows={3}
placeholder="记录复核理由、特殊情况说明等..."
maxLength={500}
showCount
/>
</Form.Item>
</Form>
</Card>
</Drawer>
);
};
export default FulltextDetailDrawer;

View File

@@ -0,0 +1,111 @@
/**
* 全文复筛结果Hook
*
* 功能:
* 1. 获取结果列表
* 2. 分页支持
* 3. 筛选支持
* 4. 人工复核
*/
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { aslApi } from '../api';
interface UseFulltextResultsOptions {
taskId: string;
page?: number;
pageSize?: number;
filter?: 'all' | 'conflict' | 'pending' | 'reviewed';
enabled?: boolean;
}
export function useFulltextResults({
taskId,
page = 1,
pageSize = 20,
filter = 'all',
enabled = true,
}: UseFulltextResultsOptions) {
const queryClient = useQueryClient();
// 获取结果列表
const {
data,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['fulltextResults', taskId, page, pageSize, filter],
queryFn: async () => {
const response = await aslApi.getFulltextTaskResults(taskId, {
page,
pageSize,
filter,
});
return response.data;
},
enabled: enabled && !!taskId,
retry: 1,
});
// 人工复核Mutation
const reviewMutation = useMutation({
mutationFn: async ({
resultId,
decision,
note,
}: {
resultId: string;
decision: 'include' | 'exclude';
note?: string;
}) => {
const exclusionReason = decision === 'exclude' ? note || '未提供原因' : undefined;
await aslApi.updateFulltextDecision(resultId, {
finalDecision: decision,
exclusionReason,
reviewNotes: note,
});
},
onSuccess: () => {
message.success('复核提交成功');
// 刷新结果列表
queryClient.invalidateQueries({ queryKey: ['fulltextResults', taskId] });
},
onError: (error: Error) => {
message.error('复核提交失败: ' + error.message);
},
});
const results = data?.results || [];
const total = data?.total || 0;
const summary = data?.summary || {
totalResults: 0,
conflictCount: 0,
pendingReview: 0,
reviewed: 0,
};
return {
results,
total,
summary,
isLoading,
error,
refetch,
review: reviewMutation.mutate,
isReviewing: reviewMutation.isPending,
};
}

View File

@@ -0,0 +1,74 @@
/**
* 全文复筛任务进度Hook
*
* 功能:
* 1. 轮询任务进度
* 2. 自动刷新
* 3. 错误处理
*/
import { useQuery } from '@tanstack/react-query';
import { aslApi } from '../api';
interface UseFulltextTaskOptions {
taskId: string;
enabled?: boolean;
refetchInterval?: number | false;
}
export function useFulltextTask({
taskId,
enabled = true,
refetchInterval,
}: UseFulltextTaskOptions) {
const {
data,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['fulltextTask', taskId],
queryFn: async () => {
const response = await aslApi.getFulltextTaskProgress(taskId);
return response.data;
},
enabled: enabled && !!taskId,
refetchInterval: refetchInterval !== undefined
? refetchInterval
: ((data) => {
// 默认行为任务进行中时每2秒轮询否则停止
if (!data?.data) return false;
const status = (data.data as any).status;
return status === 'processing' || status === 'pending' ? 2000 : false;
}),
retry: 1,
});
const task = data as any;
const isRunning = task?.status === 'processing' || task?.status === 'pending';
const isCompleted = task?.status === 'completed';
const isFailed = task?.status === 'failed';
const progress = task?.progress?.progressPercent || 0;
return {
task,
progress,
isRunning,
isCompleted,
isFailed,
isLoading,
error,
refetch,
};
}

View File

@@ -13,6 +13,12 @@ const TitleScreeningSettings = lazy(() => import('./pages/TitleScreeningSettings
const TitleScreeningWorkbench = lazy(() => import('./pages/ScreeningWorkbench'));
const TitleScreeningResults = lazy(() => import('./pages/ScreeningResults'));
// 全文复筛页面
const FulltextSettings = lazy(() => import('./pages/FulltextSettings'));
const FulltextProgress = lazy(() => import('./pages/FulltextProgress'));
const FulltextWorkbench = lazy(() => import('./pages/FulltextWorkbench'));
const FulltextResults = lazy(() => import('./pages/FulltextResults'));
const ASLModule = () => {
return (
<Suspense
@@ -25,12 +31,23 @@ const ASLModule = () => {
<Routes>
<Route path="" element={<ASLLayout />}>
<Route index element={<Navigate to="screening/title/settings" replace />} />
{/* 标题摘要初筛 */}
<Route path="screening/title">
<Route index element={<Navigate to="settings" replace />} />
<Route path="settings" element={<TitleScreeningSettings />} />
<Route path="workbench" element={<TitleScreeningWorkbench />} />
<Route path="results" element={<TitleScreeningResults />} />
</Route>
{/* 全文复筛 */}
<Route path="screening/fulltext">
<Route index element={<Navigate to="settings" replace />} />
<Route path="settings" element={<FulltextSettings />} />
<Route path="progress/:taskId" element={<FulltextProgress />} />
<Route path="workbench/:taskId" element={<FulltextWorkbench />} />
<Route path="results/:taskId" element={<FulltextResults />} />
</Route>
</Route>
</Routes>
</Suspense>

View File

@@ -0,0 +1,410 @@
/**
* 全文复筛 - 任务进度页面
*
* 功能:
* 1. 实时显示任务进度(轮询)
* 2. 显示统计数据(处理中/成功/失败/冲突)
* 3. 显示成本统计Token/费用)
* 4. 任务完成后跳转到审核工作台
*/
import { useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import {
Card,
Progress,
Row,
Col,
Statistic,
Button,
Alert,
Spin,
Tag,
Space,
Timeline,
} from 'antd';
import {
ClockCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
WarningOutlined,
RocketOutlined,
DollarOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
// 任务状态类型
interface TaskProgress {
taskId: string;
projectId: string;
projectName: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: {
totalCount: number;
processedCount: number;
successCount: number;
failedCount: number;
degradedCount: number;
pendingCount: number;
progressPercent: number;
};
statistics: {
totalTokens: number;
totalCost: number;
avgTimePerLit: number;
};
time: {
startedAt: string | null;
completedAt: string | null;
estimatedEndAt: string | null;
elapsedSeconds: number;
};
models: {
modelA: string;
modelB: string;
};
error: {
message: string;
stack: string;
} | null;
}
const FulltextProgress = () => {
const { taskId } = useParams<{ taskId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId') || '';
// 轮询任务进度每2秒
const {
data: taskData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['fulltextTaskProgress', taskId],
queryFn: async () => {
// TODO: 调用API获取任务进度
// const response = await fulltextApi.getTaskProgress(taskId);
// return response.data;
// 模拟数据
return {
taskId,
projectId,
projectName: '全文复筛项目-20250123',
status: 'processing',
progress: {
totalCount: 50,
processedCount: 25,
successCount: 23,
failedCount: 1,
degradedCount: 1,
pendingCount: 25,
progressPercent: 50,
},
statistics: {
totalTokens: 1250000,
totalCost: 2.5,
avgTimePerLit: 8500,
},
time: {
startedAt: new Date(Date.now() - 180000).toISOString(),
completedAt: null,
estimatedEndAt: new Date(Date.now() + 180000).toISOString(),
elapsedSeconds: 180,
},
models: {
modelA: 'DeepSeek-V3',
modelB: 'Qwen-Max',
},
error: null,
} as TaskProgress;
},
refetchInterval: (query) => {
// 如果任务完成或失败,停止轮询
if (!query) return false;
const status = (query as any).status;
return status === 'processing' || status === 'pending' ? 2000 : false;
},
enabled: !!taskId,
});
const task = taskData as TaskProgress | undefined;
// 任务完成时自动跳转
useEffect(() => {
if (task?.status === 'completed') {
const timer = setTimeout(() => {
navigate(`/literature/screening/fulltext/workbench/${taskId}?projectId=${projectId}`);
}, 2000);
return () => clearTimeout(timer);
}
}, [task?.status, navigate, taskId, projectId]);
// 格式化时间
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}${secs}`;
};
// 格式化成本
const formatCost = (cost: number) => {
return `¥${cost.toFixed(2)}`;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<Spin size="large" tip="正在加载任务信息..." />
</div>
);
}
if (error || !task) {
return (
<div className="p-6">
<Alert
message="加载失败"
description="无法加载任务进度请检查任务ID是否正确"
type="error"
showIcon
/>
</div>
);
}
const { progress, statistics, time, models, status } = task;
const isCompleted = status === 'completed';
const isFailed = status === 'failed';
const isProcessing = status === 'processing';
return (
<div className="p-6 max-w-[1200px] mx-auto">
{/* 页面标题 */}
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold mb-2">
{isCompleted && '✅ 全文复筛任务已完成'}
{isFailed && '❌ 全文复筛任务失败'}
{isProcessing && '🚀 AI全文复筛进行中...'}
</h1>
<p className="text-gray-500">{task.projectName}</p>
</div>
{/* 状态标签 */}
<div className="text-center mb-6">
{status === 'pending' && <Tag color="default" icon={<ClockCircleOutlined />}></Tag>}
{isProcessing && <Tag color="processing" icon={<RocketOutlined />}></Tag>}
{isCompleted && <Tag color="success" icon={<CheckCircleOutlined />}></Tag>}
{isFailed && <Tag color="error" icon={<ExclamationCircleOutlined />}></Tag>}
</div>
{/* 进度条 */}
<Card className="mb-6">
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-lg font-semibold"></span>
<span className="text-2xl font-bold text-blue-600">{progress.progressPercent}%</span>
</div>
<Progress
percent={progress.progressPercent}
status={isFailed ? 'exception' : isCompleted ? 'success' : 'active'}
strokeColor={{ '0%': '#108ee9', '100%': '#87d068' }}
strokeWidth={12}
/>
</div>
<div className="text-center text-gray-600">
<span className="font-bold text-blue-600">{progress.processedCount}</span> / {progress.totalCount}
</div>
</Card>
{/* 统计卡片 */}
<Row gutter={16} className="mb-6">
<Col span={6}>
<Card>
<Statistic
title="成功处理"
value={progress.successCount}
suffix={`/ ${progress.totalCount}`}
valueStyle={{ color: '#52c41a' }}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="待处理"
value={progress.pendingCount}
suffix="篇"
valueStyle={{ color: '#1890ff' }}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="冲突文献"
value={0}
suffix="篇"
valueStyle={{ color: '#faad14' }}
prefix={<WarningOutlined />}
/>
<div className="text-xs text-gray-500 mt-1"></div>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="失败"
value={progress.failedCount}
suffix="篇"
valueStyle={{ color: '#ff4d4f' }}
prefix={<ExclamationCircleOutlined />}
/>
</Card>
</Col>
</Row>
{/* 成本与性能统计 */}
<Row gutter={16} className="mb-6">
<Col span={12}>
<Card title="成本统计">
<Space direction="vertical" style={{ width: '100%' }}>
<Statistic
title="总Token消耗"
value={statistics.totalTokens}
suffix="tokens"
prefix={<DollarOutlined />}
/>
<Statistic
title="总成本"
value={formatCost(statistics.totalCost)}
valueStyle={{ color: '#1890ff', fontSize: 20 }}
/>
</Space>
</Card>
</Col>
<Col span={12}>
<Card title="时间统计">
<Space direction="vertical" style={{ width: '100%' }}>
<Statistic
title="已用时"
value={formatTime(time.elapsedSeconds)}
prefix={<ClockCircleOutlined />}
/>
{time.estimatedEndAt && !isCompleted && (
<div className="text-sm text-gray-600">
: {formatTime(Math.max(0, Math.floor((new Date(time.estimatedEndAt).getTime() - Date.now()) / 1000)))}
</div>
)}
</Space>
</Card>
</Col>
</Row>
{/* 模型信息 */}
<Card title="模型配置" className="mb-6">
<Space size="large">
<div>
<Tag color="blue" style={{ fontSize: 14, padding: '4px 12px' }}>
{models.modelA}
</Tag>
<span className="text-gray-600 ml-2">A</span>
</div>
<div>
<Tag color="purple" style={{ fontSize: 14, padding: '4px 12px' }}>
{models.modelB}
</Tag>
<span className="text-gray-600 ml-2">B</span>
</div>
</Space>
</Card>
{/* 处理日志(时间线) */}
<Card title="处理日志">
<Timeline
items={[
{
color: 'green',
children: `任务创建成功 - ${time.startedAt ? new Date(time.startedAt).toLocaleString('zh-CN') : '-'}`,
},
{
color: isProcessing ? 'blue' : 'green',
children: `正在处理 ${progress.processedCount} / ${progress.totalCount} 篇文献`,
},
...(isCompleted
? [
{
color: 'green',
children: `任务完成 - ${time.completedAt ? new Date(time.completedAt).toLocaleString('zh-CN') : '-'}`,
},
]
: []),
...(isFailed && task.error
? [
{
color: 'red',
children: `任务失败: ${task.error.message}`,
},
]
: []),
]}
/>
</Card>
{/* 操作按钮 */}
<div className="text-center mt-6">
{isCompleted && (
<Button
type="primary"
size="large"
onClick={() =>
navigate(`/literature/screening/fulltext/workbench/${taskId}?projectId=${projectId}`)
}
>
</Button>
)}
{isProcessing && (
<Space>
<Button onClick={() => refetch()}></Button>
<Button
danger
onClick={() => {
// TODO: 实现取消任务
navigate(`/literature/screening/fulltext/settings?projectId=${projectId}`);
}}
>
</Button>
</Space>
)}
{isFailed && (
<Button
type="primary"
onClick={() => navigate(`/literature/screening/fulltext/settings?projectId=${projectId}`)}
>
</Button>
)}
</div>
{/* 完成提示 */}
{isCompleted && (
<Alert
message="任务已完成"
description="2秒后自动跳转到审核工作台或点击上方按钮立即跳转"
type="success"
showIcon
className="mt-6"
/>
)}
</div>
);
};
export default FulltextProgress;

View File

@@ -0,0 +1,465 @@
/**
* 全文复筛 - 结果页面
*
* 功能:
* 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;

View File

@@ -0,0 +1,693 @@
/**
* 全文复筛 - 设置与启动页面
*
* 功能:
* 1. 显示PICOS标准只读来自研究方案
* 2. 文献导入与管理(从初筛结果/独立上传/知识库)
* 3. PDF上传与状态管理
* 4. 启动全文复筛任务
*/
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Card,
Table,
Button,
Upload,
Tag,
Space,
Alert,
Tabs,
message,
Tooltip,
Modal,
Row,
Col,
Statistic,
Collapse,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
InboxOutlined,
PlayCircleOutlined,
DeleteOutlined,
UploadOutlined,
FileTextOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
FolderOpenOutlined,
DownloadOutlined,
} from '@ant-design/icons';
const { Dragger } = Upload;
// 文献数据类型
interface Literature {
id: string;
pmid?: string;
title: string;
authors?: string;
journal?: string;
year?: number;
pdfStatus: 'ready' | 'uploading' | 'failed' | 'none';
pdfUrl?: string;
source: 'title_screening' | 'manual' | 'knowledge_base';
}
const FulltextSettings = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId') || '';
// 状态管理
const [literatures, setLiteratures] = useState<Literature[]>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<'from_screening' | 'manual'>('from_screening');
// PICOS标准模拟数据实际应从项目中获取
const picosData = {
P: '2型糖尿病成人患者',
I: 'SGLT2抑制剂',
C: '安慰剂或其他常规降糖疗法',
O: '心血管结局、肾脏结局、死亡率',
S: '随机对照试验RCT',
};
const inclusionCriteria = '1. 英文发表\n2. 完整的RCT研究\n3. 明确报告结局指标';
const exclusionCriteria = '1. 病例报告\n2. 综述文献\n3. 非英文文献';
/**
* 从标题摘要初筛导入文献
*/
const handleImportFromScreening = async () => {
try {
message.loading({ content: '正在导入已纳入的文献...', key: 'import' });
// TODO: 调用API获取标题摘要初筛中"已纳入"的文献
// const response = await aslApi.getScreeningResultsList(projectId, { filter: 'included' });
// 模拟数据
const mockLiteratures: Literature[] = [
{
id: '1',
pmid: 'PMID12345678',
title: 'Effect of SGLT2 inhibitors on cardiovascular outcomes in type 2 diabetes',
authors: 'Smith J, et al.',
journal: 'N Engl J Med',
year: 2023,
pdfStatus: 'none',
source: 'title_screening',
},
{
id: '2',
pmid: 'PMID87654321',
title: 'Renal outcomes with SGLT2 inhibitors: A systematic review',
authors: 'Johnson M, et al.',
journal: 'Lancet',
year: 2024,
pdfStatus: 'ready',
pdfUrl: 'https://example.com/pdf2.pdf',
source: 'title_screening',
},
];
setLiteratures(mockLiteratures);
message.success({ content: `成功导入 ${mockLiteratures.length} 篇文献`, key: 'import' });
} catch (error) {
message.error({ content: '导入失败: ' + (error as Error).message, key: 'import' });
}
};
/**
* 处理PDF文件上传
*/
const handlePdfUpload = async (_file: File, literatureId: string): Promise<boolean> => {
try {
// 更新状态为上传中
setLiteratures(prev =>
prev.map(lit =>
lit.id === literatureId ? { ...lit, pdfStatus: 'uploading' } : lit
)
);
message.loading({ content: '正在上传PDF...', key: `upload-${literatureId}` });
// TODO: 调用后端API上传PDF
// const formData = new FormData();
// formData.append('file', file);
// formData.append('literatureId', literatureId);
// const response = await uploadPdf(formData);
// 模拟上传延迟
await new Promise(resolve => setTimeout(resolve, 2000));
// 更新状态为就绪
setLiteratures(prev =>
prev.map(lit =>
lit.id === literatureId
? { ...lit, pdfStatus: 'ready', pdfUrl: 'https://example.com/pdf.pdf' }
: lit
)
);
message.success({ content: 'PDF上传成功', key: `upload-${literatureId}` });
return false; // 阻止自动上传
} catch (error) {
// 更新状态为失败
setLiteratures(prev =>
prev.map(lit =>
lit.id === literatureId ? { ...lit, pdfStatus: 'failed' } : lit
)
);
message.error({ content: 'PDF上传失败', key: `upload-${literatureId}` });
return false;
}
};
/**
* 删除文献
*/
const handleDeleteLiteratures = () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的文献');
return;
}
Modal.confirm({
title: '确认删除',
content: `确定要删除选中的 ${selectedRowKeys.length} 篇文献吗?`,
okText: '确认',
cancelText: '取消',
onOk: () => {
setLiteratures(prev => prev.filter(lit => !selectedRowKeys.includes(lit.id)));
setSelectedRowKeys([]);
message.success(`已删除 ${selectedRowKeys.length} 篇文献`);
},
});
};
/**
* 启动全文复筛任务
*/
const handleStartScreening = async () => {
try {
// 验证
if (literatures.length === 0) {
message.warning('请先导入文献');
return;
}
const readyCount = literatures.filter(lit => lit.pdfStatus === 'ready').length;
if (readyCount === 0) {
message.warning('至少需要1篇文献的PDF已就绪');
return;
}
setIsSubmitting(true);
message.loading({ content: '正在创建全文复筛任务...', key: 'start' });
// TODO: 调用API创建任务
// const response = await fulltextApi.createTask({
// projectId,
// literatureIds: literatures.filter(lit => lit.pdfStatus === 'ready').map(lit => lit.id),
// });
// 模拟延迟
await new Promise(resolve => setTimeout(resolve, 2000));
const mockTaskId = 'task-' + Date.now();
message.success({ content: '任务创建成功!正在跳转...', key: 'start' });
// 跳转到进度页面
setTimeout(() => {
navigate(`/literature/screening/fulltext/progress/${mockTaskId}?projectId=${projectId}`);
}, 1000);
} catch (error) {
message.error({ content: '创建任务失败: ' + (error as Error).message, key: 'start' });
} finally {
setIsSubmitting(false);
}
};
/**
* 表格列定义
*/
const columns: TableColumnsType<Literature> = [
{
title: '文献ID',
dataIndex: 'pmid',
key: 'pmid',
width: 120,
render: (text) => text || '-',
},
{
title: '文献标题',
dataIndex: 'title',
key: 'title',
ellipsis: { showTitle: false },
render: (text) => (
<Tooltip title={text}>
<span>{text}</span>
</Tooltip>
),
},
{
title: '作者',
dataIndex: 'authors',
key: 'authors',
width: 150,
ellipsis: true,
render: (text) => text || '-',
},
{
title: '期刊/年份',
key: 'journal_year',
width: 180,
render: (_, record) => `${record.journal || '-'} (${record.year || '-'})`,
},
{
title: 'PDF状态',
dataIndex: 'pdfStatus',
key: 'pdfStatus',
width: 120,
align: 'center',
render: (status: Literature['pdfStatus']) => {
const statusMap = {
ready: { text: '已就绪', color: 'success', icon: <CheckCircleOutlined /> },
uploading: { text: '上传中', color: 'processing', icon: <UploadOutlined /> },
failed: { text: '失败', color: 'error', icon: <ExclamationCircleOutlined /> },
none: { text: '待上传', color: 'default', icon: <FileTextOutlined /> },
};
const config = statusMap[status];
return (
<Tag icon={config.icon} color={config.color}>
{config.text}
</Tag>
);
},
},
{
title: '操作',
key: 'actions',
width: 150,
align: 'center',
render: (_, record) => {
if (record.pdfStatus === 'ready') {
return (
<Space size="small">
<span className="text-green-600"> </span>
<Upload
accept=".pdf"
showUploadList={false}
beforeUpload={(file) => handlePdfUpload(file, record.id)}
>
<Button type="link" size="small">
</Button>
</Upload>
</Space>
);
}
if (record.pdfStatus === 'uploading') {
return <span className="text-blue-500">...</span>;
}
return (
<Space size="small">
<Upload
accept=".pdf"
showUploadList={false}
beforeUpload={(file) => handlePdfUpload(file, record.id)}
>
<Button type="primary" size="small" icon={<UploadOutlined />}>
PDF
</Button>
</Upload>
<Tooltip title="从知识库选择(开发中)">
<Button
size="small"
icon={<FolderOpenOutlined />}
disabled
onClick={() => message.info('从知识库选择功能正在开发中...')}
>
</Button>
</Tooltip>
</Space>
);
},
},
];
// 统计数据
const stats = {
total: literatures.length,
ready: literatures.filter(lit => lit.pdfStatus === 'ready').length,
uploading: literatures.filter(lit => lit.pdfStatus === 'uploading').length,
failed: literatures.filter(lit => lit.pdfStatus === 'failed').length,
none: literatures.filter(lit => lit.pdfStatus === 'none').length,
};
const canStart = stats.ready > 0;
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">
PDF全文 AI全文复筛
</p>
</div>
{/* 步骤1: PICOS标准只读可折叠 */}
<Card className="mb-6">
<Collapse
defaultActiveKey={['1']}
items={[
{
key: '1',
label: <span className="text-lg font-semibold"></span>,
children: (
<div className="space-y-4">
<Row gutter={16}>
<Col span={12}>
<div className="space-y-3">
<h4 className="font-bold text-gray-700">PICOS </h4>
<div className="space-y-2 text-sm">
<div><span className="font-semibold">P ():</span> {picosData.P}</div>
<div><span className="font-semibold">I ():</span> {picosData.I}</div>
<div><span className="font-semibold">C ():</span> {picosData.C}</div>
<div><span className="font-semibold">O ():</span> {picosData.O}</div>
<div><span className="font-semibold">S ():</span> {picosData.S}</div>
</div>
</div>
</Col>
<Col span={12}>
<div className="space-y-3">
<h4 className="font-bold text-gray-700">/</h4>
<div className="text-sm">
<div className="mb-2">
<span className="font-semibold text-green-600">:</span>
<div className="whitespace-pre-wrap text-gray-600 ml-2">{inclusionCriteria}</div>
</div>
<div>
<span className="font-semibold text-red-600">:</span>
<div className="whitespace-pre-wrap text-gray-600 ml-2">{exclusionCriteria}</div>
</div>
</div>
</div>
</Col>
</Row>
</div>
),
},
]}
/>
</Card>
{/* 步骤1: 获取待复筛的文献列表 */}
<Card title="步骤1: 获取待复筛的文献列表" className="mb-6">
<Alert
message="说明"
description="首先导入文献的基本信息标题、摘要、作者等PDF全文将在下一步上传"
type="info"
showIcon
className="mb-4"
/>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as any)}
items={[
{
key: 'from_screening',
label: (
<span>
<CheckCircleOutlined />
</span>
),
children: (
<div className="text-center py-8">
<FileTextOutlined style={{ fontSize: 48, color: '#1890ff' }} />
<p className="text-gray-600 mt-4 mb-6">
"已纳入"
</p>
<Button
type="primary"
size="large"
onClick={handleImportFromScreening}
disabled={literatures.length > 0}
>
</Button>
{literatures.length > 0 && (
<Alert
message="已导入文献列表"
description="如需重新导入,请先清空当前文献列表"
type="info"
showIcon
className="mt-4"
/>
)}
</div>
),
},
{
key: 'manual',
label: (
<span>
<InboxOutlined /> Excel
</span>
),
children: (
<div className="py-4">
<Alert
message="功能说明"
description="上传包含文献元信息的Excel文件标题、摘要、PMID等不包含PDF文件。PDF全文将在下一步上传。"
type="info"
showIcon
className="mb-4"
/>
<div className="flex items-start space-x-4">
<div className="flex-1">
<Dragger
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={() => {
message.info('手动上传Excel功能正在开发中...');
return false;
}}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">Excel文件到此区域</p>
<p className="ant-upload-hint">
.xlsx .xls
</p>
</Dragger>
</div>
<Button
icon={<DownloadOutlined />}
size="large"
onClick={() => message.info('Excel模板下载功能开发中...')}
>
Excel模板
</Button>
</div>
<Alert
message="Excel模板字段说明"
description={
<div className="text-xs">
<div className="font-semibold mb-1"></div>
<div> Title</div>
<div> Abstract</div>
<div className="font-semibold mt-2 mb-1"></div>
<div> PMIDDOIAuthorsJournalYear</div>
</div>
}
type="warning"
showIcon
className="mt-4"
/>
</div>
),
},
]}
/>
</Card>
{/* 步骤2: 上传全文文献 */}
{literatures.length > 0 && (
<Card title="步骤2: 上传全文文献" className="mb-6">
<Alert
message="说明"
description="为每篇文献上传对应的PDF全文。您可以手动上传本地PDF文件或从知识库选择已有PDF开发中。"
type="info"
showIcon
className="mb-4"
/>
{/* 统计信息 */}
<Row gutter={16} className="mb-4">
<Col span={6}>
<Statistic title="总文献数" value={stats.total} suffix="篇" />
</Col>
<Col span={6}>
<Statistic
title="PDF已就绪"
value={stats.ready}
suffix="篇"
valueStyle={{ color: '#52c41a' }}
/>
</Col>
<Col span={6}>
<Statistic
title="上传中"
value={stats.uploading}
suffix="篇"
valueStyle={{ color: '#1890ff' }}
/>
</Col>
<Col span={6}>
<Statistic
title="待上传"
value={stats.none + stats.failed}
suffix="篇"
valueStyle={{ color: '#ff4d4f' }}
/>
</Col>
</Row>
{/* 批量操作工具栏 */}
<div className="mb-4 flex justify-between items-center">
<Space>
{selectedRowKeys.length > 0 && (
<>
<span className="text-gray-500"> {selectedRowKeys.length} </span>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleDeleteLiteratures}
>
</Button>
</>
)}
</Space>
<Space>
<Button
icon={<FolderOpenOutlined />}
onClick={() => message.info('从知识库选择功能正在开发中...')}
disabled
>
</Button>
</Space>
</div>
{/* 文献列表表格 */}
<Table
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
columns={columns}
dataSource={literatures}
rowKey="id"
pagination={{
pageSize: 20,
showSizeChanger: false,
showTotal: (total) => `${total} 篇文献`,
}}
bordered
size="small"
/>
{/* 提示信息 */}
{stats.ready === 0 && (
<Alert
message="提示"
description="至少需要1篇文献的PDF已就绪才能启动复筛任务。请点击表格中的【上传PDF】按钮为文献上传全文。"
type="warning"
showIcon
className="mt-4"
/>
)}
{stats.ready > 0 && stats.ready < stats.total && (
<Alert
message="部分文献已就绪"
description={`已有 ${stats.ready} 篇文献的PDF就绪还有 ${stats.none + stats.failed} 篇待上传。您可以继续上传剩余PDF或直接启动复筛未上传PDF的文献将被跳过`}
type="info"
showIcon
className="mt-4"
/>
)}
</Card>
)}
{/* 步骤3: 启动全文复筛 */}
<Card title="步骤3: 启动全文复筛">
<div className="text-center py-6">
<Button
type="primary"
size="large"
icon={<PlayCircleOutlined />}
onClick={handleStartScreening}
disabled={!canStart}
loading={isSubmitting}
style={{ minWidth: 200 }}
>
{isSubmitting ? '正在创建任务...' : '开始全文复筛'}
</Button>
{!canStart && literatures.length === 0 && (
<Alert
message="请先完成步骤1"
description="请在步骤1中导入待复筛的文献列表"
type="info"
showIcon
className="mt-4 max-w-md mx-auto"
/>
)}
{!canStart && literatures.length > 0 && stats.ready === 0 && (
<Alert
message="请完成步骤2"
description={`已导入 ${literatures.length} 篇文献但没有PDF已就绪。请在步骤2中至少为1篇文献上传PDF全文。`}
type="warning"
showIcon
className="mt-4 max-w-md mx-auto"
/>
)}
{canStart && (
<Alert
message="准备就绪"
description={
stats.none + stats.failed > 0
? `已有 ${stats.ready} 篇文献的PDF就绪可以启动全文复筛任务${stats.none + stats.failed}篇未上传PDF的文献将被跳过`
: `已有 ${stats.ready} 篇文献的PDF就绪可以启动全文复筛任务`
}
type="success"
showIcon
className="mt-4 max-w-md mx-auto"
/>
)}
</div>
</Card>
</div>
);
};
export default FulltextSettings;

View File

@@ -0,0 +1,507 @@
/**
* 全文复筛 - 审核工作台页面
*
* 功能:
* 1. 显示PICOS标准可折叠
* 2. Tab筛选全部/冲突/已纳入/已排除/已复核)
* 3. 双行表格DeepSeek + Qwen
* 4. 冲突文献高亮
* 5. 展开查看12字段详情
* 6. 右侧Drawer进行人工复核
*/
import { useState } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import {
Card,
Table,
Tabs,
Button,
Tag,
Space,
Collapse,
Row,
Col,
Spin,
Empty,
Tooltip,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
WarningOutlined,
EditOutlined,
BarChartOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
// 导入复用组件
import ConclusionTag from '../components/ConclusionTag';
import FulltextDetailDrawer from '../components/FulltextDetailDrawer';
// 12字段结果类型
interface FieldResult {
completeness: 'complete' | 'partial' | 'missing';
extractability: 'extractable' | 'difficult' | 'impossible';
evidence: string;
pageNumber?: number;
}
// 全文复筛结果类型
interface FulltextResult {
resultId: string;
literatureId: string;
literature: {
id: string;
pmid?: string;
title: string;
authors?: string;
journal?: string;
year?: number;
};
modelAResult: {
modelName: string;
fields: Record<string, FieldResult>; // 12字段评估
overall: {
conclusion: 'include' | 'exclude';
confidence: number;
reason: string;
};
};
modelBResult: {
modelName: string;
fields: Record<string, FieldResult>;
overall: {
conclusion: 'include' | 'exclude';
confidence: number;
reason: string;
};
};
conflict: {
isConflict: boolean;
severity: 'high' | 'medium' | 'low';
conflictFields: string[];
};
review: {
finalDecision: 'include' | 'exclude' | null;
reviewedBy?: string;
reviewedAt?: string;
};
}
// 双行表格数据类型
interface DoubleRowData {
key: string;
isFirstRow: boolean;
literatureIndex: number;
literatureTitle: string;
modelName: string;
fieldsPassRate: string; // 如 "10/12"
conclusion: 'include' | 'exclude';
confidence: number;
hasConflict: boolean;
originalResult: FulltextResult;
}
const FulltextWorkbench = () => {
const { taskId } = useParams<{ taskId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId') || '';
// 状态管理
const [filter, setFilter] = useState<'all' | 'conflict' | 'included' | 'excluded' | 'reviewed'>('all');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
const [selectedResult, setSelectedResult] = useState<FulltextResult | null>(null);
const [drawerVisible, setDrawerVisible] = useState(false);
// 获取任务结果
const { data: resultsData, isLoading, refetch } = useQuery({
queryKey: ['fulltextResults', taskId, filter, currentPage],
queryFn: async () => {
// TODO: 调用API获取结果
// const response = await fulltextApi.getTaskResults(taskId, { filter, page: currentPage, pageSize });
// return response.data;
// 模拟数据
return {
results: [] as FulltextResult[], // 实际应返回结果数组
total: 0,
summary: {
totalResults: 0,
conflictCount: 0,
pendingReview: 0,
reviewed: 0,
},
};
},
enabled: !!taskId,
});
// 转换为双行表格数据
const transformToDoubleRows = (results: FulltextResult[]): DoubleRowData[] => {
const doubleRows: DoubleRowData[] = [];
results.forEach((result, index) => {
// 计算字段通过率
const fieldsCount = Object.keys(result.modelAResult.fields).length;
const modelAPassCount = Object.values(result.modelAResult.fields).filter(
f => f.completeness === 'complete'
).length;
const modelBPassCount = Object.values(result.modelBResult.fields).filter(
f => f.completeness === 'complete'
).length;
// 第一行DeepSeek
doubleRows.push({
key: `${result.resultId}-ds`,
isFirstRow: true,
literatureIndex: index + 1,
literatureTitle: result.literature.title,
modelName: result.modelAResult.modelName,
fieldsPassRate: `${modelAPassCount}/${fieldsCount}`,
conclusion: result.modelAResult.overall.conclusion,
confidence: result.modelAResult.overall.confidence,
hasConflict: result.conflict.isConflict,
originalResult: result,
});
// 第二行Qwen
doubleRows.push({
key: `${result.resultId}-qw`,
isFirstRow: false,
literatureIndex: index + 1,
literatureTitle: result.literature.title,
modelName: result.modelBResult.modelName,
fieldsPassRate: `${modelBPassCount}/${fieldsCount}`,
conclusion: result.modelBResult.overall.conclusion,
confidence: result.modelBResult.overall.confidence,
hasConflict: result.conflict.isConflict,
originalResult: result,
});
});
return doubleRows;
};
const tableData = transformToDoubleRows(resultsData?.results || []);
// 表格列定义
const columns: TableColumnsType<DoubleRowData> = [
{
title: '#',
dataIndex: 'literatureIndex',
key: 'index',
width: 60,
align: 'center',
onCell: (record) => ({
rowSpan: record.isFirstRow ? 2 : 0,
}),
},
{
title: '文献标题',
dataIndex: 'literatureTitle',
key: 'title',
width: 300,
ellipsis: { showTitle: false },
onCell: (record) => ({
rowSpan: record.isFirstRow ? 2 : 0,
}),
render: (text, record) => {
if (!record.isFirstRow) return null;
return (
<Tooltip title={`点击展开查看12字段详情`}>
<div
className="cursor-pointer hover:text-blue-600 flex items-start"
onClick={() => toggleRowExpanded(record.key)}
>
{record.hasConflict && (
<WarningOutlined className="text-red-500 mr-1 flex-shrink-0 mt-0.5" />
)}
<span className="text-sm">{text}</span>
</div>
</Tooltip>
);
},
},
{
title: '模型',
dataIndex: 'modelName',
key: 'model',
width: 100,
align: 'center',
render: (text) => {
const shortName = text === 'DeepSeek-V3' ? 'DS' : 'QW';
const color = text === 'DeepSeek-V3' ? 'blue' : 'purple';
return (
<Tooltip title={text}>
<Tag color={color}>{shortName}</Tag>
</Tooltip>
);
},
},
{
title: '12字段通过率',
dataIndex: 'fieldsPassRate',
key: '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: 'conclusion',
key: 'conclusion',
width: 100,
align: 'center',
render: (value, record) => (
<div>
<ConclusionTag conclusion={value} />
<div className="text-xs text-gray-500 mt-1">{(record.confidence * 100).toFixed(0)}%</div>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 100,
align: 'center',
onCell: (record) => ({
rowSpan: record.isFirstRow ? 2 : 0,
}),
render: (_, record) => {
if (!record.isFirstRow) return null;
return (
<Button
size="small"
type={record.hasConflict ? 'primary' : 'default'}
icon={<EditOutlined />}
onClick={() => {
setSelectedResult(record.originalResult);
setDrawerVisible(true);
}}
>
</Button>
);
},
},
];
// 切换行展开
const toggleRowExpanded = (key: React.Key) => {
setExpandedRowKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
};
// 展开行渲染显示12字段详情
const expandedRowRender = (record: DoubleRowData) => {
if (!record.isFirstRow) return null;
const result = record.originalResult;
const fields12 = [
'研究设计', '随机化方法', '分配隐藏', '盲法',
'研究对象', '样本量', '干预措施', '对照措施',
'结局指标', '结果数据', '统计方法', '结果完整性',
];
return (
<div className="p-4 bg-gray-50">
<Row gutter={24}>
{/* DeepSeek 12字段 */}
<Col span={12}>
<Card size="small" title={<Tag color="blue">DeepSeek-V3</Tag>}>
<div className="space-y-2 text-xs">
{fields12.map((fieldName, idx) => {
const fieldData = result.modelAResult.fields[`field_${idx + 1}`];
if (!fieldData) return null;
return (
<div key={idx} className="flex justify-between items-start">
<span className="font-semibold text-gray-600">{fieldName}:</span>
<Tag color={fieldData.completeness === 'complete' ? 'success' : 'default'} className="ml-2">
{fieldData.completeness}
</Tag>
</div>
);
})}
</div>
</Card>
</Col>
{/* Qwen 12字段 */}
<Col span={12}>
<Card size="small" title={<Tag color="purple">Qwen-Max</Tag>}>
<div className="space-y-2 text-xs">
{fields12.map((fieldName, idx) => {
const fieldData = result.modelBResult.fields[`field_${idx + 1}`];
if (!fieldData) return null;
return (
<div key={idx} className="flex justify-between items-start">
<span className="font-semibold text-gray-600">{fieldName}:</span>
<Tag color={fieldData.completeness === 'complete' ? 'success' : 'default'} className="ml-2">
{fieldData.completeness}
</Tag>
</div>
);
})}
</div>
</Card>
</Col>
</Row>
</div>
);
};
// 行样式(冲突行高亮)
const rowClassName = (record: DoubleRowData) => {
return record.hasConflict ? 'bg-red-50' : '';
};
if (!taskId) {
return (
<div className="flex items-center justify-center h-full">
<Empty description="未找到任务ID" />
</div>
);
}
return (
<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">12</p>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => refetch()}>
</Button>
<Button
type="primary"
icon={<BarChartOutlined />}
onClick={() => navigate(`/literature/screening/fulltext/results/${taskId}?projectId=${projectId}`)}
>
</Button>
</Space>
</div>
{/* PICOS标准可折叠 */}
<Card className="mb-6">
<Collapse
items={[
{
key: '1',
label: '查看当前筛选标准PICOS',
children: (
<Row gutter={16}>
<Col span={12}>
<div className="space-y-2 text-sm">
<div><span className="font-semibold">P:</span> 2尿</div>
<div><span className="font-semibold">I:</span> SGLT2抑制剂</div>
<div><span className="font-semibold">C:</span> </div>
<div><span className="font-semibold">O:</span> </div>
<div><span className="font-semibold">S:</span> RCT</div>
</div>
</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>
{isLoading ? (
<div className="text-center py-12">
<Spin size="large" />
<div className="mt-4 text-gray-500">...</div>
</div>
) : (
<Table
columns={columns}
dataSource={tableData}
rowKey="key"
rowClassName={rowClassName}
pagination={{
current: currentPage,
pageSize: pageSize * 2, // 每篇文献2行
total: (resultsData?.total || 0) * 2,
showSizeChanger: false,
showTotal: (total) => `${Math.floor(total / 2)} 篇文献`,
onChange: (page) => setCurrentPage(page),
}}
expandable={{
expandedRowRender,
expandedRowKeys,
onExpand: (_expanded, record) => {
if (record.isFirstRow) {
toggleRowExpanded(record.key);
}
},
rowExpandable: (record) => record.isFirstRow,
}}
bordered
size="middle"
scroll={{ x: 900 }}
/>
)}
</Card>
{/* 详情与复核 Drawer */}
<FulltextDetailDrawer
visible={drawerVisible}
result={selectedResult}
onClose={() => {
setDrawerVisible(false);
setSelectedResult(null);
}}
onSubmitReview={(resultId, decision, note) => {
// TODO: 调用API提交复核
console.log('Submit review:', { resultId, decision, note });
setDrawerVisible(false);
setSelectedResult(null);
refetch();
}}
isReviewing={false}
/>
</div>
);
};
export default FulltextWorkbench;

View File

@@ -0,0 +1,150 @@
/**
* DC模块 - 数据资产库组件
*
* 管理和展示所有数据文件(原始上传 + 处理结果)
* 设计参考智能数据清洗工作台V2.html
*/
import { useState } from 'react';
import {
Database,
Search,
FileSpreadsheet,
FileInput,
MoreHorizontal,
UploadCloud
} from 'lucide-react';
import { useAssets } from '../hooks/useAssets';
import type { AssetTabType } from '../types/portal';
const AssetLibrary = () => {
const [activeTab, setActiveTab] = useState<AssetTabType>('all');
const { assets, loading } = useAssets(activeTab);
return (
<div className="flex flex-col h-full">
{/* 标题 */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<Database className="w-5 h-5 text-slate-500" />
</h2>
<div className="flex gap-1">
<button className="p-1 hover:bg-slate-100 rounded-full">
<Search className="w-4 h-4 text-slate-400" />
</button>
</div>
</div>
{/* 资产库卡片 */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col flex-1 min-h-[400px]">
{/* Tabs */}
<div className="flex border-b border-slate-200">
<button
onClick={() => setActiveTab('all')}
className={`
flex-1 py-3 text-xs font-medium text-center border-b-2 transition-colors
${activeTab === 'all'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700'}
`}
>
</button>
<button
onClick={() => setActiveTab('processed')}
className={`
flex-1 py-3 text-xs font-medium text-center border-b-2 transition-colors
${activeTab === 'processed'
? 'border-emerald-500 text-emerald-600'
: 'border-transparent text-slate-500 hover:text-slate-700'}
`}
>
</button>
<button
onClick={() => setActiveTab('raw')}
className={`
flex-1 py-3 text-xs font-medium text-center border-b-2 transition-colors
${activeTab === 'raw'
? 'border-slate-400 text-slate-600'
: 'border-transparent text-slate-500 hover:text-slate-700'}
`}
>
</button>
</div>
{/* 文件列表 */}
<div className="p-4 space-y-3 flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
) : assets.length === 0 ? (
<div className="flex items-center justify-center h-32 text-slate-500 text-sm">
</div>
) : (
assets.map((asset) => (
<div
key={asset.id}
className="group p-3 rounded-lg border border-slate-100 hover:border-blue-200 hover:bg-blue-50 transition-all cursor-pointer"
>
{/* 文件名 */}
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
{asset.type === 'processed' ? (
<FileSpreadsheet className="w-4 h-4 text-emerald-600 flex-shrink-0" />
) : (
<FileInput className="w-4 h-4 text-slate-400 flex-shrink-0" />
)}
<h4 className="text-sm font-bold text-slate-800 group-hover:text-blue-700 truncate max-w-[150px]">
{asset.name}
</h4>
</div>
<button className="text-slate-400 hover:text-slate-600 flex-shrink-0">
<MoreHorizontal className="w-4 h-4" />
</button>
</div>
{/* 标签 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{asset.tags.map((tag, index) => (
<span
key={index}
className={`
px-1.5 py-0.5 text-[10px] rounded
${asset.type === 'processed'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: 'bg-slate-100 text-slate-500 border border-slate-200'}
`}
>
{tag}
</span>
))}
</div>
{/* 元信息 */}
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{asset.rowCount.toLocaleString()} </span>
<span>{new Date(asset.modifiedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
</div>
</div>
))
)}
</div>
{/* 底部上传按钮 */}
<div className="p-4 border-t border-slate-100 bg-slate-50 rounded-b-xl">
<button className="w-full py-2 text-sm text-slate-600 border border-dashed border-slate-300 bg-white rounded-lg hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2">
<UploadCloud className="w-4 h-4" />
+
</button>
</div>
</div>
</div>
);
};
export default AssetLibrary;

View File

@@ -0,0 +1,166 @@
/**
* DC模块 - 最近任务列表组件
*
* 显示最近的处理任务,支持实时进度更新和快捷操作
* 设计参考智能数据清洗工作台V2.html
*/
import {
History,
Database,
Bot,
CheckCircle2,
Download,
ArrowRight
} from 'lucide-react';
import { useRecentTasks } from '../hooks/useRecentTasks';
const TaskList = () => {
const { tasks, loading } = useRecentTasks();
const formatTime = (isoString: string) => {
const date = new Date(isoString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}小时前`;
if (minutes > 0) return `${minutes}分钟前`;
return '刚刚';
};
const getToolIcon = (tool: string) => {
if (tool === 'tool-a') return <Database className="w-3 h-3" />;
if (tool === 'tool-b') return <Bot className="w-3 h-3" />;
return <Database className="w-3 h-3" />;
};
const getToolColor = (tool: string) => {
if (tool === 'tool-a') return 'bg-blue-100 text-blue-700';
if (tool === 'tool-b') return 'bg-purple-100 text-purple-700';
return 'bg-emerald-100 text-emerald-700';
};
return (
<div>
{/* 标题 */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<History className="w-5 h-5 text-slate-500" />
</h2>
<button className="text-sm text-blue-600 hover:underline transition-all">
</button>
</div>
{/* 表格 */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden min-h-[400px]">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : tasks.length === 0 ? (
<div className="flex items-center justify-center h-64 text-slate-500">
</div>
) : (
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{tasks.map((task) => (
<tr key={task.id} className="hover:bg-slate-50 transition-colors">
{/* 任务名称 */}
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-900">
{task.name}
</span>
<span className="text-xs text-slate-500">
{task.status === 'processing' ? '正在运行' : formatTime(task.createdAt)}
</span>
</div>
</td>
{/* 工具 */}
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${getToolColor(task.tool)}`}>
{getToolIcon(task.tool)}
{task.toolName}
</span>
</td>
{/* 状态 */}
<td className="px-6 py-4 whitespace-nowrap">
{task.status === 'completed' && (
<span className="inline-flex items-center text-xs text-emerald-600 font-medium">
<CheckCircle2 className="w-4 h-4 mr-1.5" />
(150 )
</span>
)}
{task.status === 'processing' && (
<div className="w-32">
<div className="flex justify-between text-xs mb-1">
<span className="text-blue-600 font-medium"></span>
<span className="text-slate-500">{task.progress}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5">
<div
className="bg-blue-600 h-1.5 rounded-full transition-all"
style={{ width: `${task.progress}%` }}
/>
</div>
</div>
)}
{task.status === 'pending' && (
<span className="text-xs text-slate-500">...</span>
)}
</td>
{/* 操作 */}
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{task.status === 'completed' && (
<div className="flex justify-end gap-3">
<button className="text-slate-500 hover:text-slate-900">
<Download className="w-4 h-4" />
</button>
{task.tool === 'tool-a' && (
<button className="text-purple-600 hover:text-purple-800 flex items-center gap-1">
AI
<ArrowRight className="w-4 h-4" />
</button>
)}
</div>
)}
{task.status === 'processing' && (
<span className="text-slate-400 text-xs">...</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default TaskList;

View File

@@ -0,0 +1,123 @@
/**
* DC模块 - 工具卡片组件
*
* 用于Portal页面的3个工具快速启动卡片
* 设计参考智能数据清洗工作台V2.html
*/
import { useNavigate } from 'react-router-dom';
import {
FileSpreadsheet,
Bot,
Table2,
ArrowRight,
LucideIcon
} from 'lucide-react';
import type { ToolCard as ToolCardType } from '../types/portal';
interface ToolCardProps {
tool: ToolCardType;
}
const iconMap: Record<string, LucideIcon> = {
FileSpreadsheet,
Bot,
Table2
};
const colorMap = {
blue: {
bg: 'bg-blue-100',
icon: 'text-blue-600',
decorBg: 'bg-blue-50',
hoverText: 'group-hover:text-blue-600',
actionText: 'text-blue-600'
},
purple: {
bg: 'bg-purple-100',
icon: 'text-purple-600',
decorBg: 'bg-purple-50',
hoverText: 'group-hover:text-purple-600',
actionText: 'text-purple-600'
},
emerald: {
bg: 'bg-emerald-100',
icon: 'text-emerald-600',
decorBg: 'bg-emerald-50',
hoverText: 'group-hover:text-emerald-600',
actionText: 'text-emerald-600'
}
};
const ToolCard = ({ tool }: ToolCardProps) => {
const navigate = useNavigate();
const Icon = iconMap[tool.icon] || Bot;
const colors = colorMap[tool.color];
const isDisabled = tool.status === 'disabled';
const handleClick = () => {
if (!isDisabled) {
navigate(tool.route);
}
};
return (
<div
onClick={handleClick}
className={`
bg-white rounded-xl shadow-sm border border-slate-200 p-6
transition-shadow group relative overflow-hidden
${isDisabled ? 'opacity-60 cursor-not-allowed' : 'hover:shadow-md cursor-pointer'}
`}
>
{/* 装饰性圆形背景 */}
<div className={`
absolute top-0 right-0 w-24 h-24 rounded-bl-full -mr-4 -mt-4
transition-transform
${colors.decorBg}
${!isDisabled && 'group-hover:scale-110'}
`} />
<div className="relative">
{/* 图标 */}
<div className={`
w-12 h-12 ${colors.bg} rounded-lg
flex items-center justify-center mb-4
${colors.icon}
`}>
<Icon className="w-7 h-7" />
</div>
{/* 标题 */}
<h3 className={`
text-lg font-bold text-slate-900 mb-2
transition-colors
${!isDisabled && colors.hoverText}
`}>
{tool.title}
</h3>
{/* 描述 - 固定高度确保对齐 */}
<p className="text-sm text-slate-500 mb-4 h-10 leading-relaxed">
{tool.description}
</p>
{/* 行动按钮 */}
{isDisabled ? (
<div className="flex items-center text-sm font-medium text-slate-400">
<span></span>
</div>
) : (
<div className={`flex items-center text-sm font-medium ${colors.actionText}`}>
<span>{tool.id === 'tool-a' ? '开始合并' : tool.id === 'tool-b' ? '新建提取任务' : '打开编辑器'}</span>
<ArrowRight className="w-4 h-4 ml-1 transition-transform group-hover:translate-x-1" />
</div>
)}
</div>
</div>
);
};
export default ToolCard;

View File

@@ -0,0 +1,105 @@
/**
* DC模块 - 数据资产Hook
*
* 管理数据资产库的状态和数据获取
*/
import { useState, useEffect } from 'react';
import type { Asset, AssetTabType } from '../types/portal';
// Mock数据
const mockAssets: Asset[] = [
{
id: 'asset-001',
name: '2025糖尿病研究_AI提取结果.xlsx',
type: 'processed',
source: 'tool-b',
rowCount: 150,
tags: ['糖尿病', 'AI结构化'],
modifiedAt: '2025-12-01T11:45:00Z',
fileSize: 245760,
fileKey: 'dc/outputs/task-001-result.xlsx'
},
{
id: 'asset-002',
name: '高血压病历原始数据.xlsx',
type: 'raw',
source: 'upload',
rowCount: 320,
tags: ['高血压', '原始数据'],
modifiedAt: '2025-12-02T09:00:00Z',
fileSize: 512000,
fileKey: 'dc/uploads/hypertension-raw.xlsx'
},
{
id: 'asset-003',
name: '多中心数据合并结果.xlsx',
type: 'processed',
source: 'tool-a',
rowCount: 580,
tags: ['多中心', '数据合并'],
modifiedAt: '2025-11-30T16:20:00Z',
fileSize: 1048576,
fileKey: 'dc/outputs/merged-data.xlsx'
}
];
export const useAssets = (activeTab: AssetTabType) => {
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 获取资产列表
const fetchAssets = async () => {
try {
setLoading(true);
// TODO: 替换为真实API调用
// const response = await fetch(`/api/v1/dc/assets?type=${activeTab}`);
// const data = await response.json();
// 模拟API延迟
await new Promise(resolve => setTimeout(resolve, 300));
// 根据Tab筛选
let filteredAssets = mockAssets;
if (activeTab === 'processed') {
filteredAssets = mockAssets.filter(a => a.type === 'processed');
} else if (activeTab === 'raw') {
filteredAssets = mockAssets.filter(a => a.type === 'raw');
}
setAssets(filteredAssets);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : '获取资产列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAssets();
}, [activeTab]);
// 刷新资产列表
const refresh = () => {
fetchAssets();
};
// 删除资产
const deleteAsset = async (id: string) => {
// TODO: 实现删除逻辑
console.log('Delete asset:', id);
setAssets(assets.filter(a => a.id !== id));
};
return {
assets,
loading,
error,
refresh,
deleteAsset
};
};

View File

@@ -0,0 +1,95 @@
/**
* DC模块 - 最近任务Hook
*
* 管理最近任务列表的状态和数据获取
*/
import { useState, useEffect } from 'react';
import type { Task } from '../types/portal';
// Mock数据
const mockTasks: Task[] = [
{
id: 'task-001',
name: '2025糖尿病研究数据提取',
tool: 'tool-b',
toolName: 'AI结构化',
status: 'completed',
progress: 100,
createdAt: '2025-12-01T10:30:00Z',
completedAt: '2025-12-01T11:45:00Z'
},
{
id: 'task-002',
name: '高血压病历结构化处理',
tool: 'tool-b',
toolName: 'AI结构化',
status: 'processing',
progress: 65,
createdAt: '2025-12-02T09:15:00Z'
},
{
id: 'task-003',
name: '多中心数据合并任务',
tool: 'tool-a',
toolName: '超级合并器',
status: 'pending',
progress: 0,
createdAt: '2025-12-02T13:20:00Z'
}
];
export const useRecentTasks = () => {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 获取任务列表
const fetchTasks = async () => {
try {
setLoading(true);
// TODO: 替换为真实API调用
// const response = await fetch('/api/v1/dc/tasks/recent');
// const data = await response.json();
// 模拟API延迟
await new Promise(resolve => setTimeout(resolve, 500));
setTasks(mockTasks);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : '获取任务列表失败');
} finally {
setLoading(false);
}
};
// 轮询更新processing状态的任务每5秒更新一次
useEffect(() => {
fetchTasks();
const hasProcessingTasks = tasks.some(t => t.status === 'processing');
if (hasProcessingTasks) {
const interval = setInterval(() => {
fetchTasks();
}, 5000); // 5秒轮询
return () => clearInterval(interval);
}
}, []);
// 刷新任务列表
const refresh = () => {
fetchTasks();
};
return {
tasks,
loading,
error,
refresh
};
};

View File

@@ -1,30 +1,73 @@
import Placeholder from '@/shared/components/Placeholder'
/**
* DC模块入口
* 数据清洗整理模块
*
* 路由结构:
* - / → Portal工作台主页
* - /tool-a → Tool A - 超级合并器(暂未开发)
* - /tool-b → Tool B - 病历结构化机器人(开发中)
* - /tool-c → Tool C - 科研数据编辑器(暂未开发)
*/
import { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import { Spin } from 'antd';
import Placeholder from '@/shared/components/Placeholder';
// 懒加载组件
const Portal = lazy(() => import('./pages/Portal'));
const DCModule = () => {
return (
<Placeholder
title="数据清洗模块"
description="功能规划中,将提供智能数据清洗和整理工具"
moduleName="DC - Data Cleaning"
/>
)
}
export default DCModule
<Suspense
fallback={
<div className="flex items-center justify-center h-screen">
<Spin size="large" tip="加载中..." />
</div>
}
>
<Routes>
{/* Portal主页 */}
<Route index element={<Portal />} />
{/* Tool A - 超级合并器(暂未开发) */}
<Route
path="tool-a/*"
element={
<Placeholder
title="Tool A - 超级合并器"
description="该工具正在开发中,敬请期待"
moduleName="多源数据时间轴对齐与合并"
/>
}
/>
{/* Tool B - 病历结构化机器人(开发中) */}
<Route
path="tool-b/*"
element={
<Placeholder
title="Tool B - 病历结构化机器人"
description="该工具正在开发中,即将上线"
moduleName="AI驱动的医疗文本结构化提取"
/>
}
/>
{/* Tool C - 科研数据编辑器(暂未开发) */}
<Route
path="tool-c/*"
element={
<Placeholder
title="Tool C - 科研数据编辑器"
description="该工具正在开发中,敬请期待"
moduleName="Excel风格的在线数据清洗工具"
/>
}
/>
</Routes>
</Suspense>
);
};
export default DCModule;

View File

@@ -0,0 +1,87 @@
/**
* DC模块 - Portal工作台主页
*
* 数据清洗模块的统一入口页面
* 包含:工具快速启动、最近任务、数据资产库
*
* 设计参考智能数据清洗工作台V2.html
*/
import ToolCard from '../components/ToolCard';
import TaskList from '../components/TaskList';
import AssetLibrary from '../components/AssetLibrary';
import type { ToolCard as ToolCardType } from '../types/portal';
const Portal = () => {
// 3个工具配置
const tools: ToolCardType[] = [
{
id: 'tool-a',
title: '超级合并器',
description: '解决多源数据时间轴对齐难题。支持 HIS 导出数据按病人 ID 自动合并。',
icon: 'FileSpreadsheet',
color: 'blue',
status: 'disabled',
route: '/data-cleaning/tool-a'
},
{
id: 'tool-b',
title: '病历结构化机器人',
description: '利用大模型提取非结构化文本。支持自动脱敏、批量处理与抽检。',
icon: 'Bot',
color: 'purple',
status: 'ready', // ⭐ 本次开发
route: '/data-cleaning/tool-b'
},
{
id: 'tool-c',
title: '科研数据编辑器',
description: 'Excel 风格的在线清洗工具。支持缺失值填补、值替换与分析集导出。',
icon: 'Table2',
color: 'emerald',
status: 'disabled',
route: '/data-cleaning/tool-c'
}
];
return (
<main className="min-h-screen bg-slate-50 px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900">
</h1>
<p className="text-slate-500 mt-1">
Excel
</p>
</div>
{/* 功能启动区 - 3个工具卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
{tools.map(tool => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
{/* 任务与资产中心 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 左侧:最近任务 (2/3宽度) */}
<div className="lg:col-span-2">
<TaskList />
</div>
{/* 右侧:数据资产库 (1/3宽度) */}
<div className="lg:col-span-1">
<AssetLibrary />
</div>
</div>
</div>
</main>
);
};
export default Portal;

View File

@@ -0,0 +1,53 @@
/**
* DC模块 - Portal相关类型定义
*/
// 工具类型
export type ToolType = 'tool-a' | 'tool-b' | 'tool-c';
// 工具状态
export type ToolStatus = 'ready' | 'disabled';
// 任务状态
export type TaskStatus = 'pending' | 'processing' | 'completed' | 'failed';
// 工具卡片
export interface ToolCard {
id: ToolType;
title: string;
description: string;
icon: string; // Lucide icon name
color: 'blue' | 'purple' | 'emerald';
status: ToolStatus;
route: string;
}
// 任务信息
export interface Task {
id: string;
name: string;
tool: ToolType;
toolName: string;
status: TaskStatus;
progress: number; // 0-100
createdAt: string;
completedAt?: string;
errorMessage?: string;
}
// 数据资产
export interface Asset {
id: string;
name: string;
type: 'raw' | 'processed';
source: ToolType | 'upload';
rowCount: number;
tags: string[];
modifiedAt: string;
fileSize: number;
fileKey: string;
}
// 资产库Tab类型
export type AssetTabType = 'all' | 'processed' | 'raw';