Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3140eae7db |
+11
-19
@@ -205,27 +205,24 @@ server/routes/mihome.js # REST 路由 (authRequired)
|
|||||||
|
|
||||||
### 5.2 认证与「重新授权」
|
### 5.2 认证与「重新授权」
|
||||||
|
|
||||||
- 启动时加载 `DiskCache` 凭证,并用一次真实云请求(`getHomesList`)确认 token 仍有效;
|
- 启动时 `login()` 自动尝试加载/刷新 `DiskCache` 凭证;成功则 `status=ok`。
|
||||||
只有该请求通过才 `status=ok`。
|
- 失败(30 天过期 / 风控)→ `MihomeAccount.status=expired`,前端弹授权横幅。
|
||||||
- 失败(30 天过期 / 风控 / 无凭证)→ `MihomeAccount.status=expired`,前端弹授权横幅 + 扫码窗。
|
|
||||||
- 重新授权流程(服务端驱动,前端轮询):
|
- 重新授权流程(服务端驱动,前端轮询):
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/mihome/reauthorize
|
POST /api/mihome/reauthorize
|
||||||
└─ 若已有流程在跑 => 直接复用(同一张二维码继续有效)
|
└─ 若 pendingQr 空闲 => 后台启动 QRlogin 循环(每轮 120s 超时自动续二维码)
|
||||||
└─ 否则后台启动 QRlogin 循环(每轮 120s 超时自动续二维码, 最多续 2 次)
|
└─ 立即返回 { state, qrImageUrl }
|
||||||
└─ 立即返回 { running, done, state, qrImageUrl }
|
|
||||||
|
|
||||||
GET /api/mihome/reauthorize/status
|
GET /api/mihome/reauthorize/status
|
||||||
└─ 返回 { running, done, state: idle|waiting|failed, qrImageUrl?, lastError? }
|
└─ 返回 { state: idle|waiting|scanning|ok|failed, qrImageUrl?, lastError? }
|
||||||
└─ done=true 表示扫码成功、凭证已落盘、authCache 已置 ok
|
└─ 扫码成功(qrlogin 返回) => 保存凭证 => 状态 ok => 触发一次立即同步
|
||||||
|
|
||||||
POST /api/mihome/reauthorize/cancel # 人工取消: 只让前端停止轮询, 后台长轮询自然结束
|
POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
|
||||||
```
|
```
|
||||||
|
|
||||||
- `qrState` 为模块级单例,防并发(同一时间只允许一个扫码流程)。
|
- `pendingQr` 为模块级单例,防并发(同一时间只允许一个扫码流程)。
|
||||||
- 前端在弹窗内 `setInterval 3s` 轮询,遇 `done=true` 关弹窗并刷新列表;`failed` 则停轮询显示错误。
|
- 前端在弹窗内 `setInterval 3s` 轮询直到 `ok`,成功后关弹窗并刷新列表。
|
||||||
- 二维码图片接口在 `authRequired` 之后,`<img>` 带不上 JWT,前端必须 `fetch` 后转 `blob:` URL 显示。
|
|
||||||
|
|
||||||
### 5.3 采集器 collector
|
### 5.3 采集器 collector
|
||||||
|
|
||||||
@@ -264,7 +261,7 @@ POST /api/mihome/reauthorize/cancel # 人工取消: 只让前端停止轮询,
|
|||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果);`?force=1` 跳过 5 分钟认证缓存强查一次 |
|
| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果) |
|
||||||
| GET | `/device/:did/history?range=today` | 当天逐小时(见 §5.6);缺省 `range=today` |
|
| GET | `/device/:did/history?range=today` | 当天逐小时(见 §5.6);缺省 `range=today` |
|
||||||
| GET | `/device/:did/history?range=month` | 近 30 天逐日(日均 + min/max) |
|
| GET | `/device/:did/history?range=month` | 近 30 天逐日(日均 + min/max) |
|
||||||
| GET | `/device/:did/history?range=year` | 近 52 周逐周(周均值 + min/max,服务端按日档聚合) |
|
| GET | `/device/:did/history?range=year` | 近 52 周逐周(周均值 + min/max,服务端按日档聚合) |
|
||||||
@@ -300,12 +297,7 @@ POST /api/mihome/reauthorize/cancel # 人工取消: 只让前端停止轮询,
|
|||||||
|
|
||||||
1. **授权横幅**(`status !== ok`):
|
1. **授权横幅**(`status !== ok`):
|
||||||
`米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `<img src=qrImageUrl>`
|
`米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `<img src=qrImageUrl>`
|
||||||
3s 轮询 `/reauthorize/status` → `done=true` 自动关闭并刷新。
|
3s 轮询 `/reauthorize/status` → 成功自动关闭并刷新。
|
||||||
- `status=expired` 且非网络类错误时,进入页面**自动弹出一次**扫码窗(用户关掉后不再重复弹)。
|
|
||||||
- 二维码图片接口在 `authRequired` 后面,`<img src>` 带不上 JWT,必须用 `fetch` + `Authorization`
|
|
||||||
取二进制再转 `blob:` URL(见 `src/api/mihome.js` 的 `fetchMihomeQrImage()`),否则图片 401、弹窗里一片空白。
|
|
||||||
- 认证探测不能只看 `api.refreshToken()`:该库在本地凭证字段齐全时直接返回成功而不请求云端,
|
|
||||||
所以 `checkAuth()` 必须真打一次 `getHomesList()` 才能发现 30 天 token 过期。
|
|
||||||
2. **顶部操作**:`立即同步`(POST `/sync`)。
|
2. **顶部操作**:`立即同步`(POST `/sync`)。
|
||||||
3. **家庭列表**:`antd-mobile <Collapse accordion>` 按家庭分组;组展开后显示该家庭
|
3. **家庭列表**:`antd-mobile <Collapse accordion>` 按家庭分组;组展开后显示该家庭
|
||||||
下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。
|
下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "iboard",
|
"name": "iboard",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# iBoard
|
# iBoard
|
||||||
用于收集和整理经济数据,但是以策略为主体重新组织。
|
用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||||
|
|
||||||
|
## 版本管理 (V0.1)
|
||||||
|
|
||||||
|
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.1.0`)。
|
||||||
|
发版时只改这一处,以下位置必须保持一致(改版本时一并核对):
|
||||||
|
|
||||||
|
| 文件 | 字段 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
|
||||||
|
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) |
|
||||||
|
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。
|
||||||
|
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
|
||||||
|
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
|
||||||
|
|
||||||
- 首页主要是放经济文章
|
- 首页主要是放经济文章
|
||||||
- 数据主要是放各种经济数据和图表。
|
- 数据主要是放各种经济数据和图表。
|
||||||
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ for (;;) {
|
|||||||
console.log('[米家] 扫码失败: ' + (s.lastError || '未知'))
|
console.log('[米家] 扫码失败: ' + (s.lastError || '未知'))
|
||||||
process.exit(2)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
if (s.done) {
|
if (s.state === 'idle' && s.running === false && !s.qrImageUrl) {
|
||||||
console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/')
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
if (!s.running && !s.qrImageUrl) {
|
|
||||||
// 启动即失败(可能直接完成/或没权限)
|
// 启动即失败(可能直接完成/或没权限)
|
||||||
console.log('[米家] 流程结束(无二维码产生), 请检查日志')
|
console.log('[米家] 流程结束(无二维码产生), 请检查日志')
|
||||||
process.exit(2)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
|
if (!s.running && s.qrImageUrl) {
|
||||||
|
console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
if (Date.now() - started > 150000) {
|
if (Date.now() - started > 150000) {
|
||||||
console.log('[米家] 超时, 已停止; 可重跑.')
|
console.log('[米家] 超时, 已停止; 可重跑.')
|
||||||
process.exit(3)
|
process.exit(3)
|
||||||
|
|||||||
+20
-58
@@ -20,11 +20,8 @@ export function getApi() {
|
|||||||
return _api
|
return _api
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证状态内存缓存 (成功缓存 5 分钟; 失败只缓存 30 秒, 网络/服务恢复后能尽快自愈)
|
// 认证状态内存缓存 (5 分钟 TTL, /status 不每次打米家账号服务)
|
||||||
const TTL_OK = 5 * 60 * 1000
|
|
||||||
const TTL_BAD = 30 * 1000
|
|
||||||
let authCache = { status: 'unknown', lastError: '', checkedAt: 0 }
|
let authCache = { status: 'unknown', lastError: '', checkedAt: 0 }
|
||||||
let probing = null // 并发合并: 页面首次渲染时 force 探测 + 可见性刷新可能同时进来
|
|
||||||
|
|
||||||
function persistAccount(status, lastError) {
|
function persistAccount(status, lastError) {
|
||||||
return prisma.mihomeAccount
|
return prisma.mihomeAccount
|
||||||
@@ -38,49 +35,31 @@ function persistAccount(status, lastError) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验米家是否可用: 有凭证 + 真打一次云接口确认 token 没失效
|
// 校验米家是否可用: 有凭证 + 能刷新 token (刷新会自动处理 30 天过期)
|
||||||
// force=true 忽略 TTL 强查
|
// force=true 忽略 TTL 强查
|
||||||
//
|
|
||||||
// 注意: 不能用 api.refreshToken() 当探针. 该库在本地凭证字段齐全时直接返回
|
|
||||||
// { tokenRefreshed: false } (见 @zythum02/mijia-api 的 available getter),
|
|
||||||
// 根本不发请求 —— 于是 30 天 token 过期后它永远返回成功, 前端就永远看不到
|
|
||||||
// 「授权已过期」横幅, 也就不会弹扫码. 必须发一次真实业务请求才算验证.
|
|
||||||
export async function checkAuth(force = false) {
|
export async function checkAuth(force = false) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const ttl = authCache.status === 'ok' ? TTL_OK : TTL_BAD
|
if (!force && now - authCache.checkedAt < 5 * 60 * 1000) return authCache
|
||||||
if (!force && now - authCache.checkedAt < ttl) return authCache
|
|
||||||
if (probing) return probing
|
|
||||||
probing = (async () => {
|
|
||||||
const api = getApi()
|
const api = getApi()
|
||||||
let status = 'expired'
|
let status = 'expired'
|
||||||
let lastError = ''
|
let lastError = ''
|
||||||
try {
|
try {
|
||||||
if (!api.available) throw new Error('无授权凭证')
|
if (!api.available) throw new Error('无授权凭证')
|
||||||
await api.refreshToken() // 凭证真过期时这里会自动走 passToken 刷新
|
await api.refreshToken()
|
||||||
await api.getHomesList() // 真正打到米家云, token 失效会抛 APIError
|
|
||||||
status = 'ok'
|
status = 'ok'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status = 'expired'
|
status = 'expired'
|
||||||
lastError = e?.message || String(e)
|
lastError = e?.message || String(e)
|
||||||
}
|
}
|
||||||
const changed = authCache.status !== status || authCache.lastError !== lastError
|
authCache = { status, lastError, checkedAt: now }
|
||||||
authCache = { status, lastError, checkedAt: Date.now() }
|
await persistAccount(status, lastError)
|
||||||
// 状态没变就不写库, 避免 30 秒重试把 DB 刷爆
|
|
||||||
if (changed) await persistAccount(status, lastError)
|
|
||||||
return authCache
|
return authCache
|
||||||
})()
|
|
||||||
try {
|
|
||||||
return await probing
|
|
||||||
} finally {
|
|
||||||
probing = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 扫码重授权流程 (模块级单例) ====================
|
// ==================== 扫码重授权流程 (模块级单例) ====================
|
||||||
// state: idle | waiting(已出二维码等扫码) | failed
|
// 状态: idle | waiting(已出二维码等扫码) | failed
|
||||||
// done: 只有真正登录成功才置 true, 前端靠它判断「轮询结束 = 授权成功」
|
|
||||||
// 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB
|
// 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB
|
||||||
const qrState = { running: false, done: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
|
const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
|
||||||
|
|
||||||
export function getQrState() {
|
export function getQrState() {
|
||||||
return { ...qrState }
|
return { ...qrState }
|
||||||
@@ -91,50 +70,29 @@ export function getQrImageUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startQrLogin() {
|
export async function startQrLogin() {
|
||||||
// 已有一轮在跑就直接复用: 同一张二维码继续有效, 不重复开.
|
if (qrState.running) return getQrState()
|
||||||
// 注意: 这里绝不能提前 return 而不清 done —— 上一轮成功时 done=true 会残留,
|
|
||||||
// 前端第一次轮询就拿到 {done:true, running:false}, 立刻误判成"扫码成功"关掉弹窗.
|
|
||||||
if (qrState.running) {
|
|
||||||
const snap = getQrState()
|
|
||||||
return { ...snap, done: false, running: true }
|
|
||||||
}
|
|
||||||
const api = getApi()
|
const api = getApi()
|
||||||
qrState.running = true
|
qrState.running = true
|
||||||
qrState.done = false
|
qrState.canceled = false
|
||||||
qrState.state = 'idle'
|
|
||||||
qrState.lastError = ''
|
qrState.lastError = ''
|
||||||
qrState.qrImageUrl = ''
|
qrState.qrImageUrl = ''
|
||||||
qrState.startedAt = Date.now()
|
qrState.startedAt = Date.now()
|
||||||
;(async () => {
|
;(async () => {
|
||||||
// 二维码有效期 120s (库内长轮询 AbortSignal.timeout), 超时就重开一张,
|
|
||||||
// 否则用户扫码慢一点只会看到「扫码流程失败」, 还得手动点刷新
|
|
||||||
const MAX_RETRY = 2
|
|
||||||
try {
|
|
||||||
for (let attempt = 0; ; attempt++) {
|
|
||||||
try {
|
try {
|
||||||
await api.QRlogin(async (info) => {
|
await api.QRlogin(async (info) => {
|
||||||
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
|
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
|
||||||
qrState.state = 'waiting'
|
qrState.state = 'waiting'
|
||||||
})
|
})
|
||||||
// 这里不要因为「用户点了取消」就丢弃结果: 弹窗关了但二维码可能已经被扫,
|
if (qrState.canceled) return
|
||||||
// 凭证库内部已落盘, 丢掉会让前端继续显示已过期
|
|
||||||
qrState.state = 'idle'
|
qrState.state = 'idle'
|
||||||
qrState.done = true
|
|
||||||
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
|
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
|
||||||
await persistAccount('ok', '')
|
await persistAccount('ok', '')
|
||||||
return
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e?.message || String(e)
|
if (qrState.canceled) {
|
||||||
const timedOut = /abort|timeout|timed out/i.test(msg)
|
|
||||||
if (timedOut && attempt < MAX_RETRY) {
|
|
||||||
qrState.qrImageUrl = ''
|
|
||||||
qrState.state = 'idle'
|
qrState.state = 'idle'
|
||||||
continue
|
} else {
|
||||||
}
|
|
||||||
qrState.state = 'failed'
|
qrState.state = 'failed'
|
||||||
qrState.lastError = msg
|
qrState.lastError = e?.message || String(e)
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
qrState.running = false
|
qrState.running = false
|
||||||
@@ -144,7 +102,11 @@ export async function startQrLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cancelQrLogin() {
|
export function cancelQrLogin() {
|
||||||
// 只影响前端是否继续轮询; 后台长轮询没法中断, 让它自然结束 (扫了也照样算成功)
|
qrState.canceled = true
|
||||||
if (!qrState.running && qrState.state !== 'failed') qrState.state = 'idle'
|
if (qrState.running) {
|
||||||
|
// 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果
|
||||||
|
} else if (qrState.state !== 'failed') {
|
||||||
|
qrState.state = 'idle'
|
||||||
|
}
|
||||||
return getQrState()
|
return getQrState()
|
||||||
}
|
}
|
||||||
@@ -10,9 +10,8 @@ export { getDevice, todayBuckets, monthBuckets, yearBuckets, deviceOverview } fr
|
|||||||
export { getSpec, ENV_MODELS } from './specs.js'
|
export { getSpec, ENV_MODELS } from './specs.js'
|
||||||
|
|
||||||
// GET /api/mihome/status 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日)
|
// GET /api/mihome/status 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日)
|
||||||
// force=true 跳过 5 分钟认证缓存强查一次 (用户主动打开页面/刷新时用)
|
export async function getStatus() {
|
||||||
export async function getStatus(force = false) {
|
const authState = await auth.checkAuth()
|
||||||
const authState = await auth.checkAuth(force)
|
|
||||||
const [homes, devices, account] = await Promise.all([
|
const [homes, devices, account] = await Promise.all([
|
||||||
prisma.mihomeHome.findMany({ orderBy: { id: 'asc' } }),
|
prisma.mihomeHome.findMany({ orderBy: { id: 'asc' } }),
|
||||||
prisma.mihomeDevice.findMany({ orderBy: { id: 'asc' } }),
|
prisma.mihomeDevice.findMany({ orderBy: { id: 'asc' } }),
|
||||||
@@ -57,13 +56,10 @@ export async function getStatus(force = false) {
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 以实时探测结果为准: DB 里的 status 只在实时探测还是 unknown 时兜底,
|
|
||||||
// 否则过期后残留的 ok 会把「已失效」遮掉, 前端永远不出重新授权横幅.
|
|
||||||
const liveUnknown = !authState.status || authState.status === 'unknown'
|
|
||||||
return {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
status: liveUnknown ? account?.status || 'unknown' : authState.status,
|
status: account?.status || authState.status,
|
||||||
lastError: (liveUnknown ? account?.lastError : authState.lastError) || '',
|
lastError: account?.lastError || authState.lastError,
|
||||||
checkedAt: authState.checkedAt,
|
checkedAt: authState.checkedAt,
|
||||||
},
|
},
|
||||||
capability,
|
capability,
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ function guard(handler) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数)
|
// 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数)
|
||||||
// force=1: 跳过后端 5 分钟认证缓存强查 (前端打开页面时用, 保证会话失效能立刻发现)
|
router.get('/status', guard(async (_req, res) => {
|
||||||
router.get('/status', guard(async (req, res) => {
|
res.json(await getStatus())
|
||||||
res.json(await getStatus(req.query.force === '1'))
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 历史: range = today(默认) | month | year
|
// 历史: range = today(默认) | month | year
|
||||||
|
|||||||
+7
-18
@@ -1,10 +1,9 @@
|
|||||||
// 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端)
|
// 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端)
|
||||||
import { api, getApiBase, getToken } from './client.js'
|
import { api, getApiBase } from './client.js'
|
||||||
|
|
||||||
// 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性
|
// 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性
|
||||||
// force=true 让后端跳过 5 分钟认证缓存, 立刻回真实授权状态
|
export async function getMihomeStatus() {
|
||||||
export async function getMihomeStatus(force) {
|
return api('/mihome/status')
|
||||||
return api('/mihome/status' + (force ? '?force=1' : ''))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 历史: range = today | month | year
|
// 历史: range = today | month | year
|
||||||
@@ -33,18 +32,8 @@ export async function cancelMihomeAuth() {
|
|||||||
return api('/mihome/reauthorize/cancel', { method: 'POST' })
|
return api('/mihome/reauthorize/cancel', { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 二维码图片: 后端接口在 authRequired 后面, 而 <img src> 无法带 Authorization 头,
|
// 二维码图片地址 (服务端带小米 UA 代理, 避免 CDN 拒连)
|
||||||
// 直接塞地址会 401 (二维码位置一片空白). 这里用 fetch 带 JWT 取回二进制, 再转 blob URL 给 <img>.
|
export function mihomeQrImageUrl() {
|
||||||
export async function fetchMihomeQrImage() {
|
const base = getApiBase() // 同源时为空串
|
||||||
const token = getToken()
|
return base + '/api/mihome/reauthorize/qr-image'
|
||||||
const res = await fetch(getApiBase() + '/api/mihome/reauthorize/qr-image', {
|
|
||||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
|
||||||
cache: 'no-store',
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error(data.error || '二维码加载失败 (' + res.status + ')')
|
|
||||||
}
|
|
||||||
const blob = await res.blob()
|
|
||||||
return URL.createObjectURL(blob)
|
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||||
import {
|
import {
|
||||||
getMihomeStatus, getMihomeHistory, setMihomeAlias, syncMihome,
|
getMihomeStatus, getMihomeHistory, setMihomeAlias, syncMihome,
|
||||||
startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, fetchMihomeQrImage,
|
startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, mihomeQrImageUrl,
|
||||||
} from '../../api/mihome.js'
|
} from '../../api/mihome.js'
|
||||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||||
|
|
||||||
@@ -21,8 +21,6 @@ const RANGE_OPTIONS = [
|
|||||||
|
|
||||||
const round1 = (v) => (v === null || v === undefined ? null : Math.round(Number(v) * 10) / 10)
|
const round1 = (v) => (v === null || v === undefined ? null : Math.round(Number(v) * 10) / 10)
|
||||||
const pad = (n) => String(n).padStart(2, '0')
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
// 网络类失败: 不能据此断定米家授权失效
|
|
||||||
const NETWORK_ERR_RE = /fetch failed|network|ECONN|ETIMEDOUT|timeout|socket|getaddrinfo|ENOTFOUND|aborted/i
|
|
||||||
|
|
||||||
export default function EnvView() {
|
export default function EnvView() {
|
||||||
const [status, setStatus] = useState(null)
|
const [status, setStatus] = useState(null)
|
||||||
@@ -30,20 +28,13 @@ export default function EnvView() {
|
|||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
const [refreshTick, setRefreshTick] = useState(0)
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
const [qrOpen, setQrOpen] = useState(false)
|
const [qrOpen, setQrOpen] = useState(false)
|
||||||
const autoPromptedRef = useRef(false)
|
|
||||||
|
|
||||||
// 依赖必须为空: 之前依赖 [status] 而成功回调又 setStatus(...), 每次响应都会换掉 load,
|
|
||||||
// 触发 effect 重新执行 -> 无限重复请求 /status. 首屏/重试用 force 强查, 定时轮询走缓存.
|
|
||||||
const load = useCallback((silent) => {
|
const load = useCallback((silent) => {
|
||||||
if (!silent) setErr('')
|
if (!silent) setErr('')
|
||||||
getMihomeStatus(!silent)
|
getMihomeStatus()
|
||||||
.then((d) => { setStatus(d); setErr('') })
|
.then((d) => { setStatus(d); setErr('') })
|
||||||
.catch((e) => {
|
.catch((e) => { if (!silent) { setErr(e.message); if (!status) setStatus({ auth: { status: 'expired' }, homes: [] }) } })
|
||||||
if (silent) return
|
}, [status])
|
||||||
setErr(e.message)
|
|
||||||
setStatus((prev) => prev || { auth: { status: 'expired' }, homes: [] })
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(false)
|
load(false)
|
||||||
@@ -52,16 +43,6 @@ export default function EnvView() {
|
|||||||
}, [load])
|
}, [load])
|
||||||
useRefreshOnVisible(() => load(true))
|
useRefreshOnVisible(() => load(true))
|
||||||
|
|
||||||
// 会话失效时直接把扫码弹窗弹出来 (每次挂载只自动弹一次, 用户取消后不再骚扰).
|
|
||||||
// 网络抖动也会让后端判成 expired, 这类错误不自动弹, 只留横幅提示, 避免误弹.
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoPromptedRef.current) return
|
|
||||||
if (status?.auth?.status !== 'expired') return
|
|
||||||
if (NETWORK_ERR_RE.test(status.auth.lastError || '')) return
|
|
||||||
autoPromptedRef.current = true
|
|
||||||
setQrOpen(true)
|
|
||||||
}, [status])
|
|
||||||
|
|
||||||
const doSync = async () => {
|
const doSync = async () => {
|
||||||
setSyncing(true)
|
setSyncing(true)
|
||||||
try {
|
try {
|
||||||
@@ -272,114 +253,57 @@ function EnvChart({ range, buckets }) {
|
|||||||
function AuthPopup({ open, onClose, onOk }) {
|
function AuthPopup({ open, onClose, onOk }) {
|
||||||
const [state, setState] = useState(null)
|
const [state, setState] = useState(null)
|
||||||
const timerRef = useRef(null)
|
const timerRef = useRef(null)
|
||||||
const [imgUrl, setImgUrl] = useState('')
|
const [imgTs, setImgTs] = useState(0)
|
||||||
const imgSigRef = useRef('')
|
|
||||||
const imgUrlRef = useRef('')
|
|
||||||
// 用 ref 持有最新回调: 父组件每次渲染都会新建 onClose/onOk,
|
|
||||||
// 若把它们放进 effect 依赖会导致 start() 反复重启、弹窗被反复关掉
|
|
||||||
const cbRef = useRef({ onClose, onOk })
|
|
||||||
cbRef.current = { onClose, onOk }
|
|
||||||
// 本轮是否已经结束(成功/失败), 防止多次 poll 重复触发 onOk
|
|
||||||
const settledRef = useRef(false)
|
|
||||||
|
|
||||||
const stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } }
|
const stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } }
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
stopPoll()
|
stopPoll()
|
||||||
settledRef.current = false
|
|
||||||
setState(null)
|
setState(null)
|
||||||
try {
|
try {
|
||||||
await startMihomeAuth()
|
await startMihomeAuth()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
settledRef.current = true
|
|
||||||
setState({ state: 'failed', lastError: e.message })
|
setState({ state: 'failed', lastError: e.message })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const poll = () => {
|
const poll = () => {
|
||||||
getMihomeAuthStatus()
|
getMihomeAuthStatus()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (settledRef.current) return
|
|
||||||
setState(s)
|
setState(s)
|
||||||
if (s.state === 'failed') {
|
setImgTs(Date.now()) // 让二维码 <img> 重新拉取 (换码刷新)
|
||||||
settledRef.current = true
|
if (s.state === 'failed') stopPoll()
|
||||||
|
if (s.state === 'idle' && s.running === false && s.qrImageUrl) {
|
||||||
stopPoll()
|
stopPoll()
|
||||||
return
|
onOk()
|
||||||
}
|
|
||||||
// done 只由服务端真正登录成功置位; 且必须 running=false 才算流程走完
|
|
||||||
if (!s.running && s.done) {
|
|
||||||
settledRef.current = true
|
|
||||||
stopPoll()
|
|
||||||
cbRef.current.onOk()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => { stopPoll(); setState({ state: 'failed', lastError: e.message }) })
|
||||||
if (settledRef.current) return
|
|
||||||
settledRef.current = true
|
|
||||||
stopPoll()
|
|
||||||
setState({ state: 'failed', lastError: e.message })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
poll()
|
poll()
|
||||||
timerRef.current = setInterval(poll, 3000)
|
timerRef.current = setInterval(poll, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只依赖 open: start 内部通过 ref 读回调, 不会因父组件重渲染而重启
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) start()
|
if (open) start()
|
||||||
else { stopPoll(); settledRef.current = false; setState(null) }
|
else { stopPoll(); setState(null) }
|
||||||
return stopPoll
|
return stopPoll
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// 二维码变化时才重新拉图 (轮询 3s 一次, 用字符串签名去重, 避免每轮都请求图片)
|
|
||||||
const qrSig = state?.state === 'waiting' ? state.qrImageUrl || '' : ''
|
|
||||||
useEffect(() => {
|
|
||||||
if (!qrSig) {
|
|
||||||
imgSigRef.current = ''
|
|
||||||
setImgUrl('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (qrSig === imgSigRef.current) return
|
|
||||||
imgSigRef.current = qrSig
|
|
||||||
let alive = true
|
|
||||||
setImgUrl('')
|
|
||||||
fetchMihomeQrImage()
|
|
||||||
.then((url) => {
|
|
||||||
if (!alive) { URL.revokeObjectURL(url); return }
|
|
||||||
if (imgUrlRef.current) URL.revokeObjectURL(imgUrlRef.current)
|
|
||||||
imgUrlRef.current = url
|
|
||||||
setImgUrl(url)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (!alive) return
|
|
||||||
// 图片拉取失败就停轮询, 让用户点「刷新二维码」重来, 否则下一轮状态会把错误盖掉
|
|
||||||
stopPoll()
|
|
||||||
setState({ state: 'failed', lastError: e.message })
|
|
||||||
})
|
|
||||||
return () => { alive = false }
|
|
||||||
}, [qrSig])
|
|
||||||
|
|
||||||
// 卸载时释放 blob, 防止内存泄漏
|
|
||||||
useEffect(() => () => { if (imgUrlRef.current) URL.revokeObjectURL(imgUrlRef.current) }, [])
|
|
||||||
|
|
||||||
const cancel = async () => {
|
const cancel = async () => {
|
||||||
try { await cancelMihomeAuth() } catch { /* ignore */ }
|
try { await cancelMihomeAuth() } catch { /* ignore */ }
|
||||||
stopPoll()
|
stopPoll()
|
||||||
settledRef.current = true
|
onClose()
|
||||||
cbRef.current.onClose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const waiting = state?.state === 'waiting' && !!imgUrl
|
const waiting = state?.state === 'waiting' && state?.qrImageUrl
|
||||||
const loading = state?.state === 'waiting' && !imgUrl
|
|
||||||
const failed = state?.state === 'failed'
|
const failed = state?.state === 'failed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 关闭遮罩点击: 手机端很容易误触导致弹窗一闪而过; 只允许显式点「取消」关闭
|
<Popup visible={open} onMaskClick={cancel} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
|
||||||
<Popup visible={open} closeOnMaskClick={false} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
|
|
||||||
<div className="env-qr">
|
<div className="env-qr">
|
||||||
<div className="env-qr-title">米家扫码授权</div>
|
<div className="env-qr-title">米家扫码授权</div>
|
||||||
{waiting ? (
|
{waiting ? (
|
||||||
<img className="env-qr-img" src={imgUrl} alt="扫码授权" />
|
<img className="env-qr-img" src={mihomeQrImageUrl() + '?ts=' + imgTs} alt="扫码授权" />
|
||||||
) : (
|
) : (
|
||||||
<div className="env-qr-wait"><DotLoading /> {failed ? '扫码流程失败' : '正在生成二维码...'}</div>
|
<div className="env-qr-wait"><DotLoading /> {failed ? '扫码流程失败' : '正在生成二维码...'}</div>
|
||||||
)}
|
)}
|
||||||
@@ -387,7 +311,7 @@ function AuthPopup({ open, onClose, onOk }) {
|
|||||||
<div className="env-qr-tip">打开米家 App → 我的 → 扫码登录,扫码后自动恢复</div>
|
<div className="env-qr-tip">打开米家 App → 我的 → 扫码登录,扫码后自动恢复</div>
|
||||||
<div className="env-qr-actions">
|
<div className="env-qr-actions">
|
||||||
<Button size="mini" fill="outline" onClick={cancel}>取消</Button>
|
<Button size="mini" fill="outline" onClick={cancel}>取消</Button>
|
||||||
<Button size="mini" color="primary" fill="outline" loading={loading} onClick={() => start()}>刷新二维码</Button>
|
<Button size="mini" color="primary" fill="outline" loading={!waiting && !failed} onClick={() => start()}>刷新二维码</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|||||||
Reference in New Issue
Block a user