fix: chat history persists, reuse active consultation, load messages properly
This commit is contained in:
@@ -1,12 +1,10 @@
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { NAV_ITEMS } from '@/utils/constants';
|
import { NAV_ITEMS } from '@/utils/constants';
|
||||||
import { useNotificationStore } from '@/stores/notification.store';
|
|
||||||
import styles from './TabBar.module.css';
|
import styles from './TabBar.module.css';
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const unreadCount = useNotificationStore((s) => s.unreadCount);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.tabBar}>
|
<nav className={styles.tabBar}>
|
||||||
@@ -18,12 +16,7 @@ export function TabBar() {
|
|||||||
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
|
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={() => navigate(item.path)}
|
||||||
>
|
>
|
||||||
<span className={styles.tabIcon}>
|
<span className={styles.tabIcon}>{item.icon}</span>
|
||||||
{item.icon}
|
|
||||||
{item.path === '/services' && unreadCount > 0 && (
|
|
||||||
<span className={styles.badge}>{unreadCount > 99 ? '99+' : unreadCount}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.tabLabel}>{item.label}</span>
|
<span className={styles.tabLabel}>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
.greetingBar {
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greetingText {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.notifyBtn {
|
.notifyBtn {
|
||||||
position: relative;
|
position: relative;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -12,12 +25,30 @@
|
|||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notifyBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #EF4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.overviewCard {
|
.overviewCard {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
background: linear-gradient(135deg, #2563EB 0%, #3B82F6 40%, #5B9AFF 100%);
|
background: linear-gradient(135deg, #2563EB 0%, #3B82F6 40%, #5B9AFF 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: 20px;
|
padding: 16px 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +56,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overviewTitle {
|
.overviewTitle {
|
||||||
@@ -42,56 +73,60 @@
|
|||||||
.overviewData {
|
.overviewData {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpSection,
|
.bpSection,
|
||||||
.hrSection {
|
.hrSection {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataLabel {
|
.dataLabel {
|
||||||
font-size: var(--font-size-xs);
|
font-size: 10px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpValues {
|
.bpValues {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpNum {
|
.bpNum {
|
||||||
font-size: 36px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpSep {
|
.bpSep {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-md);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hrNum {
|
.hrNum {
|
||||||
font-size: 36px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit {
|
.unit {
|
||||||
font-size: var(--font-size-xs);
|
font-size: 10px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 60px;
|
height: 48px;
|
||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255,255,255,0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Quick Actions */
|
/* Quick Actions */
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import type { HealthStats } from '@/types';
|
|||||||
import styles from './HomePage.module.css';
|
import styles from './HomePage.module.css';
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ key: 'bp', label: '血压', icon: '💓', path: '/health/records?type=blood_pressure', badge: false },
|
{ key: 'bp', label: '血压', icon: '💓', path: '/health/records?type=blood_pressure' },
|
||||||
{ key: 'med', label: '用药', icon: '💊', path: '/health/medications', badge: false },
|
{ key: 'med', label: '用药', icon: '💊', path: '/health/medications' },
|
||||||
{ key: 'chat', label: '问诊', icon: '💬', path: '/services/consultation', badge: true },
|
{ key: 'chat', label: '问诊', icon: '💬', path: '/services/consultation' },
|
||||||
{ key: 'report', label: '报告', icon: '📋', path: '/services/reports', badge: true },
|
{ key: 'report', label: '报告', icon: '📋', path: '/services/reports' },
|
||||||
{ key: 'calendar', label: '日历', icon: '📅', path: '/health/calendar', badge: false },
|
{ key: 'calendar', label: '日历', icon: '📅', path: '/health/calendar' },
|
||||||
{ key: 'followup', label: '复查', icon: '🏥', path: '/services/follow-ups', badge: false },
|
{ key: 'followup', label: '复查', icon: '🏥', path: '/services/follow-ups' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
@@ -39,13 +39,11 @@ export function HomePage() {
|
|||||||
const riskLevel = systolic && diastolic ? getBPRiskLevel(systolic, diastolic) : null;
|
const riskLevel = systolic && diastolic ? getBPRiskLevel(systolic, diastolic) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page" style={{ paddingTop: 0 }}>
|
||||||
<div style={{ padding: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div className={styles.greetingBar}>
|
||||||
<div>
|
<div className={styles.greetingText}>你好,{user?.nickname || '用户'}</div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#1A1E2B' }}>你好,{user?.nickname || '用户'}</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => navigate('/notifications')} className={styles.notifyBtn}>
|
<button onClick={() => navigate('/notifications')} className={styles.notifyBtn}>
|
||||||
🔔{unreadCount > 0 && <span style={{ position: 'absolute', top: -2, right: -2, width: 16, height: 16, borderRadius: 8, background: '#EF4444', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600 }}>{unreadCount}</span>}
|
🔔{unreadCount > 0 && <span className={styles.notifyBadge}>{unreadCount > 99 ? '99+' : unreadCount}</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,16 +94,7 @@ export function HomePage() {
|
|||||||
<div className={styles.quickActions}>
|
<div className={styles.quickActions}>
|
||||||
{QUICK_ACTIONS.map((action) => (
|
{QUICK_ACTIONS.map((action) => (
|
||||||
<button key={action.key} className={styles.quickAction} onClick={() => navigate(action.path)}>
|
<button key={action.key} className={styles.quickAction} onClick={() => navigate(action.path)}>
|
||||||
<span className={styles.quickIcon}>
|
<span className={styles.quickIcon}>{action.icon}</span>
|
||||||
{action.icon}
|
|
||||||
{action.badge && unreadCount > 0 && (
|
|
||||||
<span style={{
|
|
||||||
position: 'absolute', top: -4, right: -8, minWidth: 18, height: 18,
|
|
||||||
borderRadius: 9, background: '#EF4444', color: '#fff', fontSize: 10,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600,
|
|
||||||
}}>{unreadCount}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.quickLabel}>{action.label}</span>
|
<span className={styles.quickLabel}>{action.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
|
import { api } from '@/services/api-client';
|
||||||
import * as consultationService from '@/services/consultation.service';
|
import * as consultationService from '@/services/consultation.service';
|
||||||
import type { Consultation, ConsultationMessage, Doctor } from '@/types';
|
import type { Consultation, ConsultationMessage, Doctor } from '@/types';
|
||||||
import { formatRelative } from '@/utils/format';
|
import { formatRelative } from '@/utils/format';
|
||||||
@@ -13,51 +14,45 @@ export function ChatPage() {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const loadMessages = useCallback((cid: string) => {
|
||||||
|
api.get<ConsultationMessage[]>(`/api/consultations/${cid}/messages`)
|
||||||
|
.then((res) => setMessages(res.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get the first available doctor
|
|
||||||
consultationService.getDoctors().then((docs) => {
|
consultationService.getDoctors().then((docs) => {
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
const doc = docs[0];
|
const doc = docs[0];
|
||||||
setDoctor(doc);
|
setDoctor(doc);
|
||||||
// Find or create consultation
|
consultationService.startConsultation(doc.id, '在线咨询').then((c) => {
|
||||||
consultationService.getConsultation(doc.id).then(async (c) => {
|
|
||||||
if (c) {
|
|
||||||
setConsultation(c);
|
setConsultation(c);
|
||||||
loadMessages(c.id);
|
loadMessages(c.id);
|
||||||
} else {
|
|
||||||
const newC = await consultationService.startConsultation(doc.id, '在线咨询');
|
|
||||||
setConsultation(newC);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [loadMessages]);
|
||||||
|
|
||||||
const loadMessages = (cid: string) => {
|
|
||||||
import('@/services/api-client').then(({ api }) => {
|
|
||||||
api.get<ConsultationMessage[]>(`/api/consultations/${cid}/messages`)
|
|
||||||
.then((res) => setMessages(res.data));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
// Poll every 3s
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Poll for new messages every 3 seconds
|
|
||||||
if (!consultation?.id) return;
|
if (!consultation?.id) return;
|
||||||
const timer = setInterval(() => loadMessages(consultation.id), 3000);
|
const timer = setInterval(() => loadMessages(consultation.id), 3000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [consultation?.id]);
|
}, [consultation?.id, loadMessages]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!text.trim() || !consultation || sending) return;
|
if (!text.trim() || !consultation || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
const msgText = text;
|
const msgText = text;
|
||||||
setText('');
|
setText('');
|
||||||
|
try {
|
||||||
const sent = await consultationService.sendMessage(consultation.id, msgText);
|
const sent = await consultationService.sendMessage(consultation.id, msgText);
|
||||||
setMessages((prev) => [...prev, sent]);
|
setMessages((prev) => [...prev, sent]);
|
||||||
|
} catch { /* ignore */ }
|
||||||
setSending(false);
|
setSending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,10 +66,7 @@ export function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div
|
<div key={msg.id} className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}>
|
||||||
key={msg.id}
|
|
||||||
className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}
|
|
||||||
>
|
|
||||||
<div className={styles.bubbleContent}>{msg.content}</div>
|
<div className={styles.bubbleContent}>{msg.content}</div>
|
||||||
<div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div>
|
<div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,16 +74,9 @@ export function ChatPage() {
|
|||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.inputBar}>
|
<div className={styles.inputBar}>
|
||||||
<input
|
<input className={styles.input} value={text} onChange={(e) => setText(e.target.value)}
|
||||||
className={styles.input}
|
placeholder="输入消息..." onKeyDown={(e) => e.key === 'Enter' && handleSend()} />
|
||||||
value={text}
|
<button className={styles.sendBtn} onClick={handleSend} disabled={sending}>↑</button>
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
placeholder="输入消息..."
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
|
||||||
/>
|
|
||||||
<button className={styles.sendBtn} onClick={handleSend} disabled={sending}>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ export async function getConsultation(doctorId: string): Promise<Consultation |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startConsultation(doctorId: string, subject?: string): Promise<Consultation> {
|
export async function startConsultation(doctorId: string, subject?: string): Promise<Consultation> {
|
||||||
const existing = await getConsultation(doctorId);
|
// Reuse existing active consultation
|
||||||
|
const allRes = await api.get<RawConsultation[]>('/api/consultations');
|
||||||
|
const existing = allRes.data.find((c: RawConsultation) => c.doctorId === doctorId && c.status === 'active');
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const res = await api.post<RawConsultation>('/api/consultations', {
|
const res = await api.post<RawConsultation>('/api/consultations', {
|
||||||
|
|||||||
Reference in New Issue
Block a user