319 lines
12 KiB
React
319 lines
12 KiB
React
// 环境监控: 米家温湿度长期记录
|
||
// - 顶部: 授权横幅(重新授权弹窗) + 立即同步
|
||
// - 家庭: 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>
|
||
)
|
||
} |