修复米家会话失效后不弹登录二维码
Docker Build and Push / build-image (push) Successful in 5m46s

根因一: 过期检测形同虚设
- @zythum02/mijia-api 的 refreshToken() 在本地凭证字段齐全时直接返回成功,
  根本不发请求, 因此 checkAuth() 永远判定 ok, 过期后既不出横幅也不出扫码入口
- checkAuth() 改为真打一次 getHomesList() 验证 token; 成功缓存 5 分钟,
  失败只缓存 30 秒(便于网络恢复后自愈); 增加并发探测合并; 状态未变不写库
- getStatus() 以实时探测结果为准, DB 里的 status 仅在探测为 unknown 时兜底,
  避免残留的 ok 遮蔽已失效状态

根因二: 二维码图片 401 导致弹窗空白
- /api/mihome/reauthorize/qr-image 在 authRequired 之后, 而 <img src> 无法携带
  Authorization 头, 图片请求被拒, 用户看到的是空白/裂图
- 新增 fetchMihomeQrImage(): 带 JWT fetch 后转 blob URL 给 <img>, 并在卸载/换码时释放

其他修复
- EnvView 的 load 依赖 [status] 且成功回调又 setStatus, 造成 /status 无限重复请求;
  改为空依赖 + 函数式 setStatus; 首屏与重试走 ?force=1 强查
- 会话失效(非网络类错误)时自动弹出扫码窗, 每次挂载仅弹一次
- 扫码状态机新增 done 标记, 只有真正登录成功才算成功, 避免取消后误判
- 二维码 120s 超时自动续码(最多 2 次), 慢扫不再直接失败
- 取消后不再丢弃可能已成功的扫码结果
- scripts/mihome-login.mjs 适配 done 语义
- 同步更新设计文档 §5.2 / §5.4 / §6

