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:
27
apps/web/src/api/ai/chat.ts
Normal file
27
apps/web/src/api/ai/chat.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
255
apps/web/src/components/ai/AiSidebar.tsx
Normal file
255
apps/web/src/components/ai/AiSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
RobotOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAppStore } from '../stores/app';
|
import { useAppStore } from '../stores/app';
|
||||||
@@ -18,6 +19,7 @@ import { getMenusForUser, type MenuInfo } from '../api/menus';
|
|||||||
import { getIcon } from '../utils/iconRegistry';
|
import { getIcon } from '../utils/iconRegistry';
|
||||||
import NotificationPanel from '../components/NotificationPanel';
|
import NotificationPanel from '../components/NotificationPanel';
|
||||||
import ThemeSwitcher from '../components/ThemeSwitcher';
|
import ThemeSwitcher from '../components/ThemeSwitcher';
|
||||||
|
import AiSidebar from '../components/ai/AiSidebar';
|
||||||
|
|
||||||
const { Header, Sider, Content, Footer } = Layout;
|
const { Header, Sider, Content, Footer } = Layout;
|
||||||
|
|
||||||
@@ -311,6 +313,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
|||||||
// 动态菜单状态
|
// 动态菜单状态
|
||||||
const [dynamicMenus, setDynamicMenus] = useState<MenuInfo[]>([]);
|
const [dynamicMenus, setDynamicMenus] = useState<MenuInfo[]>([]);
|
||||||
const [menuLoading, setMenuLoading] = useState(true);
|
const [menuLoading, setMenuLoading] = useState(true);
|
||||||
|
const [aiSidebarOpen, setAiSidebarOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -504,6 +507,40 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
|||||||
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
|
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
|
||||||
</Footer>
|
</Footer>
|
||||||
</Layout>
|
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user