Files
AI-Health/doctor_web/src/pages/consultations/ChatPage.tsx
MingNian d0d7d8428d feat: 医生端Web后台 + SignalR实时问诊
- 新增 doctor_web/ React前端 (dashboard/患者/问诊/报告/随访)
- 后端新增 doctor_endpoints (14个医生API) + ConsultationHub (SignalR)
- Flutter端 SignalR 替换轮询实现实时聊天
2026-06-05 17:46:07 +08:00

168 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}