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-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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ─── 消息角标 ─── */
|
||||
.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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
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::state::AiState;
|
||||
|
||||
pub mod chat_handler;
|
||||
pub mod insight_handler;
|
||||
pub mod risk_handler;
|
||||
pub mod rule_handler;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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