现象: 米家授权过期后, 扫码弹窗从底部弹出但瞬间消失, 看不到二维码.
三处缺陷:
1. 服务端 startQrLogin() 在已有流程运行时提前 return, 未清 done.
上一轮成功残留的 done=true 会让前端第一次轮询就拿到
{running:false, done:true}, 误判为扫码成功立即关窗.
改为复用流程时显式返回 done:false.
2. 前端 AuthPopup 的成功/失败判定没有幂等保护, 且 onClose/onOk 是
父组件每次渲染新建的函数. 父级轮询触发重渲染后, 回调身份变化
会波及弹窗关闭路径. 改用 ref 持有最新回调 + settledRef 保证
一轮流程只结算一次.
3. 弹窗遮罩绑定 onMaskClick=cancel, 手机端极易误触; 关闭遮罩点击,
只允许显式点「取消」关闭.
注意: 前端需重新构建发布, 服务端需重启后生效.
This commit is contained in:
@@ -91,8 +91,13 @@ export function getQrImageUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startQrLogin() {
|
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()
|
const api = getApi()
|
||||||
qrState.running = true
|
qrState.running = true
|
||||||
qrState.done = false
|
qrState.done = false
|
||||||
|
|||||||
@@ -275,36 +275,60 @@ function AuthPopup({ open, onClose, onOk }) {
|
|||||||
const [imgUrl, setImgUrl] = useState('')
|
const [imgUrl, setImgUrl] = useState('')
|
||||||
const imgSigRef = useRef('')
|
const imgSigRef = useRef('')
|
||||||
const imgUrlRef = 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 stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } }
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
stopPoll()
|
stopPoll()
|
||||||
|
settledRef.current = false
|
||||||
setState(null)
|
setState(null)
|
||||||
try {
|
try {
|
||||||
await startMihomeAuth()
|
await startMihomeAuth()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
settledRef.current = true
|
||||||
setState({ state: 'failed', lastError: e.message })
|
setState({ state: 'failed', lastError: e.message })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const poll = () => {
|
const poll = () => {
|
||||||
getMihomeAuthStatus()
|
getMihomeAuthStatus()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
|
if (settledRef.current) return
|
||||||
setState(s)
|
setState(s)
|
||||||
if (s.state === 'failed') { stopPoll(); return }
|
if (s.state === 'failed') {
|
||||||
// done 只由真正登录成功置位; 轮询结束但没 done(用户取消/超时) 不能当成功
|
settledRef.current = true
|
||||||
if (!s.running && s.done) { stopPoll(); onOk() }
|
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()
|
poll()
|
||||||
timerRef.current = setInterval(poll, 3000)
|
timerRef.current = setInterval(poll, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只依赖 open: start 内部通过 ref 读回调, 不会因父组件重渲染而重启
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) start()
|
if (open) start()
|
||||||
else { stopPoll(); setState(null) }
|
else { stopPoll(); settledRef.current = false; setState(null) }
|
||||||
return stopPoll
|
return stopPoll
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// 二维码变化时才重新拉图 (轮询 3s 一次, 用字符串签名去重, 避免每轮都请求图片)
|
// 二维码变化时才重新拉图 (轮询 3s 一次, 用字符串签名去重, 避免每轮都请求图片)
|
||||||
@@ -341,7 +365,8 @@ function AuthPopup({ open, onClose, onOk }) {
|
|||||||
const cancel = async () => {
|
const cancel = async () => {
|
||||||
try { await cancelMihomeAuth() } catch { /* ignore */ }
|
try { await cancelMihomeAuth() } catch { /* ignore */ }
|
||||||
stopPoll()
|
stopPoll()
|
||||||
onClose()
|
settledRef.current = true
|
||||||
|
cbRef.current.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const waiting = state?.state === 'waiting' && !!imgUrl
|
const waiting = state?.state === 'waiting' && !!imgUrl
|
||||||
@@ -349,7 +374,8 @@ function AuthPopup({ open, onClose, onOk }) {
|
|||||||
const failed = state?.state === 'failed'
|
const failed = state?.state === 'failed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup visible={open} onMaskClick={cancel} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
|
// 关闭遮罩点击: 手机端很容易误触导致弹窗一闪而过; 只允许显式点「取消」关闭
|
||||||
|
<Popup visible={open} closeOnMaskClick={false} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
|
||||||
<div className="env-qr">
|
<div className="env-qr">
|
||||||
<div className="env-qr-title">米家扫码授权</div>
|
<div className="env-qr-title">米家扫码授权</div>
|
||||||
{waiting ? (
|
{waiting ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user