注意: 前端需重新构建发布后生效。
This commit is contained in:
jif2.zhang
2026-10-23 22:21:08 +08:00
parent 7b4e59957b
commit 95b9985e8b
7 changed files with 188 additions and 81 deletions
+18 -7
View File
@@ -1,9 +1,10 @@
// 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端)
import { api, getApiBase } from './client.js'
import { api, getApiBase, getToken } from './client.js'
// 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性
export async function getMihomeStatus() {
return api('/mihome/status')
// force=true 让后端跳过 5 分钟认证缓存, 立刻回真实授权状态
export async function getMihomeStatus(force) {
return api('/mihome/status' + (force ? '?force=1' : ''))
}
// 历史: range = today | month | year
@@ -32,8 +33,18 @@ export async function cancelMihomeAuth() {
return api('/mihome/reauthorize/cancel', { method: 'POST' })
}
// 二维码图片地址 (服务端带小米 UA 代理, 避免 CDN 拒连)
export function mihomeQrImageUrl() {
const base = getApiBase() // 同源时为空串
return base + '/api/mihome/reauthorize/qr-image'
// 二维码图片: 后端接口在 authRequired 后面, 而 <img src> 无法带 Authorization 头,
// 直接塞地址会 401 (二维码位置一片空白). 这里用 fetch 带 JWT 取回二进制, 再转 blob URL 给 <img>.
export async function fetchMihomeQrImage() {
const token = getToken()
const res = await fetch(getApiBase() + '/api/mihome/reauthorize/qr-image', {
headers: token ? { Authorization: 'Bearer ' + token } : {},
cache: 'no-store',
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || '二维码加载失败 (' + res.status + ')')
}
const blob = await res.blob()
return URL.createObjectURL(blob)
}
+64 -14
View File
@@ -9,7 +9,7 @@ import {
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import {
getMihomeStatus, getMihomeHistory, setMihomeAlias, syncMihome,
startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, mihomeQrImageUrl,
startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, fetchMihomeQrImage,
} from '../../api/mihome.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
@@ -21,6 +21,8 @@ const RANGE_OPTIONS = [
const round1 = (v) => (v === null || v === undefined ? null : Math.round(Number(v) * 10) / 10)
const pad = (n) => String(n).padStart(2, '0')
// 网络类失败: 不能据此断定米家授权失效
const NETWORK_ERR_RE = /fetch failed|network|ECONN|ETIMEDOUT|timeout|socket|getaddrinfo|ENOTFOUND|aborted/i
export default function EnvView() {
const [status, setStatus] = useState(null)
@@ -28,13 +30,20 @@ export default function EnvView() {
const [syncing, setSyncing] = useState(false)
const [refreshTick, setRefreshTick] = useState(0)
const [qrOpen, setQrOpen] = useState(false)
const autoPromptedRef = useRef(false)
// 依赖必须为空: 之前依赖 [status] 而成功回调又 setStatus(...), 每次响应都会换掉 load,
// 触发 effect 重新执行 -> 无限重复请求 /status. 首屏/重试用 force 强查, 定时轮询走缓存.
const load = useCallback((silent) => {
if (!silent) setErr('')
getMihomeStatus()
getMihomeStatus(!silent)
.then((d) => { setStatus(d); setErr('') })
.catch((e) => { if (!silent) { setErr(e.message); if (!status) setStatus({ auth: { status: 'expired' }, homes: [] }) } })
}, [status])
.catch((e) => {
if (silent) return
setErr(e.message)
setStatus((prev) => prev || { auth: { status: 'expired' }, homes: [] })
})
}, [])
useEffect(() => {
load(false)
@@ -43,6 +52,16 @@ export default function EnvView() {
}, [load])
useRefreshOnVisible(() => load(true))
// 会话失效时直接把扫码弹窗弹出来 (每次挂载只自动弹一次, 用户取消后不再骚扰).
// 网络抖动也会让后端判成 expired, 这类错误不自动弹, 只留横幅提示, 避免误弹.
useEffect(() => {
if (autoPromptedRef.current) return
if (status?.auth?.status !== 'expired') return
if (NETWORK_ERR_RE.test(status.auth.lastError || '')) return
autoPromptedRef.current = true
setQrOpen(true)
}, [status])
const doSync = async () => {
setSyncing(true)
try {
@@ -253,7 +272,9 @@ function EnvChart({ range, buckets }) {
function AuthPopup({ open, onClose, onOk }) {
const [state, setState] = useState(null)
const timerRef = useRef(null)
const [imgTs, setImgTs] = useState(0)
const [imgUrl, setImgUrl] = useState('')
const imgSigRef = useRef('')
const imgUrlRef = useRef('')
const stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } }
@@ -270,12 +291,9 @@ function AuthPopup({ open, onClose, onOk }) {
getMihomeAuthStatus()
.then((s) => {
setState(s)
setImgTs(Date.now()) // 让二维码 <img> 重新拉取 (换码刷新)
if (s.state === 'failed') stopPoll()
if (s.state === 'idle' && s.running === false && s.qrImageUrl) {
stopPoll()
onOk()
}
if (s.state === 'failed') { stopPoll(); return }
// done 只由真正登录成功置位; 轮询结束但没 done(用户取消/超时) 不能当成功
if (!s.running && s.done) { stopPoll(); onOk() }
})
.catch((e) => { stopPoll(); setState({ state: 'failed', lastError: e.message }) })
}
@@ -289,13 +307,45 @@ function AuthPopup({ open, onClose, onOk }) {
return stopPoll
}, [open])
// 二维码变化时才重新拉图 (轮询 3s 一次, 用字符串签名去重, 避免每轮都请求图片)
const qrSig = state?.state === 'waiting' ? state.qrImageUrl || '' : ''
useEffect(() => {
if (!qrSig) {
imgSigRef.current = ''
setImgUrl('')
return
}
if (qrSig === imgSigRef.current) return
imgSigRef.current = qrSig
let alive = true
setImgUrl('')
fetchMihomeQrImage()
.then((url) => {
if (!alive) { URL.revokeObjectURL(url); return }
if (imgUrlRef.current) URL.revokeObjectURL(imgUrlRef.current)
imgUrlRef.current = url
setImgUrl(url)
})
.catch((e) => {
if (!alive) return
// 图片拉取失败就停轮询, 让用户点「刷新二维码」重来, 否则下一轮状态会把错误盖掉
stopPoll()
setState({ state: 'failed', lastError: e.message })
})
return () => { alive = false }
}, [qrSig])
// 卸载时释放 blob, 防止内存泄漏
useEffect(() => () => { if (imgUrlRef.current) URL.revokeObjectURL(imgUrlRef.current) }, [])
const cancel = async () => {
try { await cancelMihomeAuth() } catch { /* ignore */ }
stopPoll()
onClose()
}
const waiting = state?.state === 'waiting' && state?.qrImageUrl
const waiting = state?.state === 'waiting' && !!imgUrl
const loading = state?.state === 'waiting' && !imgUrl
const failed = state?.state === 'failed'
return (
@@ -303,7 +353,7 @@ function AuthPopup({ open, onClose, onOk }) {
<div className="env-qr">
<div className="env-qr-title">米家扫码授权</div>
{waiting ? (
<img className="env-qr-img" src={mihomeQrImageUrl() + '?ts=' + imgTs} alt="扫码授权" />
<img className="env-qr-img" src={imgUrl} alt="扫码授权" />
) : (
<div className="env-qr-wait"><DotLoading /> {failed ? '扫码流程失败' : '正在生成二维码...'}</div>
)}
@@ -311,7 +361,7 @@ function AuthPopup({ open, onClose, onOk }) {
<div className="env-qr-tip">打开米家 App → 我的 → 扫码登录,扫码后自动恢复</div>
<div className="env-qr-actions">
<Button size="mini" fill="outline" onClick={cancel}>取消</Button>
<Button size="mini" color="primary" fill="outline" loading={!waiting && !failed} onClick={() => start()}>刷新二维码</Button>
<Button size="mini" color="primary" fill="outline" loading={loading} onClick={() => start()}>刷新二维码</Button>
</div>
</div>
</Popup>