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:
@@ -48,7 +48,7 @@ export default defineAppConfig({
|
|||||||
'dialysis-records/index', 'dialysis-records/detail/index',
|
'dialysis-records/index', 'dialysis-records/detail/index',
|
||||||
'dialysis-prescriptions/index', 'dialysis-prescriptions/detail/index',
|
'dialysis-prescriptions/index', 'dialysis-prescriptions/detail/index',
|
||||||
'consents/index', 'health-records/index', 'diagnoses/index',
|
'consents/index', 'health-records/index', 'diagnoses/index',
|
||||||
'elder-mode/index', 'events/index',
|
'elder-mode/index', 'events/index', 'notifications/index',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,270 +1,300 @@
|
|||||||
@import '../../styles/variables.scss';
|
@import '../../styles/variables.scss';
|
||||||
@import '../../styles/mixins.scss';
|
@import '../../styles/mixins.scss';
|
||||||
|
|
||||||
.messages-page {
|
.ai-chat-page {
|
||||||
// PageShell 接管 min-height, background
|
display: flex;
|
||||||
padding: var(--tk-section-gap) var(--tk-page-padding) var(--tk-tabbar-space);
|
flex-direction: column;
|
||||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
|
height: 100vh;
|
||||||
|
background: $bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 页头 ─── */
|
/* ─── 导航栏 ─── */
|
||||||
.messages-header {
|
.ai-chat-nav {
|
||||||
margin-bottom: var(--tk-section-gap);
|
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 {
|
.ai-chat-nav__title-wrap {
|
||||||
@include serif-number;
|
display: flex;
|
||||||
font-size: var(--tk-font-h1);
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-nav__title {
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 分段控件 Tab ─── */
|
.ai-chat-nav__online {
|
||||||
.msg-segment {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
align-items: center;
|
||||||
background: $surface-alt;
|
gap: 4px;
|
||||||
border-radius: $r-sm;
|
margin-top: 2px;
|
||||||
padding: 3px;
|
|
||||||
margin-bottom: var(--tk-gap-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
flex: 1;
|
||||||
height: 48px;
|
display: flex;
|
||||||
border-radius: $r-xs;
|
flex-direction: column;
|
||||||
@include flex-center;
|
align-items: center;
|
||||||
position: relative;
|
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 {
|
&:active {
|
||||||
opacity: var(--tk-touch-feedback-opacity);
|
opacity: var(--tk-touch-feedback-opacity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-segment-active {
|
.ai-chat-welcome__pill-icon {
|
||||||
background: $card;
|
font-size: 15px;
|
||||||
box-shadow: $shadow-sm;
|
|
||||||
|
|
||||||
.msg-segment-text {
|
|
||||||
color: $tx;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-segment-text {
|
.ai-chat-welcome__pill-text {
|
||||||
font-size: var(--tk-font-cap);
|
font-size: 13px;
|
||||||
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);
|
|
||||||
color: $tx2;
|
color: $tx2;
|
||||||
overflow: hidden;
|
}
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
/* ─── 对话区域 ─── */
|
||||||
|
.ai-chat-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: var(--tk-gap-xs);
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.consult-badge {
|
/* ─── 消息行 ─── */
|
||||||
min-width: 18px;
|
.ai-msg {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--tk-gap-sm);
|
|
||||||
align-items: flex-start;
|
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 {
|
/* ─── AI 头像 ─── */
|
||||||
opacity: 0.65;
|
.ai-msg__avatar {
|
||||||
}
|
|
||||||
|
|
||||||
.notify-icon {
|
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: $r-sm;
|
border-radius: 18px;
|
||||||
@include flex-center;
|
background: linear-gradient(135deg, $pri, $pri-d);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-icon-char {
|
.ai-msg__avatar-char {
|
||||||
@include serif-number;
|
color: $white;
|
||||||
font-size: var(--tk-font-body-sm);
|
font-size: 15px;
|
||||||
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 {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-time {
|
/* ─── 消息气泡 ─── */
|
||||||
font-size: var(--tk-font-micro);
|
.ai-msg__bubble {
|
||||||
color: var(--tk-text-secondary);
|
max-width: 75%;
|
||||||
flex-shrink: 0;
|
padding: 10px 14px;
|
||||||
margin-left: var(--tk-gap-xs);
|
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 {
|
.ai-msg__text {
|
||||||
font-size: var(--tk-font-cap);
|
display: block;
|
||||||
color: $tx2;
|
width: 100%;
|
||||||
line-height: 1.5;
|
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;
|
.ai-msg__typing {
|
||||||
height: 8px;
|
display: flex;
|
||||||
border-radius: $r-xs;
|
gap: 4px;
|
||||||
background: var(--tk-pri);
|
align-items: center;
|
||||||
flex-shrink: 0;
|
padding: 4px 0;
|
||||||
margin-top: var(--tk-gap-2xs);
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,242 +1,210 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { listConsultations, ConsultationSession } from '../../services/consultation';
|
import {
|
||||||
import { notificationService } from '../../services/notification';
|
sendAiMessage,
|
||||||
import Loading from '../../components/Loading';
|
getLocalHistory,
|
||||||
import ErrorState from '../../components/ErrorState';
|
saveLocalHistory,
|
||||||
import EmptyState from '../../components/EmptyState';
|
type AiChatMessage,
|
||||||
import GuestGuard from '../../components/GuestGuard';
|
} from '@/services/ai-chat';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useElderClass } from '@/hooks/useElderClass';
|
||||||
import { useElderClass } from '../../hooks/useElderClass';
|
|
||||||
import { usePageData } from '@/hooks/usePageData';
|
import { usePageData } from '@/hooks/usePageData';
|
||||||
import PageShell from '@/components/ui/PageShell';
|
import GuestGuard from '@/components/GuestGuard';
|
||||||
import ContentCard from '@/components/ui/ContentCard';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
type MsgTab = 'consultation' | 'notification';
|
const QUICK_ACTIONS = [
|
||||||
|
{ icon: '📋', label: '查看报告' },
|
||||||
|
{ icon: '💊', label: '用药咨询' },
|
||||||
|
{ icon: '📅', label: '预约挂号' },
|
||||||
|
{ icon: '🔔', label: '健康提醒' },
|
||||||
|
];
|
||||||
|
|
||||||
interface NotificationItem {
|
function genId(): string {
|
||||||
id: string;
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||||
title: string;
|
|
||||||
desc: string;
|
|
||||||
time: string;
|
|
||||||
type: string;
|
|
||||||
read?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
export default function Messages() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const modeClass = useElderClass();
|
const modeClass = useElderClass();
|
||||||
const [activeTab, setActiveTab] = useState<MsgTab>('consultation');
|
const [messages, setMessages] = useState<AiChatMessage[]>([]);
|
||||||
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
|
const [inputText, setInputText] = useState('');
|
||||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
const [sending, setSending] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const messagesEndRef = useRef('');
|
||||||
const [page, setPage] = useState(1);
|
const sendingRef = useRef(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
usePageData(
|
usePageData(
|
||||||
useCallback(async () => {
|
async () => {
|
||||||
if (user) await loadData(activeTab, 1, true);
|
const history = getLocalHistory();
|
||||||
}, [user, activeTab, loadData]),
|
setMessages(history);
|
||||||
{ throttleMs: 5000, enablePullDown: false },
|
if (history.length > 0) {
|
||||||
|
messagesEndRef.current = `msg-${history.length}`;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
{ throttleMs: 30000, enablePullDown: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTabChange = (tab: MsgTab) => {
|
const scrollToBottom = (list: AiChatMessage[]) => {
|
||||||
setActiveTab(tab);
|
messagesEndRef.current = `msg-${list.length}`;
|
||||||
loadData(tab, 1, true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useReachBottom(() => {
|
const handleSend = async (text?: string) => {
|
||||||
const currentList = activeTab === 'consultation' ? sessions : notifications;
|
const content = (text || inputText).trim();
|
||||||
if (!loading && currentList.length < total) {
|
if (!content || sendingRef.current) return;
|
||||||
loadData(activeTab, page + 1);
|
|
||||||
|
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) {
|
if (!user) {
|
||||||
return <GuestGuard title='请先登录' desc='登录后即可查看消息和通知' />;
|
return <GuestGuard title='请先登录' desc='登录后即可与 AI 健康助手交流' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unreadConsultCount = sessions.filter((s) => s.unread_count_patient > 0).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell safeBottom={false} scroll={false} className={`messages-page ${modeClass}`}>
|
<View className={`ai-chat-page ${modeClass}`}>
|
||||||
{/* 页头 */}
|
{/* 导航栏 */}
|
||||||
<View className='messages-header'>
|
<View className='ai-chat-nav'>
|
||||||
<Text className='messages-title'>消息</Text>
|
<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>
|
</View>
|
||||||
|
|
||||||
{/* 分段控件 Tab */}
|
{loading ? null : isEmpty ? (
|
||||||
<View className='msg-segment'>
|
/* 欢迎状态 */
|
||||||
<View
|
<View className='ai-chat-welcome'>
|
||||||
className={`msg-segment-tab ${activeTab === 'consultation' ? 'msg-segment-active' : ''}`}
|
<View className='ai-chat-welcome__avatar'>
|
||||||
onClick={() => handleTabChange('consultation')}
|
<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>
|
{messages.map((msg, idx) => {
|
||||||
{unreadConsultCount > 0 && (
|
const isUser = msg.role === 'user';
|
||||||
<View className='msg-segment-badge'>
|
return (
|
||||||
<Text className='msg-segment-badge-text'>{unreadConsultCount}</Text>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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
|
<View
|
||||||
className={`msg-segment-tab ${activeTab === 'notification' ? 'msg-segment-active' : ''}`}
|
className={`ai-chat-bar__send ${(!inputText.trim() || sending) ? 'ai-chat-bar__send--disabled' : ''}`}
|
||||||
onClick={() => handleTabChange('notification')}
|
onClick={() => handleSend()}
|
||||||
>
|
>
|
||||||
<Text className='msg-segment-text'>通知</Text>
|
<Text className='ai-chat-bar__send-icon'>›</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
/* ─── 消息区域 ─── */
|
/* ─── 消息区域 ─── */
|
||||||
.chat-body {
|
.chat-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 16px 20px;
|
padding: 16px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 日期分隔 ─── */
|
/* ─── 日期分隔 ─── */
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
|
|
||||||
/* ─── 消息气泡 ─── */
|
/* ─── 消息气泡 ─── */
|
||||||
.chat-msg__bubble {
|
.chat-msg__bubble {
|
||||||
max-width: 72%;
|
max-width: 260px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
|
||||||
&--doctor {
|
&--doctor {
|
||||||
@@ -170,7 +170,9 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: $tx;
|
color: $tx;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
word-break: break-all;
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__image {
|
.chat-msg__image {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
123
apps/miniprogram/src/pages/pkg-profile/notifications/index.tsx
Normal file
123
apps/miniprogram/src/pages/pkg-profile/notifications/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -182,6 +182,23 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.profile-logout {
|
||||||
margin-top: var(--tk-gap-md);
|
margin-top: var(--tk-gap-md);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePointsStore } from '../../stores/points';
|
|||||||
import { useUIStore } from '../../stores/ui';
|
import { useUIStore } from '../../stores/ui';
|
||||||
import { navigateToLogin } from '../../utils/navigate';
|
import { navigateToLogin } from '../../utils/navigate';
|
||||||
import { usePageData } from '@/hooks/usePageData';
|
import { usePageData } from '@/hooks/usePageData';
|
||||||
|
import { notificationService } from '@/services/notification';
|
||||||
import Loading from '../../components/Loading';
|
import Loading from '../../components/Loading';
|
||||||
import PageShell from '@/components/ui/PageShell';
|
import PageShell from '@/components/ui/PageShell';
|
||||||
import ContentCard from '@/components/ui/ContentCard';
|
import ContentCard from '@/components/ui/ContentCard';
|
||||||
@@ -91,12 +92,18 @@ export default function Profile() {
|
|||||||
const isGuest = !user;
|
const isGuest = !user;
|
||||||
const groups = isGuest ? GUEST_GROUPS : LOGGED_IN_GROUPS;
|
const groups = isGuest ? GUEST_GROUPS : LOGGED_IN_GROUPS;
|
||||||
const [pointsLoading, setPointsLoading] = useState(false);
|
const [pointsLoading, setPointsLoading] = useState(false);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
const fetchPoints = useCallback(async () => {
|
const fetchPoints = useCallback(async () => {
|
||||||
if (!isGuest) {
|
if (!isGuest) {
|
||||||
setPointsLoading(true);
|
setPointsLoading(true);
|
||||||
await refreshPoints();
|
await refreshPoints();
|
||||||
setPointsLoading(false);
|
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]);
|
}, [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) => (
|
{groups.map((group) => (
|
||||||
<View className='menu-group' key={group.title}>
|
<View className='menu-group' key={group.title}>
|
||||||
|
|||||||
42
apps/miniprogram/src/services/ai-chat.ts
Normal file
42
apps/miniprogram/src/services/ai-chat.ts
Normal 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 */ }
|
||||||
|
}
|
||||||
138
crates/erp-ai/src/handler/chat_handler.rs
Normal file
138
crates/erp-ai/src/handler/chat_handler.rs
Normal file
@@ -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<Vec<ChatHistoryItem>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<S>(
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
State(state): State<S>,
|
||||||
|
Json(body): Json<ChatRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<ChatResponse>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use std::convert::Infallible;
|
|||||||
use crate::dto::{AnalysisSseEvent, AnalysisType};
|
use crate::dto::{AnalysisSseEvent, AnalysisType};
|
||||||
use crate::state::AiState;
|
use crate::state::AiState;
|
||||||
|
|
||||||
|
pub mod chat_handler;
|
||||||
pub mod insight_handler;
|
pub mod insight_handler;
|
||||||
pub mod risk_handler;
|
pub mod risk_handler;
|
||||||
pub mod rule_handler;
|
pub mod rule_handler;
|
||||||
|
|||||||
@@ -356,6 +356,10 @@ impl AiModule {
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/ai/chat",
|
||||||
|
axum::routing::post(crate::handler::chat_handler::chat),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/ai/analyze/lab-report",
|
"/ai/analyze/lab-report",
|
||||||
axum::routing::post(crate::handler::stream_lab_report),
|
axum::routing::post(crate::handler::stream_lab_report),
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ mod m20260512_000143_seed_copilot_alert_rules;
|
|||||||
mod m20260513_000144_enforce_version_optimistic_lock;
|
mod m20260513_000144_enforce_version_optimistic_lock;
|
||||||
mod m20260513_000145_seed_missing_permissions;
|
mod m20260513_000145_seed_missing_permissions;
|
||||||
mod m20260515_000146_seed_menu_permissions_phase2;
|
mod m20260515_000146_seed_menu_permissions_phase2;
|
||||||
|
mod m20260516_000147_seed_ai_chat_permission;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -303,6 +304,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260513_000144_enforce_version_optimistic_lock::Migration),
|
Box::new(m20260513_000144_enforce_version_optimistic_lock::Migration),
|
||||||
Box::new(m20260513_000145_seed_missing_permissions::Migration),
|
Box::new(m20260513_000145_seed_missing_permissions::Migration),
|
||||||
Box::new(m20260515_000146_seed_menu_permissions_phase2::Migration),
|
Box::new(m20260515_000146_seed_menu_permissions_phase2::Migration),
|
||||||
|
Box::new(m20260516_000147_seed_ai_chat_permission::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user