- Loading 组件区分列表底部状态(无spinner)vs 加载中状态 - ErrorBoundary 添加 MAX_RETRIES=3 限制,超出提示重启 - login 页 IS_SIMULATOR 改为 === 'develop' 精确匹配 - login 密码输入 type 改为 safe-password 防截屏 - appointment/create 备注输入添加 maxlength=200 - GuestHome "查看全部" 导航到文章列表页
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import { View, Text, Swiper, SwiperItem, Image } from '@tarojs/components';
|
||
import { useState } from 'react';
|
||
import Taro, { useDidShow, useDidHide } from '@tarojs/taro';
|
||
import { safeNavigateTo } from '@/utils/navigate';
|
||
import { useAuthStore } from '../../stores/auth';
|
||
import { useUIStore } from '../../stores/ui';
|
||
import { navigateToLogin } from '../../utils/navigate';
|
||
import { usePageData } from '@/hooks/usePageData';
|
||
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
|
||
import { api } from '@/services/request';
|
||
import type { Article } from '@/services/article';
|
||
import ProgressRing from '@/components/ui/ProgressRing';
|
||
import Loading from '../../components/Loading';
|
||
import PageShell from '@/components/ui/PageShell';
|
||
import ContentCard from '@/components/ui/ContentCard';
|
||
import { useHomeData, type ReminderItem } from './useHomeData';
|
||
import './index.scss';
|
||
|
||
interface PublicBanner {
|
||
id: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
desc?: string;
|
||
image_url?: string;
|
||
link_type?: string;
|
||
link_target?: string;
|
||
}
|
||
|
||
const FALLBACK_SLIDES = [
|
||
{ id: 'slide-1', title: '专业血透中心', desc: '三甲级医护团队全程守护', image_url: '' },
|
||
{ id: 'slide-2', title: '智慧健康管理', desc: 'AI 驱动个性化健康方案', image_url: '' },
|
||
{ id: 'slide-3', title: '温馨就医环境', desc: '舒适安全的治疗体验', image_url: '' },
|
||
];
|
||
|
||
// ─── 访客首页 ───
|
||
|
||
function GuestHome({ modeClass }: { modeClass: string }) {
|
||
const [banners, setBanners] = useState<PublicBanner[]>([]);
|
||
const [articles, setArticles] = useState<Article[]>([]);
|
||
const [swiperAutoplay, setSwiperAutoplay] = useState(false);
|
||
|
||
useDidShow(() => { setSwiperAutoplay(true); });
|
||
useDidHide(() => { setSwiperAutoplay(false); });
|
||
|
||
const loadPublicData = async () => {
|
||
let tenantId = Taro.getStorageSync('tenant_id');
|
||
if (!tenantId) {
|
||
tenantId = process.env.TARO_APP_DEFAULT_TENANT_ID || '';
|
||
}
|
||
if (!tenantId) {
|
||
setBanners(FALLBACK_SLIDES);
|
||
return;
|
||
}
|
||
try {
|
||
const [bannerData, articleData] = await Promise.allSettled([
|
||
api.get<PublicBanner[]>('/public/banners', { tenant_id: tenantId }, 300_000),
|
||
api.get<{ data: Article[]; total: number }>('/public/articles', {
|
||
tenant_id: tenantId,
|
||
page_size: 4,
|
||
}, 300_000),
|
||
]);
|
||
|
||
if (bannerData.status === 'fulfilled' && bannerData.value?.length > 0) {
|
||
const apiBase = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
|
||
const resolved = bannerData.value.map((b) => {
|
||
if (!b.image_url) return b;
|
||
const fullUrl = b.image_url.startsWith('http') ? b.image_url : `${apiBase}${b.image_url}`;
|
||
return { ...b, image_url: fullUrl };
|
||
});
|
||
setBanners(resolved);
|
||
} else {
|
||
setBanners(FALLBACK_SLIDES);
|
||
}
|
||
|
||
if (articleData.status === 'fulfilled' && articleData.value?.data?.length > 0) {
|
||
setArticles(articleData.value.data);
|
||
}
|
||
} catch (err) {
|
||
console.warn('[home] 加载首页数据失败:', err);
|
||
setBanners(FALLBACK_SLIDES);
|
||
Taro.showToast({ title: '内容加载失败', icon: 'none' });
|
||
}
|
||
};
|
||
|
||
usePageData(loadPublicData, { throttleMs: 10_000, enablePullDown: true, enabled: true });
|
||
|
||
const slides = banners.length > 0 ? banners : FALLBACK_SLIDES;
|
||
|
||
const ARTICLE_ICONS = ['♥', '◇', '✦'];
|
||
const ARTICLE_COLORS = ['pri', 'acc', 'wrn'] as const;
|
||
|
||
const formatDate = (dateStr?: string) => {
|
||
if (!dateStr) return '';
|
||
const d = new Date(dateStr);
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
};
|
||
|
||
return (
|
||
<PageShell padding="none" safeBottom={false} scroll={false} className={`guest-page ${modeClass}`}>
|
||
<Swiper
|
||
className='guest-swiper'
|
||
indicatorDots
|
||
indicatorColor='rgba(255,255,255,0.4)'
|
||
indicatorActiveColor='#FFFFFF'
|
||
autoplay={swiperAutoplay}
|
||
circular
|
||
interval={4000}
|
||
duration={500}
|
||
>
|
||
{slides.map((slide, idx) => (
|
||
<SwiperItem key={slide.id || idx}>
|
||
<View className='guest-slide'>
|
||
{(slide.image_url) ? (
|
||
<Image className='guest-slide-image' src={slide.image_url} mode='aspectFill' lazyLoad />
|
||
) : (
|
||
<View className={`guest-slide-bg guest-slide-bg--${(idx % 3) + 1}`} />
|
||
)}
|
||
<View className='guest-slide-content'>
|
||
<Text className='guest-slide-title'>{slide.title}</Text>
|
||
<Text className='guest-slide-desc'>{slide.subtitle || slide.desc}</Text>
|
||
</View>
|
||
</View>
|
||
</SwiperItem>
|
||
))}
|
||
</Swiper>
|
||
|
||
{/* 健康资讯 */}
|
||
<View className='guest-section'>
|
||
<View className='guest-section-header'>
|
||
<Text className='guest-section-title'>健康资讯</Text>
|
||
<Text
|
||
className='guest-section-more'
|
||
onClick={() => safeNavigateTo('/pages/article/index')}
|
||
>
|
||
查看全部 ›
|
||
</Text>
|
||
</View>
|
||
|
||
{articles.length > 0 ? (
|
||
<View className='guest-articles'>
|
||
{articles.map((article, i) => (
|
||
<View
|
||
key={article.id}
|
||
className={`guest-article-card guest-article-card--${ARTICLE_COLORS[i % 3]}`}
|
||
onClick={() => safeNavigateTo(`/pages/article/detail/index?id=${article.id}`)}
|
||
>
|
||
<View className='guest-article-icon'>
|
||
<Text className='guest-article-icon-char'>{ARTICLE_ICONS[i % 3]}</Text>
|
||
</View>
|
||
<View className='guest-article-body'>
|
||
<Text className='guest-article-title'>{article.title}</Text>
|
||
<Text className='guest-article-date'>{formatDate(article.published_at)}</Text>
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : (
|
||
<View className='guest-articles'>
|
||
<View className='guest-article-card guest-article-card--pri'>
|
||
<View className='guest-article-icon'><Text className='guest-article-icon-char'>♥</Text></View>
|
||
<View className='guest-article-body'>
|
||
<Text className='guest-article-title'>健康数据管理</Text>
|
||
<Text className='guest-article-date'>记录并追踪您的体征数据</Text>
|
||
</View>
|
||
</View>
|
||
<View className='guest-article-card guest-article-card--acc'>
|
||
<View className='guest-article-icon'><Text className='guest-article-icon-char'>◇</Text></View>
|
||
<View className='guest-article-body'>
|
||
<Text className='guest-article-title'>积分商城</Text>
|
||
<Text className='guest-article-date'>签到赚积分,好礼兑不停</Text>
|
||
</View>
|
||
</View>
|
||
<View className='guest-article-card guest-article-card--wrn'>
|
||
<View className='guest-article-icon'><Text className='guest-article-icon-char'>✦</Text></View>
|
||
<View className='guest-article-body'>
|
||
<Text className='guest-article-title'>专业科普文章</Text>
|
||
<Text className='guest-article-date'>权威健康知识推送</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* 底部注册引导 */}
|
||
<View className='guest-cta-card'>
|
||
<Text className='guest-cta-title'>加入我们</Text>
|
||
<Text className='guest-cta-desc'>注册后即可使用签到、积分商城等全部功能</Text>
|
||
<View className='guest-cta-btn' onClick={navigateToLogin}>
|
||
<Text className='guest-cta-btn-text'>注册 / 登录</Text>
|
||
</View>
|
||
</View>
|
||
</PageShell>
|
||
);
|
||
}
|
||
|
||
// ─── 登录后首页 ───
|
||
|
||
function SOSButton() {
|
||
const user = useAuthStore((s) => s.user);
|
||
const isMedicalStaff = useAuthStore((s) => s.isMedicalStaff);
|
||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||
|
||
if (!user || !currentPatient || isMedicalStaff()) return null;
|
||
|
||
const handleSOS = async () => {
|
||
const { confirm } = await Taro.showModal({
|
||
title: '紧急求助',
|
||
content: '是否拨打急救电话?',
|
||
confirmText: '拨打',
|
||
cancelText: '取消',
|
||
});
|
||
if (confirm) {
|
||
Taro.makePhoneCall({
|
||
phoneNumber: '120',
|
||
fail: () => Taro.showToast({ title: '拨号失败', icon: 'none' }),
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<View className='sos-btn' onClick={handleSOS}>
|
||
<Text className='sos-btn-text'>SOS</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function HomeDashboard({ modeClass }: { modeClass: string }) {
|
||
const {
|
||
healthItems, indicatorCapsules, completedCount, progressPercent,
|
||
loading, todaySummary, reminders, remindersLoading, unreadCount,
|
||
greeting, displayName,
|
||
} = useHomeData();
|
||
|
||
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 (
|
||
<PageShell padding="md" safeBottom={false} scroll={false} 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>
|
||
|
||
<ContentCard variant="elevated" onPress={() => Taro.switchTab({ url: '/pages/health/index' })}>
|
||
<View className='checkin-left'>
|
||
<ProgressRing progress={progressPercent / 100} />
|
||
</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>
|
||
</ContentCard>
|
||
|
||
<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 (
|
||
<ContentCard
|
||
key={item.label}
|
||
onPress={() => safeNavigateTo(`/pages/pkg-health/trend/index?indicator=${item.indicator}`)}
|
||
activeFeedback="opacity"
|
||
>
|
||
<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>
|
||
</ContentCard>
|
||
);
|
||
})}
|
||
</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') safeNavigateTo('/pages/appointment/index');
|
||
else if (r.type === 'followup') safeNavigateTo(`/pages/pkg-profile/followups/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={() => safeNavigateTo('/pages/appointment/create/index')}>
|
||
<Text className='action-btn-text'>预约挂号</Text>
|
||
</View>
|
||
</View>
|
||
<SOSButton />
|
||
</PageShell>
|
||
);
|
||
}
|
||
|
||
// ─── 首页入口 ───
|
||
|
||
export default function Index() {
|
||
const user = useAuthStore((s) => s.user);
|
||
const isMedicalStaff = useAuthStore((s) => s.isMedicalStaff);
|
||
const mode = useUIStore((s) => s.mode);
|
||
const modeClass = mode === 'elder' ? 'elder-mode' : '';
|
||
|
||
// 医护人员访问患者首页时,自动跳转到医生端
|
||
// 不渲染 HomeDashboard,避免触发患者首页的 API 请求(并发叠加问题)
|
||
const shouldRedirect = !!(user && isMedicalStaff());
|
||
|
||
useDidShow(() => {
|
||
if (shouldRedirect) {
|
||
Taro.reLaunch({
|
||
url: '/pages/pkg-doctor-core/index',
|
||
fail: () => {
|
||
console.warn('跳转医生端失败,停留患者首页');
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
// 未登录 → 访客首页
|
||
if (!user) return <GuestHome modeClass={modeClass} />;
|
||
// 医护人员 → 等待跳转(返回 null 避免无用渲染)
|
||
if (shouldRedirect) return null;
|
||
// 患者用户 → 正常首页
|
||
return <HomeDashboard modeClass={modeClass} />;
|
||
}
|