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
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
/**
|
||
* eQuery 管理页面
|
||
*
|
||
* 展示 AI 自动生成的电子质疑清单,支持状态过滤、回复、关闭操作。
|
||
*/
|
||
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
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';
|
||
import { useIitProject } from '../context/IitProjectContext';
|
||
|
||
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 { projectId } = useIitProject();
|
||
|
||
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>
|
||
),
|
||
},
|
||
];
|
||
|
||
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;
|