feat(iit): Complete CRA Agent V3.0 P0 milestone - autonomous QC pipeline

P0-1: Variable list sync from REDCap metadata
P0-2: QC rule configuration with JSON Logic + AI suggestion
P0-3: Scheduled QC + report generation + eQuery closed loop
P0-4: Unified dashboard + AI stream timeline + critical events

Backend:
- Add IitEquery, IitCriticalEvent Prisma models + migration
- Add cronEnabled/cronExpression to IitProject
- Implement eQuery service/controller/routes (CRUD + respond/review/close)
- Implement DailyQcOrchestrator (report -> eQuery -> critical events -> notify)
- Add AI rule suggestion service
- Register daily QC cron worker and eQuery auto-review worker
- Extend QC cockpit with timeline, trend, critical events APIs
- Fix timeline issues field compat (object vs array format)

Frontend:
- Create IIT business module with 6 pages (Dashboard, AI Stream, eQuery,
  Reports, Variable List + project config pages)
- Migrate IIT config from admin panel to business module
- Implement health score, risk heatmap, trend chart, critical event alerts
- Register IIT module in App router and top navigation

Testing:
- Add E2E API test script covering 7 modules (46 assertions, all passing)

Tested: E2E API tests 46/46 passed, backend and frontend verified
Made-with: Cursor
This commit is contained in:
2026-02-26 13:28:08 +08:00
parent 31b0433195
commit 203846968c
35 changed files with 7353 additions and 22 deletions

View File

