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:
2026-02-26 21:51:02 +08:00
parent 7c3cc12b2e
commit 205932bb3f
30 changed files with 3596 additions and 114 deletions

View File

@@ -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 />} />
{/* 系统配置 */}

View File

@@ -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 />,

View File

@@ -619,6 +619,46 @@ export async function exportExtractionResults(
return response.blob();
}
// ==================== 工具 4SR 图表生成器 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}`);
}
// ==================== 工具 5Meta 分析引擎 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,
// 工具 4SR 图表生成器
getChartingPrismaData,
getChartingBaselineData,
// 工具 5Meta 分析引擎
runMetaAnalysis,
getMetaProjectData,
getMetaHealthCheck,
};

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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>
);
}

View File

@@ -30,6 +30,12 @@ const ExtractionSetup = lazy(() => import('./pages/ExtractionSetup'));
const ExtractionProgress = lazy(() => import('./pages/ExtractionProgress'));
const ExtractionWorkbench = lazy(() => import('./pages/ExtractionWorkbench'));
// 工具 4SR 图表生成器
const SRChartGenerator = lazy(() => import('./pages/SRChartGenerator'));
// 工具 5Meta 分析引擎
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>
{/* 工具 4SR 图表生成器 */}
<Route path="charting" element={<SRChartGenerator />} />
{/* 工具 5Meta 分析引擎 */}
<Route path="meta-analysis" element={<MetaAnalysisEngine />} />
</Route>
</Routes>
</Suspense>

View 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;

View 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;

View 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;
}

View 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;
}

View File

@@ -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>

View File

@@ -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>
);
};

View 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;