feat(iit): V3.2 data consistency + project isolation + admin config redesign + Chinese labels

Summary:
- Refactor timeline API to read from qc_field_status (SSOT) instead of qc_logs
- Add field-issues paginated API with severity/dimension/recordId filters
- Add LEFT JOIN field_metadata + qc_event_status for Chinese display names
- Implement per-project ChatOrchestrator cache and SessionMemory isolation
- Redesign admin IIT config tabs (REDCap -> Fields -> KB -> Rules -> Members)
- Add AI-powered QC rule generation (D3 programmatic + D1/D5/D6 LLM-based)
- Add clickable warning/critical detail Modal in ReportsPage
- Auto-dispatch eQuery after batch QC via DailyQcOrchestrator
- Update module status documentation to v3.2

Backend changes:
- iitQcCockpitController: rewrite getTimeline from qc_field_status, add getFieldIssues
- iitQcCockpitRoutes: add field-issues route
- ChatOrchestrator: per-projectId cached instances
- SessionMemory: keyed by userId::projectId
- WechatCallbackController: resolve projectId from iitUserMapping
- iitRuleSuggestionService: dimension-based suggest + generateD3Rules
- iitBatchController: call DailyQcOrchestrator after batch QC

Frontend changes:
- AiStreamPage: adapt to new timeline structure with dimension tags
- ReportsPage: clickable stats cards with issue detail Modal
- IitProjectDetailPage: reorder tabs, add AI rule generation UI
- iitProjectApi: add TimelineIssue, FieldIssueItem types and APIs

Status: TypeScript compilation verified, no new lint errors
Made-with: Cursor
This commit is contained in:
2026-03-02 14:29:59 +08:00
parent 72928d3116
commit 71d32d11ee
38 changed files with 1597 additions and 546 deletions

View File

