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:
@@ -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 />} />
|
||||
{/* 系统配置 */}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type UserRole =
|
||||
| 'HOSPITAL_ADMIN'
|
||||
| 'PHARMA_ADMIN'
|
||||
| 'DEPARTMENT_ADMIN'
|
||||
| 'IIT_OPERATOR'
|
||||
| 'USER';
|
||||
|
||||
/** 租户类型 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ==================== 批量操作 ====================
|
||||
|
||||
/** 一键全量质控 */
|
||||
|
||||
369
frontend-v2/src/modules/admin/pages/IitMemberManagePage.tsx
Normal file
369
frontend-v2/src/modules/admin/pages/IitMemberManagePage.tsx
Normal 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;
|
||||
@@ -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">
|
||||
上传研究方案、CRF、入排标准等文档到知识库,AI 将基于这些内容回答问题。
|
||||
</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">上传研究方案、CRF、入排标准等文档,AI 将基于这些内容辅助质控。</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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="模块加载"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user