feat: add SignalR real-time chat to doctor and patient frontends

This commit is contained in:
MingNian
2026-05-22 14:51:02 +08:00
parent 90615a6cb3
commit 9d384dc6fb
6 changed files with 496 additions and 41 deletions

View File

@@ -1,27 +1,32 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
import { api } from '@/services/api-client';
import * as consultationService from '@/services/consultation.service';
import type { Consultation, ConsultationMessage, Doctor } from '@/types';
import { formatRelative } from '@/utils/format';
import styles from './ChatPage.module.css';
function getToken(): string {
try {
const raw = localStorage.getItem('hrt_auth');
if (!raw) return '';
const state = JSON.parse(raw);
return state?.state?.token ?? '';
} catch { return ''; }
}
export function ChatPage() {
const [doctor, setDoctor] = useState<Doctor | null>(null);
const [consultation, setConsultation] = useState<Consultation | null>(null);
const [messages, setMessages] = useState<ConsultationMessage[]>([]);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [connected, setConnected] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const connRef = useRef<HubConnection | null>(null);
const initRef = useRef(false);
const loadMessages = useCallback((cid: string) => {
api.get<ConsultationMessage[]>(`/api/consultations/${cid}/messages`)
.then((res) => setMessages(res.data))
.catch(() => {});
}, []);
// Init once - prevent Strict Mode double-fire
// Init consultation once
useEffect(() => {
if (initRef.current) return;
initRef.current = true;
@@ -30,52 +35,93 @@ export function ChatPage() {
if (docs.length > 0) {
const doc = docs[0];
setDoctor(doc);
// Find existing active consultation first
api.get<Consultation[]>('/api/consultations').then((res) => {
const existing = (res.data as Record<string, unknown>[]).find(
(c) => c.doctorId === doc.id && c.status === 'active'
);
if (existing) {
setConsultation(existing as unknown as Consultation);
loadMessages((existing as Record<string, string>).id);
} else {
// Create new only if none exists
consultationService.startConsultation(doc.id, '在线咨询').then((c) => {
setConsultation(c);
loadMessages(c.id);
});
}
});
}
});
}, [loadMessages]);
}, []);
// Load initial messages + set up SignalR when consultation is ready
useEffect(() => {
if (!consultation?.id) return;
// Load message history
api.get<ConsultationMessage[]>(`/api/consultations/${consultation.id}/messages`)
.then((res) => setMessages(res.data))
.catch(() => {});
// Set up SignalR connection
const conn = new HubConnectionBuilder()
.withUrl('http://localhost:5000/hubs/chat', {
accessTokenFactory: () => getToken(),
})
.withAutomaticReconnect()
.build();
conn.on('ReceiveMessage', (msg: ConsultationMessage) => {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
});
conn.onreconnected(() => {
conn.invoke('JoinConsultation', consultation.id).catch(() => {});
});
conn.start()
.then(() => {
setConnected(true);
return conn.invoke('JoinConsultation', consultation.id);
})
.catch(() => {});
connRef.current = conn;
return () => {
if (conn.state === HubConnectionState.Connected) {
conn.invoke('LeaveConsultation', consultation.id).catch(() => {});
}
conn.stop();
};
}, [consultation?.id]);
// Auto-scroll on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Poll every 3s
useEffect(() => {
if (!consultation?.id) return;
const timer = setInterval(() => loadMessages(consultation.id), 3000);
return () => clearInterval(timer);
}, [consultation?.id, loadMessages]);
const handleSend = async () => {
if (!text.trim() || !consultation || sending) return;
setSending(true);
const handleSend = useCallback(async () => {
if (!text.trim() || !consultation?.id || !connRef.current) return;
const msgText = text;
setText('');
try {
const sent = await consultationService.sendMessage(consultation.id, msgText);
setMessages((prev) => [...prev, sent]);
await connRef.current.invoke('SendMessage', consultation.id, msgText);
} catch { /* ignore */ }
setSending(false);
};
}, [text, consultation?.id]);
return (
<div className={styles.page}>
<PageHeader title={doctor?.name || '在线问诊'} />
<PageHeader
title={doctor?.name || '在线问诊'}
rightAction={
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: connected ? '#4caf50' : '#ccc',
marginLeft: 8,
}} />
}
/>
<div className={styles.messages}>
{messages.length === 0 && (
<div style={{ textAlign: 'center', color: '#9CA3AF', marginTop: 40, fontSize: 14 }}>
@@ -93,7 +139,7 @@ export function ChatPage() {
<div className={styles.inputBar}>
<input className={styles.input} value={text} onChange={(e) => setText(e.target.value)}
placeholder="输入消息..." onKeyDown={(e) => e.key === 'Enter' && handleSend()} />
<button className={styles.sendBtn} onClick={handleSend} disabled={sending}></button>
<button className={styles.sendBtn} onClick={handleSend}></button>
</div>
</div>
);