feat(web): Phase 2A-2 患者档案 AI 自动摘要 — 侧边栏顶部摘要卡片

当 AI 侧边栏打开且当前页面为患者详情时,自动调用 GET /ai/health-summary
并在侧边栏顶部显示摘要卡片:
- 风险等级标签(低/中/高/严重,对应绿/橙/红/深红)
- 活跃洞察数量 + 近期分析次数
- 最多 3 条摘要项(按 category + severity 着色)
- 最新洞察标题(带警告图标)
- 离开患者页面或关闭侧边栏时自动清除

同时:
- analysis.ts 新增 getHealthSummary API + HealthSummaryResponse 类型
- 权限检查:需要 ai.analysis.list 才显示摘要卡片
This commit is contained in:
iven
2026-05-19 00:37:46 +08:00
parent 1e2ad6170a
commit 205f6fb5a2
2 changed files with 182 additions and 9 deletions

View File

@@ -16,6 +16,21 @@ export interface AnalysisItem {
updated_at: string;
}
export interface HealthSummaryResponse {
patient_id: string;
risk_level: 'low' | 'medium' | 'high' | 'critical';
active_insights_count: number;
recent_analyses_count: number;
latest_insight_title: string | null;
latest_analysis_type: string | null;
summary_items: Array<{
category: string;
title: string;
severity: string | null;
created_at: string;
}>;
}
export const analysisApi = {
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
const resp = await client.get('/ai/analysis/history', { params });
@@ -25,4 +40,8 @@ export const analysisApi = {
const resp = await client.get(`/ai/analysis/${id}`);
return resp.data.data as AnalysisItem;
},
getHealthSummary: async (patientId: string) => {
const resp = await client.get('/ai/health-summary', { params: { patient_id: patientId } });
return resp.data.data as HealthSummaryResponse;
},
};

View File

