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

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