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