Files
iboard/src/pages/monitor/EnvView.jsx
T
jif2.zhang a66ddb457a 修复扫码弹窗一闪而过
现象: 米家授权过期后, 扫码弹窗从底部弹出但瞬间消失, 看不到二维码.

三处缺陷:
1. 服务端 startQrLogin() 在已有流程运行时提前 return, 未清 done.
   上一轮成功残留的 done=true 会让前端第一次轮询就拿到
   {running:false, done:true}, 误判为扫码成功立即关窗.
   改为复用流程时显式返回 done:false.
2. 前端 AuthPopup 的成功/失败判定没有幂等保护, 且 onClose/onOk 是
   父组件每次渲染新建的函数. 父级轮询触发重渲染后, 回调身份变化
   会波及弹窗关闭路径. 改用 ref 持有最新回调 + settledRef 保证
   一轮流程只结算一次.
3. 弹窗遮罩绑定 onMaskClick=cancel, 手机端极易误触; 关闭遮罩点击,
   只允许显式点「取消」关闭.

注意: 前端需重新构建发布, 服务端需重启后生效.
2026-10-24 07:27:03 +08:00

395 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 环境监控: 米家温湿度长期记录
// - 顶部: 授权横幅(重新授权弹窗) + 立即同步
// - 家庭: Collapse 手风琴, 一次只展开一个家庭
// - 设备卡片: 标题=别名+电量, 概览+缺N天, Segmented 今天/月/年 图表
import { useEffect, useState, useRef, useCallback } from 'react'
import {
Collapse, Popup, Dialog, Toast, Empty, DotLoading, Segmented, Button, Card, Input,
} from 'antd-mobile'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import {
getMihomeStatus, getMihomeHistory, setMihomeAlias, syncMihome,
startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, fetchMihomeQrImage,
} from '../../api/mihome.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
const RANGE_OPTIONS = [
{ label: '今天', value: 'today' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' },
]
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)
const [err, setErr] = useState('')
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(!silent)
.then((d) => { setStatus(d); setErr('') })
.catch((e) => {
if (silent) return
setErr(e.message)
setStatus((prev) => prev || { auth: { status: 'expired' }, homes: [] })
})
}, [])
useEffect(() => {
load(false)
const timer = setInterval(() => load(true), 60000)
return () => clearInterval(timer)
}, [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 {
await syncMihome()
Toast.show({ icon: 'success', content: '已同步' })
setRefreshTick((x) => x + 1)
load(true)
} catch (e) {
Toast.show({ content: e.message })
} finally {
setSyncing(false)
}
}
const authOk = status?.auth?.status === 'ok'
const homes = status?.homes || []
return (
<div className="env-view">
{!authOk && status && (
<div className="env-auth-banner">
<span>米家授权{status.auth.status === 'expired' ? '已过期' : '待授权'},部分历史待补齐</span>
<span className="env-auth-btn" onClick={() => setQrOpen(true)}>重新授权</span>
</div>
)}
<div className="monitor-tip env-toolbar">
<span>每小时采样 · 数据永久保留</span>
<Button size="mini" color="primary" fill="outline" loading={syncing} onClick={doSync}>立即同步</Button>
</div>
{!status && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{err && (
<div className="error-banner">
{err}
<span className="retry-link" onClick={() => load(false)}>重试</span>
</div>
)}
{status && homes.length === 0 && authOk && <Empty description="暂无米家环境设备" />}
{status && homes.length > 0 && (
<div className="env-homes">
<Collapse accordion defaultActiveKey={homes[0].homeId}>
{homes.map((h) => (
<Collapse.Panel
key={h.homeId}
title={
<span className="env-home-title">
{h.name}
<span className="env-home-count">{h.devices.length ? h.devices.length + ' 台设备' : '无环境设备'}</span>
</span>
}
>
{h.devices.length === 0 ? (
<Empty description="该家庭暂无环境设备" />
) : (
<div className="env-devices">
{h.devices.map((d) => (
<DeviceCard key={d.did} device={d} refreshTick={refreshTick} onChanged={() => load(true)} />
))}
</div>
)}
</Collapse.Panel>
))}
</Collapse>
</div>
)}
<AuthPopup open={qrOpen} onClose={() => setQrOpen(false)} onOk={() => { setQrOpen(false); setRefreshTick((x) => x + 1); load(true) }} />
</div>
)
}
// 设备卡片: 标题=别名+电量 / 概览+缺N天 / 今天·月·年图表
function DeviceCard({ device, refreshTick, onChanged }) {
const [range, setRange] = useState('today')
const [buckets, setBuckets] = useState(null)
const [loading, setLoading] = useState(false)
const [cerr, setCerr] = useState('')
const load = useCallback((r) => {
setLoading(true); setCerr('')
getMihomeHistory(device.did, r)
.then((d) => { setBuckets(d.buckets || []); setCerr('') })
.catch((e) => setCerr(e.message))
.finally(() => setLoading(false))
}, [device.did])
useEffect(() => { load(range) }, [range, refreshTick, load])
const missing = device.missingDays?.length || 0
const latest = device.latest
const title = device.title || device.name
const batt = device.battery != null ? device.battery + '%' : null
const editAlias = () => {
const input = { current: null }
const onOk = async () => {
const val = (input.current && input.current.nativeElement) ? input.current.nativeElement.value : ''
try {
await setMihomeAlias(device.did, val.trim())
Toast.show({ icon: 'success', content: '已保存' })
onChanged()
} catch (e) {
Toast.show({ content: e.message })
}
}
let v = device.alias || device.name
Dialog.show({
title: '编辑别名',
content: <Input ref={(el) => { input.current = el }} defaultValue={v} placeholder="别名(仅本应用展示)" />,
closeOnAction: true,
actions: [
{ key: 'cancel', text: '取消' },
{ key: 'ok', text: '保存', onClick: () => { onOk(); } },
],
})
}
return (
<Card className="env-device-card">
<div className="env-device-head">
<div className="env-device-title">
<span className="env-device-name">{title}</span>
{batt && <span className="env-device-batt">电量 {batt}</span>}
<span className="env-device-edit" onClick={editAlias}>改别名</span>
</div>
{missing > 0 && <span className="env-missing-badge">缺 {missing} 天</span>}
</div>
<div className="env-device-meta">
{latest ? (
<>
<span>温度 {latest.temperature != null ? round1(latest.temperature) + '℃' : '—'}
{latest.temperatureMin != null && <em> {round1(latest.temperatureMin)}~{round1(latest.temperatureMax)}℃</em>}</span>
<span>湿度 {latest.humidity != null ? round1(latest.humidity) + '%' : '—'}
{latest.humidityMin != null && <em> {round1(latest.humidityMin)}~{round1(latest.humidityMax)}%</em>}</span>
</>
) : (
<span className="env-muted">暂无样本(今天 {pad(new Date().getHours())}:00 起每小时采样)</span>
)}
</div>
<div className="env-range">
<Segmented
options={RANGE_OPTIONS}
value={range}
onChange={(v) => setRange(v)}
/>
</div>
{cerr && <div className="env-chart-err">{cerr}</div>}
{!cerr && loading && buckets === null && <div className="env-chart-loading"><DotLoading /></div>}
{!cerr && buckets !== null && <EnvChart range={range} buckets={buckets} />}
</Card>
)
}
// 图表: 温度/湿度双轴; 月/年加 min/max 淡虚线
function EnvChart({ range, buckets }) {
if (!buckets.length) return <div className="env-chart-empty">该视图暂无数据</div>
const rows = buckets.map((b) => {
let x
let temp = round1(b.temperatureAvg ?? b.temperature)
let hum = round1(b.humidityAvg ?? b.humidity)
if (range === 'today') {
const d = new Date(b.t)
x = pad(d.getHours()) + ':00'
} else if (range === 'month') {
const d = new Date(b.t)
x = (d.getMonth() + 1) + '/' + d.getDate()
} else {
x = b.label || ''
}
return {
x,
temp,
hum,
tempMin: round1(b.temperatureMin),
tempMax: round1(b.temperatureMax),
humMin: round1(b.humidityMin),
humMax: round1(b.humidityMax),
sampleCount: b.sampleCount,
}
})
const showBand = range === 'month' || range === 'year'
return (
<div className="chart-wrap">
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rows} margin={{ top: 6, right: 6, left: -6, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" tick={{ fontSize: 10 }} minTickGap={range === 'today' ? 30 : 20} />
<YAxis yAxisId="temp" tick={{ fontSize: 10 }} width={36} unit="°" />
<YAxis yAxisId="hum" orientation="right" tick={{ fontSize: 10 }} width={40} unit="%" />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line yAxisId="temp" dataKey="temp" name="温度" stroke="#1677ff" strokeWidth={1.8} dot={false} connectNulls={false} isAnimationActive={false} />
<Line yAxisId="hum" dataKey="hum" name="湿度" stroke="#00b578" strokeWidth={1.8} dot={false} connectNulls={false} isAnimationActive={false} />
{showBand && (
<>
<Line yAxisId="temp" dataKey="tempMin" name="温/最低" stroke="#1677ff" strokeWidth={1} strokeDasharray="3 3" opacity={0.45} dot={false} isAnimationActive={false} />
<Line yAxisId="temp" dataKey="tempMax" name="温/最高" stroke="#1677ff" strokeWidth={1} strokeDasharray="3 3" opacity={0.45} dot={false} isAnimationActive={false} />
<Line yAxisId="hum" dataKey="humMin" name="湿/最低" stroke="#00b578" strokeWidth={1} strokeDasharray="3 3" opacity={0.4} dot={false} isAnimationActive={false} />
<Line yAxisId="hum" dataKey="humMax" name="湿/最高" stroke="#00b578" strokeWidth={1} strokeDasharray="3 3" opacity={0.4} dot={false} isAnimationActive={false} />
</>
)}
</LineChart>
</ResponsiveContainer>
</div>
)
}
// 重新授权弹窗: 服务端生成二维码, 3s 轮询直到 ok/failed
function AuthPopup({ open, onClose, onOk }) {
const [state, setState] = useState(null)
const timerRef = useRef(null)
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') {
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 })
})
}
poll()
timerRef.current = setInterval(poll, 3000)
}
// 只依赖 open: start 内部通过 ref 读回调, 不会因父组件重渲染而重启
useEffect(() => {
if (open) start()
else { stopPoll(); settledRef.current = false; setState(null) }
return stopPoll
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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()
settledRef.current = true
cbRef.current.onClose()
}
const waiting = state?.state === 'waiting' && !!imgUrl
const loading = state?.state === 'waiting' && !imgUrl
const failed = state?.state === 'failed'
return (
// 关闭遮罩点击: 手机端很容易误触导致弹窗一闪而过; 只允许显式点「取消」关闭
<Popup visible={open} closeOnMaskClick={false} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
<div className="env-qr">
<div className="env-qr-title">米家扫码授权</div>
{waiting ? (
<img className="env-qr-img" src={imgUrl} alt="扫码授权" />
) : (
<div className="env-qr-wait"><DotLoading /> {failed ? '扫码流程失败' : '正在生成二维码...'}</div>
)}
{failed && state?.lastError && <div className="env-qr-err">{state.lastError}</div>}
<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={loading} onClick={() => start()}>刷新二维码</Button>
</div>
</div>
</Popup>
)
}