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

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

View File

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

View File

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