feat(asl): Complete Tool 4 SR Chart Generator and Tool 5 Meta Analysis Engine
Tool 4 - SR Chart Generator: - PRISMA 2020 flow diagram with Chinese/English toggle (SVG) - Baseline characteristics table (Table 1) - Dual data source: project pipeline API + Excel upload - SVG/PNG export support - Backend: ChartingService with Prisma aggregation - Frontend: PrismaFlowDiagram, BaselineTable, DataSourceSelector Tool 5 - Meta Analysis Engine: - 3 data types: HR (metagen), dichotomous (metabin), continuous (metacont) - Random and fixed effects models - Multiple effect measures: HR / OR / RR - Forest plot + funnel plot (base64 PNG from R) - Heterogeneity statistics: I2, Q, p-value, Tau2 - Data input via Excel upload or project pipeline - R Docker image updated with meta package (13 tools total) - E2E test: 36/36 passed - Key fix: exp() back-transformation for log-scale ratio measures Also includes: - IIT CRA Agent V3.0 routing and AI chat page integration - Updated ASL module status guide (v2.3) - Updated system status guide (v6.3) - Updated R statistics engine guide (v1.4) Tested: Frontend renders correctly, backend APIs functional, E2E tests passed Made-with: Cursor
This commit is contained in:
@@ -24,7 +24,10 @@ import UserDetailPage from './modules/admin/pages/UserDetailPage'
|
||||
// 系统知识库管理
|
||||
import SystemKbListPage from './modules/admin/pages/SystemKbListPage'
|
||||
import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage'
|
||||
// IIT 项目管理 - 已迁移到 modules/iit/ 业务模块
|
||||
// IIT 项目管理(运营团队使用)
|
||||
import IitProjectListPage from './modules/admin/pages/IitProjectListPage'
|
||||
import IitProjectDetailPage from './modules/admin/pages/IitProjectDetailPage'
|
||||
import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage'
|
||||
// 运营日志
|
||||
import ActivityLogsPage from './pages/admin/ActivityLogsPage'
|
||||
// 个人中心页面
|
||||
@@ -118,7 +121,10 @@ function App() {
|
||||
{/* 系统知识库 */}
|
||||
<Route path="system-kb" element={<SystemKbListPage />} />
|
||||
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
|
||||
{/* IIT 项目管理 - 已迁移到 /iit/* 业务模块 */}
|
||||
{/* IIT 项目管理(运营团队配置) */}
|
||||
<Route path="iit-projects" element={<IitProjectListPage />} />
|
||||
<Route path="iit-projects/:id" element={<IitProjectDetailPage />} />
|
||||
<Route path="iit-projects/:id/cockpit" element={<IitQcCockpitPage />} />
|
||||
{/* 运营日志 */}
|
||||
<Route path="activity-logs" element={<ActivityLogsPage />} />
|
||||
{/* 系统配置 */}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
BellOutlined,
|
||||
BookOutlined,
|
||||
FileTextOutlined,
|
||||
|
||||
ExperimentOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { useAuth } from '../auth'
|
||||
@@ -96,6 +96,13 @@ const AdminLayout = () => {
|
||||
icon: <TeamOutlined />,
|
||||
label: '租户管理',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: '/admin/iit-projects',
|
||||
icon: <ExperimentOutlined />,
|
||||
label: 'IIT 项目管理',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: '/admin/activity-logs',
|
||||
icon: <FileTextOutlined />,
|
||||
|
||||
@@ -619,6 +619,46 @@ export async function exportExtractionResults(
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// ==================== 工具 4:SR 图表生成器 API ====================
|
||||
|
||||
export async function getChartingPrismaData(
|
||||
projectId: string
|
||||
): Promise<ApiResponse<any>> {
|
||||
return request(`/charting/prisma-data/${projectId}`);
|
||||
}
|
||||
|
||||
export async function getChartingBaselineData(
|
||||
projectId: string
|
||||
): Promise<ApiResponse<any>> {
|
||||
return request(`/charting/baseline-data/${projectId}`);
|
||||
}
|
||||
|
||||
// ==================== 工具 5:Meta 分析引擎 API ====================
|
||||
|
||||
export async function runMetaAnalysis(body: {
|
||||
data: Record<string, unknown>[];
|
||||
params: {
|
||||
data_type: string;
|
||||
model?: string;
|
||||
effect_measure?: string;
|
||||
};
|
||||
}): Promise<any> {
|
||||
return request('/meta-analysis/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMetaProjectData(
|
||||
projectId: string
|
||||
): Promise<{ rows: any[]; detectedType: string | null; count: number }> {
|
||||
return request(`/meta-analysis/project-data/${projectId}`);
|
||||
}
|
||||
|
||||
export async function getMetaHealthCheck(): Promise<{ rServiceAvailable: boolean }> {
|
||||
return request('/meta-analysis/health');
|
||||
}
|
||||
|
||||
// ==================== 统一导出API对象 ====================
|
||||
|
||||
/**
|
||||
@@ -688,4 +728,13 @@ export const aslApi = {
|
||||
getExtractionResultDetail,
|
||||
reviewExtractionResult,
|
||||
exportExtractionResults,
|
||||
|
||||
// 工具 4:SR 图表生成器
|
||||
getChartingPrismaData,
|
||||
getChartingBaselineData,
|
||||
|
||||
// 工具 5:Meta 分析引擎
|
||||
runMetaAnalysis,
|
||||
getMetaProjectData,
|
||||
getMetaHealthCheck,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SettingOutlined,
|
||||
CheckSquareOutlined,
|
||||
UnorderedListOutlined,
|
||||
ApartmentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
@@ -112,11 +113,14 @@ const ASLLayout = () => {
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'data-analysis',
|
||||
key: '/literature/charting',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: '7. SR 图表生成器',
|
||||
},
|
||||
{
|
||||
key: '/literature/meta-analysis',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '7. 数据综合分析与报告',
|
||||
disabled: true,
|
||||
title: '敬请期待'
|
||||
label: '8. Meta 分析引擎',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -136,6 +140,8 @@ const ASLLayout = () => {
|
||||
if (currentPath.includes('screening/title')) return ['title-screening'];
|
||||
if (currentPath.includes('screening/fulltext')) return ['fulltext-screening'];
|
||||
if (currentPath.includes('/extraction')) return ['extraction'];
|
||||
if (currentPath.includes('/charting')) return [];
|
||||
if (currentPath.includes('/meta-analysis')) return [];
|
||||
return [];
|
||||
};
|
||||
const openKeys = getOpenKeys();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Table, Typography, Empty } from 'antd';
|
||||
import type { BaselineRow } from '../../utils/chartingExcelUtils';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface Props {
|
||||
data: BaselineRow[];
|
||||
}
|
||||
|
||||
const KNOWN_FIELD_LABELS: Record<string, string> = {
|
||||
Study_ID: 'Study ID',
|
||||
study_id: 'Study ID',
|
||||
Intervention_Name: 'Intervention',
|
||||
intervention_name: 'Intervention',
|
||||
Control_Name: 'Control',
|
||||
control_name: 'Control',
|
||||
Intervention_N: 'Intervention N',
|
||||
intervention_n: 'Intervention N',
|
||||
Control_N: 'Control N',
|
||||
control_n: 'Control N',
|
||||
Age_Mean_SD: 'Age (Mean ± SD)',
|
||||
age_mean_sd: 'Age (Mean ± SD)',
|
||||
Male_Percent: 'Male %',
|
||||
male_percent: 'Male %',
|
||||
study_design: 'Study Design',
|
||||
Study_Design: 'Study Design',
|
||||
};
|
||||
|
||||
function humanize(key: string): string {
|
||||
if (KNOWN_FIELD_LABELS[key]) return KNOWN_FIELD_LABELS[key];
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
const BaselineTable: React.FC<Props> = ({ data }) => {
|
||||
const columns = useMemo(() => {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const allKeys = new Set<string>();
|
||||
data.forEach((row) => {
|
||||
Object.keys(row).forEach((k) => allKeys.add(k));
|
||||
});
|
||||
|
||||
const priorityOrder = [
|
||||
'Study_ID', 'study_id',
|
||||
'Intervention_Name', 'intervention_name',
|
||||
'Control_Name', 'control_name',
|
||||
'Intervention_N', 'intervention_n',
|
||||
'Control_N', 'control_n',
|
||||
'Age_Mean_SD', 'age_mean_sd',
|
||||
'Male_Percent', 'male_percent',
|
||||
];
|
||||
|
||||
const orderedKeys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pk of priorityOrder) {
|
||||
if (allKeys.has(pk) && !seen.has(pk)) {
|
||||
orderedKeys.push(pk);
|
||||
seen.add(pk);
|
||||
}
|
||||
}
|
||||
for (const k of allKeys) {
|
||||
if (!seen.has(k)) {
|
||||
orderedKeys.push(k);
|
||||
seen.add(k);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedKeys.map((key) => ({
|
||||
title: humanize(key),
|
||||
dataIndex: key,
|
||||
key,
|
||||
ellipsis: true,
|
||||
render: (val: any) => {
|
||||
if (val === null || val === undefined) return '-';
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
return String(val);
|
||||
},
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty description="暂无基线数据" />;
|
||||
}
|
||||
|
||||
const dataSource = data.map((row, i) => ({ ...row, _rowKey: `row-${i}` }));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Title level={5} className="text-center mb-4">
|
||||
Table 1. 纳入研究的基线特征 (Baseline Characteristics of Included Studies)
|
||||
</Title>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="_rowKey"
|
||||
pagination={false}
|
||||
bordered
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
className="baseline-academic-table"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaselineTable;
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Radio, Select, Upload, Button, Space, Typography, message, Spin } from 'antd';
|
||||
import {
|
||||
LinkOutlined,
|
||||
CloudUploadOutlined,
|
||||
DownloadOutlined,
|
||||
InboxOutlined,
|
||||
FileExcelOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { aslApi } from '../../api';
|
||||
import {
|
||||
downloadSRTemplate,
|
||||
parseSRExcel,
|
||||
type PrismaData,
|
||||
type BaselineRow,
|
||||
} from '../../utils/chartingExcelUtils';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
export type InputChannel = 'project' | 'upload';
|
||||
|
||||
export interface DataSourceResult {
|
||||
prisma: PrismaData | null;
|
||||
baseline: BaselineRow[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onDataReady: (result: DataSourceResult) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const DataSourceSelector: React.FC<Props> = ({ onDataReady, loading }) => {
|
||||
const [channel, setChannel] = useState<InputChannel>('upload');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||
|
||||
const { data: projectsResp, isLoading: loadingProjects } = useQuery({
|
||||
queryKey: ['asl-projects'],
|
||||
queryFn: () => aslApi.listProjects(),
|
||||
enabled: channel === 'project',
|
||||
});
|
||||
|
||||
const projects = projectsResp?.data ?? [];
|
||||
|
||||
const handleFetchFromProject = useCallback(async () => {
|
||||
if (!selectedProjectId) {
|
||||
message.warning('请先选择一个项目');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [prismaResp, baselineResp] = await Promise.all([
|
||||
aslApi.getChartingPrismaData(selectedProjectId),
|
||||
aslApi.getChartingBaselineData(selectedProjectId),
|
||||
]);
|
||||
|
||||
onDataReady({
|
||||
prisma: prismaResp.data,
|
||||
baseline: baselineResp.data.map((r: any) => ({
|
||||
Study_ID: r.studyId,
|
||||
...r.extractedData,
|
||||
})),
|
||||
});
|
||||
message.success('流水线数据加载成功');
|
||||
} catch (err: any) {
|
||||
message.error(`加载项目数据失败: ${err.message}`);
|
||||
}
|
||||
}, [selectedProjectId, onDataReady]);
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
async (file: File) => {
|
||||
setParsing(true);
|
||||
try {
|
||||
const result = await parseSRExcel(file);
|
||||
setUploadedFileName(file.name);
|
||||
onDataReady(result);
|
||||
message.success(`解析 ${file.name} 成功`);
|
||||
} catch (err: any) {
|
||||
message.error(err.message);
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[onDataReady],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-gray-800 border-b pb-2 mb-3">
|
||||
数据来源
|
||||
</div>
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(e) => {
|
||||
setChannel(e.target.value);
|
||||
setUploadedFileName(null);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Space direction="vertical" className="w-full">
|
||||
<Radio
|
||||
value="project"
|
||||
className="w-full p-2 rounded border border-transparent hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
<LinkOutlined className="mr-1" />
|
||||
关联项目流水线
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 mt-0.5 ml-5">
|
||||
从筛选和提取工具自动汇总数据
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
|
||||
<Radio
|
||||
value="upload"
|
||||
className="w-full p-2 rounded border border-transparent hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
<CloudUploadOutlined className="mr-1" />
|
||||
独立文件上传
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 mt-0.5 ml-5">
|
||||
无需使用上游工具,上传 Excel 直接出图
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{channel === 'project' && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-3">
|
||||
<Select
|
||||
placeholder="请选择项目..."
|
||||
className="w-full"
|
||||
loading={loadingProjects}
|
||||
value={selectedProjectId}
|
||||
onChange={setSelectedProjectId}
|
||||
options={projects.map((p: any) => ({
|
||||
label: p.projectName || p.name || p.id,
|
||||
value: p.id,
|
||||
}))}
|
||||
showSearch
|
||||
filterOption={(input, opt) =>
|
||||
(opt?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<LinkOutlined />}
|
||||
onClick={handleFetchFromProject}
|
||||
disabled={!selectedProjectId}
|
||||
loading={loading}
|
||||
>
|
||||
获取流水线数据
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channel === 'upload' && (
|
||||
<div className="space-y-3">
|
||||
{parsing ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spin tip="正在解析 Excel..." />
|
||||
</div>
|
||||
) : uploadedFileName ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-2">
|
||||
<FileExcelOutlined className="text-green-600 text-lg" />
|
||||
<Text className="text-green-700 font-medium flex-1">
|
||||
{uploadedFileName}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setUploadedFileName(null)}
|
||||
>
|
||||
替换
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Dragger
|
||||
accept=".xlsx,.xls,.csv"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
handleFileUpload(file as unknown as File);
|
||||
return false;
|
||||
}}
|
||||
className="bg-gray-50"
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text text-sm">
|
||||
将 Excel 文件拖到此处,或点击选择
|
||||
</p>
|
||||
<p className="ant-upload-hint text-xs">
|
||||
支持包含 PRISMA_Data 和 Baseline_Data 工作表的 .xlsx 文件
|
||||
</p>
|
||||
</Dragger>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={downloadSRTemplate}
|
||||
className="w-full text-left"
|
||||
size="small"
|
||||
>
|
||||
下载 SR 标准模板 (SR_Charting_Template.xlsx)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourceSelector;
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import type { PrismaData } from '../../utils/chartingExcelUtils';
|
||||
import { exportDiagramAsPng } from '../../utils/chartingExcelUtils';
|
||||
|
||||
type Lang = 'zh' | 'en';
|
||||
|
||||
interface Props {
|
||||
data: PrismaData;
|
||||
lang?: Lang;
|
||||
onLangChange?: (lang: Lang) => void;
|
||||
}
|
||||
|
||||
const I18N = {
|
||||
zh: {
|
||||
phase_id: '识别',
|
||||
phase_screen: '筛选',
|
||||
phase_incl: '纳入',
|
||||
row0_l1: '通过数据库和注册库',
|
||||
row0_l2: '检索到的记录',
|
||||
excl0_title: '筛选前排除的记录:',
|
||||
excl0_body: '重复记录',
|
||||
row1_l1: '经过筛选的记录',
|
||||
excl1_title: '排除的记录',
|
||||
row2_l1: '全文评估',
|
||||
row2_l2: '纳入资格的文章',
|
||||
excl2_title: '全文排除的文章',
|
||||
row3_l1: '纳入系统综述',
|
||||
row3_l2: '的研究',
|
||||
exportSvg: '导出 SVG',
|
||||
exportPng: '导出 PNG',
|
||||
title: 'PRISMA 2020 流程图',
|
||||
},
|
||||
en: {
|
||||
phase_id: 'IDENTIFICATION',
|
||||
phase_screen: 'SCREENING',
|
||||
phase_incl: 'INCLUDED',
|
||||
row0_l1: 'Records identified from',
|
||||
row0_l2: 'databases and registers',
|
||||
excl0_title: 'Records removed before screening:',
|
||||
excl0_body: 'Duplicate records',
|
||||
row1_l1: 'Records screened',
|
||||
excl1_title: 'Records excluded',
|
||||
row2_l1: 'Full-text articles assessed',
|
||||
row2_l2: 'for eligibility',
|
||||
excl2_title: 'Full-text articles excluded',
|
||||
row3_l1: 'Studies included in',
|
||||
row3_l2: 'systematic review',
|
||||
exportSvg: 'Export SVG',
|
||||
exportPng: 'Export PNG',
|
||||
title: 'PRISMA 2020 Flow Diagram',
|
||||
},
|
||||
};
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString();
|
||||
|
||||
const PrismaFlowDiagram: React.FC<Props> = ({ data, lang = 'zh', onLangChange }) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const t = I18N[lang];
|
||||
|
||||
const recordsScreened = data.totalIdentified - data.duplicatesRemoved;
|
||||
const fullTextAssessed = recordsScreened - data.titleAbstractExcluded;
|
||||
|
||||
const parseReasons = (reasons?: string): string[] => {
|
||||
if (!reasons) return [];
|
||||
return reasons.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
const titleReasons = parseReasons(data.titleAbstractExclusionReasons);
|
||||
const ftReasons = parseReasons(data.fullTextExclusionReasons);
|
||||
|
||||
const handleExportSvg = useCallback(() => {
|
||||
if (!svgRef.current) return;
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgRef.current);
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'PRISMA_2020_Flow_Diagram.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
const handleExportPng = useCallback(() => {
|
||||
if (!svgRef.current) return;
|
||||
exportDiagramAsPng(svgRef.current, 'PRISMA_2020_Flow_Diagram.png');
|
||||
}, []);
|
||||
|
||||
const W = 820;
|
||||
const mainX = 60;
|
||||
const mainW = 260;
|
||||
const exclX = 520;
|
||||
const exclW = 240;
|
||||
const arrowY = (boxY: number, boxH: number) => boxY + boxH / 2;
|
||||
|
||||
const rowH = 90;
|
||||
const gap = 50;
|
||||
const row0Y = 40;
|
||||
const row1Y = row0Y + rowH + gap;
|
||||
const row2Y = row1Y + rowH + gap;
|
||||
const row3Y = row2Y + rowH + gap;
|
||||
const totalH = row3Y + rowH + 40;
|
||||
|
||||
const mainCX = mainX + mainW / 2;
|
||||
const boxH = 70;
|
||||
const exclBoxH = Math.max(60, 40 + ftReasons.length * 16);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{/* Language toggle */}
|
||||
<div className="inline-flex rounded-md border border-gray-300 overflow-hidden text-sm">
|
||||
<button
|
||||
onClick={() => onLangChange?.('zh')}
|
||||
className={`px-3 py-1 transition-colors ${lang === 'zh' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onLangChange?.('en')}
|
||||
className={`px-3 py-1 transition-colors border-l border-gray-300 ${lang === 'en' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300" />
|
||||
|
||||
<button
|
||||
onClick={handleExportSvg}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
{t.exportSvg}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportPng}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
{t.exportPng}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={W}
|
||||
height={totalH}
|
||||
viewBox={`0 0 ${W} ${totalH}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Roboto, sans-serif' }}
|
||||
>
|
||||
<defs>
|
||||
<marker id="arrowDown" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#94a3b8" />
|
||||
</marker>
|
||||
<marker id="arrowRight" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#94a3b8" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Title */}
|
||||
<text x={W / 2} y={20} textAnchor="middle" fontSize="15" fontWeight="700" fill="#1f2937">{t.title}</text>
|
||||
|
||||
{/* Phase labels */}
|
||||
<text x="12" y={row0Y + boxH / 2} fontSize="11" fill="#94a3b8" fontWeight="600" transform={`rotate(-90, 12, ${row0Y + boxH / 2})`} textAnchor="middle">{t.phase_id}</text>
|
||||
<text x="12" y={row1Y + boxH / 2 + 20} fontSize="11" fill="#94a3b8" fontWeight="600" transform={`rotate(-90, 12, ${row1Y + boxH / 2 + 20})`} textAnchor="middle">{t.phase_screen}</text>
|
||||
<text x="12" y={row3Y + boxH / 2} fontSize="11" fill="#94a3b8" fontWeight="600" transform={`rotate(-90, 12, ${row3Y + boxH / 2})`} textAnchor="middle">{t.phase_incl}</text>
|
||||
|
||||
{/* Row 0: Records identified */}
|
||||
<rect x={mainX} y={row0Y} width={mainW} height={boxH} rx="6" fill="#ffffff" stroke="#94a3b8" strokeWidth="2" />
|
||||
<text x={mainX + mainW / 2} y={row0Y + 28} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row0_l1}</text>
|
||||
<text x={mainX + mainW / 2} y={row0Y + 46} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row0_l2}</text>
|
||||
<text x={mainX + mainW / 2} y={row0Y + 64} textAnchor="middle" fontSize="14" fontWeight="700" fill="#2563eb">(n = {fmt(data.totalIdentified)})</text>
|
||||
|
||||
<line x1={mainX + mainW} y1={arrowY(row0Y, boxH)} x2={exclX} y2={arrowY(row0Y, boxH)} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowRight)" />
|
||||
|
||||
<rect x={exclX} y={row0Y + 5} width={exclW} height={60} rx="5" fill="#fef2f2" stroke="#fca5a5" strokeWidth="1.5" />
|
||||
<text x={exclX + exclW / 2} y={row0Y + 28} textAnchor="middle" fontSize="12" fontWeight="600" fill="#dc2626">{t.excl0_title}</text>
|
||||
<text x={exclX + exclW / 2} y={row0Y + 48} textAnchor="middle" fontSize="12" fill="#6b7280">{t.excl0_body} (n = {fmt(data.duplicatesRemoved)})</text>
|
||||
|
||||
<line x1={mainCX} y1={row0Y + boxH} x2={mainCX} y2={row1Y} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowDown)" />
|
||||
|
||||
{/* Row 1: Records screened */}
|
||||
<rect x={mainX} y={row1Y} width={mainW} height={boxH} rx="6" fill="#ffffff" stroke="#94a3b8" strokeWidth="2" />
|
||||
<text x={mainX + mainW / 2} y={row1Y + 30} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row1_l1}</text>
|
||||
<text x={mainX + mainW / 2} y={row1Y + 52} textAnchor="middle" fontSize="14" fontWeight="700" fill="#2563eb">(n = {fmt(recordsScreened)})</text>
|
||||
|
||||
<line x1={mainX + mainW} y1={arrowY(row1Y, boxH)} x2={exclX} y2={arrowY(row1Y, boxH)} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowRight)" />
|
||||
|
||||
<rect x={exclX} y={row1Y + 5} width={exclW} height={Math.max(60, 40 + titleReasons.length * 16)} rx="5" fill="#fef2f2" stroke="#fca5a5" strokeWidth="1.5" />
|
||||
<text x={exclX + exclW / 2} y={row1Y + 24} textAnchor="middle" fontSize="12" fontWeight="600" fill="#dc2626">{t.excl1_title}</text>
|
||||
<text x={exclX + exclW / 2} y={row1Y + 42} textAnchor="middle" fontSize="12" fill="#6b7280">(n = {fmt(data.titleAbstractExcluded)})</text>
|
||||
{titleReasons.map((r, i) => (
|
||||
<text key={i} x={exclX + 10} y={row1Y + 58 + i * 16} fontSize="10" fill="#9ca3af">{r}</text>
|
||||
))}
|
||||
|
||||
<line x1={mainCX} y1={row1Y + boxH} x2={mainCX} y2={row2Y} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowDown)" />
|
||||
|
||||
{/* Row 2: Full-text assessed */}
|
||||
<rect x={mainX} y={row2Y} width={mainW} height={boxH} rx="6" fill="#ffffff" stroke="#94a3b8" strokeWidth="2" />
|
||||
<text x={mainX + mainW / 2} y={row2Y + 28} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row2_l1}</text>
|
||||
<text x={mainX + mainW / 2} y={row2Y + 46} textAnchor="middle" fontSize="13" fontWeight="600" fill="#374151">{t.row2_l2}</text>
|
||||
<text x={mainX + mainW / 2} y={row2Y + 64} textAnchor="middle" fontSize="14" fontWeight="700" fill="#2563eb">(n = {fmt(fullTextAssessed)})</text>
|
||||
|
||||
<line x1={mainX + mainW} y1={arrowY(row2Y, boxH)} x2={exclX} y2={arrowY(row2Y, boxH)} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowRight)" />
|
||||
|
||||
<rect x={exclX} y={row2Y + 5} width={exclW} height={exclBoxH} rx="5" fill="#fef2f2" stroke="#fca5a5" strokeWidth="1.5" />
|
||||
<text x={exclX + exclW / 2} y={row2Y + 24} textAnchor="middle" fontSize="12" fontWeight="600" fill="#dc2626">{t.excl2_title}</text>
|
||||
<text x={exclX + exclW / 2} y={row2Y + 42} textAnchor="middle" fontSize="12" fill="#6b7280">(n = {fmt(data.fullTextExcluded)})</text>
|
||||
{ftReasons.map((r, i) => (
|
||||
<text key={i} x={exclX + 10} y={row2Y + 58 + i * 16} fontSize="10" fill="#9ca3af">{r}</text>
|
||||
))}
|
||||
|
||||
<line x1={mainCX} y1={row2Y + boxH} x2={mainCX} y2={row3Y} stroke="#94a3b8" strokeWidth="1.5" markerEnd="url(#arrowDown)" />
|
||||
|
||||
{/* Row 3: Studies included */}
|
||||
<rect x={mainX} y={row3Y} width={mainW} height={boxH} rx="6" fill="#f0fdf4" stroke="#22c55e" strokeWidth="2.5" />
|
||||
<text x={mainX + mainW / 2} y={row3Y + 28} textAnchor="middle" fontSize="13" fontWeight="600" fill="#166534">{t.row3_l1}</text>
|
||||
<text x={mainX + mainW / 2} y={row3Y + 46} textAnchor="middle" fontSize="13" fontWeight="600" fill="#166534">{t.row3_l2}</text>
|
||||
<text x={mainX + mainW / 2} y={row3Y + 64} textAnchor="middle" fontSize="16" fontWeight="700" fill="#16a34a">(n = {fmt(data.finalIncluded)})</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrismaFlowDiagram;
|
||||
232
frontend-v2/src/modules/asl/components/meta/ResultsPanel.tsx
Normal file
232
frontend-v2/src/modules/asl/components/meta/ResultsPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Meta Analysis Results Panel
|
||||
*
|
||||
* Displays:
|
||||
* - Summary statistics (key-value cards)
|
||||
* - Forest plot (base64 image)
|
||||
* - Funnel plot (base64 image)
|
||||
* - Heterogeneity interpretation
|
||||
*/
|
||||
|
||||
import { Card, Row, Col, Statistic, Tag, Typography, Image, Empty, Divider, Button } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
WarningOutlined,
|
||||
DownloadOutlined,
|
||||
ExperimentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface MetaResults {
|
||||
pooled_effect: number;
|
||||
pooled_lower: number;
|
||||
pooled_upper: number;
|
||||
pooled_pvalue: number;
|
||||
i_squared: number;
|
||||
tau_squared: number;
|
||||
q_statistic: number;
|
||||
q_pvalue: number;
|
||||
k_studies: number;
|
||||
effect_measure: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface ResultsPanelProps {
|
||||
data: {
|
||||
status: string;
|
||||
results?: MetaResults;
|
||||
report_blocks?: any[];
|
||||
plots?: string[];
|
||||
warnings?: string[];
|
||||
reproducible_code?: string;
|
||||
executionMs?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function getHeterogeneityLevel(i2: number): { color: string; label: string; icon: React.ReactNode } {
|
||||
if (i2 <= 25) return { color: 'green', label: '低异质性', icon: <CheckCircleOutlined /> };
|
||||
if (i2 <= 50) return { color: 'blue', label: '中等异质性', icon: <ExperimentOutlined /> };
|
||||
if (i2 <= 75) return { color: 'orange', label: '较高异质性', icon: <WarningOutlined /> };
|
||||
return { color: 'red', label: '高异质性', icon: <WarningOutlined /> };
|
||||
}
|
||||
|
||||
function formatPValue(p: number): string {
|
||||
if (p < 0.001) return 'p < .001';
|
||||
return `p = ${p.toFixed(3)}`;
|
||||
}
|
||||
|
||||
function downloadBase64Image(b64: string, filename: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = b64;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
export default function ResultsPanel({ data }: ResultsPanelProps) {
|
||||
if (!data) {
|
||||
return <Empty description="尚未运行分析" />;
|
||||
}
|
||||
|
||||
if (data.status === 'error') {
|
||||
return (
|
||||
<Card>
|
||||
<Empty
|
||||
description={
|
||||
<span style={{ color: '#ff4d4f' }}>
|
||||
分析失败:{(data as any).message || '未知错误'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const r = data.results;
|
||||
if (!r) {
|
||||
return <Empty description="无分析结果" />;
|
||||
}
|
||||
|
||||
const hetLevel = getHeterogeneityLevel(r.i_squared);
|
||||
const forestPlot = data.plots?.[0];
|
||||
const funnelPlot = data.plots?.[1];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Summary Statistics */}
|
||||
<Card title="分析结果摘要" size="small" extra={
|
||||
data.executionMs ? <Text type="secondary">耗时: {(data.executionMs / 1000).toFixed(1)}s</Text> : null
|
||||
}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="合并效应量"
|
||||
value={r.pooled_effect}
|
||||
precision={3}
|
||||
suffix={
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
[{r.pooled_lower.toFixed(3)}, {r.pooled_upper.toFixed(3)}]
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="效应指标" value={r.effect_measure} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="P 值" value={formatPValue(r.pooled_pvalue)} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="纳入研究数" value={r.k_studies} suffix="个" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="模型" value={r.model} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>异质性 (I²)</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Tag color={hetLevel.color} icon={hetLevel.icon}>
|
||||
{r.i_squared.toFixed(1)}% — {hetLevel.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="τ²" value={r.tau_squared} precision={4} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Cochran's Q"
|
||||
value={r.q_statistic}
|
||||
precision={2}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 12 }}>({formatPValue(r.q_pvalue)})</Text>}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Warnings */}
|
||||
{data.warnings && (
|
||||
<Card size="small" title={<Text type="warning"><WarningOutlined /> 统计警告</Text>}>
|
||||
{(Array.isArray(data.warnings) ? data.warnings : [data.warnings]).map((w: string, i: number) => (
|
||||
<div key={i} style={{ color: '#fa8c16', marginBottom: 4 }}>• {w}</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Forest Plot */}
|
||||
{forestPlot && (
|
||||
<Card
|
||||
title="森林图 (Forest Plot)"
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => downloadBase64Image(forestPlot, 'forest_plot.png')}
|
||||
>
|
||||
下载 PNG
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div style={{ textAlign: 'center', overflow: 'auto' }}>
|
||||
<Image
|
||||
src={forestPlot}
|
||||
alt="Forest Plot"
|
||||
style={{ maxWidth: '100%' }}
|
||||
preview={{ mask: '点击放大' }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Funnel Plot */}
|
||||
{funnelPlot && (
|
||||
<Card
|
||||
title="漏斗图 (Funnel Plot)"
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => downloadBase64Image(funnelPlot, 'funnel_plot.png')}
|
||||
>
|
||||
下载 PNG
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div style={{ textAlign: 'center', overflow: 'auto' }}>
|
||||
<Image
|
||||
src={funnelPlot}
|
||||
alt="Funnel Plot"
|
||||
style={{ maxWidth: '100%' }}
|
||||
preview={{ mask: '点击放大' }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reproducible Code */}
|
||||
{data.reproducible_code && (
|
||||
<Card title="可复现 R 代码" size="small">
|
||||
<pre style={{
|
||||
background: '#f6f8fa',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
maxHeight: 200,
|
||||
}}>
|
||||
{data.reproducible_code}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,12 @@ const ExtractionSetup = lazy(() => import('./pages/ExtractionSetup'));
|
||||
const ExtractionProgress = lazy(() => import('./pages/ExtractionProgress'));
|
||||
const ExtractionWorkbench = lazy(() => import('./pages/ExtractionWorkbench'));
|
||||
|
||||
// 工具 4:SR 图表生成器
|
||||
const SRChartGenerator = lazy(() => import('./pages/SRChartGenerator'));
|
||||
|
||||
// 工具 5:Meta 分析引擎
|
||||
const MetaAnalysisEngine = lazy(() => import('./pages/MetaAnalysisEngine'));
|
||||
|
||||
const ASLModule = () => {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -73,6 +79,12 @@ const ASLModule = () => {
|
||||
<Route path="progress/:taskId" element={<ExtractionProgress />} />
|
||||
<Route path="workbench/:taskId" element={<ExtractionWorkbench />} />
|
||||
</Route>
|
||||
|
||||
{/* 工具 4:SR 图表生成器 */}
|
||||
<Route path="charting" element={<SRChartGenerator />} />
|
||||
|
||||
{/* 工具 5:Meta 分析引擎 */}
|
||||
<Route path="meta-analysis" element={<MetaAnalysisEngine />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
438
frontend-v2/src/modules/asl/pages/MetaAnalysisEngine.tsx
Normal file
438
frontend-v2/src/modules/asl/pages/MetaAnalysisEngine.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Meta Analysis Engine — Tool 5
|
||||
*
|
||||
* Two data channels:
|
||||
* 1. Project Pipeline: fetch approved extraction results
|
||||
* 2. Upload Excel: user uploads template
|
||||
*
|
||||
* Read-only data preview → configure params → run analysis → display results
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Card, Steps, Button, Space, Table, Select, Radio, Upload, message,
|
||||
Typography, Alert, Spin, Tag, Row, Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined, DownloadOutlined, PlayCircleOutlined,
|
||||
ExperimentOutlined, DatabaseOutlined, FileExcelOutlined,
|
||||
ReloadOutlined, ArrowLeftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import {
|
||||
downloadMetaTemplate, parseMetaExcel, validateMetaData,
|
||||
getDataTypeLabel, getRequiredColumns,
|
||||
type MetaDataType, type MetaRow,
|
||||
} from '../utils/metaExcelUtils';
|
||||
import { aslApi, listProjects } from '../api';
|
||||
import ResultsPanel from '../components/meta/ResultsPanel';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
type DataChannel = 'project' | 'upload';
|
||||
|
||||
interface AnalysisConfig {
|
||||
dataType: MetaDataType;
|
||||
model: 'random' | 'fixed';
|
||||
effectMeasure: string;
|
||||
}
|
||||
|
||||
const MetaAnalysisEngine = () => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [channel, setChannel] = useState<DataChannel>('upload');
|
||||
|
||||
// Data state
|
||||
const [rows, setRows] = useState<MetaRow[]>([]);
|
||||
const [dataType, setDataType] = useState<MetaDataType>('hr');
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// Config state
|
||||
const [config, setConfig] = useState<AnalysisConfig>({
|
||||
dataType: 'hr',
|
||||
model: 'random',
|
||||
effectMeasure: 'OR',
|
||||
});
|
||||
|
||||
// Project channel
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('');
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
const [loadingProjectData, setLoadingProjectData] = useState(false);
|
||||
|
||||
// Analysis state
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<any>(null);
|
||||
|
||||
// ===== Data Loading =====
|
||||
|
||||
const handleLoadProjects = useCallback(async () => {
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const res = await listProjects();
|
||||
setProjects(res.data || []);
|
||||
} catch (e: any) {
|
||||
message.error('加载项目失败: ' + e.message);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFetchProjectData = useCallback(async () => {
|
||||
if (!selectedProjectId) return;
|
||||
setLoadingProjectData(true);
|
||||
try {
|
||||
const res = await aslApi.getMetaProjectData(selectedProjectId);
|
||||
const { rows: r, detectedType, count } = res;
|
||||
if (count === 0) {
|
||||
message.warning('该项目没有已审核通过的提取结果');
|
||||
return;
|
||||
}
|
||||
setRows(r);
|
||||
if (detectedType) {
|
||||
const dt = detectedType as MetaDataType;
|
||||
setDataType(dt);
|
||||
setConfig(prev => ({ ...prev, dataType: dt }));
|
||||
}
|
||||
message.success(`已加载 ${count} 条研究数据`);
|
||||
} catch (e: any) {
|
||||
message.error('获取项目数据失败: ' + e.message);
|
||||
} finally {
|
||||
setLoadingProjectData(false);
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const handleFileUpload = useCallback(async (file: File) => {
|
||||
try {
|
||||
const { rows: parsed, detectedType } = await parseMetaExcel(file);
|
||||
if (parsed.length === 0) {
|
||||
message.error('Excel 文件中没有有效数据');
|
||||
return false;
|
||||
}
|
||||
setRows(parsed);
|
||||
if (detectedType) {
|
||||
setDataType(detectedType);
|
||||
setConfig(prev => ({ ...prev, dataType: detectedType }));
|
||||
message.success(`已识别 ${parsed.length} 行 ${getDataTypeLabel(detectedType)} 数据`);
|
||||
} else {
|
||||
message.warning(`已加载 ${parsed.length} 行数据,请手动选择数据类型`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('Excel 解析失败: ' + e.message);
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// ===== Validation =====
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const errs = validateMetaData(rows, config.dataType);
|
||||
setValidationErrors(errs);
|
||||
return errs.length === 0;
|
||||
}, [rows, config.dataType]);
|
||||
|
||||
// ===== Run Analysis =====
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!validate()) {
|
||||
message.error('数据校验未通过,请检查红色提示');
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setResults(null);
|
||||
try {
|
||||
const numericRows = rows.map(row => {
|
||||
const nr: Record<string, unknown> = { study_id: row.study_id };
|
||||
const required = getRequiredColumns(config.dataType).filter(k => k !== 'study_id');
|
||||
for (const k of required) {
|
||||
nr[k] = Number(row[k]);
|
||||
}
|
||||
return nr;
|
||||
});
|
||||
|
||||
const res = await aslApi.runMetaAnalysis({
|
||||
data: numericRows,
|
||||
params: {
|
||||
data_type: config.dataType,
|
||||
model: config.model,
|
||||
effect_measure: config.dataType === 'dichotomous' ? config.effectMeasure : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
setResults(res);
|
||||
setCurrentStep(2);
|
||||
message.success('Meta 分析完成');
|
||||
} catch (e: any) {
|
||||
message.error('分析失败: ' + e.message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [rows, config, validate]);
|
||||
|
||||
// ===== Table Columns =====
|
||||
|
||||
const columns = useMemo<ColumnsType<MetaRow>>(() => {
|
||||
if (rows.length === 0) return [];
|
||||
const keys = Object.keys(rows[0]);
|
||||
return keys.map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key,
|
||||
width: key === 'study_id' ? 160 : 120,
|
||||
render: (val: unknown) => {
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return <Text type="danger">—</Text>;
|
||||
}
|
||||
return String(val);
|
||||
},
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
// ===== Render Steps =====
|
||||
|
||||
const renderStep0 = () => (
|
||||
<div style={{ maxWidth: 900, margin: '0 auto' }}>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={e => { setChannel(e.target.value); setRows([]); setValidationErrors([]); }}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="large"
|
||||
>
|
||||
<Radio.Button value="upload">
|
||||
<FileExcelOutlined /> 上传 Excel 文件
|
||||
</Radio.Button>
|
||||
<Radio.Button value="project">
|
||||
<DatabaseOutlined /> 关联项目流水线
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
{channel === 'upload' && (
|
||||
<Card title="上传 Meta 分析数据" size="small">
|
||||
<Paragraph type="secondary">
|
||||
请先下载数据模板,按模板格式填写数据后上传。支持 .xlsx 格式。
|
||||
</Paragraph>
|
||||
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Text>下载模板:</Text>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadMetaTemplate('hr')}>
|
||||
HR 模板
|
||||
</Button>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadMetaTemplate('dichotomous')}>
|
||||
二分类模板
|
||||
</Button>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadMetaTemplate('continuous')}>
|
||||
连续型模板
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Upload.Dragger
|
||||
accept=".xlsx,.xls"
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleFileUpload}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} /></p>
|
||||
<p className="ant-upload-text">点击或拖拽 Excel 文件到此区域</p>
|
||||
<p className="ant-upload-hint">支持 .xlsx / .xls 格式</p>
|
||||
</Upload.Dragger>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{channel === 'project' && (
|
||||
<Card title="从项目提取结果获取数据" size="small">
|
||||
<Paragraph type="secondary">
|
||||
选择一个项目,自动从已审核通过的全文提取结果中获取 Meta 分析所需数据。
|
||||
</Paragraph>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 400 }}
|
||||
placeholder="选择筛选项目"
|
||||
value={selectedProjectId || undefined}
|
||||
onChange={setSelectedProjectId}
|
||||
onDropdownVisibleChange={(open) => { if (open && projects.length === 0) handleLoadProjects(); }}
|
||||
loading={loadingProjects}
|
||||
options={projects.map(p => ({ label: p.name || p.id, value: p.id }))}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DatabaseOutlined />}
|
||||
onClick={handleFetchProjectData}
|
||||
loading={loadingProjectData}
|
||||
disabled={!selectedProjectId}
|
||||
>
|
||||
获取数据
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Data Preview */}
|
||||
{rows.length > 0 && (
|
||||
<Card
|
||||
title={`数据预览 (${rows.length} 个研究)`}
|
||||
size="small"
|
||||
style={{ marginTop: 16 }}
|
||||
extra={
|
||||
<Tag color="blue">{getDataTypeLabel(dataType)}</Tag>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
rowKey="study_id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: 300 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={rows.length === 0}
|
||||
onClick={() => setCurrentStep(1)}
|
||||
>
|
||||
下一步:配置分析参数
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStep1 = () => (
|
||||
<div style={{ maxWidth: 700, margin: '0 auto' }}>
|
||||
<Card title="分析参数配置" size="small">
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col span={24}>
|
||||
<Text strong>数据类型</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Radio.Group
|
||||
value={config.dataType}
|
||||
onChange={e => {
|
||||
const dt = e.target.value;
|
||||
setConfig(prev => ({ ...prev, dataType: dt }));
|
||||
setDataType(dt);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value="hr">HR (风险比)</Radio.Button>
|
||||
<Radio.Button value="dichotomous">二分类</Radio.Button>
|
||||
<Radio.Button value="continuous">连续型</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Text strong>效应模型</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Radio.Group
|
||||
value={config.model}
|
||||
onChange={e => setConfig(prev => ({ ...prev, model: e.target.value }))}
|
||||
>
|
||||
<Radio value="random">随机效应 (Random)</Radio>
|
||||
<Radio value="fixed">固定效应 (Fixed)</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{config.dataType === 'dichotomous' && (
|
||||
<Col span={12}>
|
||||
<Text strong>效应指标</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Select
|
||||
value={config.effectMeasure}
|
||||
onChange={v => setConfig(prev => ({ ...prev, effectMeasure: v }))}
|
||||
style={{ width: 200 }}
|
||||
options={[
|
||||
{ label: 'OR (比值比)', value: 'OR' },
|
||||
{ label: 'RR (风险比)', value: 'RR' },
|
||||
{ label: 'RD (风险差)', value: 'RD' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="数据校验失败"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
{validationErrors.slice(0, 10).map((e, i) => <li key={i}>{e}</li>)}
|
||||
{validationErrors.length > 10 && <li>...还有 {validationErrors.length - 10} 个错误</li>}
|
||||
</ul>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => setCurrentStep(0)}>
|
||||
返回数据输入
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={running}
|
||||
onClick={handleRun}
|
||||
size="large"
|
||||
>
|
||||
运行 Meta 分析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStep2 = () => (
|
||||
<div>
|
||||
<ResultsPanel data={results} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => setCurrentStep(1)}>
|
||||
返回参数配置
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { setResults(null); setCurrentStep(0); }}>
|
||||
新建分析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 32px' }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>
|
||||
<ExperimentOutlined /> Meta 分析引擎
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
定量合并多个研究的效应量,生成森林图和漏斗图 · 支持 HR / 二分类 / 连续型数据
|
||||
</Text>
|
||||
<Tag color="green" style={{ marginLeft: 8 }}>支持独立文件模式</Tag>
|
||||
</div>
|
||||
|
||||
<Steps
|
||||
current={currentStep}
|
||||
size="small"
|
||||
style={{ marginBottom: 24, maxWidth: 600 }}
|
||||
items={[
|
||||
{ title: '数据输入' },
|
||||
{ title: '参数配置' },
|
||||
{ title: '分析结果' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Spin spinning={running} tip="正在调用 R 统计引擎,请稍候...">
|
||||
{currentStep === 0 && renderStep0()}
|
||||
{currentStep === 1 && renderStep1()}
|
||||
{currentStep === 2 && renderStep2()}
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetaAnalysisEngine;
|
||||
146
frontend-v2/src/modules/asl/pages/SRChartGenerator.tsx
Normal file
146
frontend-v2/src/modules/asl/pages/SRChartGenerator.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Radio, Card, Empty, Tag, Space, Typography } from 'antd';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
TableOutlined,
|
||||
SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import DataSourceSelector, {
|
||||
type DataSourceResult,
|
||||
} from '../components/charting/DataSourceSelector';
|
||||
import PrismaFlowDiagram from '../components/charting/PrismaFlowDiagram';
|
||||
import BaselineTable from '../components/charting/BaselineTable';
|
||||
import type { PrismaData, BaselineRow } from '../utils/chartingExcelUtils';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
type ChartType = 'prisma' | 'baseline' | 'rob';
|
||||
|
||||
const SRChartGenerator: React.FC = () => {
|
||||
const [chartType, setChartType] = useState<ChartType>('prisma');
|
||||
const [prismaData, setPrismaData] = useState<PrismaData | null>(null);
|
||||
const [baselineData, setBaselineData] = useState<BaselineRow[]>([]);
|
||||
const [hasData, setHasData] = useState(false);
|
||||
const [prismaLang, setPrismaLang] = useState<'zh' | 'en'>('zh');
|
||||
|
||||
const handleDataReady = useCallback((result: DataSourceResult) => {
|
||||
if (result.prisma) setPrismaData(result.prisma);
|
||||
if (result.baseline?.length > 0) setBaselineData(result.baseline);
|
||||
setHasData(true);
|
||||
}, []);
|
||||
|
||||
const renderResult = () => {
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-20">
|
||||
<ApartmentOutlined style={{ fontSize: 48, color: '#e5e7eb' }} />
|
||||
<Text className="mt-4 text-gray-400">
|
||||
请在左侧选择数据来源并加载数据后生成图表
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'prisma') {
|
||||
if (!prismaData) {
|
||||
return (
|
||||
<Empty description="未找到 PRISMA 数据,请确保 Excel 中包含 'PRISMA_Data' 工作表" />
|
||||
);
|
||||
}
|
||||
return <PrismaFlowDiagram data={prismaData} lang={prismaLang} onLangChange={setPrismaLang} />;
|
||||
}
|
||||
|
||||
if (chartType === 'baseline') {
|
||||
if (baselineData.length === 0) {
|
||||
return (
|
||||
<Empty description="未找到基线数据,请确保 Excel 中包含 'Baseline_Data' 工作表" />
|
||||
);
|
||||
}
|
||||
return <BaselineTable data={baselineData} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-20">
|
||||
<SafetyOutlined style={{ fontSize: 48, color: '#e5e7eb' }} />
|
||||
<Text className="mt-4 text-gray-400">
|
||||
偏倚风险汇总图 — 即将推出
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full overflow-auto bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<Title level={4} className="!mb-1">
|
||||
SR 图表生成器
|
||||
</Title>
|
||||
<Text className="text-gray-500 text-sm">
|
||||
生成 PRISMA 流程图、基线特征表等系统综述标准图表,支持一键导出
|
||||
</Text>
|
||||
</div>
|
||||
<Tag color="blue" className="text-xs">
|
||||
支持独立文件模式
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex gap-6" style={{ minHeight: 'calc(100vh - 220px)' }}>
|
||||
{/* Left: config panel */}
|
||||
<div className="w-80 shrink-0 space-y-4">
|
||||
<Card size="small" title="图表类型">
|
||||
<Radio.Group
|
||||
value={chartType}
|
||||
onChange={(e) => setChartType(e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<Space direction="vertical" className="w-full">
|
||||
<Radio value="prisma" className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<ApartmentOutlined className="text-blue-500" />
|
||||
<span className="font-medium">PRISMA 2020 流程图</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="baseline" className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<TableOutlined className="text-green-500" />
|
||||
<span className="font-medium">基线特征表 (Table 1)</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="rob" className="w-full" disabled>
|
||||
<div className="flex items-center gap-2">
|
||||
<SafetyOutlined className="text-orange-400" />
|
||||
<span className="font-medium text-gray-400">
|
||||
偏倚风险汇总图 (RoB)
|
||||
</span>
|
||||
<Tag color="default" className="text-xs ml-1">即将推出</Tag>
|
||||
</div>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<DataSourceSelector onDataReady={handleDataReady} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: rendering result */}
|
||||
<Card
|
||||
className="flex-1 min-h-[600px]"
|
||||
title={
|
||||
<span className="text-lg font-bold">
|
||||
渲染预览
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{renderResult()}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SRChartGenerator;
|
||||
220
frontend-v2/src/modules/asl/utils/chartingExcelUtils.ts
Normal file
220
frontend-v2/src/modules/asl/utils/chartingExcelUtils.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface PrismaData {
|
||||
totalIdentified: number;
|
||||
duplicatesRemoved: number;
|
||||
titleAbstractExcluded: number;
|
||||
titleAbstractExclusionReasons?: string;
|
||||
fullTextExcluded: number;
|
||||
fullTextExclusionReasons?: string;
|
||||
finalIncluded: number;
|
||||
}
|
||||
|
||||
export interface BaselineRow {
|
||||
Study_ID: string;
|
||||
Intervention_Name: string;
|
||||
Control_Name: string;
|
||||
Intervention_N: string | number;
|
||||
Control_N: string | number;
|
||||
Age_Mean_SD: string;
|
||||
Male_Percent: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ==================== Template Download ====================
|
||||
|
||||
export function downloadSRTemplate(): void {
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
const prismaData = [
|
||||
{ Stage: 'Total_Identified', Count: 1245, Exclusion_Reasons: '' },
|
||||
{ Stage: 'Duplicates_Removed', Count: 345, Exclusion_Reasons: '' },
|
||||
{
|
||||
Stage: 'Title_Abstract_Excluded',
|
||||
Count: 700,
|
||||
Exclusion_Reasons: 'Non-RCT: 400, Wrong population: 200, Review articles: 100',
|
||||
},
|
||||
{
|
||||
Stage: 'FullText_Excluded',
|
||||
Count: 80,
|
||||
Exclusion_Reasons: 'Missing outcome data: 50, No PDF available: 30',
|
||||
},
|
||||
{ Stage: 'Final_Included', Count: 120, Exclusion_Reasons: '' },
|
||||
];
|
||||
|
||||
const wsPrisma = XLSX.utils.json_to_sheet(prismaData);
|
||||
wsPrisma['!cols'] = [{ wch: 28 }, { wch: 10 }, { wch: 60 }];
|
||||
XLSX.utils.book_append_sheet(wb, wsPrisma, 'PRISMA_Data');
|
||||
|
||||
const baselineData = [
|
||||
{
|
||||
Study_ID: 'Gandhi 2018',
|
||||
Intervention_Name: 'Pembrolizumab + Chemo',
|
||||
Control_Name: 'Placebo + Chemo',
|
||||
Intervention_N: 410,
|
||||
Control_N: 206,
|
||||
Age_Mean_SD: '62.5 ± 8.1',
|
||||
Male_Percent: '60.5%',
|
||||
},
|
||||
{
|
||||
Study_ID: 'Hellmann 2019',
|
||||
Intervention_Name: 'Nivolumab + Ipilimumab',
|
||||
Control_Name: 'Chemotherapy',
|
||||
Intervention_N: 583,
|
||||
Control_N: 583,
|
||||
Age_Mean_SD: '64.0 ± 9.2',
|
||||
Male_Percent: '68.0%',
|
||||
},
|
||||
];
|
||||
|
||||
const wsBaseline = XLSX.utils.json_to_sheet(baselineData);
|
||||
wsBaseline['!cols'] = [
|
||||
{ wch: 18 }, { wch: 28 }, { wch: 22 },
|
||||
{ wch: 16 }, { wch: 12 }, { wch: 16 }, { wch: 14 },
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, wsBaseline, 'Baseline_Data');
|
||||
|
||||
XLSX.writeFile(wb, 'SR_Charting_Template.xlsx');
|
||||
}
|
||||
|
||||
// ==================== Excel Parsing ====================
|
||||
|
||||
export async function parseSRExcel(
|
||||
file: File,
|
||||
): Promise<{ prisma: PrismaData | null; baseline: BaselineRow[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const buffer = e.target?.result as ArrayBuffer;
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
|
||||
let prisma: PrismaData | null = null;
|
||||
let baseline: BaselineRow[] = [];
|
||||
|
||||
const prismaSheet = workbook.Sheets['PRISMA_Data'] || workbook.Sheets[workbook.SheetNames[0]];
|
||||
if (prismaSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json<any>(prismaSheet);
|
||||
prisma = parsePrismaRows(rows);
|
||||
}
|
||||
|
||||
const baselineSheet = workbook.Sheets['Baseline_Data'] || workbook.Sheets[workbook.SheetNames[1]];
|
||||
if (baselineSheet) {
|
||||
baseline = XLSX.utils.sheet_to_json<BaselineRow>(baselineSheet);
|
||||
}
|
||||
|
||||
resolve({ prisma, baseline });
|
||||
} catch (error) {
|
||||
reject(new Error(`Excel parsing failed: ${(error as Error).message}`));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('File read failed'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
function parsePrismaRows(rows: any[]): PrismaData {
|
||||
const data: PrismaData = {
|
||||
totalIdentified: 0,
|
||||
duplicatesRemoved: 0,
|
||||
titleAbstractExcluded: 0,
|
||||
fullTextExcluded: 0,
|
||||
finalIncluded: 0,
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const stage = String(row.Stage || row.stage || '').trim();
|
||||
const count = Number(row.Count || row.count || 0);
|
||||
const reasons = String(row.Exclusion_Reasons || row.exclusion_reasons || '').trim();
|
||||
|
||||
switch (stage) {
|
||||
case 'Total_Identified':
|
||||
data.totalIdentified = count;
|
||||
break;
|
||||
case 'Duplicates_Removed':
|
||||
data.duplicatesRemoved = count;
|
||||
break;
|
||||
case 'Title_Abstract_Excluded':
|
||||
data.titleAbstractExcluded = count;
|
||||
if (reasons) data.titleAbstractExclusionReasons = reasons;
|
||||
break;
|
||||
case 'FullText_Excluded':
|
||||
data.fullTextExcluded = count;
|
||||
if (reasons) data.fullTextExclusionReasons = reasons;
|
||||
break;
|
||||
case 'Final_Included':
|
||||
data.finalIncluded = count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ==================== SVG Export ====================
|
||||
|
||||
export function exportDiagramAsSvg(containerEl: HTMLElement, filename: string): void {
|
||||
const svgEl = containerEl.querySelector('svg');
|
||||
if (!svgEl) {
|
||||
const clone = containerEl.cloneNode(true) as HTMLElement;
|
||||
const blob = new Blob(
|
||||
[`<html><body>${clone.outerHTML}</body></html>`],
|
||||
{ type: 'text/html' },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename.replace(/\.svg$/, '.html');
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgEl);
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function exportDiagramAsPng(svgEl: SVGSVGElement, filename: string): void {
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgEl);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const img = new Image();
|
||||
|
||||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
|
||||
img.onload = () => {
|
||||
const scale = 2;
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, img.width, img.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const pngUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = pngUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
}
|
||||
240
frontend-v2/src/modules/asl/utils/metaExcelUtils.ts
Normal file
240
frontend-v2/src/modules/asl/utils/metaExcelUtils.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Meta Analysis Excel Utilities
|
||||
*
|
||||
* Three template types:
|
||||
* - HR (time-to-event): study_id, hr, lower_ci, upper_ci
|
||||
* - Dichotomous: study_id, events_e, total_e, events_c, total_c
|
||||
* - Continuous: study_id, mean_e, sd_e, n_e, mean_c, sd_c, n_c
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export type MetaDataType = 'hr' | 'dichotomous' | 'continuous';
|
||||
|
||||
export interface MetaRow {
|
||||
study_id: string;
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
const HR_HEADERS = [
|
||||
{ key: 'study_id', label: '研究ID (Study ID)' },
|
||||
{ key: 'hr', label: 'HR (Hazard Ratio)' },
|
||||
{ key: 'lower_ci', label: '95% CI 下限 (Lower)' },
|
||||
{ key: 'upper_ci', label: '95% CI 上限 (Upper)' },
|
||||
];
|
||||
|
||||
const DICHOTOMOUS_HEADERS = [
|
||||
{ key: 'study_id', label: '研究ID (Study ID)' },
|
||||
{ key: 'events_e', label: '实验组事件数 (Events Exp.)' },
|
||||
{ key: 'total_e', label: '实验组总人数 (Total Exp.)' },
|
||||
{ key: 'events_c', label: '对照组事件数 (Events Ctrl.)' },
|
||||
{ key: 'total_c', label: '对照组总人数 (Total Ctrl.)' },
|
||||
];
|
||||
|
||||
const CONTINUOUS_HEADERS = [
|
||||
{ key: 'study_id', label: '研究ID (Study ID)' },
|
||||
{ key: 'mean_e', label: '实验组均值 (Mean Exp.)' },
|
||||
{ key: 'sd_e', label: '实验组标准差 (SD Exp.)' },
|
||||
{ key: 'n_e', label: '实验组人数 (N Exp.)' },
|
||||
{ key: 'mean_c', label: '对照组均值 (Mean Ctrl.)' },
|
||||
{ key: 'sd_c', label: '对照组标准差 (SD Ctrl.)' },
|
||||
{ key: 'n_c', label: '对照组人数 (N Ctrl.)' },
|
||||
];
|
||||
|
||||
const HR_EXAMPLE: Record<string, unknown>[] = [
|
||||
{ study_id: 'Gandhi 2018', hr: 0.49, lower_ci: 0.38, upper_ci: 0.64 },
|
||||
{ study_id: 'Socinski 2018', hr: 0.56, lower_ci: 0.45, upper_ci: 0.70 },
|
||||
{ study_id: 'West 2019', hr: 0.60, lower_ci: 0.45, upper_ci: 0.80 },
|
||||
];
|
||||
|
||||
const DICHOTOMOUS_EXAMPLE: Record<string, unknown>[] = [
|
||||
{ study_id: 'Smith 2020', events_e: 25, total_e: 100, events_c: 40, total_c: 100 },
|
||||
{ study_id: 'Jones 2021', events_e: 30, total_e: 150, events_c: 55, total_c: 150 },
|
||||
{ study_id: 'Wang 2022', events_e: 12, total_e: 80, events_c: 22, total_c: 80 },
|
||||
];
|
||||
|
||||
const CONTINUOUS_EXAMPLE: Record<string, unknown>[] = [
|
||||
{ study_id: 'Trial A', mean_e: 5.2, sd_e: 1.3, n_e: 50, mean_c: 6.8, sd_c: 1.5, n_c: 50 },
|
||||
{ study_id: 'Trial B', mean_e: 4.8, sd_e: 1.1, n_e: 60, mean_c: 6.5, sd_c: 1.4, n_c: 60 },
|
||||
{ study_id: 'Trial C', mean_e: 5.0, sd_e: 1.2, n_e: 45, mean_c: 7.0, sd_c: 1.6, n_c: 45 },
|
||||
];
|
||||
|
||||
function getHeadersForType(type: MetaDataType) {
|
||||
switch (type) {
|
||||
case 'hr': return HR_HEADERS;
|
||||
case 'dichotomous': return DICHOTOMOUS_HEADERS;
|
||||
case 'continuous': return CONTINUOUS_HEADERS;
|
||||
}
|
||||
}
|
||||
|
||||
function getExampleForType(type: MetaDataType) {
|
||||
switch (type) {
|
||||
case 'hr': return HR_EXAMPLE;
|
||||
case 'dichotomous': return DICHOTOMOUS_EXAMPLE;
|
||||
case 'continuous': return CONTINUOUS_EXAMPLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an Excel template for the given data type.
|
||||
* Includes a header row with Chinese+English labels and 3 example rows.
|
||||
*/
|
||||
export function downloadMetaTemplate(type: MetaDataType) {
|
||||
const headers = getHeadersForType(type);
|
||||
const examples = getExampleForType(type);
|
||||
|
||||
const headerLabels = headers.map(h => h.label);
|
||||
const dataRows = examples.map(ex => headers.map(h => ex[h.key] ?? ''));
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headerLabels, ...dataRows]);
|
||||
|
||||
const colWidths = headers.map(h => ({ wch: Math.max(h.label.length + 4, 18) }));
|
||||
ws['!cols'] = colWidths;
|
||||
|
||||
const instrWs = XLSX.utils.aoa_to_sheet([
|
||||
['Meta 分析数据模板使用说明'],
|
||||
[],
|
||||
['1. 在 Data 工作表中填写您的数据'],
|
||||
['2. 第一行为列标题,请勿修改列标题'],
|
||||
['3. 从第二行开始填写数据,每行代表一个研究'],
|
||||
['4. study_id 为研究标识,必须唯一'],
|
||||
['5. 数值字段请填写数字,不要包含文字'],
|
||||
[],
|
||||
['数据类型说明:'],
|
||||
type === 'hr' ? ['HR (风险比):填写 HR 值及其 95% 置信区间上下限'] :
|
||||
type === 'dichotomous' ? ['二分类:填写实验组和对照组的事件数及总人数'] :
|
||||
['连续型:填写实验组和对照组的均值、标准差及人数'],
|
||||
]);
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Data');
|
||||
XLSX.utils.book_append_sheet(wb, instrWs, '使用说明');
|
||||
|
||||
const typeLabel = type === 'hr' ? 'HR' : type === 'dichotomous' ? '二分类' : '连续型';
|
||||
XLSX.writeFile(wb, `Meta分析数据模板_${typeLabel}.xlsx`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded Excel file into MetaRow[] and detect the data type.
|
||||
*/
|
||||
export function parseMetaExcel(file: File): Promise<{
|
||||
rows: MetaRow[];
|
||||
detectedType: MetaDataType | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target!.result as ArrayBuffer);
|
||||
const wb = XLSX.read(data, { type: 'array' });
|
||||
|
||||
let sheetName = 'Data';
|
||||
if (!wb.SheetNames.includes('Data')) {
|
||||
sheetName = wb.SheetNames[0];
|
||||
}
|
||||
const ws = wb.Sheets[sheetName];
|
||||
const raw: unknown[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
|
||||
|
||||
if (raw.length < 2) {
|
||||
return reject(new Error('Excel 文件至少需要包含标题行和 1 行数据'));
|
||||
}
|
||||
|
||||
const headerRow = raw[0] as string[];
|
||||
const keyMap = buildKeyMap(headerRow);
|
||||
|
||||
const rows: MetaRow[] = [];
|
||||
for (let i = 1; i < raw.length; i++) {
|
||||
const cells = raw[i] as (string | number)[];
|
||||
if (cells.every(c => c === '' || c === undefined || c === null)) continue;
|
||||
|
||||
const row: MetaRow = { study_id: '' };
|
||||
for (let j = 0; j < headerRow.length; j++) {
|
||||
const key = keyMap[j];
|
||||
if (key) row[key] = cells[j];
|
||||
}
|
||||
if (!row.study_id) row.study_id = `Study ${i}`;
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
const detectedType = detectDataType(rows);
|
||||
resolve({ rows, detectedType });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map header labels (Chinese+English) back to canonical keys.
|
||||
*/
|
||||
function buildKeyMap(headerRow: string[]): Record<number, string> {
|
||||
const allHeaders = [...HR_HEADERS, ...DICHOTOMOUS_HEADERS, ...CONTINUOUS_HEADERS];
|
||||
const labelToKey = new Map<string, string>();
|
||||
for (const h of allHeaders) {
|
||||
labelToKey.set(h.label.toLowerCase(), h.key);
|
||||
labelToKey.set(h.key.toLowerCase(), h.key);
|
||||
}
|
||||
|
||||
const map: Record<number, string> = {};
|
||||
for (let i = 0; i < headerRow.length; i++) {
|
||||
const raw = String(headerRow[i]).trim().toLowerCase();
|
||||
const key = labelToKey.get(raw);
|
||||
if (key) {
|
||||
map[i] = key;
|
||||
} else {
|
||||
const sanitized = raw.replace(/[^a-z0-9_]/g, '_');
|
||||
const match = allHeaders.find(h => sanitized.includes(h.key));
|
||||
if (match) map[i] = match.key;
|
||||
else map[i] = sanitized;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function detectDataType(rows: MetaRow[]): MetaDataType | null {
|
||||
if (rows.length === 0) return null;
|
||||
const first = rows[0];
|
||||
if (first.hr !== undefined && first.hr !== '') return 'hr';
|
||||
if (first.events_e !== undefined && first.events_e !== '') return 'dichotomous';
|
||||
if (first.mean_e !== undefined && first.mean_e !== '') return 'continuous';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDataTypeLabel(type: MetaDataType): string {
|
||||
switch (type) {
|
||||
case 'hr': return 'HR (风险比)';
|
||||
case 'dichotomous': return '二分类 (Dichotomous)';
|
||||
case 'continuous': return '连续型 (Continuous)';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequiredColumns(type: MetaDataType): string[] {
|
||||
return getHeadersForType(type).map(h => h.key);
|
||||
}
|
||||
|
||||
export function validateMetaData(rows: MetaRow[], type: MetaDataType): string[] {
|
||||
const errors: string[] = [];
|
||||
const required = getRequiredColumns(type).filter(k => k !== 'study_id');
|
||||
|
||||
if (rows.length < 2) {
|
||||
errors.push('至少需要 2 个研究才能进行 Meta 分析');
|
||||
}
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
for (const col of required) {
|
||||
const val = row[col];
|
||||
if (val === undefined || val === '' || val === null) {
|
||||
errors.push(`第 ${i + 1} 行缺少 ${col}`);
|
||||
} else if (isNaN(Number(val))) {
|
||||
errors.push(`第 ${i + 1} 行 ${col} 不是有效数字: "${val}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Spin, Button, Tag, Tooltip } from 'antd';
|
||||
import { Layout, Menu, Spin, Tag } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ThunderboltOutlined,
|
||||
FileSearchOutlined,
|
||||
AlertOutlined,
|
||||
SettingOutlined,
|
||||
LinkOutlined,
|
||||
DatabaseOutlined,
|
||||
MessageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
@@ -17,39 +16,39 @@ const { Sider, Content, Header } = Layout;
|
||||
const siderMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
type: 'group',
|
||||
label: '全局与宏观概览',
|
||||
label: '质控总览',
|
||||
children: [
|
||||
{
|
||||
key: 'dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '项目健康度大盘',
|
||||
},
|
||||
{
|
||||
key: 'variables',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '变量清单',
|
||||
},
|
||||
{
|
||||
key: 'reports',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: '定期报告与关键事件',
|
||||
label: '报告与关键事件',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
label: 'AI 监查过程与细节',
|
||||
label: 'AI 监查',
|
||||
children: [
|
||||
{
|
||||
key: 'stream',
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: 'AI 实时工作流水',
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
icon: <MessageOutlined />,
|
||||
label: 'AI 对话助手',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
label: '人工介入与协作',
|
||||
label: '人工协作',
|
||||
children: [
|
||||
{
|
||||
key: 'equery',
|
||||
@@ -62,10 +61,10 @@ const siderMenuItems: MenuProps['items'] = [
|
||||
|
||||
const viewTitles: Record<string, string> = {
|
||||
dashboard: '项目健康度大盘',
|
||||
variables: '变量清单',
|
||||
stream: 'AI 实时工作流水',
|
||||
chat: 'AI 对话助手',
|
||||
equery: '待处理电子质疑 (eQuery)',
|
||||
reports: '定期报告与关键事件',
|
||||
reports: '报告与关键事件',
|
||||
};
|
||||
|
||||
const IitLayout: React.FC = () => {
|
||||
@@ -135,30 +134,18 @@ const IitLayout: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部:项目设置入口 */}
|
||||
{/* 底部:连接状态 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #1e293b',
|
||||
marginTop: 'auto',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}>
|
||||
<Tooltip title="跳转到项目配置界面(REDCap 连接、质控规则、知识库等)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => navigate('/iit/config')}
|
||||
style={{ color: '#94a3b8', width: '100%', textAlign: 'left', padding: '4px 12px' }}
|
||||
>
|
||||
项目设置
|
||||
<LinkOutlined style={{ marginLeft: 4, fontSize: 10 }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div style={{ fontSize: 11, color: '#475569', marginTop: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontSize: 11, color: '#475569', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tag color="success" style={{ margin: 0, fontSize: 10 }}>EDC 直连</Tag>
|
||||
统一视图,全员可见
|
||||
AI 持续监控中
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
@@ -6,28 +6,20 @@ const DashboardPage = React.lazy(() => import('./pages/DashboardPage'));
|
||||
const AiStreamPage = React.lazy(() => import('./pages/AiStreamPage'));
|
||||
const EQueryPage = React.lazy(() => import('./pages/EQueryPage'));
|
||||
const ReportsPage = React.lazy(() => import('./pages/ReportsPage'));
|
||||
|
||||
const VariableListPage = React.lazy(() => import('./pages/VariableListPage'));
|
||||
|
||||
const ConfigProjectListPage = React.lazy(() => import('./config/ProjectListPage'));
|
||||
const ConfigProjectDetailPage = React.lazy(() => import('./config/ProjectDetailPage'));
|
||||
const AiChatPage = React.lazy(() => import('./pages/AiChatPage'));
|
||||
|
||||
const IitModule: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
{/* CRA 质控平台主界面 */}
|
||||
{/* CRA 质控平台 — 终端用户日常使用界面 */}
|
||||
<Route element={<IitLayout />}>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="stream" element={<AiStreamPage />} />
|
||||
<Route path="equery" element={<EQueryPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="variables" element={<VariableListPage />} />
|
||||
<Route path="chat" element={<AiChatPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 项目配置界面(独立布局,不使用 CRA 质控平台导航) */}
|
||||
<Route path="config" element={<ConfigProjectListPage />} />
|
||||
<Route path="config/:id" element={<ConfigProjectDetailPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
195
frontend-v2/src/modules/iit/pages/AiChatPage.tsx
Normal file
195
frontend-v2/src/modules/iit/pages/AiChatPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Input, Button, Spin, Typography, Avatar } from 'antd';
|
||||
import {
|
||||
SendOutlined,
|
||||
RobotOutlined,
|
||||
UserOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const WELCOME_SUGGESTIONS = [
|
||||
'最新质控报告怎么样?',
|
||||
'有没有严重违规问题?',
|
||||
'通过率趋势如何?',
|
||||
'入排标准是什么?',
|
||||
];
|
||||
|
||||
const AiChatPage: React.FC = () => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || loading) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/iit/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text.trim() }),
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
const aiMsg: ChatMessage = {
|
||||
id: `ai-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: data.reply || '抱歉,暂时无法回答。',
|
||||
timestamp: new Date(),
|
||||
duration: data.duration,
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
} catch {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: `err-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '网络错误,请检查后端服务是否运行。',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', maxWidth: 800, margin: '0 auto' }}>
|
||||
{/* Messages area */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 0' }}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', paddingTop: 60 }}>
|
||||
<RobotOutlined style={{ fontSize: 48, color: '#3b82f6', marginBottom: 16 }} />
|
||||
<h2 style={{ color: '#1e293b', marginBottom: 8 }}>CRA AI 对话助手</h2>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
|
||||
基于质控数据和知识库,为您提供项目质量分析和建议
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
||||
{WELCOME_SUGGESTIONS.map((s, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
type="dashed"
|
||||
onClick={() => sendMessage(s)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
style={{ borderRadius: 20 }}
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
|
||||
style={{
|
||||
background: msg.role === 'user' ? '#3b82f6' : '#10b981',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '75%',
|
||||
padding: '12px 16px',
|
||||
borderRadius: msg.role === 'user' ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
|
||||
background: msg.role === 'user' ? '#3b82f6' : '#f1f5f9',
|
||||
color: msg.role === 'user' ? '#fff' : '#1e293b',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
{msg.role === 'assistant' && msg.duration && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 6 }}>
|
||||
耗时 {(msg.duration / 1000).toFixed(1)}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||
<Avatar icon={<RobotOutlined />} style={{ background: '#10b981', flexShrink: 0 }} />
|
||||
<div style={{ padding: '12px 16px', background: '#f1f5f9', borderRadius: '16px 16px 16px 4px' }}>
|
||||
<Spin size="small" /> <Text type="secondary" style={{ marginLeft: 8 }}>AI 正在分析...</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
padding: '12px 0',
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
}}>
|
||||
<Input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onPressEnter={() => sendMessage(input)}
|
||||
placeholder="输入您的问题,例如:最近质控情况怎么样?"
|
||||
disabled={loading}
|
||||
size="large"
|
||||
style={{ borderRadius: 24, paddingLeft: 20 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || loading}
|
||||
size="large"
|
||||
shape="circle"
|
||||
style={{ background: '#3b82f6', borderColor: '#3b82f6' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiChatPage;
|
||||
Reference in New Issue
Block a user