新增米家环境监控模块 (采集/聚合/历史/前端 EnvView) + CI 挂载米家凭证目录并自动应用迁移
Docker Build and Push / build-image (push) Successful in 6m54s
Docker Build and Push / build-image (push) Successful in 6m54s
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
// 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端)
|
||||
import { api, getApiBase } from './client.js'
|
||||
|
||||
// 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性
|
||||
export async function getMihomeStatus() {
|
||||
return api('/mihome/status')
|
||||
}
|
||||
|
||||
// 历史: range = today | month | year
|
||||
export async function getMihomeHistory(did, range) {
|
||||
return api('/mihome/device/' + encodeURIComponent(did) + '/history?range=' + encodeURIComponent(range))
|
||||
}
|
||||
|
||||
// 改别名 (alias 传空串 = 清空)
|
||||
export async function setMihomeAlias(did, alias) {
|
||||
return api('/mihome/device/' + encodeURIComponent(did), { method: 'PUT', body: { alias } })
|
||||
}
|
||||
|
||||
// 立即同步
|
||||
export async function syncMihome() {
|
||||
return api('/mihome/sync', { method: 'POST' })
|
||||
}
|
||||
|
||||
// 重授权
|
||||
export async function startMihomeAuth() {
|
||||
return api('/mihome/reauthorize', { method: 'POST' })
|
||||
}
|
||||
export async function getMihomeAuthStatus() {
|
||||
return api('/mihome/reauthorize/status')
|
||||
}
|
||||
export async function cancelMihomeAuth() {
|
||||
return api('/mihome/reauthorize/cancel', { method: 'POST' })
|
||||
}
|
||||
|
||||
// 二维码图片地址 (服务端带小米 UA 代理, 避免 CDN 拒连)
|
||||
export function mihomeQrImageUrl() {
|
||||
const base = getApiBase() // 同源时为空串
|
||||
return base + '/api/mihome/reauthorize/qr-image'
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { NavBar, Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
|
||||
import EnvView from './monitor/EnvView.jsx'
|
||||
|
||||
// 监控页: 两个横向分类
|
||||
// - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序
|
||||
@@ -41,6 +42,7 @@ export default function MonitorPage() {
|
||||
const tabs = [
|
||||
{ key: 'servers', label: '主机监控', render: () => <ServerView /> },
|
||||
{ key: 'services', label: '服务监控', render: () => <ServiceView /> },
|
||||
{ key: 'env', label: '环境监控', render: () => <EnvView /> },
|
||||
]
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// 环境监控: 米家温湿度长期记录
|
||||
// - 顶部: 授权横幅(重新授权弹窗) + 立即同步
|
||||
// - 家庭: 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>
|
||||
)
|
||||
}
|
||||
@@ -271,6 +271,42 @@ input, textarea, select { font: inherit; }
|
||||
.chart-title { font-size: 13px; font-weight: 600; color: var(--color-text); padding: 0 4px 6px; }
|
||||
.scroll-sentinel { text-align: center; padding: 14px 0 4px; color: var(--color-muted); font-size: 12px; }
|
||||
|
||||
/* ===== 环境监控 (米家温湿度) ===== */
|
||||
.env-view { padding-bottom: 16px; }
|
||||
.env-auth-banner {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
||||
margin: 10px 12px 0; padding: 10px 14px; border-radius: 8px;
|
||||
background: #fff7e6; color: #ad6800; font-size: 13px;
|
||||
}
|
||||
.env-auth-btn { color: var(--color-primary); font-weight: 600; cursor: pointer; flex-shrink: 0; }
|
||||
.env-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.env-homes { padding: 4px 12px 0; }
|
||||
.env-homes .adm-collapse-panel-header { font-size: 14px; font-weight: 600; }
|
||||
.env-home-title { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.env-home-count { font-size: 11px; font-weight: 400; color: var(--color-muted); }
|
||||
.env-devices { display: flex; flex-direction: column; gap: 10px; padding-top: 2px; }
|
||||
.env-device-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.env-device-card .adm-card-body { padding: 12px; }
|
||||
.env-device-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
|
||||
.env-device-title { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.env-device-name { font-weight: 600; font-size: 15px; color: var(--color-text); }
|
||||
.env-device-batt { font-size: 11px; color: var(--color-muted); border: 1px solid var(--color-border); padding: 1px 6px; border-radius: 10px; }
|
||||
.env-device-edit { font-size: 11px; color: var(--color-primary); cursor: pointer; }
|
||||
.env-missing-badge { flex-shrink: 0; font-size: 11px; color: #fff; background: #c0392b; border-radius: 10px; padding: 2px 8px; }
|
||||
.env-device-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0 10px; font-size: 13px; color: var(--color-text); }
|
||||
.env-device-meta em { font-style: normal; color: var(--color-muted); font-size: 11px; }
|
||||
.env-muted { color: var(--color-muted); font-size: 12px; }
|
||||
.env-range { margin: 0 0 6px; }
|
||||
.env-chart-loading, .env-chart-empty { padding: 30px 0; text-align: center; color: var(--color-muted); font-size: 12px; }
|
||||
.env-chart-err { color: var(--color-danger); font-size: 12px; padding: 10px; }
|
||||
.env-qr { text-align: center; }
|
||||
.env-qr-title { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
|
||||
.env-qr-img { width: 200px; height: 200px; border-radius: 8px; border: 1px solid var(--color-border); }
|
||||
.env-qr-wait { height: 200px; line-height: 200px; color: var(--color-muted); font-size: 13px; }
|
||||
.env-qr-err { color: var(--color-danger); font-size: 12px; margin-top: 8px; }
|
||||
.env-qr-tip { font-size: 12px; color: var(--color-muted); margin: 10px 0 14px; }
|
||||
.env-qr-actions { display: flex; justify-content: center; gap: 12px; }
|
||||
|
||||
|
||||
/* ================== OTA 升级区 (MePage) ================== */
|
||||
.me-ota-actions {
|
||||
|
||||
Reference in New Issue
Block a user