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:
387
frontend-v2/src/modules/iit/pages/EQueryPage.tsx
Normal file
387
frontend-v2/src/modules/iit/pages/EQueryPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user