From a66ddb457a81e13a33720c2c041c7ff32850aa1a Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Sat, 24 Oct 2026 06:30:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=AB=E7=A0=81=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E4=B8=80=E9=97=AA=E8=80=8C=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: 米家授权过期后, 扫码弹窗从底部弹出但瞬间消失, 看不到二维码. 三处缺陷: 1. 服务端 startQrLogin() 在已有流程运行时提前 return, 未清 done. 上一轮成功残留的 done=true 会让前端第一次轮询就拿到 {running:false, done:true}, 误判为扫码成功立即关窗. 改为复用流程时显式返回 done:false. 2. 前端 AuthPopup 的成功/失败判定没有幂等保护, 且 onClose/onOk 是 父组件每次渲染新建的函数. 父级轮询触发重渲染后, 回调身份变化 会波及弹窗关闭路径. 改用 ref 持有最新回调 + settledRef 保证 一轮流程只结算一次. 3. 弹窗遮罩绑定 onMaskClick=cancel, 手机端极易误触; 关闭遮罩点击, 只允许显式点「取消」关闭. 注意: 前端需重新构建发布, 服务端需重启后生效. --- server/mihome/client.js | 9 ++++++-- src/pages/monitor/EnvView.jsx | 40 +++++++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/server/mihome/client.js b/server/mihome/client.js index 201d9c0..f9edf13 100644 --- a/server/mihome/client.js +++ b/server/mihome/client.js @@ -91,8 +91,13 @@ export function getQrImageUrl() { } export async function startQrLogin() { - // 已有一轮在跑就直接复用: 同一张二维码继续有效, 不重复开 - if (qrState.running) return getQrState() + // 已有一轮在跑就直接复用: 同一张二维码继续有效, 不重复开. + // 注意: 这里绝不能提前 return 而不清 done —— 上一轮成功时 done=true 会残留, + // 前端第一次轮询就拿到 {done:true, running:false}, 立刻误判成"扫码成功"关掉弹窗. + if (qrState.running) { + const snap = getQrState() + return { ...snap, done: false, running: true } + } const api = getApi() qrState.running = true qrState.done = false diff --git a/src/pages/monitor/EnvView.jsx b/src/pages/monitor/EnvView.jsx index eb42649..92a596f 100644 --- a/src/pages/monitor/EnvView.jsx +++ b/src/pages/monitor/EnvView.jsx @@ -275,36 +275,60 @@ function AuthPopup({ open, onClose, onOk }) { const [imgUrl, setImgUrl] = useState('') const imgSigRef = useRef('') const imgUrlRef = useRef('') + // 用 ref 持有最新回调: 父组件每次渲染都会新建 onClose/onOk, + // 若把它们放进 effect 依赖会导致 start() 反复重启、弹窗被反复关掉 + const cbRef = useRef({ onClose, onOk }) + cbRef.current = { onClose, onOk } + // 本轮是否已经结束(成功/失败), 防止多次 poll 重复触发 onOk + const settledRef = useRef(false) const stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } } const start = async () => { stopPoll() + settledRef.current = false setState(null) try { await startMihomeAuth() } catch (e) { + settledRef.current = true setState({ state: 'failed', lastError: e.message }) return } const poll = () => { getMihomeAuthStatus() .then((s) => { + if (settledRef.current) return setState(s) - if (s.state === 'failed') { stopPoll(); return } - // done 只由真正登录成功置位; 轮询结束但没 done(用户取消/超时) 不能当成功 - if (!s.running && s.done) { stopPoll(); onOk() } + if (s.state === 'failed') { + settledRef.current = true + stopPoll() + return + } + // done 只由服务端真正登录成功置位; 且必须 running=false 才算流程走完 + if (!s.running && s.done) { + settledRef.current = true + stopPoll() + cbRef.current.onOk() + } + }) + .catch((e) => { + if (settledRef.current) return + settledRef.current = true + stopPoll() + setState({ state: 'failed', lastError: e.message }) }) - .catch((e) => { stopPoll(); setState({ state: 'failed', lastError: e.message }) }) } poll() timerRef.current = setInterval(poll, 3000) } + // 只依赖 open: start 内部通过 ref 读回调, 不会因父组件重渲染而重启 useEffect(() => { if (open) start() - else { stopPoll(); setState(null) } + else { stopPoll(); settledRef.current = false; setState(null) } return stopPoll + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) // 二维码变化时才重新拉图 (轮询 3s 一次, 用字符串签名去重, 避免每轮都请求图片) @@ -341,7 +365,8 @@ function AuthPopup({ open, onClose, onOk }) { const cancel = async () => { try { await cancelMihomeAuth() } catch { /* ignore */ } stopPoll() - onClose() + settledRef.current = true + cbRef.current.onClose() } const waiting = state?.state === 'waiting' && !!imgUrl @@ -349,7 +374,8 @@ function AuthPopup({ open, onClose, onOk }) { const failed = state?.state === 'failed' return ( - + // 关闭遮罩点击: 手机端很容易误触导致弹窗一闪而过; 只允许显式点「取消」关闭 +
米家扫码授权
{waiting ? (