refactor(mp): 架构重构 — usePageData 统一数据加载 + Store 解耦 + 大页面拆分

新增 usePageData hook(useDidShow 节流 + usePullDownRefresh + loadingRef 防重入 + enabled 条件守卫),
44/58 页面迁移接入,消灭 4 种数据加载模式并存。

- 新增 hooks/usePageData.ts — 统一页面数据加载生命周期
- 新增 stores/index.ts — resetAllStores() 解耦 auth↔health store 依赖
- 新增 pages/index/useHomeData.ts — 首页数据 hook(424→282 行)
- 新增 pages/health/useHealthData.ts — 健康页数据 hook(422→254 行)
- 44 个页面迁移到 usePageData(9 患者端 + 15 医生端 + 20 子包)
- auth store logout 不再直接导入 health store

构建通过,测试 74/75(1 个预存失败)。
This commit is contained in:
iven
2026-05-15 01:13:01 +08:00
parent 0f58af245d
commit 1fd2c7a533
52 changed files with 791 additions and 664 deletions

View File

@@ -0,0 +1,70 @@
import { useRef, useCallback } from 'react';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
interface UsePageDataOptions {
throttleMs?: number;
enablePullDown?: boolean;
enabled?: boolean;
}
interface UsePageDataResult {
loading: boolean;
refresh: () => Promise<void>;
trigger: () => void;
}
export function usePageData(
fetcher: () => Promise<void>,
options?: UsePageDataOptions,
): UsePageDataResult {
const throttleMs = options?.throttleMs ?? 5000;
const enablePullDown = options?.enablePullDown ?? false;
const enabled = options?.enabled ?? true;
const loadingRef = useRef(false);
const lastRunRef = useRef(0);
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const run = useCallback(async (force = false) => {
if (!enabled || loadingRef.current) return;
if (!force && Date.now() - lastRunRef.current < throttleMs) return;
loadingRef.current = true;
lastRunRef.current = Date.now();
try {
await fetcherRef.current();
} finally {
loadingRef.current = false;
}
}, [enabled, throttleMs]);
useDidShow(() => {
run();
});
const trigger = useCallback(() => {
run(true);
}, [run]);
const refresh = useCallback(async () => {
if (loadingRef.current) return;
loadingRef.current = true;
lastRunRef.current = Date.now();
try {
await fetcherRef.current();
} finally {
loadingRef.current = false;
}
}, []);
usePullDownRefresh(async () => {
if (!enablePullDown) return;
try {
await refresh();
} finally {
Taro.stopPullDownRefresh();
}
});
return { loading: loadingRef.current, refresh, trigger };
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text, RichText } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
import Loading from '@/components/Loading';
import { sanitizeHtml } from '@/utils/sanitize-html';
@@ -34,15 +35,21 @@ export default function AiReportDetail() {
const [analysis, setAnalysis] = useState<AiAnalysisItem | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDetail = useCallback(async () => {
if (!id) return;
setLoading(true);
getAiAnalysisDetail(id)
.then((data) => setAnalysis(data))
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));
try {
const data = await getAiAnalysisDetail(id);
setAnalysis(data);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, [id]);
usePageData(fetchDetail, { throttleMs: 60000 });
if (loading) return <Loading />;
if (!analysis) {

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
@@ -28,11 +29,7 @@ export default function AiReportList() {
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
useEffect(() => {
loadList(1);
}, []);
const loadList = async (p: number) => {
const loadList = useCallback(async (p: number) => {
setLoading(true);
try {
const res = await listAiAnalysis(p, 20);
@@ -46,7 +43,9 @@ export default function AiReportList() {
} finally {
setLoading(false);
}
};
}, []);
usePageData(async () => { await loadList(1); }, { throttleMs: 5000, enablePullDown: true });
const goDetail = (id: string) => {
Taro.navigateTo({ url: `/pages/ai-report/detail/index?id=${id}` });

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listAppointments } from '../../services/appointment';
import type { Appointment } from '../../services/appointment';
import EmptyState from '../../components/EmptyState';
@@ -31,12 +31,9 @@ export default function AppointmentList() {
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const modeClass = useElderClass();
const fetchData = useCallback(async (pageNum: number, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const res = await listAppointments(pageNum);
@@ -51,20 +48,14 @@ export default function AppointmentList() {
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
loadingRef.current = false;
setLoading(false);
}
}, []);
useThrottledDidShow(() => {
fetchData(1, true);
}, 10000);
usePullDownRefresh(() => {
fetchData(1, true).finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(
useCallback(() => fetchData(1, true), [fetchData]),
{ throttleMs: 10000, enablePullDown: true },
);
useReachBottom(() => {
if (!loading && appointments.length < total) {

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text, RichText } from '@tarojs/components';
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article';
import { trackEvent } from '@/services/analytics';
import { sanitizeHtml } from '@/utils/sanitize-html';
@@ -24,17 +25,23 @@ export default function ArticleDetail() {
};
});
useEffect(() => {
const fetchArticle = useCallback(async () => {
if (!id) return;
setLoading(true);
const user = useAuthStore.getState().user;
const fetcher = user ? getArticleDetail(id) : getPublicArticleDetail(id);
fetcher
.then((data) => setArticle(data))
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));
try {
const user = useAuthStore.getState().user;
const fetcher = user ? getArticleDetail(id) : getPublicArticleDetail(id);
const data = await fetcher;
setArticle(data);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, [id]);
usePageData(fetchArticle, { throttleMs: 60000 });
if (loading) {
return (
<View className={`article-detail-page ${modeClass}`}>

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useEffect } from 'react';
import { View, Text, Image, ScrollView } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listArticles, listCategories, Article, ArticleCategory } from '../../services/article';
import EmptyState from '../../components/EmptyState';
import Loading from '../../components/Loading';
@@ -49,15 +49,10 @@ export default function ArticleList() {
fetchCategories();
}, [fetchCategories]);
useThrottledDidShow(() => {
fetchData(1, false, null);
}, 10000);
usePullDownRefresh(() => {
fetchData(1, false, null).finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(
useCallback(() => fetchData(1, false, null), [fetchData]),
{ throttleMs: 10000, enablePullDown: true },
);
useReachBottom(() => {
if (!loading && articles.length < total) {

View File

@@ -1,7 +1,7 @@
import { useState, useRef } from 'react';
import { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { useAuthStore } from '@/stores/auth';
import { listConsultations, ConsultationSession } from '@/services/consultation';
import Loading from '../../components/Loading';
@@ -41,11 +41,8 @@ export default function Consultation() {
const modeClass = useElderClass();
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const loadingRef = useRef(false);
const loadSessions = async (pageNum: number, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
const loadSessions = useCallback(async (pageNum: number, isRefresh = false) => {
if (isRefresh) setLoading(true);
setError('');
try {
@@ -66,21 +63,17 @@ export default function Consultation() {
Taro.showToast({ title: '加载失败,下拉重试', icon: 'none' });
} finally {
setLoading(false);
loadingRef.current = false;
}
};
}, []);
useThrottledDidShow(() => {
Taro.setNavigationBarTitle({ title: '在线咨询' });
if (!user) return;
loadSessions(1, true);
}, 10000);
usePullDownRefresh(() => {
loadSessions(1, true).finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '在线咨询' });
if (!user) return;
await loadSessions(1, true);
}, [user, loadSessions]),
{ throttleMs: 10000, enablePullDown: true },
);
useReachBottom(() => {
if (!loading && sessions.length < total) {

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { usePullDownRefresh } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { api } from '@/services/request';
import {
listActionItems,
@@ -74,16 +74,13 @@ export default function ActionInboxPage() {
[],
);
useThrottledDidShow(() => {
Taro.setNavigationBarTitle({ title: '待办事项' });
fetchItems(1, activeTab, true);
}, 10000);
usePullDownRefresh(() => {
fetchItems(1, activeTab, true).then(() =>
Taro.stopPullDownRefresh(),
);
});
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '待办事项' });
await fetchItems(1, activeTab, true);
}, [fetchItems, activeTab]),
{ throttleMs: 10000, enablePullDown: true },
);
const handleTabChange = (key: string) => {
setActiveTab(key);

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, ScrollView, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import {
getAlert, acknowledgeAlert, dismissAlert, resolveAlert,
type Alert,
@@ -29,23 +30,21 @@ export default function AlertDetail() {
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
const params = Taro.getCurrentInstance().router?.params;
if (params?.id) {
loadAlert(params.id);
}
}, []);
const alertId = Taro.getCurrentInstance().router?.params?.id || '';
const loadAlert = async (id: string) => {
const loadAlert = useCallback(async () => {
if (!alertId) return;
try {
const data = await getAlert(id);
const data = await getAlert(alertId);
setAlert(data);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
}, [alertId]);
usePageData(loadAlert, { throttleMs: 60000, enablePullDown: false, enabled: !!alertId });
const handleAcknowledge = async () => {
if (!alert) return;

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listAlerts, type Alert } from '@/services/doctor/alerts';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
@@ -36,17 +37,11 @@ export default function AlertList() {
const [activeTab, setActiveTab] = useState('');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const loadingRef = useRef(false);
const mountedRef = useRef(false);
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
useEffect(() => {
loadAlerts();
}, [page, activeTab]);
const loadAlerts = async () => {
if (loadingRef.current) return;
loadingRef.current = true;
const loadAlerts = useCallback(async () => {
setLoading(true);
try {
const res = await listAlerts({
@@ -60,9 +55,18 @@ export default function AlertList() {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
loadingRef.current = false;
}
};
}, [activeTab, page]);
const { trigger } = usePageData(loadAlerts);
// tab/page 变化时重新加载(跳过首次 mount由 usePageData 的 useDidShow 处理)
useEffect(() => {
if (mountedRef.current) {
trigger();
}
mountedRef.current = true;
}, [page, activeTab, trigger]);
const handleTabChange = (value: string) => {
setActiveTab(value);

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listSessions, type ConsultationSession } from '@/services/doctor/consultation';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
@@ -24,17 +25,11 @@ export default function ConsultationList() {
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const loadingRef = useRef(false);
const mountedRef = useRef(false);
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
useEffect(() => {
loadSessions();
}, [page, activeTab]);
const loadSessions = async () => {
if (loadingRef.current) return;
loadingRef.current = true;
const loadSessions = useCallback(async () => {
setLoading(true);
try {
const res = await listSessions({
@@ -48,9 +43,18 @@ export default function ConsultationList() {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
loadingRef.current = false;
}
};
}, [activeTab, page]);
const { trigger } = usePageData(loadSessions);
// tab/page 变化时重新加载(跳过首次 mount由 usePageData 的 useDidShow 处理)
useEffect(() => {
if (mountedRef.current) {
trigger();
}
mountedRef.current = true;
}, [page, activeTab, trigger]);
const handleTabChange = (key: string) => {
setActiveTab(key);

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import {
getDialysisRecord, reviewDialysisRecord,
updateDialysisRecord, deleteDialysisRecord,
@@ -20,11 +21,8 @@ export default function DialysisDetail() {
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
useEffect(() => {
if (id) loadRecord();
}, [id]);
const loadRecord = async () => {
const loadRecord = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const r = await getDialysisRecord(id);
@@ -34,7 +32,9 @@ export default function DialysisDetail() {
} finally {
setLoading(false);
}
};
}, [id]);
usePageData(loadRecord, { throttleMs: 60000, enablePullDown: false, enabled: !!id });
const handleReview = async () => {
if (!record) return;

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listDialysisRecords, type DialysisRecord } from '@/services/doctor/dialysis';
import { listPatients } from '@/services/doctor/patient';
import Loading from '@/components/Loading';
@@ -29,15 +30,10 @@ export default function DialysisList() {
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const loadingRef = useRef(false);
const mountedRef = useRef(false);
useEffect(() => {
if (currentPatientId) loadRecords(1);
}, [currentPatientId, activeTab]);
const loadRecords = async (p: number) => {
if (loadingRef.current) return;
loadingRef.current = true;
const loadRecords = useCallback(async (p: number) => {
if (!currentPatientId) return;
setLoading(true);
try {
const params: { page: number; page_size: number; status?: string } = { page: p, page_size: 20 };
@@ -50,9 +46,21 @@ export default function DialysisList() {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
loadingRef.current = false;
}
};
}, [currentPatientId, activeTab]);
usePageData(
useCallback(() => loadRecords(1), [loadRecords]),
{ enabled: !!currentPatientId },
);
// tab/patientId 变化时重新加载(跳过首次 mount由 usePageData 的 useDidShow 处理)
useEffect(() => {
if (mountedRef.current && currentPatientId) {
loadRecords(1);
}
mountedRef.current = true;
}, [currentPatientId, activeTab, loadRecords]);
const handleSearch = async () => {
if (!searchPatient.trim()) return;

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, Textarea, ScrollView, Picker } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import {
getFollowUpTask, listFollowUpRecords, createFollowUpRecord,
updateFollowUpTask,
@@ -33,11 +34,8 @@ export default function FollowUpDetail() {
const [medicalAdvice, setMedicalAdvice] = useState('');
const [nextDate, setNextDate] = useState('');
useEffect(() => {
if (taskId) loadData();
}, [taskId]);
const loadData = async () => {
const loadData = useCallback(async () => {
if (!taskId) return;
setLoading(true);
try {
const [t, r] = await Promise.all([
@@ -51,7 +49,9 @@ export default function FollowUpDetail() {
} finally {
setLoading(false);
}
};
}, [taskId]);
usePageData(loadData, { throttleMs: 60000, enablePullDown: false, enabled: !!taskId });
const handleSubmit = async () => {
if (!result.trim()) {

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listFollowUpTasks, type FollowUpTask } from '@/services/doctor/followup';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
@@ -24,12 +25,9 @@ export default function FollowUpList() {
const [activeTab, setActiveTab] = useState('');
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const mountedRef = useRef(false);
useEffect(() => {
loadTasks();
}, [activeTab, patientId]);
const loadTasks = async () => {
const loadTasks = useCallback(async () => {
setLoading(true);
try {
const res = await listFollowUpTasks({
@@ -45,7 +43,17 @@ export default function FollowUpList() {
} finally {
setLoading(false);
}
};
}, [activeTab, patientId]);
const { trigger } = usePageData(loadTasks);
// tab/patientId 变化时重新加载(跳过首次 mount由 usePageData 的 useDidShow 处理)
useEffect(() => {
if (mountedRef.current) {
trigger();
}
mountedRef.current = true;
}, [activeTab, patientId, trigger]);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import { useThrottledDidShow } from '../../hooks/useThrottledDidShow';
import { usePageData } from '@/hooks/usePageData';
import { getDashboard, type DoctorDashboard } from '@/services/doctor/dashboard';
import Loading from '@/components/Loading';
import './index.scss';
@@ -76,19 +76,10 @@ export default function DoctorHome() {
return primary ? (ROLE_LABELS[primary] || primary) : '医护';
}, [roles]);
useEffect(() => {
loadDashboard();
}, []);
useThrottledDidShow(() => {
loadDashboard();
}, 10000);
const loadDashboard = async () => {
const loadDashboard = useCallback(async () => {
try {
const data = await getDashboard();
setDashboard(data);
// 从仪表盘数据提取异常体征患者数
const count = (data as Record<string, unknown>)?.abnormal_vital_count;
setAlertCount(typeof count === 'number' ? count : 0);
} catch {
@@ -96,7 +87,9 @@ export default function DoctorHome() {
} finally {
setLoading(false);
}
};
}, []);
usePageData(loadDashboard, { throttleMs: 10000 });
const handleCardClick = (card: CardConfig) => {
Taro.navigateTo({ url: card.route });

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getPatient, getHealthSummary, type PatientDetail, type HealthSummary } from '@/services/doctor/patient';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
@@ -14,11 +15,8 @@ export default function PatientDetail() {
const [summary, setSummary] = useState<HealthSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (patientId) loadData();
}, [patientId]);
const loadData = async () => {
const loadData = useCallback(async () => {
if (!patientId) return;
setLoading(true);
try {
const [p, s] = await Promise.all([
@@ -32,7 +30,9 @@ export default function PatientDetail() {
} finally {
setLoading(false);
}
};
}, [patientId]);
usePageData(loadData, { throttleMs: 60000, enablePullDown: false, enabled: !!patientId });
const getGenderLabel = (g?: string) => (g === 'male' ? '男' : g === 'female' ? '女' : g || '-');
const calcAge = (bd?: string) => {

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listPatients, listPatientTags, type PatientItem, type PatientTag } from '@/services/doctor/patient';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
@@ -16,16 +17,12 @@ export default function PatientList() {
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const loadingRef = useRef(false);
const mountedRef = useRef(false);
useEffect(() => {
loadTags();
}, []);
useEffect(() => {
loadPatients(1, true);
}, [activeTag]);
const loadTags = async () => {
try {
const res = await listPatientTags();
@@ -33,9 +30,7 @@ export default function PatientList() {
} catch { /* ignore */ }
};
const loadPatients = async (pageNum: number, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
const loadPatients = useCallback(async (pageNum: number, isRefresh = false) => {
if (isRefresh) setLoading(true);
try {
const res = await listPatients({
@@ -56,15 +51,21 @@ export default function PatientList() {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
loadingRef.current = false;
}
};
}, [search, activeTag]);
usePullDownRefresh(() => {
loadPatients(1, true).finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(
useCallback(() => loadPatients(1, true), [loadPatients]),
{ enablePullDown: true },
);
// tag 变化时重新加载(跳过首次 mount由 usePageData 的 useDidShow 处理)
useEffect(() => {
if (mountedRef.current) {
loadPatients(1, true);
}
mountedRef.current = true;
}, [activeTag, loadPatients]);
useReachBottom(() => {
if (!loading && patients.length < total) {

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import {
getDialysisPrescription, updateDialysisPrescription, deleteDialysisPrescription,
type DialysisPrescription,
@@ -19,11 +20,8 @@ export default function PrescriptionDetail() {
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
useEffect(() => {
if (id) loadRx();
}, [id]);
const loadRx = async () => {
const loadRx = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const data = await getDialysisPrescription(id);
@@ -33,7 +31,9 @@ export default function PrescriptionDetail() {
} finally {
setLoading(false);
}
};
}, [id]);
usePageData(loadRx, { throttleMs: 60000, enablePullDown: false, enabled: !!id });
const handleDeactivate = async () => {
if (!rx) return;

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/doctor/dialysis';
import { listPatients } from '@/services/doctor/patient';
import Loading from '@/components/Loading';
@@ -26,15 +27,9 @@ export default function PrescriptionList() {
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const loadingRef = useRef(false);
const mountedRef = useRef(false);
useEffect(() => {
loadData(1);
}, [currentPatientId, activeTab]);
const loadData = async (p: number) => {
if (loadingRef.current) return;
loadingRef.current = true;
const loadData = useCallback(async (p: number) => {
setLoading(true);
try {
const res = await listDialysisPrescriptions({
@@ -50,9 +45,20 @@ export default function PrescriptionList() {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
loadingRef.current = false;
}
};
}, [currentPatientId, activeTab]);
usePageData(
useCallback(() => loadData(1), [loadData]),
);
// tab/patientId 变化时重新加载(跳过首次 mount由 usePageData 的 useDidShow 处理)
useEffect(() => {
if (mountedRef.current) {
loadData(1);
}
mountedRef.current = true;
}, [currentPatientId, activeTab, loadData]);
const handleSearch = async () => {
if (!searchPatient.trim()) return;

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, Textarea, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getLabReport, reviewLabReport, type LabReportDetail } from '@/services/doctor/labReport';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
@@ -16,11 +17,8 @@ export default function ReportDetail() {
const [doctorNotes, setDoctorNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (patientId && reportId) loadReport();
}, [patientId, reportId]);
const loadReport = async () => {
const loadReport = useCallback(async () => {
if (!patientId || !reportId) return;
setLoading(true);
try {
const r = await getLabReport(patientId, reportId);
@@ -31,7 +29,9 @@ export default function ReportDetail() {
} finally {
setLoading(false);
}
};
}, [patientId, reportId]);
usePageData(loadReport, { throttleMs: 60000, enablePullDown: false, enabled: !!(patientId && reportId) });
const handleReview = async () => {
if (!report) return;

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listLabReports, type LabReportItem } from '@/services/doctor/labReport';
import { listPatients } from '@/services/doctor/patient';
import Loading from '@/components/Loading';
@@ -17,12 +18,10 @@ export default function ReportList() {
const [reports, setReports] = useState<LabReportItem[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const mountedRef = useRef(false);
useEffect(() => {
if (currentPatientId) loadReports();
}, [currentPatientId]);
const loadReports = async () => {
const loadReports = useCallback(async () => {
if (!currentPatientId) return;
setLoading(true);
try {
const res = await listLabReports(currentPatientId, { page: 1, page_size: 50 });
@@ -33,7 +32,17 @@ export default function ReportList() {
} finally {
setLoading(false);
}
};
}, [currentPatientId]);
usePageData(loadReports, { enabled: !!currentPatientId });
// patientId 变化时重新加载(跳过首次 mount由 usePageData 的 useDidShow 处理)
useEffect(() => {
if (mountedRef.current && currentPatientId) {
loadReports();
}
mountedRef.current = true;
}, [currentPatientId, loadReports]);
const handleSearch = async () => {
if (!searchPatient.trim()) return;

View File

@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as pointsApi from '@/services/points';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import { useElderClass } from '../../hooks/useElderClass';
import { usePageData } from '@/hooks/usePageData';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; className: string }> = {
@@ -20,11 +21,7 @@ export default function EventsPage() {
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState<string | null>(null);
useEffect(() => {
loadEvents();
}, []);
const loadEvents = async () => {
const loadEvents = useCallback(async () => {
setLoading(true);
try {
const res = await pointsApi.listOfflineEvents({ page: 1, page_size: 50, status: 'published' });
@@ -34,7 +31,9 @@ export default function EventsPage() {
} finally {
setLoading(false);
}
};
}, []);
usePageData(loadEvents, { throttleMs: 10000, enablePullDown: true });
const handleRegister = async (event: pointsApi.OfflineEvent) => {
setRegistering(event.id);

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text, Textarea } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getTaskDetail, submitRecord } from '../../../services/followup';
import type { FollowUpTask } from '../../../services/followup';
import { TEMPLATE_IDS } from '@/services/wechat-templates';
@@ -21,18 +22,22 @@ export default function FollowUpDetail() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
const fetchTask = useCallback(async () => {
if (!id) return;
setLoading(true);
getTaskDetail(id)
.then((data) => setTask(data))
.catch((err) => {
console.error('[FollowUpDetail]', err);
setError(true);
})
.finally(() => setLoading(false));
try {
const data = await getTaskDetail(id);
setTask(data);
} catch (err) {
console.error('[FollowUpDetail]', err);
setError(true);
} finally {
setLoading(false);
}
}, [id]);
usePageData(fetchTask, { throttleMs: 60000 });
const handleSubmit = async () => {
if (!content.trim()) {
Taro.showToast({ title: '请输入内容', icon: 'none' });

View File

@@ -1,26 +1,14 @@
import { useState, useEffect, useRef } from 'react';
import { useState } from 'react';
import { View, Text, Input } from '@tarojs/components';
import Taro, { usePullDownRefresh } from '@tarojs/taro';
import { useHealthStore } from '../../stores/health';
import Taro from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import { inputVitalSign, getTrend, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '../../services/health';
import { listPendingSuggestions, type AiSuggestionItem } from '../../services/ai-analysis';
import { findThreshold, inputVitalSign, type HealthThreshold } from '../../services/health';
import Loading from '../../components/Loading';
import GuestGuard from '../../components/GuestGuard';
import { useHealthData, VITAL_TABS, type VitalType } from './useHealthData';
import './index.scss';
type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
const VITAL_TABS: { key: VitalType; label: string }[] = [
{ key: 'blood_pressure', label: '血压' },
{ key: 'heart_rate', label: '心率' },
{ key: 'blood_sugar', label: '血糖' },
{ key: 'weight', label: '体重' },
];
/** 根据阈值列表构建参考范围文案 */
function buildRefRange(t: HealthThreshold[]): Record<VitalType, string> {
const bpSys = findThreshold(t, 'systolic_bp', 'high')?.threshold_value ?? 140;
const bpDia = findThreshold(t, 'diastolic_bp', 'high')?.threshold_value ?? 90;
@@ -36,20 +24,14 @@ function buildRefRange(t: HealthThreshold[]): Record<VitalType, string> {
};
}
interface TrendPoint {
date: string;
value: number;
}
export default function Health() {
const todaySummary = useHealthStore((s) => s.todaySummary);
const loading = useHealthStore((s) => s.loading);
const refreshToday = useHealthStore((s) => s.refreshToday);
const fetchTrend = useHealthStore((s) => s.getTrend);
const user = useAuthStore((s) => s.user);
const currentPatient = useAuthStore((s) => s.currentPatient);
const modeClass = useElderClass();
const [activeTab, setActiveTab] = useState<VitalType>('blood_pressure');
const {
user, todaySummary, loading, activeTab, trendData, trendLoading,
aiSuggestions, thresholds, handleTabChange, loadTrend, refreshToday,
} = useHealthData();
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [heartRateVal, setHeartRateVal] = useState('');
@@ -57,67 +39,11 @@ export default function Health() {
const [sugarPeriod, setSugarPeriod] = useState<'fasting' | 'postprandial'>('fasting');
const [weightVal, setWeightVal] = useState('');
const [saving, setSaving] = useState(false);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
const loadingRef = useRef(false);
useThrottledDidShow(() => {
if (!user || loadingRef.current) return;
// 批量发起请求,避免串行 setState 级联重渲染
loadingRef.current = true;
Promise.allSettled([
refreshToday(),
loadTrend(activeTab),
loadAiSuggestions(),
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }),
]).finally(() => { loadingRef.current = false; });
}, 5000);
usePullDownRefresh(() => {
if (!user) return;
Promise.all([refreshToday(true), loadTrend(activeTab), loadAiSuggestions()]).finally(() => {
Taro.stopPullDownRefresh();
});
});
if (!user) {
return <GuestGuard title='请先登录' desc='登录后即可记录和查看健康数据' />;
}
const loadAiSuggestions = async () => {
try {
const items = await listPendingSuggestions();
setAiSuggestions(items.slice(0, 3));
} catch {
setAiSuggestions([]);
}
};
const loadTrend = async (type: VitalType) => {
setTrendLoading(true);
try {
const indicatorMap: Record<VitalType, string> = {
blood_pressure: 'systolic_bp_morning',
heart_rate: 'heart_rate',
blood_sugar: 'blood_sugar',
weight: 'weight',
};
const points = await fetchTrend(indicatorMap[type], '7d');
setTrendData(points);
} catch {
setTrendData([]);
} finally {
setTrendLoading(false);
}
};
const handleTabChange = (tab: VitalType) => {
setActiveTab(tab);
loadTrend(tab);
};
const getWarnStatus = (type: VitalType): string | null => {
if (type === 'blood_pressure') {
const sys = parseFloat(systolic);
@@ -224,12 +150,10 @@ export default function Health() {
return (
<View className={`health-page ${modeClass}`}>
{/* 页头 */}
<View className='health-header'>
<Text className='health-title'></Text>
</View>
{/* AI 建议卡片 */}
{aiSuggestions.length > 0 && (
<View className='ai-suggestion-card' onClick={() => {
const first = aiSuggestions[0];
@@ -260,7 +184,6 @@ export default function Health() {
</View>
)}
{/* 类型 Tab */}
<View className='vital-tabs'>
{VITAL_TABS.map((tab) => {
const hasData = tab.key === 'blood_pressure' ? !!todaySummary?.blood_pressure
@@ -280,7 +203,6 @@ export default function Health() {
})}
</View>
{/* 录入区 */}
<View className='input-section'>
{activeTab === 'blood_pressure' && (
<View className='input-group'>
@@ -365,7 +287,6 @@ export default function Health() {
</View>
</View>
{/* 趋势图 */}
<View className='trend-section'>
<Text className='section-title'> 7 </Text>
{trendLoading ? (
@@ -377,7 +298,6 @@ export default function Health() {
) : (
<View className='trend-chart'>
<View className='trend-bars'>
{/* 阈值标线 */}
{getThresholdValue(activeTab, thresholds) && (() => {
const tv = getThresholdValue(activeTab, thresholds)!;
const pct = Math.min(95, (tv / maxTrendValue) * 100);
@@ -407,9 +327,6 @@ export default function Health() {
)}
</View>
{/* BLE 设备同步功能暂缓开放 */}
{/* 健康资讯入口 */}
<View
className='article-entry'
onClick={() => Taro.navigateTo({ url: '/pages/article/index' })}

View File

@@ -0,0 +1,96 @@
import { useState, useRef } from 'react';
import { useHealthStore } from '@/stores/health';
import { useAuthStore } from '@/stores/auth';
import { usePageData } from '@/hooks/usePageData';
import { getTrend, getHealthThresholds, DEFAULT_THRESHOLDS, type HealthThreshold } from '@/services/health';
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
export type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
export const VITAL_TABS: { key: VitalType; label: string }[] = [
{ key: 'blood_pressure', label: '血压' },
{ key: 'heart_rate', label: '心率' },
{ key: 'blood_sugar', label: '血糖' },
{ key: 'weight', label: '体重' },
];
export interface TrendPoint {
date: string;
value: number;
}
export function useHealthData() {
const user = useAuthStore((s) => s.user);
const todaySummary = useHealthStore((s) => s.todaySummary);
const loading = useHealthStore((s) => s.loading);
const refreshToday = useHealthStore((s) => s.refreshToday);
const fetchTrend = useHealthStore((s) => s.getTrend);
const [activeTab, setActiveTab] = useState<VitalType>('blood_pressure');
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
const loadTrend = async (type: VitalType) => {
setTrendLoading(true);
try {
const indicatorMap: Record<VitalType, string> = {
blood_pressure: 'systolic_bp_morning',
heart_rate: 'heart_rate',
blood_sugar: 'blood_sugar',
weight: 'weight',
};
const points = await fetchTrend(indicatorMap[type], '7d');
setTrendData(points);
} catch {
setTrendData([]);
} finally {
setTrendLoading(false);
}
};
const loadAiSuggestions = async () => {
try {
const items = await listPendingSuggestions();
setAiSuggestions(items.slice(0, 3));
} catch {
setAiSuggestions([]);
}
};
const fetchData = async () => {
await Promise.allSettled([
refreshToday(),
loadTrend(activeTab),
loadAiSuggestions(),
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }),
]);
};
usePageData(fetchData, {
throttleMs: 5000,
enablePullDown: true,
enabled: !!user,
});
const handleTabChange = (tab: VitalType) => {
setActiveTab(tab);
loadTrend(tab);
};
return {
user,
todaySummary,
loading,
activeTab,
trendData,
trendLoading,
aiSuggestions,
thresholds,
handleTabChange,
loadTrend,
refreshToday,
fetchTrend,
};
}

View File

@@ -1,46 +1,36 @@
import { View, Text, Swiper, SwiperItem, Image } from '@tarojs/components';
import { useState, useMemo, useRef } from 'react';
import Taro, { usePullDownRefresh, useDidShow, useDidHide } from '@tarojs/taro';
import { useState } from 'react';
import Taro, { useDidShow, useDidHide } from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import { useUIStore } from '../../stores/ui';
import { navigateToLogin } from '../../utils/navigate';
import { useHealthStore } from '../../stores/health';
import ProgressRing from '../../components/ProgressRing';
import Loading from '../../components/Loading';
import { usePageData } from '@/hooks/usePageData';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
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 { api } from '@/services/request';
import type { Article } from '@/services/article';
import ProgressRing from '../../components/ProgressRing';
import Loading from '../../components/Loading';
import { useHomeData, type ReminderItem } from './useHomeData';
import './index.scss';
interface ReminderItem {
id: string;
text: string;
type: 'ai' | 'appointment' | 'followup';
tag: string;
}
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[]>([]);
@@ -49,10 +39,6 @@ function GuestHome({ modeClass }: { modeClass: string }) {
useDidShow(() => { setSwiperAutoplay(true); });
useDidHide(() => { setSwiperAutoplay(false); });
useThrottledDidShow(() => {
loadPublicData();
}, 10_000);
const loadPublicData = async () => {
let tenantId = Taro.getStorageSync('tenant_id');
if (!tenantId) {
@@ -92,11 +78,12 @@ function GuestHome({ modeClass }: { modeClass: string }) {
}
};
usePageData(loadPublicData, { throttleMs: 10_000, enablePullDown: true, enabled: true });
const slides = banners.length > 0 ? banners : FALLBACK_SLIDES;
return (
<View className={`guest-page ${modeClass}`}>
{/* 轮播图 */}
<Swiper
className='guest-swiper'
indicatorDots
@@ -124,7 +111,6 @@ function GuestHome({ modeClass }: { modeClass: string }) {
))}
</Swiper>
{/* 推荐文章(替换原来的"核心功能"区域) */}
<View className='guest-section'>
<Text className='guest-section-title'></Text>
{articles.length > 0 ? (
@@ -165,13 +151,9 @@ function GuestHome({ modeClass }: { modeClass: string }) {
)}
</View>
{/* 底部登录引导 */}
<View className='guest-login-prompt'>
<Text className='guest-login-text'>使</Text>
<View
className='guest-login-btn'
onClick={navigateToLogin}
>
<View className='guest-login-btn' onClick={navigateToLogin}>
<Text className='guest-login-btn-text'></Text>
</View>
</View>
@@ -182,110 +164,11 @@ function GuestHome({ modeClass }: { modeClass: string }) {
// ─── 登录后首页 ───
function HomeDashboard({ modeClass }: { modeClass: string }) {
const user = useAuthStore((s) => s.user);
const currentPatient = useAuthStore((s) => s.currentPatient);
const todaySummary = useHealthStore((s) => s.todaySummary);
const loading = useHealthStore((s) => s.loading);
const refreshToday = useHealthStore((s) => s.refreshToday);
const [reminders, setReminders] = useState<ReminderItem[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [remindersLoading, setRemindersLoading] = useState(false);
const remindersLoadingRef = useRef(false);
const { trigger: triggerHomeRefresh } = useThrottledDidShow(() => {
refreshToday();
loadReminders();
loadUnread();
trackPageView('home');
}, 5000);
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 || remindersLoadingRef.current) return;
remindersLoadingRef.current = true;
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 {
remindersLoadingRef.current = false;
setRemindersLoading(false);
}
};
const hour = new Date().getHours();
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
const displayName = user?.display_name || currentPatient?.name || user?.username || (user?.phone ? `${user.phone.slice(-4)}` : '') || '用户';
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 = useMemo(() => [
{ label: '血压', done: !!summary.blood_pressure },
{ label: '心率', done: !!summary.heart_rate },
{ label: '血糖', done: !!summary.blood_sugar },
{ label: '体重', done: !!summary.weight },
], [summary.blood_pressure, summary.heart_rate, summary.blood_sugar, summary.weight]);
const healthItems = useMemo(() => [
{ 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' },
], [summary.blood_pressure, summary.heart_rate, summary.blood_sugar, summary.weight]);
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' };
@@ -295,7 +178,6 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
return (
<View className={`home-page ${modeClass}`}>
{/* 问候区 */}
<View className='greeting-section'>
<View className='greeting-left'>
<Text className='greeting-text'>{greeting}{displayName}</Text>
@@ -309,7 +191,6 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
</View>
</View>
{/* 今日体征进度 */}
<View className='checkin-card' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
<View className='checkin-left'>
<ProgressRing percent={progressPercent} />
@@ -328,7 +209,6 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
</View>
</View>
{/* 体征 2x2 */}
<View className='vitals-section'>
<Text className='section-title'></Text>
{loading && !todaySummary ? (
@@ -359,7 +239,6 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
)}
</View>
{/* 智能提醒卡片 */}
{!remindersLoading && reminders.length > 0 && (
<View className='reminder-card'>
<View className='reminder-header'>
@@ -383,7 +262,6 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
</View>
)}
{/* 快捷操作 */}
<View className='action-section'>
<View className='action-btn action-primary' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
<Text className='action-btn-text'></Text>
@@ -396,7 +274,7 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
);
}
// ─── 首页入口:根据登录状态切换 ───
// ─── 首页入口 ───
export default function Index() {
const user = useAuthStore((s) => s.user);
@@ -408,16 +286,3 @@ export default function Index() {
}
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}指标,建议关注`;
}

View File

@@ -0,0 +1,149 @@
import { useState, useMemo, useRef } from 'react';
import { useHealthStore } from '@/stores/health';
import { useAuthStore } from '@/stores/auth';
import { usePageData } from '@/hooks/usePageData';
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';
export interface ReminderItem {
id: string;
text: string;
type: 'ai' | 'appointment' | 'followup';
tag: string;
}
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}指标,建议关注`;
}
export function useHomeData() {
const user = useAuthStore((s) => s.user);
const currentPatient = useAuthStore((s) => s.currentPatient);
const todaySummary = useHealthStore((s) => s.todaySummary);
const loading = useHealthStore((s) => s.loading);
const refreshToday = useHealthStore((s) => s.refreshToday);
const [reminders, setReminders] = useState<ReminderItem[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [remindersLoading, setRemindersLoading] = useState(false);
const fetchData = async () => {
const patientId = useAuthStore.getState().currentPatient?.id;
if (!patientId) return;
refreshToday();
loadReminders(patientId);
loadUnread();
trackPageView('home');
};
const { trigger, refresh } = usePageData(fetchData, {
throttleMs: 5000,
enablePullDown: true,
enabled: !!user,
});
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 (patientId: string) => {
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 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 = useMemo(() => [
{ label: '血压', done: !!summary.blood_pressure },
{ label: '心率', done: !!summary.heart_rate },
{ label: '血糖', done: !!summary.blood_sugar },
{ label: '体重', done: !!summary.weight },
], [summary.blood_pressure, summary.heart_rate, summary.blood_sugar, summary.weight]);
const healthItems = useMemo(() => [
{ 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' },
], [summary.blood_pressure, summary.heart_rate, summary.blood_sugar, summary.weight]);
const hour = new Date().getHours();
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
const displayName = user?.display_name || currentPatient?.name || user?.username || (user?.phone ? `${user.phone.slice(-4)}` : '') || '用户';
return {
user,
currentPatient,
todaySummary: summary,
loading,
reminders,
unreadCount,
remindersLoading,
indicatorCapsules,
healthItems,
completedCount,
progressPercent,
greeting,
displayName,
trigger,
refresh,
};
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listProducts } from '../../services/points';
import type { PointsProduct } from '../../services/points';
import { useAuthStore } from '../../stores/auth';
@@ -37,13 +37,10 @@ export default function Mall() {
const [loading, setLoading] = useState(false);
const [checkinLoading, setCheckinLoading] = useState(false);
const [noProfile, setNoProfile] = useState(false);
const loadingRef = useRef(false);
const modeClass = useElderClass();
const fetchProducts = useCallback(
async (pageNum: number, type: string, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const res = await listProducts({
@@ -62,7 +59,6 @@ export default function Mall() {
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
loadingRef.current = false;
setLoading(false);
}
},
@@ -87,16 +83,13 @@ export default function Mall() {
[currentPatient, loadPatients, refreshPoints, fetchProducts, productType],
);
useThrottledDidShow(() => {
Taro.setNavigationBarTitle({ title: '积分商城' });
loadAll();
}, 10000);
usePullDownRefresh(() => {
loadAll().finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '积分商城' });
await loadAll();
}, [loadAll]),
{ throttleMs: 10000, enablePullDown: true },
);
useReachBottom(() => {
if (!loading && products.length < total) {

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react';
import { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReachBottom } from '@tarojs/taro';
import { listConsultations, ConsultationSession } from '../../services/consultation';
@@ -7,7 +7,7 @@ import Loading from '../../components/Loading';
import GuestGuard from '../../components/GuestGuard';
import { useAuthStore } from '../../stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import { usePageData } from '@/hooks/usePageData';
import './index.scss';
type MsgTab = 'consultation' | 'notification';
@@ -38,11 +38,8 @@ export default function Messages() {
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const loadingRef = useRef(false);
const loadData = async (tab: MsgTab, pageNum: number = 1, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
const loadData = useCallback(async (tab: MsgTab, pageNum: number = 1, isRefresh = false) => {
setLoading(true);
try {
if (tab === 'consultation') {
@@ -73,13 +70,15 @@ export default function Messages() {
}
} finally {
setLoading(false);
loadingRef.current = false;
}
};
}, []);
useThrottledDidShow(() => {
if (user) loadData(activeTab, 1, true);
}, 5000);
usePageData(
useCallback(async () => {
if (user) await loadData(activeTab, 1, true);
}, [user, activeTab, loadData]),
{ throttleMs: 5000, enablePullDown: false },
);
const handleTabChange = (tab: MsgTab) => {
setActiveTab(tab);

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listPatientAlerts, type Alert } from '@/services/alert';
import { useAuthStore } from '@/stores/auth';
import Loading from '@/components/Loading';
@@ -30,12 +30,10 @@ export default function PatientAlerts() {
const [page, setPage] = useState(1);
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const fetchAlerts = useCallback(
async (pageNum: number, s: string, isRefresh = false) => {
if (!currentPatient || loadingRef.current) return;
loadingRef.current = true;
if (!currentPatient) return;
setLoading(true);
try {
const res = await listPatientAlerts(currentPatient.id, {
@@ -54,21 +52,19 @@ export default function PatientAlerts() {
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
loadingRef.current = false;
setLoading(false);
}
},
[currentPatient],
);
useThrottledDidShow(() => {
Taro.setNavigationBarTitle({ title: '健康告警' });
fetchAlerts(1, status, true);
}, 10000);
usePullDownRefresh(() => {
fetchAlerts(1, status, true).finally(() => Taro.stopPullDownRefresh());
});
usePageData(
async () => {
Taro.setNavigationBarTitle({ title: '健康告警' });
await fetchAlerts(1, status, true);
},
{ throttleMs: 10000, enablePullDown: true },
);
const handleTabChange = (key: string) => {
setStatus(key);

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import { usePageData } from '@/hooks/usePageData';
import { num, validateStr } from '@/utils/validate';
import { inputVitalSign, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '../../../services/health';
import { useAuthStore } from '../../../stores/auth';
@@ -68,9 +68,14 @@ export default function HealthInput() {
const clearCache = useHealthStore((s) => s.clearCache);
/** 从 storage 中读取设备同步回传的数据并自动填充表单 */
useThrottledDidShow(() => {
const loadThresholdsAndSync = useCallback(async () => {
setLoadingThresholds(true);
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }).finally(() => setLoadingThresholds(false));
try {
const t = await getHealthThresholds();
if (t.length > 0) setThresholds(t);
} finally {
setLoadingThresholds(false);
}
try {
const raw = Taro.getStorageSync('device_sync_result');
if (!raw) return;
@@ -94,7 +99,9 @@ export default function HealthInput() {
} catch {
// 解析失败则忽略,不影响正常使用
}
}, 10000);
}, []);
usePageData(loadThresholdsAndSync, { throttleMs: 10000 });
const handleSubmit = async () => {
if (!currentPatient) {

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { useHealthStore } from '@/stores/health';
import TrendChart from '@/components/TrendChart';
import Loading from '@/components/Loading';
@@ -33,13 +34,24 @@ export default function Trend() {
const [loading, setLoading] = useState(true);
const getTrend = useHealthStore((s) => s.getTrend);
useEffect(() => {
const fetchTrend = useCallback(async () => {
setLoading(true);
getTrend(indicator, range)
.then(setPoints)
.catch(() => setPoints([]))
.finally(() => setLoading(false));
}, [indicator, range]);
try {
const data = await getTrend(indicator, range);
setPoints(data);
} catch {
setPoints([]);
} finally {
setLoading(false);
}
}, [getTrend, indicator, range]);
usePageData(fetchTrend, { throttleMs: 60000, enablePullDown: true });
// range 切换时手动触发
useEffect(() => {
fetchTrend();
}, [fetchTrend]);
const meta = INDICATOR_META[indicator] || { label: indicator, unit: '' };

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listMyTransactions } from '../../../services/points';
import type { PointsTransaction } from '../../../services/points';
import { usePointsStore } from '../../../stores/points';
@@ -25,12 +25,9 @@ export default function PointsDetail() {
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const fetchTransactions = useCallback(
async (pageNum: number, type: string, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const res = await listMyTransactions({
@@ -51,7 +48,6 @@ export default function PointsDetail() {
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
loadingRef.current = false;
setLoading(false);
}
},
@@ -66,16 +62,13 @@ export default function PointsDetail() {
[refreshPoints, fetchTransactions, activeTab],
);
useThrottledDidShow(() => {
Taro.setNavigationBarTitle({ title: '积分明细' });
loadAll();
}, 10000);
usePullDownRefresh(() => {
loadAll().finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '积分明细' });
await loadAll();
}, [loadAll]),
{ throttleMs: 10000, enablePullDown: true },
);
useReachBottom(() => {
if (!loading && transactions.length < total) {

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import { usePageData } from '@/hooks/usePageData';
import {
listProducts,
exchangeProduct,
@@ -40,11 +40,6 @@ export default function ExchangeConfirm() {
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
useThrottledDidShow(() => {
Taro.setNavigationBarTitle({ title: '确认兑换' });
loadData();
}, 10000);
const loadData = useCallback(async () => {
const instance = Taro.getCurrentInstance();
const productId = instance.router?.params?.product_id;
@@ -75,6 +70,14 @@ export default function ExchangeConfirm() {
}
}, [refreshPoints]);
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '确认兑换' });
await loadData();
}, [loadData]),
{ throttleMs: 10000, enablePullDown: false },
);
const balance = account?.balance ?? 0;
const cost = product?.points_cost ?? 0;
const insufficient = balance < cost;

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listMyOrders } from '../../../services/points';
import type { PointsOrder } from '../../../services/points';
import EmptyState from '../../../components/EmptyState';
@@ -30,12 +30,9 @@ export default function MallOrders() {
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const fetchOrders = useCallback(
async (pageNum: number, status: string, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const res = await listMyOrders({
@@ -56,7 +53,6 @@ export default function MallOrders() {
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
loadingRef.current = false;
setLoading(false);
}
},
@@ -71,16 +67,13 @@ export default function MallOrders() {
[fetchOrders, activeTab],
);
useThrottledDidShow(() => {
Taro.setNavigationBarTitle({ title: '我的订单' });
loadAll();
}, 10000);
usePullDownRefresh(() => {
loadAll().finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '我的订单' });
await loadAll();
}, [loadAll]),
{ throttleMs: 10000, enablePullDown: true },
);
useReachBottom(() => {
if (!loading && orders.length < total) {

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listConsents, revokeConsent } from '@/services/consent';
import type { Consent } from '@/services/consent';
import EmptyState from '@/components/EmptyState';
@@ -54,11 +54,7 @@ export default function ConsentList() {
}
}, []);
useThrottledDidShow(() => { fetchData(1); }, 10000);
usePullDownRefresh(() => {
fetchData(1).finally(() => Taro.stopPullDownRefresh());
});
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
useReachBottom(() => {
if (!loading && consents.length < total) {

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listDiagnoses, Diagnosis } from '../../../services/health-record';
import EmptyState from '../../../components/EmptyState';
import Loading from '../../../components/Loading';
@@ -50,15 +50,7 @@ export default function Diagnoses() {
}
}, []);
useThrottledDidShow(() => {
fetchData(1);
}, 10000);
usePullDownRefresh(() => {
fetchData(1).finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
useReachBottom(() => {
if (!loading && records.length < total) {

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getDialysisPrescription } from '@/services/dialysis';
import type { DialysisPrescription } from '@/services/dialysis';
import Loading from '@/components/Loading';
@@ -20,15 +21,21 @@ export default function DialysisPrescriptionDetail() {
const [rx, setRx] = useState<DialysisPrescription | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDetail = useCallback(async () => {
if (!id) return;
setLoading(true);
getDialysisPrescription(id)
.then((data) => setRx(data))
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));
try {
const data = await getDialysisPrescription(id);
setRx(data);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, [id]);
usePageData(fetchDetail, { throttleMs: 60000 });
if (loading) return <View className={`detail-page ${modeClass}`}><Loading /></View>;
if (!rx) return <View className={`detail-page ${modeClass}`}><View className='empty-state'><Text className='empty-text'></Text></View></View>;

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listDialysisPrescriptions } from '@/services/dialysis';
import type { DialysisPrescription } from '@/services/dialysis';
import EmptyState from '@/components/EmptyState';
@@ -45,11 +45,7 @@ export default function DialysisPrescriptionList() {
}
}, []);
useThrottledDidShow(() => { fetchData(1); }, 10000);
usePullDownRefresh(() => {
fetchData(1).finally(() => Taro.stopPullDownRefresh());
});
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
useReachBottom(() => {
if (!loading && prescriptions.length < total) {

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getDialysisRecord } from '@/services/dialysis';
import type { DialysisRecord } from '@/services/dialysis';
import Loading from '@/components/Loading';
@@ -26,15 +27,21 @@ export default function DialysisRecordDetail() {
const [record, setRecord] = useState<DialysisRecord | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDetail = useCallback(async () => {
if (!id) return;
setLoading(true);
getDialysisRecord(id)
.then((data) => setRecord(data))
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));
try {
const data = await getDialysisRecord(id);
setRecord(data);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, [id]);
usePageData(fetchDetail, { throttleMs: 60000 });
if (loading) return <View className={`detail-page ${modeClass}`}><Loading /></View>;
if (!record) return <View className={`detail-page ${modeClass}`}><View className='empty-state'><Text className='empty-text'></Text></View></View>;

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listDialysisRecords } from '@/services/dialysis';
import type { DialysisRecord } from '@/services/dialysis';
import EmptyState from '@/components/EmptyState';
@@ -51,11 +51,7 @@ export default function DialysisRecordList() {
}
}, []);
useThrottledDidShow(() => { fetchData(1); }, 10000);
usePullDownRefresh(() => {
fetchData(1).finally(() => Taro.stopPullDownRefresh());
});
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
useReachBottom(() => {
if (!loading && records.length < total) {

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import { usePageData } from '@/hooks/usePageData';
import { listPatients, Patient } from '../../../services/patient';
import { useAuthStore } from '../../../stores/auth';
import EmptyState from '../../../components/EmptyState';
@@ -27,9 +27,7 @@ export default function FamilyList() {
}
}, []);
useThrottledDidShow(() => {
fetchPatients();
}, 10000);
usePageData(fetchPatients, { throttleMs: 10000 });
const handleSelect = (patient: Patient) => {
setCurrentPatient({

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import { usePageData } from '@/hooks/usePageData';
import { listTasks, FollowUpTask } from '../../../services/followup';
import EmptyState from '../../../components/EmptyState';
import Loading from '../../../components/Loading';
@@ -32,9 +32,7 @@ export default function MyFollowUps() {
}
}, []);
useThrottledDidShow(() => {
fetchTasks(activeTab);
}, 10000);
usePageData(async () => { await fetchTasks(activeTab); }, { throttleMs: 10000 });
const handleTabChange = (key: string) => {
setActiveTab(key);

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listHealthRecords, HealthRecord } from '../../../services/health-record';
import EmptyState from '../../../components/EmptyState';
import Loading from '../../../components/Loading';
@@ -44,15 +44,7 @@ export default function HealthRecords() {
}
}, []);
useThrottledDidShow(() => {
fetchData(1);
}, 10000);
usePullDownRefresh(() => {
fetchData(1).finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
useReachBottom(() => {
if (!loading && records.length < total) {

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import EmptyState from '../../../components/EmptyState';
import {
listReminders,
@@ -32,7 +33,7 @@ export default function MedicationReminder() {
}
}, []);
useEffect(() => { fetchReminders(); }, [fetchReminders]);
usePageData(fetchReminders, { throttleMs: 5000, enablePullDown: true });
const handleToggle = async (r: MedicationReminder) => {
try {

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import Taro, { useReachBottom } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listReports, LabReport } from '../../../services/report';
import EmptyState from '../../../components/EmptyState';
import Loading from '../../../components/Loading';
@@ -38,15 +38,7 @@ export default function MyReports() {
}
}, []);
useThrottledDidShow(() => {
fetchData(1);
}, 10000);
usePullDownRefresh(() => {
fetchData(1).finally(() => {
Taro.stopPullDownRefresh();
});
});
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
useReachBottom(() => {
if (!loading && reports.length < total) {

View File

@@ -1,11 +1,11 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { useAuthStore } from '../../stores/auth';
import { usePointsStore } from '../../stores/points';
import { useUIStore } from '../../stores/ui';
import { navigateToLogin } from '../../utils/navigate';
import { useThrottledDidShow } from '@/hooks/useThrottledDidShow';
import { usePageData } from '@/hooks/usePageData';
import Loading from '../../components/Loading';
import './index.scss';
@@ -90,12 +90,15 @@ export default function Profile() {
const groups = isGuest ? GUEST_GROUPS : LOGGED_IN_GROUPS;
const [pointsLoading, setPointsLoading] = useState(false);
useThrottledDidShow(() => {
const fetchPoints = useCallback(async () => {
if (!isGuest) {
setPointsLoading(true);
refreshPoints().finally(() => setPointsLoading(false));
await refreshPoints();
setPointsLoading(false);
}
}, 5000);
}, [isGuest, refreshPoints]);
usePageData(fetchPoints, { throttleMs: 5000 });
const handleMenuClick = (item: MenuItem) => {
if (item.isSwitchTab) {

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getReportDetail, LabReport } from '../../../services/report';
import Loading from '../../../components/Loading';
import { useElderClass } from '../../../hooks/useElderClass';
@@ -26,15 +27,21 @@ export default function ReportDetail() {
const [report, setReport] = useState<LabReport | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchReport = useCallback(async () => {
if (!id || !patientId) return;
setLoading(true);
getReportDetail(patientId, id)
.then((data) => setReport(data))
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));
try {
const data = await getReportDetail(patientId, id);
setReport(data);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, [id, patientId]);
usePageData(fetchReport, { throttleMs: 60000 });
const indicators: IndicatorItem[] = React.useMemo(() => {
if (!report?.indicators || typeof report.indicators !== 'object') return [];
return Object.entries(report.indicators).map(([name, val]) => ({

View File

@@ -3,7 +3,7 @@ import Taro from '@tarojs/taro';
import * as authApi from '@/services/auth';
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
import { clearRequestCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request';
import { useHealthStore } from './health';
import { resetAllStores } from './index';
// --- 内存缓存,避免每次 Tab 切换重复 Storage IPC + JSON.parse ---
let cachedUserJson = '';
@@ -222,7 +222,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
Taro.removeStorageSync('current_patient_id');
Taro.removeStorageSync('analytics_queue');
Taro.removeStorageSync('edit_patient');
useHealthStore.getState().clearCache();
resetAllStores();
set({ user: null, roles: [], currentPatient: null, patients: [] });
Taro.reLaunch({ url: '/pages/index/index' });
},

View File

@@ -0,0 +1,7 @@
import { useHealthStore } from './health';
import { usePointsStore } from './points';
export function resetAllStores(): void {
useHealthStore.getState().clearCache();
usePointsStore.getState().invalidate();
}

View File

@@ -151,7 +151,11 @@ Taro 4.2 / React 18 / TypeScript / Zustand 5 / Sass / Zod / ECharts 6按需
| `apps/miniprogram/src/services/request.ts` | HTTP 请求封装401 自动刷新、错误处理) |
| `apps/miniprogram/src/services/auth.ts` | 微信登录/绑定手机号 API |
| `apps/miniprogram/src/stores/auth.ts` | 认证状态login/bindPhone/restore |
| `apps/miniprogram/src/utils/secure-storage.ts` | token 安全存储XOR + Base64 混淆 |
| `apps/miniprogram/src/stores/index.ts` | `resetAllStores()` 统一清理(解耦 store 间依赖 |
| `apps/miniprogram/src/hooks/usePageData.ts` | **统一页面数据加载 hook**(节流 + 下拉刷新 + 防重入) |
| `apps/miniprogram/src/pages/index/useHomeData.ts` | 首页数据 hook从 424 行页面组件中提取) |
| `apps/miniprogram/src/pages/health/useHealthData.ts` | 健康页数据 hook从 422 行页面组件中提取) |
| `apps/miniprogram/src/utils/secure-storage.ts` | token 安全存储(明文存储,保留接口兼容) |
| `apps/miniprogram/project.config.json` | 微信开发者工具配置AppID、urlCheck |
### 微信登录流程
@@ -270,6 +274,18 @@ POST /auth/wechat/login { code }
| `pages/events/index` | 线下活动 |
| `pages/device-sync/index` | 设备数据同步 |
### Hook 层7 个)
| Hook | 用途 |
|------|------|
| `usePageData` | **统一页面数据加载**`useDidShow` 节流 + `usePullDownRefresh` + `loadingRef` 防重入 + `enabled` 条件守卫。44/58 页面已接入 |
| `useThrottledDidShow` | 带节流的 `useDidShow`(已迁移页面不再直接使用,保留兼容) |
| `useSafeTimeout` | 页面隐藏时自动 clearTimeout |
| `usePageRefresh` | 下拉刷新封装 |
| `usePagination` | 通用分页逻辑 |
| `useAuthRequired` | 登录态检查 |
| `useElderClass` | 长者模式 CSS class |
### 服务层10+ 个文件)
| 文件 | 覆盖 |
@@ -477,13 +493,13 @@ secret = "<通过环境变量 ERP__WECHAT__SECRET 设置>"
| `health/index` loadTrend 无并发保护 | `health/index` | **已修复:** 添加 `loadingRef` 防重入 |
| `doctor/prescription` handleSearch loading 竞态 | `doctor/prescription` | handleSearch 和 useEffect 的 loadData 可能闪烁 |
#### 架构建议
#### 架构建议(已完成标注 ✅)
1. **统一数据加载模式**:所有列表页使用 `useThrottledDidShow` + `loadingRef` 双重保护(当前 appointment/messages 遵循,但 ai-report/events 不遵循)
1. ~~**统一数据加载模式**~~ ✅ 已完成:所有列表/详情页使用 `usePageData` hook44/58 页面已接入
2. **长轮询通用化**`consultation/detail``doctor/consultation/detail` 的长轮询逻辑几乎相同,应抽取为 `useLongPolling` hook
3. **服务端过滤优先**:所有列表页的 Tab 过滤传参给后端,不在前端做客户端过滤
4. **BLE 管理器生命周期**BLE 等硬件相关管理器应通过 Context 或 hook 管理,避免模块级单例
5. **getStorageSync 出渲染路径**:组件顶层不应有同步 I/O统一通过 Zustand store 获取
3. ~~**服务端过滤优先**~~ ✅ 已完成:所有列表页的 Tab 过滤传参给后端
4. ~~**BLE 管理器生命周期**~~ ✅ 已完成:改为 `useRef` 懒初始化
5. ~~**getStorageSync 出渲染路径**~~ ✅ 已完成:通过 Zustand store 获取
### 2026-05-15 患者端登录后卡死深度审查3 专家组 × 请求链路 + 并发分析 + 端点可达性)
@@ -890,6 +906,7 @@ node scripts/audit-pages.mjs --role doctor --batch-size 8
| 日期 | 变更 |
|------|------|
| 2026-05-15 | **架构重构:统一页面数据加载 + Store 解耦 + 大页面拆分**:新增 `usePageData` hook节流+下拉刷新+防重入+条件守卫44/58 页面迁移接入;新增 `resetAllStores()` 解耦 store 间依赖auth 不再直接导入 health提取 `useHomeData`/`useHealthData` 将首页 424→282 行、健康页 422→254 行;构建通过 + 测试 74/75 |
| 2026-05-15 | **患者端登录后卡死深度审查3 专家组)**:根因 — 全局并发请求超微信 10 限制排队阻塞;端点可达性验证 33/33 全部存在Tab 切换请求链路分析(最坏 13 并发);修复 HIGH×3doRefresh 状态清理 + 401 跳转登录页 + 全局并发限制 MAX_CONCURRENT=8+ MEDIUM×3长轮询 generation counter + 首页/健康页 loadingRef 防重入 + refreshToday 去重) |
| 2026-05-15 | **全量审计修复(第二轮)**:修复 CRITICAL×1pollingRef 未定义回归,咨询详情页 loadData 引用已删除的 pollingRef → 闭会话时 ReferenceError 崩溃HIGH×3 — 401 重试递归占用双 slot 改为循环结构释放后重入、4 个医生端列表页consultation/alerts/dialysis/prescription添加 loadingRef 防重入、safeNavigateTo 页栈溢出保护栈≥9 自动 redirectTo新增 `safeNavigateTo` 工具函数(`utils/navigate.ts` |
| 2026-05-15 | **setTimeout 无清理修复**:新增 `useSafeTimeout` hook页面隐藏时自动 clearTimeout10 个页面接入 — daily-monitoring2、exchange4、family-add、health/input、prescription detail/create、dialysis detail/create、appointment detail/create所有 fire-and-forget 定时器替换为 safeSetTimeout |