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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user