feat: 医生端Web后台 + SignalR实时问诊
- 新增 doctor_web/ React前端 (dashboard/患者/问诊/报告/随访) - 后端新增 doctor_endpoints (14个医生API) + ConsultationHub (SignalR) - Flutter端 SignalR 替换轮询实现实时聊天
This commit is contained in:
167
doctor_web/src/pages/consultations/ChatPage.tsx
Normal file
167
doctor_web/src/pages/consultations/ChatPage.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { api } from '../../services/api-client';
|
||||
import { getConnection, startConnection } from '../../services/signalr';
|
||||
import type { ConsultationMessage } from '../../types';
|
||||
|
||||
export default function ChatPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [messages, setMessages] = useState<ConsultationMessage[]>([]);
|
||||
const [status, setStatus] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [connected, setConnected] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 加载历史消息
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api.get<{ status: string; messages: ConsultationMessage[] }>(`/api/doctor/consultations/${id}/messages`)
|
||||
.then(data => {
|
||||
setMessages(data.messages);
|
||||
setStatus(data.status);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
// 建立 SignalR 连接
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let disposed = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const conn = getConnection();
|
||||
await startConnection();
|
||||
if (disposed) return;
|
||||
|
||||
// 加入问诊房间
|
||||
await conn.invoke('JoinConsultation', id);
|
||||
setConnected(true);
|
||||
|
||||
// 注册消息接收
|
||||
const handler = (msg: ConsultationMessage & { consultationId: string }) => {
|
||||
if (msg.consultationId !== id) return;
|
||||
setMessages(prev => {
|
||||
if (prev.some(m => m.id === msg.id)) return prev; // 去重
|
||||
return [...prev, msg];
|
||||
});
|
||||
};
|
||||
conn.on('ReceiveMessage', handler);
|
||||
|
||||
// 重连时重新加入房间
|
||||
conn.onreconnected(() => {
|
||||
conn.invoke('JoinConsultation', id);
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
const conn = getConnection();
|
||||
if (conn.state === 'Connected') {
|
||||
conn.invoke('LeaveConsultation', id).catch(() => {});
|
||||
conn.off('ReceiveMessage');
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// 自动滚到底部
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
async function sendMessage() {
|
||||
if (!text.trim() || !id) return;
|
||||
const content = text.trim();
|
||||
setText('');
|
||||
|
||||
// 通过 HTTP 发送(后端会通过 SignalR 广播)
|
||||
try {
|
||||
await api.post(`/api/doctor/consultations/${id}/messages`, { content });
|
||||
} catch {
|
||||
// fallback: 直接通过 SignalR 发送
|
||||
try {
|
||||
await getConnection().invoke('SendMessage', id, 'Doctor', '医生 · 王建国', content);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
{/* 顶栏 */}
|
||||
<div style={{ background: '#FFF', padding: '16px 24px', borderBottom: '1px solid #EDF0F7', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>问诊对话</div>
|
||||
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 2 }}>
|
||||
{status === 'AiTalking' ? 'AI 分身对话中' : status === 'WaitingDoctor' ? '等待医生回复' : status === 'DoctorReplied' ? '医生已回复' : status === 'Closed' ? '已结束' : status}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: 4, background: connected ? '#20C997' : '#EF4444' }} />
|
||||
<span style={{ fontSize: 12, color: '#9BA0B4' }}>{connected ? '已连接' : '未连接'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
|
||||
{messages.map((msg, i) => {
|
||||
const isDoctor = msg.senderType === 'Doctor';
|
||||
const isAi = msg.senderType === 'Ai';
|
||||
return (
|
||||
<div key={msg.id || i} style={{ display: 'flex', flexDirection: 'column', alignItems: isDoctor ? 'flex-end' : 'flex-start', marginBottom: 16 }}>
|
||||
{/* 发送者名称 */}
|
||||
{!isDoctor && msg.senderName && (
|
||||
<div style={{ fontSize: 11, color: '#9BA0B4', marginBottom: 4, marginLeft: 8 }}>{msg.senderName}</div>
|
||||
)}
|
||||
{/* 气泡 */}
|
||||
<div style={{
|
||||
maxWidth: '70%',
|
||||
padding: '12px 18px',
|
||||
borderRadius: isDoctor ? '18px 4px 18px 18px' : '4px 18px 18px 18px',
|
||||
background: isDoctor ? '#4F6EF7' : isAi ? '#F5F5F5' : '#FFF',
|
||||
color: isDoctor ? '#FFF' : '#1A1D28',
|
||||
border: isDoctor ? 'none' : '1px solid #EDF0F7',
|
||||
fontSize: 15,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{msg.content}
|
||||
</div>
|
||||
{/* AI 标记 */}
|
||||
{isAi && (
|
||||
<div style={{ fontSize: 10, color: '#F9A825', marginTop: 4, marginLeft: 8, background: '#FFF8E1', padding: '2px 8px', borderRadius: 6 }}>
|
||||
AI 分析,仅供参考
|
||||
</div>
|
||||
)}
|
||||
{/* 时间 */}
|
||||
<div style={{ fontSize: 11, color: '#CCC', marginTop: 4, marginLeft: 8, marginRight: 8 }}>
|
||||
{new Date(msg.createdAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* 输入栏 */}
|
||||
<div style={{ background: '#FFF', padding: '14px 24px', borderTop: '1px solid #EDF0F7' }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<input
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="输入回复..."
|
||||
style={{ flex: 1, padding: '12px 18px', border: '1px solid #E1E5ED', borderRadius: 12, fontSize: 15, outline: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!text.trim()}
|
||||
style={{ padding: '12px 24px', background: text.trim() ? '#4F6EF7' : '#E1E5ED', color: text.trim() ? '#FFF' : '#9BA0B4', border: 'none', borderRadius: 12, fontSize: 15, fontWeight: 600 }}>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user