feat(web): Phase 2A-1 AI 侧边栏骨架 — 浮动按钮 + 聊天 Drawer

新增 Web 管理后台 AI 侧边栏:
- 右下角渐变色浮动按钮(RobotOutlined),hover 放大效果
- AiSidebar Drawer 组件:聊天消息列表 + 输入框 + 发送按钮
- 自动检测当前页面患者 ID,携带 patient_id 上下文到 /ai/chat
- 权限检查:无 ai.chat.send 权限时禁用输入并提示
- 气泡样式对话:用户消息蓝色右对齐,助手消息灰色左对齐
- 清空对话、加载态(思考中 Spin)、Enter 发送 + Shift+Enter 换行

新增文件:
- apps/web/src/api/ai/chat.ts — AI 聊天 API 模块
- apps/web/src/components/ai/AiSidebar.tsx — 侧边栏组件

修改文件:
- apps/web/src/layouts/MainLayout.tsx — 集成浮动按钮 + AiSidebar
This commit is contained in:
iven
2026-05-19 00:32:08 +08:00
parent b2053d5bcc
commit 1e2ad6170a
3 changed files with 319 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import client from '../client';
export interface ChatHistoryItem {
role: 'user' | 'assistant';
content: string;
}
export interface ChatResponse {
reply: string;
message_id: string;
iterations: number;
}
export const aiChatApi = {
sendMessage: async (
message: string,
history: ChatHistoryItem[],
patientId?: string
): Promise<ChatResponse> => {
const resp = await client.post('/ai/chat', {
message,
history,
...(patientId ? { patient_id: patientId } : {}),
});
return resp.data.data as ChatResponse;
},
};

View File

@@ -0,0 +1,255 @@
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 { useLocation } from 'react-router-dom';
import { aiChatApi, type ChatHistoryItem } from '../../api/ai/chat';
import { useAuthStore } from '../../stores/auth';
const { Text } = Typography;
const { TextArea } = Input;
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
}
function extractPatientId(pathname: string): string | null {
const match = pathname.match(/\/health\/patients\/([0-9a-f-]+)/i);
return match?.[1] ?? null;
}
export default function AiSidebar({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const { token } = theme.useToken();
const patientId = extractPatientId(location.pathname);
const permissions = useAuthStore((s) => s.permissions);
const canChat = permissions.includes('ai.chat.send');
// 欢迎消息
useEffect(() => {
if (open && messages.length === 0) {
setMessages([
{
id: 'welcome',
role: 'assistant',
content: patientId
? '你好!我是 AI 健康助手。当前已关联患者档案,你可以问我关于该患者的体征、化验报告、用药等信息。'
: '你好!我是 AI 健康助手。你可以向我咨询健康相关问题,或打开患者详情页查看患者数据。',
},
]);
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const scrollToBottom = useCallback(() => {
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const handleSend = async () => {
const text = input.trim();
if (!text || loading || !canChat) return;
const userMsg: ChatMessage = {
id: `u-${Date.now()}`,
role: 'user',
content: text,
};
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
setLoading(true);
try {
const history: ChatHistoryItem[] = newMessages
.filter((m) => m.id !== 'welcome')
.map((m) => ({
role: m.role,
content: m.content,
}));
const resp = await aiChatApi.sendMessage(text, history, patientId ?? undefined);
setMessages((prev) => [
...prev,
{
id: resp.message_id,
role: 'assistant',
content: resp.reply,
},
]);
} catch {
setMessages((prev) => [
...prev,
{
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉AI 服务暂时不可用,请稍后再试。',
},
]);
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClear = () => {
setMessages([
{
id: 'welcome',
role: 'assistant',
content: '对话已清空。有什么可以帮你的?',
},
]);
};
return (
<Drawer
title={
<Space>
<RobotOutlined style={{ color: token.colorPrimary }} />
<span>AI </span>
{patientId && (
<Tag color="blue" style={{ marginLeft: 8, fontSize: 11 }}>
</Tag>
)}
</Space>
}
placement="right"
width={400}
open={open}
onClose={onClose}
styles={{
body: { display: 'flex', flexDirection: 'column', padding: 0 },
}}
extra={
<Button
size="small"
icon={<DeleteOutlined />}
onClick={handleClear}
title="清空对话"
/>
}
>
{/* 消息列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px 16px',
background: token.colorBgLayout,
}}
>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: 12,
}}
>
<div
style={{
maxWidth: '85%',
padding: '8px 12px',
borderRadius: 12,
fontSize: 14,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background:
msg.role === 'user'
? token.colorPrimary
: token.colorBgContainer,
color:
msg.role === 'user'
? token.colorTextLightSolid
: token.colorText,
borderBottomRightRadius: msg.role === 'user' ? 4 : 12,
borderBottomLeftRadius: msg.role === 'assistant' ? 4 : 12,
}}
>
{msg.content}
</div>
</div>
))}
{loading && (
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 12 }}>
<div
style={{
padding: '8px 16px',
borderRadius: 12,
borderBottomLeftRadius: 4,
background: token.colorBgContainer,
}}
>
<Spin size="small" /> <Text type="secondary">...</Text>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div
style={{
padding: 12,
borderTop: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
}}
>
<Space.Compact style={{ width: '100%' }}>
<TextArea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
canChat ? '输入消息... (Enter 发送, Shift+Enter 换行)' : '无 AI 聊天权限'
}
disabled={loading || !canChat}
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ borderRadius: '8px 0 0 8px' }}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={loading}
disabled={!input.trim() || !canChat}
style={{ height: 'auto', borderRadius: '0 8px 8px 0', minHeight: 40 }}
/>
</Space.Compact>
{!canChat && (
<Text type="warning" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
AI
</Text>
)}
</div>
</Drawer>
);
}

View File

@@ -8,6 +8,7 @@ import {
AppstoreOutlined,
RightOutlined,
UserOutlined,
RobotOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
@@ -18,6 +19,7 @@ import { getMenusForUser, type MenuInfo } from '../api/menus';
import { getIcon } from '../utils/iconRegistry';
import NotificationPanel from '../components/NotificationPanel';
import ThemeSwitcher from '../components/ThemeSwitcher';
import AiSidebar from '../components/ai/AiSidebar';
const { Header, Sider, Content, Footer } = Layout;
@@ -311,6 +313,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
// 动态菜单状态
const [dynamicMenus, setDynamicMenus] = useState<MenuInfo[]>([]);
const [menuLoading, setMenuLoading] = useState(true);
const [aiSidebarOpen, setAiSidebarOpen] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -504,6 +507,40 @@ export default function MainLayout({ children }: { children: React.ReactNode })
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
</Footer>
</Layout>
{/* AI 助手浮动按钮 + 侧边栏 */}
<Tooltip title="AI 健康助手" placement="left">
<div
onClick={() => setAiSidebarOpen(true)}
style={{
position: 'fixed',
right: 24,
bottom: 32,
width: 48,
height: 48,
borderRadius: '50%',
background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(22, 119, 255, 0.4)',
zIndex: 1000,
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.1)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(22, 119, 255, 0.6)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(22, 119, 255, 0.4)';
}}
>
<RobotOutlined style={{ color: '#fff', fontSize: 22 }} />
</div>
</Tooltip>
<AiSidebar open={aiSidebarOpen} onClose={() => setAiSidebarOpen(false)} />
</Layout>
);
}