后端: - erp-message: 添加 appointment.created/confirmed/cancelled 事件监听,自动发送站内通知 - erp-health: 新增 GET /health/patient-tags 标签列表端点 + list_tags service - wechat-templates: 添加 isTemplateConfigured 运行时校验 前端: - 新增 Zustand useHealthStore 共享患者/医生名称缓存 - PatientTagManage: UUID 输入替换为 Checkbox 标签选择器 - VitalSignsTab: 添加体征数据录入 Modal (血压/心率/体重/血糖) - LabReportsTab: 添加化验报告创建 Modal - HealthRecordsTab: 添加健康记录创建 Modal - patients API: 添加 TagItem 类型 + listTags 方法 小程序: - 首页待办事项接入预约和随访 API,替换硬编码 EmptyState
184 lines
7.1 KiB
TypeScript
184 lines
7.1 KiB
TypeScript
import { View, Text } from '@tarojs/components';
|
||
import { useState } from 'react';
|
||
import Taro, { useDidShow } from '@tarojs/taro';
|
||
import { useAuthStore } from '../../stores/auth';
|
||
import { useHealthStore } from '../../stores/health';
|
||
import EmptyState from '../../components/EmptyState';
|
||
import Loading from '../../components/Loading';
|
||
import { trackPageView } from '@/services/analytics';
|
||
import * as appointmentApi from '@/services/appointment';
|
||
import * as followupApi from '@/services/followup';
|
||
import './index.scss';
|
||
|
||
interface UpcomingItem {
|
||
id: string;
|
||
title: string;
|
||
subtitle: string;
|
||
type: 'appointment' | 'followup';
|
||
}
|
||
|
||
export default function Index() {
|
||
const { user, currentPatient, restore: restoreAuth } = useAuthStore();
|
||
const { todaySummary, loading, refreshToday } = useHealthStore();
|
||
const [upcomingItems, setUpcomingItems] = useState<UpcomingItem[]>([]);
|
||
const [upcomingLoading, setUpcomingLoading] = useState(false);
|
||
|
||
useDidShow(() => {
|
||
restoreAuth();
|
||
refreshToday();
|
||
loadUpcoming();
|
||
trackPageView('home');
|
||
});
|
||
|
||
const loadUpcoming = async () => {
|
||
const patientId = useAuthStore.getState().currentPatient?.id;
|
||
if (!patientId) return;
|
||
setUpcomingLoading(true);
|
||
try {
|
||
const items: UpcomingItem[] = [];
|
||
const [apptRes, taskRes] = await Promise.allSettled([
|
||
appointmentApi.listAppointments(patientId, 1),
|
||
followupApi.listTasks(patientId, 'pending'),
|
||
]);
|
||
if (apptRes.status === 'fulfilled') {
|
||
for (const a of apptRes.value.data.slice(0, 3)) {
|
||
if (a.status === 'pending' || a.status === 'confirmed') {
|
||
items.push({
|
||
id: a.id,
|
||
title: `预约: ${a.appointment_date} ${a.start_time}`,
|
||
subtitle: `${a.doctor_name || '医护'} · ${a.status === 'pending' ? '待确认' : '已确认'}`,
|
||
type: 'appointment',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (taskRes.status === 'fulfilled') {
|
||
for (const t of taskRes.value.data.slice(0, 2)) {
|
||
items.push({
|
||
id: t.id,
|
||
title: `随访: ${t.task_type}`,
|
||
subtitle: `${t.description?.slice(0, 30) || ''} · 截止 ${t.due_date}`,
|
||
type: 'followup',
|
||
});
|
||
}
|
||
}
|
||
setUpcomingItems(items);
|
||
} catch {
|
||
setUpcomingItems([]);
|
||
} finally {
|
||
setUpcomingLoading(false);
|
||
}
|
||
};
|
||
|
||
const hour = new Date().getHours();
|
||
const greeting = hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好';
|
||
const displayName = user?.display_name || currentPatient?.name || '访客';
|
||
|
||
const quickServices = [
|
||
{ label: '预约挂号', icon: '📅', path: '/pages/appointment/create/index' },
|
||
{ label: '健康录入', icon: '📊', path: '/pages/health/input/index' },
|
||
{ label: '健康趋势', icon: '📈', path: '/pages/health/trend/index' },
|
||
{ label: '资讯文章', icon: '📰', path: '/pages/article/index' },
|
||
];
|
||
|
||
const handleServiceClick = (path: string) => {
|
||
// tabBar 页面必须使用 switchTab,其他页面用 navigateTo
|
||
const isTabBar = ['pages/index/index', 'pages/health/index', 'pages/appointment/index', 'pages/article/index', 'pages/profile/index']
|
||
.some((p) => path.includes(p));
|
||
if (isTabBar) {
|
||
Taro.switchTab({ url: path });
|
||
} else {
|
||
Taro.navigateTo({ url: path });
|
||
}
|
||
};
|
||
|
||
const healthItems = [
|
||
{ label: '血压', value: todaySummary?.blood_pressure ? `${todaySummary.blood_pressure.systolic}/${todaySummary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', status: todaySummary?.blood_pressure?.status },
|
||
{ label: '心率', value: todaySummary?.heart_rate ? `${todaySummary.heart_rate.value}` : '--', unit: 'bpm', status: todaySummary?.heart_rate?.status },
|
||
{ label: '血糖', value: todaySummary?.blood_sugar ? `${todaySummary.blood_sugar.value}` : '--', unit: 'mmol/L', status: todaySummary?.blood_sugar?.status },
|
||
{ label: '体重', value: todaySummary?.weight ? `${todaySummary.weight.value}` : '--', unit: 'kg', status: todaySummary?.weight?.status },
|
||
];
|
||
|
||
const getStatusLabel = (status?: string) => {
|
||
if (status === 'high') return '偏高 ▲';
|
||
if (status === 'low') return '偏低 ▼';
|
||
if (status === 'normal') return '正常';
|
||
return '';
|
||
};
|
||
|
||
return (
|
||
<View className='index-page'>
|
||
<View className='greeting-bar'>
|
||
<View className='greeting-text'>
|
||
<Text className='greeting-hello'>{greeting},</Text>
|
||
<Text className='greeting-name'>{displayName}</Text>
|
||
</View>
|
||
<Text className='greeting-date'>{new Date().toLocaleDateString('zh-CN')}</Text>
|
||
</View>
|
||
|
||
<View className='health-card'>
|
||
<Text className='section-title'>今日健康</Text>
|
||
{loading && !todaySummary ? (
|
||
<Loading />
|
||
) : (
|
||
<View className='health-grid'>
|
||
{healthItems.map((item) => (
|
||
<View className={`health-item ${item.status === 'high' || item.status === 'low' ? 'health-item-warn' : item.status === 'normal' ? 'health-item-ok' : ''}`} key={item.label}>
|
||
<Text className='health-label'>{item.label}</Text>
|
||
<Text className='health-value'>{item.value}</Text>
|
||
<View className='health-item-bottom'>
|
||
<Text className='health-unit'>{item.unit}</Text>
|
||
{item.status && <Text className={`health-status ${item.status}`}>{getStatusLabel(item.status)}</Text>}
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
<View className='quick-services'>
|
||
<Text className='section-title'>快捷服务</Text>
|
||
<View className='service-grid'>
|
||
{quickServices.map((svc) => (
|
||
<View className='service-item' key={svc.label} onClick={() => handleServiceClick(svc.path)}>
|
||
<Text className='service-icon'>{svc.icon}</Text>
|
||
<Text className='service-label'>{svc.label}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
|
||
<View className='upcoming'>
|
||
<Text className='section-title'>待办事项</Text>
|
||
{upcomingLoading ? (
|
||
<Loading />
|
||
) : upcomingItems.length === 0 ? (
|
||
<EmptyState icon='📋' text='暂无待办事项' hint='预约挂号后将在此显示' />
|
||
) : (
|
||
<View className='upcoming-list'>
|
||
{upcomingItems.map((item) => (
|
||
<View
|
||
key={item.id}
|
||
className='upcoming-item'
|
||
onClick={() => {
|
||
if (item.type === 'appointment') {
|
||
Taro.navigateTo({ url: `/pages/appointment/index` });
|
||
} else {
|
||
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${item.id}` });
|
||
}
|
||
}}
|
||
>
|
||
<View className='upcoming-item-main'>
|
||
<Text className='upcoming-item-title'>{item.title}</Text>
|
||
<Text className='upcoming-item-sub'>{item.subtitle}</Text>
|
||
</View>
|
||
<Text className='upcoming-item-arrow'>›</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|