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:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user