refactor(mp): 长轮询通用化 — useLongPolling hook + 咨询详情页接入
- 新增 useLongPolling hook:generation counter 防重叠、useDidShow/Hide 可见性控制、失败退避、enabled 守卫
- 患者端 + 医生端 consultation/detail 接入,删除约 80 行重复长轮询代码
- 架构建议 5/5 全部完成 ✅
This commit is contained in:
93
apps/miniprogram/src/hooks/useLongPolling.ts
Normal file
93
apps/miniprogram/src/hooks/useLongPolling.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import { useDidShow, useDidHide } from '@tarojs/taro';
|
||||||
|
|
||||||
|
interface LongPollOptions<T> {
|
||||||
|
/** 轮询函数,返回新数据或 null/undefined */
|
||||||
|
pollFn: () => Promise<T | null | undefined>;
|
||||||
|
/** 收到新数据后的回调 */
|
||||||
|
onData: (data: T) => void;
|
||||||
|
/** 是否启用轮询(受外部状态控制) */
|
||||||
|
enabled: boolean;
|
||||||
|
/** 成功轮询间隔 ms,默认 3000 */
|
||||||
|
intervalMs?: number;
|
||||||
|
/** 连续失败上限,默认 50 */
|
||||||
|
maxFailures?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用长轮询 hook。
|
||||||
|
*
|
||||||
|
* - generation counter 杜绝新旧循环重叠
|
||||||
|
* - useDidShow/useDidHide 控制页面可见性
|
||||||
|
* - 失败退避:delay = min(failCount * 2000, 30000)
|
||||||
|
* - 自动在 enabled 变 false 时停止(如会话关闭)
|
||||||
|
*/
|
||||||
|
export function useLongPolling<T>({
|
||||||
|
pollFn,
|
||||||
|
onData,
|
||||||
|
enabled,
|
||||||
|
intervalMs = 3000,
|
||||||
|
maxFailures = 50,
|
||||||
|
}: LongPollOptions<T>) {
|
||||||
|
const generation = useRef(0);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const pollFnRef = useRef(pollFn);
|
||||||
|
const onDataRef = useRef(onData);
|
||||||
|
pollFnRef.current = pollFn;
|
||||||
|
onDataRef.current = onData;
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
const gen = ++generation.current;
|
||||||
|
runPoll(gen, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
generation.current += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runPoll = async (gen: number, failCount: number) => {
|
||||||
|
if (gen !== generation.current || !mountedRef.current) return;
|
||||||
|
if (failCount >= maxFailures) return;
|
||||||
|
try {
|
||||||
|
const data = await pollFnRef.current();
|
||||||
|
if (gen !== generation.current || !mountedRef.current) return;
|
||||||
|
if (data != null) {
|
||||||
|
onDataRef.current(data);
|
||||||
|
}
|
||||||
|
failCount = 0;
|
||||||
|
} catch {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
if (gen !== generation.current || !mountedRef.current) return;
|
||||||
|
const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : intervalMs;
|
||||||
|
await new Promise<void>((r) => setTimeout(r, delay));
|
||||||
|
if (gen === generation.current && mountedRef.current) {
|
||||||
|
runPoll(gen, failCount);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// enabled 变 false → 停止;变 true → 启动
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
start();
|
||||||
|
} else {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
if (enabled) start();
|
||||||
|
});
|
||||||
|
useDidHide(() => {
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
generation.current += 1;
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { start, stop };
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { View, Text, Input, Image, ScrollView } from '@tarojs/components';
|
import { View, Text, Input, Image, ScrollView } from '@tarojs/components';
|
||||||
import Taro, { useRouter, useDidShow, useDidHide } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import {
|
import {
|
||||||
getSession,
|
getSession,
|
||||||
listMessages,
|
listMessages,
|
||||||
@@ -12,16 +12,11 @@ import {
|
|||||||
} from '@/services/consultation';
|
} from '@/services/consultation';
|
||||||
import Loading from '@/components/Loading';
|
import Loading from '@/components/Loading';
|
||||||
import { useElderClass } from '@/hooks/useElderClass';
|
import { useElderClass } from '@/hooks/useElderClass';
|
||||||
|
import { useLongPolling } from '@/hooks/useLongPolling';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
/** DOM 节点数量上限,超过时只渲染最新的消息 */
|
|
||||||
const MAX_RENDER_MESSAGES = 200;
|
const MAX_RENDER_MESSAGES = 200;
|
||||||
/** React state 中保留的消息上限(比渲染上限略多,保留滚动缓冲) */
|
|
||||||
const MAX_STATE_MESSAGES = 300;
|
const MAX_STATE_MESSAGES = 300;
|
||||||
/** 成功轮询后最小间隔(ms),防止后端快速响应时紧密递归 */
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
|
||||||
/** 连续失败上限,超过后停止轮询 */
|
|
||||||
const MAX_CONSECUTIVE_FAILURES = 50;
|
|
||||||
|
|
||||||
export default function ConsultationDetail() {
|
export default function ConsultationDetail() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -32,74 +27,37 @@ 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 pollingGeneration = useRef(0);
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
const messagesRef = useRef<ConsultationMessage[]>([]);
|
const messagesRef = useRef<ConsultationMessage[]>([]);
|
||||||
const modeClass = useElderClass();
|
const modeClass = useElderClass();
|
||||||
|
const dataLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
useLongPolling({
|
||||||
|
pollFn: () => {
|
||||||
|
const cur = messagesRef.current;
|
||||||
|
const lastId = cur.length > 0 ? cur[cur.length - 1].id : undefined;
|
||||||
|
return pollMessages(sessionId, lastId);
|
||||||
|
},
|
||||||
|
onData: (newMsgs) => {
|
||||||
|
if (!Array.isArray(newMsgs) || newMsgs.length === 0) return;
|
||||||
|
setMessages((prev) => {
|
||||||
|
const existing = new Set(prev.map((msg) => msg.id));
|
||||||
|
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
||||||
|
const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES);
|
||||||
|
messagesRef.current = next;
|
||||||
|
scrollViewRef.current = `msg-${next.length}`;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: !!sessionId && dataLoadedRef.current && session?.status !== 'closed',
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
loadData();
|
loadData();
|
||||||
markRead();
|
markRead();
|
||||||
startLongPolling();
|
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
pollingGeneration.current += 1;
|
|
||||||
mountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
// 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询)
|
|
||||||
useDidShow(() => {
|
|
||||||
if (sessionId && session?.status !== 'closed') {
|
|
||||||
startLongPolling();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
useDidHide(() => {
|
|
||||||
pollingGeneration.current += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.status === 'closed') {
|
|
||||||
pollingGeneration.current += 1;
|
|
||||||
}
|
|
||||||
}, [session?.status]);
|
|
||||||
|
|
||||||
const startLongPolling = () => {
|
|
||||||
const gen = ++pollingGeneration.current;
|
|
||||||
longPoll(gen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const longPoll = async (gen: number, failCount = 0) => {
|
|
||||||
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
|
||||||
if (failCount >= MAX_CONSECUTIVE_FAILURES) return;
|
|
||||||
try {
|
|
||||||
const currentMessages = messagesRef.current;
|
|
||||||
const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined;
|
|
||||||
const newMsgs = await pollMessages(sessionId, lastId);
|
|
||||||
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
|
||||||
if (newMsgs && newMsgs.length > 0) {
|
|
||||||
setMessages((prev) => {
|
|
||||||
const existing = new Set(prev.map((msg) => msg.id));
|
|
||||||
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
|
||||||
const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES);
|
|
||||||
messagesRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
scrollViewRef.current = `msg-${currentMessages.length + newMsgs.length}`;
|
|
||||||
}
|
|
||||||
failCount = 0;
|
|
||||||
} catch {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
|
||||||
const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS;
|
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
|
||||||
if (gen === pollingGeneration.current && mountedRef.current) {
|
|
||||||
longPoll(gen, failCount);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -112,6 +70,7 @@ export default function ConsultationDetail() {
|
|||||||
setMessages(msgs);
|
setMessages(msgs);
|
||||||
messagesRef.current = msgs;
|
messagesRef.current = msgs;
|
||||||
scrollViewRef.current = `msg-${msgs.length}`;
|
scrollViewRef.current = `msg-${msgs.length}`;
|
||||||
|
dataLoadedRef.current = true;
|
||||||
} catch {
|
} catch {
|
||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { useState, useEffect, useRef } 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, useDidShow, useDidHide } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import {
|
import {
|
||||||
getSession, listMessages, pollMessages,
|
getSession, listMessages, pollMessages,
|
||||||
markSessionRead, sendMessage, closeSession,
|
markSessionRead, sendMessage, closeSession,
|
||||||
type ConsultationSession, type ConsultationMessage,
|
type ConsultationSession, type ConsultationMessage,
|
||||||
} from '@/services/doctor/consultation';
|
} from '@/services/doctor/consultation';
|
||||||
import Loading from '@/components/Loading';
|
import Loading from '@/components/Loading';
|
||||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
import { useElderClass } from '@/hooks/useElderClass';
|
||||||
|
import { useLongPolling } from '@/hooks/useLongPolling';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
/** DOM 节点数量上限,超过时只渲染最新的消息 */
|
|
||||||
const MAX_RENDER_MESSAGES = 200;
|
const MAX_RENDER_MESSAGES = 200;
|
||||||
/** React state 中保留的消息上限(比渲染上限略多,保留滚动缓冲) */
|
|
||||||
const MAX_STATE_MESSAGES = 300;
|
const MAX_STATE_MESSAGES = 300;
|
||||||
/** 成功轮询后最小间隔(ms),防止后端快速响应时紧密递归 */
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
|
||||||
/** 连续失败上限,超过后停止轮询 */
|
|
||||||
const MAX_CONSECUTIVE_FAILURES = 50;
|
|
||||||
|
|
||||||
export default function ConsultationDetail() {
|
export default function ConsultationDetail() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -29,73 +24,36 @@ 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 pollingGeneration = useRef(0);
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
const messagesRef = useRef<ConsultationMessage[]>([]);
|
const messagesRef = useRef<ConsultationMessage[]>([]);
|
||||||
|
const dataLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
useLongPolling({
|
||||||
|
pollFn: () => {
|
||||||
|
const cur = messagesRef.current;
|
||||||
|
const lastId = cur.length > 0 ? cur[cur.length - 1].id : undefined;
|
||||||
|
return pollMessages(sessionId, lastId);
|
||||||
|
},
|
||||||
|
onData: (newMsgs) => {
|
||||||
|
if (!Array.isArray(newMsgs) || newMsgs.length === 0) return;
|
||||||
|
setMessages((prev) => {
|
||||||
|
const existing = new Set(prev.map((msg) => msg.id));
|
||||||
|
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
||||||
|
const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES);
|
||||||
|
messagesRef.current = next;
|
||||||
|
scrollViewRef.current = `msg-${next.length}`;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: !!sessionId && dataLoadedRef.current && session?.status !== 'closed',
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
loadData();
|
loadData();
|
||||||
markRead();
|
markRead();
|
||||||
startLongPolling();
|
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
pollingGeneration.current += 1;
|
|
||||||
mountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
// 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询)
|
|
||||||
useDidShow(() => {
|
|
||||||
if (sessionId && session?.status !== 'closed') {
|
|
||||||
startLongPolling();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
useDidHide(() => {
|
|
||||||
pollingGeneration.current += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.status === 'closed') {
|
|
||||||
pollingGeneration.current += 1;
|
|
||||||
}
|
|
||||||
}, [session?.status]);
|
|
||||||
|
|
||||||
const startLongPolling = () => {
|
|
||||||
const gen = ++pollingGeneration.current;
|
|
||||||
longPoll(gen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const longPoll = async (gen: number, failCount = 0) => {
|
|
||||||
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
|
||||||
if (failCount >= MAX_CONSECUTIVE_FAILURES) return;
|
|
||||||
try {
|
|
||||||
const currentMessages = messagesRef.current;
|
|
||||||
const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined;
|
|
||||||
const newMsgs = await pollMessages(sessionId, lastId);
|
|
||||||
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
|
||||||
if (newMsgs && newMsgs.length > 0) {
|
|
||||||
setMessages((prev) => {
|
|
||||||
const existing = new Set(prev.map((msg) => msg.id));
|
|
||||||
const fresh = newMsgs.filter((msg) => !existing.has(msg.id));
|
|
||||||
const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES);
|
|
||||||
messagesRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
scrollViewRef.current = `msg-${currentMessages.length + newMsgs.length}`;
|
|
||||||
}
|
|
||||||
failCount = 0;
|
|
||||||
} catch {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
if (gen !== pollingGeneration.current || !mountedRef.current) return;
|
|
||||||
const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS;
|
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
|
||||||
if (gen === pollingGeneration.current && mountedRef.current) {
|
|
||||||
longPoll(gen, failCount);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -108,6 +66,7 @@ export default function ConsultationDetail() {
|
|||||||
setMessages(msgs);
|
setMessages(msgs);
|
||||||
messagesRef.current = msgs;
|
messagesRef.current = msgs;
|
||||||
scrollViewRef.current = `msg-${msgs.length}`;
|
scrollViewRef.current = `msg-${msgs.length}`;
|
||||||
|
dataLoadedRef.current = true;
|
||||||
} catch {
|
} catch {
|
||||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ POST /auth/wechat/login { code }
|
|||||||
| `usePagination` | 通用分页逻辑 |
|
| `usePagination` | 通用分页逻辑 |
|
||||||
| `useAuthRequired` | 登录态检查 |
|
| `useAuthRequired` | 登录态检查 |
|
||||||
| `useElderClass` | 长者模式 CSS class |
|
| `useElderClass` | 长者模式 CSS class |
|
||||||
|
| `useLongPolling` | **通用长轮询**:generation counter 防重叠 + useDidShow/Hide 可见性控制 + 失败退避(delay = min(failCount×2s, 30s))+ enabled 条件守卫。咨询详情页(患者+医生端)已接入 |
|
||||||
|
|
||||||
### 服务层(10+ 个文件)
|
### 服务层(10+ 个文件)
|
||||||
|
|
||||||
@@ -496,7 +497,7 @@ secret = "<通过环境变量 ERP__WECHAT__SECRET 设置>"
|
|||||||
#### 架构建议(已完成标注 ✅)
|
#### 架构建议(已完成标注 ✅)
|
||||||
|
|
||||||
1. ~~**统一数据加载模式**~~ ✅ 已完成:所有列表/详情页使用 `usePageData` hook,44/58 页面已接入
|
1. ~~**统一数据加载模式**~~ ✅ 已完成:所有列表/详情页使用 `usePageData` hook,44/58 页面已接入
|
||||||
2. **长轮询通用化**:`consultation/detail` 和 `doctor/consultation/detail` 的长轮询逻辑几乎相同,应抽取为 `useLongPolling` hook
|
2. ~~**长轮询通用化**~~ ✅ 已完成:抽取 `useLongPolling` hook(generation counter + useDidShow/Hide + 失败退避),患者端+医生端 consultation/detail 已接入
|
||||||
3. ~~**服务端过滤优先**~~ ✅ 已完成:所有列表页的 Tab 过滤传参给后端
|
3. ~~**服务端过滤优先**~~ ✅ 已完成:所有列表页的 Tab 过滤传参给后端
|
||||||
4. ~~**BLE 管理器生命周期**~~ ✅ 已完成:改为 `useRef` 懒初始化
|
4. ~~**BLE 管理器生命周期**~~ ✅ 已完成:改为 `useRef` 懒初始化
|
||||||
5. ~~**getStorageSync 出渲染路径**~~ ✅ 已完成:通过 Zustand store 获取
|
5. ~~**getStorageSync 出渲染路径**~~ ✅ 已完成:通过 Zustand store 获取
|
||||||
@@ -906,6 +907,7 @@ node scripts/audit-pages.mjs --role doctor --batch-size 8
|
|||||||
|
|
||||||
| 日期 | 变更 |
|
| 日期 | 变更 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-05-15 | **架构重构 P3:长轮询通用化 useLongPolling**:抽取 `useLongPolling` hook(generation counter + useDidShow/Hide 可见性 + 失败退避 + enabled 守卫);患者端 + 医生端 consultation/detail 接入,删除 ~80 行重复代码;架构建议 #2 全部完成 ✅ |
|
||||||
| 2026-05-15 | **架构重构 P2:request.ts 模块级状态收编 + AbortSignal + Analytics 受控**:提取 `ConcurrencyLimiter` 类(并发限制)、`ResponseCache` 类(缓存+去重+patientId 绑定);新增 `resetForTesting()` 测试隔离函数;`api.get/post/put/delete` 支持 `AbortSignal` 请求取消;app.tsx Analytics 定时器改为 `useDidShow`/`useDidHide` 控制后台暂停;构建通过 + 测试 74/75 |
|
| 2026-05-15 | **架构重构 P2:request.ts 模块级状态收编 + AbortSignal + Analytics 受控**:提取 `ConcurrencyLimiter` 类(并发限制)、`ResponseCache` 类(缓存+去重+patientId 绑定);新增 `resetForTesting()` 测试隔离函数;`api.get/post/put/delete` 支持 `AbortSignal` 请求取消;app.tsx Analytics 定时器改为 `useDidShow`/`useDidHide` 控制后台暂停;构建通过 + 测试 74/75 |
|
||||||
| 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 | **患者端登录后卡死深度审查(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-15 | **全量审计修复(第二轮)**:修复 CRITICAL×1(pollingRef 未定义回归,咨询详情页 loadData 引用已删除的 pollingRef → 闭会话时 ReferenceError 崩溃);HIGH×3 — 401 重试递归占用双 slot 改为循环结构释放后重入、4 个医生端列表页(consultation/alerts/dialysis/prescription)添加 loadingRef 防重入、safeNavigateTo 页栈溢出保护(栈≥9 自动 redirectTo);新增 `safeNavigateTo` 工具函数(`utils/navigate.ts`) |
|
||||||
|
|||||||
Reference in New Issue
Block a user