Files
AIclinicalresearch/frontend-v2/src/modules/iit/pages/AiStreamPage.tsx
HaHafeng 2030ebe28f feat(iit): Complete V3.1 QC engine + GCP business reports + AI timeline + bug fixes
V3.1 QC Engine:
- QcExecutor unified entry + D1-D7 dimension engines + three-level aggregation
- HealthScoreEngine + CompletenessEngine + ProtocolDeviationEngine + QcAggregator
- B4 flexible cron scheduling (project-level cronExpression + pg-boss dispatcher)
- Prisma migrations for qc_field_status, event_status, project_stats

GCP Business Reports (Phase A - 4 reports):
- D1 Eligibility: record_summary full list + qc_field_status D1 overlay
- D2 Completeness: data entry rate and missing rate aggregation
- D3/D4 Query Tracking: severity distribution from qc_field_status
- D6 Protocol Deviation: D6 dimension filtering
- 4 frontend table components + ReportsPage 5-tab restructure

AI Timeline Enhancement:
- SkillRunner outputs totalRules (33 actual rules vs 1 skill)
- iitQcCockpitController severity mapping fix (critical->red, warning->yellow)
- AiStreamPage expandable issue detail table with Chinese labels
- Event label localization (eventLabel from backend)

Business-side One-click Batch QC:
- DashboardPage batch QC button with SyncOutlined icon
- Auto-refresh QcReport cache after batch execution

Bug Fixes:
- dimension_code -> rule_category in 4 SQL queries
- D1 eligibility data source: record_summary full + qc_field_status overlay
- Timezone UTC -> Asia/Shanghai (QcReportService toBeijingTime helper)
- Pass rate calculation: passed/totalEvents instead of passed/totalRecords

Docs:
- Update IIT module status with GCP reports and bug fix milestones
- Update system status doc v6.6 with IIT progress

Tested: Backend compiles, frontend linter clean, batch QC verified
Made-with: Cursor
2026-03-01 22:49:49 +08:00

278 lines
9.0 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.
/**
* AI 实时工作流水页 (Level 2)
*
* 以 Timeline 展示 Agent 每次质控的完整动作链AI 白盒化。
* 显示中文事件名、实际规则数、五层定位详情、最终判定状态。
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Card,
Timeline,
Empty,
Tag,
Typography,
Space,
DatePicker,
Button,
Badge,
Pagination,
Collapse,
Table,
} from 'antd';
import {
ThunderboltOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
WarningOutlined,
SyncOutlined,
ClockCircleOutlined,
RobotOutlined,
ApiOutlined,
BellOutlined,
FileSearchOutlined,
} from '@ant-design/icons';
import * as iitProjectApi from '../api/iitProjectApi';
import type { TimelineItem } from '../api/iitProjectApi';
import { useIitProject } from '../context/IitProjectContext';
const { Text } = Typography;
const STATUS_DOT: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
PASS: { color: 'green', icon: <CheckCircleOutlined />, label: '通过' },
FAIL: { color: 'red', icon: <CloseCircleOutlined />, label: '严重' },
WARNING: { color: 'orange', icon: <WarningOutlined />, label: '警告' },
};
const TRIGGER_TAG: Record<string, { color: string; label: string }> = {
webhook: { color: 'blue', label: 'EDC 触发' },
cron: { color: 'purple', label: '定时巡查' },
manual: { color: 'cyan', label: '手动执行' },
batch: { color: 'geekblue', label: '批量质控' },
};
const AiStreamPage: React.FC = () => {
const { projectId } = useIitProject();
const [items, setItems] = useState<TimelineItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [dateFilter, setDateFilter] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
if (!projectId) return;
setLoading(true);
try {
const result = await iitProjectApi.getTimeline(projectId, {
page,
pageSize: 30,
date: dateFilter,
});
setItems(result.items);
setTotal(result.total);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, [projectId, page, dateFilter]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleRefresh = () => {
setPage(1);
fetchData();
};
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 };
const { red, yellow } = item.details.issuesSummary;
const time = new Date(item.time);
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 ?? '—',
},
];
return {
color: dotCfg.color as any,
dot: dotCfg.icon,
children: (
<div style={{ paddingBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text strong style={{ fontSize: 13, fontFamily: 'monospace' }}>{timeStr}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>{dateStr}</Text>
<Tag color={triggerCfg.color} style={{ fontSize: 10, lineHeight: '18px', padding: '0 6px' }}>
{triggerCfg.label}
</Tag>
<Tag color={dotCfg.color} style={{ fontSize: 10, lineHeight: '18px', padding: '0 6px' }}>
{dotCfg.label}
</Tag>
</div>
<div style={{
background: '#f8fafc',
borderRadius: 8,
padding: '8px 12px',
border: '1px solid #e2e8f0',
fontSize: 13,
lineHeight: 1.6,
}}>
<Space wrap size={4} style={{ marginBottom: 4 }}>
<RobotOutlined style={{ color: '#3b82f6' }} />
<Text> <Text code>{item.recordId}</Text></Text>
{eventLabel && <Tag color="geekblue">{eventLabel}</Tag>}
</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}>
<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>}
</Space>
</div>
)}
{issues.length > 0 && (
<Collapse
ghost
size="small"
style={{ marginTop: 4 }}
items={[{
key: '1',
label: (
<Space size={4}>
<FileSearchOutlined style={{ color: '#64748b' }} />
<Text type="secondary"> {issues.length} </Text>
</Space>
),
children: (
<Table
dataSource={issues}
rowKey={(_, i) => `issue-${i}`}
size="small"
pagination={false}
columns={issueColumns}
/>
),
}]}
/>
)}
{issues.length === 0 && item.status === 'PASS' && (
<div style={{ marginLeft: 20, marginTop: 2 }}>
<Text type="success" style={{ fontSize: 12 }}>
<CheckCircleOutlined />
</Text>
</div>
)}
</div>
</div>
),
};
});
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Tag icon={<ThunderboltOutlined />} color="processing"></Tag>
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#3b82f6' }}>
<Text type="secondary"></Text>
</Badge>
</Space>
<Space>
<DatePicker
placeholder="按日期筛选"
onChange={(d) => {
setDateFilter(d ? d.format('YYYY-MM-DD') : undefined);
setPage(1);
}}
allowClear
/>
<Button icon={<SyncOutlined spin={loading} />} onClick={handleRefresh} loading={loading}>
</Button>
</Space>
</div>
<Card>
{items.length > 0 ? (
<>
<Timeline items={timelineItems} />
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Pagination
current={page}
total={total}
pageSize={30}
onChange={setPage}
showTotal={(t) => `${t}`}
size="small"
/>
</div>
</>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
loading
? '加载中...'
: '定时质控开启后Agent 的每一步推理和操作都将在此透明展示'
}
style={{ padding: '48px 0' }}
/>
)}
</Card>
</div>
);
};
export default AiStreamPage;