Files
hms/apps/miniprogram/src/pages/index/index.tsx
iven 7ad5ddb898 fix(mp): Phase 3 品质打磨 — Loading优化+ErrorBoundary重试上限+登录安全+输入限制
- Loading 组件区分列表底部状态(无spinner)vs 加载中状态
- ErrorBoundary 添加 MAX_RETRIES=3 限制,超出提示重启
- login 页 IS_SIMULATOR 改为 === 'develop' 精确匹配
- login 密码输入 type 改为 safe-password 防截屏
- appointment/create 备注输入添加 maxlength=200
- GuestHome "查看全部" 导航到文章列表页
2026-05-21 16:30:50 +08:00

369 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} />;
}