访客模式: - 未登录用户可见首页(轮播图+健康资讯+登录引导)和"我的"页面 - 健康和消息 tab 显示 GuestGuard 登录拦截 - 登录页增加"暂不登录,先看看"跳过入口 - 401 拦截器增加 hasToken 检查,避免访客被重定向到登录页 - 退出登录后 reLaunch 到首页而非登录页 长辈模式: - 新增 stores/ui.ts 管理显示模式(标准/长辈) - 长辈模式放大字体 ×1.3、间距 ×1.2、按钮加大 - "我的 → 账号 → 长辈模式"切换页 - 设置持久化到 Storage 修复: - Health/Messages 页面 Hooks 顺序违规(条件 return 在 hooks 之间) 导致访客模式下页面白屏,所有 hooks 移到条件判断之前 工程: - scripts/mpsync.sh/ps1 自动清理残留 DevTools 进程 - project.config.json 默认关闭域名校验
354 lines
13 KiB
TypeScript
354 lines
13 KiB
TypeScript
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
|
||
import { useState, useCallback } from 'react';
|
||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||
import { useAuthStore } from '../../stores/auth';
|
||
import { useUIStore } from '../../stores/ui';
|
||
import { useHealthStore } from '../../stores/health';
|
||
import ProgressRing from '../../components/ProgressRing';
|
||
import Loading from '../../components/Loading';
|
||
import { trackPageView } from '@/services/analytics';
|
||
import * as appointmentApi from '@/services/appointment';
|
||
import * as followupApi from '@/services/followup';
|
||
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
|
||
import { notificationService } from '@/services/notification';
|
||
import * as articleApi from '@/services/article';
|
||
import './index.scss';
|
||
|
||
interface ReminderItem {
|
||
id: string;
|
||
text: string;
|
||
type: 'ai' | 'appointment' | 'followup';
|
||
tag: string;
|
||
}
|
||
|
||
// ─── 访客首页 ───
|
||
|
||
const CAROUSEL_SLIDES = [
|
||
{ id: 'slide-1', title: '专业血透中心', desc: '三甲级医护团队全程守护' },
|
||
{ id: 'slide-2', title: '智慧健康管理', desc: 'AI 驱动个性化健康方案' },
|
||
{ id: 'slide-3', title: '温馨就医环境', desc: '舒适安全的治疗体验' },
|
||
];
|
||
|
||
function GuestHome({ modeClass }: { modeClass: string }) {
|
||
const [articles, setArticles] = useState<articleApi.Article[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
useDidShow(() => {
|
||
loadArticles();
|
||
trackPageView('guest-home');
|
||
});
|
||
|
||
const loadArticles = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await articleApi.listArticles({ page: 1, page_size: 4 });
|
||
setArticles(res.data || []);
|
||
} catch {
|
||
// 文章加载失败不阻塞
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<View className={`guest-page ${modeClass}`}>
|
||
{/* 轮播图 */}
|
||
<Swiper
|
||
className='guest-swiper'
|
||
indicatorDots
|
||
indicatorColor='rgba(255,255,255,0.4)'
|
||
indicatorActiveColor='#FFFFFF'
|
||
autoplay
|
||
circular
|
||
interval={4000}
|
||
duration={500}
|
||
>
|
||
{CAROUSEL_SLIDES.map((slide) => (
|
||
<SwiperItem key={slide.id}>
|
||
<View className='guest-slide'>
|
||
<View className='guest-slide-bg guest-slide-bg--1' />
|
||
<View className='guest-slide-content'>
|
||
<Text className='guest-slide-title'>{slide.title}</Text>
|
||
<Text className='guest-slide-desc'>{slide.desc}</Text>
|
||
</View>
|
||
</View>
|
||
</SwiperItem>
|
||
))}
|
||
</Swiper>
|
||
|
||
{/* 健康资讯 */}
|
||
<View className='guest-section'>
|
||
<Text className='guest-section-title'>健康资讯</Text>
|
||
{loading ? (
|
||
<Loading />
|
||
) : articles.length > 0 ? (
|
||
<View className='guest-articles'>
|
||
{articles.map((a) => (
|
||
<View
|
||
key={a.id}
|
||
className='guest-article-card'
|
||
onClick={() => Taro.navigateTo({ url: `/pages/article/detail/index?id=${a.id}` })}
|
||
>
|
||
<Text className='guest-article-title'>{a.title}</Text>
|
||
{a.summary && <Text className='guest-article-summary'>{a.summary}</Text>}
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : (
|
||
<View className='guest-empty'>
|
||
<Text className='guest-empty-text'>暂无文章</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* 底部登录引导 */}
|
||
<View className='guest-login-prompt'>
|
||
<Text className='guest-login-text'>登录后即可使用完整健康管理服务</Text>
|
||
<View
|
||
className='guest-login-btn'
|
||
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
|
||
>
|
||
<Text className='guest-login-btn-text'>立即登录</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── 登录后首页 ───
|
||
|
||
function HomeDashboard({ modeClass }: { modeClass: string }) {
|
||
const { user, currentPatient } = useAuthStore();
|
||
const { todaySummary, loading, refreshToday } = useHealthStore();
|
||
const [reminders, setReminders] = useState<ReminderItem[]>([]);
|
||
const [unreadCount, setUnreadCount] = useState(0);
|
||
const [remindersLoading, setRemindersLoading] = useState(false);
|
||
|
||
useDidShow(() => {
|
||
refreshToday();
|
||
loadReminders();
|
||
loadUnread();
|
||
trackPageView('home');
|
||
});
|
||
|
||
usePullDownRefresh(() => {
|
||
Promise.all([refreshToday(true), loadReminders(), loadUnread()]).finally(() => {
|
||
Taro.stopPullDownRefresh();
|
||
});
|
||
});
|
||
|
||
const loadUnread = async () => {
|
||
try {
|
||
const res = await notificationService.getUnreadCount() as { data?: { count?: number } | number };
|
||
const d = res.data;
|
||
setUnreadCount(typeof d === 'object' && d ? (d.count ?? 0) : 0);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const loadReminders = async () => {
|
||
const patientId = useAuthStore.getState().currentPatient?.id;
|
||
if (!patientId) return;
|
||
setRemindersLoading(true);
|
||
try {
|
||
const items: ReminderItem[] = [];
|
||
const [apptRes, taskRes, suggestRes] = await Promise.allSettled([
|
||
appointmentApi.listAppointments(patientId, 1),
|
||
followupApi.listTasks(patientId, 'pending'),
|
||
listPendingSuggestions(),
|
||
]);
|
||
|
||
if (suggestRes.status === 'fulfilled') {
|
||
for (const s of suggestRes.value.data.slice(0, 1)) {
|
||
items.push({ id: s.id, text: buildSuggestionText(s), type: 'ai', tag: 'AI 建议' });
|
||
}
|
||
}
|
||
if (apptRes.status === 'fulfilled') {
|
||
for (const a of apptRes.value.data.slice(0, 1)) {
|
||
if (a.status === 'pending' || a.status === 'confirmed') {
|
||
items.push({
|
||
id: a.id,
|
||
text: `${a.appointment_date} ${a.start_time} — ${a.doctor_name || '医护'} ${a.department || '门诊'}`,
|
||
type: 'appointment',
|
||
tag: '预约',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (taskRes.status === 'fulfilled') {
|
||
for (const t of taskRes.value.data.slice(0, 1)) {
|
||
items.push({
|
||
id: t.id,
|
||
text: `${t.follow_up_type} · 截止 ${t.planned_date}`,
|
||
type: 'followup',
|
||
tag: '随访',
|
||
});
|
||
}
|
||
}
|
||
setReminders(items.slice(0, 3));
|
||
} catch {
|
||
setReminders([]);
|
||
} finally {
|
||
setRemindersLoading(false);
|
||
}
|
||
};
|
||
|
||
const hour = new Date().getHours();
|
||
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
|
||
const displayName = user?.display_name || currentPatient?.name || '访客';
|
||
|
||
const summary = todaySummary || {};
|
||
const indicators = [!!summary.blood_pressure, !!summary.heart_rate, !!summary.blood_sugar, !!summary.weight];
|
||
const completedCount = indicators.filter(Boolean).length;
|
||
const progressPercent = Math.round((completedCount / 4) * 100);
|
||
|
||
const indicatorCapsules = [
|
||
{ label: '血压', done: !!summary.blood_pressure },
|
||
{ label: '心率', done: !!summary.heart_rate },
|
||
{ label: '血糖', done: !!summary.blood_sugar },
|
||
{ label: '体重', done: !!summary.weight },
|
||
];
|
||
|
||
const healthItems = [
|
||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'systolic_bp_morning' },
|
||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status, indicator: 'heart_rate' },
|
||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar' },
|
||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status, indicator: 'weight' },
|
||
];
|
||
|
||
const getStatusTag = (status?: string) => {
|
||
if (status === 'high' || status === 'low') return { label: status === 'high' ? '偏高' : '偏低', cls: 'tag-warn' };
|
||
if (status === 'normal') return { label: '正常', cls: 'tag-ok' };
|
||
return null;
|
||
};
|
||
|
||
return (
|
||
<View className={`home-page ${modeClass}`}>
|
||
{/* 问候区 */}
|
||
<View className='greeting-section'>
|
||
<View className='greeting-left'>
|
||
<Text className='greeting-text'>{greeting},{displayName}</Text>
|
||
<Text className='greeting-date'>
|
||
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}
|
||
</Text>
|
||
</View>
|
||
<View className='greeting-bell' onClick={() => Taro.switchTab({ url: '/pages/messages/index' })}>
|
||
<Text className='greeting-bell-icon'>消</Text>
|
||
{unreadCount > 0 && <View className='greeting-bell-dot' />}
|
||
</View>
|
||
</View>
|
||
|
||
{/* 今日体征进度 */}
|
||
<View className='checkin-card' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
|
||
<View className='checkin-left'>
|
||
<ProgressRing percent={progressPercent} />
|
||
</View>
|
||
<View className='checkin-right'>
|
||
<Text className='checkin-title'>
|
||
{completedCount === 4 ? '今日体征已全部记录' : completedCount === 0 ? '今日尚未记录体征' : `今日已记录 ${completedCount}/4 项`}
|
||
</Text>
|
||
<View className='checkin-capsules'>
|
||
{indicatorCapsules.map((cap) => (
|
||
<Text key={cap.label} className={`capsule ${cap.done ? 'capsule-done' : 'capsule-pending'}`}>
|
||
{cap.done ? '✓ ' : ''}{cap.label}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 体征 2x2 */}
|
||
<View className='vitals-section'>
|
||
<Text className='section-title'>今日体征</Text>
|
||
{loading && !todaySummary ? (
|
||
<Loading />
|
||
) : (
|
||
<View className='vitals-grid'>
|
||
{healthItems.map((item) => {
|
||
const tag = getStatusTag(item.status);
|
||
return (
|
||
<View
|
||
className='vital-card'
|
||
key={item.label}
|
||
onClick={() => Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${item.indicator}` })}
|
||
>
|
||
<Text className='vital-label'>{item.label}</Text>
|
||
<View className='vital-value-row'>
|
||
<Text className='vital-value'>{item.value}</Text>
|
||
<Text className='vital-unit'>{item.unit}</Text>
|
||
</View>
|
||
<View className='vital-bottom'>
|
||
{tag && <Text className={`vital-tag ${tag.cls}`}>{tag.label}</Text>}
|
||
{!item.status && <Text className='vital-tag tag-empty'>未记录</Text>}
|
||
</View>
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* 智能提醒卡片 */}
|
||
{!remindersLoading && reminders.length > 0 && (
|
||
<View className='reminder-card'>
|
||
<View className='reminder-header'>
|
||
<Text className='reminder-title'>智能提醒</Text>
|
||
<Text className='reminder-count'>{reminders.length} 条待处理</Text>
|
||
</View>
|
||
{reminders.map((r, i) => (
|
||
<View
|
||
key={r.id}
|
||
className={`reminder-item ${i > 0 ? 'reminder-item-border' : ''}`}
|
||
onClick={() => {
|
||
if (r.type === 'appointment') Taro.navigateTo({ url: '/pages/appointment/index' });
|
||
else if (r.type === 'followup') Taro.navigateTo({ url: `/pages/followup/detail/index?id=${r.id}` });
|
||
}}
|
||
>
|
||
<Text className='reminder-tag'>{r.tag}</Text>
|
||
<Text className='reminder-text'>{r.text}</Text>
|
||
<Text className='reminder-arrow'>›</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
)}
|
||
|
||
{/* 快捷操作 */}
|
||
<View className='action-section'>
|
||
<View className='action-btn action-primary' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
|
||
<Text className='action-btn-text'>记录体征</Text>
|
||
</View>
|
||
<View className='action-btn action-outline' onClick={() => Taro.navigateTo({ url: '/pages/appointment/create/index' })}>
|
||
<Text className='action-btn-text'>预约挂号</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── 首页入口:根据登录状态切换 ───
|
||
|
||
export default function Index() {
|
||
const user = useAuthStore((s) => s.user);
|
||
const mode = useUIStore((s) => s.mode);
|
||
const modeClass = mode === 'elder' ? 'elder-mode' : '';
|
||
|
||
if (!user) {
|
||
return <GuestHome modeClass={modeClass} />;
|
||
}
|
||
return <HomeDashboard modeClass={modeClass} />;
|
||
}
|
||
|
||
function buildSuggestionText(s: AiSuggestionItem): string {
|
||
const riskMap: Record<string, string> = { high: '高风险', medium: '中风险', low: '低风险' };
|
||
const typeMap: Record<string, string> = {
|
||
vital_sign_anomaly: '体征异常',
|
||
lab_result_anomaly: '化验异常',
|
||
medication_adherence: '用药提醒',
|
||
lifestyle: '生活建议',
|
||
};
|
||
const risk = riskMap[s.risk_level] || '';
|
||
const type = typeMap[s.suggestion_type] || '健康建议';
|
||
return `${type}:发现${risk}指标,建议关注`;
|
||
}
|