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
278 lines
9.0 KiB
TypeScript
278 lines
9.0 KiB
TypeScript
/**
|
||
* 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;
|