fix(mp): 患者端卡死深度审查修复 — CRITICAL 回归 + 并发保护 + 页栈溢出防护
CRITICAL: - 咨询详情页 loadData 引用已删除的 pollingRef → 移除残余引用 HIGH: - 401 重试递归改循环结构,避免并发限制器双 slot 占用 - 医生端 4 个列表页添加 loadingRef 防重入(consultation/alerts/dialysis/prescription) - 新增 safeNavigateTo 页栈溢出保护(栈≥9 自动 redirectTo) 前期修复一并提交: - 全局并发限制 MAX_CONCURRENT=8 - doRefresh 失败时完整清理 Storage + 重置缓存状态 - 401 跳转登录页修正 - 长轮询 generation counter 模式 - 首页/健康页 loadingRef + refreshToday 去重
This commit is contained in:
@@ -32,7 +32,7 @@ export default function ConsultationDetail() {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const scrollViewRef = useRef('');
|
const scrollViewRef = useRef('');
|
||||||
const pollingRef = useRef(false);
|
const pollingGeneration = useRef(0);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const messagesRef = useRef<ConsultationMessage[]>([]);
|
const messagesRef = useRef<ConsultationMessage[]>([]);
|
||||||
const modeClass = useElderClass();
|
const modeClass = useElderClass();
|
||||||
@@ -44,40 +44,40 @@ export default function ConsultationDetail() {
|
|||||||
startLongPolling();
|
startLongPolling();
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
pollingRef.current = false;
|
pollingGeneration.current += 1;
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
// 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询)
|
// 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询)
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
if (sessionId && !pollingRef.current && session?.status !== 'closed') {
|
if (sessionId && session?.status !== 'closed') {
|
||||||
startLongPolling();
|
startLongPolling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
useDidHide(() => {
|
useDidHide(() => {
|
||||||
pollingRef.current = false;
|
pollingGeneration.current += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.status === 'closed') {
|
if (session?.status === 'closed') {
|
||||||
pollingRef.current = false;
|
pollingGeneration.current += 1;
|
||||||
}
|
}
|
||||||
}, [session?.status]);
|
}, [session?.status]);
|
||||||
|
|
||||||
const startLongPolling = () => {
|
const startLongPolling = () => {
|
||||||
pollingRef.current = true;
|
const gen = ++pollingGeneration.current;
|
||||||
longPoll();
|
longPoll(gen);
|
||||||
};
|
};
|
||||||
|
|
||||||
const longPoll = async (failCount = 0) => {
|
const longPoll = async (gen: number, failCount = 0) => {
|
||||||
if (!pollingRef.current || !mountedRef.current) return;
|
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
||||||
if (failCount >= MAX_CONSECUTIVE_FAILURES) return;
|
if (failCount >= MAX_CONSECUTIVE_FAILURES) return;
|
||||||
try {
|
try {
|
||||||
const currentMessages = messagesRef.current;
|
const currentMessages = messagesRef.current;
|
||||||
const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined;
|
const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined;
|
||||||
const newMsgs = await pollMessages(sessionId, lastId);
|
const newMsgs = await pollMessages(sessionId, lastId);
|
||||||
if (!mountedRef.current) return;
|
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
||||||
if (newMsgs && newMsgs.length > 0) {
|
if (newMsgs && newMsgs.length > 0) {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const existing = new Set(prev.map((msg) => msg.id));
|
const existing = new Set(prev.map((msg) => msg.id));
|
||||||
@@ -92,11 +92,11 @@ export default function ConsultationDetail() {
|
|||||||
} catch {
|
} catch {
|
||||||
failCount++;
|
failCount++;
|
||||||
}
|
}
|
||||||
if (!pollingRef.current || !mountedRef.current) return;
|
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
||||||
const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS;
|
const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS;
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
if (pollingRef.current && mountedRef.current) {
|
if (gen === pollingGeneration.current && mountedRef.current) {
|
||||||
longPoll(failCount);
|
longPoll(gen, failCount);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,7 +112,6 @@ export default function ConsultationDetail() {
|
|||||||
setMessages(msgs);
|
setMessages(msgs);
|
||||||
messagesRef.current = msgs;
|
messagesRef.current = msgs;
|
||||||
scrollViewRef.current = `msg-${msgs.length}`;
|
scrollViewRef.current = `msg-${msgs.length}`;
|
||||||
if (s.status === 'closed') pollingRef.current = false;
|
|
||||||
} catch {
|
} catch {
|
||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { View, Text, ScrollView } from '@tarojs/components';
|
import { View, Text, ScrollView } from '@tarojs/components';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { listAlerts, type Alert } from '@/services/doctor/alerts';
|
import { listAlerts, type Alert } from '@/services/doctor/alerts';
|
||||||
import Loading from '@/components/Loading';
|
import Loading from '@/components/Loading';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { useElderClass } from '../../../hooks/useElderClass';
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
|
import { safeNavigateTo } from '@/utils/navigate';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||||
@@ -35,6 +36,7 @@ export default function AlertList() {
|
|||||||
const [activeTab, setActiveTab] = useState('');
|
const [activeTab, setActiveTab] = useState('');
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
||||||
|
|
||||||
@@ -43,6 +45,8 @@ export default function AlertList() {
|
|||||||
}, [page, activeTab]);
|
}, [page, activeTab]);
|
||||||
|
|
||||||
const loadAlerts = async () => {
|
const loadAlerts = async () => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await listAlerts({
|
const res = await listAlerts({
|
||||||
@@ -56,6 +60,7 @@ export default function AlertList() {
|
|||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +70,7 @@ export default function AlertList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAlertClick = (alert: Alert) => {
|
const handleAlertClick = (alert: Alert) => {
|
||||||
Taro.navigateTo({ url: `/pages/doctor/alerts/detail/index?id=${alert.id}` });
|
safeNavigateTo(`/pages/doctor/alerts/detail/index?id=${alert.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
const formatTime = (dateStr: string) => {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function ConsultationDetail() {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const scrollViewRef = useRef('');
|
const scrollViewRef = useRef('');
|
||||||
const pollingRef = useRef(false);
|
const pollingGeneration = useRef(0);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const messagesRef = useRef<ConsultationMessage[]>([]);
|
const messagesRef = useRef<ConsultationMessage[]>([]);
|
||||||
|
|
||||||
@@ -40,40 +40,40 @@ export default function ConsultationDetail() {
|
|||||||
startLongPolling();
|
startLongPolling();
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
pollingRef.current = false;
|
pollingGeneration.current += 1;
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
// 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询)
|
// 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询)
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
if (sessionId && !pollingRef.current && session?.status !== 'closed') {
|
if (sessionId && session?.status !== 'closed') {
|
||||||
startLongPolling();
|
startLongPolling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
useDidHide(() => {
|
useDidHide(() => {
|
||||||
pollingRef.current = false;
|
pollingGeneration.current += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.status === 'closed') {
|
if (session?.status === 'closed') {
|
||||||
pollingRef.current = false;
|
pollingGeneration.current += 1;
|
||||||
}
|
}
|
||||||
}, [session?.status]);
|
}, [session?.status]);
|
||||||
|
|
||||||
const startLongPolling = () => {
|
const startLongPolling = () => {
|
||||||
pollingRef.current = true;
|
const gen = ++pollingGeneration.current;
|
||||||
longPoll();
|
longPoll(gen);
|
||||||
};
|
};
|
||||||
|
|
||||||
const longPoll = async (failCount = 0) => {
|
const longPoll = async (gen: number, failCount = 0) => {
|
||||||
if (!pollingRef.current || !mountedRef.current) return;
|
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
||||||
if (failCount >= MAX_CONSECUTIVE_FAILURES) return;
|
if (failCount >= MAX_CONSECUTIVE_FAILURES) return;
|
||||||
try {
|
try {
|
||||||
const currentMessages = messagesRef.current;
|
const currentMessages = messagesRef.current;
|
||||||
const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined;
|
const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined;
|
||||||
const newMsgs = await pollMessages(sessionId, lastId);
|
const newMsgs = await pollMessages(sessionId, lastId);
|
||||||
if (!mountedRef.current) return;
|
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
||||||
if (newMsgs && newMsgs.length > 0) {
|
if (newMsgs && newMsgs.length > 0) {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const existing = new Set(prev.map((msg) => msg.id));
|
const existing = new Set(prev.map((msg) => msg.id));
|
||||||
@@ -88,11 +88,11 @@ export default function ConsultationDetail() {
|
|||||||
} catch {
|
} catch {
|
||||||
failCount++;
|
failCount++;
|
||||||
}
|
}
|
||||||
if (!pollingRef.current || !mountedRef.current) return;
|
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
||||||
const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS;
|
const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS;
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
if (pollingRef.current && mountedRef.current) {
|
if (gen === pollingGeneration.current && mountedRef.current) {
|
||||||
longPoll(failCount);
|
longPoll(gen, failCount);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,7 +108,6 @@ export default function ConsultationDetail() {
|
|||||||
setMessages(msgs);
|
setMessages(msgs);
|
||||||
messagesRef.current = msgs;
|
messagesRef.current = msgs;
|
||||||
scrollViewRef.current = `msg-${msgs.length}`;
|
scrollViewRef.current = `msg-${msgs.length}`;
|
||||||
if (s.status === 'closed') pollingRef.current = false;
|
|
||||||
} catch {
|
} catch {
|
||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { View, Text, ScrollView } from '@tarojs/components';
|
import { View, Text, ScrollView } from '@tarojs/components';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { listSessions, type ConsultationSession } from '@/services/doctor/consultation';
|
import { listSessions, type ConsultationSession } from '@/services/doctor/consultation';
|
||||||
@@ -7,6 +7,7 @@ import EmptyState from '@/components/EmptyState';
|
|||||||
import { useElderClass } from '../../../hooks/useElderClass';
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
|
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag';
|
||||||
import { formatDateTime } from '@/utils/date';
|
import { formatDateTime } from '@/utils/date';
|
||||||
|
import { safeNavigateTo } from '@/utils/navigate';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -23,6 +24,7 @@ export default function ConsultationList() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
||||||
|
|
||||||
@@ -31,6 +33,8 @@ export default function ConsultationList() {
|
|||||||
}, [page, activeTab]);
|
}, [page, activeTab]);
|
||||||
|
|
||||||
const loadSessions = async () => {
|
const loadSessions = async () => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await listSessions({
|
const res = await listSessions({
|
||||||
@@ -44,6 +48,7 @@ export default function ConsultationList() {
|
|||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +87,7 @@ export default function ConsultationList() {
|
|||||||
<View
|
<View
|
||||||
key={s.id}
|
key={s.id}
|
||||||
className='session-card'
|
className='session-card'
|
||||||
onClick={() => Taro.navigateTo({ url: `/pages/doctor/consultation/detail/index?id=${s.id}` })}
|
onClick={() => safeNavigateTo(`/pages/doctor/consultation/detail/index?id=${s.id}`)}
|
||||||
>
|
>
|
||||||
<View className='session-card__top'>
|
<View className='session-card__top'>
|
||||||
<Text className='session-card__subject'>{s.subject || '在线咨询'}</Text>
|
<Text className='session-card__subject'>{s.subject || '在线咨询'}</Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import { listDialysisRecords, type DialysisRecord } from '@/services/doctor/dialysis';
|
import { listDialysisRecords, type DialysisRecord } from '@/services/doctor/dialysis';
|
||||||
@@ -6,6 +6,7 @@ import { listPatients } from '@/services/doctor/patient';
|
|||||||
import Loading from '@/components/Loading';
|
import Loading from '@/components/Loading';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { useElderClass } from '../../../hooks/useElderClass';
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
|
import { safeNavigateTo } from '@/utils/navigate';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -28,12 +29,15 @@ export default function DialysisList() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPatientId) loadRecords(1);
|
if (currentPatientId) loadRecords(1);
|
||||||
}, [currentPatientId, activeTab]);
|
}, [currentPatientId, activeTab]);
|
||||||
|
|
||||||
const loadRecords = async (p: number) => {
|
const loadRecords = async (p: number) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: { page: number; page_size: number; status?: string } = { page: p, page_size: 20 };
|
const params: { page: number; page_size: number; status?: string } = { page: p, page_size: 20 };
|
||||||
@@ -46,6 +50,7 @@ export default function DialysisList() {
|
|||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,9 +119,7 @@ export default function DialysisList() {
|
|||||||
<View
|
<View
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className='record-card'
|
className='record-card'
|
||||||
onClick={() => Taro.navigateTo({
|
onClick={() => safeNavigateTo(`/pages/doctor/dialysis/detail/index?id=${r.id}`)}
|
||||||
url: `/pages/doctor/dialysis/detail/index?id=${r.id}`,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<View className='record-card__header'>
|
<View className='record-card__header'>
|
||||||
<Text className={`type-tag type-tag--${(r.dialysis_type || 'hd').toLowerCase()}`}>
|
<Text className={`type-tag type-tag--${(r.dialysis_type || 'hd').toLowerCase()}`}>
|
||||||
@@ -164,7 +167,7 @@ export default function DialysisList() {
|
|||||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Taro.navigateTo({ url: `/pages/doctor/dialysis/create/index?patientId=${currentPatientId}` });
|
safeNavigateTo(`/pages/doctor/dialysis/create/index?patientId=${currentPatientId}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='fab-text'>+</Text>
|
<Text className='fab-text'>+</Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/doctor/dialysis';
|
import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/doctor/dialysis';
|
||||||
@@ -6,6 +6,7 @@ import { listPatients } from '@/services/doctor/patient';
|
|||||||
import Loading from '@/components/Loading';
|
import Loading from '@/components/Loading';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { useElderClass } from '../../../hooks/useElderClass';
|
import { useElderClass } from '../../../hooks/useElderClass';
|
||||||
|
import { safeNavigateTo } from '@/utils/navigate';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -25,12 +26,15 @@ export default function PrescriptionList() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(1);
|
loadData(1);
|
||||||
}, [currentPatientId, activeTab]);
|
}, [currentPatientId, activeTab]);
|
||||||
|
|
||||||
const loadData = async (p: number) => {
|
const loadData = async (p: number) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await listDialysisPrescriptions({
|
const res = await listDialysisPrescriptions({
|
||||||
@@ -46,6 +50,7 @@ export default function PrescriptionList() {
|
|||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,9 +109,7 @@ export default function PrescriptionList() {
|
|||||||
<View
|
<View
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className='prescription-card'
|
className='prescription-card'
|
||||||
onClick={() => Taro.navigateTo({
|
onClick={() => safeNavigateTo(`/pages/doctor/prescription/detail/index?id=${p.id}`)}
|
||||||
url: `/pages/doctor/prescription/detail/index?id=${p.id}`,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<View className='prescription-card__header'>
|
<View className='prescription-card__header'>
|
||||||
<Text className='prescription-card__model'>{p.dialyzer_model || '透析处方'}</Text>
|
<Text className='prescription-card__model'>{p.dialyzer_model || '透析处方'}</Text>
|
||||||
@@ -156,7 +159,7 @@ export default function PrescriptionList() {
|
|||||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Taro.navigateTo({ url: `/pages/doctor/prescription/create/index?patientId=${currentPatientId}` });
|
safeNavigateTo(`/pages/doctor/prescription/create/index?patientId=${currentPatientId}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='fab-text'>+</Text>
|
<Text className='fab-text'>+</Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { View, Text, Input } from '@tarojs/components';
|
import { View, Text, Input } from '@tarojs/components';
|
||||||
import Taro, { usePullDownRefresh } from '@tarojs/taro';
|
import Taro, { usePullDownRefresh } from '@tarojs/taro';
|
||||||
import { useHealthStore } from '../../stores/health';
|
import { useHealthStore } from '../../stores/health';
|
||||||
@@ -61,16 +61,18 @@ export default function Health() {
|
|||||||
const [trendLoading, setTrendLoading] = useState(false);
|
const [trendLoading, setTrendLoading] = useState(false);
|
||||||
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
|
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
|
||||||
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
|
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
useThrottledDidShow(() => {
|
useThrottledDidShow(() => {
|
||||||
if (!user) return;
|
if (!user || loadingRef.current) return;
|
||||||
// 批量发起请求,避免串行 setState 级联重渲染
|
// 批量发起请求,避免串行 setState 级联重渲染
|
||||||
|
loadingRef.current = true;
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
refreshToday(),
|
refreshToday(),
|
||||||
loadTrend(activeTab),
|
loadTrend(activeTab),
|
||||||
loadAiSuggestions(),
|
loadAiSuggestions(),
|
||||||
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }),
|
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }),
|
||||||
]);
|
]).finally(() => { loadingRef.current = false; });
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
usePullDownRefresh(() => {
|
usePullDownRefresh(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Text, Swiper, SwiperItem, Image } from '@tarojs/components';
|
import { View, Text, Swiper, SwiperItem, Image } from '@tarojs/components';
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
import Taro, { usePullDownRefresh, useDidShow, useDidHide } from '@tarojs/taro';
|
import Taro, { usePullDownRefresh, useDidShow, useDidHide } from '@tarojs/taro';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
import { useUIStore } from '../../stores/ui';
|
import { useUIStore } from '../../stores/ui';
|
||||||
@@ -190,6 +190,7 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
|
|||||||
const [reminders, setReminders] = useState<ReminderItem[]>([]);
|
const [reminders, setReminders] = useState<ReminderItem[]>([]);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [remindersLoading, setRemindersLoading] = useState(false);
|
const [remindersLoading, setRemindersLoading] = useState(false);
|
||||||
|
const remindersLoadingRef = useRef(false);
|
||||||
|
|
||||||
const { trigger: triggerHomeRefresh } = useThrottledDidShow(() => {
|
const { trigger: triggerHomeRefresh } = useThrottledDidShow(() => {
|
||||||
refreshToday();
|
refreshToday();
|
||||||
@@ -216,7 +217,8 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
|
|||||||
|
|
||||||
const loadReminders = async () => {
|
const loadReminders = async () => {
|
||||||
const patientId = useAuthStore.getState().currentPatient?.id;
|
const patientId = useAuthStore.getState().currentPatient?.id;
|
||||||
if (!patientId) return;
|
if (!patientId || remindersLoadingRef.current) return;
|
||||||
|
remindersLoadingRef.current = true;
|
||||||
setRemindersLoading(true);
|
setRemindersLoading(true);
|
||||||
try {
|
try {
|
||||||
const items: ReminderItem[] = [];
|
const items: ReminderItem[] = [];
|
||||||
@@ -257,6 +259,7 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
|
|||||||
} catch {
|
} catch {
|
||||||
setReminders([]);
|
setReminders([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
remindersLoadingRef.current = false;
|
||||||
setRemindersLoading(false);
|
setRemindersLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -103,65 +103,102 @@ async function doRefresh(): Promise<boolean> {
|
|||||||
} catch {
|
} catch {
|
||||||
// token 刷新失败
|
// token 刷新失败
|
||||||
}
|
}
|
||||||
|
isLoggingOut = true;
|
||||||
secureRemove('access_token');
|
secureRemove('access_token');
|
||||||
secureRemove('refresh_token');
|
secureRemove('refresh_token');
|
||||||
secureRemove('user_data');
|
secureRemove('user_data');
|
||||||
secureRemove('user_roles');
|
secureRemove('user_roles');
|
||||||
secureRemove('tenant_id');
|
secureRemove('tenant_id');
|
||||||
secureRemove('wechat_openid');
|
secureRemove('wechat_openid');
|
||||||
|
Taro.removeStorageSync('current_patient');
|
||||||
|
Taro.removeStorageSync('current_patient_id');
|
||||||
|
clearRequestCache();
|
||||||
|
cachedPatientId = '';
|
||||||
|
headersCacheTs = 0;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Core request ---
|
// --- Core request ---
|
||||||
async function request<T>(method: string, path: string, data?: unknown, timeout?: number, _retryCount401 = 0): Promise<T> {
|
// 微信小程序并发请求限制为 10 个,超出会排队阻塞
|
||||||
const headers = await getHeaders();
|
const MAX_CONCURRENT = 8;
|
||||||
const url = `${BASE_URL}${path}`;
|
let activeRequests = 0;
|
||||||
let res: Taro.request.SuccessCallbackResult;
|
const pendingQueue: Array<() => void> = [];
|
||||||
try {
|
|
||||||
res = await Taro.request({ url, method: method as any, data, header: headers, timeout: timeout || 15000 });
|
|
||||||
} catch (err: any) {
|
|
||||||
const msg = err?.errMsg || '';
|
|
||||||
if (msg.includes('timeout')) {
|
|
||||||
Taro.showToast({ title: '网络超时,请重试', icon: 'none' });
|
|
||||||
throw new Error('网络超时');
|
|
||||||
}
|
|
||||||
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none' });
|
|
||||||
throw new Error('网络异常');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode === 401) {
|
function acquireSlot(): Promise<void> {
|
||||||
if (isLoggingOut || _retryCount401 >= MAX_401_RETRY) {
|
if (activeRequests < MAX_CONCURRENT) {
|
||||||
throw new Error('登录已过期');
|
activeRequests++;
|
||||||
}
|
return Promise.resolve();
|
||||||
const hasToken = !!safeGet('access_token');
|
}
|
||||||
if (hasToken) {
|
return new Promise<void>((resolve) => { pendingQueue.push(resolve); });
|
||||||
const refreshed = await tryRefreshToken();
|
}
|
||||||
if (refreshed) return request<T>(method, path, data, timeout, _retryCount401 + 1);
|
|
||||||
const pages = Taro.getCurrentPages();
|
function releaseSlot(): void {
|
||||||
const currentPath = pages[pages.length - 1]?.path || '';
|
activeRequests--;
|
||||||
if (!currentPath.includes('pages/login')) {
|
const next = pendingQueue.shift();
|
||||||
Taro.reLaunch({ url: '/pages/index/index' });
|
if (next) { activeRequests++; next(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, data?: unknown, timeout?: number): Promise<T> {
|
||||||
|
let retryCount401 = 0;
|
||||||
|
for (;;) {
|
||||||
|
await acquireSlot();
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders();
|
||||||
|
const url = `${BASE_URL}${path}`;
|
||||||
|
let res: Taro.request.SuccessCallbackResult;
|
||||||
|
try {
|
||||||
|
res = await Taro.request({ url, method: method as any, data, header: headers, timeout: timeout || 15000 });
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.errMsg || '';
|
||||||
|
if (msg.includes('timeout')) {
|
||||||
|
Taro.showToast({ title: '网络超时,请重试', icon: 'none' });
|
||||||
|
throw new Error('网络超时');
|
||||||
|
}
|
||||||
|
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none' });
|
||||||
|
throw new Error('网络异常');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.statusCode === 401) {
|
||||||
|
if (isLoggingOut || retryCount401 >= MAX_401_RETRY) {
|
||||||
|
throw new Error('登录已过期');
|
||||||
|
}
|
||||||
|
const hasToken = !!safeGet('access_token');
|
||||||
|
if (hasToken) {
|
||||||
|
const refreshed = await tryRefreshToken();
|
||||||
|
if (refreshed) {
|
||||||
|
isLoggingOut = false;
|
||||||
|
retryCount401++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
const currentPath = pages[pages.length - 1]?.path || '';
|
||||||
|
if (!currentPath.includes('pages/login')) {
|
||||||
|
Taro.reLaunch({ url: '/pages/login/index' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('登录已过期');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode === 403) {
|
||||||
|
Taro.showToast({ title: '权限不足', icon: 'none' });
|
||||||
|
throw new Error('权限不足');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode >= 500) {
|
||||||
|
Taro.showToast({ title: '服务器繁忙,请稍后重试', icon: 'none' });
|
||||||
|
throw new Error('服务器错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = res.data as ApiResponse<T>;
|
||||||
|
if (!body.success) {
|
||||||
|
const userMsg = body.error_code ? (ERROR_CODE_MAP[body.error_code] || '操作失败,请稍后重试') : '操作失败,请稍后重试';
|
||||||
|
throw new Error(userMsg);
|
||||||
|
}
|
||||||
|
return body.data as T;
|
||||||
|
} finally {
|
||||||
|
releaseSlot();
|
||||||
}
|
}
|
||||||
throw new Error('登录已过期');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode === 403) {
|
|
||||||
Taro.showToast({ title: '权限不足', icon: 'none' });
|
|
||||||
throw new Error('权限不足');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode >= 500) {
|
|
||||||
Taro.showToast({ title: '服务器繁忙,请稍后重试', icon: 'none' });
|
|
||||||
throw new Error('服务器错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = res.data as ApiResponse<T>;
|
|
||||||
if (!body.success) {
|
|
||||||
const userMsg = body.error_code ? (ERROR_CODE_MAP[body.error_code] || '操作失败,请稍后重试') : '操作失败,请稍后重试';
|
|
||||||
throw new Error(userMsg);
|
|
||||||
}
|
|
||||||
return body.data as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQuery(params?: Record<string, string | number | undefined>): string {
|
function buildQuery(params?: Record<string, string | number | undefined>): string {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const CACHE_TTL = 5 * 60 * 1000;
|
|||||||
const TODAY_SUMMARY_TTL = 60_000;
|
const TODAY_SUMMARY_TTL = 60_000;
|
||||||
const MAX_TREND_KEYS = 20;
|
const MAX_TREND_KEYS = 20;
|
||||||
|
|
||||||
|
let refreshingToday = false;
|
||||||
|
|
||||||
export const useHealthStore = create<HealthState>((set, get) => ({
|
export const useHealthStore = create<HealthState>((set, get) => ({
|
||||||
todaySummary: null,
|
todaySummary: null,
|
||||||
todaySummaryFetchedAt: 0,
|
todaySummaryFetchedAt: 0,
|
||||||
@@ -28,10 +30,12 @@ export const useHealthStore = create<HealthState>((set, get) => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
|
|
||||||
refreshToday: async (force = false) => {
|
refreshToday: async (force = false) => {
|
||||||
|
if (refreshingToday) return;
|
||||||
const state = get();
|
const state = get();
|
||||||
if (!force && state.todaySummary && Date.now() - state.todaySummaryFetchedAt < TODAY_SUMMARY_TTL) {
|
if (!force && state.todaySummary && Date.now() - state.todaySummaryFetchedAt < TODAY_SUMMARY_TTL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
refreshingToday = true;
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
try {
|
try {
|
||||||
const patientId = Taro.getStorageSync('current_patient_id') || undefined;
|
const patientId = Taro.getStorageSync('current_patient_id') || undefined;
|
||||||
@@ -39,6 +43,8 @@ export const useHealthStore = create<HealthState>((set, get) => ({
|
|||||||
set({ todaySummary: data, todaySummaryFetchedAt: Date.now(), loading: false });
|
set({ todaySummary: data, todaySummaryFetchedAt: Date.now(), loading: false });
|
||||||
} catch {
|
} catch {
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
|
} finally {
|
||||||
|
refreshingToday = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
|
|
||||||
const LOGIN_PAGE = '/pages/login/index';
|
const LOGIN_PAGE = '/pages/login/index';
|
||||||
|
const MAX_PAGE_STACK = 9;
|
||||||
|
|
||||||
export function navigateToLogin() {
|
export function navigateToLogin() {
|
||||||
Taro.reLaunch({ url: LOGIN_PAGE });
|
Taro.reLaunch({ url: LOGIN_PAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function safeNavigateTo(url: string): void {
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
if (pages.length >= MAX_PAGE_STACK) {
|
||||||
|
Taro.redirectTo({ url });
|
||||||
|
} else {
|
||||||
|
Taro.navigateTo({ url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
| 咨询页长轮询 CPU 飙升 | [[miniprogram]] §5 审查 | longPoll delay=0 递归 | **已修复:** 成功路径加 3s 间隔 + 连续失败上限 50 次 |
|
| 咨询页长轮询 CPU 飙升 | [[miniprogram]] §5 审查 | longPoll delay=0 递归 | **已修复:** 成功路径加 3s 间隔 + 连续失败上限 50 次 |
|
||||||
| 小程序 DevTools 卡死(所有 API ERR_SSL_PROTOCOL_ERROR) | [[miniprogram]] request.ts | `process.env.NODE_ENV === 'production'` 时 http→https 自动转换 | **已修复:** 移除 `getHeaders` 中自动 http→https 升级,URL 以 `.env` 为唯一来源 |
|
| 小程序 DevTools 卡死(所有 API ERR_SSL_PROTOCOL_ERROR) | [[miniprogram]] request.ts | `process.env.NODE_ENV === 'production'` 时 http→https 自动转换 | **已修复:** 移除 `getHeaders` 中自动 http→https 升级,URL 以 `.env` 为唯一来源 |
|
||||||
| 小程序 Tab 切换卡死(并发请求阻塞 30s) | [[miniprogram]] request.ts | `getHeaders()` 中 `await tryRefreshToken()` 预检查 | **已修复:** 移除 getHeaders 中的同步 Token 刷新预检查,仅依赖 401 重试路径 |
|
| 小程序 Tab 切换卡死(并发请求阻塞 30s) | [[miniprogram]] request.ts | `getHeaders()` 中 `await tryRefreshToken()` 预检查 | **已修复:** 移除 getHeaders 中的同步 Token 刷新预检查,仅依赖 401 重试路径 |
|
||||||
|
| 小程序患者端登录后卡死(Tab 切换频繁卡死) | [[miniprogram]] §5 审查 | 并发请求超微信 10 限制排队 + 长轮询重叠 + 防重入缺失 | **已修复:** 全局并发限制 MAX_CONCURRENT=8 + generation counter 长轮询 + loadingRef 防重入 |
|
||||||
| 小程序登录卡死(getPhoneNumber 无响应) | [[miniprogram]] login 页 | DevTools 中 `openType='getPhoneNumber'` 不弹窗 | **已修复:** dev 模式新增"开发模式快速登录"按钮绕过手机号授权 |
|
| 小程序登录卡死(getPhoneNumber 无响应) | [[miniprogram]] login 页 | DevTools 中 `openType='getPhoneNumber'` 不弹窗 | **已修复:** dev 模式新增"开发模式快速登录"按钮绕过手机号授权 |
|
||||||
| 轮播图图片 500 | [[erp-health]] banner_handler | 媒体文件不在磁盘上 | 测试数据问题,生产环境不影响 |
|
| 轮播图图片 500 | [[erp-health]] banner_handler | 媒体文件不在磁盘上 | 测试数据问题,生产环境不影响 |
|
||||||
| 设备同步内存无限增长 | [[miniprogram]] §5 审查 | BLE 模块单例 + readings 无上限 | **已修复:** useRef 懒初始化 + MAX_LIVE_READINGS=200 |
|
| 设备同步内存无限增长 | [[miniprogram]] §5 审查 | BLE 模块单例 + readings 无上限 | **已修复:** useRef 懒初始化 + MAX_LIVE_READINGS=200 |
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ secret = "<通过环境变量 ERP__WECHAT__SECRET 设置>"
|
|||||||
| `ai-report/list` 缺少 `useThrottledDidShow` | `ai-report/list` | 只在挂载时加载,从详情页返回不刷新 |
|
| `ai-report/list` 缺少 `useThrottledDidShow` | `ai-report/list` | 只在挂载时加载,从详情页返回不刷新 |
|
||||||
| `events/list` 缺少分页 | `events/index` | 固定加载 50 条,无滚动加载更多 |
|
| `events/list` 缺少分页 | `events/index` | 固定加载 50 条,无滚动加载更多 |
|
||||||
| `device-sync` tryAutoSync 无并发保护 | `device-sync` | 快速进出页面可能重复上传 |
|
| `device-sync` tryAutoSync 无并发保护 | `device-sync` | 快速进出页面可能重复上传 |
|
||||||
| `health/index` loadTrend 无并发保护 | `health/index` | 快速切 Tab 时可能并行请求+闪烁 |
|
| `health/index` loadTrend 无并发保护 | `health/index` | **已修复:** 添加 `loadingRef` 防重入 |
|
||||||
| `doctor/prescription` handleSearch loading 竞态 | `doctor/prescription` | handleSearch 和 useEffect 的 loadData 可能闪烁 |
|
| `doctor/prescription` handleSearch loading 竞态 | `doctor/prescription` | handleSearch 和 useEffect 的 loadData 可能闪烁 |
|
||||||
|
|
||||||
#### 架构建议
|
#### 架构建议
|
||||||
@@ -485,6 +485,83 @@ secret = "<通过环境变量 ERP__WECHAT__SECRET 设置>"
|
|||||||
4. **BLE 管理器生命周期**:BLE 等硬件相关管理器应通过 Context 或 hook 管理,避免模块级单例
|
4. **BLE 管理器生命周期**:BLE 等硬件相关管理器应通过 Context 或 hook 管理,避免模块级单例
|
||||||
5. **getStorageSync 出渲染路径**:组件顶层不应有同步 I/O,统一通过 Zustand store 获取
|
5. **getStorageSync 出渲染路径**:组件顶层不应有同步 I/O,统一通过 Zustand store 获取
|
||||||
|
|
||||||
|
### 2026-05-15 患者端登录后卡死深度审查(3 专家组 × 请求链路 + 并发分析 + 端点可达性)
|
||||||
|
|
||||||
|
> 游客端卡死修复后,患者登录后又频繁出现开发者工具卡死。组织 3 个并行专家组深度审查。
|
||||||
|
> **根因:全局并发请求超过微信 10 个限制**,快速 Tab 切换时首页 5 个 + 健康页 4 个 + analytics 1 个 + 个人中心 2 个 = 12 个并发请求,超限排队阻塞 UI。
|
||||||
|
|
||||||
|
#### 审查发现
|
||||||
|
|
||||||
|
| 级别 | 问题 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **根因** | **请求并发超微信 10 限制** | 快速 Tab 切换产生 12+ 并发请求,超出排队;慢请求占位导致后续全部阻塞 |
|
||||||
|
| HIGH | doRefresh 失败未设 isLoggingOut | Storage 清了但标志未设,401 路径可能重入 |
|
||||||
|
| HIGH | 401 失败后 reLaunch 首页而非登录页 | 首页 restoreAuth 又发请求,可能循环 |
|
||||||
|
| MEDIUM | 长轮询 useDidShow 重启时新旧循环重叠 | pollingRef 设 true 时旧 setTimeout 回调也会继续 |
|
||||||
|
| MEDIUM | 首页 loadReminders 无并发保护 | useThrottledDidShow + pullDownRefresh 可同时触发 |
|
||||||
|
| MEDIUM | health store refreshToday 无防重入 | 两个页面同时调用会发两次请求 |
|
||||||
|
| MEDIUM | 健康页 loadTrend/loadAiSuggestions 无防重入 | 下拉刷新 + useThrottledDidShow 双倍请求 |
|
||||||
|
|
||||||
|
#### 端点可达性验证
|
||||||
|
|
||||||
|
**所有 33 个小程序 service 端点后端均已实现**,无 ghost endpoint(特别确认 `/analytics/batch`、`/messages/unread-count`、`/ai/suggestions` 均存在)。
|
||||||
|
|
||||||
|
#### Tab 切换请求链路分析
|
||||||
|
|
||||||
|
每次 Tab 切换触发两层回调:
|
||||||
|
|
||||||
|
| 层级 | 操作 | 开销 |
|
||||||
|
|------|------|------|
|
||||||
|
| App 级 `useDidShow` | `restoreAuth()` + `restoreUI()` | 4-6 次同步 Storage IPC,约 10-50ms |
|
||||||
|
| 页面级 `useThrottledDidShow` | 页面数据加载 | 1-5 个异步 API 请求,timeout 15s |
|
||||||
|
|
||||||
|
**最坏情况(所有缓存过期):**
|
||||||
|
|
||||||
|
| 页面 | 并发请求数 |
|
||||||
|
|------|-----------|
|
||||||
|
| 首页 HomeDashboard | 5(refreshToday + 3 个 loadReminders + getUnreadCount) |
|
||||||
|
| 健康页 | 4(refreshToday + getTrend + listPendingSuggestions + getHealthThresholds) |
|
||||||
|
| 消息页 | 1(listConsultations 或 listNotifications) |
|
||||||
|
| 个人中心 | 2(getAccount + getCheckinStatus) |
|
||||||
|
| analytics flushEvents | 1(POST /analytics/batch,每 30s) |
|
||||||
|
| **合计** | **13 个** — 超限 3 个 |
|
||||||
|
|
||||||
|
#### 修复清单(6 项,全部已修复)
|
||||||
|
|
||||||
|
| # | 级别 | 文件 | 修复 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | **HIGH** | `services/request.ts` | `doRefresh` 失败后设 `isLoggingOut=true` + 清理所有 Storage(含 `current_patient`)+ 清请求缓存 + 重置 headers 缓存 |
|
||||||
|
| 2 | **HIGH** | `services/request.ts` | 401 失败后跳 `/pages/login/index`(而非首页);重试成功后重置 `isLoggingOut` |
|
||||||
|
| 3 | **HIGH** | `services/request.ts` | **新增全局并发限制 `MAX_CONCURRENT=8`**:acquireSlot/releaseSlot 队列机制,防止超过微信 10 个请求限制;用 try/finally 确保所有路径释放槽位 |
|
||||||
|
| 4 | MEDIUM | `pages/consultation/detail` + `doctor/consultation/detail` | 长轮询改为 **generation counter**,`startLongPolling` 递增 generation,旧循环检测到 generation 变化自动退出,杜绝新旧循环重叠 |
|
||||||
|
| 5 | MEDIUM | `pages/index/index.tsx` | 首页 `loadReminders` 添加 `remindersLoadingRef` 防重入 |
|
||||||
|
| 6 | MEDIUM | `pages/health/index.tsx` + `stores/health.ts` | 健康页整体 `loadingRef` 防重入;health store `refreshToday` 添加全局 `refreshingToday` 去重 |
|
||||||
|
|
||||||
|
#### 并发限制器原理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// request.ts — acquireSlot/releaseSlot 队列
|
||||||
|
const MAX_CONCURRENT = 8; // 留 2 个空位给系统请求
|
||||||
|
let activeRequests = 0;
|
||||||
|
const pendingQueue: Array<() => void> = [];
|
||||||
|
|
||||||
|
function acquireSlot(): Promise<void> {
|
||||||
|
if (activeRequests < MAX_CONCURRENT) {
|
||||||
|
activeRequests++;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => { pendingQueue.push(resolve); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseSlot(): void {
|
||||||
|
activeRequests--;
|
||||||
|
const next = pendingQueue.shift();
|
||||||
|
if (next) { activeRequests++; next(); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有页面请求共享同一队列,保证同时最多 8 个网络请求在飞。超出自动排队,先到先得。
|
||||||
|
|
||||||
## 6. MCP 联调(微信开发者工具自动化)
|
## 6. MCP 联调(微信开发者工具自动化)
|
||||||
|
|
||||||
> 通过 MCP (Model Context Protocol) 工具直接操控微信开发者工具中的小程序模拟器,实现页面导航、元素交互、数据读取等操作。
|
> 通过 MCP (Model Context Protocol) 工具直接操控微信开发者工具中的小程序模拟器,实现页面导航、元素交互、数据读取等操作。
|
||||||
@@ -813,6 +890,8 @@ node scripts/audit-pages.mjs --role doctor --batch-size 8
|
|||||||
|
|
||||||
| 日期 | 变更 |
|
| 日期 | 变更 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-05-15 | **患者端登录后卡死深度审查(3 专家组)**:根因 — 全局并发请求超微信 10 限制排队阻塞;端点可达性验证 33/33 全部存在;Tab 切换请求链路分析(最坏 13 并发);修复 HIGH×3(doRefresh 状态清理 + 401 跳转登录页 + 全局并发限制 MAX_CONCURRENT=8)+ MEDIUM×3(长轮询 generation counter + 首页/健康页 loadingRef 防重入 + refreshToday 去重) |
|
||||||
|
| 2026-05-15 | **全量审计修复(第二轮)**:修复 CRITICAL×1(pollingRef 未定义回归,咨询详情页 loadData 引用已删除的 pollingRef → 闭会话时 ReferenceError 崩溃);HIGH×3 — 401 重试递归占用双 slot 改为循环结构释放后重入、4 个医生端列表页(consultation/alerts/dialysis/prescription)添加 loadingRef 防重入、safeNavigateTo 页栈溢出保护(栈≥9 自动 redirectTo);新增 `safeNavigateTo` 工具函数(`utils/navigate.ts`) |
|
||||||
| 2026-05-14 | **全页面性能与稳定性审查(5 专家组)**:审查 58 页面 + 基础设施层;修复 CRITICAL×3(长轮询紧密递归、BLE 模块单例、TrendChart 同步 API)+ HIGH×6(原生 HTML input、客户端过滤→服务端、Storage 渲染路径、messagesRef 同步、工作台刷新、告警单条查询);新增 §5 审查发现章节 + 架构建议 |
|
| 2026-05-14 | **全页面性能与稳定性审查(5 专家组)**:审查 58 页面 + 基础设施层;修复 CRITICAL×3(长轮询紧密递归、BLE 模块单例、TrendChart 同步 API)+ HIGH×6(原生 HTML input、客户端过滤→服务端、Storage 渲染路径、messagesRef 同步、工作台刷新、告警单条查询);新增 §5 审查发现章节 + 架构建议 |
|
||||||
| 2026-05-13 | **T40 UI 设计系统合规审计+修复**:60 页面全覆盖审计(PASS 31 / PASS_WITH_ISSUES 27 / NEEDS_WORK 2);修复 HIGH×2 + MEDIUM×6 + LOW×67;新增 `$white` 变量 + `--tk-font-display` Token;44 处 `#fff` 统一为 `$white`;14 处圆角硬编码统一为变量;3 处 TSX inline 颜色提取为 SCSS 类;ErrorBoundary 重构为 SCSS;2 处静默 catch 修复;2 处离调色板颜色修正 |
|
| 2026-05-13 | **T40 UI 设计系统合规审计+修复**:60 页面全覆盖审计(PASS 31 / PASS_WITH_ISSUES 27 / NEEDS_WORK 2);修复 HIGH×2 + MEDIUM×6 + LOW×67;新增 `$white` 变量 + `--tk-font-display` Token;44 处 `#fff` 统一为 `$white`;14 处圆角硬编码统一为变量;3 处 TSX inline 颜色提取为 SCSS 类;ErrorBoundary 重构为 SCSS;2 处静默 catch 修复;2 处离调色板颜色修正 |
|
||||||
| 2026-05-10 | **访客首页改造**:轮播图接入 `/public/banners` API + `wx.downloadFile` 下载图片到本地临时路径;文章列表接入 `/public/articles` API;文章详情页根据登录状态选择认证/公开 API(`getPublicArticleDetail`);`.env` 新增 `TARO_APP_DEFAULT_TENANT_ID`;集成契约新增 4 个公开端点 |
|
| 2026-05-10 | **访客首页改造**:轮播图接入 `/public/banners` API + `wx.downloadFile` 下载图片到本地临时路径;文章列表接入 `/public/articles` API;文章详情页根据登录状态选择认证/公开 API(`getPublicArticleDetail`);`.env` 新增 `TARO_APP_DEFAULT_TENANT_ID`;集成契约新增 4 个公开端点 |
|
||||||
|
|||||||
Reference in New Issue
Block a user