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

@@ -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}>