fix(mp): setTimeout 无清理修复 — useSafeTimeout hook + 10 页面接入

新增 useSafeTimeout hook,页面隐藏时自动清理所有定时器。
10 个页面接入:daily-monitoring、exchange、family-add、
health/input、prescription detail/create、dialysis detail/create、
appointment detail/create。所有 fire-and-forget setTimeout 替换为
safeSetTimeout,避免页面切走后定时器回调在错误上下文执行。
This commit is contained in:
iven
2026-05-15 00:38:23 +08:00
parent 74bffb4878
commit fed1759985
11 changed files with 57 additions and 14 deletions

View File

@@ -0,0 +1,23 @@
import { useRef, useCallback } from 'react';
import { useDidHide } from '@tarojs/taro';
export function useSafeTimeout() {
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const safeSetTimeout = useCallback((fn: () => void, ms: number) => {
const id = setTimeout(() => {
timers.current = timers.current.filter((t) => t !== id);
fn();
}, ms);
timers.current.push(id);
}, []);
const clearAll = useCallback(() => {
timers.current.forEach(clearTimeout);
timers.current = [];
}, []);
useDidHide(() => clearAll());
return { safeSetTimeout, clearAll };
}

View File

@@ -8,6 +8,7 @@ import { trackEvent } from '@/services/analytics';
import StepIndicator from '../../../components/StepIndicator';
import WeekCalendar from '../../../components/WeekCalendar';
import { useElderClass } from '../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
const DEPARTMENTS = [
@@ -43,6 +44,7 @@ export default function AppointmentCreate() {
const [timeSlot, setTimeSlot] = useState('');
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
const [schedules, setSchedules] = useState<any[]>([]);
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
const modeClass = useElderClass();
@@ -127,7 +129,7 @@ export default function AppointmentCreate() {
await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] });
} catch { /* 用户拒绝 */ }
}
setTimeout(() => Taro.navigateBack(), 1500);
safeSetTimeout(() => Taro.navigateBack(), 1500);
} catch {
Taro.showToast({ title: '预约失败', icon: 'none' });
} finally {

View File

@@ -6,6 +6,7 @@ import type { Appointment } from '../../../services/appointment';
import Loading from '../../../components/Loading';
import ErrorState from '../../../components/ErrorState';
import { useElderClass } from '../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; className: string }> = {
@@ -23,6 +24,7 @@ export default function AppointmentDetail() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [cancelling, setCancelling] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
const modeClass = useElderClass();
useEffect(() => {
@@ -55,7 +57,7 @@ export default function AppointmentDetail() {
try {
await cancelAppointment(appointment.id, appointment.version);
Taro.showToast({ title: '已取消预约', icon: 'success' });
setTimeout(() => Taro.navigateBack(), 1500);
safeSetTimeout(() => Taro.navigateBack(), 1500);
} catch {
Taro.showToast({ title: '取消失败', icon: 'none' });
} finally {

View File

@@ -6,6 +6,7 @@ import {
} from '@/services/doctor/dialysis';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
const DIALYSIS_TYPES = ['HD', 'HDF', 'HF'];
@@ -63,6 +64,7 @@ export default function DialysisCreate() {
const [form, setForm] = useState<FormState>({ ...initialForm, patient_id: patientIdFromRoute });
const [loading, setLoading] = useState(isEdit);
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
useEffect(() => {
if (isEdit && id) loadRecord();
@@ -145,7 +147,7 @@ export default function DialysisCreate() {
await createDialysisRecord(payload);
Taro.showToast({ title: '创建成功', icon: 'success' });
}
setTimeout(() => Taro.navigateBack(), 1000);
safeSetTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: isEdit ? '更新失败' : '创建失败', icon: 'none' });
} finally {

View File

@@ -8,6 +8,7 @@ import {
} from '@/services/doctor/dialysis';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
export default function DialysisDetail() {
@@ -17,6 +18,7 @@ export default function DialysisDetail() {
const [record, setRecord] = useState<DialysisRecord | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
useEffect(() => {
if (id) loadRecord();
@@ -73,7 +75,7 @@ export default function DialysisDetail() {
try {
await deleteDialysisRecord(id, record.version);
Taro.showToast({ title: '已删除', icon: 'success' });
setTimeout(() => Taro.navigateBack(), 1000);
safeSetTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: '删除失败', icon: 'none' });
setSubmitting(false);

View File

@@ -4,6 +4,7 @@ import Taro, { useRouter } from '@tarojs/taro';
import { createDialysisPrescription } from '@/services/doctor/dialysis';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
interface FormState {
@@ -54,6 +55,7 @@ export default function PrescriptionCreate() {
const modeClass = useElderClass();
const [form, setForm] = useState<FormState>(initialForm);
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
const updateField = (key: keyof FormState, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -92,7 +94,7 @@ export default function PrescriptionCreate() {
try {
await createDialysisPrescription(payload);
Taro.showToast({ title: '创建成功', icon: 'success' });
setTimeout(() => Taro.navigateBack(), 1000);
safeSetTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: '创建失败', icon: 'none' });
} finally {

View File

@@ -7,6 +7,7 @@ import {
} from '@/services/doctor/dialysis';
import Loading from '@/components/Loading';
import { useElderClass } from '../../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
export default function PrescriptionDetail() {
@@ -16,6 +17,7 @@ export default function PrescriptionDetail() {
const [rx, setRx] = useState<DialysisPrescription | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
useEffect(() => {
if (id) loadRx();
@@ -63,7 +65,7 @@ export default function PrescriptionDetail() {
try {
await deleteDialysisPrescription(id, rx.version);
Taro.showToast({ title: '已删除', icon: 'success' });
setTimeout(() => Taro.navigateBack(), 1000);
safeSetTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: '删除失败', icon: 'none' });
setSubmitting(false);

View File

@@ -8,6 +8,7 @@ import { useHealthStore } from '@/stores/health';
import { usePointsStore } from '@/stores/points';
import { clearRequestCache } from '@/services/request';
import { trackEvent } from '@/services/analytics';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
@@ -85,6 +86,7 @@ export default function DailyMonitoring() {
const [urineOutput, setUrineOutput] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
// ── Collapsible sections ──
const [collapsed, setCollapsed] = useState<Record<SectionKey, boolean>>({
@@ -225,11 +227,11 @@ export default function DailyMonitoring() {
usePointsStore.getState().invalidate();
Taro.showToast({ title: '上报成功', icon: 'success' });
setTimeout(() => {
safeSetTimeout(() => {
Taro.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 });
}, 1600);
setTimeout(() => {
safeSetTimeout(() => {
Taro.navigateBack();
}, 3200);
} catch (e: unknown) {

View File

@@ -8,6 +8,7 @@ import { useAuthStore } from '../../../stores/auth';
import { useHealthStore } from '@/stores/health';
import { usePointsStore } from '@/stores/points';
import { clearRequestCache } from '@/services/request';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import { trackEvent } from '@/services/analytics';
import { useElderClass } from '../../../hooks/useElderClass';
import Loading from '../../../components/Loading';
@@ -61,6 +62,7 @@ export default function HealthInput() {
const [diastolic, setDiastolic] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
const [loadingThresholds, setLoadingThresholds] = useState(true);
const currentPatient = useAuthStore((s) => s.currentPatient);
const clearCache = useHealthStore((s) => s.clearCache);
@@ -155,7 +157,7 @@ export default function HealthInput() {
usePointsStore.getState().invalidate();
Taro.showToast({ title: '录入成功', icon: 'success' });
trackEvent('health_data_input', { type: currentIndicator });
setTimeout(() => Taro.navigateBack(), 1000);
safeSetTimeout(() => Taro.navigateBack(), 1000);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '录入失败';
Taro.showToast({ title: msg, icon: 'none' });

View File

@@ -10,6 +10,7 @@ import type { PointsProduct } from '../../../services/points';
import { usePointsStore } from '../../../stores/points';
import Loading from '../../../components/Loading';
import { useElderClass } from '../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
const TYPE_INITIAL: Record<string, string> = {
@@ -37,6 +38,7 @@ export default function ExchangeConfirm() {
const refreshPoints = usePointsStore((s) => s.refresh);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
useThrottledDidShow(() => {
Taro.setNavigationBarTitle({ title: '确认兑换' });
@@ -48,7 +50,7 @@ export default function ExchangeConfirm() {
const productId = instance.router?.params?.product_id;
if (!productId) {
Taro.showToast({ title: '参数错误', icon: 'none' });
setTimeout(() => Taro.navigateBack(), 1500);
safeSetTimeout(() => Taro.navigateBack(), 1500);
return;
}
@@ -61,13 +63,13 @@ export default function ExchangeConfirm() {
const found = productRes.data.find((p) => p.id === productId);
if (!found) {
Taro.showToast({ title: '商品不存在', icon: 'none' });
setTimeout(() => Taro.navigateBack(), 1500);
safeSetTimeout(() => Taro.navigateBack(), 1500);
return;
}
setProduct(found);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
setTimeout(() => Taro.navigateBack(), 1500);
safeSetTimeout(() => Taro.navigateBack(), 1500);
} finally {
setLoading(false);
}
@@ -96,7 +98,7 @@ export default function ExchangeConfirm() {
const order = await exchangeProduct(product.id);
Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 });
setTimeout(() => {
safeSetTimeout(() => {
Taro.showModal({
title: '兑换成功',
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,

View File

@@ -3,6 +3,7 @@ import { View, Text, Input, Picker } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { createPatient, updatePatient, Patient } from '../../../services/patient';
import { useElderClass } from '../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
const RELATION_OPTIONS = ['本人', '配偶', '父母', '子女', '其他'];
@@ -23,6 +24,7 @@ export default function FamilyAdd() {
);
const [birthDate, setBirthDate] = useState(editData?.birth_date || '');
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
useEffect(() => {
return () => { Taro.removeStorageSync('edit_patient'); };
@@ -54,7 +56,7 @@ export default function FamilyAdd() {
Taro.hideLoading();
Taro.showToast({ title: '添加成功', icon: 'success' });
}
setTimeout(() => Taro.navigateBack(), 1000);
safeSetTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.hideLoading();
Taro.showToast({ title: editId ? '修改失败' : '添加失败', icon: 'none' });