3 Commits
Author SHA1 Message Date
jif2.zhang 5e6ea552d1 调整分支策略: 仅 master 部署, 取消 dev-* 自动发布
分支规则 (写入 readme):
- master   主分支, 唯一部署出口, push 即发布生产
- dev-*    开发集成分支, 只跑 CI 校验, 不部署; 上线先合到 master
- tmp-*    临时分支, 针对单个问题, 合到 dev-* 后删除
- android-* 仅触发 APK 外壳构建, 构建前须与 master 同步

CI 变更:
- .gitea/workflows/docker-build.yml 触发分支收窄为 master
  此前 dev-* 与 master 共用同一套生产环境, 推送开发分支即直接发布,
  容易把半成品带上线, 现取消该触发
- 同步修正工作流内相关注释 (迁移时机, 版本校验范围)

文档变更:
- readme 新增「分支规则」章节: 职责表/流程图/CI 触发范围/变更说明
- readme 原「分支与 CI/CD」章节与新规则冲突, 已按新策略重写
  (dev-* 不再触发部署; 数据库迁移只在 master 发布时应用)
- 明确团队约定的 main 主分支在本仓库落地为 master
2026-10-24 07:36:53 +08:00
jif2.zhang a66ddb457a 修复扫码弹窗一闪而过
现象: 米家授权过期后, 扫码弹窗从底部弹出但瞬间消失, 看不到二维码.

三处缺陷:
1. 服务端 startQrLogin() 在已有流程运行时提前 return, 未清 done.
   上一轮成功残留的 done=true 会让前端第一次轮询就拿到
   {running:false, done:true}, 误判为扫码成功立即关窗.
   改为复用流程时显式返回 done:false.
2. 前端 AuthPopup 的成功/失败判定没有幂等保护, 且 onClose/onOk 是
   父组件每次渲染新建的函数. 父级轮询触发重渲染后, 回调身份变化
   会波及弹窗关闭路径. 改用 ref 持有最新回调 + settledRef 保证
   一轮流程只结算一次.
3. 弹窗遮罩绑定 onMaskClick=cancel, 手机端极易误触; 关闭遮罩点击,
   只允许显式点「取消」关闭.

注意: 前端需重新构建发布, 服务端需重启后生效.
2026-10-24 07:27:03 +08:00
jif2.zhang 3de002aae8 修复米家会话失效后不弹登录二维码
根因一: 过期检测形同虚设
- @zythum02/mijia-api 的 refreshToken() 在本地凭证字段齐全时直接返回成功,
  根本不发请求, 因此 checkAuth() 永远判定 ok, 过期后既不出横幅也不出扫码入口
- checkAuth() 改为真打一次 getHomesList() 验证 token; 成功缓存 5 分钟,
  失败只缓存 30 秒(便于网络恢复后自愈); 增加并发探测合并; 状态未变不写库
- getStatus() 以实时探测结果为准, DB 里的 status 仅在探测为 unknown 时兜底,
  避免残留的 ok 遮蔽已失效状态

根因二: 二维码图片 401 导致弹窗空白
- /api/mihome/reauthorize/qr-image 在 authRequired 之后, 而 <img src> 无法携带
  Authorization 头, 图片请求被拒, 用户看到的是空白/裂图
- 新增 fetchMihomeQrImage(): 带 JWT fetch 后转 blob URL 给 <img>, 并在卸载/换码时释放

其他修复
- EnvView 的 load 依赖 [status] 且成功回调又 setStatus, 造成 /status 无限重复请求;
  改为空依赖 + 函数式 setStatus; 首屏与重试走 ?force=1 强查
- 会话失效(非网络类错误)时自动弹出扫码窗, 每次挂载仅弹一次
- 扫码状态机新增 done 标记, 只有真正登录成功才算成功, 避免取消后误判
- 二维码 120s 超时自动续码(最多 2 次), 慢扫不再直接失败
- 取消后不再丢弃可能已成功的扫码结果
- scripts/mihome-login.mjs 适配 done 语义
- 同步更新设计文档 §5.2 / §5.4 / §6