@@ -8,7 +8,6 @@ import { RouteGuard } from './framework/router'
import MainLayout from './framework/layout/MainLayout'
import AdminLayout from './framework/layout/AdminLayout'
import OrgLayout from './framework/layout/OrgLayout'
import HomePage from './pages/HomePage'
import LoginPage from './pages/LoginPage'
import AdminDashboard from './pages/admin/AdminDashboard'
import OrgDashboard from './pages/org/OrgDashboard'
@@ -80,8 +79,8 @@ function App() {
{/* 业务应用端 /app/* */}
<Route path="/" element={<MainLayout />}>
{/* 首页 */}
<Route index element={<HomePage />} />
{/* 首页重定向到 AI 问答 */}
<Route index element={<Navigate to="/ai-qa" replace />} />
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
{MODULES.filter(m => !m.isExternal).map(module => (

View File

@@ -62,9 +62,16 @@ apiClient.interceptors.response.use(
const hasRefreshToken = !!getRefreshToken();
const alreadyRetried = originalRequest._retry;
if (!is401 || !hasRefreshToken || alreadyRetried) {
if (is401 && !hasRefreshToken) {
// 检测是否被踢出(其他设备登录)
const responseMsg = (error.response?.data as any)?.message || '';
const isKicked = is401 && responseMsg.includes('其他设备');
if (!is401 || !hasRefreshToken || alreadyRetried || isKicked) {
if (is401) {
clearTokens();
if (isKicked) {
alert('您的账号已在其他设备登录,当前会话已失效,请重新登录');
}
window.location.href = '/login';
}
return Promise.reject(error);

View File

@@ -195,6 +195,24 @@ export async function testRule(
return response.data.data;
}
/** AI 规则建议(支持按维度生成) */
export async function suggestRules(
projectId: string,
dimension?: string
): Promise<import('../types/iitProject').RuleSuggestion[]> {
const params = dimension ? { dimension } : {};
const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/suggest`, {}, { params });
return response.data.data;
}
/** D3 规则自动生成(数据驱动,无需 LLM */
export async function generateD3Rules(
projectId: string
): Promise<import('../types/iitProject').RuleSuggestion[]> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/generate-d3`);
return response.data.data;
}
// ==================== 用户映射 ====================
/** 获取角色选项 */

View File

@@ -73,9 +73,6 @@ const ModulePermissionModal: React.FC<ModulePermissionModalProps> = ({
}
};
// 可用模块(租户已订阅的)
const subscribedModules = moduleOptions.filter((m) => m.isSubscribed);
return (
<Modal
title={`配置模块权限 - ${membership.tenantName}`}
@@ -88,28 +85,35 @@ const ModulePermissionModal: React.FC<ModulePermissionModalProps> = ({
<Spin spinning={loading}>
<Alert
message="模块权限说明"
description="选择用户在该租户内可以访问的模块。取消所有选择将默认继承租户的全部模块权限。"
description="选择用户在该租户内可以访问的模块。取消所有选择将默认继承租户的全部模块权限。灰色模块表示租户尚未订阅。"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
{subscribedModules.length > 0 ? (
{moduleOptions.length > 0 ? (
<Checkbox.Group
value={selectedModules}
onChange={(values) => setSelectedModules(values as string[])}
style={{ width: '100%' }}
>
<Row gutter={[16, 16]}>
{subscribedModules.map((module) => (
{moduleOptions.map((module) => (
<Col span={12} key={module.code}>
<Checkbox value={module.code}>{module.name}</Checkbox>
<Checkbox value={module.code}>
{module.name}
{!module.isSubscribed && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
()
</Text>
)}
</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
) : (
<Text type="secondary"></Text>
<Text type="secondary"></Text>
)}
</Spin>
</Modal>

View File

@@ -27,6 +27,7 @@ import {
Empty,
Tooltip,
Badge,
Collapse,
} from 'antd';
import {
ArrowLeftOutlined,
@@ -42,6 +43,8 @@ import {
BarChartOutlined,
DashboardOutlined,
ClockCircleOutlined,
BulbOutlined,
RobotOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type {
@@ -49,6 +52,7 @@ import type {
UpdateProjectRequest,
QCRule,
CreateRuleRequest,
RuleSuggestion,
IitUserMapping,
CreateUserMappingRequest,
RoleOption,
@@ -65,13 +69,27 @@ const SEVERITY_MAP = {
info: { color: 'processing', text: '信息' },
};
const CATEGORY_MAP = {
const CATEGORY_MAP: Record<string, { color: string; text: string }> = {
D1: { color: '#52c41a', text: 'D1 入选/排除' },
D2: { color: '#13c2c2', text: 'D2 完整性' },
D3: { color: '#1890ff', text: 'D3 准确性' },
D4: { color: '#faad14', text: 'D4 质疑管理' },
D5: { color: '#ff4d4f', text: 'D5 安全性' },
D6: { color: '#722ed1', text: 'D6 方案偏离' },
D7: { color: '#eb2f96', text: 'D7 药物管理' },
inclusion: { color: '#52c41a', text: '纳入标准' },
exclusion: { color: '#ff4d4f', text: '排除标准' },
lab_values: { color: '#1890ff', text: '变量范围' },
logic_check: { color: '#722ed1', text: '逻辑检查' },
};
const AI_BUILD_DIMENSIONS = [
{ key: 'D3', label: 'D3 准确性规则', description: '基于变量定义自动生成范围/枚举检查', icon: <BarChartOutlined />, needsKb: false, isD3: true },
{ key: 'D1', label: 'D1 入选/排除规则', description: 'AI 基于知识库生成纳入排除标准检查', icon: <CheckCircleOutlined />, needsKb: true, isD3: false },
{ key: 'D5', label: 'D5 安全性规则', description: 'AI 生成 AE/SAE 报告时限和完整性检查', icon: <ExclamationCircleOutlined />, needsKb: true, isD3: false },
{ key: 'D6', label: 'D6 方案偏离规则', description: 'AI 生成访视窗口期和用药合规性检查', icon: <BookOutlined />, needsKb: true, isD3: false },
];
// ==================== 主组件 ====================
const IitProjectDetailPage: React.FC = () => {
@@ -143,31 +161,34 @@ const IitProjectDetailPage: React.FC = () => {
return <Empty description="项目不存在" />;
}
const hasFields = !!project.lastSyncAt;
const hasKb = !!(project.knowledgeBaseId || project.knowledgeBase?.id);
const tabItems = [
{
key: 'basic',
label: 'REDCap 配置',
label: 'REDCap 配置',
children: <BasicConfigTab project={project} onUpdate={loadProject} />,
},
{
key: 'rules',
label: '质控规则',
children: <QCRulesTab projectId={project.id} />,
},
{
key: 'members',
label: '项目成员',
children: <UserMappingTab projectId={project.id} />,
key: 'fields',
label: '② 变量清单',
children: <FieldMetadataTab projectId={project.id} project={project} onUpdate={loadProject} />,
},
{
key: 'kb',
label: '知识库',
children: <KnowledgeBaseTab project={project} onUpdate={loadProject} />,
label: '知识库',
children: <KnowledgeBaseTab project={project} onUpdate={loadProject} hasFields={hasFields} />,
},
{
key: 'fields',
label: '变量清单',
children: <FieldMetadataTab projectId={project.id} />,
key: 'rules',
label: `④ 质控规则`,
children: <QCRulesTab projectId={project.id} project={project} hasFields={hasFields} hasKb={hasKb} />,
},
{
key: 'members',
label: '⑤ 项目成员',
children: <UserMappingTab projectId={project.id} />,
},
];
@@ -238,7 +259,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
form.setFieldsValue({
@@ -281,18 +301,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
}
};
const handleSyncMetadata = async () => {
setSyncing(true);
try {
const result = await iitProjectApi.syncMetadata(project.id);
message.success(`同步成功!共 ${result.fieldCount} 个字段`);
} catch (error) {
message.error('同步失败');
} finally {
setSyncing(false);
}
};
return (
<div>
<Form form={form} layout="vertical" onFinish={handleSave} style={{ maxWidth: 600 }}>
@@ -394,9 +402,6 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
<Button icon={<CheckCircleOutlined />} onClick={handleTestConnection} loading={testing}>
</Button>
<Button icon={<SyncOutlined />} onClick={handleSyncMetadata} loading={syncing}>
</Button>
</Space>
</Form.Item>
</Form>
@@ -417,19 +422,28 @@ const BasicConfigTab: React.FC<BasicConfigTabProps> = ({ project, onUpdate }) =>
);
};
// ==================== Tab 2: 质控规则 ====================
// ==================== Tab 4: 质控规则 ====================
interface QCRulesTabProps {
projectId: string;
project: IitProject;
hasFields: boolean;
hasKb: boolean;
}
const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId, project: _project, hasFields, hasKb }) => {
const [rules, setRules] = useState<QCRule[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<QCRule | null>(null);
const [form] = Form.useForm();
const [aiLoading, setAiLoading] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<RuleSuggestion[]>([]);
const [selectedSuggestionKeys, setSelectedSuggestionKeys] = useState<React.Key[]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [importing, setImporting] = useState(false);
const loadRules = useCallback(async () => {
setLoading(true);
try {
@@ -476,14 +490,14 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
try {
const fieldValue = values.field as string;
const logicValue = values.logic as string;
const ruleData: CreateRuleRequest = {
name: values.name as string,
field: fieldValue.includes(',') ? fieldValue.split(',').map((s) => s.trim()) : fieldValue,
logic: JSON.parse(logicValue),
message: values.message as string,
severity: values.severity as 'error' | 'warning' | 'info',
category: values.category as 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check',
category: values.category as CreateRuleRequest['category'],
};
if (editingRule) {
@@ -501,22 +515,73 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
}
};
const columns = [
const handleAiBuild = async (dim: typeof AI_BUILD_DIMENSIONS[number]) => {
if (!hasFields) {
message.warning('请先在「② 变量清单」中同步元数据');
return;
}
if (dim.needsKb && !hasKb) {
message.warning('此维度需要知识库支持,请先在「③ 知识库」中创建并上传文档');
return;
}
setAiLoading(dim.key);
try {
let result: RuleSuggestion[];
if (dim.isD3) {
result = await iitProjectApi.generateD3Rules(projectId);
} else {
result = await iitProjectApi.suggestRules(projectId, dim.key);
}
if (result.length === 0) {
message.info('未生成规则,请检查变量清单或知识库内容');
return;
}
setSuggestions(result);
setSelectedSuggestionKeys(result.map((_, i) => i));
setPreviewOpen(true);
} catch (err: any) {
message.error(err?.response?.data?.error || err?.message || 'AI 生成失败');
} finally {
setAiLoading(null);
}
};
const handleImportSelected = async () => {
const selected = selectedSuggestionKeys.map((k) => suggestions[k as number]).filter(Boolean);
if (selected.length === 0) {
message.warning('请至少选择一条规则');
return;
}
setImporting(true);
try {
const rulesToImport: CreateRuleRequest[] = selected.map((s) => ({
name: s.name,
field: s.field,
logic: s.logic,
message: s.message,
severity: s.severity,
category: s.category as CreateRuleRequest['category'],
}));
const result = await iitProjectApi.importRules(projectId, rulesToImport);
message.success(`成功导入 ${result.count} 条规则`);
setPreviewOpen(false);
setSuggestions([]);
setSelectedSuggestionKeys([]);
loadRules();
} catch (err: any) {
message.error(err?.response?.data?.error || '导入失败');
} finally {
setImporting(false);
}
};
const ruleColumns = [
{
title: '规则名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 100,
render: (category: keyof typeof CATEGORY_MAP) => {
const cat = CATEGORY_MAP[category];
return <Tag color={cat?.color}>{cat?.text || category}</Tag>;
},
width: 220,
},
{
title: '严重程度',
@@ -560,29 +625,149 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
},
];
const DIMENSION_ORDER = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'];
const groupedRules = React.useMemo(() => {
const groups: Record<string, QCRule[]> = {};
for (const rule of rules) {
const cat = rule.category || 'other';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(rule);
}
const sorted: Record<string, QCRule[]> = {};
for (const dim of DIMENSION_ORDER) {
if (groups[dim]) { sorted[dim] = groups[dim]; delete groups[dim]; }
}
for (const [key, val] of Object.entries(groups)) {
sorted[key] = val;
}
return sorted;
}, [rules]);
const previewColumns = [
{ title: '规则名称', dataIndex: 'name', key: 'name', width: 200 },
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
render: (c: string) => {
const cat = CATEGORY_MAP[c];
return <Tag color={cat?.color}>{cat?.text || c}</Tag>;
},
},
{
title: '严重程度',
dataIndex: 'severity',
key: 'severity',
width: 100,
render: (s: string) => {
const sev = SEVERITY_MAP[s as keyof typeof SEVERITY_MAP];
return <Tag color={sev?.color}>{sev?.text || s}</Tag>;
},
},
{
title: '字段',
dataIndex: 'field',
key: 'field',
width: 150,
render: (f: string | string[]) => <Text code>{Array.isArray(f) ? f.join(', ') : f}</Text>,
},
{ title: '提示信息', dataIndex: 'message', key: 'message', ellipsis: true },
];
if (!hasFields) {
return (
<Alert
message="请先完成变量清单同步"
description="质控规则依赖变量清单数据。请先在「② 变量清单」Tab 中从 REDCap 同步元数据。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Alert
message="质控规则使用 JSON Logic 格式定义,用于数据质量检查"
type="info"
showIcon
style={{ flex: 1, marginRight: 16 }}
/>
{/* AI 自动构建规则区域 */}
<Card
size="small"
title={<Space><RobotOutlined /> AI </Space>}
style={{ marginBottom: 16, border: '1px solid #d9d9d9', background: '#fafafa' }}
>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{AI_BUILD_DIMENSIONS.map((dim) => {
const disabled = dim.needsKb && !hasKb;
return (
<Tooltip
key={dim.key}
title={disabled ? '需要先创建知识库并上传文档' : dim.description}
>
<Button
icon={dim.icon}
loading={aiLoading === dim.key}
disabled={disabled || !!aiLoading}
onClick={() => handleAiBuild(dim)}
style={{ height: 'auto', padding: '8px 16px' }}
>
<div style={{ textAlign: 'left' }}>
<div style={{ fontWeight: 500 }}>{dim.label}</div>
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
{dim.isD3 ? '数据驱动(无需 LLM' : 'AI 生成(需要知识库)'}
</div>
</div>
</Button>
</Tooltip>
);
})}
</div>
{!hasKb && (
<Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
D3 D1/D5/D6
</Text>
)}
</Card>
{/* 规则列表(按维度分组) */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong> ({rules.length})</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
<Table
columns={columns}
dataSource={rules}
rowKey="id"
loading={loading}
pagination={false}
size="small"
/>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : rules.length === 0 ? (
<Empty description="暂无规则,请使用上方 AI 构建或手动添加" />
) : (
<Collapse
defaultActiveKey={Object.keys(groupedRules)}
items={Object.entries(groupedRules).map(([cat, catRules]) => {
const catMeta = CATEGORY_MAP[cat];
return {
key: cat,
label: (
<Space>
<Tag color={catMeta?.color}>{catMeta?.text || cat}</Tag>
<Text type="secondary">{catRules.length} </Text>
</Space>
),
children: (
<Table
columns={ruleColumns}
dataSource={catRules}
rowKey="id"
pagination={false}
size="small"
/>
),
};
})}
/>
)}
{/* 手动编辑规则 Modal */}
<Modal
title={editingRule ? '编辑规则' : '添加规则'}
open={modalOpen}
@@ -597,10 +782,15 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select
options={Object.entries(CATEGORY_MAP).map(([value, { text }]) => ({
value,
label: text,
}))}
options={[
{ value: 'D1', label: 'D1 入选/排除' },
{ value: 'D2', label: 'D2 完整性' },
{ value: 'D3', label: 'D3 准确性' },
{ value: 'D4', label: 'D4 质疑管理' },
{ value: 'D5', label: 'D5 安全性' },
{ value: 'D6', label: 'D6 方案偏离' },
{ value: 'D7', label: 'D7 药物管理' },
]}
/>
</Form.Item>
@@ -660,6 +850,55 @@ const QCRulesTab: React.FC<QCRulesTabProps> = ({ projectId }) => {
</Form.Item>
</Form>
</Modal>
{/* AI 规则预览 + 批量导入 Modal */}
<Modal
title={
<Space>
<BulbOutlined style={{ color: '#faad14' }} />
AI
</Space>
}
open={previewOpen}
onCancel={() => { setPreviewOpen(false); setSuggestions([]); setSelectedSuggestionKeys([]); }}
width={900}
footer={
<Space>
<Text type="secondary"> {selectedSuggestionKeys.length} / {suggestions.length} </Text>
<Button onClick={() => { setPreviewOpen(false); setSuggestions([]); setSelectedSuggestionKeys([]); }}>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
loading={importing}
disabled={selectedSuggestionKeys.length === 0}
onClick={handleImportSelected}
>
</Button>
</Space>
}
>
<Alert
message="以下规则由 AI 自动生成,请检查后勾选需要导入的规则。导入后可随时编辑或删除。"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Table
columns={previewColumns}
dataSource={suggestions.map((s, i) => ({ ...s, _key: i }))}
rowKey="_key"
size="small"
pagination={false}
scroll={{ y: 400 }}
rowSelection={{
selectedRowKeys: selectedSuggestionKeys,
onChange: (keys) => setSelectedSuggestionKeys(keys),
}}
/>
</Modal>
</div>
);
};
@@ -927,9 +1166,10 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
interface KnowledgeBaseTabProps {
project: IitProject;
onUpdate: () => void;
hasFields: boolean;
}
const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }) => {
const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate, hasFields }) => {
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
@@ -1018,6 +1258,18 @@ const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }
}
};
if (!hasFields) {
return (
<Alert
message="请先完成变量清单同步"
description="建议在「② 变量清单」Tab 中先从 REDCap 同步变量元数据,然后再创建知识库。知识库中的研究方案等文档将与变量数据配合,帮助 AI 生成更精准的质控规则。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
if (!kbId) {
return (
<div>
@@ -1150,20 +1402,25 @@ const KnowledgeBaseTab: React.FC<KnowledgeBaseTabProps> = ({ project, onUpdate }
);
};
// ==================== Tab 5: 变量清单 ====================
// ==================== Tab 2: 变量清单 ====================
interface FieldMetadataTabProps {
projectId: string;
project: IitProject;
onUpdate: () => void;
}
const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId, project, onUpdate }) => {
const [fields, setFields] = useState<any[]>([]);
const [forms, setForms] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [formFilter, setFormFilter] = useState<string | undefined>(undefined);
const [search, setSearch] = useState('');
const hasRedcapConfig = !!(project.redcapUrl && project.redcapApiToken);
const fetchFields = useCallback(async () => {
setLoading(true);
try {
@@ -1184,6 +1441,32 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
fetchFields();
}, [fetchFields]);
const handleSyncMetadata = async () => {
setSyncing(true);
try {
const result = await iitProjectApi.syncMetadata(projectId);
message.success(`同步成功!共 ${result.fieldCount} 个字段`);
fetchFields();
onUpdate();
} catch (error) {
message.error('同步元数据失败,请检查 REDCap 配置');
} finally {
setSyncing(false);
}
};
if (!hasRedcapConfig) {
return (
<Alert
message="请先完成 REDCap 配置"
description="变量清单需要从 REDCap 同步获取。请先在「① REDCap 配置」Tab 中填写 REDCap URL 和 API Token 并保存。"
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
/>
);
}
const columns = [
{
title: '变量名',
@@ -1229,32 +1512,61 @@ const FieldMetadataTab: React.FC<FieldMetadataTabProps> = ({ projectId }) => {
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="按表单筛选"
style={{ width: 200 }}
value={formFilter}
onChange={setFormFilter}
options={forms.map(f => ({ value: f, label: f }))}
{total === 0 && !loading ? (
<Alert
message="尚未同步变量清单"
description="点击下方按钮从 REDCap 同步所有数据变量定义。这是后续配置知识库和质控规则的基础。"
type="info"
showIcon
icon={<SyncOutlined />}
style={{ marginBottom: 16 }}
action={
<Button
type="primary"
size="large"
icon={<SyncOutlined spin={syncing} />}
onClick={handleSyncMetadata}
loading={syncing}
>
REDCap
</Button>
}
/>
<Input.Search
placeholder="搜索变量名或标签"
allowClear
style={{ width: 250 }}
onSearch={setSearch}
) : (
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<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>
<Button icon={<SyncOutlined />} onClick={handleSyncMetadata} loading={syncing}>
</Button>
</div>
)}
{total > 0 && (
<Table
columns={columns}
dataSource={fields}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 50, showSizeChanger: true, showTotal: t => `${t}` }}
scroll={{ y: 500 }}
/>
<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

@@ -76,6 +76,12 @@ export interface TestConnectionResult {
// ==================== 质控规则相关 ====================
export type DimensionCode = 'D1' | 'D2' | 'D3' | 'D4' | 'D5' | 'D6' | 'D7';
export type RuleCategory =
| 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'
| DimensionCode;
export interface QCRule {
id: string;
name: string;
@@ -83,7 +89,7 @@ export interface QCRule {
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
category: RuleCategory;
metadata?: Record<string, unknown>;
}
@@ -93,10 +99,20 @@ export interface CreateRuleRequest {
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
category: RuleCategory;
metadata?: Record<string, unknown>;
}
export interface RuleSuggestion {
name: string;
field: string | string[];
logic: Record<string, unknown>;
message: string;
severity: 'error' | 'warning' | 'info';
category: string;
applicableEvents?: string[];
}
export interface RuleStats {
total: number;
byCategory: Record<string, number>;

View File

@@ -8,11 +8,12 @@
* - 5个阶段12个智能体卡片
*/
import React, { useState, useMemo } from 'react';
import { BrainCircuit, Search } from 'lucide-react';
import React, { useMemo } from 'react';
import { BrainCircuit } from 'lucide-react';
import { AgentCard } from './AgentCard';
import { AGENTS, PHASES } from '../constants';
import type { AgentConfig } from '../types';
import { useAuth } from '@/framework/auth';
import '../styles/agent-hub.css';
interface AgentHubProps {
@@ -20,40 +21,32 @@ interface AgentHubProps {
}
export const AgentHub: React.FC<AgentHubProps> = ({ onAgentSelect }) => {
const [searchValue, setSearchValue] = useState('');
const { hasModule } = useAuth();
// Protocol Agent 按用户模块权限动态显示(管理后台配置 AIA_PROTOCOL
const showProtocol = hasModule('AIA_PROTOCOL');
const visiblePhases = useMemo(() => {
return showProtocol ? PHASES : PHASES.filter(p => !p.isProtocolAgent);
}, [showProtocol]);
// 按阶段分组智能体
const agentsByPhase = useMemo(() => {
const grouped: Record<number, AgentConfig[]> = {};
AGENTS.forEach(agent => {
const visibleAgents = showProtocol ? AGENTS : AGENTS.filter(a => !a.isProtocolAgent);
visibleAgents.forEach(agent => {
if (!grouped[agent.phase]) {
grouped[agent.phase] = [];
}
grouped[agent.phase].push(agent);
});
return grouped;
}, []);
// 搜索提交
const handleSearch = () => {
if (searchValue.trim()) {
// 默认进入第一个智能体并携带搜索内容
const firstAgent = AGENTS[0];
onAgentSelect({ ...firstAgent, initialQuery: searchValue } as any);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
}, [showProtocol]);
return (
<div className="agent-hub">
{/* 主体内容 */}
<main className="hub-main">
{/* 头部搜索区 */}
{/* 头部标题区 */}
<div className="hub-header">
<div className="header-title">
<div className="title-icon">
@@ -61,29 +54,14 @@ export const AgentHub: React.FC<AgentHubProps> = ({ onAgentSelect }) => {
</div>
<h1 className="title-text">
<span className="title-badge">DeepSeek</span>
</h1>
</div>
<div className="search-box">
<input
type="text"
placeholder="输入研究想法例如SGLT2抑制剂对心衰患者预后的影响..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
className="search-input"
/>
<button className="search-btn" onClick={handleSearch}>
<Search size={20} />
</button>
</div>
</div>
{/* 流水线模块 */}
<div className="pipeline-container">
{PHASES.map((phase, phaseIndex) => {
const isLast = phaseIndex === PHASES.length - 1;
{visiblePhases.map((phase, phaseIndex) => {
const isLast = phaseIndex === visiblePhases.length - 1;
const agents = agentsByPhase[phase.phase] || [];
// Protocol Agent 阶段特殊处理phase 0单独显示1个卡片

View File

@@ -127,7 +127,7 @@ export const AGENTS: AgentConfig[] = [
phase: 4,
order: 9,
isTool: true,
toolUrl: '/dc',
toolUrl: '/research-management',
},
{
id: 'TOOL_10',
@@ -138,7 +138,7 @@ export const AGENTS: AgentConfig[] = [
phase: 4,
order: 10,
isTool: true,
toolUrl: '/dc/analysis',
toolUrl: '/research-management',
},
// Phase 5: 写作助手 (2个)

View File

@@ -1004,4 +1004,4 @@
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
}

View File

@@ -8,9 +8,7 @@
import { Layout, Menu } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
FileTextOutlined,
SearchOutlined,
FolderOpenOutlined,
FilterOutlined,
FileSearchOutlined,
DatabaseOutlined,
@@ -32,29 +30,15 @@ const ASLLayout = () => {
// 菜单项配置
const menuItems: MenuItem[] = [
{
key: 'research-plan',
icon: <FileTextOutlined />,
label: '1. 研究方案生成',
disabled: true,
title: '敬请期待'
},
{
key: '/literature/research/deep',
icon: <SearchOutlined />,
label: '2. 智能文献检索',
},
{
key: 'literature-management',
icon: <FolderOpenOutlined />,
label: '3. 文献管理',
disabled: true,
title: '敬请期待'
label: '1. 智能文献检索',
},
{
key: 'title-screening',
icon: <FilterOutlined />,
label: '4. 标题摘要初筛',
label: '2. 标题摘要初筛',
children: [
{
key: '/literature/screening/title/settings',
@@ -76,7 +60,7 @@ const ASLLayout = () => {
{
key: 'fulltext-screening',
icon: <FileSearchOutlined />,
label: '5. 全文复筛',
label: '3. 全文复筛',
children: [
{
key: '/literature/screening/fulltext/settings',
@@ -98,7 +82,7 @@ const ASLLayout = () => {
{
key: 'extraction',
icon: <DatabaseOutlined />,
label: '6. 全文智能提取',
label: '4. 全文智能提取',
children: [
{
key: '/literature/extraction/setup',
@@ -115,12 +99,12 @@ const ASLLayout = () => {
{
key: '/literature/charting',
icon: <ApartmentOutlined />,
label: '7. SR 图表生成器',
label: '5. SR 图表生成器',
},
{
key: '/literature/meta-analysis',
icon: <BarChartOutlined />,
label: '8. Meta 分析引擎',
label: '6. Meta 分析引擎',
},
];

View File

@@ -6,16 +6,13 @@
*/
import { useState, useEffect } from 'react';
import { Card, Checkbox, Button, Input, Select, Spin, Divider, Typography, Tag } from 'antd';
import { Card, Button, Input, Typography, Tag } from 'antd';
import {
ArrowLeftOutlined,
ThunderboltOutlined,
GlobalOutlined,
EditOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { aslApi } from '../../api';
import type { DataSourceConfig } from '../../types/deepResearch';
const { Text } = Typography;
@@ -43,24 +40,11 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
initialQuery, onSubmit, onBack, loading, collapsed, onExpand,
}) => {
const [query, setQuery] = useState(initialQuery);
const [dataSources, setDataSources] = useState<DataSourceConfig[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [yearRange, setYearRange] = useState<string>('近5年');
const [targetCount, setTargetCount] = useState<string>('全面检索');
const [loadingSources, setLoadingSources] = useState(true);
const [loadingTextIdx, setLoadingTextIdx] = useState(0);
useEffect(() => {
aslApi.getDeepResearchDataSources().then(res => {
const sources = res.data || [];
setDataSources(sources);
setSelectedIds(sources.filter((s: DataSourceConfig) => s.defaultChecked).map((s: DataSourceConfig) => s.id));
}).catch(() => {
setDataSources([]);
}).finally(() => {
setLoadingSources(false);
});
}, []);
// 默认使用 PubMed + 近5年 + 全面检索(数据源/年限/篇数 UI 暂时隐藏)
const yearRange = '近5年';
const targetCount = '全面检索';
useEffect(() => {
if (!loading) { setLoadingTextIdx(0); return; }
@@ -70,21 +54,10 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
return () => clearInterval(timer);
}, [loading]);
const handleToggle = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const handleSubmit = () => {
const domains = dataSources
.filter(s => selectedIds.includes(s.id))
.map(s => s.domainScope);
onSubmit(query, domains, { yearRange, targetCount });
onSubmit(query, ['PubMed'], { yearRange, targetCount });
};
const selectedNames = dataSources.filter(s => selectedIds.includes(s.id)).map(s => s.label);
if (collapsed) {
return (
<Card size="small" className="!bg-white">
@@ -94,9 +67,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
<div className="min-w-0">
<Text strong className="block truncate">{query}</Text>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{selectedNames.map(name => (
<Tag key={name} className="!text-xs !m-0">{name}</Tag>
))}
<Tag className="!text-xs !m-0">PubMed</Tag>
<Text type="secondary" className="text-xs">{yearRange} · {targetCount}</Text>
</div>
</div>
@@ -111,9 +82,6 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
);
}
const englishSources = dataSources.filter(s => s.category === 'english');
const chineseSources = dataSources.filter(s => s.category === 'chinese');
return (
<div>
<div className="flex items-center gap-3 mb-4">
@@ -133,77 +101,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
/>
</Card>
<Card className="mb-4" size="small" title={<><GlobalOutlined className="mr-2" /></>}>
{loadingSources ? (
<Spin size="small" />
) : (
<>
<Text type="secondary" className="block mb-3 text-xs"></Text>
<div className="flex flex-col gap-2 mb-4">
{englishSources.map(ds => (
<Checkbox
key={ds.id}
checked={selectedIds.includes(ds.id)}
onChange={() => handleToggle(ds.id)}
>
<span className="font-medium">{ds.label}</span>
<span className="text-gray-400 text-xs ml-2">{ds.domainScope}</span>
</Checkbox>
))}
</div>
<Divider className="!my-3" />
<Text type="secondary" className="block mb-3 text-xs"></Text>
<div className="flex flex-col gap-2">
{chineseSources.map(ds => (
<Checkbox
key={ds.id}
checked={selectedIds.includes(ds.id)}
onChange={() => handleToggle(ds.id)}
>
<span className="font-medium">{ds.label}</span>
<span className="text-gray-400 text-xs ml-2">{ds.domainScope}</span>
</Checkbox>
))}
</div>
</>
)}
</Card>
<Card className="mb-6" size="small" title="高级筛选">
<div className="flex gap-4">
<div className="flex-1">
<Text type="secondary" className="block mb-1 text-xs"></Text>
<Select
value={yearRange}
onChange={setYearRange}
className="w-full"
options={[
{ value: '不限', label: '不限' },
{ value: '近1年', label: '近1年' },
{ value: '近3年', label: '近3年' },
{ value: '近5年', label: '近5年' },
{ value: '近10年', label: '近10年' },
]}
/>
</div>
<div className="flex-1">
<Text type="secondary" className="block mb-1 text-xs"></Text>
<Select
value={targetCount}
onChange={setTargetCount}
className="w-full"
options={[
{ value: '全面检索', label: '全面检索' },
{ value: '约20篇', label: '约20篇' },
{ value: '约50篇', label: '约50篇' },
{ value: '约100篇', label: '约100篇' },
]}
/>
</div>
</div>
</Card>
{/* 数据源/年限/篇数暂时隐藏,默认 PubMed + 近5年 + 全面检索 */}
<Button
type="primary"
@@ -212,7 +110,7 @@ const SetupPanel: React.FC<SetupPanelProps> = ({
block
onClick={handleSubmit}
loading={loading}
disabled={!query.trim() || selectedIds.length === 0}
disabled={!query.trim()}
>
{loading ? LOADING_TEXTS[loadingTextIdx] : '生成检索需求书'}
</Button>

View File

@@ -47,7 +47,7 @@ const ASLModule = () => {
>
<Routes>
<Route path="" element={<ASLLayout />}>
<Route index element={<Navigate to="screening/title/settings" replace />} />
<Route index element={<Navigate to="research/deep" replace />} />
{/* 智能文献检索 V1.x保留兼容 */}
<Route path="research/search" element={<ResearchSearch />} />

View File

@@ -423,30 +423,32 @@ export async function getQcRecordDetail(
// ==================== AI 时间线 ====================
export interface TimelineIssue {
ruleId: string;
ruleName: string;
ruleCategory?: string;
field?: string;
fieldLabel?: string;
eventId?: string;
eventLabel?: string;
formName?: string;
message: string;
severity: string;
actualValue?: string;
expectedValue?: string;
}
export interface TimelineItem {
id: string;
type: 'qc_check';
time: string;
recordId: string;
eventLabel?: string;
formName?: string;
status: string;
triggeredBy: string;
description: string;
details: {
rulesEvaluated: number;
rulesPassed: number;
rulesFailed: number;
issuesSummary: { red: number; yellow: number };
issues?: Array<{
ruleId: string;
ruleName: string;
field?: string;
message: string;
severity: string;
actualValue?: string;
expectedValue?: string;
}>;
issues: TimelineIssue[];
};
}
@@ -459,6 +461,49 @@ export async function getTimeline(
return response.data.data;
}
// ==================== 字段级问题查询 ====================
export interface FieldIssueItem {
id: string;
recordId: string;
eventId: string;
eventLabel?: string;
formName: string;
fieldName: string;
fieldLabel?: string;
ruleCategory: string;
ruleName: string;
ruleId: string;
severity: string;
status: string;
message: string;
actualValue?: string;
expectedValue?: string;
lastQcAt: string;
}
export interface FieldIssuesSummary {
totalIssues: number;
bySeverity: Record<string, number>;
byDimension: Record<string, number>;
}
export interface FieldIssuesResponse {
items: FieldIssueItem[];
total: number;
page: number;
pageSize: number;
summary: FieldIssuesSummary;
}
export async function getFieldIssues(
projectId: string,
params?: { page?: number; pageSize?: number; severity?: string; dimension?: string; recordId?: string }
): Promise<FieldIssuesResponse> {
const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/field-issues`, { params });
return response.data.data;
}
// ==================== 重大事件 ====================
export interface CriticalEvent {

View File

@@ -1,8 +1,8 @@
/**
* AI 实时工作流水页 (Level 2)
*
* 以 Timeline 展示 Agent 每次质控的完整动作链AI 白盒化
* 显示中文事件名、实际规则数、五层定位详情、最终判定状态
* 以 Timeline 展示 Agent 质控结果,数据来源: qc_field_status (SSOT)
* 按受试者分组展示问题详情,支持按维度分组查看
*/
import React, { useState, useEffect, useCallback } from 'react';
@@ -28,16 +28,20 @@ import {
SyncOutlined,
ClockCircleOutlined,
RobotOutlined,
ApiOutlined,
BellOutlined,
FileSearchOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { TimelineItem } from '../api/iitProjectApi';
import type { TimelineItem, TimelineIssue } from '../api/iitProjectApi';
import { useIitProject } from '../context/IitProjectContext';
const { Text } = Typography;
const DIMENSION_LABELS: Record<string, string> = {
D1: '入选/排除', D2: '完整性', D3: '准确性', D4: '质疑管理',
D5: '安全性', D6: '方案偏离', D7: '药物管理',
};
const STATUS_DOT: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
PASS: { color: 'green', icon: <CheckCircleOutlined />, label: '通过' },
FAIL: { color: 'red', icon: <CloseCircleOutlined />, label: '严重' },
@@ -66,7 +70,7 @@ const AiStreamPage: React.FC = () => {
try {
const result = await iitProjectApi.getTimeline(projectId, {
page,
pageSize: 30,
pageSize: 20,
date: dateFilter,
});
setItems(result.items);
@@ -85,6 +89,59 @@ const AiStreamPage: React.FC = () => {
fetchData();
};
const issueColumns = [
{
title: '维度',
dataIndex: 'ruleCategory',
width: 90,
render: (v: string) => {
const label = DIMENSION_LABELS[v] || v;
return <Tag color="blue">{v ? `${v} ${label}` : '—'}</Tag>;
},
},
{
title: '规则',
dataIndex: 'ruleName',
width: 160,
render: (v: string, r: TimelineIssue) => <Text>{v || r.ruleId || '—'}</Text>,
},
{
title: '字段',
dataIndex: 'field',
width: 140,
render: (v: string, r: TimelineIssue) => {
const label = r.fieldLabel || v;
return label ? <Text>{label}</Text> : '—';
},
},
{
title: '事件',
dataIndex: 'eventId',
width: 140,
render: (v: string, r: TimelineIssue) => {
const label = r.eventLabel || v;
return label || '—';
},
},
{ title: '问题描述', dataIndex: 'message', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => (
<Tag color={s === 'critical' ? 'error' : 'warning'}>
{s === 'critical' ? '严重' : '警告'}
</Tag>
),
},
{
title: '实际值',
dataIndex: 'actualValue',
width: 90,
render: (v: string) => v ?? '—',
},
];
const timelineItems = items.map((item) => {
const dotCfg = STATUS_DOT[item.status] || { color: 'gray', icon: <ClockCircleOutlined />, label: '未知' };
const triggerCfg = TRIGGER_TAG[item.triggeredBy] || { color: 'default', label: item.triggeredBy };
@@ -93,42 +150,18 @@ const AiStreamPage: React.FC = () => {
const timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateStr = time.toLocaleDateString('zh-CN');
const eventLabel = item.eventLabel || '';
const issues = item.details.issues || [];
const issueColumns = [
{
title: '规则',
dataIndex: 'ruleName',
width: 160,
render: (v: string, r: any) => (
<Space size={4}>
<Text>{v || r.ruleId}</Text>
</Space>
),
},
{ title: '字段', dataIndex: 'field', width: 110, render: (v: string) => v ? <Text code>{v}</Text> : '—' },
{ title: '问题描述', dataIndex: 'message', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => (
<Tag color={s === 'critical' ? 'error' : 'warning'}>
{s === 'critical' ? '严重' : '警告'}
</Tag>
),
},
{
title: '实际值',
dataIndex: 'actualValue',
width: 90,
render: (v: string) => v ?? '—',
},
];
// 按维度分组
const groupedByDimension = issues.reduce<Record<string, TimelineIssue[]>>((acc, iss) => {
const key = iss.ruleCategory || '其他';
if (!acc[key]) acc[key] = [];
acc[key].push(iss);
return acc;
}, {});
return {
color: dotCfg.color as any,
color: dotCfg.color as string,
dot: dotCfg.icon,
children: (
<div style={{ paddingBottom: 8 }}>
@@ -153,29 +186,20 @@ const AiStreamPage: React.FC = () => {
}}>
<Space wrap size={4} style={{ marginBottom: 4 }}>
<RobotOutlined style={{ color: '#3b82f6' }} />
<Text> <Text code>{item.recordId}</Text></Text>
{eventLabel && <Tag color="geekblue">{eventLabel}</Tag>}
<Text> <Text code>{item.recordId}</Text></Text>
</Space>
<div style={{ marginLeft: 20 }}>
<Space size={4}>
<ApiOutlined style={{ color: '#8b5cf6' }} />
<Text> <Text strong>{item.details.rulesEvaluated}</Text> </Text>
<Text type="success"> {item.details.rulesPassed} </Text>
{item.details.rulesFailed > 0 && (
<Text type="danger">/ {item.details.rulesFailed} </Text>
)}
</Space>
</div>
{(red > 0 || yellow > 0) && (
<div style={{ marginLeft: 20, marginTop: 2 }}>
<Space size={4}>
<Space size={8}>
<BellOutlined style={{ color: red > 0 ? '#ef4444' : '#f59e0b' }} />
{red > 0 && <Badge count={red} style={{ backgroundColor: '#ef4444' }} />}
{red > 0 && <Text type="danger"></Text>}
{yellow > 0 && <Badge count={yellow} style={{ backgroundColor: '#f59e0b' }} />}
{yellow > 0 && <Text style={{ color: '#d97706' }}></Text>}
{red > 0 && <><Badge count={red} style={{ backgroundColor: '#ef4444' }} /><Text type="danger"></Text></>}
{yellow > 0 && <><Badge count={yellow} style={{ backgroundColor: '#f59e0b' }} /><Text style={{ color: '#d97706' }}></Text></>}
{Object.entries(groupedByDimension).map(([dim, dimIssues]) => (
<Tag key={dim} color="processing" style={{ fontSize: 10 }}>
{dim} {DIMENSION_LABELS[dim] || ''}: {dimIssues.length}
</Tag>
))}
</Space>
</div>
)}
@@ -200,6 +224,7 @@ const AiStreamPage: React.FC = () => {
size="small"
pagination={false}
columns={issueColumns}
scroll={{ x: 800 }}
/>
),
}]}
@@ -225,7 +250,7 @@ const AiStreamPage: React.FC = () => {
<Space>
<Tag icon={<ThunderboltOutlined />} color="processing"></Tag>
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#3b82f6' }}>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
</Badge>
</Space>
<Space>
@@ -251,9 +276,9 @@ const AiStreamPage: React.FC = () => {
<Pagination
current={page}
total={total}
pageSize={30}
pageSize={20}
onChange={setPage}
showTotal={(t) => `${t} `}
showTotal={(t) => `${t} 位受试者`}
size="small"
/>
</div>

View File

@@ -27,6 +27,8 @@ import {
Select,
Badge,
Spin,
Modal,
Pagination,
} from 'antd';
import {
FileTextOutlined,
@@ -38,9 +40,10 @@ import {
DatabaseOutlined,
QuestionCircleOutlined,
ExceptionOutlined,
EyeOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { QcReport, CriticalEvent } from '../api/iitProjectApi';
import type { QcReport, CriticalEvent, FieldIssueItem, FieldIssuesSummary } from '../api/iitProjectApi';
import { useIitProject } from '../context/IitProjectContext';
const EligibilityTable = lazy(() => import('../components/reports/EligibilityTable'));
@@ -62,6 +65,16 @@ const ReportsPage: React.FC = () => {
const [ceTotal, setCeTotal] = useState(0);
const [ceStatusFilter, setCeStatusFilter] = useState<string | undefined>(undefined);
// 问题详情 Modal
const [issueModalOpen, setIssueModalOpen] = useState(false);
const [issueModalSeverity, setIssueModalSeverity] = useState<string | undefined>(undefined);
const [issueModalDimension, setIssueModalDimension] = useState<string | undefined>(undefined);
const [issueItems, setIssueItems] = useState<FieldIssueItem[]>([]);
const [issueTotal, setIssueTotal] = useState(0);
const [issuePage, setIssuePage] = useState(1);
const [issueSummary, setIssueSummary] = useState<FieldIssuesSummary | null>(null);
const [issueLoading, setIssueLoading] = useState(false);
const fetchReport = useCallback(async () => {
if (!projectId) return;
setLoading(true);
@@ -82,6 +95,34 @@ const ReportsPage: React.FC = () => {
useEffect(() => { fetchReport(); }, [fetchReport]);
const fetchIssues = useCallback(async (severity?: string, dimension?: string, pg = 1) => {
if (!projectId) return;
setIssueLoading(true);
try {
const data = await iitProjectApi.getFieldIssues(projectId, {
page: pg,
pageSize: 20,
severity,
dimension,
});
setIssueItems(data.items);
setIssueTotal(data.total);
setIssueSummary(data.summary);
} catch {
setIssueItems([]);
} finally {
setIssueLoading(false);
}
}, [projectId]);
const openIssueModal = (severity?: string) => {
setIssueModalSeverity(severity);
setIssueModalDimension(undefined);
setIssuePage(1);
setIssueModalOpen(true);
fetchIssues(severity, undefined, 1);
};
const handleRefresh = async () => {
if (!projectId) return;
setRefreshing(true);
@@ -117,8 +158,12 @@ const ReportsPage: React.FC = () => {
<Col span={4}><Card size="small">
<Statistic title="通过率" value={summary.passRate} suffix="%" valueStyle={{ color: summary.passRate >= 80 ? '#52c41a' : summary.passRate >= 60 ? '#faad14' : '#ff4d4f' }} />
</Card></Col>
<Col span={4}><Card size="small"><Statistic title="严重问题" value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} /></Card></Col>
<Col span={4}><Card size="small"><Statistic title="警告" value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} /></Card></Col>
<Col span={4}><Card size="small" hoverable onClick={() => openIssueModal('critical')} style={{ cursor: 'pointer' }}>
<Statistic title={<Space size={4}> <EyeOutlined style={{ fontSize: 12, color: '#999' }} /></Space>} value={summary.criticalIssues} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} />
</Card></Col>
<Col span={4}><Card size="small" hoverable onClick={() => openIssueModal('warning')} style={{ cursor: 'pointer' }}>
<Statistic title={<Space size={4}> <EyeOutlined style={{ fontSize: 12, color: '#999' }} /></Space>} value={summary.warningIssues} prefix={<AlertOutlined />} valueStyle={{ color: '#faad14' }} />
</Card></Col>
<Col span={4}><Card size="small"><Statistic title="待处理 Query" value={summary.pendingQueries} /></Card></Col>
</Row>
@@ -223,6 +268,117 @@ const ReportsPage: React.FC = () => {
const lazyFallback = <Spin style={{ display: 'block', margin: '40px auto' }} />;
const DIMENSION_LABELS: Record<string, string> = {
D1: '入选/排除', D2: '完整性', D3: '准确性', D4: '质疑管理',
D5: '安全性', D6: '方案偏离', D7: '药物管理',
};
const issueDetailColumns = [
{ title: '受试者', dataIndex: 'recordId', width: 80, render: (v: string) => <Text strong>{v}</Text> },
{
title: '事件',
dataIndex: 'eventLabel',
width: 130,
render: (v: string, r: FieldIssueItem) => v || r.eventId || '—',
},
{
title: '字段',
dataIndex: 'fieldLabel',
width: 130,
render: (v: string, r: FieldIssueItem) => (v || r.fieldName) ? <Text>{v || r.fieldName}</Text> : '—',
},
{
title: '维度',
dataIndex: 'ruleCategory',
width: 100,
render: (v: string) => <Tag color="blue">{v ? `${v} ${DIMENSION_LABELS[v] || ''}` : '—'}</Tag>,
},
{ title: '规则', dataIndex: 'ruleName', width: 140, ellipsis: true },
{ title: '问题描述', dataIndex: 'message', ellipsis: true },
{
title: '严重度',
dataIndex: 'severity',
width: 80,
render: (s: string) => <Tag color={s === 'critical' ? 'error' : 'warning'}>{s === 'critical' ? '严重' : '警告'}</Tag>,
},
{ title: '实际值', dataIndex: 'actualValue', width: 90, render: (v: string) => v ?? '—' },
{ title: '检出时间', dataIndex: 'lastQcAt', width: 150, render: (d: string) => d ? new Date(d).toLocaleString('zh-CN') : '—' },
];
const issueModal = (
<Modal
title={
<Space>
{issueModalSeverity === 'critical' ? <WarningOutlined style={{ color: '#ff4d4f' }} /> : <AlertOutlined style={{ color: '#faad14' }} />}
{issueModalSeverity === 'critical' ? '严重问题详情' : issueModalSeverity === 'warning' ? '警告详情' : '所有问题详情'}
<Badge count={issueTotal} style={{ backgroundColor: issueModalSeverity === 'critical' ? '#ff4d4f' : '#faad14' }} />
</Space>
}
open={issueModalOpen}
onCancel={() => setIssueModalOpen(false)}
width={1100}
footer={null}
>
<div style={{ marginBottom: 12 }}>
<Space wrap>
<Select
placeholder="按维度筛选"
allowClear
style={{ width: 160 }}
value={issueModalDimension}
onChange={(val) => {
setIssueModalDimension(val);
setIssuePage(1);
fetchIssues(issueModalSeverity, val, 1);
}}
options={
issueSummary
? Object.entries(issueSummary.byDimension).map(([dim, cnt]) => ({
value: dim,
label: `${dim} ${DIMENSION_LABELS[dim] || ''} (${cnt})`,
}))
: []
}
/>
{issueSummary && (
<Space size={12}>
<Text type="secondary">
: <Text type="danger" strong>{issueSummary.bySeverity.critical || 0}</Text>
</Text>
<Text type="secondary">
: <Text style={{ color: '#faad14' }} strong>{issueSummary.bySeverity.warning || 0}</Text>
</Text>
</Space>
)}
</Space>
</div>
<Table
dataSource={issueItems}
rowKey="id"
columns={issueDetailColumns}
size="small"
loading={issueLoading}
pagination={false}
scroll={{ x: 1000, y: 400 }}
/>
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Pagination
current={issuePage}
total={issueTotal}
pageSize={20}
onChange={(pg) => {
setIssuePage(pg);
fetchIssues(issueModalSeverity, issueModalDimension, pg);
}}
showTotal={(t) => `${t}`}
size="small"
/>
</div>
</Modal>
);
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -282,6 +438,7 @@ const ReportsPage: React.FC = () => {
]}
/>
</Card>
{issueModal}
</div>
);
};

View File

@@ -84,7 +84,7 @@ const DashboardPage: React.FC = () => {
const [selectedTypeId, setSelectedTypeId] = useState<KBType | null>(null);
// 表单数据
const [formData, setFormData] = useState({ name: '', department: 'Cardiology' });
const [formData, setFormData] = useState({ name: '', department: 'General' });
const [files, setFiles] = useState<any[]>([]);
// 新增创建知识库后保存ID用于Step3上传文档
const [createdKbId, setCreatedKbId] = useState<string | null>(null);
@@ -141,7 +141,7 @@ const DashboardPage: React.FC = () => {
const handleCreateOpen = () => {
setCreateStep(1);
setSelectedTypeId(null);
setFormData({ name: '', department: 'Cardiology' });
setFormData({ name: '', department: 'General' });
setFiles([]);
setCreatedKbId(null);
setUploadedCount(0);
@@ -400,19 +400,7 @@ const DashboardPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2"> ( AI )</label>
<select
className="w-full px-4 py-3 border border-gray-300 rounded-lg bg-white outline-none focus:ring-2 focus:ring-blue-500 text-base"
value={formData.department}
onChange={(e) => setFormData({...formData, department: e.target.value})}
>
<option value="Cardiology"></option>
<option value="Neurology"></option>
<option value="Oncology"></option>
<option value="General"></option>
</select>
</div>
{/* 科室选择暂时隐藏,默认使用 General */}
</div>
</div>
)}