feat(iit): QC deep fix + V3.1 architecture plan + project member management
QC System Deep Fix: - HardRuleEngine: add null tolerance + field availability pre-check (skipped status) - SkillRunner: baseline data merge for follow-up events + field availability check - QcReportService: record-level pass rate calculation + accurate LLM XML report - iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary - seed-iit-qc-rules: null/empty string tolerance + applicableEvents config V3.1 Architecture Design (docs only, no code changes): - QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions - Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines) - Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions - CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts Project Member Management: - Cross-tenant member search and assignment (remove tenant restriction) - IIT project detail page enhancement with tabbed layout (KB + members) - IitProjectContext for business-side project selection - System-KB route access control adjustment for project operators Frontend: - AdminLayout sidebar menu restructure - IitLayout with project context provider - IitMemberManagePage new component - Business-side pages adapt to project context Prisma: - 2 new migrations (user-project RBAC + is_demo flag) - Schema updates for project member management Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Spin, Tag } from 'antd';
|
||||
import { Layout, Menu, Spin, Tag, Select, Alert, Button, Result } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ThunderboltOutlined,
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
AlertOutlined,
|
||||
LinkOutlined,
|
||||
MessageOutlined,
|
||||
ExperimentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { IitProjectProvider, useIitProject } from './context/IitProjectContext';
|
||||
|
||||
const { Sider, Content, Header } = Layout;
|
||||
|
||||
@@ -67,9 +69,83 @@ const viewTitles: Record<string, string> = {
|
||||
reports: '报告与关键事件',
|
||||
};
|
||||
|
||||
const IitLayout: React.FC = () => {
|
||||
const ProjectSelector: React.FC = () => {
|
||||
const { project, projects, loading, switchProject, isDemo } = useIitProject();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (projects.length === 1) {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
|
||||
{isDemo ? '体验项目' : '当前监控项目'}
|
||||
</div>
|
||||
<div style={{ background: '#1e293b', borderRadius: 8, padding: 12, border: isDemo ? '1px solid #d48806' : '1px solid #334155' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#fff', marginBottom: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{isDemo && <ExperimentOutlined style={{ color: '#faad14' }} />}
|
||||
{project?.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>{project?.description || project?.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
|
||||
当前监控项目
|
||||
</div>
|
||||
<Select
|
||||
value={project?.id}
|
||||
onChange={switchProject}
|
||||
style={{ width: '100%' }}
|
||||
popupMatchSelectWidth={false}
|
||||
options={projects.map(p => ({
|
||||
value: p.id,
|
||||
label: `${p.isDemo ? '[体验] ' : ''}${p.name}${p.description ? ' / ' + p.description : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NoProjectPage: React.FC = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f8fafc',
|
||||
padding: 24,
|
||||
}}>
|
||||
<Result
|
||||
icon={<ThunderboltOutlined style={{ color: '#3b82f6', fontSize: 64 }} />}
|
||||
title="CRA 智能质控平台"
|
||||
subTitle="您尚未加入任何 IIT 项目,也暂无可体验的演示项目。请联系项目管理员将您添加到相关项目中。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => window.history.back()}>
|
||||
返回首页
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const IitInnerLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { projects, loading, error, isDemo } = useIitProject();
|
||||
|
||||
const pathSegments = location.pathname.split('/');
|
||||
const currentView = pathSegments[pathSegments.length - 1] || 'dashboard';
|
||||
@@ -79,6 +155,18 @@ const IitLayout: React.FC = () => {
|
||||
navigate(`/iit/${key}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f8fafc' }}>
|
||||
<Spin size="large" tip="正在加载项目信息..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || projects.length === 0) {
|
||||
return <NoProjectPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100%', background: '#f8fafc' }}>
|
||||
<Sider
|
||||
@@ -89,7 +177,6 @@ const IitLayout: React.FC = () => {
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Logo / 系统标题 */}
|
||||
<div style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
@@ -104,23 +191,8 @@ const IitLayout: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 当前项目信息 */}
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
|
||||
当前监控项目
|
||||
</div>
|
||||
<div style={{
|
||||
background: '#1e293b',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
border: '1px solid #334155',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#fff', marginBottom: 4 }}>IIT-2026-001</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>非小细胞肺癌真实世界研究</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectSelector />
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<Menu
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
@@ -134,7 +206,6 @@ const IitLayout: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部:连接状态 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #1e293b',
|
||||
@@ -151,7 +222,6 @@ const IitLayout: React.FC = () => {
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
{/* 顶部标题栏 */}
|
||||
<Header style={{
|
||||
background: '#fff',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
@@ -166,11 +236,23 @@ const IitLayout: React.FC = () => {
|
||||
{headerTitle}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{isDemo && (
|
||||
<Tag icon={<ExperimentOutlined />} color="warning">体验模式</Tag>
|
||||
)}
|
||||
<Tag icon={<LinkOutlined />} color="processing">EDC 实时直连中</Tag>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
{isDemo && (
|
||||
<Alert
|
||||
message="您当前正在体验演示项目,数据仅供参考。如需正式使用,请联系项目管理员将您添加到实际项目中。"
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
banner
|
||||
/>
|
||||
)}
|
||||
|
||||
<Content style={{
|
||||
overflow: 'auto',
|
||||
padding: 24,
|
||||
@@ -189,4 +271,10 @@ const IitLayout: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const IitLayout: React.FC = () => (
|
||||
<IitProjectProvider>
|
||||
<IitInnerLayout />
|
||||
</IitProjectProvider>
|
||||
);
|
||||
|
||||
export default IitLayout;
|
||||
|
||||
106
frontend-v2/src/modules/iit/context/IitProjectContext.tsx
Normal file
106
frontend-v2/src/modules/iit/context/IitProjectContext.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import apiClient from '@/common/api/axios';
|
||||
|
||||
const STORAGE_KEY = 'iit_selected_project_id';
|
||||
|
||||
export interface MyProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
redcapProjectId?: string;
|
||||
createdAt?: string;
|
||||
myRole?: string;
|
||||
isDemo?: boolean;
|
||||
}
|
||||
|
||||
interface IitProjectContextValue {
|
||||
projectId: string;
|
||||
project: MyProject | null;
|
||||
projects: MyProject[];
|
||||
myRole: string | null;
|
||||
isDemo: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
switchProject: (id: string) => void;
|
||||
}
|
||||
|
||||
const IitProjectContext = createContext<IitProjectContextValue | null>(null);
|
||||
|
||||
async function fetchMyProjects(): Promise<MyProject[]> {
|
||||
try {
|
||||
const resp = await apiClient.get('/api/v1/iit/my-projects');
|
||||
return resp.data.data || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const IitProjectProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [projects, setProjects] = useState<MyProject[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const all: MyProject[] = await fetchMyProjects();
|
||||
setProjects(all);
|
||||
|
||||
if (all.length === 0) {
|
||||
setSelectedId('');
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
const savedValid = saved && all.some(p => p.id === saved);
|
||||
const initial = savedValid ? saved! : all[0].id;
|
||||
setSelectedId(initial);
|
||||
localStorage.setItem(STORAGE_KEY, initial);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '获取项目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
const switchProject = useCallback((id: string) => {
|
||||
if (projects.some(p => p.id === id)) {
|
||||
setSelectedId(id);
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
}
|
||||
}, [projects]);
|
||||
|
||||
const project = projects.find(p => p.id === selectedId) || null;
|
||||
const myRole = project?.myRole || null;
|
||||
const isDemo = project?.isDemo === true;
|
||||
|
||||
return (
|
||||
<IitProjectContext.Provider value={{
|
||||
projectId: selectedId,
|
||||
project,
|
||||
projects,
|
||||
myRole,
|
||||
isDemo,
|
||||
loading,
|
||||
error,
|
||||
switchProject,
|
||||
}}>
|
||||
{children}
|
||||
</IitProjectContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useIitProject(): IitProjectContextValue {
|
||||
const ctx = useContext(IitProjectContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useIitProject must be used within IitProjectProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -6,9 +6,32 @@ import {
|
||||
UserOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useIitProject } from '../context/IitProjectContext';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/** 过滤 AI 回复中泄漏的 DSML / XML 工具调用标签(关键词检测) */
|
||||
function stripToolCallXml(text: string): string {
|
||||
if (text.includes('DSML')) {
|
||||
const dsmlIdx = text.indexOf('DSML');
|
||||
let cutStart = text.lastIndexOf('<', dsmlIdx);
|
||||
if (cutStart === -1) cutStart = dsmlIdx;
|
||||
const before = text.substring(0, cutStart).trim();
|
||||
const lastClose = text.lastIndexOf('>');
|
||||
const after = lastClose > dsmlIdx ? text.substring(lastClose + 1).trim() : '';
|
||||
return (before + (after ? '\n' + after : '')).trim();
|
||||
}
|
||||
if (text.includes('function_calls')) {
|
||||
const idx = text.indexOf('function_calls');
|
||||
let cutStart = text.lastIndexOf('<', idx);
|
||||
if (cutStart === -1) cutStart = idx;
|
||||
return text.substring(0, cutStart).trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
@@ -25,6 +48,7 @@ const WELCOME_SUGGESTIONS = [
|
||||
];
|
||||
|
||||
const AiChatPage: React.FC = () => {
|
||||
const { projectId } = useIitProject();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -56,7 +80,7 @@ const AiChatPage: React.FC = () => {
|
||||
const resp = await fetch('/api/v1/iit/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text.trim() }),
|
||||
body: JSON.stringify({ message: text.trim(), projectId }),
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
@@ -135,11 +159,18 @@ const AiChatPage: React.FC = () => {
|
||||
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' ? (
|
||||
<div className="chat-md-content">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{stripToolCallXml(msg.content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</span>
|
||||
)}
|
||||
{msg.role === 'assistant' && msg.duration && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 6 }}>
|
||||
耗时 {(msg.duration / 1000).toFixed(1)}s
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Timeline,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { TimelineItem } from '../api/iitProjectApi';
|
||||
import { useIitProject } from '../context/IitProjectContext';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -48,8 +48,7 @@ const TRIGGER_TAG: Record<string, { color: string; label: string }> = {
|
||||
};
|
||||
|
||||
const AiStreamPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
const { projectId } = useIitProject();
|
||||
|
||||
const [items, setItems] = useState<TimelineItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -82,10 +81,6 @@ const AiStreamPage: React.FC = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
return <div style={{ padding: 24 }}><Empty description="请先选择一个项目" /></div>;
|
||||
}
|
||||
|
||||
const timelineItems = items.map((item) => {
|
||||
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined /> };
|
||||
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
@@ -31,13 +31,13 @@ import {
|
||||
FileSearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import { useIitProject } from '../context/IitProjectContext';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
const { projectId } = useIitProject();
|
||||
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [heatmap, setHeatmap] = useState<any>(null);
|
||||
@@ -79,10 +79,6 @@ const DashboardPage: React.FC = () => {
|
||||
const healthColor = healthScore >= 80 ? '#52c41a' : healthScore >= 60 ? '#faad14' : '#ff4d4f';
|
||||
const healthLabel = healthScore >= 80 ? '良好' : healthScore >= 60 ? '需关注' : '风险';
|
||||
|
||||
if (!projectId) {
|
||||
return <div style={{ padding: 24 }}><Empty description="请先选择一个项目" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Health Score */}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { Equery, EqueryStats } from '../api/iitProjectApi';
|
||||
import { useIitProject } from '../context/IitProjectContext';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -55,8 +55,7 @@ const SEVERITY_CONFIG: Record<string, { color: string; label: string }> = {
|
||||
};
|
||||
|
||||
const EQueryPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
const { projectId } = useIitProject();
|
||||
|
||||
const [equeries, setEqueries] = useState<Equery[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -217,14 +216,6 @@ const EQueryPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty description="请先选择一个项目" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats */}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -34,12 +33,12 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import * as iitProjectApi from '../api/iitProjectApi';
|
||||
import type { QcReport, CriticalEvent } from '../api/iitProjectApi';
|
||||
import { useIitProject } from '../context/IitProjectContext';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const ReportsPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') || '';
|
||||
const { projectId } = useIitProject();
|
||||
|
||||
const [report, setReport] = useState<QcReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -87,10 +86,6 @@ const ReportsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
return <div style={{ padding: 24 }}><Empty description="请先选择一个项目" /></div>;
|
||||
}
|
||||
|
||||
if (!report && !loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
|
||||
Reference in New Issue
Block a user