@@ -0,0 +1,387 @@
/**
* eQuery 管理页面
*
* 展示 AI 自动生成的电子质疑清单,支持状态过滤、回复、关闭操作。
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Card,
Table,
Tag,
Button,
Select,
Space,
Typography,
Badge,
Modal,
Input,
message,
Statistic,
Row,
Col,
Tooltip,
Empty,
} from 'antd';
import {
AlertOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
SendOutlined,
EyeOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import * as iitProjectApi from '../api/iitProjectApi';
import type { Equery, EqueryStats } from '../api/iitProjectApi';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
const STATUS_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
pending: { color: 'warning', label: '待处理', icon: <ClockCircleOutlined /> },
responded: { color: 'processing', label: '已回复', icon: <SendOutlined /> },
reviewing: { color: 'purple', label: 'AI 复核中', icon: <SyncOutlined spin /> },
closed: { color: 'success', label: '已关闭', icon: <CheckCircleOutlined /> },
reopened: { color: 'error', label: '已重开', icon: <CloseCircleOutlined /> },
};
const SEVERITY_CONFIG: Record<string, { color: string; label: string }> = {
error: { color: 'error', label: '严重' },
warning: { color: 'warning', label: '警告' },
info: { color: 'default', label: '信息' },
};
const EQueryPage: React.FC = () => {
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId') || '';
const [equeries, setEqueries] = useState<Equery[]>([]);
const [total, setTotal] = useState(0);
const [stats, setStats] = useState<EqueryStats | null>(null);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [page, setPage] = useState(1);
// Respond modal
const [respondModal, setRespondModal] = useState(false);
const [respondTarget, setRespondTarget] = useState<Equery | null>(null);
const [responseText, setResponseText] = useState('');
const [responding, setResponding] = useState(false);
// Detail modal
const [detailModal, setDetailModal] = useState(false);
const [detailTarget, setDetailTarget] = useState<Equery | null>(null);
const fetchData = useCallback(async () => {
if (!projectId) return;
setLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
iitProjectApi.listEqueries(projectId, { status: statusFilter, page, pageSize: 20 }),
iitProjectApi.getEqueryStats(projectId),
]);
setEqueries(listResult.items);
setTotal(listResult.total);
setStats(statsResult);
} catch {
message.error('加载 eQuery 数据失败');
} finally {
setLoading(false);
}
}, [projectId, statusFilter, page]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleRespond = async () => {
if (!respondTarget || !responseText.trim()) {
message.warning('请输入回复内容');
return;
}
setResponding(true);
try {
await iitProjectApi.respondEquery(projectId, respondTarget.id, { responseText });
message.success('回复成功');
setRespondModal(false);
setResponseText('');
fetchData();
} catch {
message.error('回复失败');
} finally {
setResponding(false);
}
};
const handleClose = async (equery: Equery) => {
try {
await iitProjectApi.closeEquery(projectId, equery.id, { closedBy: 'manual' });
message.success('已关闭');
fetchData();
} catch (err: any) {
message.error(err.message || '关闭失败');
}
};
const columns: ColumnsType<Equery> = [
{
title: '受试者',
dataIndex: 'recordId',
key: 'recordId',
width: 100,
render: (id: string) => <Text strong>{id}</Text>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 110,
render: (status: string) => {
const cfg = STATUS_CONFIG[status] || { color: 'default', label: status, icon: null };
return <Tag icon={cfg.icon} color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '严重程度',
dataIndex: 'severity',
key: 'severity',
width: 90,
render: (s: string) => {
const cfg = SEVERITY_CONFIG[s] || { color: 'default', label: s };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '质疑内容',
dataIndex: 'queryText',
key: 'queryText',
ellipsis: true,
render: (text: string) => (
<Tooltip title={text}>
<span>{text}</span>
</Tooltip>
),
},
{
title: '字段',
dataIndex: 'fieldName',
key: 'fieldName',
width: 130,
render: (f: string) => f ? <Text code style={{ fontSize: 11 }}>{f}</Text> : '—',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (d: string) => new Date(d).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 160,
render: (_: unknown, record: Equery) => (
<Space size={4}>
<Tooltip title="查看详情">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => { setDetailTarget(record); setDetailModal(true); }}
/>
</Tooltip>
{['pending', 'reopened'].includes(record.status) && (
<Button
type="link"
size="small"
onClick={() => { setRespondTarget(record); setRespondModal(true); }}
>
</Button>
)}
{record.status !== 'closed' && (
<Button
type="link"
size="small"
danger
onClick={() => handleClose(record)}
>
</Button>
)}
</Space>
),
},
];
if (!projectId) {
return (
<div style={{ padding: 24 }}>
<Empty description="请先选择一个项目" />
</div>
);
}
return (
<div>
{/* Stats */}
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={4}>
<Card size="small">
<Statistic title="总计" value={stats.total} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="待处理" value={stats.pending} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="已回复" value={stats.responded} valueStyle={{ color: '#1677ff' }} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="已关闭" value={stats.closed} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="已重开" value={stats.reopened} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic
title="平均解决时长"
value={stats.avgResolutionHours ?? '—'}
suffix={stats.avgResolutionHours !== null ? 'h' : ''}
/>
</Card>
</Col>
</Row>
)}
{/* Filter */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space>
<Select
placeholder="按状态过滤"
allowClear
style={{ width: 160 }}
value={statusFilter}
onChange={(v) => { setStatusFilter(v); setPage(1); }}
options={Object.entries(STATUS_CONFIG).map(([value, { label }]) => ({ value, label }))}
/>
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#1677ff' }}>
<Text type="secondary"> eQuery</Text>
</Badge>
</Space>
</Card>
{/* Table */}
<Card bodyStyle={{ padding: 0 }}>
<Table<Equery>
columns={columns}
dataSource={equeries}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t}`,
}}
locale={{ emptyText: <Empty description="暂无 eQuery质控完成后将自动生成" /> }}
/>
</Card>
{/* Respond Modal */}
<Modal
title={`回复 eQuery - 受试者 ${respondTarget?.recordId}`}
open={respondModal}
onCancel={() => setRespondModal(false)}
onOk={handleRespond}
confirmLoading={responding}
okText="提交回复"
>
{respondTarget && (
<div style={{ marginBottom: 16 }}>
<Paragraph type="secondary">{respondTarget.queryText}</Paragraph>
{respondTarget.expectedAction && (
<Paragraph type="secondary" italic>: {respondTarget.expectedAction}</Paragraph>
)}
</div>
)}
<TextArea
rows={4}
value={responseText}
onChange={(e) => setResponseText(e.target.value)}
placeholder="请输入回复说明(如:已修正数据 / 数据正确,原因是..."
/>
</Modal>
{/* Detail Modal */}
<Modal
title={`eQuery 详情 - ${detailTarget?.id?.substring(0, 8)}`}
open={detailModal}
onCancel={() => setDetailModal(false)}
footer={null}
width={640}
>
{detailTarget && (
<div>
<Row gutter={[16, 12]}>
<Col span={8}><Text type="secondary">:</Text> <Text strong>{detailTarget.recordId}</Text></Col>
<Col span={8}><Text type="secondary">:</Text> <Text code>{detailTarget.fieldName || '—'}</Text></Col>
<Col span={8}><Text type="secondary">:</Text> {detailTarget.formName || '—'}</Col>
<Col span={8}>
<Text type="secondary">:</Text>{' '}
<Tag color={STATUS_CONFIG[detailTarget.status]?.color}>{STATUS_CONFIG[detailTarget.status]?.label}</Tag>
</Col>
<Col span={8}>
<Text type="secondary">:</Text>{' '}
<Tag color={SEVERITY_CONFIG[detailTarget.severity]?.color}>{SEVERITY_CONFIG[detailTarget.severity]?.label}</Tag>
</Col>
<Col span={8}><Text type="secondary">:</Text> {new Date(detailTarget.createdAt).toLocaleString('zh-CN')}</Col>
</Row>
<Card size="small" title="质疑内容" style={{ marginTop: 16 }}>
<Paragraph>{detailTarget.queryText}</Paragraph>
{detailTarget.expectedAction && (
<Paragraph type="secondary">: {detailTarget.expectedAction}</Paragraph>
)}
</Card>
{detailTarget.responseText && (
<Card size="small" title="CRC 回复" style={{ marginTop: 12 }}>
<Paragraph>{detailTarget.responseText}</Paragraph>
<Text type="secondary">: {detailTarget.respondedAt ? new Date(detailTarget.respondedAt).toLocaleString('zh-CN') : '—'}</Text>
</Card>
)}
{detailTarget.reviewResult && (
<Card size="small" title="AI 复核结果" style={{ marginTop: 12 }}>
<Tag color={detailTarget.reviewResult === 'passed' ? 'success' : 'error'}>
{detailTarget.reviewResult === 'passed' ? '复核通过' : '复核不通过'}
</Tag>
{detailTarget.reviewNote && <Paragraph style={{ marginTop: 8 }}>{detailTarget.reviewNote}</Paragraph>}
</Card>
)}
{detailTarget.resolution && (
<Card size="small" title="关闭说明" style={{ marginTop: 12 }}>
<Paragraph>{detailTarget.resolution}</Paragraph>
</Card>
)}
</div>
)}
</Modal>
</div>
);
};
export default EQueryPage;