Files
hms/apps/web/src/pages/health/AiAnalysisList.tsx
iven 1f91dcc5cc
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(ai): 修复分析结果 JSON 嵌套 bug
- replay_cached 直接回放纯文本,不再包装 JSON 壳
- complete_analysis 跳过已完成的记录,防止缓存命中时覆写
- 前端 AnalysisContent 增加 extractPlainText 递归解析 JSON
2026-05-05 19:45:36 +08:00

483 lines
16 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.
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { Table, Select, Tag, Space, Button, message, Result, Typography } from 'antd';
import {
RobotOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useThemeMode } from '../../hooks/useThemeMode';
import { usePermission } from '../../hooks/usePermission';
import { analysisApi, type AnalysisItem } from '../../api/ai/analysis';
import { suggestionApi, type SuggestionItem } from '../../api/ai/suggestions';
import { EntityName } from '../../components/EntityName';
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 风格)
// ---------------------------------------------------------------------------
/** 递归提取 JSON 嵌套中的实际文本内容 */
function extractPlainText(raw: string): string {
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null && typeof parsed.content === 'string') {
return extractPlainText(parsed.content);
}
return raw;
} catch {
return raw;
}
}
function AnalysisContent({ content, isDark }: { content: string; isDark: boolean }) {
const text = extractPlainText(content);
// 简单的 Markdown 风格渲染
const lines = text.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 { hasPermission } = usePermission('ai.analysis.list');
if (!hasPermission) return <Result status="403" title="权限不足" subTitle="您没有查看 AI 分析的权限" />;
const [searchParams] = useSearchParams();
const urlPatientId = searchParams.get('patient_id');
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; patient_id?: string }>({
page: 1,
page_size: 20,
patient_id: urlPatientId || undefined,
});
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: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
width: 140,
render: (_: unknown, record: AnalysisItem) => (
<Link to={`/health/patients/${record.patient_id}`}>
<EntityName name={record.patient_name} id={record.patient_id} />
</Link>
),
},
{
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>
)}
{/* 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>
);
}