// 环境监控: 米家温湿度长期记录
// - 顶部: 授权横幅(重新授权弹窗) + 立即同步
// - 家庭: 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 ? '扫码流程失败' : '正在生成二维码...'}
)}
{failed && state?.lastError &&
{state.lastError}
}
打开米家 App → 我的 → 扫码登录,扫码后自动恢复
)
}