@@ -1,8 +1,25 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Drawer, Input, Button, Space, Typography, Spin, Tag, theme } from 'antd';
import { SendOutlined, RobotOutlined, DeleteOutlined } from '@ant-design/icons';
import {
Drawer,
Input,
Button,
Space,
Typography,
Spin,
Tag,
Card,
theme,
} from 'antd';
import {
SendOutlined,
RobotOutlined,
DeleteOutlined,
WarningOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import { useLocation } from 'react-router-dom';
import { aiChatApi, type ChatHistoryItem } from '../../api/ai/chat';
import { analysisApi, type HealthSummaryResponse } from '../../api/ai/analysis';
import { useAuthStore } from '../../stores/auth';
const { Text } = Typography;
@@ -19,6 +36,13 @@ function extractPatientId(pathname: string): string | null {
return match?.[1] ?? null;
}
const RISK_CONFIG: Record<string, { color: string; label: string }> = {
low: { color: 'green', label: '低风险' },
medium: { color: 'orange', label: '中风险' },
high: { color: 'red', label: '高风险' },
critical: { color: '#cf1322', label: '严重' },
};
export default function AiSidebar({
open,
onClose,
@@ -29,6 +53,8 @@ export default function AiSidebar({
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<HealthSummaryResponse | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const { token } = theme.useToken();
@@ -36,6 +62,7 @@ export default function AiSidebar({
const patientId = extractPatientId(location.pathname);
const permissions = useAuthStore((s) => s.permissions);
const canChat = permissions.includes('ai.chat.send');
const canViewSummary = permissions.includes('ai.analysis.list');
// 欢迎消息
useEffect(() => {
@@ -52,6 +79,30 @@ export default function AiSidebar({
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// 自动加载患者健康摘要
useEffect(() => {
if (!open || !patientId || !canViewSummary) {
setSummary(null);
return;
}
let cancelled = false;
setSummaryLoading(true);
analysisApi
.getHealthSummary(patientId)
.then((data) => {
if (!cancelled) setSummary(data);
})
.catch(() => {
if (!cancelled) setSummary(null);
})
.finally(() => {
if (!cancelled) setSummaryLoading(false);
});
return () => {
cancelled = true;
};
}, [open, patientId, canViewSummary]);
const scrollToBottom = useCallback(() => {
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -85,7 +136,11 @@ export default function AiSidebar({
content: m.content,
}));
const resp = await aiChatApi.sendMessage(text, history, patientId ?? undefined);
const resp = await aiChatApi.sendMessage(
text,
history,
patientId ?? undefined
);
setMessages((prev) => [
...prev,
@@ -126,6 +181,8 @@ export default function AiSidebar({
]);
};
const riskInfo = summary ? RISK_CONFIG[summary.risk_level] ?? RISK_CONFIG.low : null;
return (
<Drawer
title={
@@ -155,6 +212,86 @@ export default function AiSidebar({
/>
}
>
{/* 患者健康摘要卡片 */}
{patientId && canViewSummary && (
<div style={{ padding: '12px 16px 0' }}>
{summaryLoading ? (
<Card size="small" style={{ textAlign: 'center' }}>
<Spin size="small" /> <Text type="secondary">...</Text>
</Card>
) : summary ? (
<Card
size="small"
title={
<Space size={4}>
<SafetyCertificateOutlined />
<span style={{ fontSize: 13 }}></span>
{riskInfo && (
<Tag
color={riskInfo.color}
style={{ marginLeft: 4, fontSize: 11 }}
>
{riskInfo.label}
</Tag>
)}
</Space>
}
style={{ marginBottom: 4 }}
styles={{ body: { padding: '8px 12px' } }}
>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
<div style={{ marginBottom: 4 }}>
{summary.active_insights_count}
{summary.recent_analyses_count > 0 &&
` | 近期分析 ${summary.recent_analyses_count}`}
</div>
{summary.summary_items.length > 0 && (
<div>
{summary.summary_items.slice(0, 3).map((item, i) => (
<div key={i} style={{ marginTop: 3 }}>
<Tag
color={
item.severity === 'high'
? 'red'
: item.severity === 'medium'
? 'orange'
: 'blue'
}
style={{ fontSize: 10, marginRight: 4 }}
>
{item.category}
</Tag>
<span>{item.title}</span>
</div>
))}
{summary.summary_items.length > 3 && (
<Text
type="secondary"
style={{ fontSize: 11, marginTop: 2, display: 'block' }}
>
{summary.summary_items.length - 3} ...
</Text>
)}
</div>
)}
{summary.latest_insight_title && (
<div
style={{
marginTop: 4,
paddingTop: 4,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
<WarningOutlined style={{ marginRight: 4, color: token.colorWarning }} />
: {summary.latest_insight_title}
</div>
)}
</div>
</Card>
) : null}
</div>
)}
{/* 消息列表 */}
<div
style={{
@@ -169,7 +306,8 @@ export default function AiSidebar({
key={msg.id}
style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
justifyContent:
msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: 12,
}}
>
@@ -199,7 +337,13 @@ export default function AiSidebar({
</div>
))}
{loading && (
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 12 }}>
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
marginBottom: 12,
}}
>
<div
style={{
padding: '8px 16px',
@@ -208,7 +352,8 @@ export default function AiSidebar({
background: token.colorBgContainer,
}}
>
<Spin size="small" /> <Text type="secondary">...</Text>
<Spin size="small" />{' '}
<Text type="secondary">...</Text>
</div>
</div>
)}
@@ -229,7 +374,9 @@ export default function AiSidebar({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
canChat ? '输入消息... (Enter 发送, Shift+Enter 换行)' : '无 AI 聊天权限'
canChat
? '输入消息... (Enter 发送, Shift+Enter 换行)'
: '无 AI 聊天权限'
}
disabled={loading || !canChat}
autoSize={{ minRows: 1, maxRows: 4 }}
@@ -241,11 +388,18 @@ export default function AiSidebar({
onClick={handleSend}
loading={loading}
disabled={!input.trim() || !canChat}
style={{ height: 'auto', borderRadius: '0 8px 8px 0', minHeight: 40 }}
style={{
height: 'auto',
borderRadius: '0 8px 8px 0',
minHeight: 40,
}}
/>
</Space.Compact>
{!canChat && (
<Text type="warning" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
<Text
type="warning"
style={{ fontSize: 12, marginTop: 4, display: 'block' }}
>
AI
</Text>
)}