Files
AIclinicalresearch/frontend-v2/src/modules/iit/pages/EQueryPage.tsx
HaHafeng 0b29fe88b5 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
2026-03-01 15:27:05 +08:00

379 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;