- 新增 suggestions API 层(list/approve/getComparison) - 展开分析详情时自动加载关联的 AI 建议列表 - 风险等级彩色标签(低/中/高) - 建议类型、原因、执行状态展示 - 待审批建议支持批准/拒绝操作
460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||
import { Table, Select, Tag, Space, Button, message, Typography } from 'antd';
|
||
import {
|
||
RobotOutlined,
|
||
CheckCircleOutlined,
|
||
CloseCircleOutlined,
|
||
ExclamationCircleOutlined,
|
||
WarningOutlined,
|
||
} from '@ant-design/icons';
|
||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||
import { analysisApi, type AnalysisItem } from '../../api/ai/analysis';
|
||
import { suggestionApi, type SuggestionItem } from '../../api/ai/suggestions';
|
||
|
||
const { Text } = Typography;
|
||
|
||
const ANALYSIS_TYPE_MAP: Record<string, string> = {
|
||
lab_report_interpretation: '化验单解读',
|
||
health_trend_analysis: '趋势分析',
|
||
personalized_checkup_plan: '体检方案',
|
||
report_summary_generation: '报告摘要',
|
||
};
|
||
|
||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||
completed: { color: 'green', text: '已完成' },
|
||
failed: { color: 'red', text: '失败' },
|
||
streaming: { color: 'blue', text: '进行中' },
|
||
pending: { color: 'orange', text: '等待中' },
|
||
};
|
||
|
||
const TYPE_OPTIONS = Object.entries(ANALYSIS_TYPE_MAP).map(([value, label]) => ({
|
||
value,
|
||
label,
|
||
}));
|
||
|
||
const RISK_CONFIG: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||
low: { color: 'green', text: '低风险', icon: <CheckCircleOutlined /> },
|
||
medium: { color: 'orange', text: '中风险', icon: <ExclamationCircleOutlined /> },
|
||
high: { color: 'red', text: '高风险', icon: <WarningOutlined /> },
|
||
};
|
||
|
||
const SUGGESTION_TYPE_MAP: Record<string, string> = {
|
||
followup: '随访建议',
|
||
appointment: '预约建议',
|
||
alert: '预警通知',
|
||
};
|
||
|
||
const SUGGESTION_STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||
pending: { color: 'orange', text: '待审批' },
|
||
approved: { color: 'green', text: '已批准' },
|
||
rejected: { color: 'red', text: '已拒绝' },
|
||
executed: { color: 'blue', text: '已执行' },
|
||
expired: { color: 'default', text: '已过期' },
|
||
parse_failed: { color: 'red', text: '解析失败' },
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 分析结果渲染(Markdown 风格)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function AnalysisContent({ content, isDark }: { content: string; isDark: boolean }) {
|
||
// 简单的 Markdown 风格渲染
|
||
const lines = content.split('\n');
|
||
const rendered = lines.map((line, i) => {
|
||
// 标题行
|
||
if (line.startsWith('### ')) {
|
||
return (
|
||
<Text key={i} strong style={{ display: 'block', fontSize: 14, marginTop: 12, marginBottom: 4 }}>
|
||
{line.slice(4)}
|
||
</Text>
|
||
);
|
||
}
|
||
if (line.startsWith('## ')) {
|
||
return (
|
||
<Text key={i} strong style={{ display: 'block', fontSize: 15, marginTop: 16, marginBottom: 6, borderBottom: '1px solid ' + (isDark ? '#1e293b' : '#f0f0f0'), paddingBottom: 4 }}>
|
||
{line.slice(3)}
|
||
</Text>
|
||
);
|
||
}
|
||
// 列表项
|
||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||
return (
|
||
<div key={i} style={{ paddingLeft: 16, position: 'relative', lineHeight: 1.8 }}>
|
||
<span style={{ position: 'absolute', left: 4, color: '#3b82f6' }}>•</span>
|
||
{renderInlineStyles(line.slice(2))}
|
||
</div>
|
||
);
|
||
}
|
||
// 有序列表
|
||
const orderedMatch = line.match(/^(\d+)\.\s/);
|
||
if (orderedMatch) {
|
||
return (
|
||
<div key={i} style={{ paddingLeft: 16, lineHeight: 1.8 }}>
|
||
<Text type="secondary" style={{ marginRight: 4 }}>{orderedMatch[1]}.</Text>
|
||
{renderInlineStyles(line.slice(orderedMatch[0].length))}
|
||
</div>
|
||
);
|
||
}
|
||
// 空行
|
||
if (line.trim() === '') {
|
||
return <div key={i} style={{ height: 8 }} />;
|
||
}
|
||
// 普通段落
|
||
return <div key={i} style={{ lineHeight: 1.8 }}>{renderInlineStyles(line)}</div>;
|
||
});
|
||
|
||
return <div style={{ fontSize: 13 }}>{rendered}</div>;
|
||
}
|
||
|
||
/** 简单行内样式渲染(粗体、代码) */
|
||
function renderInlineStyles(text: string) {
|
||
// 拆分 **bold** 和 `code` 模式
|
||
const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
|
||
return parts.map((part, i) => {
|
||
if (part.startsWith('**') && part.endsWith('**')) {
|
||
return <Text key={i} strong>{part.slice(2, -2)}</Text>;
|
||
}
|
||
if (part.startsWith('`') && part.endsWith('`')) {
|
||
return (
|
||
<code key={i} style={{
|
||
background: 'rgba(59, 130, 246, 0.1)',
|
||
padding: '1px 4px',
|
||
borderRadius: 3,
|
||
fontSize: 12,
|
||
fontFamily: 'monospace',
|
||
}}>
|
||
{part.slice(1, -1)}
|
||
</code>
|
||
);
|
||
}
|
||
return <span key={i}>{part}</span>;
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AI 建议面板
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function SuggestionPanel({ analysisId, isDark }: { analysisId: string; isDark: boolean }) {
|
||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||
|
||
const fetchSuggestions = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await suggestionApi.list({ analysis_id: analysisId });
|
||
setSuggestions(result.data || []);
|
||
} catch {
|
||
// 静默处理
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [analysisId]);
|
||
|
||
useEffect(() => {
|
||
if (analysisId) fetchSuggestions();
|
||
}, [analysisId, fetchSuggestions]);
|
||
|
||
const handleAction = async (id: string, action: 'approve' | 'reject') => {
|
||
setActionLoading(id);
|
||
try {
|
||
await suggestionApi.approve(id, action);
|
||
message.success(action === 'approve' ? '已批准' : '已拒绝');
|
||
fetchSuggestions();
|
||
} catch {
|
||
message.error('操作失败');
|
||
} finally {
|
||
setActionLoading(null);
|
||
}
|
||
};
|
||
|
||
if (loading) return <div style={{ padding: '8px 0' }}>加载建议中...</div>;
|
||
if (suggestions.length === 0) return null;
|
||
|
||
return (
|
||
<div style={{
|
||
marginTop: 16,
|
||
padding: 12,
|
||
background: isDark ? '#0f172a' : '#f8fafc',
|
||
borderRadius: 8,
|
||
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
|
||
}}>
|
||
<Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||
AI 建议列表 ({suggestions.length})
|
||
</Text>
|
||
{suggestions.map((s) => {
|
||
const risk = RISK_CONFIG[s.risk_level] || { color: 'default', text: s.risk_level, icon: null };
|
||
const status = SUGGESTION_STATUS_CONFIG[s.status] || { color: 'default', text: s.status };
|
||
const typeLabel = SUGGESTION_TYPE_MAP[s.suggestion_type] || s.suggestion_type;
|
||
const isPending = s.status === 'pending';
|
||
const params = s.params as Record<string, unknown> | null;
|
||
const reason = params?.reason as string || params?.message as string || '';
|
||
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '8px 12px',
|
||
marginBottom: 6,
|
||
background: isDark ? '#111827' : '#fff',
|
||
borderRadius: 6,
|
||
border: `1px solid ${isDark ? '#1e293b' : '#f0f0f0'}`,
|
||
}}
|
||
>
|
||
<Space size={8}>
|
||
<Tag color={risk.color} style={{ margin: 0 }}>
|
||
{risk.icon} {risk.text}
|
||
</Tag>
|
||
<Tag style={{ margin: 0 }}>{typeLabel}</Tag>
|
||
{reason && <Text type="secondary" style={{ fontSize: 12, maxWidth: 300 }} ellipsis>{reason}</Text>}
|
||
<Tag color={status.color} style={{ margin: 0, fontSize: 11 }}>{status.text}</Tag>
|
||
</Space>
|
||
{isPending && (
|
||
<Space size={4}>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<CheckCircleOutlined />}
|
||
loading={actionLoading === s.id}
|
||
onClick={() => handleAction(s.id, 'approve')}
|
||
>
|
||
批准
|
||
</Button>
|
||
<Button
|
||
danger
|
||
size="small"
|
||
icon={<CloseCircleOutlined />}
|
||
loading={actionLoading === s.id}
|
||
onClick={() => handleAction(s.id, 'reject')}
|
||
>
|
||
拒绝
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 主组件
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export default function AiAnalysisList() {
|
||
const [data, setData] = useState<AnalysisItem[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [loading, setLoading] = useState(false);
|
||
const [query, setQuery] = useState<{ page: number; page_size: number; analysis_type?: string }>({
|
||
page: 1,
|
||
page_size: 20,
|
||
});
|
||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||
const [detail, setDetail] = useState<AnalysisItem | null>(null);
|
||
const isDark = useThemeMode();
|
||
|
||
const fetchData = useCallback(
|
||
async (params: { page: number; page_size: number; analysis_type?: string }) => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await analysisApi.list(params);
|
||
setData(result.data);
|
||
setTotal(result.total);
|
||
} catch {
|
||
message.error('加载分析历史失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
fetchData(query);
|
||
}, [query, fetchData]);
|
||
|
||
const handleExpand = async (expanded: boolean, record: AnalysisItem) => {
|
||
if (expanded && record.id !== expandedId) {
|
||
try {
|
||
const item = await analysisApi.get(record.id);
|
||
setDetail(item);
|
||
setExpandedId(record.id);
|
||
} catch {
|
||
// 展开失败不阻塞
|
||
}
|
||
} else if (!expanded) {
|
||
setExpandedId(null);
|
||
setDetail(null);
|
||
}
|
||
};
|
||
|
||
// 解析 metadata 中的趋势统计信息
|
||
const trendMetrics = useMemo(() => {
|
||
if (!detail?.result_metadata) return null;
|
||
const meta = detail.result_metadata as Record<string, unknown>;
|
||
// 自动分析结果中可能包含 metrics 统计
|
||
// 也从 sanitized_input 中解析(如果有的话)
|
||
return meta;
|
||
}, [detail]);
|
||
|
||
const columns = useMemo(() => [
|
||
{
|
||
title: '分析类型',
|
||
dataIndex: 'analysis_type',
|
||
key: 'analysis_type',
|
||
width: 130,
|
||
render: (v: string) => (
|
||
<Tag color="blue">{ANALYSIS_TYPE_MAP[v] || v}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '患者 ID',
|
||
dataIndex: 'patient_id',
|
||
key: 'patient_id',
|
||
width: 120,
|
||
render: (v: string) => (
|
||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v.slice(0, 8)}</span>
|
||
),
|
||
},
|
||
{
|
||
title: '模型',
|
||
dataIndex: 'model_used',
|
||
key: 'model_used',
|
||
width: 130,
|
||
render: (v: string) => v || '-',
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
width: 90,
|
||
render: (v: string) => {
|
||
const cfg = STATUS_CONFIG[v] || { color: 'default', text: v };
|
||
return <Tag color={cfg.color}>{cfg.text}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '创建时间',
|
||
dataIndex: 'created_at',
|
||
key: 'created_at',
|
||
width: 170,
|
||
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||
},
|
||
], []);
|
||
|
||
return (
|
||
<div>
|
||
<div className="erp-page-header">
|
||
<div>
|
||
<h4>AI 分析历史</h4>
|
||
<div className="erp-page-subtitle">查看所有 AI 智能分析记录和结果</div>
|
||
</div>
|
||
<Space size={8}>
|
||
<Select
|
||
placeholder="筛选类型"
|
||
value={query.analysis_type}
|
||
onChange={(v) => setQuery((prev) => ({ ...prev, analysis_type: v, page: 1 }))}
|
||
options={TYPE_OPTIONS}
|
||
allowClear
|
||
style={{ width: 150 }}
|
||
/>
|
||
</Space>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
background: isDark ? '#111827' : '#FFFFFF',
|
||
borderRadius: 12,
|
||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
loading={loading}
|
||
expandable={{
|
||
expandedRowKeys: expandedId ? [expandedId] : [],
|
||
onExpand: handleExpand,
|
||
expandedRowRender: () => {
|
||
if (!detail) return null;
|
||
const isTrendAnalysis = detail.analysis_type === 'trend';
|
||
|
||
return (
|
||
<div style={{ padding: '8px 0' }}>
|
||
{/* 自动分析标记 */}
|
||
{trendMetrics && (trendMetrics as Record<string, unknown>).auto_analysis === true && (
|
||
<div style={{ marginBottom: 8 }}>
|
||
<Tag color="purple" style={{ fontSize: 11 }}>
|
||
<RobotOutlined style={{ marginRight: 4 }} />
|
||
系统自动分析
|
||
</Tag>
|
||
</div>
|
||
)}
|
||
|
||
{detail.error_message && (
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text type="danger">错误: {detail.error_message}</Text>
|
||
</div>
|
||
)}
|
||
{detail.result_content && (
|
||
<div>
|
||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||
分析结果
|
||
</Text>
|
||
<div
|
||
style={{
|
||
background: isDark ? '#0f172a' : '#f8fafc',
|
||
padding: 16,
|
||
borderRadius: 8,
|
||
maxHeight: 600,
|
||
overflow: 'auto',
|
||
}}
|
||
>
|
||
<AnalysisContent content={detail.result_content} isDark={isDark} />
|
||
</div>
|
||
|
||
{/* 趋势分析类型显示统计摘要提示 */}
|
||
{isTrendAnalysis && (
|
||
<div style={{ marginTop: 12, padding: '8px 12px', background: isDark ? '#0f172a' : '#fffbeb', borderRadius: 6, border: `1px solid ${isDark ? '#1e293b' : '#fde68a'}` }}>
|
||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||
提示:趋势分析基于最小二乘法线性回归和 2 倍标准差异常检测。R² 越接近 1 表示趋势拟合越好。
|
||
斜率为正表示上升趋势,斜率为负表示下降趋势。
|
||
</Text>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!detail.result_content && !detail.error_message && (
|
||
<Text type="secondary">暂无结果内容</Text>
|
||
)}
|
||
</div>
|
||
|
||
{/* AI 建议面板 */}
|
||
{detail.id && (
|
||
<SuggestionPanel analysisId={detail.id} isDark={isDark} />
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
}}
|
||
pagination={{
|
||
current: query.page,
|
||
total,
|
||
pageSize: query.page_size,
|
||
showSizeChanger: true,
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
onChange: (p, ps) => setQuery((prev) => ({ ...prev, page: p, page_size: ps })),
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|