// 环境监控: 米家温湿度长期记录 // - 顶部: 授权横幅(重新授权弹窗) + 立即同步 // - 家庭: 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, mihomeQrImageUrl, } 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') 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 load = useCallback((silent) => { if (!silent) setErr('') getMihomeStatus() .then((d) => { setStatus(d); setErr('') }) .catch((e) => { if (!silent) { setErr(e.message); if (!status) setStatus({ auth: { status: 'expired' }, homes: [] }) } }) }, [status]) useEffect(() => { load(false) const timer = setInterval(() => load(true), 60000) return () => clearInterval(timer) }, [load]) useRefreshOnVisible(() => load(true)) 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 (
{!authOk && status && (
米家授权{status.auth.status === 'expired' ? '已过期' : '待授权'},部分历史待补齐 setQrOpen(true)}>重新授权
)}
每小时采样 · 数据永久保留
{!status && !err &&
加载中...
} {err && (
{err} load(false)}>重试
)} {status && homes.length === 0 && authOk && } {status && homes.length > 0 && (
{homes.map((h) => ( {h.name} {h.devices.length ? h.devices.length + ' 台设备' : '无环境设备'} } > {h.devices.length === 0 ? ( ) : (
{h.devices.map((d) => ( load(true)} /> ))}
)}
))}
)} setQrOpen(false)} onOk={() => { setQrOpen(false); setRefreshTick((x) => x + 1); load(true) }} />
) } // 设备卡片: 标题=别名+电量 / 概览+缺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.current = el }} defaultValue={v} placeholder="别名(仅本应用展示)" />, closeOnAction: true, actions: [ { key: 'cancel', text: '取消' }, { key: 'ok', text: '保存', onClick: () => { onOk(); } }, ], }) } return (
{title} {batt && 电量 {batt}} 改别名
{missing > 0 && 缺 {missing} 天}
{latest ? ( <> 温度 {latest.temperature != null ? round1(latest.temperature) + '℃' : '—'} {latest.temperatureMin != null && {round1(latest.temperatureMin)}~{round1(latest.temperatureMax)}℃} 湿度 {latest.humidity != null ? round1(latest.humidity) + '%' : '—'} {latest.humidityMin != null && {round1(latest.humidityMin)}~{round1(latest.humidityMax)}%} ) : ( 暂无样本(今天 {pad(new Date().getHours())}:00 起每小时采样) )}
setRange(v)} />
{cerr &&
{cerr}
} {!cerr && loading && buckets === null &&
} {!cerr && buckets !== null && }
) } // 图表: 温度/湿度双轴; 月/年加 min/max 淡虚线 function EnvChart({ range, buckets }) { if (!buckets.length) return
该视图暂无数据
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 (
{showBand && ( <> )}
) } // 重新授权弹窗: 服务端生成二维码, 3s 轮询直到 ok/failed function AuthPopup({ open, onClose, onOk }) { const [state, setState] = useState(null) const timerRef = useRef(null) const [imgTs, setImgTs] = useState(0) const stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } } const start = async () => { stopPoll() setState(null) try { await startMihomeAuth() } catch (e) { setState({ state: 'failed', lastError: e.message }) return } const poll = () => { getMihomeAuthStatus() .then((s) => { setState(s) setImgTs(Date.now()) // 让二维码 重新拉取 (换码刷新) if (s.state === 'failed') stopPoll() if (s.state === 'idle' && s.running === false && s.qrImageUrl) { stopPoll() onOk() } }) .catch((e) => { stopPoll(); setState({ state: 'failed', lastError: e.message }) }) } poll() timerRef.current = setInterval(poll, 3000) } useEffect(() => { if (open) start() else { stopPoll(); setState(null) } return stopPoll }, [open]) const cancel = async () => { try { await cancelMihomeAuth() } catch { /* ignore */ } stopPoll() onClose() } const waiting = state?.state === 'waiting' && state?.qrImageUrl const failed = state?.state === 'failed' return (
米家扫码授权
{waiting ? ( )} {failed && state?.lastError &&
{state.lastError}
}
打开米家 App → 我的 → 扫码登录,扫码后自动恢复
) }