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:
2026-03-01 15:27:05 +08:00
parent c3f7d54fdf
commit 0b29fe88b5
61 changed files with 6877 additions and 524 deletions

View File

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

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

View File

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

View File

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

View File

@@ -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 */}

View File

@@ -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 */}

View File

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