feat(ai): 新增 AI 客服聊天功能 + 消息页重构为小华助手

- 新增 POST /ai/chat 端点,由 LLM(Ollama qwen3)担任 24h 健康客服"小华"
- 新增 ai.chat.send 权限,绑定管理员/患者/医生/护士/健康管理师角色
- 消息页从咨询列表重构为单窗口 AI 对话(欢迎态 + 聊天态 + 快捷问诊)
- 通知功能迁移到"我的"页面菜单项(带未读角标),独立通知列表页
- 修复气泡文字截断:改用百分比 max-width + block Text + pre-wrap 换行
- 修复权限绑定:迁移 SQL 角色名从英文改为中文(admin→管理员,patient→患者)
This commit is contained in:
iven
2026-05-17 00:49:41 +08:00
parent 4be28de3ce
commit 710b2e2423
14 changed files with 952 additions and 439 deletions

View File

@@ -48,7 +48,7 @@ export default defineAppConfig({
'dialysis-records/index', 'dialysis-records/detail/index',
'dialysis-prescriptions/index', 'dialysis-prescriptions/detail/index',
'consents/index', 'health-records/index', 'diagnoses/index',
'elder-mode/index', 'events/index',
'elder-mode/index', 'events/index', 'notifications/index',
],
},
{

View File

@@ -1,270 +1,300 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.messages-page {
// PageShell 接管 min-height, background
padding: var(--tk-section-gap) var(--tk-page-padding) var(--tk-tabbar-space);
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
.ai-chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background: $bg;
}
/* ─── 页头 ─── */
.messages-header {
margin-bottom: var(--tk-section-gap);
/* ─── 导航栏 ─── */
.ai-chat-nav {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 20px 12px;
background: $card;
border-bottom: 1px solid $bd-l;
flex-shrink: 0;
}
.messages-title {
@include serif-number;
font-size: var(--tk-font-h1);
.ai-chat-nav__title-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.ai-chat-nav__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 17px;
font-weight: 700;
color: $tx;
}
/* ─── 分段控件 Tab ─── */
.msg-segment {
.ai-chat-nav__online {
display: flex;
gap: 0;
background: $surface-alt;
border-radius: $r-sm;
padding: 3px;
margin-bottom: var(--tk-gap-sm);
align-items: center;
gap: 4px;
margin-top: 2px;
}
.msg-segment-tab {
.ai-chat-nav__dot {
width: 6px;
height: 6px;
border-radius: 3px;
background: $acc;
}
.ai-chat-nav__online-text {
font-size: 11px;
color: $acc;
}
/* ─── 欢迎状态 ─── */
.ai-chat-welcome {
flex: 1;
height: 48px;
border-radius: $r-xs;
@include flex-center;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 20px;
}
.ai-chat-welcome__avatar {
width: 72px;
height: 72px;
border-radius: 36px;
background: linear-gradient(135deg, $pri, $pri-d);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(196, 98, 58, 0.25);
}
.ai-chat-welcome__avatar-char {
color: $white;
font-size: 32px;
font-weight: 600;
font-family: Georgia, 'Times New Roman', serif;
}
.ai-chat-welcome__greeting {
font-size: 17px;
font-weight: 600;
color: $tx;
margin-top: 16px;
}
.ai-chat-welcome__desc {
font-size: 13px;
color: $tx3;
text-align: center;
margin-top: 6px;
line-height: 1.6;
white-space: pre-line;
}
.ai-chat-welcome__divider {
width: 32px;
height: 1px;
background: $bd;
margin: 20px 0 16px;
}
.ai-chat-welcome__hint {
font-size: 12px;
color: $tx3;
margin-bottom: 12px;
}
.ai-chat-welcome__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 320px;
}
.ai-chat-welcome__pill {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: $card;
border-radius: $r;
border: 1px solid $bd-l;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.msg-segment-active {
background: $card;
box-shadow: $shadow-sm;
.msg-segment-text {
color: $tx;
}
.ai-chat-welcome__pill-icon {
font-size: 15px;
}
.msg-segment-text {
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx3;
}
.msg-segment-badge {
position: absolute;
top: 4px;
right: 12px;
min-width: 16px;
height: 16px;
border-radius: $r-xs;
background: $dan;
@include flex-center;
padding: 0 4px;
}
.msg-segment-badge-text {
font-size: var(--tk-font-micro);
color: $white;
font-weight: 600;
}
/* ─── 内容区 ─── */
.msg-content {
// wrapper
}
.msg-list {
display: flex;
flex-direction: column;
gap: var(--tk-gap-xs);
}
/* ─── 咨询卡片 ─── */
.consult-card {
display: flex;
gap: var(--tk-gap-sm);
align-items: center;
// ContentCard 接管 background, border-radius, padding, box-shadow, active feedback
}
.consult-card-muted {
opacity: 0.65;
}
.consult-avatar {
width: 48px;
height: 48px;
border-radius: $r-pill;
background: $surface-alt;
@include flex-center;
flex-shrink: 0;
}
.consult-avatar-active {
background: var(--tk-pri-l);
}
.consult-avatar-char {
@include serif-number;
font-size: var(--tk-font-body-sm);
font-weight: 700;
color: $tx3;
}
.consult-avatar-active .consult-avatar-char {
color: var(--tk-pri);
}
.consult-body {
flex: 1;
min-width: 0;
}
.consult-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-2xs);
&:last-child {
margin-bottom: 0;
}
}
.consult-doctor {
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx;
}
.consult-type-tag {
font-size: var(--tk-font-micro);
font-weight: 400;
color: $tx3;
margin-left: 6px;
}
.consult-time {
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
flex-shrink: 0;
}
.consult-preview {
font-size: var(--tk-font-cap);
.ai-chat-welcome__pill-text {
font-size: 13px;
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ─── 对话区域 ─── */
.ai-chat-body {
flex: 1;
margin-right: var(--tk-gap-xs);
width: 100%;
box-sizing: border-box;
padding: 16px;
}
.consult-badge {
min-width: 18px;
height: 18px;
border-radius: $r-pill;
background: $dan;
@include flex-center;
padding: 0 4px;
flex-shrink: 0;
}
.consult-badge-text {
font-size: var(--tk-font-micro);
color: $white;
font-weight: 600;
}
/* ─── 通知卡片 ─── */
.notify-card {
/* ─── 消息行 ─── */
.ai-msg {
display: flex;
gap: var(--tk-gap-sm);
align-items: flex-start;
// ContentCard 接管 background, border-radius, padding, box-shadow
margin-bottom: 8px;
width: 100%;
&--self {
justify-content: flex-end;
}
&--ai {
gap: 10px;
}
}
.notify-card-muted {
opacity: 0.65;
}
.notify-icon {
/* ─── AI 头像 ─── */
.ai-msg__avatar {
width: 36px;
height: 36px;
border-radius: $r-sm;
@include flex-center;
border-radius: 18px;
background: linear-gradient(135deg, $pri, $pri-d);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.notify-icon-char {
@include serif-number;
font-size: var(--tk-font-body-sm);
font-weight: 700;
}
.notify-type-appointment,
.notify-type-points {
background: var(--tk-pri-l);
color: var(--tk-pri);
}
.notify-type-alert {
background: $wrn-l;
color: $wrn;
}
.notify-type-followup,
.notify-type-report {
background: $acc-l;
color: $acc;
}
.notify-body {
flex: 1;
min-width: 0;
}
.notify-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-2xs);
}
.notify-title {
font-size: var(--tk-font-cap);
font-weight: 400;
color: $tx;
}
.notify-title-bold {
.ai-msg__avatar-char {
color: $white;
font-size: 15px;
font-weight: 600;
}
.notify-time {
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
flex-shrink: 0;
margin-left: var(--tk-gap-xs);
/* ─── 消息气泡 ─── */
.ai-msg__bubble {
max-width: 75%;
padding: 10px 14px;
box-sizing: border-box;
&--ai {
background: $card;
border-radius: 4px 16px 16px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
&--self {
background: $pri-l;
border-radius: 16px 4px 16px 16px;
max-width: 80%;
}
}
.notify-desc {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.5;
.ai-msg__text {
display: block;
width: 100%;
font-size: 15px;
color: $tx;
line-height: 1.6;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
}
.notify-dot {
width: 8px;
height: 8px;
border-radius: $r-xs;
background: var(--tk-pri);
flex-shrink: 0;
margin-top: var(--tk-gap-2xs);
/* ─── 打字指示器 ─── */
.ai-msg__typing {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 0;
}
.ai-msg__dot {
width: 6px;
height: 6px;
border-radius: 3px;
background: $tx3;
animation: ai-typing-pulse 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
@keyframes ai-typing-pulse {
0%, 80%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
/* ─── 底部输入栏 ─── */
.ai-chat-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
padding-bottom: calc(10px + env(safe-area-inset-bottom));
background: $card;
border-top: 1px solid $bd-l;
flex-shrink: 0;
}
.ai-chat-bar__input {
flex: 1;
height: 40px;
background: $surface-alt;
border: none;
border-radius: 20px;
padding: 0 14px;
font-size: 14px;
color: $tx;
}
.ai-chat-bar__placeholder {
color: $tx3;
font-size: 14px;
}
.ai-chat-bar__send {
width: 40px;
height: 40px;
border-radius: 20px;
background: $pri;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--disabled {
opacity: 0.5;
}
&:active:not(&--disabled) {
opacity: var(--tk-touch-feedback-opacity);
}
}
.ai-chat-bar__send-icon {
color: $white;
font-size: 20px;
font-weight: 700;
}

View File

@@ -1,242 +1,210 @@
import { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReachBottom } from '@tarojs/taro';
import { listConsultations, ConsultationSession } from '../../services/consultation';
import { notificationService } from '../../services/notification';
import Loading from '../../components/Loading';
import ErrorState from '../../components/ErrorState';
import EmptyState from '../../components/EmptyState';
import GuestGuard from '../../components/GuestGuard';
import { useAuthStore } from '../../stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import { useState, useRef, useEffect } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import {
sendAiMessage,
getLocalHistory,
saveLocalHistory,
type AiChatMessage,
} from '@/services/ai-chat';
import { useElderClass } from '@/hooks/useElderClass';
import { usePageData } from '@/hooks/usePageData';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import GuestGuard from '@/components/GuestGuard';
import { useAuthStore } from '@/stores/auth';
import './index.scss';
type MsgTab = 'consultation' | 'notification';
const QUICK_ACTIONS = [
{ icon: '📋', label: '查看报告' },
{ icon: '💊', label: '用药咨询' },
{ icon: '📅', label: '预约挂号' },
{ icon: '🔔', label: '健康提醒' },
];
interface NotificationItem {
id: string;
title: string;
desc: string;
time: string;
type: string;
read?: boolean;
function genId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
const NOTIFY_ICONS: Record<string, { icon: string; cls: string }> = {
appointment: { icon: '约', cls: 'notify-type-appointment' },
alert: { icon: '警', cls: 'notify-type-alert' },
followup: { icon: '随', cls: 'notify-type-followup' },
points: { icon: '分', cls: 'notify-type-points' },
report: { icon: '报', cls: 'notify-type-report' },
};
export default function Messages() {
const user = useAuthStore((s) => s.user);
const modeClass = useElderClass();
const [activeTab, setActiveTab] = useState<MsgTab>('consultation');
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const loadData = useCallback(async (tab: MsgTab, pageNum: number = 1, isRefresh = false) => {
setLoading(true);
setError(false);
try {
if (tab === 'consultation') {
const res = await listConsultations({ page: pageNum, page_size: 20 });
const list = res.data || [];
if (isRefresh) {
setSessions(list);
} else {
setSessions((prev) => [...prev, ...list]);
}
setTotal(res.total || 0);
} else {
const res = await notificationService.list<{ data: unknown[]; total?: number }>({ page: pageNum, page_size: 20 });
const list = (res as { data?: unknown[] })?.data || [];
if (isRefresh) {
setNotifications(list as NotificationItem[]);
} else {
setNotifications((prev) => [...prev, ...(list as NotificationItem[])]);
}
setTotal((res as { total?: number })?.total || 0);
}
setPage(pageNum);
} catch {
setError(true);
if (isRefresh) {
if (tab === 'consultation') setSessions([]);
else setNotifications([]);
}
Taro.showToast({ title: '加载失败,下拉重试', icon: 'none' });
} finally {
setLoading(false);
}
}, []);
const [messages, setMessages] = useState<AiChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const messagesEndRef = useRef('');
const sendingRef = useRef(false);
usePageData(
useCallback(async () => {
if (user) await loadData(activeTab, 1, true);
}, [user, activeTab, loadData]),
{ throttleMs: 5000, enablePullDown: false },
async () => {
const history = getLocalHistory();
setMessages(history);
if (history.length > 0) {
messagesEndRef.current = `msg-${history.length}`;
}
setLoading(false);
},
{ throttleMs: 30000, enablePullDown: false },
);
const handleTabChange = (tab: MsgTab) => {
setActiveTab(tab);
loadData(tab, 1, true);
const scrollToBottom = (list: AiChatMessage[]) => {
messagesEndRef.current = `msg-${list.length}`;
};
useReachBottom(() => {
const currentList = activeTab === 'consultation' ? sessions : notifications;
if (!loading && currentList.length < total) {
loadData(activeTab, page + 1);
const handleSend = async (text?: string) => {
const content = (text || inputText).trim();
if (!content || sendingRef.current) return;
sendingRef.current = true;
setSending(true);
setInputText('');
const userMsg: AiChatMessage = {
id: genId(),
role: 'user',
content,
created_at: new Date().toISOString(),
};
const next = [...messages, userMsg];
setMessages(next);
scrollToBottom(next);
try {
const resp = await sendAiMessage(content, next);
const aiMsg: AiChatMessage = {
id: resp.message_id || genId(),
role: 'assistant',
content: resp.reply,
created_at: new Date().toISOString(),
};
const updated = [...next, aiMsg];
setMessages(updated);
scrollToBottom(updated);
saveLocalHistory(updated);
} catch {
const errMsg: AiChatMessage = {
id: genId(),
role: 'assistant',
content: '抱歉,暂时无法回复,请稍后再试。',
created_at: new Date().toISOString(),
};
const updated = [...next, errMsg];
setMessages(updated);
scrollToBottom(updated);
Taro.showToast({ title: '发送失败', icon: 'none' });
} finally {
setSending(false);
sendingRef.current = false;
}
});
const formatTime = (dateStr: string | null) => {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 60) return `${diffMin} 分钟前`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour} 小时前`;
return dateStr.slice(0, 10);
};
const handleQuickAction = (label: string) => {
handleSend(label);
};
const isEmpty = messages.length === 0;
if (!user) {
return <GuestGuard title='请先登录' desc='登录后即可查看消息和通知' />;
return <GuestGuard title='请先登录' desc='登录后即可与 AI 健康助手交流' />;
}
const unreadConsultCount = sessions.filter((s) => s.unread_count_patient > 0).length;
return (
<PageShell safeBottom={false} scroll={false} className={`messages-page ${modeClass}`}>
{/* 页头 */}
<View className='messages-header'>
<Text className='messages-title'></Text>
<View className={`ai-chat-page ${modeClass}`}>
{/* 导航栏 */}
<View className='ai-chat-nav'>
<View className='ai-chat-nav__title-wrap'>
<Text className='ai-chat-nav__title'> · </Text>
<View className='ai-chat-nav__online'>
<View className='ai-chat-nav__dot' />
<Text className='ai-chat-nav__online-text'>24线</Text>
</View>
</View>
</View>
{/* 分段控件 Tab */}
<View className='msg-segment'>
<View
className={`msg-segment-tab ${activeTab === 'consultation' ? 'msg-segment-active' : ''}`}
onClick={() => handleTabChange('consultation')}
{loading ? null : isEmpty ? (
/* 欢迎状态 */
<View className='ai-chat-welcome'>
<View className='ai-chat-welcome__avatar'>
<Text className='ai-chat-welcome__avatar-char'></Text>
</View>
<Text className='ai-chat-welcome__greeting'></Text>
<Text className='ai-chat-welcome__desc'>
{'\n'}
</Text>
<View className='ai-chat-welcome__divider' />
<Text className='ai-chat-welcome__hint'></Text>
<View className='ai-chat-welcome__actions'>
{QUICK_ACTIONS.map((a) => (
<View
key={a.label}
className='ai-chat-welcome__pill'
onClick={() => handleQuickAction(a.label)}
>
<Text className='ai-chat-welcome__pill-icon'>{a.icon}</Text>
<Text className='ai-chat-welcome__pill-text'>{a.label}</Text>
</View>
))}
</View>
</View>
) : (
/* 对话区域 */
<ScrollView
scrollY
className='ai-chat-body'
scrollIntoView={messagesEndRef.current}
scrollWithAnimation
>
<Text className='msg-segment-text'></Text>
{unreadConsultCount > 0 && (
<View className='msg-segment-badge'>
<Text className='msg-segment-badge-text'>{unreadConsultCount}</Text>
{messages.map((msg, idx) => {
const isUser = msg.role === 'user';
return (
<View key={msg.id} id={`msg-${idx + 1}`} className={`ai-msg ${isUser ? 'ai-msg--self' : 'ai-msg--ai'}`}>
{!isUser && (
<View className='ai-msg__avatar'>
<Text className='ai-msg__avatar-char'></Text>
</View>
)}
<View className={`ai-msg__bubble ${isUser ? 'ai-msg__bubble--self' : 'ai-msg__bubble--ai'}`}>
<Text className='ai-msg__text'>{msg.content}</Text>
</View>
</View>
);
})}
{sending && (
<View className='ai-msg ai-msg--ai'>
<View className='ai-msg__avatar'>
<Text className='ai-msg__avatar-char'></Text>
</View>
<View className='ai-msg__bubble ai-msg__bubble--ai'>
<View className='ai-msg__typing'>
<View className='ai-msg__dot' />
<View className='ai-msg__dot' />
<View className='ai-msg__dot' />
</View>
</View>
</View>
)}
</View>
</ScrollView>
)}
{/* 输入栏 */}
<View className='ai-chat-bar'>
<Input
className='ai-chat-bar__input'
placeholder='输入您的问题...'
placeholderClass='ai-chat-bar__placeholder'
value={inputText}
onInput={(e) => setInputText(e.detail.value)}
confirmType='send'
onConfirm={() => handleSend()}
disabled={sending}
/>
<View
className={`msg-segment-tab ${activeTab === 'notification' ? 'msg-segment-active' : ''}`}
onClick={() => handleTabChange('notification')}
className={`ai-chat-bar__send ${(!inputText.trim() || sending) ? 'ai-chat-bar__send--disabled' : ''}`}
onClick={() => handleSend()}
>
<Text className='msg-segment-text'></Text>
<Text className='ai-chat-bar__send-icon'></Text>
</View>
</View>
<View className='msg-content'>
{error ? (
<ErrorState onRetry={() => loadData(activeTab, 1, true)} />
) : (
<>
{/* 咨询列表 */}
{activeTab === 'consultation' && (
loading ? (
<Loading />
) : sessions.length === 0 ? (
<EmptyState text='暂无咨询消息' />
) : (
<View className='msg-list'>
{sessions.map((session) => {
const displayName = session.doctor_name || '在线咨询';
const avatarChar = session.doctor_name?.charAt(0) || '咨';
const hasUnread = session.unread_count_patient > 0;
return (
<ContentCard
key={session.id}
onPress={() => Taro.navigateTo({ url: `/pages/pkg-consultation/detail/index?id=${session.id}` })}
padding="sm"
className={`consult-card ${hasUnread ? '' : 'consult-card-muted'}`}
>
<View className={`consult-avatar ${hasUnread ? 'consult-avatar-active' : ''}`}>
<Text className='consult-avatar-char'>{avatarChar}</Text>
</View>
<View className='consult-body'>
<View className='consult-row'>
<Text className='consult-doctor'>
{displayName}
{session.consultation_type && (
<Text className='consult-type-tag'>
{session.consultation_type === 'online' ? '在线' : '门诊'}
</Text>
)}
</Text>
<Text className='consult-time'>{formatTime(session.last_message_at)}</Text>
</View>
<View className='consult-row'>
<Text className='consult-preview'>
{session.last_message || session.subject || '暂无消息'}
</Text>
{hasUnread && (
<View className='consult-badge'>
<Text className='consult-badge-text'>
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
</Text>
</View>
)}
</View>
</View>
</ContentCard>
);
})}
</View>
)
)}
{/* 通知列表 */}
{activeTab === 'notification' && (
loading ? (
<Loading />
) : notifications.length === 0 ? (
<EmptyState text='暂无新通知' />
) : (
<View className='msg-list'>
{notifications.map((n) => {
const cfg = NOTIFY_ICONS[n.type] || NOTIFY_ICONS.report;
const isUnread = !n.read;
return (
<ContentCard key={n.id} padding="sm" activeFeedback="none" className={`notify-card ${isUnread ? '' : 'notify-card-muted'}`}>
<View className={`notify-icon ${cfg.cls}`}>
<Text className={`notify-icon-char ${cfg.cls}`}>{cfg.icon}</Text>
</View>
<View className='notify-body'>
<View className='notify-row'>
<Text className={`notify-title ${isUnread ? 'notify-title-bold' : ''}`}>{n.title}</Text>
<Text className='notify-time'>{n.time}</Text>
</View>
<Text className='notify-desc'>{n.desc}</Text>
</View>
{isUnread && <View className='notify-dot' />}
</ContentCard>
);
})}
</View>
)
)}
</>
)}
</View>
</PageShell>
</View>
);
}

View File

@@ -98,7 +98,7 @@
/* ─── 消息区域 ─── */
.chat-body {
flex: 1;
padding: 16px 20px;
padding: 16px 24px;
}
/* ─── 日期分隔 ─── */
@@ -151,7 +151,7 @@
/* ─── 消息气泡 ─── */
.chat-msg__bubble {
max-width: 72%;
max-width: 260px;
padding: 10px 14px;
&--doctor {
@@ -170,7 +170,9 @@
font-size: 15px;
color: $tx;
line-height: 1.6;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
}
.chat-msg__image {

View File

@@ -0,0 +1,91 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.notify-list {
display: flex;
flex-direction: column;
gap: var(--tk-gap-xs);
}
.notify-muted {
opacity: 0.65;
}
.notify-item {
display: flex;
gap: var(--tk-gap-sm);
align-items: flex-start;
}
.notify-icon {
width: 36px;
height: 36px;
border-radius: $r-sm;
@include flex-center;
flex-shrink: 0;
}
.notify-icon-char {
font-size: var(--tk-font-body-sm);
font-weight: 700;
}
.ntype-appointment,
.ntype-points {
background: var(--tk-pri-l);
color: var(--tk-pri);
}
.ntype-alert {
background: $wrn-l;
color: $wrn;
}
.ntype-followup,
.ntype-report {
background: $acc-l;
color: $acc;
}
.notify-body {
flex: 1;
min-width: 0;
}
.notify-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-2xs);
}
.notify-title {
font-size: var(--tk-font-cap);
color: $tx;
}
.notify-bold {
font-weight: 600;
}
.notify-time {
font-size: var(--tk-font-micro);
color: var(--tk-text-secondary);
flex-shrink: 0;
margin-left: var(--tk-gap-xs);
}
.notify-desc {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.5;
}
.notify-dot {
width: 8px;
height: 8px;
border-radius: $r-xs;
background: var(--tk-pri);
flex-shrink: 0;
margin-top: var(--tk-gap-2xs);
}

View File

@@ -0,0 +1,123 @@
import { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReachBottom } from '@tarojs/taro';
import { notificationService } from '@/services/notification';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import EmptyState from '@/components/EmptyState';
import ErrorState from '@/components/ErrorState';
import Loading from '@/components/Loading';
import { useElderClass } from '@/hooks/useElderClass';
import { usePageData } from '@/hooks/usePageData';
import './index.scss';
interface NotificationItem {
id: string;
title: string;
desc: string;
time: string;
type: string;
read?: boolean;
}
const TYPE_ICONS: Record<string, { icon: string; cls: string }> = {
appointment: { icon: '约', cls: 'ntype-appointment' },
alert: { icon: '警', cls: 'ntype-alert' },
followup: { icon: '随', cls: 'ntype-followup' },
points: { icon: '分', cls: 'ntype-points' },
report: { icon: '报', cls: 'ntype-report' },
};
export default function Notifications() {
const modeClass = useElderClass();
const [items, setItems] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const load = useCallback(async (pageNum: number, isRefresh = false) => {
if (isRefresh) setLoading(true);
setError('');
try {
const res = await notificationService.list<{ data: NotificationItem[]; total?: number }>({
page: pageNum,
page_size: 20,
});
const list = res.data || [];
setItems((prev) => (isRefresh ? list : [...prev, ...list]));
setTotal(res.total || 0);
setPage(pageNum);
} catch {
setError('加载失败');
if (isRefresh) setItems([]);
} finally {
setLoading(false);
}
}, []);
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '消息通知' });
await load(1, true);
}, [load]),
{ throttleMs: 10000, enablePullDown: true },
);
useReachBottom(() => {
if (!loading && items.length < total) load(page + 1);
});
if (loading && items.length === 0) return <Loading />;
if (error && items.length === 0) {
return (
<PageShell className={modeClass}>
<ErrorState text={error} onRetry={() => load(1, true)} />
</PageShell>
);
}
return (
<PageShell className={modeClass}>
{items.length === 0 ? (
<EmptyState text='暂无新通知' />
) : (
<View className='notify-list'>
{items.map((n) => {
const cfg = TYPE_ICONS[n.type] || TYPE_ICONS.report;
const unread = !n.read;
return (
<ContentCard
key={n.id}
padding='sm'
activeFeedback='none'
className={unread ? '' : 'notify-muted'}
onPress={async () => {
if (unread) {
try { await notificationService.markRead(n.id); } catch { /* ignore */ }
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
}
}}
>
<View className='notify-item'>
<View className={`notify-icon ${cfg.cls}`}>
<Text className='notify-icon-char'>{cfg.icon}</Text>
</View>
<View className='notify-body'>
<View className='notify-row'>
<Text className={`notify-title ${unread ? 'notify-bold' : ''}`}>{n.title}</Text>
<Text className='notify-time'>{n.time}</Text>
</View>
<Text className='notify-desc'>{n.desc}</Text>
</View>
{unread && <View className='notify-dot' />}
</View>
</ContentCard>
);
})}
</View>
)}
</PageShell>
);
}

View File

@@ -182,6 +182,23 @@
flex-shrink: 0;
}
/* ─── 消息角标 ─── */
.menu-badge {
min-width: 18px;
height: 18px;
border-radius: $r-pill;
background: $dan;
@include flex-center;
padding: 0 5px;
margin-right: var(--tk-gap-xs);
}
.menu-badge-text {
font-size: var(--tk-font-micro);
color: $white;
font-weight: 600;
}
/* ─── 退出登录 ─── */
.profile-logout {
margin-top: var(--tk-gap-md);

View File

@@ -6,6 +6,7 @@ import { usePointsStore } from '../../stores/points';
import { useUIStore } from '../../stores/ui';
import { navigateToLogin } from '../../utils/navigate';
import { usePageData } from '@/hooks/usePageData';
import { notificationService } from '@/services/notification';
import Loading from '../../components/Loading';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
@@ -91,12 +92,18 @@ export default function Profile() {
const isGuest = !user;
const groups = isGuest ? GUEST_GROUPS : LOGGED_IN_GROUPS;
const [pointsLoading, setPointsLoading] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const fetchPoints = useCallback(async () => {
if (!isGuest) {
setPointsLoading(true);
await refreshPoints();
setPointsLoading(false);
try {
const res = await notificationService.list<{ total?: number; data?: { read?: boolean }[] }>({ page: 1, page_size: 50 });
const items = (res as { data?: { read?: boolean }[] })?.data || [];
setUnreadCount(items.filter((n) => !n.read).length);
} catch { /* ignore */ }
}
}, [isGuest, refreshPoints]);
@@ -171,6 +178,29 @@ export default function Profile() {
</>
)}
{/* 消息通知入口 */}
{!isGuest && (
<View className='menu-group'>
<ContentCard
padding="none"
onPress={() => Taro.navigateTo({ url: '/pages/pkg-profile/notifications/index' })}
>
<View className='menu-item'>
<View className='menu-icon menu-icon--pri-l'>
<Text className='menu-icon-text menu-icon-text--pri'></Text>
</View>
<Text className='menu-label'></Text>
{unreadCount > 0 && (
<View className='menu-badge'>
<Text className='menu-badge-text'>{unreadCount > 99 ? '99+' : unreadCount}</Text>
</View>
)}
<Text className='menu-arrow'></Text>
</View>
</ContentCard>
</View>
)}
{/* 分组菜单 */}
{groups.map((group) => (
<View className='menu-group' key={group.title}>

View File

@@ -0,0 +1,42 @@
import { api } from './request';
export interface AiChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
created_at: string;
}
export interface AiChatResponse {
reply: string;
message_id: string;
}
/** 发送消息给 AI 客服 */
export async function sendAiMessage(
message: string,
history?: AiChatMessage[],
): Promise<AiChatResponse> {
const resp = await api.post<AiChatResponse>('/ai/chat', {
message,
history: history?.slice(-10),
});
return resp;
}
/** 获取聊天历史(本地缓存) */
export function getLocalHistory(): AiChatMessage[] {
try {
const raw = wx.getStorageSync('ai_chat_history');
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
/** 保存聊天历史到本地 */
export function saveLocalHistory(messages: AiChatMessage[]): void {
try {
wx.setStorageSync('ai_chat_history', JSON.stringify(messages.slice(-100)));
} catch { /* ignore */ }
}