注意: 前端需重新构建发布后生效。
2026-10-24 07:26:59 +08:00
9 changed files with 285 additions and 95 deletions
+12 -6
View File
@@ -1,17 +1,22 @@
name: Docker Build and Push name: Docker Build and Push
# main / master / dev-* 分支 push 触发, 其他分支不触发 # 仅主分支 (master) push 触发部署; 其他分支一律不触发 (见 readme「分支规则」)
# 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署 # 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/ # OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
# #
# dev-* (如 dev-0.1 / dev-0.2) 与 main/master 部署到同一套环境 (容器 iboard, 宿主 3001), # 分支策略 (2026-10 调整):
# 即开发分支推送 = 直接发布: 数据库共用同一个库, migrate deploy 在启新容器前执行 (幂等). # - master = 主分支, 唯一部署出口, push 即发布生产 (容器 iboard, 宿主 3001)
# - dev-* = 开发集成分支, 只做 CI 校验 (构建/版本一致性), **不再自动部署**;
# 要上线必须先合并到 master 再推 master
# - tmp-* = 单个问题的临时分支, 合到 dev-* 后删除
# - android-*= 只用于触发 APK 外壳构建, 不部署; 改动前须与 master 同步
#
# 历史: 之前 dev-* 与 master 共用同一套生产环境 (推送即发布), 容易把半成品带上线,
# 现已取消 dev-* 的 push 触发。
on: on:
push: push:
branches: branches:
- main
- master - master
- 'dev-*'
jobs: jobs:
build-image: build-image:
@@ -39,6 +44,7 @@ jobs:
# (tauri.conf.json 的 version 就是 APK 的 versionName) # (tauri.conf.json 的 version 就是 APK 的 versionName)
# 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json, # 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json,
# dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号 # dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号
# (master 分支跳过分支前缀校验, 但四处一致性照样强制)
env: env:
GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_REF_NAME: ${{ github.ref_name }}
run: node scripts/ci/check-version.mjs run: node scripts/ci/check-version.mjs
@@ -75,7 +81,7 @@ jobs:
run: docker images run: docker images
- name: Apply DB migrations - name: Apply DB migrations
# 数据库自动升级: 按 prisma/migrations/ 下的迁移顺序应用 (dev-* 与 main/master 共库) # 数据库自动升级: 按 prisma/migrations/ 下的迁移顺序应用 (生产库由 master 部署时统一升级)
# migrate deploy 幂等, 无新迁移时为 no-op # migrate deploy 幂等, 无新迁移时为 no-op
# 位置关键: 在 "Remove old container" 之前 —— 建表/改表成功后才切换流量, # 位置关键: 在 "Remove old container" 之前 —— 建表/改表成功后才切换流量,
# 迁移失败则整个 workflow 中断, 不会用新镜像顶掉仍在运行的旧容器 # 迁移失败则整个 workflow 中断, 不会用新镜像顶掉仍在运行的旧容器
+19 -11
View File
@@ -205,24 +205,27 @@ server/routes/mihome.js # REST 路由 (authRequired)
### 5.2 认证与「重新授权」 ### 5.2 认证与「重新授权」
- 启动时 `login()` 自动尝试加载/刷新 `DiskCache` 凭证;成功则 `status=ok`。 - 启动时加载 `DiskCache` 凭证,并用一次真实云请求(`getHomesList`)确认 token 仍有效;
- 失败(30 天过期 / 风控)→ `MihomeAccount.status=expired`,前端弹授权横幅。 只有该请求通过才 `status=ok`。
- 失败(30 天过期 / 风控 / 无凭证)→ `MihomeAccount.status=expired`,前端弹授权横幅 + 扫码窗。
- 重新授权流程(服务端驱动,前端轮询): - 重新授权流程(服务端驱动,前端轮询):
``` ```
POST /api/mihome/reauthorize POST /api/mihome/reauthorize
└─ 若 pendingQr 空闲 => 后台启动 QRlogin 循环(每轮 120s 超时自动续二维码) └─ 若已有流程在跑 => 直接复用(同一张二维码继续有效)
└─ 立即返回 { state, qrImageUrl } └─ 否则后台启动 QRlogin 循环(每轮 120s 超时自动续二维码, 最多续 2 次)
└─ 立即返回 { running, done, state, qrImageUrl }
GET /api/mihome/reauthorize/status GET /api/mihome/reauthorize/status
└─ 返回 { state: idle|waiting|scanning|ok|failed, qrImageUrl?, lastError? } └─ 返回 { running, done, state: idle|waiting|failed, qrImageUrl?, lastError? }
└─ 扫码成功(qrlogin 返回) => 保存凭证 => 状态 ok => 触发一次立即同步 └─ done=true 表示扫码成功、凭证已落盘、authCache 已置 ok
POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环 POST /api/mihome/reauthorize/cancel # 人工取消: 只让前端停止轮询, 后台长轮询自然结束
``` ```
- `pendingQr` 为模块级单例,防并发(同一时间只允许一个扫码流程)。 - `qrState` 为模块级单例,防并发(同一时间只允许一个扫码流程)。
- 前端在弹窗内 `setInterval 3s` 轮询直到 `ok`,成功后关弹窗并刷新列表。 - 前端在弹窗内 `setInterval 3s` 轮询,遇 `done=true` 关弹窗并刷新列表;`failed` 则停轮询显示错误。
- 二维码图片接口在 `authRequired` 之后,`<img>` 带不上 JWT,前端必须 `fetch` 后转 `blob:` URL 显示。
### 5.3 采集器 collector ### 5.3 采集器 collector
@@ -261,7 +264,7 @@ POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果) | | GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果);`?force=1` 跳过 5 分钟认证缓存强查一次 |
| 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,服务端按日档聚合) |
@@ -297,7 +300,12 @@ POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
1. **授权横幅**(`status !== ok`): 1. **授权横幅**(`status !== ok`):
`米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `<img src=qrImageUrl>` `米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `<img src=qrImageUrl>`
3s 轮询 `/reauthorize/status` → 成功自动关闭并刷新。 3s 轮询 `/reauthorize/status` → `done=true` 自动关闭并刷新。
- `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>` 按家庭分组;组展开后显示该家庭
下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。 下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。
+51 -5
View File
@@ -1,6 +1,50 @@
# iBoard # iBoard
用于收集和整理经济数据,但是以策略为主体重新组织。 用于收集和整理经济数据,但是以策略为主体重新组织。
## 分支规则
### 分支类型与职责
| 分支 | 职责 | 部署 | 合并方向 |
| --- | --- | --- | --- |
| `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — |
| `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 |
| `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 |
| `android-*` | **Android 外壳构建分支**,如 `android-1.0` | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 |
> **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。
> 下文所有「main 主分支」均指 `master`。
### 流程
```
tmp-xxx ──合并──▶ dev-0.2 ──合并──▶ master ──push──▶ 自动部署生产
│ │
│ └──push──▶ 只跑 CI(版本一致性校验),不部署
│
└── 问题解决后删除
android-1.0 ── 从 master 同步代码后 push ──▶ 只触发 APK 构建 / 发布到 Packages
```
1. **日常开发**:从 `dev-*` 拉 `tmp-*`,改完合回 `dev-*`,`tmp-*` 用完即删。
2. **上线**:`dev-*` 验收通过后合并到 `master`,**推送 `master` 才部署生产**。
3. **打 Android 包**:`android-*` 与 `master` 同步(`git merge master` 或 `git reset --hard origin/master`)后推送,
触发 [android-build.yml](.gitea/workflows/android-build.yml),产物发布到 Gitea Generic Packages。
### CI 触发范围
| 工作流 | 触发分支 | 动作 |
| --- | --- | --- |
| [docker-build.yml](.gitea/workflows/docker-build.yml) | **仅 `master`** | 构建前端 + 生成签名 OTA 包 + 构建镜像 + 迁移数据库 + 部署生产 |
| [android-build.yml](.gitea/workflows/android-build.yml) | `android-*` | 构建并签名 release APK,上传到 Packages |
| [ota-build.yml](.gitea/workflows/ota-build.yml) | 仅手动 `workflow_dispatch` | 单独构建/签发 OTA 包 |
| [sync-registry.yml](.gitea/workflows/sync-registry.yml) | 见文件 | 同步注册中心 |
> ⚠️ **重要变更(2026-10)**:`dev-*` 分支**已取消** push 自动部署。
> 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。
> 现在开发分支的提交只有合并进 `master` 并推送后才会部署。
## 版本管理 ## 版本管理
### 一句话规则 ### 一句话规则
@@ -60,19 +104,21 @@ tauri.android.versionCode = 2000 # 整数,升级判定用 = major*1000000
## 分支与 CI/CD (Gitea Actions) ## 分支与 CI/CD (Gitea Actions)
分支按版本命名:`dev-0.1`、`dev-0.2`…… 对应 `docs/V<版本>/` 下的需求/设计文档。 分支按版本命名:`dev-0.1`、`dev-0.2`…… 对应 `docs/V<版本>/` 下的需求/设计文档。
职责与合并方向见上文「分支规则」。
| 分支 | 触发的 workflow | 行为 | | 分支 | 触发的 workflow | 行为 |
| --- | --- | --- | | --- | --- | --- |
| `main` / `master` | `docker-build.yml` | 生产发布:构建前端 → 生成签名 OTA 包 → 打 Docker 镜像 → 数据库自动升级 → 重启容器 `iboard` (宿主 3001) | | `master`(主分支) | `docker-build.yml` | 生产发布:构建前端 → 生成签名 OTA 包 → 打 Docker 镜像 → 数据库自动升级 → 重启容器 `iboard` (宿主 3001) |
| `dev-*` | `docker-build.yml` | **与生产同环境**:推送即构建、迁移数据库、部署(开发分支直接顶掉生产,含 OTA 热更) | | `dev-*` | **不触发部署** | 推送只走版本一致性校验;上线须先合并到 `master` |
| `android-*` | `android-build.yml` | 打签名 release APK 并上传到 Gitea Generic Package Registry | | `tmp-*` | 不触发 | 临时分支,合到 `dev-*` 后删除 |
| `android-*` | `android-build.yml` | 打签名 release APK 并上传到 Gitea Generic Package Registry(构建前须与 `master` 同步) |
| 手动 | `ota-build.yml` / `sync-registry.yml` | 单独出 OTA 包 / 同步 secrets 到注册中心 | | 手动 | `ota-build.yml` / `sync-registry.yml` | 单独出 OTA 包 / 同步 secrets 到注册中心 |
数据库升级约定: 数据库升级约定:
- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行 - 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行
(幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。 (幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。
- `dev-*` 与 `main/master` 共用同一个 MariaDB 库(同一个 `DATABASE_URL` secret), - 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
因此开发分支上的迁移会直接作用到该库结构,合入前请确认迁移可回滚。 开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。 - 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
- 首页主要是放经济文章 - 首页主要是放经济文章
+5 -5
View File
@@ -21,15 +21,15 @@ for (;;) {
console.log('[米家] 扫码失败: ' + (s.lastError || '未知')) console.log('[米家] 扫码失败: ' + (s.lastError || '未知'))
process.exit(2) process.exit(2)
} }
if (s.state === 'idle' && s.running === false && !s.qrImageUrl) { if (s.done) {
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)
+77 -39
View File
@@ -20,8 +20,11 @@ export function getApi() {
return _api return _api
} }
// 认证状态内存缓存 (5 分钟 TTL, /status 不每次打米家账号服务) // 认证状态内存缓存 (成功缓存 5 分钟; 失败只缓存 30 秒, 网络/服务恢复后能尽快自愈)
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
@@ -35,31 +38,49 @@ function persistAccount(status, lastError) {
}) })
} }
// 校验米家是否可用: 有凭证 + 能刷新 token (刷新会自动处理 30 天过期) // 校验米家是否可用: 有凭证 + 真打一次云接口确认 token 没失效
// 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()
if (!force && now - authCache.checkedAt < 5 * 60 * 1000) return authCache const ttl = authCache.status === 'ok' ? TTL_OK : TTL_BAD
const api = getApi() if (!force && now - authCache.checkedAt < ttl) return authCache
let status = 'expired' if (probing) return probing
let lastError = '' probing = (async () => {
const api = getApi()
let status = 'expired'
let lastError = ''
try {
if (!api.available) throw new Error('无授权凭证')
await api.refreshToken() // 凭证真过期时这里会自动走 passToken 刷新
await api.getHomesList() // 真正打到米家云, token 失效会抛 APIError
status = 'ok'
} catch (e) {
status = 'expired'
lastError = e?.message || String(e)
}
const changed = authCache.status !== status || authCache.lastError !== lastError
authCache = { status, lastError, checkedAt: Date.now() }
// 状态没变就不写库, 避免 30 秒重试把 DB 刷爆
if (changed) await persistAccount(status, lastError)
return authCache
})()
try { try {
if (!api.available) throw new Error('无授权凭证') return await probing
await api.refreshToken() } finally {
status = 'ok' probing = null
} catch (e) {
status = 'expired'
lastError = e?.message || String(e)
} }
authCache = { status, lastError, checkedAt: now }
await persistAccount(status, lastError)
return authCache
} }
// ==================== 扫码重授权流程 (模块级单例) ==================== // ==================== 扫码重授权流程 (模块级单例) ====================
// 状态: idle | waiting(已出二维码等扫码) | failed // state: idle | waiting(已出二维码等扫码) | failed
// done: 只有真正登录成功才置 true, 前端靠它判断「轮询结束 = 授权成功」
// 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB // 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB
const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 } const qrState = { running: false, done: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
export function getQrState() { export function getQrState() {
return { ...qrState } return { ...qrState }
@@ -70,29 +91,50 @@ 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.canceled = false qrState.done = 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 { try {
await api.QRlogin(async (info) => { for (let attempt = 0; ; attempt++) {
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || '' try {
qrState.state = 'waiting' await api.QRlogin(async (info) => {
}) qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
if (qrState.canceled) return qrState.state = 'waiting'
qrState.state = 'idle' })
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() } // 这里不要因为「用户点了取消」就丢弃结果: 弹窗关了但二维码可能已经被扫,
await persistAccount('ok', '') // 凭证库内部已落盘, 丢掉会让前端继续显示已过期
} catch (e) { qrState.state = 'idle'
if (qrState.canceled) { qrState.done = true
qrState.state = 'idle' authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
} else { await persistAccount('ok', '')
qrState.state = 'failed' return
qrState.lastError = e?.message || String(e) } catch (e) {
const msg = e?.message || String(e)
const timedOut = /abort|timeout|timed out/i.test(msg)
if (timedOut && attempt < MAX_RETRY) {
qrState.qrImageUrl = ''
qrState.state = 'idle'
continue
}
qrState.state = 'failed'
qrState.lastError = msg
return
}
} }
} finally { } finally {
qrState.running = false qrState.running = false
@@ -102,11 +144,7 @@ export async function startQrLogin() {
} }
export function cancelQrLogin() { export function cancelQrLogin() {
qrState.canceled = true // 只影响前端是否继续轮询; 后台长轮询没法中断, 让它自然结束 (扫了也照样算成功)
if (qrState.running) { if (!qrState.running && qrState.state !== 'failed') qrState.state = 'idle'
// 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果
} else if (qrState.state !== 'failed') {
qrState.state = 'idle'
}
return getQrState() return getQrState()
} }
+8 -4
View File
@@ -10,8 +10,9 @@ 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 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日)
export async function getStatus() { // force=true 跳过 5 分钟认证缓存强查一次 (用户主动打开页面/刷新时用)
const authState = await auth.checkAuth() export async function getStatus(force = false) {
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' } }),
@@ -56,10 +57,13 @@ export async function getStatus() {
})), })),
})) }))
// 以实时探测结果为准: DB 里的 status 只在实时探测还是 unknown 时兜底,
// 否则过期后残留的 ok 会把「已失效」遮掉, 前端永远不出重新授权横幅.
const liveUnknown = !authState.status || authState.status === 'unknown'
return { return {
auth: { auth: {
status: account?.status || authState.status, status: liveUnknown ? account?.status || 'unknown' : authState.status,
lastError: account?.lastError || authState.lastError, lastError: (liveUnknown ? account?.lastError : authState.lastError) || '',
checkedAt: authState.checkedAt, checkedAt: authState.checkedAt,
}, },
capability, capability,
+3 -2
View File
@@ -33,8 +33,9 @@ function guard(handler) {
} }
// 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数) // 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数)
router.get('/status', guard(async (_req, res) => { // force=1: 跳过后端 5 分钟认证缓存强查 (前端打开页面时用, 保证会话失效能立刻发现)
res.json(await getStatus()) router.get('/status', guard(async (req, res) => {
res.json(await getStatus(req.query.force === '1'))
})) }))
// 历史: range = today(默认) | month | year // 历史: range = today(默认) | month | year
+18 -7
View File
@@ -1,9 +1,10 @@
// 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端) // 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端)
import { api, getApiBase } from './client.js' import { api, getApiBase, getToken } from './client.js'
// 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性 // 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性
export async function getMihomeStatus() { // force=true 让后端跳过 5 分钟认证缓存, 立刻回真实授权状态
return api('/mihome/status') export async function getMihomeStatus(force) {
return api('/mihome/status' + (force ? '?force=1' : ''))
} }
// 历史: range = today | month | year // 历史: range = today | month | year
@@ -32,8 +33,18 @@ export async function cancelMihomeAuth() {
return api('/mihome/reauthorize/cancel', { method: 'POST' }) return api('/mihome/reauthorize/cancel', { method: 'POST' })
} }
// 二维码图片地址 (服务端带小米 UA 代理, 避免 CDN 拒连) // 二维码图片: 后端接口在 authRequired 后面, 而 <img src> 无法带 Authorization 头,
export function mihomeQrImageUrl() { // 直接塞地址会 401 (二维码位置一片空白). 这里用 fetch 带 JWT 取回二进制, 再转 blob URL 给 <img>.
const base = getApiBase() // 同源时为空串 export async function fetchMihomeQrImage() {
return base + '/api/mihome/reauthorize/qr-image' const token = getToken()
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)
} }
+92 -16
View File
@@ -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, mihomeQrImageUrl, startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, fetchMihomeQrImage,
} from '../../api/mihome.js' } from '../../api/mihome.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
@@ -21,6 +21,8 @@ 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)
@@ -28,13 +30,20 @@ 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() getMihomeStatus(!silent)
.then((d) => { setStatus(d); setErr('') }) .then((d) => { setStatus(d); setErr('') })
.catch((e) => { if (!silent) { setErr(e.message); if (!status) setStatus({ auth: { status: 'expired' }, homes: [] }) } }) .catch((e) => {
}, [status]) if (silent) return
setErr(e.message)
setStatus((prev) => prev || { auth: { status: 'expired' }, homes: [] })
})
}, [])
useEffect(() => { useEffect(() => {
load(false) load(false)
@@ -43,6 +52,16 @@ 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 {
@@ -253,57 +272,114 @@ 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 [imgTs, setImgTs] = useState(0) const [imgUrl, setImgUrl] = useState('')
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)
setImgTs(Date.now()) // 让二维码 <img> 重新拉取 (换码刷新) if (s.state === 'failed') {
if (s.state === 'failed') stopPoll() settledRef.current = true
if (s.state === 'idle' && s.running === false && s.qrImageUrl) {
stopPoll() stopPoll()
onOk() return
}
// done 只由服务端真正登录成功置位; 且必须 running=false 才算流程走完
if (!s.running && s.done) {
settledRef.current = true
stopPoll()
cbRef.current.onOk()
} }
}) })
.catch((e) => { stopPoll(); setState({ state: 'failed', lastError: e.message }) }) .catch((e) => {
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(); setState(null) } else { stopPoll(); settledRef.current = false; 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()
onClose() settledRef.current = true
cbRef.current.onClose()
} }
const waiting = state?.state === 'waiting' && state?.qrImageUrl const waiting = state?.state === 'waiting' && !!imgUrl
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={mihomeQrImageUrl() + '?ts=' + imgTs} alt="扫码授权" /> <img className="env-qr-img" src={imgUrl} alt="扫码授权" />
) : ( ) : (
<div className="env-qr-wait"><DotLoading /> {failed ? '扫码流程失败' : '正在生成二维码...'}</div> <div className="env-qr-wait"><DotLoading /> {failed ? '扫码流程失败' : '正在生成二维码...'}</div>
)} )}
@@ -311,7 +387,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={!waiting && !failed} onClick={() => start()}>刷新二维码</Button> <Button size="mini" color="primary" fill="outline" loading={loading} onClick={() => start()}>刷新二维码</Button>
</div> </div>
</div> </div>
</Popup> </Popup>