- 新增 doctor_web/ React前端 (dashboard/患者/问诊/报告/随访) - 后端新增 doctor_endpoints (14个医生API) + ConsultationHub (SignalR) - Flutter端 SignalR 替换轮询实现实时聊天
168 lines
6.3 KiB
TypeScript
168 lines
6.3 KiB
TypeScript
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>
|
||
);
|
||
}
|