From 710b2e24236a0704c3a0a2813752ddefccdefc70 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 17 May 2026 00:49:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E6=96=B0=E5=A2=9E=20AI=20=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E8=81=8A=E5=A4=A9=E5=8A=9F=E8=83=BD=20+=20=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=A1=B5=E9=87=8D=E6=9E=84=E4=B8=BA=E5=B0=8F=E5=8D=8E?= =?UTF-8?q?=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /ai/chat 端点,由 LLM(Ollama qwen3)担任 24h 健康客服"小华" - 新增 ai.chat.send 权限,绑定管理员/患者/医生/护士/健康管理师角色 - 消息页从咨询列表重构为单窗口 AI 对话(欢迎态 + 聊天态 + 快捷问诊) - 通知功能迁移到"我的"页面菜单项(带未读角标),独立通知列表页 - 修复气泡文字截断:改用百分比 max-width + block Text + pre-wrap 换行 - 修复权限绑定:迁移 SQL 角色名从英文改为中文(admin→管理员,patient→患者) --- apps/miniprogram/src/app.config.ts | 2 +- .../miniprogram/src/pages/messages/index.scss | 482 ++++++++++-------- apps/miniprogram/src/pages/messages/index.tsx | 386 +++++++------- .../pages/pkg-consultation/detail/index.scss | 8 +- .../pkg-profile/notifications/index.scss | 91 ++++ .../pages/pkg-profile/notifications/index.tsx | 123 +++++ apps/miniprogram/src/pages/profile/index.scss | 17 + apps/miniprogram/src/pages/profile/index.tsx | 30 ++ apps/miniprogram/src/services/ai-chat.ts | 42 ++ crates/erp-ai/src/handler/chat_handler.rs | 138 +++++ crates/erp-ai/src/handler/mod.rs | 1 + crates/erp-ai/src/module.rs | 4 + crates/erp-server/migration/src/lib.rs | 2 + ...20260516_000147_seed_ai_chat_permission.rs | 65 +++ 14 files changed, 952 insertions(+), 439 deletions(-) create mode 100644 apps/miniprogram/src/pages/pkg-profile/notifications/index.scss create mode 100644 apps/miniprogram/src/pages/pkg-profile/notifications/index.tsx create mode 100644 apps/miniprogram/src/services/ai-chat.ts create mode 100644 crates/erp-ai/src/handler/chat_handler.rs create mode 100644 crates/erp-server/migration/src/m20260516_000147_seed_ai_chat_permission.rs diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index a4a92b2..bd05280 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -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', ], }, { diff --git a/apps/miniprogram/src/pages/messages/index.scss b/apps/miniprogram/src/pages/messages/index.scss index b9e17cf..3f8c15e 100644 --- a/apps/miniprogram/src/pages/messages/index.scss +++ b/apps/miniprogram/src/pages/messages/index.scss @@ -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; } diff --git a/apps/miniprogram/src/pages/messages/index.tsx b/apps/miniprogram/src/pages/messages/index.tsx index 80f6b91..d1e596e 100644 --- a/apps/miniprogram/src/pages/messages/index.tsx +++ b/apps/miniprogram/src/pages/messages/index.tsx @@ -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 = { - 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('consultation'); - const [sessions, setSessions] = useState([]); - const [notifications, setNotifications] = useState([]); - 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([]); + 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 ; + return ; } - const unreadConsultCount = sessions.filter((s) => s.unread_count_patient > 0).length; - return ( - - {/* 页头 */} - - 消息 + + {/* 导航栏 */} + + + 健康助手 · 小华 + + + 24小时在线 + + - {/* 分段控件 Tab */} - - handleTabChange('consultation')} + {loading ? null : isEmpty ? ( + /* 欢迎状态 */ + + + + + 您好,我是小华 + + 您的专属健康助手,随时为您解答{'\n'}健康问题、预约服务、报告解读等 + + + 您可能想问 + + {QUICK_ACTIONS.map((a) => ( + handleQuickAction(a.label)} + > + {a.icon} + {a.label} + + ))} + + + ) : ( + /* 对话区域 */ + - 咨询 - {unreadConsultCount > 0 && ( - - {unreadConsultCount} + {messages.map((msg, idx) => { + const isUser = msg.role === 'user'; + return ( + + {!isUser && ( + + + + )} + + {msg.content} + + + ); + })} + {sending && ( + + + + + + + + + + + )} - + + )} + + {/* 输入栏 */} + + setInputText(e.detail.value)} + confirmType='send' + onConfirm={() => handleSend()} + disabled={sending} + /> handleTabChange('notification')} + className={`ai-chat-bar__send ${(!inputText.trim() || sending) ? 'ai-chat-bar__send--disabled' : ''}`} + onClick={() => handleSend()} > - 通知 + - - - {error ? ( - loadData(activeTab, 1, true)} /> - ) : ( - <> - {/* 咨询列表 */} - {activeTab === 'consultation' && ( - loading ? ( - - ) : sessions.length === 0 ? ( - - ) : ( - - {sessions.map((session) => { - const displayName = session.doctor_name || '在线咨询'; - const avatarChar = session.doctor_name?.charAt(0) || '咨'; - const hasUnread = session.unread_count_patient > 0; - return ( - Taro.navigateTo({ url: `/pages/pkg-consultation/detail/index?id=${session.id}` })} - padding="sm" - className={`consult-card ${hasUnread ? '' : 'consult-card-muted'}`} - > - - {avatarChar} - - - - - {displayName} - {session.consultation_type && ( - - {session.consultation_type === 'online' ? '在线' : '门诊'} - - )} - - {formatTime(session.last_message_at)} - - - - {session.last_message || session.subject || '暂无消息'} - - {hasUnread && ( - - - {session.unread_count_patient > 99 ? '99+' : session.unread_count_patient} - - - )} - - - - ); - })} - - ) - )} - - {/* 通知列表 */} - {activeTab === 'notification' && ( - loading ? ( - - ) : notifications.length === 0 ? ( - - ) : ( - - {notifications.map((n) => { - const cfg = NOTIFY_ICONS[n.type] || NOTIFY_ICONS.report; - const isUnread = !n.read; - return ( - - - {cfg.icon} - - - - {n.title} - {n.time} - - {n.desc} - - {isUnread && } - - ); - })} - - ) - )} - - )} - - + ); } diff --git a/apps/miniprogram/src/pages/pkg-consultation/detail/index.scss b/apps/miniprogram/src/pages/pkg-consultation/detail/index.scss index a4c22c3..2d87696 100644 --- a/apps/miniprogram/src/pages/pkg-consultation/detail/index.scss +++ b/apps/miniprogram/src/pages/pkg-consultation/detail/index.scss @@ -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 { diff --git a/apps/miniprogram/src/pages/pkg-profile/notifications/index.scss b/apps/miniprogram/src/pages/pkg-profile/notifications/index.scss new file mode 100644 index 0000000..6125390 --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-profile/notifications/index.scss @@ -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); +} diff --git a/apps/miniprogram/src/pages/pkg-profile/notifications/index.tsx b/apps/miniprogram/src/pages/pkg-profile/notifications/index.tsx new file mode 100644 index 0000000..811fd4e --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-profile/notifications/index.tsx @@ -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 = { + 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([]); + 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 ; + + if (error && items.length === 0) { + return ( + + load(1, true)} /> + + ); + } + + return ( + + {items.length === 0 ? ( + + ) : ( + + {items.map((n) => { + const cfg = TYPE_ICONS[n.type] || TYPE_ICONS.report; + const unread = !n.read; + return ( + { + if (unread) { + try { await notificationService.markRead(n.id); } catch { /* ignore */ } + setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x))); + } + }} + > + + + {cfg.icon} + + + + {n.title} + {n.time} + + {n.desc} + + {unread && } + + + ); + })} + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/profile/index.scss b/apps/miniprogram/src/pages/profile/index.scss index 5d28cf0..8ce177e 100644 --- a/apps/miniprogram/src/pages/profile/index.scss +++ b/apps/miniprogram/src/pages/profile/index.scss @@ -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); diff --git a/apps/miniprogram/src/pages/profile/index.tsx b/apps/miniprogram/src/pages/profile/index.tsx index 735f279..55e014a 100644 --- a/apps/miniprogram/src/pages/profile/index.tsx +++ b/apps/miniprogram/src/pages/profile/index.tsx @@ -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 && ( + + Taro.navigateTo({ url: '/pages/pkg-profile/notifications/index' })} + > + + + + + 消息通知 + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + + + + + )} + {/* 分组菜单 */} {groups.map((group) => ( diff --git a/apps/miniprogram/src/services/ai-chat.ts b/apps/miniprogram/src/services/ai-chat.ts new file mode 100644 index 0000000..c516364 --- /dev/null +++ b/apps/miniprogram/src/services/ai-chat.ts @@ -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 { + const resp = await api.post('/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 */ } +} diff --git a/crates/erp-ai/src/handler/chat_handler.rs b/crates/erp-ai/src/handler/chat_handler.rs new file mode 100644 index 0000000..6fbcea4 --- /dev/null +++ b/crates/erp-ai/src/handler/chat_handler.rs @@ -0,0 +1,138 @@ +use axum::Json; +use axum::extract::{Extension, FromRef, State}; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; +use serde::{Deserialize, Serialize}; + +use crate::dto::GenerateRequest; +use crate::state::AiState; + +// === 请求 / 响应 === + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ChatRequest { + pub message: String, + pub history: Option>, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct ChatHistoryItem { + pub role: String, + pub content: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ChatResponse { + pub reply: String, + pub message_id: String, +} + +const SYSTEM_PROMPT: &str = r#"你是 HMS 健康管理平台的 AI 客服助手"小华"。你的职责是: +1. 回答用户的健康咨询问题 +2. 帮助用户了解体检报告指标 +3. 提供预约挂号、用药提醒等服务指导 +4. 推荐健康生活方式 + +注意: +- 你不能替代医生的诊断,遇到需要诊断的问题请建议用户就医 +- 不能推荐具体药物,只能提供一般性健康建议 +- 语气要亲切、专业、耐心 +- 回复要简洁明了,避免过长 +- 如果用户问的问题超出健康范围,礼貌引导回到健康话题"#; + +#[utoipa::path( + post, + path = "/ai/chat", + request_body = ChatRequest, + responses((status = 200, description = "AI 客服回复")), + tag = "AI 客服", + security(("bearer_auth" = [])), +)] +pub async fn chat( + Extension(ctx): Extension, + State(state): State, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.chat.send")?; + + let message = body.message.trim(); + if message.is_empty() { + return Err(erp_core::error::AppError::Validation("消息不能为空".into())); + } + if message.len() > 2000 { + return Err(erp_core::error::AppError::Validation( + "消息长度不能超过 2000 字".into(), + )); + } + + let user_prompt = match body.history { + Some(ref hist) if !hist.is_empty() => { + let filtered: Vec<&ChatHistoryItem> = hist + .iter() + .filter(|h| h.role == "user" || h.role == "assistant") + .collect(); + let start = filtered.len().saturating_sub(10); + let ctx: String = filtered[start..] + .iter() + .map(|h| { + format!( + "{}: {}", + if h.role == "user" { "用户" } else { "助手" }, + h.content + ) + }) + .collect::>() + .join("\n"); + format!("历史对话:\n{}\n\n用户最新消息: {}", ctx, message) + } + _ => message.to_string(), + }; + + let ai_state = AiState::from_ref(&state); + let resolved = ai_state + .provider_registry + .resolve("auto") + .await + .map_err(|e| { + tracing::error!(error = %e, "AI provider resolve failed"); + erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into()) + })?; + + let req = GenerateRequest { + system_prompt: SYSTEM_PROMPT.to_string(), + user_prompt, + model: String::new(), + temperature: 0.7, + max_tokens: 1024, + }; + + tracing::info!( + tenant_id = %ctx.tenant_id, + user_id = %ctx.user_id, + msg_len = message.len(), + "AI chat request" + ); + + let resp = resolved.provider().generate(req).await.map_err(|e| { + tracing::error!(error = %e, "AI chat generate failed"); + erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into()) + })?; + + let message_id = uuid::Uuid::now_v7().to_string(); + + tracing::info!( + tenant_id = %ctx.tenant_id, + message_id = %message_id, + tokens = resp.output_tokens, + "AI chat response sent" + ); + + Ok(Json(ApiResponse::ok(ChatResponse { + reply: resp.content, + message_id, + }))) +} diff --git a/crates/erp-ai/src/handler/mod.rs b/crates/erp-ai/src/handler/mod.rs index 33a3449..753ffda 100644 --- a/crates/erp-ai/src/handler/mod.rs +++ b/crates/erp-ai/src/handler/mod.rs @@ -11,6 +11,7 @@ use std::convert::Infallible; use crate::dto::{AnalysisSseEvent, AnalysisType}; use crate::state::AiState; +pub mod chat_handler; pub mod insight_handler; pub mod risk_handler; pub mod rule_handler; diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index 581795c..2aefa91 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -356,6 +356,10 @@ impl AiModule { S: Clone + Send + Sync + 'static, { Router::new() + .route( + "/ai/chat", + axum::routing::post(crate::handler::chat_handler::chat), + ) .route( "/ai/analyze/lab-report", axum::routing::post(crate::handler::stream_lab_report), diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 2da9c77..5749100 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -148,6 +148,7 @@ mod m20260512_000143_seed_copilot_alert_rules; mod m20260513_000144_enforce_version_optimistic_lock; mod m20260513_000145_seed_missing_permissions; mod m20260515_000146_seed_menu_permissions_phase2; +mod m20260516_000147_seed_ai_chat_permission; pub struct Migrator; @@ -303,6 +304,7 @@ impl MigratorTrait for Migrator { Box::new(m20260513_000144_enforce_version_optimistic_lock::Migration), Box::new(m20260513_000145_seed_missing_permissions::Migration), Box::new(m20260515_000146_seed_menu_permissions_phase2::Migration), + Box::new(m20260516_000147_seed_ai_chat_permission::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260516_000147_seed_ai_chat_permission.rs b/crates/erp-server/migration/src/m20260516_000147_seed_ai_chat_permission.rs new file mode 100644 index 0000000..ea8fe34 --- /dev/null +++ b/crates/erp-server/migration/src/m20260516_000147_seed_ai_chat_permission.rs @@ -0,0 +1,65 @@ +//! 新增 ai.chat.send 权限码 — AI 客服聊天 + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let sys = "00000000-0000-0000-0000-000000000000"; + + // 注册权限到所有租户 + db.execute_unprepared(&format!( + r#" + INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, + created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), t.id, 'ai.chat.send', 'AI 客服聊天', 'ai', 'chat.send', 'AI 客服聊天', + NOW(), NOW(), '{sys}', '{sys}', NULL, 1 + FROM tenant t + WHERE NOT EXISTS ( + SELECT 1 FROM permissions p + WHERE p.code = 'ai.chat.send' AND p.tenant_id = t.id AND p.deleted_at IS NULL + ) + "# + )).await?; + + // 绑定到管理员角色 + db.execute_unprepared( + r#" + INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version) + SELECT r.id, p.id, t.id, r.id, r.id, 1 + FROM tenant t + JOIN roles r ON r.tenant_id = t.id AND r.name = '管理员' AND r.deleted_at IS NULL + JOIN permissions p ON p.tenant_id = t.id AND p.code = 'ai.chat.send' AND p.deleted_at IS NULL + WHERE NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.tenant_id = t.id + ) + "#, + ).await?; + + // 绑定到患者角色(患者需要使用 AI 客服) + db.execute_unprepared( + r#" + INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version) + SELECT r.id, p.id, t.id, r.id, r.id, 1 + FROM tenant t + JOIN roles r ON r.tenant_id = t.id AND r.name = '患者' AND r.deleted_at IS NULL + JOIN permissions p ON p.tenant_id = t.id AND p.code = 'ai.chat.send' AND p.deleted_at IS NULL + WHERE NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.tenant_id = t.id + ) + "#, + ).await?; + + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +}