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;
|
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 = {
|
export const analysisApi = {
|
||||||
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
|
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
|
||||||
const resp = await client.get('/ai/analysis/history', { params });
|
const resp = await client.get('/ai/analysis/history', { params });
|
||||||
@@ -25,4 +40,8 @@ export const analysisApi = {
|
|||||||
const resp = await client.get(`/ai/analysis/${id}`);
|
const resp = await client.get(`/ai/analysis/${id}`);
|
||||||
return resp.data.data as AnalysisItem;
|
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 { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Drawer, Input, Button, Space, Typography, Spin, Tag, theme } from 'antd';
|
import {
|
||||||
import { SendOutlined, RobotOutlined, DeleteOutlined } from '@ant-design/icons';
|
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 { useLocation } from 'react-router-dom';
|
||||||
import { aiChatApi, type ChatHistoryItem } from '../../api/ai/chat';
|
import { aiChatApi, type ChatHistoryItem } from '../../api/ai/chat';
|
||||||
|
import { analysisApi, type HealthSummaryResponse } from '../../api/ai/analysis';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -19,6 +36,13 @@ function extractPatientId(pathname: string): string | null {
|
|||||||
return match?.[1] ?? 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({
|
export default function AiSidebar({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -29,6 +53,8 @@ export default function AiSidebar({
|
|||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [summary, setSummary] = useState<HealthSummaryResponse | null>(null);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@@ -36,6 +62,7 @@ export default function AiSidebar({
|
|||||||
const patientId = extractPatientId(location.pathname);
|
const patientId = extractPatientId(location.pathname);
|
||||||
const permissions = useAuthStore((s) => s.permissions);
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
const canChat = permissions.includes('ai.chat.send');
|
const canChat = permissions.includes('ai.chat.send');
|
||||||
|
const canViewSummary = permissions.includes('ai.analysis.list');
|
||||||
|
|
||||||
// 欢迎消息
|
// 欢迎消息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,6 +79,30 @@ export default function AiSidebar({
|
|||||||
}
|
}
|
||||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
@@ -85,7 +136,11 @@ export default function AiSidebar({
|
|||||||
content: m.content,
|
content: m.content,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const resp = await aiChatApi.sendMessage(text, history, patientId ?? undefined);
|
const resp = await aiChatApi.sendMessage(
|
||||||
|
text,
|
||||||
|
history,
|
||||||
|
patientId ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
@@ -126,6 +181,8 @@ export default function AiSidebar({
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const riskInfo = summary ? RISK_CONFIG[summary.risk_level] ?? RISK_CONFIG.low : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -169,7 +306,8 @@ export default function AiSidebar({
|
|||||||
key={msg.id}
|
key={msg.id}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
justifyContent:
|
||||||
|
msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -199,7 +337,13 @@ export default function AiSidebar({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 12 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
@@ -208,7 +352,8 @@ export default function AiSidebar({
|
|||||||
background: token.colorBgContainer,
|
background: token.colorBgContainer,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Spin size="small" /> <Text type="secondary">思考中...</Text>
|
<Spin size="small" />{' '}
|
||||||
|
<Text type="secondary">思考中...</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -229,7 +374,9 @@ export default function AiSidebar({
|
|||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
canChat ? '输入消息... (Enter 发送, Shift+Enter 换行)' : '无 AI 聊天权限'
|
canChat
|
||||||
|
? '输入消息... (Enter 发送, Shift+Enter 换行)'
|
||||||
|
: '无 AI 聊天权限'
|
||||||
}
|
}
|
||||||
disabled={loading || !canChat}
|
disabled={loading || !canChat}
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
@@ -241,11 +388,18 @@ export default function AiSidebar({
|
|||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!input.trim() || !canChat}
|
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>
|
</Space.Compact>
|
||||||
{!canChat && (
|
{!canChat && (
|
||||||
<Text type="warning" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
<Text
|
||||||
|
type="warning"
|
||||||
|
style={{ fontSize: 12, marginTop: 4, display: 'block' }}
|
||||||
|
>
|
||||||
你没有 AI 聊天权限,请联系管理员开通。
|
你没有 AI 聊天权限,请联系管理员开通。
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user