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

@@ -28,6 +28,7 @@ import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage'
import IitProjectListPage from './modules/admin/pages/IitProjectListPage'
import IitProjectDetailPage from './modules/admin/pages/IitProjectDetailPage'
import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage'
import IitMemberManagePage from './modules/admin/pages/IitMemberManagePage'
// 运营日志
import ActivityLogsPage from './pages/admin/ActivityLogsPage'
// 个人中心页面
@@ -129,6 +130,8 @@ function App() {
<Route path="iit-projects" element={<IitProjectListPage />} />
<Route path="iit-projects/:id" element={<IitProjectDetailPage />} />
<Route path="iit-projects/:id/cockpit" element={<IitQcCockpitPage />} />
{/* IIT 项目人员管理 */}
<Route path="iit-members" element={<IitMemberManagePage />} />
{/* 运营日志 */}
<Route path="activity-logs" element={<ActivityLogsPage />} />
{/* 系统配置 */}

View File

@@ -9,6 +9,7 @@ export type UserRole =
| 'HOSPITAL_ADMIN'
| 'PHARMA_ADMIN'
| 'DEPARTMENT_ADMIN'
| 'IIT_OPERATOR'
| 'USER';
/** 租户类型 */

View File

@@ -53,15 +53,16 @@ const AdminLayout = () => {
return <Navigate to="/login" state={{ from: location }} replace />
}
// 权限检查:只有 SUPER_ADMIN 和 PROMPT_ENGINEER 可访问
const allowedRoles = ['SUPER_ADMIN', 'PROMPT_ENGINEER']
if (!allowedRoles.includes(user?.role || '')) {
// 权限检查:可进入管理端的角色
const adminAllowedRoles = ['SUPER_ADMIN', 'PROMPT_ENGINEER', 'IIT_OPERATOR', 'PHARMA_ADMIN', 'HOSPITAL_ADMIN']
const userRole = user?.role || ''
if (!adminAllowedRoles.includes(userRole)) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-6xl mb-4">🚫</div>
<h2 className="text-xl mb-2 text-gray-800">访</h2>
<p className="text-gray-500 mb-4"> SUPER_ADMIN PROMPT_ENGINEER </p>
<h2 className="text-xl mb-2 text-gray-800">访</h2>
<p className="text-gray-500 mb-4">访</p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 text-white rounded hover:opacity-90"
@@ -74,51 +75,66 @@ const AdminLayout = () => {
)
}
// 侧边栏菜单
const menuItems: MenuProps['items'] = [
{
key: '/admin/dashboard',
icon: <DashboardOutlined />,
label: '运营概览',
},
{
key: '/admin/prompts',
icon: <CodeOutlined />,
label: 'Prompt管理',
},
{
key: '/admin/system-kb',
icon: <BookOutlined />,
label: '系统知识库',
},
{
key: '/admin/tenants',
icon: <TeamOutlined />,
label: '租户管理',
},
{ type: 'divider' },
{
key: '/admin/iit-projects',
icon: <ExperimentOutlined />,
label: 'IIT 项目管理',
},
{ type: 'divider' },
{
key: '/admin/activity-logs',
icon: <FileTextOutlined />,
label: '运营日志',
},
{
key: '/admin/users',
icon: <UserOutlined />,
label: '用户管理',
},
{
key: '/admin/system',
icon: <SettingOutlined />,
label: '系统配置',
},
]
// 侧边栏菜单 — 三区分组(方案 B 逻辑拆分),按角色过滤
const platformGroup = {
type: 'group' as const,
label: '平台管理',
children: [
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '运营概览' },
{ key: '/admin/prompts', icon: <CodeOutlined />, label: 'Prompt 管理' },
{ key: '/admin/system-kb', icon: <BookOutlined />, label: '系统知识库' },
{ key: '/admin/system', icon: <SettingOutlined />, label: '系统配置' },
],
}
const projectGroup = {
type: 'group' as const,
label: '项目运营',
children: [
{ key: '/admin/iit-projects', icon: <ExperimentOutlined />, label: 'IIT 项目管理' },
{ key: '/admin/iit-members', icon: <TeamOutlined />, label: '项目人员管理' },
],
}
const bizGroup = {
type: 'group' as const,
label: '商务运营',
children: [
{ key: '/admin/tenants', icon: <TeamOutlined />, label: '租户管理' },
{ key: '/admin/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/admin/activity-logs', icon: <FileTextOutlined />, label: '运营日志' },
],
}
// RBAC: 按角色决定可见的菜单组
const menuItems: MenuProps['items'] = (() => {
const items: MenuProps['items'] = []
if (userRole === 'SUPER_ADMIN') {
items.push(platformGroup, { type: 'divider' }, projectGroup, { type: 'divider' }, bizGroup)
} else if (userRole === 'PROMPT_ENGINEER') {
// Prompt 工程师只看平台管理中的 Prompt 和知识库
items.push({
type: 'group',
label: '平台管理',
children: [
{ key: '/admin/prompts', icon: <CodeOutlined />, label: 'Prompt 管理' },
{ key: '/admin/system-kb', icon: <BookOutlined />, label: '系统知识库' },
],
})
} else if (userRole === 'IIT_OPERATOR') {
items.push({
type: 'group',
label: '项目运营',
children: [
{ key: '/admin/iit-projects', icon: <ExperimentOutlined />, label: 'IIT 项目管理' },
{ key: '/admin/iit-members', icon: <TeamOutlined />, label: '项目人员管理' },
],
})
} else if (userRole === 'PHARMA_ADMIN' || userRole === 'HOSPITAL_ADMIN') {
items.push(projectGroup)
}
return items
})()
// 用户下拉菜单
const userMenuItems: MenuProps['items'] = [
@@ -149,10 +165,18 @@ const AdminLayout = () => {
navigate(key)
}
// 获取当前选中的菜单项
const selectedKey = menuItems.find(item =>
location.pathname.startsWith(item?.key as string)
)?.key as string || '/admin/dashboard'
// 获取当前选中的菜单项(支持 group 嵌套结构)
const allMenuKeys: string[] = []
menuItems?.forEach(item => {
if (item && 'key' in item && item.key) allMenuKeys.push(item.key as string)
if (item && 'children' in item && Array.isArray((item as any).children)) {
(item as any).children.forEach((child: any) => {
if (child?.key) allMenuKeys.push(child.key as string)
})
}
})
const defaultPage = allMenuKeys[0] || '/admin/dashboard'
const selectedKey = allMenuKeys.find(k => location.pathname.startsWith(k)) || defaultPage
return (
<Layout className="h-screen">
@@ -173,7 +197,7 @@ const AdminLayout = () => {
<span className="text-2xl"></span>
{!collapsed && (
<span className="ml-2 font-bold" style={{ color: PRIMARY_COLOR }}>
</span>
)}
</div>

View File

@@ -41,7 +41,7 @@ const TopNavigation = () => {
// 检查用户权限,决定显示哪些切换入口
const userRole = user?.role || ''
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole)
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER', 'IIT_OPERATOR'].includes(userRole)
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole)
// 用户菜单 - 动态构建
@@ -86,7 +86,11 @@ const TopNavigation = () => {
authLogout()
navigate('/login')
} else if (key === 'switch-admin') {
navigate('/admin/dashboard')
if (userRole === 'IIT_OPERATOR') {
navigate('/admin/iit-projects')
} else {
navigate('/admin/dashboard')
}
} else if (key === 'switch-org') {
navigate('/org/dashboard')
} else {

View File

@@ -36,7 +36,7 @@ export const PermissionProvider = ({ children }: PermissionProviderProps) => {
let version: UserVersion = 'professional'
if (authUser.role === 'SUPER_ADMIN' || authUser.role === 'PROMPT_ENGINEER') {
version = 'premium'
} else if (authUser.role === 'HOSPITAL_ADMIN' || authUser.role === 'PHARMA_ADMIN' || authUser.role === 'DEPARTMENT_ADMIN') {
} else if (authUser.role === 'HOSPITAL_ADMIN' || authUser.role === 'PHARMA_ADMIN' || authUser.role === 'DEPARTMENT_ADMIN' || authUser.role === 'IIT_OPERATOR') {
version = 'advanced'
}

View File

@@ -17,6 +17,7 @@ import type {
UpdateUserMappingRequest,
RoleOption,
KnowledgeBaseOption,
TenantOption,
} from '../types/iitProject';
import type {
QcCockpitData,
@@ -25,12 +26,21 @@ import type {
const BASE_URL = '/api/v1/admin/iit-projects';
// ==================== 租户选项 ====================
/** 获取租户选项列表(创建项目时选择租户用) */
export async function getTenantOptions(): Promise<TenantOption[]> {
const response = await apiClient.get(`${BASE_URL}/tenant-options`);
return response.data.data;
}
// ==================== 项目 CRUD ====================
/** 获取项目列表 */
export async function listProjects(params?: {
status?: string;
search?: string;
tenantId?: string;
}): Promise<IitProject[]> {
const response = await apiClient.get(BASE_URL, { params });
return response.data.data;
@@ -89,6 +99,17 @@ export async function syncMetadata(projectId: string): Promise<{
return response.data.data;
}
// ==================== 变量元数据 ====================
/** 获取字段元数据列表 */
export async function getFieldMetadata(
projectId: string,
params?: { formName?: string; search?: string }
): Promise<{ fields: any[]; total: number; forms: string[] }> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/field-metadata`, { params });
return response.data.data;
}
// ==================== 知识库关联 ====================
/** 关联知识库 */
@@ -230,6 +251,30 @@ export async function deleteUserMapping(
await apiClient.delete(`${BASE_URL}/${projectId}/users/${mappingId}`);
}
/** 搜索平台用户(用于添加项目成员时选择用户) */
export interface PlatformUserOption {
id: string;
name: string;
phone: string;
email?: string;
role?: string;
}
export async function searchPlatformUsers(search: string): Promise<PlatformUserOption[]> {
if (!search || search.length < 2) return [];
const response = await apiClient.get('/api/admin/users', {
params: { search, pageSize: 20 },
});
const items = response.data.data?.data || response.data.data?.items || response.data.data?.list || [];
return items.map((u: any) => ({
id: u.id,
name: u.name || '',
phone: u.phone || '',
email: u.email || '',
role: u.role || '',
}));
}
// ==================== 批量操作 ====================
/** 一键全量质控 */

View File

@@ -0,0 +1,369 @@
/**
* IIT 项目人员管理页面
*
* 独立入口:运营管理端 → 项目运营 → 项目人员管理
* 左侧项目列表 + 右侧成员列表,支持搜索平台用户并关联到项目
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Card,
List,
Button,
Table,
Modal,
Form,
Input,
Select,
Space,
Tag,
Spin,
Typography,
message,
Popconfirm,
Tooltip,
Empty,
Badge,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
UserOutlined,
TeamOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type {
IitProject,
IitUserMapping,
CreateUserMappingRequest,
RoleOption,
} from '../types/iitProject';
const { Title, Text } = Typography;
const IitMemberManagePage: React.FC = () => {
const [projects, setProjects] = useState<IitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [projectsLoading, setProjectsLoading] = useState(true);
const [mappings, setMappings] = useState<IitUserMapping[]>([]);
const [roleOptions, setRoleOptions] = useState<RoleOption[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingMapping, setEditingMapping] = useState<IitUserMapping | null>(null);
const [form] = Form.useForm();
const [userSearchOptions, setUserSearchOptions] = useState<iitProjectApi.PlatformUserOption[]>([]);
const [userSearching, setUserSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<iitProjectApi.PlatformUserOption | null>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
(async () => {
setProjectsLoading(true);
try {
const [data, roles] = await Promise.all([
iitProjectApi.listProjects({ status: 'active' }),
iitProjectApi.getRoleOptions(),
]);
setProjects(data);
setRoleOptions(roles);
if (data.length > 0) setSelectedProjectId(data[0].id);
} catch {
message.error('加载项目列表失败');
} finally {
setProjectsLoading(false);
}
})();
}, []);
const loadMembers = useCallback(async () => {
if (!selectedProjectId) return;
setMembersLoading(true);
try {
const data = await iitProjectApi.listUserMappings(selectedProjectId);
setMappings(data);
} catch {
message.error('加载成员列表失败');
} finally {
setMembersLoading(false);
}
}, [selectedProjectId]);
useEffect(() => {
loadMembers();
}, [loadMembers]);
const handleUserSearch = (value: string) => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (!value || value.length < 2) {
setUserSearchOptions([]);
return;
}
setUserSearching(true);
searchTimerRef.current = setTimeout(async () => {
try {
const users = await iitProjectApi.searchPlatformUsers(value);
setUserSearchOptions(users);
} catch {
setUserSearchOptions([]);
} finally {
setUserSearching(false);
}
}, 400);
};
const handleUserSelect = (_value: string, option: any) => {
const user = userSearchOptions.find(u => u.id === option.key);
if (user) {
setSelectedUser(user);
form.setFieldsValue({ userId: user.id, systemUserId: user.id });
}
};
const handleUserClear = () => {
setSelectedUser(null);
form.setFieldsValue({ userId: undefined, systemUserId: undefined });
};
const handleAdd = () => {
setEditingMapping(null);
form.resetFields();
setSelectedUser(null);
setUserSearchOptions([]);
setModalOpen(true);
};
const handleEdit = (mapping: IitUserMapping) => {
setEditingMapping(mapping);
form.setFieldsValue(mapping);
if (mapping.user) {
const u = { id: mapping.user.id, name: mapping.user.name, phone: mapping.user.phone, email: mapping.user.email };
setSelectedUser(u);
setUserSearchOptions([u]);
} else {
setSelectedUser(null);
setUserSearchOptions([]);
}
setModalOpen(true);
};
const handleDelete = async (mappingId: string) => {
if (!selectedProjectId) return;
try {
await iitProjectApi.deleteUserMapping(selectedProjectId, mappingId);
message.success('删除成功');
loadMembers();
} catch {
message.error('删除失败');
}
};
const handleSubmit = async (values: CreateUserMappingRequest) => {
if (!selectedProjectId) return;
try {
if (editingMapping) {
await iitProjectApi.updateUserMapping(selectedProjectId, editingMapping.id, values);
message.success('更新成功');
} else {
await iitProjectApi.createUserMapping(selectedProjectId, values);
message.success('添加成功');
}
setModalOpen(false);
loadMembers();
} catch {
message.error('操作失败');
}
};
const selectedProject = projects.find(p => p.id === selectedProjectId);
const columns = [
{
title: '平台用户',
key: 'platformUser',
width: 180,
render: (_: unknown, record: IitUserMapping) =>
record.user ? (
<div>
<div style={{ fontWeight: 500 }}>{record.user.name}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{record.user.phone}</Text>
</div>
) : (
<Text type="secondary"></Text>
),
},
{
title: '企业微信 ID',
dataIndex: 'wecomUserId',
key: 'wecomUserId',
width: 140,
render: (id: string | undefined) => id || <Text type="secondary">-</Text>,
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 160,
render: (role: string) => {
const option = roleOptions.find(r => r.value === role);
return <Tag>{option?.label || role}</Tag>;
},
},
{
title: '操作',
key: 'action',
width: 100,
render: (_: unknown, record: IitUserMapping) => (
<Space>
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
</Tooltip>
<Popconfirm title="确认移除此成员?" onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<Title level={4} style={{ marginBottom: 24 }}>
<TeamOutlined style={{ marginRight: 8 }} />
</Title>
<div style={{ display: 'flex', gap: 24 }}>
{/* 左侧:项目列表 */}
<Card
title="IIT 项目"
size="small"
style={{ width: 300, flexShrink: 0, maxHeight: 'calc(100vh - 180px)', overflow: 'auto' }}
loading={projectsLoading}
>
{projects.length === 0 ? (
<Empty description="暂无项目" />
) : (
<List
dataSource={projects}
renderItem={project => (
<List.Item
onClick={() => setSelectedProjectId(project.id)}
style={{
cursor: 'pointer',
padding: '8px 12px',
borderRadius: 6,
background: project.id === selectedProjectId ? '#e6f4ff' : undefined,
border: project.id === selectedProjectId ? '1px solid #91caff' : '1px solid transparent',
marginBottom: 4,
}}
>
<List.Item.Meta
avatar={<Badge count={project.userMappingCount || 0} size="small"><UserOutlined style={{ fontSize: 20 }} /></Badge>}
title={<Text strong={project.id === selectedProjectId}>{project.name}</Text>}
description={<Text type="secondary" style={{ fontSize: 12 }}>{project.description || project.redcapProjectId}</Text>}
/>
</List.Item>
)}
/>
)}
</Card>
{/* 右侧:成员列表 */}
<Card
title={selectedProject ? `${selectedProject.name} — 项目成员` : '请选择一个项目'}
size="small"
style={{ flex: 1, maxHeight: 'calc(100vh - 180px)', overflow: 'auto' }}
extra={
selectedProjectId && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
)
}
>
{!selectedProjectId ? (
<Empty description="请从左侧选择一个项目" />
) : (
<Table
columns={columns}
dataSource={mappings}
rowKey="id"
loading={membersLoading}
pagination={false}
size="small"
/>
)}
</Card>
</div>
{/* 添加/编辑成员弹窗 */}
<Modal
title={editingMapping ? '编辑项目成员' : '添加项目成员'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
footer={null}
width={520}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit} style={{ marginTop: 24 }}>
<Form.Item name="userId" hidden>
<Input />
</Form.Item>
<Form.Item name="systemUserId" hidden>
<Input />
</Form.Item>
<Form.Item
label="关联平台用户"
extra={selectedUser ? `已选择: ${selectedUser.name} (${selectedUser.phone})` : '输入姓名或手机号搜索至少2个字符'}
>
<Select
showSearch
filterOption={false}
onSearch={handleUserSearch}
onSelect={handleUserSelect}
onClear={handleUserClear}
loading={userSearching}
placeholder="搜索用户姓名或手机号"
allowClear
notFoundContent={userSearching ? <Spin size="small" /> : (userSearchOptions.length === 0 ? null : undefined)}
value={selectedUser ? selectedUser.id : undefined}
options={userSearchOptions.map(u => ({
key: u.id,
value: u.id,
label: `${u.name} (${u.phone})`,
}))}
/>
</Form.Item>
<Form.Item name="role" label="项目角色" rules={[{ required: true, message: '请选择角色' }]}>
<Select options={roleOptions} placeholder="选择角色" />
</Form.Item>
<Form.Item name="wecomUserId" label="企业微信用户 ID" extra="用于接收质控通知">
<Input placeholder="企业微信通讯录中的用户 ID可选" />
</Form.Item>
<Form.Item name="redcapUsername" label="REDCap 用户名" extra="可选REDCap 中的用户名">
<Input placeholder="REDCap 用户名(可选)" />
</Form.Item>
<Form.Item>
<Space>
<Button onClick={() => setModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
{editingMapping ? '更新' : '添加'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default IitMemberManagePage;

View File

@@ -4,7 +4,7 @@
* 包含4个Tab基本配置、质控规则、用户映射、知识库
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card,
@@ -13,6 +13,7 @@ import {
Form,
Input,
Select,
Switch,
Table,
Modal,
message,
@@ -34,15 +35,13 @@ import {
DeleteOutlined,
EditOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
LinkOutlined,
DisconnectOutlined,
ExclamationCircleOutlined,
BookOutlined,
ThunderboltOutlined,
BarChartOutlined,
DashboardOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type {
@@ -53,7 +52,6 @@ import type {
IitUserMapping,
CreateUserMappingRequest,
RoleOption,
KnowledgeBaseOption,
} from '../types/iitProject';
const { Title, Text, Paragraph } = Typography;
@@ -157,8 +155,8 @@ const IitProjectDetailPage: React.FC = () => {
children: <QCRulesTab projectId={project.id} />,
},
{
key: 'users',
label: '通知设置',
key: 'members',
label: '项目成员',
children: <UserMappingTab projectId={project.id} />,
},
{
@@ -166,6 +164,11 @@ const IitProjectDetailPage: React.FC = () => {
label: '知识库',
children: <KnowledgeBaseTab project={project} onUpdate={loadProject} />,
},
{
key: 'fields',
label: '变量清单',
children: <FieldMetadataTab projectId={project.id} />,
},
];
return (
@@ -244,6 +247,8 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
redcapUrl: project.redcapUrl,
redcapProjectId: project.redcapProjectId,
redcapApiToken: project.redcapApiToken,
cronEnabled: (project as any).cronEnabled ?? false,
cronExpression: (project as any).cronExpression ?? '0 8 * * *',
});
}, [form, project]);
@@ -299,6 +304,12 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
<TextArea rows={2} />
</Form.Item>
{project.tenantId && (
<Form.Item label="所属租户">
<Tag color="cyan">{(project as any).tenantName || (project as any).tenant?.name || project.tenantId}</Tag>
</Form.Item>
)}
<Form.Item name="redcapUrl" label="REDCap URL" rules={[{ required: true }]}>
<Input />
</Form.Item>
@@ -315,6 +326,46 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
<Input.Password placeholder="REDCap 项目的 API Token" />
</Form.Item>
<Form.Item label={<Space><ClockCircleOutlined /></Space>} style={{ marginTop: 24 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Form.Item name="cronEnabled" valuePropName="checked" noStyle>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.cronEnabled !== cur.cronEnabled}>
{() => form.getFieldValue('cronEnabled') ? (
<Form.Item name="cronExpression" style={{ marginBottom: 0, marginTop: 8 }}>
<Select
style={{ width: 300 }}
options={[
{ value: '0 8 * * *', label: '每天 08:00' },
{ value: '0 9 * * 1', label: '每周一 09:00' },
{ value: '0 8 * * 1,3,5', label: '每周一三五 08:00' },
{ value: 'custom', label: '自定义 Cron 表达式' },
]}
/>
</Form.Item>
) : null}
</Form.Item>
</Space>
</Form.Item>
<Form.Item label="体验项目" extra="启用后,未加入任何项目的用户可在 CRA 质控模块中体验此项目">
<Switch
checked={project.isDemo || false}
onChange={async (checked) => {
try {
await iitProjectApi.updateProject(project.id, { isDemo: checked });
message.success(checked ? '已设为体验项目' : '已取消体验项目');
onUpdate();
} catch {
message.error('操作失败');
}
}}
checkedChildren="体验项目"
unCheckedChildren="正式项目"
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
@@ -607,6 +658,11 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
const [editingMapping, setEditingMapping] = useState<IitUserMapping | null>(null);
const [form] = Form.useForm();
const [userSearchOptions, setUserSearchOptions] = useState<iitProjectApi.PlatformUserOption[]>([]);
const [userSearching, setUserSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<iitProjectApi.PlatformUserOption | null>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
try {
@@ -616,7 +672,7 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
]);
setMappings(mappingsData);
setRoleOptions(rolesData);
} catch (error) {
} catch {
message.error('加载数据失败');
} finally {
setLoading(false);
@@ -627,15 +683,57 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
loadData();
}, [loadData]);
const handleUserSearch = (value: string) => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (!value || value.length < 2) {
setUserSearchOptions([]);
return;
}
setUserSearching(true);
searchTimerRef.current = setTimeout(async () => {
try {
const users = await iitProjectApi.searchPlatformUsers(value);
setUserSearchOptions(users);
} catch {
setUserSearchOptions([]);
} finally {
setUserSearching(false);
}
}, 400);
};
const handleUserSelect = (_value: string, option: any) => {
const user = userSearchOptions.find(u => u.id === option.key);
if (user) {
setSelectedUser(user);
form.setFieldsValue({ userId: user.id, systemUserId: user.id });
}
};
const handleUserClear = () => {
setSelectedUser(null);
form.setFieldsValue({ userId: undefined, systemUserId: undefined });
};
const handleAdd = () => {
setEditingMapping(null);
form.resetFields();
setSelectedUser(null);
setUserSearchOptions([]);
setModalOpen(true);
};
const handleEdit = (mapping: IitUserMapping) => {
setEditingMapping(mapping);
form.setFieldsValue(mapping);
if (mapping.user) {
const u = { id: mapping.user.id, name: mapping.user.name, phone: mapping.user.phone, email: mapping.user.email };
setSelectedUser(u);
setUserSearchOptions([u]);
} else {
setSelectedUser(null);
setUserSearchOptions([]);
}
setModalOpen(true);
};
@@ -644,7 +742,7 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
await iitProjectApi.deleteUserMapping(projectId, mappingId);
message.success('删除成功');
loadData();
} catch (error) {
} catch {
message.error('删除失败');
}
};
@@ -660,23 +758,38 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
}
setModalOpen(false);
loadData();
} catch (error) {
} catch {
message.error('操作失败');
}
};
const columns = [
{
title: '企业微信用户 ID',
title: '平台用户',
key: 'platformUser',
width: 180,
render: (_: unknown, record: IitUserMapping) =>
record.user ? (
<div>
<div style={{ fontWeight: 500 }}>{record.user.name}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{record.user.phone}</Text>
</div>
) : (
<Text type="secondary"></Text>
),
},
{
title: '企业微信 ID',
dataIndex: 'wecomUserId',
key: 'wecomUserId',
render: (id: string | undefined) => id || <Text type="secondary"></Text>,
width: 140,
render: (id: string | undefined) => id || <Text type="secondary">-</Text>,
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 120,
width: 160,
render: (role: string) => {
const option = roleOptions.find((r) => r.value === role);
return <Tag>{option?.label || role}</Tag>;
@@ -691,7 +804,7 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
<Tooltip title="编辑">
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
</Tooltip>
<Popconfirm title="确认删除此通知接收人" onConfirm={() => handleDelete(record.id)}>
<Popconfirm title="确认删除此项目成员" onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
@@ -703,7 +816,7 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
@@ -717,31 +830,54 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
/>
<Modal
title={editingMapping ? '编辑通知接收人' : '添加通知接收人'}
title={editingMapping ? '编辑项目成员' : '添加项目成员'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
footer={null}
width={520}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit} style={{ marginTop: 24 }}>
<Form.Item name="userId" hidden>
<Input />
</Form.Item>
<Form.Item name="systemUserId" hidden>
<Input />
</Form.Item>
<Form.Item
label="关联平台用户"
extra={selectedUser ? `已选择: ${selectedUser.name} (${selectedUser.phone})` : '输入姓名或手机号搜索至少2个字符'}
>
<Select
showSearch
filterOption={false}
onSearch={handleUserSearch}
onSelect={handleUserSelect}
onClear={handleUserClear}
loading={userSearching}
placeholder="搜索用户姓名或手机号"
allowClear
notFoundContent={userSearching ? <Spin size="small" /> : (userSearchOptions.length === 0 ? null : undefined)}
value={selectedUser ? selectedUser.id : undefined}
options={userSearchOptions.map(u => ({
key: u.id,
value: u.id,
label: `${u.name} (${u.phone})`,
}))}
/>
</Form.Item>
<Form.Item name="role" label="项目角色" rules={[{ required: true, message: '请选择角色' }]}>
<Select options={roleOptions} placeholder="选择角色" />
</Form.Item>
<Form.Item
name="wecomUserId"
label="企业微信用户 ID"
rules={[{ required: true, message: '请输入企业微信用户 ID' }]}
extra="用于接收质控通知FengZhiBo"
>
<Input placeholder="企业微信通讯录中的用户 ID" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select options={roleOptions} placeholder="选择角色(可选)" allowClear />
</Form.Item>
<Form.Item
name="systemUserId"
label="系统用户 ID"
extra="可选,用于系统内部标识"
>
<Input placeholder="例如user_001可选" />
<Input placeholder="企业微信通讯录中的用户 ID(可选)" />
</Form.Item>
<Form.Item
@@ -774,146 +910,331 @@ interface KnowledgeBaseTabProps {
}
const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }) => {
const [kbList, setKbList] = useState<KnowledgeBaseOption[]>([]);
const [loading, setLoading] = useState(true);
const [linking, setLinking] = useState(false);
const navigate = useNavigate();
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const kbId = project.knowledgeBaseId || project.knowledgeBase?.id;
const loadDocuments = useCallback(async () => {
if (!kbId) return;
setLoading(true);
try {
const { listDocuments } = await import('../api/systemKbApi');
const docs = await listDocuments(kbId);
setDocuments(docs);
} catch {
message.error('加载文档列表失败');
} finally {
setLoading(false);
}
}, [kbId]);
useEffect(() => {
const loadKbList = async () => {
setLoading(true);
try {
const data = await iitProjectApi.listKnowledgeBases();
setKbList(data);
} catch (error) {
message.error('加载知识库列表失败');
} finally {
setLoading(false);
}
};
loadKbList();
}, []);
if (kbId) loadDocuments();
}, [kbId, loadDocuments]);
const handleLink = async (kbId: string) => {
setLinking(true);
const handleCreateKb = async () => {
setCreating(true);
try {
await iitProjectApi.linkKnowledgeBase(project.id, kbId);
message.success('关联成功');
const { createKnowledgeBase } = await import('../api/systemKbApi');
const code = `IIT_${project.id.substring(0, 8).toUpperCase()}`;
const kb = await createKnowledgeBase({
code,
name: `${project.name} 项目知识库`,
description: `IIT 项目 "${project.name}" 的专属知识库`,
category: 'iit_project',
});
await iitProjectApi.linkKnowledgeBase(project.id, kb.id);
message.success('项目知识库创建成功');
onUpdate();
} catch (error) {
message.error('关联失败');
} catch (err: any) {
message.error(err?.response?.data?.error || '创建知识库失败');
} finally {
setLinking(false);
setCreating(false);
}
};
const handleUnlink = async () => {
setLinking(true);
const handleUpload = async (file: File) => {
if (!kbId) return;
setUploading(true);
setUploadPercent(0);
try {
await iitProjectApi.unlinkKnowledgeBase(project.id);
message.success('解除关联成功');
const { uploadDocument } = await import('../api/systemKbApi');
await uploadDocument(kbId, file, (p) => setUploadPercent(p));
message.success(`${file.name} 上传成功`);
loadDocuments();
onUpdate();
} catch (error) {
message.error('操作失败');
} catch {
message.error('上传失败');
} finally {
setLinking(false);
setUploading(false);
}
};
if (loading) {
return <Spin />;
const handleDeleteDoc = async (docId: string) => {
if (!kbId) return;
try {
const { deleteDocument } = await import('../api/systemKbApi');
await deleteDocument(kbId, docId);
message.success('删除成功');
loadDocuments();
onUpdate();
} catch {
message.error('删除失败');
}
};
const handleDownload = async (docId: string) => {
if (!kbId) return;
try {
const { getDocumentDownloadUrl } = await import('../api/systemKbApi');
const result = await getDocumentDownloadUrl(kbId, docId);
window.open(result.url, '_blank');
} catch {
message.error('获取下载链接失败');
}
};
if (!kbId) {
return (
<div>
<Alert
message="尚未创建项目知识库"
description="创建专属知识库后您可以上传研究方案、CRF、入排标准等文档AI 将基于这些内容回答问题。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={handleCreateKb} loading={creating}>
</Button>
</div>
</div>
);
}
const statusMap: Record<string, { color: string; text: string }> = {
ready: { color: 'success', text: '就绪' },
processing: { color: 'processing', text: '处理中' },
pending: { color: 'default', text: '待处理' },
failed: { color: 'error', text: '失败' },
};
const docColumns = [
{
title: '文件名',
dataIndex: 'filename',
key: 'filename',
ellipsis: true,
},
{
title: '类型',
dataIndex: 'fileType',
key: 'fileType',
width: 80,
render: (v: string) => <Tag>{v || '-'}</Tag>,
},
{
title: '大小',
dataIndex: 'fileSize',
key: 'fileSize',
width: 100,
render: (v: number | null) => v ? `${(v / 1024).toFixed(1)} KB` : '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (s: string) => {
const info = statusMap[s] || { color: 'default', text: s };
return <Badge status={info.color as any} text={info.text} />;
},
},
{
title: '上传时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, record: any) => (
<Space>
<Tooltip title="下载">
<Button type="text" size="small" onClick={() => handleDownload(record.id)}></Button>
</Tooltip>
<Popconfirm title="确认删除此文档?" onConfirm={() => handleDeleteDoc(record.id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div>
{project.knowledgeBase ? (
<div>
<Alert
message="已关联知识库"
description={
<div style={{ marginTop: 8 }}>
<Space direction="vertical">
<Text>
<BookOutlined style={{ marginRight: 8 }} />
{project.knowledgeBase.name}
</Text>
<Text type="secondary">
: {project.knowledgeBase.documentCount}
</Text>
</Space>
</div>
}
type="success"
showIcon
action={
<Space direction="vertical">
<Button
size="small"
onClick={() => navigate(`/admin/system-kb/${project.knowledgeBase?.id}`)}
>
</Button>
<Popconfirm title="确认解除关联?" onConfirm={handleUnlink}>
<Button size="small" danger icon={<DisconnectOutlined />}>
</Button>
</Popconfirm>
</Space>
}
/>
<Alert
message={
<Space>
<BookOutlined />
<span>: {project.knowledgeBase?.name || '已关联'}</span>
<Text type="secondary">({documents.length} )</Text>
</Space>
}
type="success"
showIcon={false}
style={{ marginBottom: 16 }}
/>
<div style={{ marginTop: 24 }}>
<Title level={5}></Title>
<Text type="secondary">
CRFAI
</Text>
<div style={{ marginTop: 16 }}>
<Space wrap>
<Tag color="blue"></Tag>
<Tag color="green">CRF</Tag>
<Tag color="orange"></Tag>
<Tag color="purple"></Tag>
<Tag color="cyan"></Tag>
<Tag color="magenta"></Tag>
<Tag></Tag>
</Space>
</div>
</div>
</div>
) : (
<div>
<Alert
message="未关联知识库"
description="请选择或创建一个知识库,用于存储研究方案、入排标准等文档。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary">CRFAI </Text>
<input
type="file"
id="kb-file-upload"
style={{ display: 'none' }}
accept=".pdf,.docx,.doc,.txt,.md,.xlsx,.csv"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUpload(f);
e.target.value = '';
}}
/>
<Button
type="primary"
icon={<PlusOutlined />}
loading={uploading}
onClick={() => document.getElementById('kb-file-upload')?.click()}
>
{uploading ? `上传中 ${uploadPercent}%` : '上传文档'}
</Button>
</div>
<Title level={5} style={{ marginTop: 24 }}>
</Title>
<Table
columns={docColumns}
dataSource={documents}
rowKey="id"
loading={loading}
pagination={false}
size="small"
locale={{ emptyText: '暂无文档,请点击上方按钮上传' }}
/>
</div>
);
};
{kbList.length === 0 ? (
<Empty description="暂无可用知识库">
<Button type="primary" onClick={() => navigate('/admin/system-kb')}>
</Button>
</Empty>
) : (
<Select
style={{ width: 400 }}
placeholder="选择要关联的知识库"
loading={linking}
onChange={handleLink}
options={kbList.map((kb) => ({
value: kb.id,
label: kb.name,
}))}
/>
)}
</div>
)}
// ==================== Tab 5: 变量清单 ====================
interface FieldMetadataTabProps {
projectId: string;
}
const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
const [fields, setFields] = useState<any[]>([]);
const [forms, setForms] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [formFilter, setFormFilter] = useState<string | undefined>(undefined);
const [search, setSearch] = useState('');
const fetchFields = useCallback(async () => {
setLoading(true);
try {
const resp = await iitProjectApi.getFieldMetadata(projectId, { formName: formFilter, search: search || undefined });
setFields(resp.fields);
setTotal(resp.total);
if (forms.length === 0 && resp.forms) {
setForms(resp.forms);
}
} catch {
message.error('加载变量清单失败');
} finally {
setLoading(false);
}
}, [projectId, formFilter, search]);
useEffect(() => {
fetchFields();
}, [fetchFields]);
const columns = [
{
title: '变量名',
dataIndex: 'fieldName',
key: 'fieldName',
width: 180,
render: (v: string) => <Text code>{v}</Text>,
},
{
title: '标签',
dataIndex: 'fieldLabel',
key: 'fieldLabel',
ellipsis: true,
},
{
title: '表单',
dataIndex: 'formName',
key: 'formName',
width: 160,
render: (v: string) => <Tag>{v}</Tag>,
},
{
title: '类型',
dataIndex: 'fieldType',
key: 'fieldType',
width: 120,
},
{
title: '选项',
dataIndex: 'choices',
key: 'choices',
ellipsis: true,
width: 200,
},
{
title: '验证',
dataIndex: 'validation',
key: 'validation',
width: 120,
render: (v: string) => v ? <Tag color="blue">{v}</Tag> : '-',
},
];
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按表单筛选"
style={{ width: 200 }}
value={formFilter}
onChange={setFormFilter}
options={forms.map(f => ({ value: f, label: f }))}
/>
<Input.Search
placeholder="搜索变量名或标签"
allowClear
style={{ width: 250 }}
onSearch={setSearch}
/>
<Text type="secondary"> {total} </Text>
</Space>
<Table
columns={columns}
dataSource={fields}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 50, showSizeChanger: true, showTotal: t => `${t}` }}
scroll={{ y: 500 }}
/>
</div>
);
};

View File

@@ -12,6 +12,7 @@ import {
Modal,
Form,
Input,
Select,
message,
Popconfirm,
Typography,
@@ -19,10 +20,10 @@ import {
Spin,
Row,
Col,
Tag,
Badge,
Space,
Alert,
Tag,
} from 'antd';
import {
PlusOutlined,
@@ -35,9 +36,11 @@ import {
DatabaseOutlined,
TeamOutlined,
BookOutlined,
BankOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { IitProject, CreateProjectRequest, TestConnectionResult } from '../types/iitProject';
import type { IitProject, CreateProjectRequest, TestConnectionResult, TenantOption } from '../types/iitProject';
import { useAuth } from '@/framework/auth/AuthContext';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
@@ -51,18 +54,30 @@ const STATUS_MAP: Record<string, { color: string; text: string }> = {
const IitProjectListPage: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [projects, setProjects] = useState<IitProject[]>([]);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [connectionResult, setConnectionResult] = useState<TestConnectionResult | null>(null);
const [tenantOptions, setTenantOptions] = useState<TenantOption[]>([]);
const [filterTenantId, setFilterTenantId] = useState<string | undefined>(undefined);
const [form] = Form.useForm();
const isSuperOrOperator = user?.role === 'SUPER_ADMIN' || user?.role === 'IIT_OPERATOR';
// 加载租户选项
useEffect(() => {
if (isSuperOrOperator) {
iitProjectApi.getTenantOptions().then(setTenantOptions).catch(() => {});
}
}, [isSuperOrOperator]);
// 加载项目列表
const loadProjects = async () => {
const loadProjects = async (tenantId?: string) => {
setLoading(true);
try {
const data = await iitProjectApi.listProjects();
const data = await iitProjectApi.listProjects({ tenantId });
setProjects(data);
} catch (error) {
message.error('加载项目列表失败');
@@ -72,8 +87,8 @@ const IitProjectListPage: React.FC = () => {
};
useEffect(() => {
loadProjects();
}, []);
loadProjects(filterTenantId);
}, [filterTenantId]);
// 测试连接
const handleTestConnection = async () => {
@@ -108,7 +123,7 @@ const IitProjectListPage: React.FC = () => {
setCreateModalOpen(false);
form.resetFields();
setConnectionResult(null);
loadProjects();
loadProjects(filterTenantId);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '创建项目失败';
message.error(errorMessage);
@@ -120,7 +135,7 @@ const IitProjectListPage: React.FC = () => {
try {
await iitProjectApi.deleteProject(id);
message.success('删除项目成功');
loadProjects();
loadProjects(filterTenantId);
} catch (error) {
message.error('删除项目失败');
}
@@ -186,6 +201,15 @@ const IitProjectListPage: React.FC = () => {
<TeamOutlined style={{ color: '#722ed1' }} />
<Text type="secondary">: {project.userMappingCount || 0}</Text>
</div>
{project.tenantName && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BankOutlined style={{ color: '#13c2c2' }} />
<Text type="secondary">{project.tenantName}</Text>
</div>
)}
{project.isDemo && (
<Tag color="warning" style={{ marginTop: 4 }}></Tag>
)}
{project.knowledgeBaseId && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BookOutlined style={{ color: '#faad14' }} />
@@ -213,7 +237,17 @@ const IitProjectListPage: React.FC = () => {
<Text type="secondary"> REDCap </Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}>
{isSuperOrOperator && tenantOptions.length > 0 && (
<Select
allowClear
placeholder="按租户筛选"
style={{ width: 180 }}
value={filterTenantId}
onChange={(v) => setFilterTenantId(v)}
options={tenantOptions.map(t => ({ value: t.id, label: t.name }))}
/>
)}
<Button icon={<ReloadOutlined />} onClick={() => loadProjects(filterTenantId)} loading={loading}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
@@ -270,6 +304,22 @@ const IitProjectListPage: React.FC = () => {
<TextArea rows={2} placeholder="项目简要描述" />
</Form.Item>
{isSuperOrOperator && tenantOptions.length > 0 && (
<Form.Item
name="tenantId"
label="所属租户"
rules={[{ required: true, message: '请选择项目所属租户' }]}
extra="选择此项目归属的客户(药企/医院)"
>
<Select
placeholder="请选择租户"
options={tenantOptions.map(t => ({ value: t.id, label: `${t.name} (${t.code})` }))}
showSearch
optionFilterProp="label"
/>
</Form.Item>
)}
<Form.Item
name="redcapUrl"
label="REDCap URL"

View File

@@ -12,6 +12,10 @@ export interface IitProject {
redcapUrl: string;
redcapApiToken?: string;
knowledgeBaseId?: string;
tenantId?: string;
tenantName?: string;
tenantCode?: string;
isDemo?: boolean;
status: string;
lastSyncAt?: string;
createdAt: string;
@@ -33,6 +37,14 @@ export interface CreateProjectRequest {
redcapProjectId: string;
redcapApiToken: string;
knowledgeBaseId?: string;
tenantId?: string;
}
export interface TenantOption {
id: string;
code: string;
name: string;
type: string;
}
export interface UpdateProjectRequest {
@@ -43,6 +55,7 @@ export interface UpdateProjectRequest {
redcapApiToken?: string;
knowledgeBaseId?: string;
status?: string;
isDemo?: boolean;
}
export interface TestConnectionRequest {
@@ -94,22 +107,31 @@ export interface IitUserMapping {
systemUserId: string;
redcapUsername: string;
wecomUserId?: string;
userId?: string;
role: string;
createdAt: string;
updatedAt: string;
user?: {
id: string;
name: string;
phone: string;
email?: string;
};
}
export interface CreateUserMappingRequest {
systemUserId: string;
redcapUsername: string;
systemUserId?: string;
redcapUsername?: string;
wecomUserId?: string;
role: string;
userId?: string;
role?: string;
}
export interface UpdateUserMappingRequest {
systemUserId?: string;
redcapUsername?: string;
wecomUserId?: string;
userId?: string;
role?: string;
}

View File

@@ -9,6 +9,7 @@ export type UserRole =
| 'HOSPITAL_ADMIN'
| 'PHARMA_ADMIN'
| 'DEPARTMENT_ADMIN'
| 'IIT_OPERATOR'
| 'USER';
// 租户类型
@@ -173,6 +174,7 @@ export const ROLE_DISPLAY_NAMES: Record<UserRole, string> = {
HOSPITAL_ADMIN: '医院管理员',
PHARMA_ADMIN: '药企管理员',
DEPARTMENT_ADMIN: '科室主任',
IIT_OPERATOR: 'IIT项目运营',
USER: '普通用户',
};
@@ -183,6 +185,7 @@ export const ROLE_COLORS: Record<UserRole, string> = {
HOSPITAL_ADMIN: 'blue',
PHARMA_ADMIN: 'green',
DEPARTMENT_ADMIN: 'orange',
IIT_OPERATOR: 'cyan',
USER: 'default',
};

View File

@@ -959,9 +959,7 @@
border: none;
border-top: 1px solid #E5E7EB;
margin: 12px 0;
}
.message-bubble .markdown-content h1,
}.message-bubble .markdown-content h1,
.message-bubble .markdown-content h2,
.message-bubble .markdown-content h3 {
margin: 16px 0 8px 0;
@@ -1008,4 +1006,4 @@
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
}

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

View File

@@ -51,7 +51,7 @@ const LegacySystemPage: React.FC<LegacySystemPageProps> = ({ targetUrl }) => {
authDoneRef.current = true
setStatus('ready')
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '旧系统认证失败'
const msg = err?.response?.data?.message || err?.message || '服务连接失败,请稍后重试'
setErrorMsg(msg)
setStatus('error')
message.error(msg)
@@ -66,7 +66,7 @@ const LegacySystemPage: React.FC<LegacySystemPageProps> = ({ targetUrl }) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 36 }} spin />} />
<span style={{ color: '#666' }}>...</span>
<span style={{ color: '#666' }}>...</span>
</div>
)
}
@@ -96,7 +96,7 @@ const LegacySystemPage: React.FC<LegacySystemPageProps> = ({ targetUrl }) => {
border: 'none',
display: 'block',
}}
title="旧系统"
title="模块加载"
/>
)
}