Files
iboard/src/pages/monitor/EnvView.jsx
T

319 lines
12 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, 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 (
<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 [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()) // 让二维码 <img> 重新拉取 (换码刷新)
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 (
<Popup visible={open} onMaskClick={cancel} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
<div className="env-qr">
<div className="env-qr-title">米家扫码授权</div>
{waiting ? (
<img className="env-qr-img" src={mihomeQrImageUrl() + '?ts=' + imgTs} 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={!waiting && !failed} onClick={() => start()}>刷新二维码</Button>
</div>
</div>
</Popup>
)
}