25 Commits
Author SHA1 Message Date
cheney 3140eae7db chore: 版本号统一为 V0.1 (package.json 1.0.0 -> 0.1.0, 与 tauri.conf.json/Cargo.toml 对齐) + readme 补充版本管理约定 2026-10-22 09:59:26 +08:00
jif2.zhang 7b4e59957b 修复容器内 news/oil 定时失效: 运行时镜像携带 scripts/ + axios 转生产依赖 + cron 脚本模块惰性加载降级
Docker Build and Push / build-image (push) Failing after 9m34s
2026-09-21 14:09:18 +08:00
jif2.zhang 1d69915cf3 米家调度独立模块注册, 避免被容器中无法加载的旧 cron.js 连带失效
Docker Build and Push / build-image (push) Successful in 4m36s
2026-09-21 11:52:24 +08:00
jif2.zhang 8e2c6ffa35 新增米家环境监控模块 (采集/聚合/历史/前端 EnvView) + CI 挂载米家凭证目录并自动应用迁移
Docker Build and Push / build-image (push) Successful in 6m54s
2026-09-21 11:42:58 +08:00
jif2.zhang 53f63e0986 新增 CI secrets 同步到注册中心的独立 workflow
Docker Build and Push / build-image (push) Successful in 10m57s
2026-09-20 20:32:54 +08:00
cheney 508419180c 调整App头部显示,固定头部和分类
Docker Build and Push / build-image (push) Successful in 4m56s
2026-08-08 19:40:39 +08:00
cheney 4f2becb99a 调整App头部显示,避免与系统消息栏重合
Docker Build and Push / build-image (push) Successful in 4m51s
2026-08-08 19:20:30 +08:00
cheney 2fa7c7fc70 remove pagespy
Docker Build and Push / build-image (push) Successful in 4m56s
2026-08-08 18:12:48 +08:00
cheney 1b330bdd4d 移除 reasonix.toml
Docker Build and Push / build-image (push) Successful in 5m0s
2026-08-08 11:44:12 +08:00
cheney 5822a01ed7 fix rust env
Android Build & Publish / build-android (push) Successful in 18m36s
2026-08-08 10:40:33 +08:00
cheney b0bc1e9727 开放 http
Android Build & Publish / build-android (push) Failing after 2m12s
2026-08-08 10:32:28 +08:00
cheney 68998fa07e token 名字修改
Android Build & Publish / build-android (push) Successful in 42m17s
2026-08-08 09:13:23 +08:00
cheney d5a31e5366 修改签名脚本
Android Build & Publish / build-android (push) Failing after 39m54s
2026-08-08 08:28:49 +08:00
cheney 05ad191913 环境变量
Android Build & Publish / build-android (push) Failing after 23m5s
2026-08-08 07:52:39 +08:00
cheney 9ecbf8d8e0 更新脚本
Android Build & Publish / build-android (push) Failing after 18m32s
2026-08-08 07:01:50 +08:00
cheney 44d60471ca fix: 修复登录成功但未跳转内部页面的竞态
Docker Build and Push / build-image (push) Successful in 6m12s
LoginPage.onSubmit 在 setUser 异步提交前手动 navigate(from),
切到受保护页时 user 仍为 null, 被 RequireAuth 立即踢回 /login,
导致提示登录成功却进不去内部页面。改为依赖组件顶部
<Navigate to={from} replace/> 在 user 就绪后可靠跳转。
2026-08-08 05:37:40 +08:00
cheney 60628775ac ota 升级地址
Docker Build and Push / build-image (push) Successful in 5m9s
2026-08-07 18:11:43 +08:00
cheney add3ff8831 修改 ota cicd
Docker Build and Push / build-image (push) Successful in 6m37s
2026-08-07 17:10:07 +08:00
cheney 22a36fb213 fix 打开 ota 授权
Docker Build and Push / build-image (push) Successful in 2m51s
2026-08-07 16:24:10 +08:00
cheney a5bc8ef1f1 功能完成
Docker Build and Push / build-image (push) Successful in 2m53s
2026-08-07 14:56:27 +08:00
cheney b9ec6fbe0b 油价集成 2026-08-07 14:36:57 +08:00
cheney 8999b29646 跟新 CICD,每次制作 ota 升级包
Docker Build and Push / build-image (push) Successful in 2m50s
2026-08-07 10:04:57 +08:00
cheney 4f5cdb4325 OTA
Docker Build and Push / build-image (push) Successful in 2m55s
2026-08-07 09:50:42 +08:00
cheney dbe04b7da3 ota 2026-08-07 09:49:26 +08:00
cheney ac8a4b8cd2 准备切换分支 2026-08-07 09:10:40 +08:00
78 changed files with 16067 additions and 603 deletions
+177
View File
@@ -0,0 +1,177 @@
name: Android Build & Publish
# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0)
# 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry
on:
push:
branches:
- 'android-*'
jobs:
build-android:
runs-on: ubuntu-latest
# 需要写权限: 上传 packages
permissions:
contents: read
packages: write
env:
# 固定工具链版本, 便于 actions/cache 命中
# Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致
VITE_API_BASE: http://bh.vps.honor3.com:3001
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Setup JDK 17
uses: https://gitea.com/actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
# ---------- 缓存: 命中后跳过下面的安装 ----------
- name: Cache Android SDK & NDK
id: cache-android
uses: https://gitea.com/actions/cache@v4
with:
path: ~/android-sdk
key: android-sdk-ndk-27.2-${{ runner.os }}
- name: Cache Rust toolchain
id: cache-rust
uses: https://gitea.com/actions/cache@v4
with:
path: |
~/.cargo
~/.rustup
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
key: rust-android-v1-${{ runner.os }}
- name: Cache Tauri cargo target
uses: https://gitea.com/actions/cache@v4
with:
path: src-tauri/target
key: tauri-target-android-${{ runner.os }}
# ---------- 安装工具链 (仅缓存未命中时) ----------
- name: Install Rust + Android targets
if: steps.cache-rust.outputs.cache-hit != 'true'
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
. "$HOME/.cargo/env"
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
cargo install cargo-ndk --locked
- name: Install Android SDK & NDK
if: steps.cache-android.outputs.cache-hit != 'true'
run: |
export ANDROID_HOME="$HOME/android-sdk"
sudo apt-get update
sudo apt-get install -y --no-install-recommends unzip
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip
unzip -q /tmp/cmdline-tools.zip -d /tmp/
mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
yes | sdkmanager --licenses >/dev/null || true
sdkmanager --install \
"platforms;android-36" \
"build-tools;36.0.0" \
"platform-tools" \
"ndk;27.2.12479018" \
"cmake;3.22.1"
- name: Install frontend deps
run: npm ci --no-audit --no-fund
# ---------- 全局 Android 环境 (对后续所有 step 生效, 无论缓存是否命中) ----------
- name: Set Android env
run: |
echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=$HOME/android-sdk/ndk/27.2.12479018" >> "$GITHUB_ENV"
echo "$HOME/android-sdk/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
echo "$HOME/android-sdk/platform-tools" >> "$GITHUB_PATH"
# ---------- 构建 ----------
- name: Init Android project
run: |
# tauri android init 需要 cargo (运行 cargo metadata)。
# Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH,
# 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。
. "$HOME/.cargo/env"
cd src-tauri
npx tauri android init --ci
- name: Inject release signing config
run: bash scripts/ci/android-signing.sh
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: Build release APK
run: |
export ANDROID_HOME="$HOME/android-sdk"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
export CARGO_HOME="$HOME/.cargo"
. "$HOME/.cargo/env"
cd src-tauri
npx tauri android build --apk
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
exit 1
fi
- name: Locate APK
id: apk
run: |
# 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK
APK=""
UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1)
if [ -n "$UNIVERSAL" ]; then
APK="$UNIVERSAL"
else
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1)
fi
if [ -z "$APK" ]; then
echo "ERROR: 未找到已签名 APK" >&2
exit 1
fi
echo "path=$APK" >> $GITHUB_OUTPUT
ls -lh "$APK"
# ---------- 上传到 Gitea Generic Package ----------
- name: Publish APK to Gitea packages
env:
# 注意: 不能用 GITEA_TOKEN (内置 job token, 无组织 packages 写权限, 且命名规则禁止 GITEA_ 前缀)
# 用一个自定义名 secret 存放有 packages 写权限的 user token
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
APK_PATH: ${{ steps.apk.outputs.path }}
run: |
set -euo pipefail
# 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一
BRANCH="${GITHUB_REF_NAME#android-}"
PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}"
FILE_NAME="iboard-$(basename "$APK_PATH")"
OWNER="${GITHUB_REPOSITORY_OWNER}"
PKG_NAME="iboard"
# Gitea Generic Package Registry 上传端点 (PUT)
# 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com
URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}"
echo "上传到: ${URL}"
curl -sS --fail-with-body \
-H "Authorization: token ${PACKAGE_TOKEN}" \
--upload-file "$APK_PATH" \
"${URL}"
echo ""
echo "已发布: ${URL}"
+53 -2
View File
@@ -1,13 +1,54 @@
name: Docker Build and Push
on: [push]
# 仅 main 分支 push 触发, 其他分支不触发
# 每次主分支提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 部署
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
on:
push:
branches:
- main
- master
jobs:
build-image:
runs-on: ubuntu-latest
env:
# 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
VITE_API_BASE: http://bh.vps.honor3.com:3001
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
- name: Install deps
run: npm ci --no-audit --no-fund
- name: Build frontend
# dist/ 供 OTA 打包用 (Dockerfile 内也会构建一份)
run: npm run build
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Build & sign OTA package
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
OTA_NOTES: ${{ github.sha }}
run: |
set -euo pipefail
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES}
echo "===== latest.json ====="
cat ota-package/latest.json
echo "===== bundle sha256 ====="
sha256sum ota-package/iboard-ota.tar.gz
- name: Build Docker image
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build .
@@ -15,6 +56,12 @@ jobs:
- name: List images
run: docker images
- name: Apply DB migrations
# 仓库内的 prisma 迁移自动应用 (migrate deploy 幂等, 无新迁移时为 no-op)
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npx prisma migrate deploy
- name: Remove old container
run: docker rm -vf iboard || true
@@ -23,11 +70,15 @@ jobs:
- name: Run container
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
# OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package
# 米家凭证 (auth.txt) 不进 git: 运维放宿主机 /app/iboard/cache, bind-mount 进容器 (MIHOME_CACHE_DIR=/app/cache)
run: |
docker run -d --name iboard -p 3001:3001 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-e JWT_SECRET="${{ secrets.JWT_SECRET }}" \
-e MIHOME_CACHE_DIR=/app/cache \
-v /app/iboard/cache:/app/cache \
iboard:latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
+75
View File
@@ -0,0 +1,75 @@
name: OTA Frontend Build & Sign
# 前端 OTA 升级包构建 + 签名 + 上传 artifact
# 触发: 仅手动 (workflow_dispatch), 发版前用
# - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建
# 输入: sequence / notes / min_binary (可覆盖默认)
# Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)
# 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey,
# 换密钥请手动改 conf 并 commit.
on:
workflow_dispatch:
inputs:
sequence:
description: 'OTA sequence (单调递增整数, 留空用 timestamp)'
required: false
type: string
notes:
description: '发布说明 (写入 latest.json.notes)'
required: false
type: string
default: ''
min_binary:
description: '最低 binary 版本 (留空用 src-tauri/tauri.conf.json 的 version)'
required: false
type: string
default: ''
jobs:
build-ota:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
- name: Install deps
run: npm ci --no-audit --no-fund
- name: Build frontend
# 复用 vite build 产物; 不打 Docker (本 workflow 只关心前端)
run: npm run build
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Build & sign OTA package
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
OTA_NOTES: ${{ inputs.notes }}
run: |
set -euo pipefail
OUT="ota-package"
ARGS="--out ${OUT}"
# inputs 留空时 → 不传 --sequence/--min-binary, 脚本默认 sequence=时间戳, min-binary=tauri.conf version
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
node scripts/build-ota.mjs ${ARGS}
echo "===== latest.json ====="
cat ${OUT}/latest.json
echo "===== bundle sha256 ====="
sha256sum ${OUT}/iboard-ota.tar.gz
- name: Upload OTA package as artifact
uses: https://gitea.com/actions/upload-artifact@v4
with:
name: iboard-ota-${{ inputs.sequence || github.run_number }}
path: ota-package/
retention-days: 30
if-no-files-found: error
+52
View File
@@ -0,0 +1,52 @@
name: Sync Secrets to Registry
# 将本仓库 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
# 目标: service-iboard 项目 (proj-msid70ql-6xbq4w)
# 触发: 仅手动 (workflow_dispatch)
# 依赖 secret:
# - REGISTRY_MCP_TOKEN : 注册中心 MCP 访问令牌 (在注册中心 🔑Token 管理 里生成)
# - 其余 secret 与三个构建 workflow 引用的保持一致 (未设置则跳过对应键)
# 注意: 传输为明文 http, 请保证注册中心仅内网可达; 若可访问则改用 https
on:
workflow_dispatch:
inputs:
dry_run:
description: '仅预演不写入 (true=只打印计划)'
required: false
default: 'false'
jobs:
sync-secrets:
runs-on: ubuntu-latest
permissions:
contents: read
env:
REGISTRY_MCP_URL: http://bh.vps.honor3.com:8008/mcp
REGISTRY_PROJECT_ID: proj-msid70ql-6xbq4w
REGISTRY_IDC: '1'
REGISTRY_ENVIRONMENT: '1'
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
- name: Sync secrets via MCP
run: |
if [ "${{ inputs.dry_run }}" = "true" ]; then
node scripts/sync-registry.mjs --dry-run
else
node scripts/sync-registry.mjs
fi
env:
REGISTRY_MCP_TOKEN: ${{ secrets.REGISTRY_MCP_TOKEN }}
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
+11 -1
View File
@@ -34,4 +34,14 @@ dist/*
# local env (contains API keys)
/.env
.reasonix/*
.reasonix/*
# OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交)
ota-package/*
!ota-package/README.md
# apizero 文件缓存 (运行时按需生成)
cache/apizero/
# Dockerfile 持久卷挂载点 (跟 ota-package 同类)
.cache/
# 米家凭证缓存 (含 token, 绝不能提交)
server/mihome/.cache/
+9
View File
@@ -48,9 +48,18 @@ COPY --from=builder /app/server ./server
COPY --from=builder /app/generated ./generated
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/content ./content
COPY --from=builder /app/public ./public
# OTA 升级包: CI (docker-build.yml) 生成的 ota-package/, 随镜像发布
# 本地构建时只有占位文件 (ota-package/README.md), server 找不到 latest.json 则 OTA 返回"无更新"
COPY ota-package ./ota-package
ENV OTA_PACKAGE_DIR=/app/ota-package
# OTA bundle 下载用的绝对 base (manifest.url 必须是绝对 URL, 插件不拼 base, 相对路径会导致下载 0% 失败)
# 生产为 http 明文 (无 https), 客户端 tauri.conf.json 的 hotswap.require_https 已设为 false 放行 http
ENV OTA_PUBLIC_BASE=http://bh.vps.honor3.com:3001
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1
+24
View File
@@ -0,0 +1,24 @@
node:events:497
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE: address already in use :::3001
at Server.setupListenHandle [as _listen2] (node:net:1940:16)
at listenInCluster (node:net:1997:12)
at Server.listen (node:net:2102:7)
at Function.listen (D:\workbench\iboard\node_modules\express\lib\application.js:635:24)
at file:///D:/workbench/iboard/server/index.js:57:5
at ModuleJob.run (node:internal/modules/esm/module_job:345:25)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:651:26)
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
Emitted 'error' event on Server instance at:
at emitErrorNT (node:net:1976:8)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
code: 'EADDRINUSE',
errno: -4091,
syscall: 'listen',
address: '::',
port: 3001
}
Node.js v22.19.0
+2
View File
@@ -0,0 +1,2 @@
[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测
[cron] 已注册: 每天 04:00 浙江油价历史抓取
+392
View File
@@ -0,0 +1,392 @@
# 环境监控模块设计(米家温湿度长期记录)
> 目标项目: iboard(Tauri + React + Express + Prisma/MariaDB)
> 状态: 设计稿 v1.1 · 2026-09-20 · 待评审(v1.1: 视图新增 今天/月/年三档粒度 + 标题显示电量)
## 1. 需求与目标
在监控页新增「环境监控」tab,实现**多家庭**下温湿度**长期记录**:
1. 监控页新增一个 tab「环境监控」。
2. 多家庭支持:每个家庭一个可折叠 group,**手风琴**式展开(同一时间只能展开一个)。
3. 每个设备可独立修改**别名**(仅本应用展示用,不改动米家云端设备名)。
4. 数据**非实时**(定时批量,不做推送):**每小时采样 1 次**落库小时档,每日聚合成日档。
5. 记录保留**永久**(无清理任务)。
6. 缺失补齐语义(用户确认):
- **应用停机**导致的缺失 → 应用恢复后自动重新获取补齐(有统计的型号经云端聚合真实回填,
见 §2.2B;无统计的型号保持缺口标注);
- **设备自身原因**(离线/故障)的缺失 → 保持缺失,不伪造数据;
- 总原则:**本地数据不少于米家可提供的数据**。
7. 米家授权失效 → 前端显示「重新授权」按钮,点击弹出**二维码**,人工扫码后恢复。
8. 每设备数据**默认显示当天**,粒度**小时**。
9. 每设备数据可切换为**月**,粒度**天**。
10. 每设备数据可切换为**年**,粒度**周**。
11. 设备卡片**标题 = 别名 + 电量**(电量取当天缓存值,不必频繁更新)。
已确认的数据范围:仅**温湿度**(不含照度 / 人体存在)。
## 2. 已验证事实(调研结论,作为设计地基)
调研用的账号 `18814803393`,登录链路与数据能力已实测(脚本位于
`C:\Users\xydkj\AppData\Local\Temp\opencode\mibridge-research\`):
| 项目 | 结论 | 证据 |
| --- | --- | --- |
| 登录方式 | 密码登录被米家拒绝(`70016`);**扫码登录可行** | `qr-once.mjs` |
| Token 有效期 | 30 天,`DiskCache` 持久化,脚本自动加载刷新 | `.mijia-cache/` |
| 实时读值 | `api.getDevicesProp([{did,siid,piid}])` 稳定可用,100 项请求 10 轮 100% 成功 | `env-monitor.mjs` |
| 单轮延迟 | p50 ≈ 520ms,p95 ≈ 612ms(10 项属性/轮) | 同上 |
| VRF 空调目标温写入 | 整数生效但**生效延迟 5~6s**(updateTime 才更新);半度 26.5 被服务端拒收(`-704220043`) | `vrf-temp-test*.mjs` |
| 温湿度历史读取 | **不可读**:`history-data(8.1)`/`start-time(8.3)` 返回 `-704010000`;`start-synic` 动作要求参数;`/v2/home/device/prop_history` 类端点不可达 | `dev-history-probe.mjs` |
### 2.1 环境设备清单(共 3 台,均属家庭「黄金农场」505001017889)
| 家庭 | 设备 | 型号 | did | 温度 | 湿度 | 电量 |
| --- | --- | --- | --- | --- | --- | --- |
| 黄金农场 | 炎米智能温湿度计 | `ym001.sensor_ht.ymwsdj` | `blt.3.1nmu8afgt0g00` | 2.1001 | 2.1002 | `3.1003` |
| 黄金农场 | 炎米智能温湿度计 2 | `ym001.sensor_ht.ymwsdj` | `blt.3.1nmu8eunt0001` | 2.1001 | 2.1002 | `3.1003` |
| 黄金农场 | 闹钟(青萍,含温湿) | `cgllc.clock.dove` | `blt.3.12cq3lu8glo00` | 2.1 | 2.2 | `2.3` |
> 第二个家庭「韩家」(586001550228) 当前无环境设备,设计需容忍空家庭。
### 2.2 关键约束:逐点历史不可达,但云端「聚合统计历史」可用(实测反转)
原结论"云端历史完全不可达"已推翻。实测分两条:
**A. 逐点历史(设备本地日志)云 API 不可达**
- 炎米温湿度计:规格含 `8.1 history-data / 8.3 start-time / start-synic`,但 8.1/8.3 读取全返回
`-704010000`(不可读),`start-synic` 动作试了 8 种参数形态全部报 `-704220025 参数个数不匹配`,
判定为"局域网直连通道",云侧无暴露;
- 青萍闹钟:miot 规格仅 temperature / relative-humidity / battery-level,**无任何历史属性/动作**。
**B. 云端聚合统计历史「可用」(%@zythum02/mijia-api 可直接打 `POST /v2/user/statistics`)**
参数 `{ did, key(siid.piid), data_type, limit, time_start, time_end }`,用本库 `api.request()` 直连即可。
| data_type | 桶(Asia/Shanghai) | value 语义 | 实测(炎米 ym001.sensor_ht.ymwsdj) |
| --- | --- | --- | --- |
| `stat_hour_v3` | 1 小时整点 | 小时均值 | 连续入库(炎米1 有部分缺档),如 `[28.8]` |
| `stat_day_v3` | 1 天 00:00 | 当日 [min, max] | 45 天全有,如 `[28.1, 29.2]`,与实时读值吻合 |
| `stat_month_v3` | 1 月 | 当月 [min, max] | 回溯到 2026-01(联网注册后云端一直记账) |
| `stat_week_v3` | 1 周 | — | 该型号返回 `-8 invalid params`(未开通周档) |
| 回补深度 | — | — | 两台炎米均回溯到接入米家日起(实测到 2026-01-03);缺席日=设备当天无上报 |
- 青萍闹钟 `cgllc.clock.dove` 与 VRF `proxy.624017060.00001` 全部 data_type 报 `-8 invalid params`
→ **云侧未为其开通统计**(青萍与其无历史服务一致)。
- 证据:`mibridge-research/stats-probe.mjs` / `stats-probe2.json`。
因此:
- **停机缺口可补真值**:对开通统计的型号(炎米等),停机恢复后用 `stat_day_v3` 回填缺日
[min,max]、`stat_hour_v3` 补小时均值,`origin=vendor_history`(见 §5.3);
- 「精确到某个刻的逐点值」仍不可得(那是设备本地日志,须走局域网 BLE/网关,见 §10);
- 未开通统计的型号(青萍)维持「当天实时采样 + 缺口标注」。
## 3. 总体方案
- 后端新增 `server/mihome/` 模块,封装与米家的认证 + 采集 + 查询;
- 依赖新增 `@zythum02/mijia-api`(扫码登录、`getDevicesProp`、`DiskCache`),
认证缓存文件用 `DiskCache` 存于服务端磁盘(`.cache/`),与采集进程同机;
- **每小时**采样 1 次写小时档(`MihomeEnvHour`),**每日 03:00** (Asia/Shanghai) 聚合日档
(均值/min/max)+ 读一次电量;服务启动时做**追赶同步**(缺小时/缺天则按序补齐),
停机跨天的历史缺口由 `vendorHistoryFiller` 用 `/v2/user/statistics` 真实回填(有统计的型号);
- 前端 `MonitorPage` 增加 tab,`antd-mobile Collapse`(手风琴)+ `recharts` 趋势图,
别名用 `Dialog.Prompt` 编辑;每设备卡片标题=别名+电量,展开后 `Segmented` 切换
**今天(时)/月(天)/年(周)** 三档视图;
- 数据永久保存,小时档按 `(deviceId, hour)`、日档按 `(deviceId, date)` 唯一,天然防重。
## 4. 数据模型(Prisma)
```prisma
// ===== 环境监控:米家 =====
// 米家授权状态(DiskCache 存凭证, 本表只存状态/错误供前端展示)
model MihomeAccount {
id Int @id @default(autoincrement())
key String @unique @default("default")
userId String @default("")
status String @default("unknown") // ok | pending | expired
lastError String @default("")
updatedAt DateTime @updatedAt
}
model MihomeHome {
id Int @id @default(autoincrement())
homeId String @unique
name String
createdAt DateTime @default(now())
devices MihomeDevice[]
}
model MihomeDevice {
id Int @id @default(autoincrement())
did String @unique
homeId String
home MihomeHome @relation(fields: [homeId], references: [homeId])
model String
name String
alias String? // 用户别名, 仅本应用展示
tempSiid Int
tempPiid Int
humSiid Int
humPiid Int
battSiid Int // 电池电量属性(如炎米3.1003 / 青萍2.3)
battPiid Int
battery Int? // 最近一次电量(每天读一次, 标题展示用)
batteryAt DateTime? // 电量读取时间(当天缓存, 不频繁更新)
enabled Boolean @default(true)
lastSyncAt DateTime?
createdAt DateTime @default(now())
samples MihomeEnvSample[]
hours MihomeEnvHour[]
}
model MihomeEnvSample {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
date DateTime // 采样日(Asia/Shanghai 当日 00:00 存 UTC)
temperature Float? // 当日最后一次 live 值(或 vendor hour 均值)
humidity Float?
temperatureAvg Float? // 当日均值(由小时档聚合, 或 vendor_hour)
humidityAvg Float?
temperatureMin Float? // 当日最低
temperatureMax Float? // 当日最高
humidityMin Float?
humidityMax Float?
origin String @default("live") // live | vendor_history | backfill
sampledAt DateTime?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, date]) // 一天一条, 防重
@@index([deviceId, date])
}
model MihomeEnvHour {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
hour DateTime // Asia/Shanghai 整点(存 UTC)
temperatureAvg Float? // 该小时均值(live 采样点聚合, 或 vendor_hour)
humidityAvg Float?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, hour]) // 一小时一条, 防重
@@index([deviceId, hour])
}
```
要点:
- 凭证不进库,只在密钥盘(`server/mihome/.cache/`,需加入 `.gitignore`);
- `MihomeHome` / `MihomeDevice` 随每次同步 **upsert**(以米家为准,重扫不漏不错);
- 存储分三档:**小时档**(当天视图,逐小时)、**日档**(月视图,日均/min/max)、
**年视图按日档现场聚合周桶**(不落库,见 §5.6);
- 电量只存在 `MihomeDevice.battery/batteryAt`,每天读 1 次覆盖当天展示用(需求 11);
- `MihomeEnvSample.origin`:
- `live`——定时采样的真实值(小时档写入,日档由小时聚合);
- `vendor_history`——经 `/v2/user/statistics` 拉回的云端聚合历史(min/max/hour 均值);
- `backfill`——停机恢复后对**当天**的补记(仍用真实值,与 `live` 同字段)。
## 5. 后端设计
### 5.1 代码结构
```
server/mihome/
index.js # 模块入口: 初始化 MijiaAPI + DiskCache, 导出 service
auth.js # 认证管理 + 扫码流程(后台 QRlogin 循环 + pendingQr 状态机)
collector.js # 采集器: 每小时采样 / 每日聚合 / 追赶同步 / 电量 / 设备发现
vendorStats.js # /v2/user/statistics 封装(按型号探测可用性, 提供 hour/day 回填)
history.js # 查询聚合: today(时) / month(天) / year(周)
cache.js # DiskCache 路径与清理
server/routes/mihome.js # REST 路由 (authRequired)
```
### 5.2 认证与「重新授权」
- 启动时 `login()` 自动尝试加载/刷新 `DiskCache` 凭证;成功则 `status=ok`。
- 失败(30 天过期 / 风控)→ `MihomeAccount.status=expired`,前端弹授权横幅。
- 重新授权流程(服务端驱动,前端轮询):
```
POST /api/mihome/reauthorize
└─ 若 pendingQr 空闲 => 后台启动 QRlogin 循环(每轮 120s 超时自动续二维码)
└─ 立即返回 { state, qrImageUrl }
GET /api/mihome/reauthorize/status
└─ 返回 { state: idle|waiting|scanning|ok|failed, qrImageUrl?, lastError? }
└─ 扫码成功(qrlogin 返回) => 保存凭证 => 状态 ok => 触发一次立即同步
POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
```
- `pendingQr` 为模块级单例,防并发(同一时间只允许一个扫码流程)。
- 前端在弹窗内 `setInterval 3s` 轮询直到 `ok`,成功后关弹窗并刷新列表。
### 5.3 采集器 collector
1. **预检**:`whoami().login` 不为真 → 置 `expired`,本轮跳过(不产生记录)。
2. **设备发现**:`getDevicesList()` → upsert 家庭/设备;仅保留具备
`temperature` + `relative-humidity` 属性的型号(经 `getDeviceInfo`/miot-spec 判定,
当前命中 `ym001.sensor_ht.ymwsdj`、`cgllc.clock.dove` 白名单);记录其 `battSiid/battPiid`。
3. **小时采样(每小时)**:批量 `getDevicesProp` 读温度+湿度 → upsert
`MihomeEnvHour(deviceId, hour=当前整点)`,写 `temperatureAvg/humidityAvg`(此小时第一个有效点
即视为小时值)。每台独立 try/catch:
- 设备离线/超时 → 该小时不写(缺口保持);
- 网络/API 异常 → 整体捕获,本轮不误标,留待下次。
4. **电量(每天 1 次)**:每日首轮采样顺带读 `battery-level` → 写
`MihomeDevice.battery/batteryAt`(当天缓存,标题展示用,不频繁更新)。读不到不报错。
5. **每日聚合(03:00 + 启动追赶)**: upsert `MihomeEnvSample(deviceId, date)`:
- `temperatureAvg/humidityAvg` = 当日各小时均值;`temperatureMin/Max`、
`humidityMin/Max` = 当日小时 min/max;`temperature/humidity` = 当日最后一次 live 值;
- 当日采样小时数 < 8h 且设备在线的 → 判定硬件故障?**否**:仅当设备整天在线的阈值外仍缺,
视为设备离线缺档(保持缺口,不伪造)。
6. **追赶同步(补齐)**:每次轮询/同步结束后检查——对每个设备
「缺小时档且采集入口健康」→ 按序补齐小时档;「今天就无样本」→ 立即执行每日聚合。
服务启动时触发一轮,实现**停机恢复当天即补**。
7. **历史补齐(vendorHistoryFiller / vendorStats.js)**:每台设备先探测一次各 data_type
可用性(实测:炎米 ✅ / 青萍、VRF ❌`-8 invalid params`),仅对可用型号扫描近 45 天缺口:
- 缺小时 → `stat_hour_v3` 回填 `MihomeEnvHour.temperatureAvg/humidityAvg`;
- 缺天 → `stat_day_v3` 回填日档 min/max,再由小时档均值回填 avg;
- 命中即写 `origin=vendor_history`;对「服务端停机跨天」缺口尤其有效。
8. **缺口标记**:`GET /status` 计算每设备最近 30 天缺样本日期列表(`missingDays`):
今天缺=待采;历史缺=先尝试 vendor 统计补齐,补不了则标注不可得。
> 补数边界(诚实策略):已开通统计的型号(炎米等)停机跨天缺口可用**云端聚合真值**补齐
> ([min,max]/小时均值,`origin=vendor_history`);未开通的型号(青萍)保持缺口标注、不伪造。
> 「精确逐点历史」仍不可得,只能走局域网 BLE/网关(§10 可选项 C)。
### 5.4 路由表(均在 `/api/mihome`,`authRequired`)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果) |
| GET | `/device/:did/history?range=today` | 当天逐小时(见 §5.6);缺省 `range=today` |
| GET | `/device/:did/history?range=month` | 近 30 天逐日(日均 + min/max) |
| GET | `/device/:did/history?range=year` | 近 52 周逐周(周均值 + min/max,服务端按日档聚合) |
| PUT | `/device/:did` | `{ alias }` 改别名(可清空) |
| POST | `/sync` | 手动触发一次采集(前端「立即同步」) |
| POST | `/reauthorize` | 开始扫码(返回二维码 URL) |
| GET | `/reauthorize/status` | 轮询扫码状态 |
| POST | `/reauthorize/cancel` | 取消扫码 |
### 5.5 定时任务(并入 `server/cron.js`)
- `0 * * * *`(Asia/Shanghai):小时采样(温度+湿度,每小时整点 +5min 缓冲)。
- `5 0,3 * * *`:每日聚合 → `battery` 读取 → vendor 补齐扫描。
- 兼容表达式:`npm run data:mihome`(手动跑一次采集器)备用。
### 5.6 历史查询聚合(history.js)
| range | 时间窗 | 粒度 | 数据源/算法 |
| --- | --- | --- | --- |
| `today` | 当天 00:00→now | 小时 | `MihomeEnvHour` 当日行;未来小时返回 null |
| `month` | 近 30 天 | 天 | `MihomeEnvSample`:`temperatureAvg/humidityAvg`(折线)+ min/max(区间带) |
| `year` | 近 52 周 | 周 | 按日档**现场聚合**:每周 `avg`(日 avg 均值)、`min`(日 min 最小)、`max`(日 max 最大),不落库 |
响应统一形如 `[{ t, temperature, temperatureMin, temperatureMax, humidity, humidityMin, humidityMax }]`,
`t` 为桶起始(Asia/Shanghai),缺数据字段为 null。
## 6. 前端设计
`src/pages/MonitorPage.jsx` 的 `tabs` 增加 `{ key: 'env', label: '环境监控' }`,
对应 `src/pages/monitor/EnvView.jsx`。
### EnvView 结构
1. **授权横幅**(`status !== ok`):
`米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `<img src=qrImageUrl>`
3s 轮询 `/reauthorize/status` → 成功自动关闭并刷新。
2. **顶部操作**:`立即同步`(POST `/sync`)。
3. **家庭列表**:`antd-mobile <Collapse accordion>` 按家庭分组;组展开后显示该家庭
下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。
4. **设备卡片**(不可折叠,点击进入详情/趋势):
- 标题:**别名(无别名则原名)+ 电量**(如 `卧室 78%`,电量用 `battery` 当天缓存值,
不频繁请求);附带编辑别名按钮(`Dialog.Prompt` → `PUT`);
- 概览:`温度 28.1℃ · 湿度 71%`(最新样本)+ 「缺 N 天」暗红角标;
- 图表区:`Segmented`(antd-mobile) 切换 **今天/月/年**,默认**今天**;
`recharts LineChart` 双线(温度/湿度)+ 可选 min/max 区间带(`Area` band):
- `今天` → X 轴逐小时(00:00→当前),数据 `/device/:did/history?range=today`;
- `月` → X 轴逐日(近 30 天),数据 `?range=month`(日均折线 + 日 min/max 影带);
- `年` → X 轴逐周(近 52 周),数据 `?range=year`(周均值折线 + 周 min/max 影带);
- 视图切换/拉数据留在卡片内部(不用全局 tab),逻辑沿用现有 `ServerDetail.jsx` /
`DataView.jsx` 图表写法。
5. 样式复用 `globals.css` 的 `.card`/`.monitor-*` 体系,新增少量 collapse 样式。
> 手风琴用 `Collapse accordion`;「不同时打开」即一次仅一个家庭展开。
## 7. 迁移与部署
1. `npm i @zythum02/mijia-api`(加入 dependencies)。
2. `schema.prisma` 追加 5 个模型 → `npx prisma migrate dev --name add_mihome_env`。
3. `.env` 可选 `MIHOME_CACHE_DIR`(默认 `server/mihome/.cache`);
服务端放 `.cache/` 于 `.gitignore`。
4. 首次部署:在服务器上人工跑一次扫码授权(`npm run data:mihome` 的初始化提示
会输出二维码),写入服务器本地的 `.cache/`。
5. 数据库迁移走现有 `prisma migrate deploy` 流程。
## 8. 风险与开放问题
| # | 风险/问题 | 应对 |
| --- | --- | --- |
| 1 | **逐点历史不可读**(设备本地日志),停机期要精确到刻的值补不上 | 已找到云端聚合通道(`/v2/user/statistics`,min/max/时均值,实测可行);按模型开通情况补齐(炎米 ✅ / 青萍 ❌);精确逐点 → 可选 §10 C |
| 2 | token 30 天过期需人工扫码 | 需求 4 的重新授权流程已覆盖;扫码流程 120s 超时自动换码 |
| 3 | 采样时刻固定凌晨,代表性一般 | 已改**每小时采样**,当天小时视图/日均/周均代表性充分;03:00 仅做日档聚合 |
| 4 | 第三方 `@zythum02` 库上游变动 | 锁定版本;模块边界隔离,只暴露我们自己需要的函数 |
| 5 | VRF/其他设备写入延迟 5~6s | 仅影响将来的控制功能,本模块只读,无影响 |
## 9. 实施步骤(里程碑)
- **M1 后端地基**:依赖 + Prisma 迁移(5 模型)+ `server/mihome`
(认证 + collector 小时采样/每日聚合/电量 + vendorStats 补齐 + history.js 三段聚合 + 路由)+ cron。
- **M2 前端**:`EnvView`(授权弹窗 / 手风琴家庭组 / 设备卡=别名+电量 /
别名编辑 / Segmented 今天·月·年视图与图表 / 缺口角标)。
- **M3 联调**:真机/Web 端到端 —— 小时采样与日聚合、停机恢复追赶与 vendor 补齐、
扫码重授权、别名、电量、三个粒度的图表与缺口展示。
- **M4(可选,已基本闭环)**:`/v2/user/statistics` 云端聚合历史通道已实测打通,
直接并入 M1 的 `vendorHistoryFiller`(无需再抓包 App)。
- **M5(可选)**:可选项 C 家侧本地采样代理(见 §10),用于「精确逐点」与分钟级采样。
## 10. 可选项 C:家侧本地采样代理 + 缓冲补数
> 背景:主线「云 API 每日采样」的停机跨天缺口现可由云端聚合统计(§2.2B)补齐到 [min,max]/小时
> 粒度;可选项 C 是把它做到**精确逐点 / 分钟级**——家侧一个常驻采集进程(可选配合小米中枢网关),
> 本地连续采样并缓冲,服务端恢复后拉真值回填。
> 与主线**并行可选**,不影响 §1~§9 落地;仅在追求细粒度历史时投入。
### 10.1 事实依据(已调研)
| 事实 | 结论 |
| --- | --- |
| 家庭现有网关 | 无中枢/多模网关;`blt.*` 设备挂在「小爱音箱 Play 增强版」上转发,该音箱**不开放局域网 API** |
| 局域网直连设备本身 | 需本地 BLE 适配器;青萍闹钟(CGD1)协议已逆向但**仅有实时/设置**、无历史通道;炎米 ym001 **零社区支持**,需自行逆向厂商 GATT + 推导 MiKey,把握低 |
| 若有小米中枢网关 | 官方 HA 集成走网关**内置本地 MQTT Broker**,可本地化 WiFi+BLE 设备、断外网可用、延迟 10~50ms(社区实测 `ha_xiaomi_home`);但本地 Broker **只推实时 props,不提供历史** |
| 概率评估 | 本地实时读取:无网关走蓝牙 ~60~85%(青萍高/炎米中);加中枢网关 ~85%。补停机期历史:三条局域网路线均**不直接提供**,只能靠「本地连续采样 + 缓冲」自建 |
### 10.2 方案
```
家庭内常驻小机(现有 Windows 机 / 树莓派) 跑 server/mihome-agent/
├─ 采样源(可切换): a) 云 getDevicesProp (同 §5.3, 无需新硬件)
│ b) 加中枢网关后走本地 MQTT(断外网可用, 秒级粒度)
│ c) 未来本地 BLE 嗅探(青萍 CGD1 优先)
├─ 每 N 分钟采样 → 写本地缓冲 SQLite/JSONL(本地保留 180 天)
└─ 提供 GET /samples?from&to&did 供服务端拉取
服务端(iboard)
└─ 停机恢复/每日同步时: 调 agent 拉缺口区间真实值 → 写 MihomeEnvSample(origin=backfill)
(服务端主动拉 agent,agent 无需入公网,可用内网/隧道)
```
### 10.3 效果与代价
- **效果**:停机期 agent 缓冲持续采样,恢复后以**精确逐点/分钟级**真值回填(粒度高于 §2.2B 的
聚合统计);加中枢网关后数据链路断外网可用、秒级推送。
- **代价**:家中常驻进程(可复用现有 Windows 开发机);可选购买中枢网关(~¥349);
agent 与服务端的传输通道需内网/VPN/隧道。
- **决策点**:是否投入属产品决策。当前「补齐停机缺口」已被 §2.2B 的云端统计覆盖(满足
「数据不比米家少」);C 只对「要更细粒度」有增量价值。接入点:agent 值写入
`MihomeEnvSample`,`origin=backfill`。
- **本地直连协议现状**(中枢/蓝牙):官方 HA 集成走中枢内置 MQTT Broker(需 HA 或移植其
`MipsLocalClient` 的证书鉴权+微链路,无公开协议,自有 Node agent 直连把握中低);纯 BLE
嗅探见 §10.1 表格(青萍 CGD1 有逆向基础、炎米零资料)。
+30
View File
@@ -77,3 +77,33 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
*
*/
export type ScraperCache = Prisma.ScraperCacheModel
/**
* Model OilPriceHistory
*
*/
export type OilPriceHistory = Prisma.OilPriceHistoryModel
/**
* Model MihomeAccount
*
*/
export type MihomeAccount = Prisma.MihomeAccountModel
/**
* Model MihomeHome
*
*/
export type MihomeHome = Prisma.MihomeHomeModel
/**
* Model MihomeDevice
*
*/
export type MihomeDevice = Prisma.MihomeDeviceModel
/**
* Model MihomeEnvSample
*
*/
export type MihomeEnvSample = Prisma.MihomeEnvSampleModel
/**
* Model MihomeEnvHour
*
*/
export type MihomeEnvHour = Prisma.MihomeEnvHourModel
+30
View File
@@ -101,3 +101,33 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
*
*/
export type ScraperCache = Prisma.ScraperCacheModel
/**
* Model OilPriceHistory
*
*/
export type OilPriceHistory = Prisma.OilPriceHistoryModel
/**
* Model MihomeAccount
*
*/
export type MihomeAccount = Prisma.MihomeAccountModel
/**
* Model MihomeHome
*
*/
export type MihomeHome = Prisma.MihomeHomeModel
/**
* Model MihomeDevice
*
*/
export type MihomeDevice = Prisma.MihomeDeviceModel
/**
* Model MihomeEnvSample
*
*/
export type MihomeEnvSample = Prisma.MihomeEnvSampleModel
/**
* Model MihomeEnvHour
*
*/
export type MihomeEnvHour = Prisma.MihomeEnvHourModel
+147
View File
@@ -306,6 +306,85 @@ export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
}
export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type FloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
@@ -550,4 +629,72 @@ export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
File diff suppressed because one or more lines are too long
+547 -2
View File
@@ -395,7 +395,13 @@ export const ModelName = {
ServerNodeHistory: 'ServerNodeHistory',
ServiceCheck: 'ServiceCheck',
Econ_SowInventory: 'Econ_SowInventory',
ScraperCache: 'ScraperCache'
ScraperCache: 'ScraperCache',
OilPriceHistory: 'OilPriceHistory',
MihomeAccount: 'MihomeAccount',
MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice',
MihomeEnvSample: 'MihomeEnvSample',
MihomeEnvHour: 'MihomeEnvHour'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -411,7 +417,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache"
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1207,6 +1213,402 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
OilPriceHistory: {
payload: Prisma.$OilPriceHistoryPayload<ExtArgs>
fields: Prisma.OilPriceHistoryFieldRefs
operations: {
findUnique: {
args: Prisma.OilPriceHistoryFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload> | null
}
findUniqueOrThrow: {
args: Prisma.OilPriceHistoryFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
}
findFirst: {
args: Prisma.OilPriceHistoryFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload> | null
}
findFirstOrThrow: {
args: Prisma.OilPriceHistoryFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
}
findMany: {
args: Prisma.OilPriceHistoryFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>[]
}
create: {
args: Prisma.OilPriceHistoryCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
}
createMany: {
args: Prisma.OilPriceHistoryCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.OilPriceHistoryDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
}
update: {
args: Prisma.OilPriceHistoryUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
}
deleteMany: {
args: Prisma.OilPriceHistoryDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.OilPriceHistoryUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.OilPriceHistoryUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
}
aggregate: {
args: Prisma.OilPriceHistoryAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateOilPriceHistory>
}
groupBy: {
args: Prisma.OilPriceHistoryGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OilPriceHistoryGroupByOutputType>[]
}
count: {
args: Prisma.OilPriceHistoryCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OilPriceHistoryCountAggregateOutputType> | number
}
}
}
MihomeAccount: {
payload: Prisma.$MihomeAccountPayload<ExtArgs>
fields: Prisma.MihomeAccountFieldRefs
operations: {
findUnique: {
args: Prisma.MihomeAccountFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload> | null
}
findUniqueOrThrow: {
args: Prisma.MihomeAccountFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
}
findFirst: {
args: Prisma.MihomeAccountFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload> | null
}
findFirstOrThrow: {
args: Prisma.MihomeAccountFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
}
findMany: {
args: Prisma.MihomeAccountFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>[]
}
create: {
args: Prisma.MihomeAccountCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
}
createMany: {
args: Prisma.MihomeAccountCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.MihomeAccountDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
}
update: {
args: Prisma.MihomeAccountUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
}
deleteMany: {
args: Prisma.MihomeAccountDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.MihomeAccountUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.MihomeAccountUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
}
aggregate: {
args: Prisma.MihomeAccountAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeAccount>
}
groupBy: {
args: Prisma.MihomeAccountGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeAccountGroupByOutputType>[]
}
count: {
args: Prisma.MihomeAccountCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeAccountCountAggregateOutputType> | number
}
}
}
MihomeHome: {
payload: Prisma.$MihomeHomePayload<ExtArgs>
fields: Prisma.MihomeHomeFieldRefs
operations: {
findUnique: {
args: Prisma.MihomeHomeFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload> | null
}
findUniqueOrThrow: {
args: Prisma.MihomeHomeFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
}
findFirst: {
args: Prisma.MihomeHomeFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload> | null
}
findFirstOrThrow: {
args: Prisma.MihomeHomeFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
}
findMany: {
args: Prisma.MihomeHomeFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>[]
}
create: {
args: Prisma.MihomeHomeCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
}
createMany: {
args: Prisma.MihomeHomeCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.MihomeHomeDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
}
update: {
args: Prisma.MihomeHomeUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
}
deleteMany: {
args: Prisma.MihomeHomeDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.MihomeHomeUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.MihomeHomeUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
}
aggregate: {
args: Prisma.MihomeHomeAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeHome>
}
groupBy: {
args: Prisma.MihomeHomeGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeHomeGroupByOutputType>[]
}
count: {
args: Prisma.MihomeHomeCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeHomeCountAggregateOutputType> | number
}
}
}
MihomeDevice: {
payload: Prisma.$MihomeDevicePayload<ExtArgs>
fields: Prisma.MihomeDeviceFieldRefs
operations: {
findUnique: {
args: Prisma.MihomeDeviceFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload> | null
}
findUniqueOrThrow: {
args: Prisma.MihomeDeviceFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
}
findFirst: {
args: Prisma.MihomeDeviceFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload> | null
}
findFirstOrThrow: {
args: Prisma.MihomeDeviceFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
}
findMany: {
args: Prisma.MihomeDeviceFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>[]
}
create: {
args: Prisma.MihomeDeviceCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
}
createMany: {
args: Prisma.MihomeDeviceCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.MihomeDeviceDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
}
update: {
args: Prisma.MihomeDeviceUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
}
deleteMany: {
args: Prisma.MihomeDeviceDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.MihomeDeviceUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.MihomeDeviceUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
}
aggregate: {
args: Prisma.MihomeDeviceAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeDevice>
}
groupBy: {
args: Prisma.MihomeDeviceGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeDeviceGroupByOutputType>[]
}
count: {
args: Prisma.MihomeDeviceCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeDeviceCountAggregateOutputType> | number
}
}
}
MihomeEnvSample: {
payload: Prisma.$MihomeEnvSamplePayload<ExtArgs>
fields: Prisma.MihomeEnvSampleFieldRefs
operations: {
findUnique: {
args: Prisma.MihomeEnvSampleFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload> | null
}
findUniqueOrThrow: {
args: Prisma.MihomeEnvSampleFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
}
findFirst: {
args: Prisma.MihomeEnvSampleFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload> | null
}
findFirstOrThrow: {
args: Prisma.MihomeEnvSampleFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
}
findMany: {
args: Prisma.MihomeEnvSampleFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>[]
}
create: {
args: Prisma.MihomeEnvSampleCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
}
createMany: {
args: Prisma.MihomeEnvSampleCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.MihomeEnvSampleDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
}
update: {
args: Prisma.MihomeEnvSampleUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
}
deleteMany: {
args: Prisma.MihomeEnvSampleDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.MihomeEnvSampleUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.MihomeEnvSampleUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
}
aggregate: {
args: Prisma.MihomeEnvSampleAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeEnvSample>
}
groupBy: {
args: Prisma.MihomeEnvSampleGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeEnvSampleGroupByOutputType>[]
}
count: {
args: Prisma.MihomeEnvSampleCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeEnvSampleCountAggregateOutputType> | number
}
}
}
MihomeEnvHour: {
payload: Prisma.$MihomeEnvHourPayload<ExtArgs>
fields: Prisma.MihomeEnvHourFieldRefs
operations: {
findUnique: {
args: Prisma.MihomeEnvHourFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload> | null
}
findUniqueOrThrow: {
args: Prisma.MihomeEnvHourFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
}
findFirst: {
args: Prisma.MihomeEnvHourFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload> | null
}
findFirstOrThrow: {
args: Prisma.MihomeEnvHourFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
}
findMany: {
args: Prisma.MihomeEnvHourFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>[]
}
create: {
args: Prisma.MihomeEnvHourCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
}
createMany: {
args: Prisma.MihomeEnvHourCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.MihomeEnvHourDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
}
update: {
args: Prisma.MihomeEnvHourUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
}
deleteMany: {
args: Prisma.MihomeEnvHourDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.MihomeEnvHourUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.MihomeEnvHourUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
}
aggregate: {
args: Prisma.MihomeEnvHourAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeEnvHour>
}
groupBy: {
args: Prisma.MihomeEnvHourGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeEnvHourGroupByOutputType>[]
}
count: {
args: Prisma.MihomeEnvHourCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.MihomeEnvHourCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1430,6 +1832,98 @@ export const ScraperCacheScalarFieldEnum = {
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof ScraperCacheScalarFieldEnum]
export const OilPriceHistoryScalarFieldEnum = {
id: 'id',
date: 'date',
province: 'province',
fuel: 'fuel',
price: 'price',
source: 'source',
createdAt: 'createdAt'
} as const
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
export const MihomeAccountScalarFieldEnum = {
id: 'id',
key: 'key',
userId: 'userId',
status: 'status',
lastError: 'lastError',
updatedAt: 'updatedAt'
} as const
export type MihomeAccountScalarFieldEnum = (typeof MihomeAccountScalarFieldEnum)[keyof typeof MihomeAccountScalarFieldEnum]
export const MihomeHomeScalarFieldEnum = {
id: 'id',
homeId: 'homeId',
name: 'name',
createdAt: 'createdAt'
} as const
export type MihomeHomeScalarFieldEnum = (typeof MihomeHomeScalarFieldEnum)[keyof typeof MihomeHomeScalarFieldEnum]
export const MihomeDeviceScalarFieldEnum = {
id: 'id',
did: 'did',
homeId: 'homeId',
model: 'model',
name: 'name',
alias: 'alias',
tempSiid: 'tempSiid',
tempPiid: 'tempPiid',
humSiid: 'humSiid',
humPiid: 'humPiid',
battSiid: 'battSiid',
battPiid: 'battPiid',
battery: 'battery',
batteryAt: 'batteryAt',
enabled: 'enabled',
lastSyncAt: 'lastSyncAt',
createdAt: 'createdAt'
} as const
export type MihomeDeviceScalarFieldEnum = (typeof MihomeDeviceScalarFieldEnum)[keyof typeof MihomeDeviceScalarFieldEnum]
export const MihomeEnvSampleScalarFieldEnum = {
id: 'id',
deviceId: 'deviceId',
date: 'date',
temperature: 'temperature',
humidity: 'humidity',
temperatureAvg: 'temperatureAvg',
humidityAvg: 'humidityAvg',
temperatureMin: 'temperatureMin',
temperatureMax: 'temperatureMax',
humidityMin: 'humidityMin',
humidityMax: 'humidityMax',
origin: 'origin',
sampledAt: 'sampledAt',
raw: 'raw',
createdAt: 'createdAt'
} as const
export type MihomeEnvSampleScalarFieldEnum = (typeof MihomeEnvSampleScalarFieldEnum)[keyof typeof MihomeEnvSampleScalarFieldEnum]
export const MihomeEnvHourScalarFieldEnum = {
id: 'id',
deviceId: 'deviceId',
hour: 'hour',
temperatureAvg: 'temperatureAvg',
humidityAvg: 'humidityAvg',
raw: 'raw',
createdAt: 'createdAt'
} as const
export type MihomeEnvHourScalarFieldEnum = (typeof MihomeEnvHourScalarFieldEnum)[keyof typeof MihomeEnvHourScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -1589,6 +2083,51 @@ export const ScraperCacheOrderByRelevanceFieldEnum = {
export type ScraperCacheOrderByRelevanceFieldEnum = (typeof ScraperCacheOrderByRelevanceFieldEnum)[keyof typeof ScraperCacheOrderByRelevanceFieldEnum]
export const OilPriceHistoryOrderByRelevanceFieldEnum = {
province: 'province',
fuel: 'fuel',
source: 'source'
} as const
export type OilPriceHistoryOrderByRelevanceFieldEnum = (typeof OilPriceHistoryOrderByRelevanceFieldEnum)[keyof typeof OilPriceHistoryOrderByRelevanceFieldEnum]
export const MihomeAccountOrderByRelevanceFieldEnum = {
key: 'key',
userId: 'userId',
status: 'status',
lastError: 'lastError'
} as const
export type MihomeAccountOrderByRelevanceFieldEnum = (typeof MihomeAccountOrderByRelevanceFieldEnum)[keyof typeof MihomeAccountOrderByRelevanceFieldEnum]
export const MihomeHomeOrderByRelevanceFieldEnum = {
homeId: 'homeId',
name: 'name'
} as const
export type MihomeHomeOrderByRelevanceFieldEnum = (typeof MihomeHomeOrderByRelevanceFieldEnum)[keyof typeof MihomeHomeOrderByRelevanceFieldEnum]
export const MihomeDeviceOrderByRelevanceFieldEnum = {
did: 'did',
homeId: 'homeId',
model: 'model',
name: 'name',
alias: 'alias'
} as const
export type MihomeDeviceOrderByRelevanceFieldEnum = (typeof MihomeDeviceOrderByRelevanceFieldEnum)[keyof typeof MihomeDeviceOrderByRelevanceFieldEnum]
export const MihomeEnvSampleOrderByRelevanceFieldEnum = {
origin: 'origin'
} as const
export type MihomeEnvSampleOrderByRelevanceFieldEnum = (typeof MihomeEnvSampleOrderByRelevanceFieldEnum)[keyof typeof MihomeEnvSampleOrderByRelevanceFieldEnum]
/**
* Field references
@@ -1757,6 +2296,12 @@ export type GlobalOmitConfig = {
serviceCheck?: Prisma.ServiceCheckOmit
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
scraperCache?: Prisma.ScraperCacheOmit
oilPriceHistory?: Prisma.OilPriceHistoryOmit
mihomeAccount?: Prisma.MihomeAccountOmit
mihomeHome?: Prisma.MihomeHomeOmit
mihomeDevice?: Prisma.MihomeDeviceOmit
mihomeEnvSample?: Prisma.MihomeEnvSampleOmit
mihomeEnvHour?: Prisma.MihomeEnvHourOmit
}
/* Types for Logging */
@@ -62,7 +62,13 @@ export const ModelName = {
ServerNodeHistory: 'ServerNodeHistory',
ServiceCheck: 'ServiceCheck',
Econ_SowInventory: 'Econ_SowInventory',
ScraperCache: 'ScraperCache'
ScraperCache: 'ScraperCache',
OilPriceHistory: 'OilPriceHistory',
MihomeAccount: 'MihomeAccount',
MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice',
MihomeEnvSample: 'MihomeEnvSample',
MihomeEnvHour: 'MihomeEnvHour'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -265,6 +271,98 @@ export const ScraperCacheScalarFieldEnum = {
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof ScraperCacheScalarFieldEnum]
export const OilPriceHistoryScalarFieldEnum = {
id: 'id',
date: 'date',
province: 'province',
fuel: 'fuel',
price: 'price',
source: 'source',
createdAt: 'createdAt'
} as const
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
export const MihomeAccountScalarFieldEnum = {
id: 'id',
key: 'key',
userId: 'userId',
status: 'status',
lastError: 'lastError',
updatedAt: 'updatedAt'
} as const
export type MihomeAccountScalarFieldEnum = (typeof MihomeAccountScalarFieldEnum)[keyof typeof MihomeAccountScalarFieldEnum]
export const MihomeHomeScalarFieldEnum = {
id: 'id',
homeId: 'homeId',
name: 'name',
createdAt: 'createdAt'
} as const
export type MihomeHomeScalarFieldEnum = (typeof MihomeHomeScalarFieldEnum)[keyof typeof MihomeHomeScalarFieldEnum]
export const MihomeDeviceScalarFieldEnum = {
id: 'id',
did: 'did',
homeId: 'homeId',
model: 'model',
name: 'name',
alias: 'alias',
tempSiid: 'tempSiid',
tempPiid: 'tempPiid',
humSiid: 'humSiid',
humPiid: 'humPiid',
battSiid: 'battSiid',
battPiid: 'battPiid',
battery: 'battery',
batteryAt: 'batteryAt',
enabled: 'enabled',
lastSyncAt: 'lastSyncAt',
createdAt: 'createdAt'
} as const
export type MihomeDeviceScalarFieldEnum = (typeof MihomeDeviceScalarFieldEnum)[keyof typeof MihomeDeviceScalarFieldEnum]
export const MihomeEnvSampleScalarFieldEnum = {
id: 'id',
deviceId: 'deviceId',
date: 'date',
temperature: 'temperature',
humidity: 'humidity',
temperatureAvg: 'temperatureAvg',
humidityAvg: 'humidityAvg',
temperatureMin: 'temperatureMin',
temperatureMax: 'temperatureMax',
humidityMin: 'humidityMin',
humidityMax: 'humidityMax',
origin: 'origin',
sampledAt: 'sampledAt',
raw: 'raw',
createdAt: 'createdAt'
} as const
export type MihomeEnvSampleScalarFieldEnum = (typeof MihomeEnvSampleScalarFieldEnum)[keyof typeof MihomeEnvSampleScalarFieldEnum]
export const MihomeEnvHourScalarFieldEnum = {
id: 'id',
deviceId: 'deviceId',
hour: 'hour',
temperatureAvg: 'temperatureAvg',
humidityAvg: 'humidityAvg',
raw: 'raw',
createdAt: 'createdAt'
} as const
export type MihomeEnvHourScalarFieldEnum = (typeof MihomeEnvHourScalarFieldEnum)[keyof typeof MihomeEnvHourScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -423,3 +521,48 @@ export const ScraperCacheOrderByRelevanceFieldEnum = {
export type ScraperCacheOrderByRelevanceFieldEnum = (typeof ScraperCacheOrderByRelevanceFieldEnum)[keyof typeof ScraperCacheOrderByRelevanceFieldEnum]
export const OilPriceHistoryOrderByRelevanceFieldEnum = {
province: 'province',
fuel: 'fuel',
source: 'source'
} as const
export type OilPriceHistoryOrderByRelevanceFieldEnum = (typeof OilPriceHistoryOrderByRelevanceFieldEnum)[keyof typeof OilPriceHistoryOrderByRelevanceFieldEnum]
export const MihomeAccountOrderByRelevanceFieldEnum = {
key: 'key',
userId: 'userId',
status: 'status',
lastError: 'lastError'
} as const
export type MihomeAccountOrderByRelevanceFieldEnum = (typeof MihomeAccountOrderByRelevanceFieldEnum)[keyof typeof MihomeAccountOrderByRelevanceFieldEnum]
export const MihomeHomeOrderByRelevanceFieldEnum = {
homeId: 'homeId',
name: 'name'
} as const
export type MihomeHomeOrderByRelevanceFieldEnum = (typeof MihomeHomeOrderByRelevanceFieldEnum)[keyof typeof MihomeHomeOrderByRelevanceFieldEnum]
export const MihomeDeviceOrderByRelevanceFieldEnum = {
did: 'did',
homeId: 'homeId',
model: 'model',
name: 'name',
alias: 'alias'
} as const
export type MihomeDeviceOrderByRelevanceFieldEnum = (typeof MihomeDeviceOrderByRelevanceFieldEnum)[keyof typeof MihomeDeviceOrderByRelevanceFieldEnum]
export const MihomeEnvSampleOrderByRelevanceFieldEnum = {
origin: 'origin'
} as const
export type MihomeEnvSampleOrderByRelevanceFieldEnum = (typeof MihomeEnvSampleOrderByRelevanceFieldEnum)[keyof typeof MihomeEnvSampleOrderByRelevanceFieldEnum]
+6
View File
@@ -20,4 +20,10 @@ export type * from './models/ServerNodeHistory.ts'
export type * from './models/ServiceCheck.ts'
export type * from './models/Econ_SowInventory.ts'
export type * from './models/ScraperCache.ts'
export type * from './models/OilPriceHistory.ts'
export type * from './models/MihomeAccount.ts'
export type * from './models/MihomeHome.ts'
export type * from './models/MihomeDevice.ts'
export type * from './models/MihomeEnvSample.ts'
export type * from './models/MihomeEnvHour.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
# ota-package/ 目录占位文件
本目录是 OTA 升级包输出目录(git 忽略):
- **CI(docker-build.yml)**:每次主分支提交运行 `scripts/build-ota.mjs` 生成 `iboard-ota.tar.gz` + `latest.json` + `*.minisig`,随 Docker 镜像发布到容器内 `OTA_PACKAGE_DIR=/app/ota-package`。
- **本地手动构建**:只有本占位文件,Dockerfile 的 `COPY ota-package ./ota-package` 不会失败;容器内 `readLatest()` 找不到 `latest.json` 时 OTA 检查返回"无更新包"。
本文件保留在 git 中是为了让 `COPY` 在本地构建时不报错;真实 OTA 产物(含私钥 hotswap.key)永不提交。
+1032 -458
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -1,6 +1,6 @@
{
"name": "iboard",
"version": "1.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"tauri": "tauri",
@@ -8,10 +8,12 @@
"dev:server": "node --watch --experimental-strip-types --no-warnings server/index.js",
"dev": "concurrently -n web,api -c blue,green \"npm run dev:web\" \"npm run dev:server\"",
"build": "vite build",
"build:ota": "node scripts/build-ota.mjs",
"preview": "vite preview",
"start": "node --experimental-strip-types --no-warnings server/index.js",
"db:migrate": "prisma migrate deploy",
"db:seed": "node scripts/seed.cjs",
"registry:sync": "node scripts/sync-registry.mjs",
"data:refresh": "node --no-warnings scripts/refresh-indicators.cjs",
"data:discover": "node --no-warnings scripts/discover-mcp-tools.cjs",
"icons": "node scripts/generate-icons.mjs",
@@ -22,13 +24,17 @@
"tauri:android:build": "tauri android build",
"tauri:android:apk": "tauri android build --apk",
"test": "echo \"Error: no test specified\" && exit 1",
"data:news": "node --no-warnings scripts/refresh-news.cjs"
"data:news": "node --no-warnings scripts/refresh-news.cjs",
"data:mihome": "node --experimental-strip-types --no-warnings scripts/mihome-cli.mjs",
"data:mihome:login": "node --experimental-strip-types --no-warnings scripts/mihome-login.mjs",
"publish": "bash scripts/wsl/build-apk.sh release"
},
"dependencies": {
"@huolala-tech/page-spy": "^1.5.7",
"@prisma/adapter-mariadb": "^7.9.1",
"@prisma/client": "^7.7.0",
"@zythum02/mijia-api": "^0.1.4",
"antd-mobile": "^5.42.3",
"axios": "^1.15.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
@@ -42,13 +48,13 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.8.1",
"tauri-plugin-hotswap-api": "^0.0.4",
"ws": "^8.21.2"
},
"devDependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/cli": "^2.0.0",
"@vitejs/plugin-react": "^4.3.1",
"axios": "^1.15.0",
"cheerio": "^1.2.0",
"concurrently": "^9.0.0",
"csv-parser": "^3.2.0",
@@ -0,0 +1,17 @@
-- 浙江省油价历史表
-- - date: 调价日次日 (即数据实际抓取日, 便于按时间排序)
-- - province + fuel: 浙江 / 92/95/0
-- - unique (date, province, fuel): 防 cron 重复入库
-- - DOUBLE 容纳 999.999 元/升
CREATE TABLE OilPriceHistory (
id INT NOT NULL AUTO_INCREMENT,
date DATETIME(3) NOT NULL,
province VARCHAR(191) NOT NULL,
fuel VARCHAR(8) NOT NULL,
price DOUBLE NOT NULL,
source VARCHAR(191) NOT NULL DEFAULT 'apizero',
createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX OilPriceHistory_date_province_fuel_key(date, province, fuel),
INDEX OilPriceHistory_province_fuel_date_idx(province, fuel, date)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,95 @@
-- CreateTable
CREATE TABLE `MihomeAccount` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`key` VARCHAR(191) NOT NULL DEFAULT 'default',
`userId` VARCHAR(191) NOT NULL DEFAULT '',
`status` VARCHAR(191) NOT NULL DEFAULT 'unknown',
`lastError` VARCHAR(191) NOT NULL DEFAULT '',
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `MihomeAccount_key_key`(`key`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MihomeHome` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`homeId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `MihomeHome_homeId_key`(`homeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MihomeDevice` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`did` VARCHAR(191) NOT NULL,
`homeId` VARCHAR(191) NOT NULL,
`model` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`alias` VARCHAR(191) NULL,
`tempSiid` INTEGER NOT NULL,
`tempPiid` INTEGER NOT NULL,
`humSiid` INTEGER NOT NULL,
`humPiid` INTEGER NOT NULL,
`battSiid` INTEGER NOT NULL,
`battPiid` INTEGER NOT NULL,
`battery` INTEGER NULL,
`batteryAt` DATETIME(3) NULL,
`enabled` BOOLEAN NOT NULL DEFAULT true,
`lastSyncAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `MihomeDevice_did_key`(`did`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MihomeEnvSample` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`deviceId` INTEGER NOT NULL,
`date` DATETIME(3) NOT NULL,
`temperature` DOUBLE NULL,
`humidity` DOUBLE NULL,
`temperatureAvg` DOUBLE NULL,
`humidityAvg` DOUBLE NULL,
`temperatureMin` DOUBLE NULL,
`temperatureMax` DOUBLE NULL,
`humidityMin` DOUBLE NULL,
`humidityMax` DOUBLE NULL,
`origin` VARCHAR(191) NOT NULL DEFAULT 'live',
`sampledAt` DATETIME(3) NULL,
`raw` JSON NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `MihomeEnvSample_deviceId_date_idx`(`deviceId`, `date`),
UNIQUE INDEX `MihomeEnvSample_deviceId_date_key`(`deviceId`, `date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MihomeEnvHour` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`deviceId` INTEGER NOT NULL,
`hour` DATETIME(3) NOT NULL,
`temperatureAvg` DOUBLE NULL,
`humidityAvg` DOUBLE NULL,
`raw` JSON NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `MihomeEnvHour_deviceId_hour_idx`(`deviceId`, `hour`),
UNIQUE INDEX `MihomeEnvHour_deviceId_hour_key`(`deviceId`, `hour`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `MihomeDevice` ADD CONSTRAINT `MihomeDevice_homeId_fkey` FOREIGN KEY (`homeId`) REFERENCES `MihomeHome`(`homeId`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MihomeEnvSample` ADD CONSTRAINT `MihomeEnvSample_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `MihomeDevice`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MihomeEnvHour` ADD CONSTRAINT `MihomeEnvHour_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `MihomeDevice`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+101
View File
@@ -181,3 +181,104 @@ model ScraperCache {
}
// 浙江省油价历史 (apizero price 接口每日抓取)
// 每天 04:00 cron 触发: 检查 schedule 里待定且 <= 昨天的调价日, 抓 price?province=浙江 存本表
// unique (date, province, fuel) 防重复
model OilPriceHistory {
id Int @id @default(autoincrement())
date DateTime // 调价日次日 (即数据实际抓取日)
province String // '浙江'
fuel String // '92' | '95' | '0'
price Float // 油价 (元/升, 4 位小数足够)
source String @default("apizero")
createdAt DateTime @default(now())
@@unique([date, province, fuel])
@@index([province, fuel, date])
}
// ==================== 环境监控:米家 ====================
// 米家授权状态 (DiskCache 存凭证, 本表只存状态/错误供前端展示)
model MihomeAccount {
id Int @id @default(autoincrement())
key String @unique @default("default")
userId String @default("")
status String @default("unknown") // ok | pending | expired
lastError String @default("")
updatedAt DateTime @updatedAt
}
model MihomeHome {
id Int @id @default(autoincrement())
homeId String @unique
name String
createdAt DateTime @default(now())
devices MihomeDevice[]
}
model MihomeDevice {
id Int @id @default(autoincrement())
did String @unique
homeId String
home MihomeHome @relation(fields: [homeId], references: [homeId])
model String
name String
alias String? // 用户别名, 仅本应用展示
tempSiid Int
tempPiid Int
humSiid Int
humPiid Int
battSiid Int // 电池电量属性 (炎米 3.1003 / 青萍 2.3)
battPiid Int
battery Int? // 最近一次电量 (每天读一次, 标题展示用)
batteryAt DateTime? // 电量读取时间 (当天缓存, 不频繁更新)
enabled Boolean @default(true)
lastSyncAt DateTime?
createdAt DateTime @default(now())
samples MihomeEnvSample[]
hours MihomeEnvHour[]
}
// 日档: 月视图(逐日)数据源; unique (deviceId, date) 防重
model MihomeEnvSample {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
date DateTime // 采样日 (Asia/Shanghai 当日 00:00 存 UTC)
temperature Float? // 当日最后一次实时值 (或 vendor hour 均值)
humidity Float?
temperatureAvg Float? // 当日均值 (由小时档聚合, 或 vendor_hour)
humidityAvg Float?
temperatureMin Float? // 当日最低
temperatureMax Float? // 当日最高
humidityMin Float?
humidityMax Float?
origin String @default("live") // live | vendor_history | backfill
sampledAt DateTime?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, date])
@@index([deviceId, date])
}
// 小时档: 当天视图(逐小时)数据源; unique (deviceId, hour) 防重
model MihomeEnvHour {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
hour DateTime // Asia/Shanghai 整点 (存 UTC)
temperatureAvg Float? // 该小时均值 (live 采样点, 或 vendor_hour)
humidityAvg Float?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, hour])
@@index([deviceId, hour])
}
+131 -24
View File
@@ -1,24 +1,131 @@
# iBoard
用于收集和整理经济数据,但是以策略为主体重新组织。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表。
- 策略是放按照经济理论组织的相关数据和图表的对比。
## 首页
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
### 新闻模块
增加一个新闻模块,数据来源是每日夜间 3点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序10条。
存入数据库。
### 报警模块
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
## 监控
监控分为服务器监控和服务监控两部分。
服务器监控对接 komari,地址:https://komari.honor3.com/
# 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` 为运行时产物,不纳入版本管理。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表。
- 策略是放按照经济理论组织的相关数据和图表的对比。
## 首页
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
### 新闻模块
增加一个新闻模块,数据来源是每日夜间 3点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序10条。
存入数据库。
### 报警模块
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
## 监控
监控分为服务器监控和服务监控两部分。
服务器监控对接 komari,地址:https://komari.honor3.com/
## 前端 OTA 升级
只升级前端资源 (`dist/`), **不触碰** Tauri 框架/原生层, 不重打 APK.
底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap)
(签名/回滚/资源替换全部由插件处理).
### 工作流
```
开发者 服务端
npm run build ──┐
scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
│ 输出 latest.json (sequence/version/...)
▼
ota-package/ ── 部署到服务器 ──▶ /srv/iboard/ota-package
│
▼
GET /api/ota/check/<seq>
客户端 (Tauri) │
启动 ─▶ notifyReady() ▼
我页 ─▶ checkUpdate() ◀──── 200 + manifest (含 url/signature) ─────┘
applyUpdate() ─▶ 下载 tar.gz ─▶ minisign 校验 ─▶ 解压到 AppData/ota-dist
location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包
```
### 首次部署
1. **生成签名密钥** (一次性):
```bash
# 安装 minisign: Windows (scoop) / macOS (brew) / Linux (apt)
node scripts/ota-genkey.mjs
# 产物: ota-package/hotswap.{key, pub}
```
2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段
(脚本会打印).
3. **配置 endpoint** (`src-tauri/tauri.conf.json`):
```json
"hotswap": {
"endpoint": "https://your-server/api/ota/check/{{current_sequence}}",
"pubkey": "RWR...",
"require_https": true
}
```
4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录.
### 发布新版前端
```bash
npm run build # 生成 dist/
node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
# sequence 默认 = Date.now(), 也可 --sequence 43 手动指定
# 也可加 --notes "修复登录按钮" --min-binary 0.1.0
```
然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到
服务端 `OTA_PACKAGE_DIR` 指向的目录. sequence 单调递增即可.
### 客户端使用
打开 App 的"我"页, 点 [检查更新] → [立即升级] 即可. 失败会自动回滚到内置 dist.
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**.
> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物.
### Web 端
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
Web 端不会自动下载包, 也不会替换前端代码.
### CI 自动签名 (Gitea Actions)
.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流 (每次发版前点一次).
**配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets):
- MINISIGN_PRIVATE_KEY: 完整 .key 文件内容, **整段** (含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行), 整段作为多行 secret 粘贴即可.
**使用流程**:
1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow
2. 填 sequence (可选, 不填用时间戳) 和
otes (可选)
3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig
4. 下载 artifact, scp 到服务器 OTA_PACKAGE_DIR 目录
5. 客户端"我"页 [检查更新] 即可触发升级
**注意**:
- CI 用的私钥必须**与** auri.conf.json 里 plugins.hotswap.pubkey **配对**; 换密钥时需要同时更新 CI secret 和 conf.
- 不要把私钥 commit 到 git, 整个 ota-package/hotswap.key 已被 .gitignore 排除.
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
// 用法:
// node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
// 流程:
// 1) 校验 dist/ 已构建
// 2) 生成 bundle.tar.gz (dist/ 内容)
// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
// 5) 全部产物输出到 out 目录 (默认 ota-package/)
//
// minisign 安装: 见 readme "OTA 升级" 章节
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const args = process.argv.slice(2)
function getArg(name, def) {
const i = args.indexOf(name)
return i >= 0 && args[i + 1] ? args[i + 1] : def
}
const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
const MIN_BINARY = getArg('--min-binary', '')
const NOTES = getArg('--notes', '')
const NO_SIGN = args.includes('--no-sign')
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const APP_VERSION = pkg.version || '0.0.0'
const DIST = path.join(ROOT, 'dist')
if (!fs.existsSync(path.join(DIST, 'index.html'))) {
console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build')
process.exit(1)
}
// 准备输出目录
fs.mkdirSync(OUT_DIR, { recursive: true })
// 1) 生成 bundle.tar.gz
const BUNDLE_NAME = 'iboard-ota.tar.gz'
const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME)
const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' })
if (r.status !== 0) {
console.error('[build-ota] tar 打包失败')
process.exit(1)
}
const bundleStat = fs.statSync(BUNDLE_PATH)
const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex')
// 2) 签名 (minisign)
let signature = ''
let pubkey = ''
if (!NO_SIGN) {
// CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
let KEY_PATH_TMP = null
let KEY_PATH_USE = KEY_PATH
if (KEY_FROM_ENV) {
KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
KEY_PATH_USE = KEY_PATH_TMP
}
if (!fs.existsSync(KEY_PATH_USE)) {
console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
console.error(' 注入方式二选一:')
console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key <path>')
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
process.exit(1)
}
// minisign -Sm <file> -s <key> 生成 detached signature
const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE]
const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' })
// 清理 CI 临时私钥 (无论成功失败都删)
if (KEY_PATH_TMP) {
try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
KEY_PATH_TMP = null
}
if (sigR.status !== 0) {
console.error('[build-ota] minisign 签名失败')
process.exit(1)
}
const sigPath = BUNDLE_PATH + '.minisig'
signature = fs.readFileSync(sigPath, 'utf8').trim()
// 尝试同步 pubkey 到 tauri.conf.json (如果存在)
const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
if (fs.existsSync(PUB_PATH)) {
pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
const lines = pubkey.split(/\r?\n/)
pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
}
}
// 3) 写 latest.json
const minBinary = MIN_BINARY || (() => {
// 默认从 src-tauri/tauri.conf.json 读 version
try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION }
catch { return APP_VERSION }
})()
const manifest = {
version: APP_VERSION + '-ota',
sequence: SEQUENCE,
min_binary_version: minBinary,
bundle: BUNDLE_NAME,
bundle_size: bundleStat.size,
bundle_sha256: bundleSha256,
signature,
notes: NOTES,
pub_date: new Date().toISOString(),
mandatory: false,
// 内部用: 前端 web 模式比对 build_id
build_id: 'seq-' + SEQUENCE,
}
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
// 4) 提示
console.log('')
console.log('=== OTA 包生成完成 ===')
console.log('输出目录: ', OUT_DIR)
console.log('版本: ', manifest.version)
console.log('sequence: ', manifest.sequence)
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
console.log('bundle sha256:', bundleSha256)
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
if (pubkey) {
console.log('')
console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
console.log(' ' + pubkey)
}
console.log('')
console.log('下一步: 把这两个文件发布到 OTA 存储:')
console.log(' ' + BUNDLE_NAME)
console.log(' latest.json')
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# CI 专用: 为 Tauri Android release 构建注入 release 签名配置
# 在 `tauri android init` 之后、`tauri android build` 之前运行。
#
# 依赖 (由调用方注入环境变量, 不要写死密钥到脚本/仓库):
# ANDROID_KEYSTORE_B64 - base64 编码的 keystore 文件内容 (CI secret)
# ANDROID_KEY_ALIAS - 密钥别名 (CI secret/variable)
# ANDROID_KEY_PASSWORD - store & key 密码 (CI secret; 本脚本两者用同一个)
#
# 作用:
# 1. 把 base64 密钥解码写到 $RUNNER_TEMP/iboard-release.keystore
# 2. 生成 gen/android/keystore.properties (Tauri 签名约定读取此文件)
# 3. 修改 gen/android/app/build.gradle.kts, 添加 signingConfigs.release
# 并让 buildTypes.release 使用它, 产出已签名 APK
set -euo pipefail
APP_GRADLE="src-tauri/gen/android/app/build.gradle.kts"
echo "[signing] 注入 Android release 签名配置..."
# --- 1. 解码 keystore ---
if [ -z "${ANDROID_KEYSTORE_B64:-}" ]; then
echo "[signing] ERROR: ANDROID_KEYSTORE_B64 未设置, 无法签名 release APK" >&2
exit 1
fi
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS 未设置}"
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD 未设置}"
KEYSTORE_FILE="${RUNNER_TEMP:-/tmp}/iboard-release.keystore"
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "$KEYSTORE_FILE"
# --- 2. 写 keystore.properties (Tauri 官方约定的签名配置文件位置) ---
# 注意: 不能写进 git, 这是运行时生成物
KEYSTORE_PROPERTIES="src-tauri/gen/android/keystore.properties"
cat > "$KEYSTORE_PROPERTIES" <<EOF
keyAlias=${ANDROID_KEY_ALIAS}
password=${ANDROID_KEY_PASSWORD}
storeFile=${KEYSTORE_FILE}
EOF
# --- 3. 修改 build.gradle.kts 注入 signingConfigs ---
if [ ! -f "$APP_GRADLE" ]; then
echo "[signing] ERROR: 找不到 $APP_GRADLE (请先运行 tauri android init)" >&2
exit 1
fi
# 幂等: 已注入则跳过
if grep -q 'create("release")' "$APP_GRADLE" 2>/dev/null; then
echo "[signing] build.gradle.kts 已有 release signingConfigs, 跳过注入"
else
# 在 android { 块内 buildTypes { 之前插入 signingConfigs 块
# 用 Python 处理 Kotlin DSL, 对模板结构做最小侵入
python3 - "$APP_GRADLE" <<'PYEOF'
import sys, re
path = sys.argv[1]
src = open(path, encoding='utf-8').read()
# Gradle Kotlin DSL 默认 import java.util.* (Properties 短名可用), 但没有 java.io.*
# 因此顶部需补 import java.io.FileInputStream, 注入块内全部用短名
if 'import java.io.FileInputStream' not in src:
# 在第一个 import 之后插入; 若无 import 则插到文件顶部
imp = re.search(r'^import .*$', src, flags=re.M)
new_import = 'import java.io.FileInputStream\n'
if imp:
src = src[:imp.end()] + '\n' + new_import + src[imp.end():]
else:
src = new_import + src
# 定位 android { 块内的 buildTypes { (注意 Kotlin 嵌套花括号)
m = re.search(r'(\n[ \t]*)(buildTypes\s*\{)', src)
if not m:
print('[signing] 未找到 buildTypes 块, 中止', file=sys.stderr)
sys.exit(1)
indent = m.group(1) # 含换行, e.g. "\n "
inner_indent = indent + ' '
signing_block = (
f'{inner_indent}signingConfigs {{\n'
f'{inner_indent} create("release") {{\n'
f'{inner_indent} val keystorePropertiesFile = rootProject.file("keystore.properties")\n'
f'{inner_indent} val keystoreProperties = Properties()\n'
f'{inner_indent} if (keystorePropertiesFile.exists()) {{\n'
f'{inner_indent} keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n'
f'{inner_indent} }}\n'
f'{inner_indent} keyAlias = keystoreProperties["keyAlias"] as? String ?: ""\n'
f'{inner_indent} keyPassword = keystoreProperties["password"] as? String ?: ""\n'
f'{inner_indent} storeFile = file(keystoreProperties["storeFile"] as? String ?: "")\n'
f'{inner_indent} storePassword = keystoreProperties["password"] as? String ?: ""\n'
f'{inner_indent} }}\n'
f'{inner_indent}}}\n'
)
# 在 buildTypes { 前插入 signingConfigs 块
src = src.replace(m.group(0), signing_block + m.group(0), 1)
# 让 buildTypes 里的 release 使用该 signingConfig
rel = re.search(r'(\n[ \t]*)(getByName\("release"\)\s*\{)', src)
if not rel:
rel = re.search(r'(\n[ \t]*)(release\s*\{)', src)
if rel:
rel_indent = rel.group(1) + ' '
inject = rel_indent + 'signingConfig = signingConfigs.getByName("release")\n'
src = src.replace(rel.group(0), rel.group(0) + inject, 1)
open(path, 'w', encoding='utf-8').write(src)
print('[signing] build.gradle.kts 已注入 import + signingConfigs + release signingConfig')
PYEOF
fi
# --- 4. 放行明文 HTTP (release APK 默认拦截 http://, 而服务器仅支持 HTTP) ---
# Tauri 模板默认 debug=true, release=false。Android 9+ 会拦截 release 的所有 http:// 明文请求
# 导致 Failed to fetch。这里把 release 的 usesCleartextTraffic 改为 "true"。
# 幂等: 第二次运行时模板已被改为 "true", 替换无 false 可改, 也不会重复注入。
if [ ! -f "$APP_GRADLE" ]; then
echo "[signing] ERROR: 找不到 $APP_GRADLE (明文放行跳过)" >&2
exit 1
fi
python3 - "$APP_GRADLE" <<'PYEOF'
import sys, re
path = sys.argv[1]
src = open(path, encoding='utf-8').read()
# 1) 把显式的 usesCleartextTraffic = "false" 全部改为 "true"
new = re.sub(
r'(manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*)"false"',
r'\1"true"',
src,
)
# 2) 若模板根本没有 usesCleartextTraffic 配置, 则在 release 构建块内注入一行 true
if 'usesCleartextTraffic' not in src:
m = re.search(r'^([ \t]*)getByName\("release"\)\s*\{', new, flags=re.M)
if not m:
m = re.search(r'^([ \t]*)release\s*\{', new, flags=re.M)
if m:
inner = m.group(1) + ' '
inject = f'\n{inner}manifestPlaceholders["usesCleartextTraffic"] = "true"'
new = new.replace(m.group(0), m.group(0) + inject, 1)
else:
print('[signing] 警告: 未找到 release 构建块, 明文放行注入跳过', file=sys.stderr)
open(path, 'w', encoding='utf-8').write(new)
print('[signing] usesCleartextTraffic 已设为 true (release 放行明文 HTTP)')
PYEOF
echo "[signing] 完成: keystore.properties 已写, build.gradle.kts 已注入"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// 一次性导入浙江省油价历史 (2025-2026)
// 来源: 手工整理 (省发改委公告 / 潮新闻 / 钱江晚报 / 杭州日报 / 杭州网 等)
// 用法: node --experimental-strip-types --no-warnings scripts/import-oil-history.mjs [--dry]
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import 'dotenv/config'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
// 接入 prisma (用 file:// URL 避免 Windows 绝对路径 import 报错)
const adapterMod = await import('@prisma/adapter-mariadb')
const clientPath = pathToFileURL(path.join(ROOT, 'generated/prisma/client.ts')).href
const { PrismaClient } = await import(clientPath)
const adapter = new adapterMod.PrismaMariaDb((() => {
const u = new URL(process.env.DATABASE_URL)
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
}
})())
const prisma = new PrismaClient({ adapter })
// 数据: [调价日, 92, 95, 0, source]
const RAW = [
['2026-07-31', 7.94, 8.44, 7.62, 'manual'],
['2026-07-17', 7.39, 7.86, 7.06, 'manual'],
['2026-06-18', 7.91, 8.41, 7.59, 'manual'],
['2026-06-04', 8.32, 8.85, 8.02, 'manual'],
['2026-05-21', 8.74, 9.30, 8.45, 'manual'],
['2026-02-03', 6.91, 7.35, 6.56, 'manual'],
['2025-12-22', 6.68, 7.11, 6.32, 'manual'],
['2025-12-08', 6.82, 7.25, 6.46, 'manual'],
['2025-11-24', 6.86, null, null, 'manual'],
['2025-11-10', 6.92, 7.36, 6.57, 'manual'],
['2025-10-13', 7.03, 7.48, 6.68, 'manual'],
['2025-06-17', 7.15, 7.60, 6.81, 'manual'],
['2025-06-03', 6.94, 7.38, 6.59, 'manual'],
]
const PROVINCE = '浙江'
const FUEL_COLS = [
{ fuel: '92', idx: 1 },
{ fuel: '95', idx: 2 },
{ fuel: '0', idx: 3 },
]
const DRY = process.argv.includes('--dry')
async function main() {
console.log('=== 浙江省油价历史导入 ===')
console.log('dry run:', DRY)
console.log('')
let inserted = 0
let updated = 0
let skipped = 0
for (const row of RAW) {
const dateStr = row[0]
const source = row[4]
for (const { fuel, idx } of FUEL_COLS) {
const price = row[idx]
if (price == null || Number.isNaN(price)) {
console.log('[SKIP] ' + dateStr + ' ' + fuel + ': 价格为空')
skipped++
continue
}
const date = new Date(dateStr + 'T00:00:00')
if (DRY) {
console.log('[DRY] ' + dateStr + ' ' + fuel + ' = ' + price + ' (source=' + source + ')')
inserted++
continue
}
try {
await prisma.oilPriceHistory.create({
data: { date, province: PROVINCE, fuel, price, source },
})
console.log('[OK] ' + dateStr + ' ' + fuel + ' = ' + price)
inserted++
} catch (e) {
if (e?.code === 'P2002') {
try {
await prisma.oilPriceHistory.update({
where: { date_province_fuel: { date, province: PROVINCE, fuel } },
data: { price, source },
})
console.log('[UPD] ' + dateStr + ' ' + fuel + ' = ' + price)
updated++
} catch (e2) {
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e2.message)
}
} else {
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e.message)
}
}
}
}
console.log('')
console.log('=== 汇总 ===')
console.log('插入: ' + inserted)
console.log('更新: ' + updated)
console.log('跳过: ' + skipped)
await prisma.$disconnect()
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+36
View File
@@ -0,0 +1,36 @@
// 定时任务失败报警: 向 Alert 表插入一条错误记录
// 供 server/cron.js 与 scripts/scheduler/index.js 共用
// 首页报警 tab 会展示 Alert 表, 字段按错误语义填充 (indicatorName=任务+错误摘要)
import 'dotenv/config'
import mysql from 'mysql2/promise'
function parseUrl(url) {
const u = new URL(url)
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
}
export async function insertCronAlert(taskName, message) {
if (!process.env.DATABASE_URL) {
console.error('[cron-alert] 缺少 DATABASE_URL, 跳过报警写入')
return
}
let conn = null
try {
conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
// 用 Asia/Shanghai 时区的当天日期, 避免 UTC 偏移导致 period 显示成昨天
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
const cleanMsg = String(message || '').replace(/\s+/g, ' ').trim()
const name = ('[cron] ' + taskName + ' 失败: ' + cleanMsg).slice(0, 180)
await conn.execute(
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) ' +
'VALUES (?, ?, 0, 0, ?, ?, 0, 0, NOW(3))',
[name, today, 'cron', 'error'],
)
console.error('[cron-alert] 已写入报警:', name)
} catch (e) {
// 报警写入本身失败 (如 DB 不可达) 不能反噬主流程/再次抛错, 只记录
console.error('[cron-alert] 写入报警失败:', e.message)
} finally {
if (conn) await conn.end().catch(() => {})
}
}
+29
View File
@@ -0,0 +1,29 @@
// 手动跑一次米家采集 (备用): npm run data:mihome
// 带文件日志, 便于定位卡点
import fs from 'node:fs'
const LOG = 'D:\\workbench\\iboard\\server\\mihome\\.cache\\cli.log'
const log = (m) => fs.appendFileSync(LOG, new Date().toISOString() + ' ' + m + '\n')
process.on('uncaughtException', (e) => { log('uncaughtException: ' + (e.stack || e)); process.exit(1) })
process.on('unhandledRejection', (e) => { log('unhandledRejection: ' + (e && e.stack ? e.stack : String(e))); process.exit(1) })
log('start')
const m = await import('../server/mihome/index.js')
log('imported; calling dailyMaintenance')
try {
const r = await m.dailyMaintenance()
log('done: ' + JSON.stringify(r))
console.log('[data:mihome] 完成:', JSON.stringify(r))
} catch (e) {
if (e instanceof m.MihomeAuthError) {
log('auth error: ' + e.message)
console.log('[data:mihome] 米家授权不可用: ' + e.message)
console.log('[data:mihome] 请运行 npm run data:mihome:login 扫码授权')
process.exit(2)
}
log('failed: ' + (e && e.stack ? e.stack : String(e)))
console.error('[data:mihome] 失败:', e.message)
process.exit(1)
}
log('exit(0)')
process.exit(0)
+38
View File
@@ -0,0 +1,38 @@
// 手动扫码授权: npm run data:mihome:login
// 启动后台 QRlogin, 轮询状态; 出二维码后打印地址, 扫码成功后自动结束.
import { startQrLogin, getQrState, checkAuth } from '../server/mihome/client.js'
const auth = await checkAuth(true)
if (auth.status === 'ok') {
console.log('[米家] 已授权有效, 无需重扫 (本地 .cache 凭证可用)')
process.exit(0)
}
await startQrLogin()
const started = Date.now()
console.log('[米家] 等待扫码... (120s 超时)')
for (;;) {
const s = getQrState()
if (s.qrImageUrl && s.state === 'waiting') {
console.log('[米家] 二维码(需米家 App 扫):')
console.log(' image: ' + s.qrImageUrl)
}
if (s.state === 'failed') {
console.log('[米家] 扫码失败: ' + (s.lastError || '未知'))
process.exit(2)
}
if (s.state === 'idle' && s.running === false && !s.qrImageUrl) {
// 启动即失败(可能直接完成/或没权限)
console.log('[米家] 流程结束(无二维码产生), 请检查日志')
process.exit(2)
}
if (!s.running && s.qrImageUrl) {
console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/')
process.exit(0)
}
if (Date.now() - started > 150000) {
console.log('[米家] 超时, 已停止; 可重跑.')
process.exit(3)
}
await new Promise((r) => setTimeout(r, 2000))
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
// 生成 minisign 密钥对 (ota-package/hotswap.{key,pub})
// 首次跑一遍: node scripts/ota-genkey.mjs
// - hotswap.key: 私钥, 不要提交, 部署时由 CI 注入 (环境变量 / secret)
// - hotswap.pub: 公钥, 写到 src-tauri/tauri.conf.json 的 plugins.hotswap.pubkey
//
// 依赖: minisign (https://jedisct1.github.io/minisign/)
// - Windows: scoop install minisign 或 choco install minisign
// - macOS: brew install minisign
// - Linux: apt install minisign
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const OUT_DIR = path.join(ROOT, 'ota-package')
fs.mkdirSync(OUT_DIR, { recursive: true })
const KEY = path.join(OUT_DIR, 'hotswap.key')
const PUB = path.join(OUT_DIR, 'hotswap.pub')
if (fs.existsSync(KEY) || fs.existsSync(PUB)) {
console.error('[ota-genkey] 已存在密钥, 如需重新生成请先删除 ota-package/hotswap.{key,pub}')
console.error(' 注意: 重新生成密钥会导致旧 bundle 无法被客户端验证, 慎用!')
process.exit(1)
}
// 检查 minisign 是否存在
const which = spawnSync('minisign', ['-v'], { stdio: 'pipe' })
if (which.status !== 0 && which.error) {
console.error('[ota-genkey] 未找到 minisign 命令, 请先安装:')
console.error(' Windows: scoop install minisign')
console.error(' macOS: brew install minisign')
console.error(' Linux: apt install minisign')
process.exit(1)
}
// -G: 生成 -W: 无密码 -p: 公钥输出 -s: 私钥输出
const r = spawnSync('minisign', ['-G', '-W', '-p', PUB, '-s', KEY], { stdio: 'inherit' })
if (r.status !== 0) {
console.error('[ota-genkey] minisign 生成失败')
process.exit(1)
}
console.log('\n=== 密钥生成完成 ===')
console.log('私钥: ' + KEY + ' (不要提交到 git!)')
console.log('公钥: ' + PUB)
const pubText = fs.readFileSync(PUB, 'utf8')
const lines = pubText.split(/\r?\n/)
const pubB64 = lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ'))
console.log('\n请把下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
console.log(' ' + (pubB64 || '').trim())
// 自动写 .gitignore 提醒
const gitignore = path.join(ROOT, '.gitignore')
if (fs.existsSync(gitignore)) {
let txt = fs.readFileSync(gitignore, 'utf8')
if (!txt.includes('ota-package/hotswap.key')) {
if (!txt.endsWith('\n')) txt += '\n'
txt += '\n# OTA 升级私钥, 不要提交\nota-package/hotswap.key\n'
fs.writeFileSync(gitignore, txt)
console.log('\n[ota-genkey] 已把 ota-package/hotswap.key 加入 .gitignore')
}
}
+11 -8
View File
@@ -1,13 +1,14 @@
import cron from 'node-cron';
import { trigger as triggerUSDCNYRate } from '../scraper/usd-cny-rate.scraper.js';
import { trigger as triggerSH300Data } from '../scraper/sh300.scraper.js';
import { insertCronAlert } from '../lib/cron-alert.js';
console.log('='.repeat(70));
console.log('🚀 启动定时任务调度器');
console.log('='.repeat(70));
// 注册定时任务 - USD-CNY 汇率数据
const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
// 注册定时任务 - USD-CNY 汇率数据 (每天 6:20, 出错生成报警)
const jobUSDCNY = cron.schedule('20 6 * * *', async () => {
console.log('\n' + '='.repeat(70));
console.log('⏰ 执行定时任务: USD-CNY 汇率数据更新');
console.log('📅 执行时间:', new Date().toISOString());
@@ -18,6 +19,7 @@ const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
console.log('\n✅ 任务执行成功!');
} catch (error) {
console.error('\n❌ 任务执行失败:', error.message);
await insertCronAlert('USD-CNY 汇率更新', error.message);
} finally {
console.log('='.repeat(70));
}
@@ -26,8 +28,8 @@ const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
timezone: 'Asia/Shanghai'
});
// 注册定时任务 - 沪深300指数数据
const jobSH300 = cron.schedule('0 0 * * *', async () => {
// 注册定时任务 - 沪深300指数数据 (每天 6:30, 出错生成报警)
const jobSH300 = cron.schedule('30 6 * * *', async () => {
console.log('\n' + '='.repeat(70));
console.log('⏰ 执行定时任务: 沪深300指数数据更新');
console.log('📅 执行时间:', new Date().toISOString());
@@ -38,6 +40,7 @@ const jobSH300 = cron.schedule('0 0 * * *', async () => {
console.log('\n✅ 任务执行成功!');
} catch (error) {
console.error('\n❌ 任务执行失败:', error.message);
await insertCronAlert('沪深300指数更新', error.message);
} finally {
console.log('='.repeat(70));
}
@@ -47,12 +50,12 @@ const jobSH300 = cron.schedule('0 0 * * *', async () => {
});
console.log('✅ 定时任务已启动: USD-CNY 汇率数据更新');
console.log('📅 执行表达式: 0 0 * * * (每天 0 点)');
console.log('📅 下次执行时间: 明天 00:00');
console.log('📅 执行表达式: 20 6 * * * (每天 6:20)');
console.log('📅 下次执行时间: 明天 06:20');
console.log('');
console.log('✅ 定时任务已启动: 沪深300指数数据更新');
console.log('📅 执行表达式: 0 0 * * * (每天 0 点)');
console.log('📅 下次执行时间: 明天 00:00');
console.log('📅 执行表达式: 30 6 * * * (每天 6:30)');
console.log('📅 下次执行时间: 明天 06:30');
console.log('');
console.log('按 Ctrl+C 停止...');
console.log('='.repeat(70));
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
// 将 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
// 传输层: MCP streamable HTTP (POST /mcp), 认证 Bearer <REGISTRY_MCP_TOKEN>
// 详见 workflow: .gitea/workflows/sync-registry.yml
//
// 用法:
// REGISTRY_MCP_URL=http://bh.vps.honor3.com:8008/mcp \
// REGISTRY_MCP_TOKEN=<MCP 访问令牌> \
// REGISTRY_PROJECT_ID=proj-msid70ql-6xbq4w \
// node scripts/sync-registry.mjs [--dry-run]
//
// 安全: 本脚本不会把任何 secret 的值打印到日志, 只输出 键名 + 结果。
// 注意: 当前通过明文 http 传输, 请确保注册中心仅在内网可达 (或改用 https)。
const MCP_URL = process.env.REGISTRY_MCP_URL || 'http://bh.vps.honor3.com:8008/mcp'
const MCP_TOKEN = process.env.REGISTRY_MCP_TOKEN || ''
const PROJECT_ID = process.env.REGISTRY_PROJECT_ID || 'proj-msid70ql-6xbq4w'
const IDC = process.env.REGISTRY_IDC || '1'
const ENV = process.env.REGISTRY_ENVIRONMENT || '1'
const DRY_RUN = process.argv.includes('--dry-run')
// CI 环境变量 -> 注册中心配置键 (key 支持 / 嵌套, 本项目分组映射为 path => group/key)
const SECRET_MAP = [
{ env: 'MINISIGN_PRIVATE_KEY', key: 'minisign.privateKey', group: 'secret', type: 'string', description: 'minisign 签名 OTA 升级包的私钥' },
{ env: 'DATABASE_URL', key: 'database.url', group: 'secret', type: 'string', description: 'MySQL 连接串' },
{ env: 'JWT_SECRET', key: 'jwt.secret', group: 'secret', type: 'string', description: 'JWT 签名密钥' },
{ env: 'ANDROID_KEYSTORE_B64', key: 'android.keystore.b64', group: 'secret', type: 'string', description: 'Android release keystore (base64)' },
{ env: 'ANDROID_KEY_ALIAS', key: 'android.key.alias', group: 'secret', type: 'string', description: 'Android keystore 别名' },
{ env: 'ANDROID_KEY_PASSWORD', key: 'android.key.password', group: 'secret', type: 'string', description: 'Android keystore 密码' },
{ env: 'PACKAGE_TOKEN', key: 'package.token', group: 'secret', type: 'string', description: 'Gitea Generic Package 写权限 token' },
]
if (!MCP_TOKEN) {
console.error('缺少 REGISTRY_MCP_TOKEN (MCP 访问令牌)')
process.exit(2)
}
let sessionId = null
let nextId = 0
function parseSse(text) {
let data = ''
for (const line of text.split(/\r?\n/)) {
if (line.startsWith('data:')) data += line.slice(5).trim()
}
if (!data) throw new Error(`MCP 响应没有 data 字段: ${text.slice(0, 200)}`)
const obj = JSON.parse(data)
if (obj.error) {
const e = new Error(`${obj.error.code}: ${obj.error.message}`)
e.mcpError = obj.error
throw e
}
return obj
}
async function rpc(method, params) {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
}
if (MCP_TOKEN) headers.Authorization = `Bearer ${MCP_TOKEN}`
if (sessionId) headers['mcp-session-id'] = sessionId
const res = await fetch(MCP_URL, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: ++nextId, method, params }),
})
const sid = res.headers.get('mcp-session-id')
if (sid) sessionId = sid
const text = await res.text()
return parseSse(text)
}
async function callTool(name, args) {
const payload = await rpc('tools/call', { name, arguments: args })
if (payload.result?.isError) {
throw new Error(`工具 ${name} 执行失败: ${JSON.stringify(payload.result.content || {})}`)
}
const sc = payload.result?.structuredContent
if (sc !== undefined && sc !== null) return sc
const content = payload.result?.content || []
const text = content.map((c) => c.text || '').join('\n').trim()
if (text) {
try { return JSON.parse(text) } catch { return { text } }
}
return payload.result || {}
}
async function main() {
console.log(`== 同步 CI secrets -> 注册中心 (project=${PROJECT_ID})${DRY_RUN ? ' [dry-run]' : ''}`)
// 1) initialize 拿 mcp-session-id (rpc 内部已缓存 header)
await rpc('initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'iboard-ci', version: '1.0.0' },
})
// 2) 记录当前模式, 非 edit 则临时切到 edit (写 value 需要), 结束后恢复
const mode = (await callTool('get_mode', {})).mode
console.log(`当前模式: ${mode}`)
const needEdit = mode !== 'edit'
if (needEdit) {
if (DRY_RUN) {
console.log('(dry-run) 待切换到 edit 模式')
} else {
await callTool('set_mode', { mode: 'edit' })
console.log('已临时切换为 edit 模式')
}
}
try {
// 3) 拉取现有配置, 键为 group/idc/environment/key
const list = await callTool('list_configs', { projectId: PROJECT_ID })
const configs = Array.isArray(list.configs) ? list.configs : list
const existing = new Map()
for (const c of configs || []) {
if (!c || !c.key) continue
existing.set(`${c.idc || ''}|${c.environment || ''}|${c.group || ''}|${c.key}`, c)
}
// 4) 逐个 upsert
let updated = 0
let created = 0
let skipped = 0
for (const { env, key, group, type, description } of SECRET_MAP) {
const value = process.env[env]
if (value === undefined || value === '') {
console.log(`- [skip] ${group}/${key} <- 环境变量 ${env} 未设置`)
skipped++
continue
}
const found = existing.get(`${IDC}|${ENV}|${group}|${key}`)
const target = `${group}/${key}`
if (found) {
if (DRY_RUN) {
console.log(`- [dry-run] update ${target} (cfg ${found.id}) <- ${env}`)
} else {
await callTool('update_config', { projectId: PROJECT_ID, configId: found.id, value })
console.log(`- [update] ${target} (cfg ${found.id}) <- ${env}`)
}
updated++
} else {
if (DRY_RUN) {
console.log(`- [dry-run] create ${target} <- ${env}`)
} else {
await callTool('create_config', {
projectId: PROJECT_ID,
key,
value,
type,
description,
idc: IDC,
environment: ENV,
group,
})
console.log(`- [create] ${target} <- ${env}`)
}
created++
}
}
console.log(`\n== 完成: ${DRY_RUN ? 'dry-run' : '写入'} ${updated} 更新 / ${created} 新建 / ${skipped} 跳过 (值不回显)`)
} finally {
// 5) 恢复原模式 (本次未实际切模式时无操作)
if (needEdit && !DRY_RUN) {
await callTool('set_mode', { mode })
console.log(`已恢复模式: ${mode}`)
}
}
}
main().catch((err) => {
console.error(`\n同步失败: ${err.message}`)
process.exit(1)
})
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# 临时验证脚本: 只编 armv7 一个 target, 验证 cc-rs 的 NDK 命名问题已修复 (用完即删)
set -euo pipefail
export ANDROID_HOME="$HOME/android-sdk"
export ANDROID_NDK_HOME="$HOME/android-sdk/ndk/27.2.12479018"
export PATH="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$HOME/.cargo/bin:$PATH"
cd /mnt/d/workbench/iboard/src-tauri
cargo build --target armv7-linux-androideabi --features custom-protocol --lib --release 2>&1 | tail -30
+46
View File
@@ -0,0 +1,46 @@
// apizero 接口的文件缓存
// - 按 key 存 JSON 到磁盘: <APIZERO_CACHE_DIR>/<key>.json
// - 存在性检查: 文件在即视为有效 (用户要求: "同一个年份只要存在就不再调用接口")
// - 不做 TTL: 用户没要求, 也避免误命中过期数据
//
// 环境变量:
// APIZERO_CACHE_DIR 缓存目录 (默认 <cwd>/cache/apizero)
//
// 未来若要加 TTL / DB 后端, 替换本文件即可, 上层调用方不变.
import fs from 'node:fs'
import path from 'node:path'
function dir() {
return process.env.APIZERO_CACHE_DIR
? path.resolve(process.env.APIZERO_CACHE_DIR)
: path.resolve(process.cwd(), 'cache', 'apizero')
}
// 读缓存, 不存在返回 null
export function read(key) {
const p = path.join(dir(), key + '.json')
if (!fs.existsSync(p)) return null
try {
const raw = fs.readFileSync(p, 'utf8')
return JSON.parse(raw)
} catch (e) {
console.warn(`[apizero-cache] 读 ${key} 失败, 忽略:`, e.message)
return null
}
}
// 写缓存 (原子: 先写 .tmp 再 rename)
export function write(key, data) {
const d = dir()
fs.mkdirSync(d, { recursive: true })
const final = path.join(d, key + '.json')
const tmp = final + '.tmp.' + process.pid
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8')
fs.renameSync(tmp, final)
return final
}
// 存在性检查
export function has(key) {
return fs.existsSync(path.join(dir(), key + '.json'))
}
+77
View File
@@ -0,0 +1,77 @@
// apizero (https://apizero.cn) 通用 HTTP 客户端
// - 统一从 env APIZERO_KEY 读取 API key (test key 也能用)
// - 统一 QPS 节流 (apizero 限制 3 req/s)
// - 统一错误格式: 抛出 ApizeroError(code, message, httpStatus)
// - 所有 apizero 接口都进这一层, 后续新增模块复用
//
// 依赖: 原生 fetch (Node 18+)
// 简易令牌桶, 限速 3 req/s
let lastCallAt = 0
const MIN_INTERVAL_MS = 1000 / 3 // ~333ms
async function throttle() {
const now = Date.now()
const wait = lastCallAt + MIN_INTERVAL_MS - now
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
lastCallAt = Date.now()
}
// apizero 标准错误
export class ApizeroError extends Error {
constructor(code, message, httpStatus) {
super(message || `apizero error code=${code}`)
this.name = 'ApizeroError'
this.code = code
this.httpStatus = httpStatus
}
}
const BASE_URL = 'https://v1.apizero.cn/api'
// 读 key: 优先 env APIZERO_KEY, 否则用空 (匿名调用, 配额更小)
function getKey() {
return process.env.APIZERO_KEY || ''
}
// 通用 GET 入口
// - endpoint: 'oil-price-forecast' (不带 /api 前缀)
// - params: 普通对象
// 返回: apizero 的 data 字段 (已解一层)
export async function get(endpoint, params = {}) {
await throttle()
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
}
const key = getKey()
if (key) qs.set('key', key)
const url = `${BASE_URL}/${endpoint}?${qs.toString()}`
let res
try {
res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } })
} catch (e) {
throw new ApizeroError('NETWORK', `apizero 网络错误: ${e.message}`, 0)
}
// HTTP 层错误
if (!res.ok) {
let body = ''
try { body = await res.text() } catch { /* ignore */ }
throw new ApizeroError('HTTP', `apizero HTTP ${res.status}: ${body.substring(0, 200)}`, res.status)
}
// 解析 body
let json
try { json = await res.json() }
catch (e) { throw new ApizeroError('PARSE', 'apizero 响应非 JSON: ' + e.message, res.status) }
// apizero 业务层错误: code !== 0
if (json && typeof json.code === 'number' && json.code !== 0) {
throw new ApizeroError(json.code, json.msg || 'apizero 业务错误', res.status)
}
return json && json.data !== undefined ? json.data : json
}
+6
View File
@@ -0,0 +1,6 @@
// apizero 模块统一出口
// 后续新增 apizero 接口就在这里再 export 一行
export * as oil from './oil-price-forecast.js'
export * as history from './oil-history.js'
export { ApizeroError } from './client.js'
+87
View File
@@ -0,0 +1,87 @@
// 浙江省油价历史: 走 Prisma 落库 + apizero price 接口写入
// 设计:
// - 每天 04:00 cron 检查 schedule, 待定且 <= 昨天的调价日 -> 抓 price?province=浙江 存本表
// - unique (date, province, fuel) 防重复
// - 查询 API: 取某省某油品最近 N 天 (默认 180)
import { prisma } from '../db.js'
import * as client from './client.js'
const PROVINCE = '浙江'
// 油品 -> apizero prices 对象里的 key (price action 返回 "92号汽油" 这种带后缀的 key)
const FUEL_KEYS = {
'92': '92号汽油',
'95': '95号汽油',
'0': '0号柴油',
}
// 解析 apizero price action 返回的 prices (中文 key) -> 标准 fuel code
function parsePrice(data) {
if (!data || !data.prices) throw new Error('apizero 响应无 prices 字段')
const out = {}
for (const [fuel, label] of Object.entries(FUEL_KEYS)) {
const raw = data.prices[label]
if (typeof raw === 'string') {
// "7.64 元/升" -> 7.64
const m = raw.match(/^([\d.]+)/)
if (m) out[fuel] = parseFloat(m[1])
} else if (typeof raw === 'number') {
out[fuel] = raw
}
}
return out
}
// 抓一次浙江当前价并入库 (date = 今日)
// 返回 { inserted, skipped } (按 unique 约束: 已存在则 skip)
export async function fetchAndStore() {
const data = await client.get('oil-price-forecast', { action: 'price', province: PROVINCE })
const prices = parsePrice(data)
if (Object.keys(prices).length === 0) {
throw new Error('apizero 返回价格为空')
}
const date = new Date()
// 抹掉时分秒, 便于按天比较
date.setHours(0, 0, 0, 0)
let inserted = 0
let skipped = 0
for (const [fuel, price] of Object.entries(prices)) {
try {
await prisma.oilPriceHistory.create({
data: { date, province: PROVINCE, fuel, price, source: 'apizero' },
})
inserted++
} catch (e) {
// P2002: unique 冲突 -> 跳过
if (e?.code === 'P2002') skipped++
else throw e
}
}
return { inserted, skipped, date: date.toISOString().slice(0, 10) }
}
// 查询某省某油品最近 N 天
// province 必填, 默认 浙江
// fuel 可选, 留空返回 4 个油品
// days 默认 180
export async function query({ province = PROVINCE, fuel, days = 180 }) {
const since = new Date()
since.setDate(since.getDate() - days)
since.setHours(0, 0, 0, 0)
const where = { province, date: { gte: since } }
if (fuel) where.fuel = fuel
const rows = await prisma.oilPriceHistory.findMany({
where,
orderBy: [{ date: 'asc' }],
})
// 整形: Decimal -> number
return rows.map((r) => ({
date: r.date.toISOString().slice(0, 10),
province: r.province,
fuel: r.fuel,
price: Number(r.price),
}))
}
+41
View File
@@ -0,0 +1,41 @@
// apizero: 今日油价 (oil-price-forecast)
// 4 个 action:
// forecast - 油价 + 国际原油 + 下次调价预测 (每次都查实时, 不缓存)
// price - 单省油价
// price-all - 32 省全部油价
// schedule - 某年调价日历 (按 year 缓存到文件, 同年份不重复调)
//
// 文档: https://apizero.cn/aidocs/oil-price-forecast/raw.md
import * as client from './client.js'
import * as cache from './cache.js'
// 调价日历: 按年份缓存, 文件存在即视为有效, 不重复调用
// cache key 形如 "oil-schedule-2026"
export async function getSchedule(year) {
const y = Number(year)
if (!Number.isInteger(y) || y < 2025 || y > 2026) {
throw new client.ApizeroError(4000, `year 不在 2025-2026 范围: ${year}`, 400)
}
const cacheKey = `oil-schedule-${y}`
const hit = cache.read(cacheKey)
if (hit) return { ...hit, _cached: true }
const data = await client.get('oil-price-forecast', { action: 'schedule', year: y })
cache.write(cacheKey, data)
return { ...data, _cached: false }
}
// 实时: 国际原油 + 下次调价预测
export async function getForecast() {
return client.get('oil-price-forecast', { action: 'forecast' })
}
// 单省油价
export async function getPrice(province) {
if (!province) throw new client.ApizeroError(4000, 'province 必填', 400)
return client.get('oil-price-forecast', { action: 'price', province })
}
// 32 省全部油价
export async function getPriceAll() {
return client.get('oil-price-forecast', { action: 'price-all' })
}
+64 -6
View File
@@ -1,8 +1,24 @@
import cron from 'node-cron'
import { prisma } from './db.js'
import mysql from 'mysql2/promise'
import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
import { checkAfterRefresh } from './alert-engine.js'
import { ApizeroError, oil, history } from './apizero/index.js'
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
async function importNewsFetcher() {
const mod = await import('../scripts/fetchers/news.fetcher.js')
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
}
async function alertError(taskName, message) {
try {
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
await insertCronAlert(taskName, message)
} catch (e) {
console.error('[cron] 报警写入失败(忽略):', e.message)
}
}
function parseUrl(url) {
const u = new URL(url)
@@ -13,11 +29,12 @@ async function openConn() {
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
}
// 每天凌晨 3:00 (本地时间) 拉取新闻 + 跑跨源对比
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
function scheduleNews() {
cron.schedule('0 3 * * *', async () => {
console.log('[cron] 3:00 触发')
cron.schedule('0 6 * * *', async () => {
console.log('[cron] 6:00 触发')
try {
const { fetchNewsList, persistNews } = await importNewsFetcher()
const list = await fetchNewsList()
const r = await persistNews(list)
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
@@ -28,9 +45,50 @@ function scheduleNews() {
} finally { await conn.end() }
} catch (e) {
console.error('[cron] 任务失败:', e.message)
await alertError('新闻拉取+报警检测', e.message)
}
})
console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测')
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
}
// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
// - 0 命中: 不调 apizero (省配额)
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
function scheduleOilHistory() {
cron.schedule('10 6 * * *', async () => {
console.log('[cron] 6:10 油价历史触发')
try {
const year = new Date().getFullYear()
const sched = await oil.getSchedule(year)
const list = sched.schedule || []
// 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
const today = new Date(todayStr + 'T00:00:00+08:00')
const yesterdayMs = today.getTime() - 86400000
const pendingPast = list.filter((it) => {
if (it.status !== '待定') return false
const t = new Date(it.date + 'T00:00:00').getTime()
return Number.isFinite(t) && t <= yesterdayMs
})
console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
if (pendingPast.length === 0) {
console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
return
}
const r = await history.fetchAndStore()
console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
} catch (e) {
if (e instanceof ApizeroError) {
console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
} else {
console.error('[cron] 油价历史任务失败:', e.message)
}
await alertError('浙江油价历史抓取', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
}
scheduleNews()
scheduleOilHistory()
+13 -1
View File
@@ -8,7 +8,10 @@ import dataRouter from './routes/data.js'
import newsRouter from './routes/news.js'
import alertsRouter from './routes/alerts.js'
import articlesRouter from './routes/articles.js'
import otaRouter from './routes/ota.js'
import apizeroRouter from './routes/apizero.js'
import monitorsRouter from './routes/monitors.js'
import mihomeRouter from './routes/mihome.js'
const PORT = Number(process.env.PORT) || 3001
const DIST_DIR = path.join(process.cwd(), 'dist')
@@ -23,11 +26,16 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
// 业务路由
app.use('/api/auth', authRouter)
// OTA 接口是公开的 (检查/下载更新不需要登录), 必须挂载在带 authRequired 的 router 之前,
// 否则 /api/ota/* 请求会先进入 dataRouter 等并被 authRequired 以 401 拦截
app.use('/api', otaRouter)
app.use('/api', dataRouter)
app.use('/api', newsRouter)
app.use('/api', alertsRouter)
app.use('/api', articlesRouter)
app.use('/api', apizeroRouter)
app.use('/api', monitorsRouter)
app.use('/api/mihome', mihomeRouter)
// 静态资源 (Vite build 产物)
if (HAS_DIST) {
@@ -50,7 +58,11 @@ app.use((err, _req, res, _next) => {
// 启动定时任务: 每天凌晨 3 点拉取新闻
import('./cron.js').catch((e) => console.error('[cron] 加载失败:', e.message))
// 米家环境监控定时调度 (独立注册, 不与 cron.js 绑定, 见 server/mihome/scheduler.js)
import('./mihome/scheduler.js').catch((e) => console.error('[cron] 米家调度注册失败:', e.message))
app.listen(PORT, () => {
console.log(`[iboard-api] listening on http://127.0.0.1:${PORT}`)
console.log(`[iboard-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
})
})
+10
View File
@@ -0,0 +1,10 @@
// 米家凭证缓存目录: 认证 token 只落本地磁盘, 不进库不进 git
// 环境变量:
// MIHOME_CACHE_DIR 覆盖 (默认 <cwd>/server/mihome/.cache)
import path from 'node:path'
export function cacheDir() {
return process.env.MIHOME_CACHE_DIR
? path.resolve(process.env.MIHOME_CACHE_DIR)
: path.resolve(process.cwd(), 'server', 'mihome', '.cache')
}
+112
View File
@@ -0,0 +1,112 @@
// 米家客户端单例 + 认证状态 + 扫码流程状态机
import { MijiaAPI } from '@zythum02/mijia-api'
import { DiskCache } from '@zythum02/mijia-api/cache/disk-cache'
import { prisma } from '../db.js'
import { cacheDir } from './cache.js'
// 未授权/已过期 (模块内部错误, 路由层做 4xx 映射)
export class MihomeAuthError extends Error {
constructor(message = '米家未授权或已过期') {
super(message)
this.name = 'MihomeAuthError'
}
}
let _api = null
// 惰性单例: 首次调用时才构造并加载磁盘凭证
export function getApi() {
if (!_api) _api = new MijiaAPI(new DiskCache(cacheDir()))
return _api
}
// 认证状态内存缓存 (5 分钟 TTL, /status 不每次打米家账号服务)
let authCache = { status: 'unknown', lastError: '', checkedAt: 0 }
function persistAccount(status, lastError) {
return prisma.mihomeAccount
.upsert({
where: { key: 'default' },
create: { key: 'default', status, lastError: lastError || '' },
update: { status, lastError: lastError || '' },
})
.catch((e) => {
console.warn('[mihome] 写入授权状态失败(忽略):', e.message)
})
}
// 校验米家是否可用: 有凭证 + 能刷新 token (刷新会自动处理 30 天过期)
// force=true 忽略 TTL 强查
export async function checkAuth(force = false) {
const now = Date.now()
if (!force && now - authCache.checkedAt < 5 * 60 * 1000) return authCache
const api = getApi()
let status = 'expired'
let lastError = ''
try {
if (!api.available) throw new Error('无授权凭证')
await api.refreshToken()
status = 'ok'
} catch (e) {
status = 'expired'
lastError = e?.message || String(e)
}
authCache = { status, lastError, checkedAt: now }
await persistAccount(status, lastError)
return authCache
}
// ==================== 扫码重授权流程 (模块级单例) ====================
// 状态: idle | waiting(已出二维码等扫码) | failed
// 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB
const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
export function getQrState() {
return { ...qrState }
}
export function getQrImageUrl() {
return qrState.qrImageUrl || ''
}
export async function startQrLogin() {
if (qrState.running) return getQrState()
const api = getApi()
qrState.running = true
qrState.canceled = false
qrState.lastError = ''
qrState.qrImageUrl = ''
qrState.startedAt = Date.now()
;(async () => {
try {
await api.QRlogin(async (info) => {
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
qrState.state = 'waiting'
})
if (qrState.canceled) return
qrState.state = 'idle'
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
await persistAccount('ok', '')
} catch (e) {
if (qrState.canceled) {
qrState.state = 'idle'
} else {
qrState.state = 'failed'
qrState.lastError = e?.message || String(e)
}
} finally {
qrState.running = false
}
})()
return getQrState()
}
export function cancelQrLogin() {
qrState.canceled = true
if (qrState.running) {
// 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果
} else if (qrState.state !== 'failed') {
qrState.state = 'idle'
}
return getQrState()
}
+285
View File
@@ -0,0 +1,285 @@
// 采集器: 家庭/设备发现 + 每小时采样 + 电量 + 日聚合 + vendor 补齐编排
import { prisma } from '../db.js'
import { checkAuth, getApi, MihomeAuthError } from './client.js'
import { getSpec } from './specs.js'
import { dayStart, hourStart, addDays } from './time.js'
import * as vendorStats from './vendorStats.js'
const BACKFILL_DAYS = 45 // 停机/首次接入时, 云端统计补齐窗口
const round2 = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : null)
// 互斥锁: 同一时刻只允许一个同步在跑 (cron 与手动/启动触发并发时合并)
let runLock = null
function withLock(fn) {
if (runLock) return runLock
runLock = Promise.resolve()
.then(fn)
.finally(() => {
runLock = null
})
return runLock
}
// ==================== 设备发现 ====================
async function syncHomes() {
const api = getApi()
const homes = await api.getHomesList()
for (const h of homes) {
const homeId = String(h.id)
await prisma.mihomeHome.upsert({
where: { homeId },
create: { homeId, name: h.name || homeId },
update: { name: h.name || homeId },
})
}
return homes.length
}
async function syncDevices() {
const api = getApi()
const list = await api.getDevicesList()
let kept = 0
for (const d of list) {
const spec = getSpec(d.model)
if (!spec) continue // 非环境设备白名单, 跳过
const homeId = String(d.home_id || '')
if (!homeId) continue
const data = {
homeId,
model: d.model,
name: String(d.name || d.did),
tempSiid: spec.temp[0],
tempPiid: spec.temp[1],
humSiid: spec.hum[0],
humPiid: spec.hum[1],
battSiid: spec.batt[0],
battPiid: spec.batt[1],
}
await prisma.mihomeDevice.upsert({
where: { did: d.did },
create: { did: d.did, ...data },
update: data,
})
kept++
}
return kept
}
async function listEnabledDevices() {
return prisma.mihomeDevice.findMany({ where: { enabled: true } })
}
// ==================== 采样 ====================
// 每小时采样: 批量读温湿度 -> upsert 小时档
async function sampleHour(devices) {
const api = getApi()
const bucket = hourStart()
const props = []
for (const d of devices) {
props.push({ did: d.did, siid: d.tempSiid, piid: d.tempPiid })
props.push({ did: d.did, siid: d.humSiid, piid: d.humPiid })
}
if (props.length === 0) return 0
const results = []
try {
const res = await api.getDevicesProp(props)
results.push(...(Array.isArray(res) ? res : [res]))
} catch (e) {
// 整体失败: 不写入任何小时档, 缺档留给整点补采/vendor 补齐, 本次直接抛给上层记录
throw e
}
// did -> { temp, hum }
const byDevice = new Map(devices.map((d) => [d.did, d]))
const perDevice = new Map()
for (const r of results) {
if (!r || !r.did) continue
const ok = Number(r.code) === 0 && r.value !== undefined && r.value !== null
if (!ok) continue
const d = byDevice.get(r.did)
if (!d) continue
const cur = perDevice.get(r.did) || {}
if (r.siid === d.tempSiid && r.piid === d.tempPiid) {
cur.temperatureAvg = round2(r.value)
} else if (r.siid === d.humSiid && r.piid === d.humPiid) {
cur.humidityAvg = round2(r.value)
}
perDevice.set(r.did, cur)
}
let written = 0
for (const d of devices) {
const v = perDevice.get(d.did)
if (!v || (v.temperatureAvg === undefined && v.humidityAvg === undefined)) continue
await prisma.mihomeEnvHour.upsert({
where: { deviceId_hour: { deviceId: d.id, hour: bucket } },
create: {
deviceId: d.id,
hour: bucket,
temperatureAvg: v.temperatureAvg ?? null,
humidityAvg: v.humidityAvg ?? null,
raw: null, // 自采
},
update: {
temperatureAvg: v.temperatureAvg ?? null,
humidityAvg: v.humidityAvg ?? null,
raw: null,
},
})
await prisma.mihomeDevice.update({
where: { id: d.id },
data: { lastSyncAt: new Date() },
})
written++
}
return written
}
// 电量: 每天读一次 (batteryAt 不在今天就刷新), 标题展示用
async function refreshBattery(devices) {
const api = getApi()
const today = dayStart()
for (const d of devices) {
const isFresh = d.batteryAt && d.batteryAt.getTime() >= today.getTime()
if (isFresh) continue
try {
const res = await api.getDevicesProp({ did: d.did, siid: d.battSiid, piid: d.battPiid })
if (res && Number(res.code) === 0 && typeof res.value === 'number') {
await prisma.mihomeDevice.update({
where: { id: d.id },
data: { battery: Math.round(res.value), batteryAt: new Date() },
})
}
} catch (e) {
// 电量读不到不报错, 下次再试
}
}
}
// ==================== 日聚合 ====================
// 按日档重算: 读该上海日小时内档 -> avg/min/max + 最后一次值
export async function aggregateDay(device, dayDate) {
const start = dayStart(dayDate.getTime())
const end = addDays(start, 1)
const hours = await prisma.mihomeEnvHour.findMany({
where: { deviceId: device.id, hour: { gte: start, lt: end } },
orderBy: { hour: 'asc' },
})
if (hours.length === 0) return false
const temps = hours.filter((h) => h.temperatureAvg != null).map((h) => h.temperatureAvg)
const hums = hours.filter((h) => h.humidityAvg != null).map((h) => h.humidityAvg)
const last = hours[hours.length - 1]
const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null)
const allVendor = hours.every((h) => h.raw && h.raw.vendor === true)
await prisma.mihomeEnvSample.upsert({
where: { deviceId_date: { deviceId: device.id, date: start } },
create: {
deviceId: device.id,
date: start,
temperature: temps.length ? temps[temps.length - 1] : null,
humidity: hums.length ? hums[hums.length - 1] : null,
temperatureAvg: round2(avg(temps)),
humidityAvg: round2(avg(hums)),
temperatureMin: round2(temps.length ? Math.min(...temps) : null),
temperatureMax: round2(temps.length ? Math.max(...temps) : null),
humidityMin: round2(hums.length ? Math.min(...hums) : null),
humidityMax: round2(hums.length ? Math.max(...hums) : null),
origin: allVendor ? 'vendor_history' : 'live',
sampledAt: last.hour,
raw: { bucketCount: hours.length },
},
update: {
temperature: temps.length ? temps[temps.length - 1] : null,
humidity: hums.length ? hums[hums.length - 1] : null,
temperatureAvg: round2(avg(temps)),
humidityAvg: round2(avg(hums)),
temperatureMin: round2(temps.length ? Math.min(...temps) : null),
temperatureMax: round2(temps.length ? Math.max(...temps) : null),
humidityMin: round2(hums.length ? Math.min(...hums) : null),
humidityMax: round2(hums.length ? Math.max(...hums) : null),
origin: allVendor ? 'vendor_history' : 'live',
sampledAt: last.hour,
},
})
return true
}
// 补聚合缺口日: [since, today) 有小时档却没有日档的日子
export async function aggregateGapDays(device, since) {
const today = dayStart()
const existing = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: since, lt: today } },
select: { date: true },
})
const have = new Set(existing.map((s) => s.date.getTime()))
let agg = 0
for (let t = since.getTime(); t < today.getTime(); t += 86400000) {
if (!have.has(t)) {
const ok = await aggregateDay(device, new Date(t))
if (ok) agg++
}
}
return agg
}
// ==================== 对外入口 ====================
// 每次同步: 发现 + 当前小时采样 + 今天/昨天聚合 + 电量 (轻量, 每小时跑)
// 注意: 单独不包 withLock, 由 syncNow/dailyMaintenance 统一加锁, 避免嵌套自锁
async function syncLocked() {
const auth = await checkAuth()
if (auth.status !== 'ok') throw new MihomeAuthError(auth.lastError || '米家未授权或已过期')
const began = Date.now()
const homes = await syncHomes()
const devices = await syncDevices()
const kept = await listEnabledDevices()
const sampled = await sampleHour(kept)
await refreshBattery(kept)
// 今天 + 昨天都聚合 (保证 00 点边界不丢)
const nowMs = Date.now()
const today = dayStart(nowMs)
let agg = 0
for (const d of kept) {
if (await aggregateDay(d, today)) agg++
const y = addDays(today, -1)
if (await aggregateDay(d, y)) agg++
}
return {
ok: true,
homes,
devices,
sampled,
aggregated: agg,
ms: Date.now() - began,
}
}
export function syncNow() {
return withLock(syncLocked)
}
// 每日维护: 轻量同步 + vendor 补齐历史缺口 (45 天窗口) + 缺口日聚合
export function dailyMaintenance() {
return withLock(async () => {
const r = await syncLocked()
const auth = await checkAuth()
if (auth.status !== 'ok') throw new MihomeAuthError(auth.lastError || '米家未授权或已过期')
const since = addDays(dayStart(), -BACKFILL_DAYS)
const devices = await listEnabledDevices()
let hFilled = 0
let dFilled = 0
let agg = 0
for (const d of devices) {
hFilled += await vendorStats.fillHourGaps(d, since)
dFilled += await vendorStats.fillDayGaps(d, since)
agg += await aggregateGapDays(d, since)
}
return { ...r, vendorHours: hFilled, vendorDays: dFilled, gapAggregated: agg }
})
}
+142
View File
@@ -0,0 +1,142 @@
// 历史查询聚合: today(时) / month(天) / year(周)
// 响应统一: [{ t(桶起始 ms, Asia/Shanghai), temperature, temperatureAvg, temperatureMin, temperatureMax,
// humidity, humidityAvg, humidityMin, humidityMax, sampleCount?, missing? }]
import { prisma } from '../db.js'
import { dayStart, hourStart, monthStart, yearStart, weekStart, addDays, addHours, cnDateStr } from './time.js'
// 设备是否存在 (路由层校验)
export async function getDevice(did) {
return prisma.mihomeDevice.findUnique({ where: { did } })
}
// ---------- 当天逐小时 ----------
export async function todayBuckets(device) {
const start = dayStart()
const now = hourStart()
const rows = await prisma.mihomeEnvHour.findMany({
where: { deviceId: device.id, hour: { gte: start, lt: addDays(start, 1) } },
orderBy: { hour: 'asc' },
})
const byHour = new Map(rows.map((r) => [r.hour.getTime(), r]))
const out = []
for (let t = start.getTime(); t <= now.getTime(); t += 3600000) {
const r = byHour.get(t)
out.push({
t,
temperature: r?.temperatureAvg ?? null,
humidity: r?.humidityAvg ?? null,
sampleCount: r ? 1 : 0,
})
}
return out
}
// ---------- 当前月逐日 ----------
export async function monthBuckets(device) {
const start = monthStart()
const today = dayStart()
const end = addDays(today, 1)
const rows = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
orderBy: { date: 'asc' },
})
const byDay = new Map(rows.map((r) => [r.date.getTime(), r]))
const out = []
for (let t = start.getTime(); t < end.getTime(); t += 86400000) {
const r = byDay.get(t)
out.push(r
? {
t,
temperature: r.temperature,
temperatureAvg: r.temperatureAvg,
temperatureMin: r.temperatureMin,
temperatureMax: r.temperatureMax,
humidity: r.humidity,
humidityAvg: r.humidityAvg,
humidityMin: r.humidityMin,
humidityMax: r.humidityMax,
origin: r.origin,
sampleCount: 1,
}
: { t, sampleCount: 0, missing: true })
}
return out
}
// ---------- 当前年逐周 (按日档现场聚合, 不落库) ----------
export async function yearBuckets(device) {
const start = yearStart()
const today = dayStart()
const end = addDays(today, 1)
const rows = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
orderBy: { date: 'asc' },
})
// 日 -> 周 (上海周一为一周起点)
const weekOf = (dayStartMs) => weekStart(new Date(dayStartMs)).getTime()
const weeks = new Map() // weekStartMs -> { tempA: [], tempMin: [], tempMax: [], humA: [], humMin: [], humMax: [], days: [] }
for (const r of rows) {
const k = weekOf(r.date.getTime())
if (!weeks.has(k)) {
weeks.set(k, { tempA: [], tempMin: [], tempMax: [], humA: [], humMin: [], humMax: [], days: [] })
}
const w = weeks.get(k)
if (r.temperatureAvg != null) w.tempA.push(r.temperatureAvg)
if (r.temperatureMin != null) w.tempMin.push(r.temperatureMin)
if (r.temperatureMax != null) w.tempMax.push(r.temperatureMax)
if (r.humidityAvg != null) w.humA.push(r.humidityAvg)
if (r.humidityMin != null) w.humMin.push(r.humidityMin)
if (r.humidityMax != null) w.humMax.push(r.humidityMax)
w.days.push(r.date)
}
const avg = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null)
const out = []
for (const [k, w] of [...weeks.entries()].sort((a, b) => a[0] - b[0])) {
// 补齐周内缺的天数 (统计无数据的日算缺失)
const wkStart = new Date(k)
const missing = addDays(wkStart, 7)
const haveMap = new Set(w.days.map((d) => d.getTime()))
let missingDays = 0
for (let t = k; t < missing.getTime(); t += 86400000) {
// 只统计当年/到今天的日
if (t >= start.getTime() && t < end.getTime() && !haveMap.has(t)) missingDays++
}
out.push({
t: k,
label: cnDateStr(wkStart).slice(5) + '~' + cnDateStr(addDays(wkStart, 6)).slice(5),
temperatureAvg: avg(w.tempA),
temperatureMin: w.tempMin.length ? Math.min(...w.tempMin) : null,
temperatureMax: w.tempMax.length ? Math.max(...w.tempMax) : null,
humidityAvg: avg(w.humA),
humidityMin: w.humMin.length ? Math.min(...w.humMin) : null,
humidityMax: w.humMax.length ? Math.max(...w.humMax) : null,
sampleCount: w.days.length,
missingDays,
})
}
return out
}
// ---------- 设备概览 (状态页用): 最新样本 + 近 30 天缺日列表 ----------
export async function deviceOverview(device) {
const latest = await prisma.mihomeEnvSample.findFirst({
where: { deviceId: device.id },
orderBy: { date: 'desc' },
})
// 近 30 天 (含今天) 缺少样本的上海日
const start = addDays(dayStart(), -29)
const end = addDays(dayStart(), 1)
const have = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
select: { date: true },
})
const haveSet = new Set(have.map((h) => h.date.getTime()))
const missingDays = []
for (let t = start.getTime(); t < end.getTime(); t += 86400000) {
if (!haveSet.has(t)) missingDays.push(cnDateStr(new Date(t)))
}
return {
latest,
missingDays,
}
}
+68
View File
@@ -0,0 +1,68 @@
// server/mihome 模块入口: 汇总导出采集/认证/查询, 并提供 /status 聚合
import { prisma } from '../db.js'
import * as vendorStats from './vendorStats.js'
import * as history from './history.js'
import * as auth from './client.js'
export { checkAuth, startQrLogin, cancelQrLogin, getQrState, getQrImageUrl, MihomeAuthError } from './client.js'
export { syncNow, dailyMaintenance } from './collector.js'
export { getDevice, todayBuckets, monthBuckets, yearBuckets, deviceOverview } from './history.js'
export { getSpec, ENV_MODELS } from './specs.js'
// GET /api/mihome/status 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日)
export async function getStatus() {
const authState = await auth.checkAuth()
const [homes, devices, account] = await Promise.all([
prisma.mihomeHome.findMany({ orderBy: { id: 'asc' } }),
prisma.mihomeDevice.findMany({ orderBy: { id: 'asc' } }),
prisma.mihomeAccount.findUnique({ where: { key: 'default' } }),
])
const overviews = await Promise.all(
devices.map(async (d) => {
const ov = await history.deviceOverview(d)
return {
did: d.did,
latest: ov.latest,
missingDays: ov.missingDays,
}
})
)
const perDevice = new Map(overviews.map((o) => [o.did, o]))
// 统计可用性 (探测过的型号才有值, 未探测 undefined)
const capability = {}
for (const d of devices) {
const v = vendorStats.capabilityOf(d.model)
if (v !== undefined) capability[d.model] = v
}
const homeList = homes.map((h) => ({
homeId: h.homeId,
name: h.name,
devices: devices
.filter((d) => d.homeId === h.homeId)
.map((d) => ({
did: d.did,
model: d.model,
name: d.name,
alias: d.alias,
title: d.alias || d.name,
battery: d.battery,
batteryAt: d.batteryAt,
lastSyncAt: d.lastSyncAt,
latest: perDevice.get(d.did)?.latest ?? null,
missingDays: perDevice.get(d.did)?.missingDays ?? [],
})),
}))
return {
auth: {
status: account?.status || authState.status,
lastError: account?.lastError || authState.lastError,
checkedAt: authState.checkedAt,
},
capability,
homes: homeList,
}
}
+38
View File
@@ -0,0 +1,38 @@
// 米家环境监控定时调度
// 独立于 server/cron.js 注册: 容器镜像不含 scripts/, 旧 cron.js 顶层 import 脚本模块会整体加载失败,
// 故米家节拍不依赖它, 由 server/index.js 单独加载本模块, 确保采样/聚合在部署环境一定生效。
import cron from 'node-cron'
import * as mihome from './index.js'
export function registerMihomeScheduler() {
cron.schedule('5 * * * *', async () => {
console.log('[cron] 米家小时采样触发 @ 上海 xx:05')
try {
const r = await mihome.syncNow()
console.log('[cron] 米家采样完成: 家庭=' + r.homes + ' 设备=' + r.devices + ' 采样=' + r.sampled + ' 聚合=' + r.aggregated + ' (' + r.ms + 'ms)')
} catch (e) {
console.error('[cron] 米家小时采样失败:', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每小时 xx:05 米家温湿度采样')
cron.schedule('5 0,3 * * *', async () => {
console.log('[cron] 米家每日维护触发 (00:05 / 03:05)')
try {
const r = await mihome.dailyMaintenance()
console.log('[cron] 米家聚合完成: 采样=' + r.sampled + ' 聚合=' + r.aggregated
+ ' vendor小时=' + r.vendorHours + ' vendor天=' + r.vendorDays + ' 缺口聚合=' + r.gapAggregated)
} catch (e) {
console.error('[cron] 米家每日维护失败:', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 00:05/03:05 米家日聚合 + vendor 补齐')
// 启动追赶: 服务重启/停机恢复后立即补一次 (不打断 5 分钟内的整点采样)
setTimeout(() => {
mihome.dailyMaintenance().catch((e) => console.error('[cron] 米家启动追赶失败:', e.message))
}, 8000)
console.log('[cron] 已注册: 启动后 8s 执行米家追赶同步')
}
registerMihomeScheduler()
+26
View File
@@ -0,0 +1,26 @@
// 环境设备白名单: 型号 -> 属性定义 (siid.piid), 均为调研阶段实测确认
// - temp/hum: 采集用; batt: 电量 (每天读一次, 标题展示)
// - vendorStats: 是否已实测该型号支持 /v2/user/statistics 云端聚合历史
export const ENV_MODELS = {
// 炎米智能温湿度计: 实测 stat_hour_v3/stat_day_v3 可用, 回溯到接入米家日
'ym001.sensor_ht.ymwsdj': {
temp: [2, 1001],
hum: [2, 1002],
batt: [3, 1003],
vendorStats: true,
},
// 青萍蓝牙闹钟: 云端统计返回 -8 invalid params (未开通), 只走实时采样
'cgllc.clock.dove': {
temp: [2, 1],
hum: [2, 2],
batt: [2, 3],
vendorStats: false,
},
}
export function getSpec(model) {
return ENV_MODELS[model] || null
}
export const XIAOMI_APP_UA =
'APP/com.xiaomi.mihome APPV/6.0.89 Channel/MI-COM-BD-00059-00 OSVersion/MIUI-12.0.1 Android/28'
+78
View File
@@ -0,0 +1,78 @@
// Asia/Shanghai 时区工具 (中国无夏令时, 固定 +08:00)
// 桶语义: 小时档/日档桶的 \"边界时刻\" 都按上海时区对齐, 存储统一存 UTC (Date)
const OFFSET_MS = 8 * 3600 * 1000
// 把时刻按上海时区拆成 \"假装 UTC 的本地部件\"
function cnParts(ts) {
const d = new Date(ts + OFFSET_MS)
return {
y: d.getUTCFullYear(),
mo: d.getUTCMonth() + 1,
da: d.getUTCDate(),
h: d.getUTCHours(),
min: d.getUTCMinutes(),
sec: d.getUTCSeconds(),
dow: d.getUTCDay(), // 0=Sun
}
}
// 上海当日 00:00 (返回对应 UTC 时刻的 Date)
export function dayStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, p.da) - OFFSET_MS)
}
// 上海整点 (返回对应 UTC 时刻的 Date)
export function hourStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, p.da, p.h) - OFFSET_MS)
}
// 上海当周起始 (周一 00:00)
export function weekStart(date) {
const dow = cnParts(date.getTime()).dow
const diff = dow === 0 ? 6 : dow - 1 // 距周一的天数
return addDays(dayStart(date.getTime()), -diff)
}
export function addDays(date, n) {
return new Date(date.getTime() + n * 86400000)
}
// 上海当月 1 号 00:00
export function monthStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, 1) - OFFSET_MS)
}
// 上海当年 1 月 1 日 00:00
export function yearStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, 0, 1) - OFFSET_MS)
}
export function addHours(date, n) {
return new Date(date.getTime() + n * 3600000)
}
// 上海本地 \"YYYY-MM-DD\"
export function cnDateStr(date) {
const p = cnParts(date.getTime())
return `${p.y}-${String(p.mo).padStart(2, '0')}-${String(p.da).padStart(2, '0')}`
}
// 上海本地 \"HH:mm\"
export function cnTimeStr(date) {
const p = cnParts(date.getTime())
return `${String(p.h).padStart(2, '0')}:${String(p.min).padStart(2, '0')}`
}
// 上海本地 \"YYYY-MM-DD HH:mm\" (用于日志)
export function cnFullStr(date) {
return `${cnDateStr(date)} ${cnTimeStr(date)}`
}
// 判断两个日期是否同一上海日
export function sameCnDay(a, b) {
return dayStart(a.getTime()).getTime() === dayStart(b.getTime()).getTime()
}
+179
View File
@@ -0,0 +1,179 @@
// /v2/user/statistics 云端聚合历史: 探测可用性 + 小时/日缺口回填
// 实测: 炎米 ym001 可用 (stat_hour_v3/stat_day_v3/stat_month_v3); 青萍/VRF 返回 -8 invalid params
import { prisma } from '../db.js'
import { getApi } from './client.js'
import { dayStart, hourStart, addDays, addHours } from './time.js'
import { getSpec } from './specs.js'
// 型号 -> 是否支持统计 (探测一次后缓存; undefined=未探测)
const capabilityCache = new Map()
export function capabilityOf(model) {
if (capabilityCache.has(model)) return capabilityCache.get(model)
return undefined
}
// 用同型号任一设备探测该型号统计可用性
async function ensureCapability(device) {
if (capabilityCache.has(device.model)) return capabilityCache.get(device.model)
let supported = false
try {
const api = getApi()
const res = await api.getStatistics({
did: device.did,
key: `${device.tempSiid}.${device.tempPiid}`,
data_type: 'stat_day_v3',
limit: 1,
time_start: Math.floor(Date.now() / 1000) - 86400,
time_end: Math.floor(Date.now() / 1000),
})
supported = Array.isArray(res)
} catch (e) {
supported = false
}
capabilityCache.set(device.model, supported)
console.log(`[mihome] 统计探测 ${device.model}: ${supported ? '可用' : '不可用'}`)
return supported
}
// 解析 "[28.8]" / "[28.1,29.200001]" -> [28.8, 29.2]
function parseStatValue(value) {
if (typeof value !== 'string') return []
const m = value.match(/\[([^\]]*)\]/)
if (!m) return []
return m[1]
.split(',')
.map((s) => parseFloat(s.trim()))
.filter((n) => Number.isFinite(n))
}
// 拉某设备某属性的小时均值序列 (stat_hour_v3), 默认带排序
async function fetchStatHours(device, key, fromTs, toTs) {
const api = getApi()
const res = await api.getStatistics({
did: device.did,
key,
data_type: 'stat_hour_v3',
limit: 2000,
time_start: fromTs,
time_end: toTs,
})
if (!Array.isArray(res)) return []
return res
.filter((r) => r && r.time)
.sort((a, b) => a.time - b.time)
.map((r) => ({ time: new Date(r.time * 1000), values: parseStatValue(r.value) }))
}
// 回填小时缺口: 查 [since, now) 内缺失的整点桶, 从云端补齐 (origin 标记 raw.vendor=true)
// 返回新增小时桶数量
export async function fillHourGaps(device, since) {
if (!(await ensureCapability(device))) return 0
const now = hourStart()
if (since >= now) return 0
const existing = await prisma.mihomeEnvHour.findMany({
where: {
deviceId: device.id,
hour: { gte: since, lte: addHours(now, -1) },
},
select: { hour: true },
})
const have = new Set(existing.map((h) => h.hour.getTime()))
// 实际缺失桶数
let missingCount = 0
for (let t = since.getTime(); t <= addHours(now, -1).getTime(); t += 3600000) {
if (!have.has(t)) missingCount++
}
if (missingCount === 0) return 0
const fromTs = Math.floor(since.getTime() / 1000)
const toTs = Math.floor(now.getTime() / 1000)
let filled = 0
for (const [siid, piid, col, num] of [
[device.tempSiid, device.tempPiid, 'temperatureAvg', 0],
[device.humSiid, device.humPiid, 'humidityAvg', 1],
]) {
const rows = await fetchStatHours(device, `${siid}.${piid}`, fromTs, toTs)
for (const r of rows) {
const bucket = r.time.getTime()
if (r.values.length <= num || have.has(bucket) || bucket > now.getTime()) continue
// 只补严格已过去的整点
have.add(bucket)
try {
await prisma.mihomeEnvHour.upsert({
where: { deviceId_hour: { deviceId: device.id, hour: r.time } },
create: { deviceId: device.id, hour: r.time, [col]: r.values[num], raw: { vendor: true } },
update: { [col]: r.values[num], raw: { vendor: true } },
})
filled++
} catch (e) {
if (e?.code !== 'P2002') throw e
}
}
}
if (filled > 0) console.log(`[mihome] vendor 补齐小时档 ${device.did}: +${filled}`)
return filled
}
// 回填日缺口: 对仍无日档的日子, 若云端有日 [min,max] 则补一条 vendor_history
export async function fillDayGaps(device, since) {
if (!(await ensureCapability(device))) return 0
const today = dayStart()
const existingSamples = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: since, lt: today } },
select: { date: true },
})
const haveDay = new Set(existingSamples.map((s) => s.date.getTime()))
const fromTs = Math.floor(since.getTime() / 1000)
const toTs = Math.floor(Date.now() / 1000)
let filled = 0
for (const [siid, piid] of [
[device.tempSiid, device.tempPiid],
[device.humSiid, device.humPiid],
]) {
const api = getApi()
const res = await api.getStatistics({
did: device.did,
key: `${siid}.${piid}`,
data_type: 'stat_day_v3',
limit: 120,
time_start: fromTs,
time_end: toTs,
})
if (!Array.isArray(res)) continue
const isTemp = siid === device.tempSiid && piid === device.tempPiid
for (const r of res) {
if (!r || !r.time) continue
const day = new Date(r.time * 1000)
const vals = parseStatValue(r.value)
const bucket = dayStart(day.getTime()).getTime()
if (haveDay.has(bucket) || bucket >= today.getTime() || vals.length < 2) continue
haveDay.add(bucket)
const data = isTemp
? { temperatureMin: vals[0], temperatureMax: vals[1] }
: { humidityMin: vals[0], humidityMax: vals[1] }
try {
await prisma.mihomeEnvSample.upsert({
where: { deviceId_date: { deviceId: device.id, date: dayStart(day.getTime()) } },
create: {
deviceId: device.id,
date: dayStart(day.getTime()),
origin: 'vendor_history',
...data,
},
update: { origin: 'vendor_history', ...data },
})
filled++
} catch (e) {
if (e?.code !== 'P2002') throw e
}
}
}
if (filled > 0) console.log(`[mihome] vendor 补齐日档 ${device.did}: +${filled}`)
return filled
}
+63
View File
@@ -0,0 +1,63 @@
// apizero 代理路由 (前端不直接调 apizero, 一律走后端, key 不外泄)
//
// GET /api/apizero/oil/forecast - 油价 + 预测 (实时)
// GET /api/apizero/oil/price-all - 32 省油价
// GET /api/apizero/oil/price?province= - 单省油价
// GET /api/apizero/oil/schedule?year= - 某年调价日历 (按年缓存)
//
// 错误: apizero 错误统一包成 502 (上游错误) 或 400 (参数错误)
import { Router } from 'express'
import { authRequired } from '../middleware.js'
import { ApizeroError, oil, history } from '../apizero/index.js'
const router = Router()
router.use(authRequired)
function handleApizeroError(e, res, next) {
if (e instanceof ApizeroError) {
// 参数错 -> 400, 上游错 -> 502, QPS 限流 / 额度用完 -> 503
if (e.httpStatus === 400) return res.status(400).json({ error: e.message, apizero_code: e.code })
if (e.code === 4029 || e.code === 4030) return res.status(503).json({ error: e.message, apizero_code: e.code })
return res.status(502).json({ error: e.message, apizero_code: e.code })
}
next(e)
}
// 实时: 油价 + 国际原油 + 下次调价预测
router.get('/apizero/oil/forecast', async (req, res, next) => {
try { res.json(await oil.getForecast()) }
catch (e) { handleApizeroError(e, res, next) }
})
// 32 省油价
router.get('/apizero/oil/price-all', async (req, res, next) => {
try { res.json(await oil.getPriceAll()) }
catch (e) { handleApizeroError(e, res, next) }
})
// 单省油价
router.get('/apizero/oil/price', async (req, res, next) => {
try { res.json(await oil.getPrice(req.query.province)) }
catch (e) { handleApizeroError(e, res, next) }
})
// 调价日历 (按年缓存)
router.get('/apizero/oil/schedule', async (req, res, next) => {
try { res.json(await oil.getSchedule(req.query.year)) }
catch (e) { handleApizeroError(e, res, next) }
})
export default router
// 浙江省油价历史 (DB)
// GET /api/apizero/oil/history?fuel=92&days=180
// - fuel: 92 / 95 / 0 (留空 = 3 个油品都返回)
// - days: 默认 180 (半年)
router.get('/apizero/oil/history', async (req, res, next) => {
try {
const fuel = req.query.fuel ? String(req.query.fuel) : undefined
const days = Math.max(1, Math.min(Number(req.query.days) || 180, 730))
const items = await history.query({ fuel, days })
res.json({ items, fuel: fuel || 'all', days })
} catch (e) { next(e) }
})
+93
View File
@@ -0,0 +1,93 @@
// 环境监控 REST 路由: /api/mihome/* (authRequired)
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import {
getStatus,
getDevice,
todayBuckets,
monthBuckets,
yearBuckets,
syncNow,
startQrLogin,
cancelQrLogin,
getQrState,
getQrImageUrl,
MihomeAuthError,
} from '../mihome/index.js'
import { XIAOMI_APP_UA } from '../mihome/specs.js'
const router = Router()
router.use(authRequired)
// 统一错误边界: 米家授权错误映射为 401, 其余走 500
function guard(handler) {
return async (req, res, next) => {
try {
return await handler(req, res, next)
} catch (e) {
if (e instanceof MihomeAuthError) return res.status(401).json({ error: e.message })
return next(e)
}
}
}
// 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数)
router.get('/status', guard(async (_req, res) => {
res.json(await getStatus())
}))
// 历史: range = today(默认) | month | year
router.get('/device/:did/history', guard(async (req, res) => {
const device = await getDevice(req.params.did)
if (!device) return res.status(404).json({ error: '设备不存在' })
const range = req.query.range || 'today'
let buckets
if (range === 'month') buckets = await monthBuckets(device)
else if (range === 'year') buckets = await yearBuckets(device)
else buckets = await todayBuckets(device)
res.json({ did: device.did, range: range === 'today' ? 'today' : range, buckets })
}))
// 改别名 (可清空); alias 最长 24 字符
router.put('/device/:did', guard(async (req, res) => {
const device = await getDevice(req.params.did)
if (!device) return res.status(404).json({ error: '设备不存在' })
const alias = typeof req.body?.alias === 'string' ? req.body.alias.trim().slice(0, 24) : ''
const updated = await prisma.mihomeDevice.update({ where: { id: device.id }, data: { alias: alias || null } })
res.json({ did: updated.did, alias: updated.alias || '' })
}))
// 立即同步一次 (小时采样 + 日聚合), 首页「立即同步」
router.post('/sync', guard(async (_req, res) => {
res.json(await syncNow())
}))
// 重授权: 启动扫码 (幂等; 已在跑则返回当前状态)
router.post('/reauthorize', guard(async (_req, res) => {
res.json(await startQrLogin())
}))
// 扫码状态轮询
router.get('/reauthorize/status', guard(async (_req, res) => {
res.json(getQrState())
}))
// 取消扫码
router.post('/reauthorize/cancel', guard(async (_req, res) => {
res.json(cancelQrLogin())
}))
// 代理二维码图片 (带小米 UA, 避免被 CDN 拒)
router.get('/reauthorize/qr-image', guard(async (_req, res) => {
const url = getQrImageUrl()
if (!url) return res.status(404).json({ error: '暂无二维码' })
const r = await fetch(url, { headers: { 'User-Agent': XIAOMI_APP_UA } })
if (!r.ok) throw new Error('二维码拉取失败 ' + r.status)
const buf = Buffer.from(await r.arrayBuffer())
res.set('Content-Type', r.headers.get('content-type') || 'image/png')
res.set('Cache-Control', 'no-store')
res.send(buf)
}))
export default router
+102
View File
@@ -0,0 +1,102 @@
// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定)
// GET /api/ota/check/:current_sequence
// - 客户端传当前 sequence, 服务端判断是否有更新
// - 有: 200 + manifest JSON
// - 无: 204 No Content
// GET /api/ota/bundle/:filename
// - 下载前端 bundle (tar.gz), 供 manifest.url 引用
// GET /api/ota/manifest-web
// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用)
//
// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放:
// - latest.json (由 scripts/build-ota.mjs 生成)
// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size }
// - <filename> (tar.gz 升级包)
import { Router } from 'express'
import fs from 'node:fs'
import path from 'node:path'
const router = Router()
function resolveOtaDir() {
const dir = process.env.OTA_PACKAGE_DIR
? path.resolve(process.env.OTA_PACKAGE_DIR)
: path.resolve(process.cwd(), 'ota-package')
return dir
}
function readLatest() {
const p = path.join(resolveOtaDir(), 'latest.json')
if (!fs.existsSync(p)) return null
try {
return JSON.parse(fs.readFileSync(p, 'utf8'))
} catch (e) {
console.error('[ota] latest.json 解析失败:', e.message)
return null
}
}
// GET /api/ota/check/:current_sequence
// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式
router.get('/ota/check/:current_sequence', (req, res) => {
const currentSeq = Number(req.params.current_sequence)
if (!Number.isFinite(currentSeq) || currentSeq < 0) {
return res.status(400).json({ error: '无效的 sequence' })
}
const latest = readLatest()
if (!latest) {
// 无可用包, 按约定返回 204
return res.status(204).end()
}
// 不新于当前则 204
if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) {
return res.status(204).end()
}
// 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤)
// 客户端传 binary_version query: 暂不强制, 让客户端兜底
const dir = resolveOtaDir()
const base = process.env.OTA_PUBLIC_BASE || ''
res.json({
version: latest.version,
sequence: latest.sequence,
min_binary_version: latest.min_binary_version || '0.0.0',
url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
signature: latest.signature,
notes: latest.notes || '',
pub_date: latest.pub_date || new Date().toISOString(),
mandatory: !!latest.mandatory,
bundle_size: latest.bundle_size || 0,
})
})
// GET /api/ota/bundle/:filename - 流式下载 bundle
router.get('/ota/bundle/:filename', (req, res) => {
const dir = resolveOtaDir()
// 防越界: 只取 basename
const name = path.basename(req.params.filename)
const p = path.join(dir, name)
if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' })
const stat = fs.statSync(p)
res.setHeader('Content-Type', 'application/gzip')
res.setHeader('Content-Length', stat.size)
res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"')
fs.createReadStream(p).pipe(res)
})
// GET /api/ota/manifest-web - Web 浏览器用的简化版
// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新
router.get('/ota/manifest-web', (_req, res) => {
const latest = readLatest()
if (!latest) return res.json({ available: false })
res.json({
available: true,
version: latest.version,
build_id: latest.build_id || latest.version,
note: latest.notes || '',
size: latest.bundle_size || 0,
})
})
// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方
export default router
+658 -8
View File
@@ -309,6 +309,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -341,6 +358,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -364,9 +391,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -377,7 +404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -390,6 +417,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -701,6 +737,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -718,6 +763,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -743,6 +798,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -771,6 +836,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -778,7 +852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -792,6 +866,12 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -994,8 +1074,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1017,8 +1099,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core",
"wasm-bindgen",
]
[[package]]
@@ -1169,6 +1254,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1258,6 +1362,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1268,6 +1373,38 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1286,9 +1423,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1323,6 +1462,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-hotswap",
]
[[package]]
@@ -1657,6 +1797,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@@ -1678,6 +1824,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -1710,6 +1862,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1752,6 +1910,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2020,6 +2195,49 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2276,6 +2494,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.19",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.19",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -2297,6 +2571,32 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2372,6 +2672,53 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -2402,10 +2749,24 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -2421,12 +2782,66 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2436,6 +2851,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -2493,6 +2917,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@@ -2617,6 +3064,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -2687,7 +3146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -2815,6 +3274,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -2878,6 +3343,27 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -2899,7 +3385,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.1",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -2942,6 +3428,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -2978,7 +3475,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3061,6 +3558,45 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-hotswap"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3550b0b6727104637a12c6c1c5270714d4a8d935de3b2b71a5fe5a111d7ffc5"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"log",
"minisign-verify",
"reqwest 0.12.28",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"thiserror 2.0.19",
"tokio",
"url",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3161,6 +3697,19 @@ dependencies = [
"toml 1.1.4+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@@ -3279,6 +3828,26 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -3288,6 +3857,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
@@ -3569,6 +4139,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -3612,6 +4188,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -3733,6 +4315,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -3756,6 +4351,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
@@ -3812,6 +4417,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -3997,6 +4611,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4042,6 +4667,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -4310,6 +4944,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -4354,6 +4998,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
+4
View File
@@ -18,6 +18,10 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# 前端 OTA 升级 (运行时覆盖 tauri://localhost 资源, 不动 Tauri 框架/原生层)
# 见 docs/architecture.md - 通过替换 Assets provider, webview 仍走 tauri:// 协议,
# 但优先从 AppData 读, 缺失回退到内置 dist
tauri-plugin-hotswap = "0.0.4"
# Android 平台需要的 JNI / Activity 启动能力
[target."cfg(target_os = \"android\")".dependencies]
+4 -3
View File
@@ -1,9 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "默认能力: 允许 core API",
"description": "默认能力: 允许 core API + hotswap 插件 (前端 OTA 升级)",
"windows": ["main"],
"permissions": [
"core:default"
"core:default",
"hotswap:default"
]
}
}
+14 -3
View File
@@ -1,8 +1,19 @@
// 跨平台入口: 桌面从这里走, Android 走 mobile_entry_point 宏
// 启动流程:
// 1) 初始化 tauri-plugin-hotswap (前端 OTA 升级插件)
// - 必须在 build 之前调用, 插件会替换 tauri::generate_context! 的 asset provider,
// 后续可被 tauri::Builder 消费
// 2) 业务初始化
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// 生成 context, 交给 hotswap 包装 (替换 Assets provider, 让 tauri://localhost 资源
// 优先从本地覆盖目录读, 走不到时回退到内置 dist)
let context = tauri::generate_context!();
let (hotswap, context) = tauri_plugin_hotswap::init(context)
.expect("failed to initialize tauri-plugin-hotswap");
tauri::Builder::default()
.setup(|_app| Ok(()))
.run(tauri::generate_context!())
.plugin(hotswap)
.run(context)
.expect("error while running iBoard");
}
}
+9 -1
View File
@@ -9,6 +9,14 @@
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"plugins": {
"hotswap": {
"endpoint": "http://bh.vps.honor3.com:3001/api/ota/check/{{current_sequence}}",
"pubkey": "RWQQ4/wfHjJiC/O6NmA7eIQYUNRpCsz5wLLsLqJXgHmsUraAavUWx0HA",
"require_https": false,
"headers": {}
}
},
"app": {
"windows": [
{
@@ -39,4 +47,4 @@
"minSdkVersion": 24
}
}
}
}
+39
View File
@@ -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'
}
+32
View File
@@ -0,0 +1,32 @@
// 前端油价 API: 一律走后端 /api/apizero/oil/*, 不直接调 apizero (key 不外泄)
import { api, getApiBase } from './client.js'
// 实时: 油价 + 国际原油 + 下次调价预测
export async function getOilForecast() {
return api('/apizero/oil/forecast')
}
// 32 省油价 (可暂未在 UI 用, 留作 API 暴露)
export async function getOilPriceAll() {
return api('/apizero/oil/price-all')
}
// 单省油价
export async function getOilPrice(province) {
return api('/apizero/oil/price?province=' + encodeURIComponent(province))
}
// 某年调价日历 (后端按年缓存, 同年份不会重复调 apizero)
export async function getOilSchedule(year) {
return api('/apizero/oil/schedule?year=' + encodeURIComponent(year))
}
// 浙江省油价历史 (走 DB, 由 cron 每天 04:00 抓取并落库)
// fuel: '92' | '95' | '0' | undefined (留空 = 3 个油品)
// days: 默认 180 (半年)
export async function getOilHistory({ fuel, days = 180 } = {}) {
const qs = new URLSearchParams()
if (fuel) qs.set('fuel', fuel)
qs.set('days', String(days))
return api('/apizero/oil/history?' + qs.toString())
}
+177
View File
@@ -0,0 +1,177 @@
// 前端 OTA 封装
// - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...)
// - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效)
//
// 设计原则:
// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
import { getApiBase, getToken } from './client.js'
import { APP_VERSION, BUILD_ID } from '../version.js'
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源
function resolveUrl(p) {
const base = getApiBase()
return base ? base + p : p
}
// 当前版本信息 (前端展示用)
export function getCurrentVersionInfo() {
return { version: APP_VERSION, build: BUILD_ID }
}
// 启动时: 确认当前资源可用 (Tauri only)
// 失败/异常都不抛, 仅 console.warn
export function initOta() {
if (!inTauri) return
// 动态 import: web 模式根本不打这个 chunk
import('tauri-plugin-hotswap-api')
.then((mod) => mod.notifyReady())
.then(() => console.log('[ota] notifyReady ok'))
.catch((e) => console.warn('[ota] notifyReady failed:', e))
}
// 检查更新
// 返回统一结构, 调用方不关心是 Tauri 还是 web:
// {
// available: boolean,
// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok',
// info: { version, sequence, notes, mandatory, bundle_size }
// }
export async function checkOtaUpdate() {
if (!inTauri) {
return checkOtaUpdateWeb()
}
return checkOtaUpdateTauri()
}
async function checkOtaUpdateTauri() {
// 走 hotswap 插件 (内部会带 sequence / platform / arch 等参数)
const { checkUpdate } = await import('tauri-plugin-hotswap-api')
const r = await checkUpdate()
if (!r.available) {
return { available: false, reason: 'no_package', info: r }
}
return {
available: true,
reason: 'ok',
info: {
version: r.version,
sequence: r.sequence,
notes: r.notes,
mandatory: r.mandatory,
bundle_size: r.bundle_size,
},
}
}
async function checkOtaUpdateWeb() {
// Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
try {
const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' })
if (!res.ok) {
return { available: false, reason: 'unsupported', info: null }
}
const data = await res.json()
if (!data.available) return { available: false, reason: 'no_package', info: data }
if (data.build_id && data.build_id === BUILD_ID) {
return { available: false, reason: 'same_version', info: data }
}
return {
available: true,
reason: 'ok',
info: {
version: data.version,
notes: data.note || '',
// web 端没有强制/mandatory 等字段, 仅展示
mandatory: false,
bundle_size: data.size || 0,
},
}
} catch (e) {
console.warn('[ota] web check failed:', e)
return { available: false, reason: 'unsupported', info: null }
}
}
// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理
// onProgress(received, total) 进度回调
// 成功后必须 reload, 新资源才会生效
export async function applyOtaUpdate(onProgress) {
if (!inTauri) {
throw new Error('OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面')
}
const mod = await import('tauri-plugin-hotswap-api')
// 注册一次性进度监听 (applyUpdate 期间触发)
let unlisten = null
let unlistenLifecycle = null
if (onProgress) {
try {
unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total))
} catch (e) {
console.warn('[ota] onDownloadProgress 监听失败:', e)
}
}
// 监听 lifecycle 事件, 捕获失败详情 (download-error / apply 失败时 error 字段带具体原因)
// applyUpdate() 抛出的错误往往只有泛化信息, 细节藏在 lifecycle 事件里
let lifecycleError = ''
try {
unlistenLifecycle = await mod.onLifecycle((ev) => {
if (ev.event === 'download-error' || ev.event === 'apply' || ev.event === 'check-error') {
if (ev.error) {
lifecycleError = ev.error
console.error('[ota] lifecycle', ev.event, 'error:', ev.error)
}
}
})
} catch (e) {
console.warn('[ota] onLifecycle 监听失败:', e)
}
try {
const newVersion = await mod.applyUpdate()
return newVersion
} catch (e) {
// 把 lifecycle 里的详细错误拼进抛出的异常, 方便前端展示真实原因
const base = e?.message || '升级失败'
const detail = lifecycleError ? ` (${lifecycleError})` : ''
const err = new Error(base + detail)
err.cause = e
throw err
} finally {
if (unlisten) {
try { unlisten() } catch { /* ignore */ }
}
if (unlistenLifecycle) {
try { unlistenLifecycle() } catch { /* ignore */ }
}
}
}
// 回滚到内置 dist (Tauri only, 一般用于 OTA 出错时手动恢复)
export async function rollbackOta() {
if (!inTauri) throw new Error('仅 Tauri 客户端可用')
const mod = await import('tauri-plugin-hotswap-api')
return mod.rollback()
}
// 取当前 OTA 状态 (Tauri only)
export async function getOtaVersionInfo() {
if (!inTauri) return null
try {
const mod = await import('tauri-plugin-hotswap-api')
return await mod.getVersionInfo()
} catch (e) {
console.warn('[ota] getVersionInfo failed:', e)
return null
}
}
// reload 当前页 (新资源生效)
export function reloadApp() {
window.location.reload()
}
-21
View File
@@ -1,21 +0,0 @@
// 仅在 Tauri WebView 内启用 PageSpy, 用于生产环境远程调试.
// web 模式 (Vite dev / 浏览器) 通过检测 window.__TAURI_INTERNALS__ 直接跳过,
// 配合 dynamic import 让 page-spy 这个 chunk 在 web 模式下完全不下载.
// VITE_PAGESPY_API 留空可关闭连接 (例如本地调试不想连远端 PageSpy 服务时).
// 加载失败不阻塞应用启动, 仅 console.warn 提示.
const PAGESPY_API = import.meta.env.VITE_PAGESPY_API || 'http://bh.vps.honor3.com:6752'
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
if (inTauri && PAGESPY_API) {
// 动态 import: 仅在 Tauri 内才下载 page-spy chunk
import('@huolala-tech/page-spy')
.then(({ default: PageSpy }) => {
// PageSpy 默认会 hook fetch / XMLHttpRequest, 自动抓取网络请求和错误
const instance = new PageSpy({ api: PAGESPY_API })
window.__IBOARD_PAGESPY__ = instance
})
.catch((err) => {
console.warn('[PageSpy] init failed:', err)
})
}
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react'
// 页面从后台恢复到前台时调用 refetch (Tauri 移动端/桌面通用, 不引库)
// - visibilitychange: Tauri 移动端 (iOS WKWebView / Android WebView) 唯一可靠的前后台机制,
// 回前台时 document.visibilityState 必然变为 'visible'
// - window focus: 桌面端 (WebView2) 兜底, 部分场景不派发 DOM focus 但原生窗口焦点事件可靠
// 用法: const load = useCallback(...); useRefreshOnVisible(load)
// 回调通过 ref 持有, effect 只绑定一次, 不随每次渲染重新订阅
export default function useRefreshOnVisible(refetch) {
const fnRef = useRef(refetch)
fnRef.current = refetch
useEffect(() => {
// 回前台时 visibilitychange 与 focus 会接连触发, 用 300ms 窗口节流避免重复请求
let lastTs = 0
const fire = () => {
const now = Date.now()
if (now - lastTs < 300) return
lastTs = now
fnRef.current?.()
}
const onVisibility = () => {
if (document.visibilityState === 'visible') fire()
}
const onFocus = () => fire()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('focus', onFocus)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('focus', onFocus)
}
}, [])
}
+24 -21
View File
@@ -1,22 +1,25 @@
// 远程调试: 仅在 Tauri WebView 内启用, web 模式自动跳过
import './debug/pagespy.js'
// 移动端 UI 框架: 全局样式 + 主题 (antd-mobile 5)
import 'antd-mobile/es/global'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { AuthProvider } from './auth/AuthContext.jsx'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
// 移动端 UI 框架: 全局样式 + 主题 (antd-mobile 5)
import 'antd-mobile/es/global'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { AuthProvider } from './auth/AuthContext.jsx'
import { initOta } from './api/ota.js'
import './styles/globals.css'
// OTA 启动: 仅 Tauri 内生效
// - notifyReady: 确认当前资源可用, 否则下次启动自动回滚 (hotswap 插件行为)
// - 异步执行, 不阻塞 React 挂载
initOta()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)
+41 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { NavBar, Card, Empty, DotLoading, Badge } from 'antd-mobile'
import { api } from '../api/client.js'
import SwipeTabs from '../components/SwipeTabs.jsx'
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
// 首页: 报警模块 + 新闻模块
// - 报警: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值 (按时间倒序)
@@ -44,7 +45,7 @@ export default function HomePage() {
return (
<div className="page-with-navbar">
<NavBar back={null}>首页</NavBar>
<NavBar back={null} />
<div className="home-content">
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
</div>
@@ -82,6 +83,26 @@ function AlertSection({ onCount }) {
}, [done, onCount])
useEffect(() => { fetchPage() }, [])
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
const reload = useCallback(async () => {
if (inFlightRef.current) return
inFlightRef.current = true
setLoading(true)
try {
const data = await api('/alerts?limit=10')
setItems(data.items || [])
onCount?.(data.total ?? (data.items?.length || 0))
cursorRef.current = data.nextCursor
setDone(!data.nextCursor)
} catch (e) {
console.error('alerts reload error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [onCount])
useRefreshOnVisible(reload)
useEffect(() => {
if (done || items.length === 0) return
const el = sentinelRef.current
@@ -152,6 +173,25 @@ function NewsSection() {
}, [done])
useEffect(() => { fetchPage() }, [])
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
const reload = useCallback(async () => {
if (inFlightRef.current) return
inFlightRef.current = true
setLoading(true)
try {
const data = await api('/news?limit=10')
setItems(data.items || [])
cursorRef.current = data.nextCursor
setDone(!data.nextCursor)
} catch (e) {
console.error('news reload error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [])
useRefreshOnVisible(reload)
useEffect(() => {
if (done || items.length === 0) return
const el = sentinelRef.current
+4 -3
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { useLocation, Navigate } from 'react-router-dom'
import { Button, Card, Checkbox, Input, Toast } from 'antd-mobile'
import { useAuth } from '../auth/AuthContext.jsx'
@@ -7,7 +7,6 @@ import { useAuth } from '../auth/AuthContext.jsx'
// 使用 antd-mobile: Input / Checkbox / Button / Card / Toast
export default function LoginPage() {
const { user, login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const from = location.state?.from?.pathname || '/'
@@ -17,6 +16,9 @@ export default function LoginPage() {
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
// 登录成功后 user 会变成非空, 由顶部 <Navigate to={from} replace/> 负责可靠跳转
// 不能在 onSubmit 里手动 navigate: setUser 是异步的, 在它提交前导航会切到受保护页,
// 此时 user 仍为 null 被 RequireAuth 立刻踢回 /login, 造成"提示成功却进不去"
if (user) return <Navigate to={from} replace />
async function onSubmit(e) {
@@ -26,7 +28,6 @@ export default function LoginPage() {
try {
await login(email.trim(), password, remember)
Toast.show({ icon: 'success', content: '登录成功' })
navigate(from, { replace: true })
} catch (e) {
setErr(e.message)
} finally {
+17 -9
View File
@@ -1,27 +1,34 @@
import { useState } from 'react'
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
import { NavBar } from 'antd-mobile'
import SwipeTabs from '../components/SwipeTabs.jsx'
import DataView from './macro/DataView.jsx'
import StrategyView from './macro/StrategyView.jsx'
import ArticlesView from './macro/ArticlesView.jsx'
import ArticleDetail from './macro/ArticleDetail.jsx'
import OilPage from './macro/OilPage.jsx'
// 宏观: 顶栏 3 tab (经济文章 / 经济数据 / 策略研究), 默认 经济数据
// /macro → 重定向到 /macro/data
// /macro/data → 经济数据 (默认)
// /macro/strategy→ 策略研究
// /macro/articles→ 经济文章
// 宏观: 顶栏 4 tab (油价 / 经济数据 / 策略研究 / 经济文章), 默认 油价
// /macro → 显示 oil tab (本组件 activeFromPath 决定)
// /macro/oil → 油价 (新增)
// /macro/data → 经济数据
// /macro/strategy → 策略研究
// /macro/articles → 经济文章
// /macro/articles/:slug → 文章详情
const TABS = [
{ key: 'oil', label: '油价', path: '/macro/oil' },
{ key: 'data', label: '经济数据', path: '/macro/data' },
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
]
function activeFromPath(pathname) {
if (pathname.startsWith('/macro/oil')) return 'oil'
if (pathname.startsWith('/macro/articles')) return 'articles'
if (pathname.startsWith('/macro/strategy')) return 'strategy'
return 'data'
if (pathname.startsWith('/macro/data')) return 'data'
// 父路由 /macro 或 / 走 Tab 列表第一个 (油价)
return 'oil'
}
export default function MacroPage() {
@@ -33,6 +40,7 @@ export default function MacroPage() {
key: t.key,
label: t.label,
render: () => {
if (t.key === 'oil') return <OilPage />
if (t.key === 'data') return <DataView />
if (t.key === 'strategy') return <StrategyView />
if (t.key === 'articles') return <ArticlesView />
@@ -46,7 +54,6 @@ export default function MacroPage() {
}
// 详情页 /macro/articles/:slug 和 /macro/data/:name 单独走 React Router 渲染, 不参与滑屏
// 这样浏览器/手机物理返回键也能正常回到列表
if (location.pathname.match(/^\/macro\/(articles|data|strategy)\/[^/]+$/)) {
return (
<Routes>
@@ -58,8 +65,9 @@ export default function MacroPage() {
}
return (
<div>
<div className="page-with-navbar">
<NavBar back={null} />
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
</div>
)
}
}
+165 -4
View File
@@ -1,14 +1,101 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Avatar, List, NavBar, Toast } from 'antd-mobile'
import { Avatar, Button, List, NavBar, ProgressBar, Toast } from 'antd-mobile'
import { useAuth } from '../auth/AuthContext.jsx'
import { APP_VERSION, BUILD_ID } from '../version.js'
import {
applyOtaUpdate,
checkOtaUpdate,
getCurrentVersionInfo,
getOtaVersionInfo,
reloadApp,
rollbackOta,
} from '../api/ota.js'
// 我的页: 顶部 NavBar + 用户头像卡片 + 信息列表
// 使用 antd-mobile: NavBar / List / Avatar / Toast
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
// OTA 区:
// - 显示当前 APP_VERSION (前端包, 对应内置 dist)
// - 显示当前 hotswap 状态 (是否已应用覆盖包)
// - [检查更新]: 走 checkOtaUpdate()
// - [立即升级]: 走 applyOtaUpdate(), 完成后 reload
// - [回滚]: 清除覆盖包, 回到内置 dist
export default function MePage() {
const { user, logout } = useAuth()
const navigate = useNavigate()
// OTA 状态
const [otaState, setOtaState] = useState('idle') // idle | checking | available | none | downloading | ready | error
const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
const [progress, setProgress] = useState({ received: 0, total: 0 })
const [error, setError] = useState('')
// 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
useEffect(() => {
let cancelled = false
getOtaVersionInfo().then((v) => {
if (!cancelled) setOtaVersion(v)
})
return () => { cancelled = true }
}, [])
async function onCheck() {
setError('')
setOtaState('checking')
try {
const r = await checkOtaUpdate()
if (r.available) {
setOtaInfo(r.info)
setOtaState('available')
Toast.show({ icon: 'success', content: '发现新版本 v' + (r.info?.version || '') })
} else {
setOtaInfo(null)
setOtaState('none')
if (r.reason === 'same_version') {
Toast.show({ content: '已是最新版本' })
} else if (r.reason === 'no_package') {
Toast.show({ content: '当前没有可用更新' })
} else {
Toast.show({ content: '当前没有可用更新' })
}
}
} catch (e) {
setError(e?.message || '检查失败')
setOtaState('error')
Toast.show({ icon: 'fail', content: '检查失败: ' + (e?.message || '') })
}
}
async function onApply() {
setError('')
setOtaState('downloading')
setProgress({ received: 0, total: 0 })
try {
const newVer = await applyOtaUpdate((received, total) => {
setProgress({ received, total })
})
Toast.show({ icon: 'success', content: '已下载 v' + newVer + ', 即将重启' })
setOtaState('ready')
// 给用户 1s 看提示, 然后 reload
setTimeout(() => reloadApp(), 1000)
} catch (e) {
setError(e?.message || '升级失败')
setOtaState('error')
Toast.show({ icon: 'fail', content: '升级失败: ' + (e?.message || '') })
}
}
async function onRollback() {
if (!window.confirm('确认回滚到内置版本?')) return
try {
await rollbackOta()
Toast.show({ icon: 'success', content: '已回滚, 正在重启' })
setTimeout(() => reloadApp(), 800)
} catch (e) {
Toast.show({ icon: 'fail', content: '回滚失败: ' + (e?.message || '') })
}
}
async function onLogout() {
const confirmed = window.confirm('确定退出登录?')
if (!confirmed) return
@@ -17,9 +104,14 @@ export default function MePage() {
navigate('/login', { replace: true })
}
const cur = getCurrentVersionInfo()
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const showProgress = otaState === 'downloading' && progress.total > 0
const progressPct = showProgress ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0
return (
<div className="me-page">
<NavBar back={null} className="me-navbar">我的</NavBar>
<NavBar back={null} className="me-navbar" />
<div className="me-profile">
<Avatar
className="me-avatar"
@@ -31,6 +123,75 @@ export default function MePage() {
<div className="me-name">{user?.name || '用户'}</div>
<div className="me-email">{user?.email}</div>
</div>
{/* OTA 升级区 */}
<List className="me-list" mode="card" header="前端升级 (OTA)">
<List.Item extra={'v' + cur.version + ' · ' + cur.build}>当前版本</List.Item>
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
运行资源
</List.Item>
<List.Item>
<div className="me-ota-actions">
<Button
size="small"
color="primary"
loading={otaState === 'checking'}
disabled={otaState === 'downloading'}
onClick={onCheck}
>
检查更新
</Button>
<Button
size="small"
color="danger"
disabled={otaState !== 'available' || otaState === 'downloading'}
onClick={onApply}
>
立即升级
</Button>
{isTauri && otaVersion?.active && (
<Button
size="small"
fill="outline"
onClick={onRollback}
disabled={otaState === 'downloading'}
>
回滚
</Button>
)}
</div>
</List.Item>
{otaState === 'available' && otaInfo && (
<List.Item extra={'v' + otaInfo.version}>
新版本
{otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
</List.Item>
)}
{otaState === 'downloading' && (
<List.Item>
<div className="me-ota-progress">
<div className="me-ota-progress-text">
正在下载 {showProgress ? progressPct + '%' : ''}
{' '}({(progress.received / 1024).toFixed(0)} KB
{progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''})
</div>
<ProgressBar percent={progressPct || undefined} />
</div>
</List.Item>
)}
{otaInfo?.notes && (
<List.Item description={otaInfo.notes}>发布说明</List.Item>
)}
{error && (
<List.Item>
<span className="me-ota-error">{error}</span>
</List.Item>
)}
{!isTauri && (
<List.Item description="Web 端仅展示提示, 实际升级需在 Tauri 客户端进行">提示</List.Item>
)}
</List>
<List className="me-list" mode="card">
<List.Item extra={'#' + (user?.id || '-')}>用户 ID</List.Item>
<List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}>
+7 -1
View File
@@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom'
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 时序
@@ -40,10 +42,11 @@ 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">
<NavBar back={null}>监控</NavBar>
<NavBar back={null} />
<div className="monitor-content">
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
</div>
@@ -74,6 +77,9 @@ function ServerView() {
return () => clearInterval(timer)
}, [load])
// 后台回前台时立即静默刷新 (不闪 loading), 优于等下一轮 30s 轮询
useRefreshOnVisible(() => load(true))
return (
<div className="server-list">
{items === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
+21 -12
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { Card, List, NavBar } from 'antd-mobile'
import { api } from '../../api/client.js'
import TrendBadge, { calcTrend, lastPoint } from '../../components/TrendBadge.jsx'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
// 经济数据
// 列表态 (默认): 列出所有指标, 每行显示最新值 + 趋势徽标, 点击进入详情
@@ -24,19 +25,18 @@ export default function DataView() {
const { name } = useParams()
const navigate = useNavigate()
useEffect(() => {
let cancelled = false
const load = useCallback(() => {
setLoading(true)
api('/indicators')
.then((data) => {
if (cancelled) return
setIndicators(data.indicators || [])
})
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
.then((data) => { setErr(''); setIndicators(data.indicators || []) })
.catch((e) => setErr(e.message))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
// 后台回前台时重新拉取
useRefreshOnVisible(load)
if (loading) return <div className="loading">加载中...</div>
if (err) return <div className="error-banner">{err}</div>
if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div>
@@ -82,6 +82,10 @@ function DataDetail({ indicator, onBack }) {
const rows = (i.points || [])
// 图表 X 轴统一用 period 字段
const chartRows = rows.map((r) => ({ ...r, period: periodOf(r) }))
// 表格按时间反序: 最新在前
const tableRows = [...rows].reverse()
const [showAll, setShowAll] = useState(false)
const visibleRows = showAll ? tableRows : tableRows.slice(0, 10)
const lp = lastPoint({ rows })
const { trend } = calcTrend(rows)
const title = i.title || i.name
@@ -106,17 +110,22 @@ function DataDetail({ indicator, onBack }) {
</LineChart>
</ResponsiveContainer>
</div>
<div style={{ maxHeight: 240, overflow: 'auto' }}>
<div className={'data-table-wrap' + (showAll ? ' scroll' : '')}>
<table className="data-table">
<thead>
<tr><th>月份</th><th style={{ textAlign: 'right' }}>数值</th></tr>
</thead>
<tbody>
{rows.map((r) => (
{visibleRows.map((r) => (
<tr key={periodOf(r)}><td>{periodOf(r)}</td><td style={{ textAlign: 'right' }}>{r.value}</td></tr>
))}
</tbody>
</table>
{tableRows.length > 10 && !showAll && (
<div className="table-more" onClick={() => setShowAll(true)}>
展开全部(共 {tableRows.length} 行)
</div>
)}
</div>
</Card>
</div>
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useMemo, useState } from 'react'
import { Card, DotLoading, Empty, ErrorBlock } from 'antd-mobile'
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { getOilForecast, getOilHistory, getOilSchedule } from '../../api/oil.js'
// 油价页 (新):
// - 上: 下次调价预测 (无 WTI / 无柱状图)
// - 中: 浙江省最近半年价格走势 (3 油品折线, 一张图)
// - 下: 调价日历 (按年切换)
// 数据源:
// - 预测/日历: apizero 实时 (后端代理)
// - 走势: DB oil_price_history 表 (cron 每天 04:00 抓浙江 3 油品入库)
const FUEL_LABELS = {
'92': '92# 汽油',
'95': '95# 汽油',
'0': '0# 柴油',
}
// 3 油品各自配色 (与原柱状图色调保持一致)
const FUEL_COLORS = {
'92': '#1677ff',
'95': '#52c41a',
'0': '#888',
}
function n(v, d = 2) { return Number.isFinite(Number(v)) ? Number(v).toFixed(d) : '-' }
// 调价预测 (保留, 但不显示 WTI / 布伦特)
function ForecastPanel() {
const [forecast, setForecast] = useState(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState('')
useEffect(() => {
let cancelled = false
setLoading(true)
setErr('')
getOilForecast()
.then((d) => { if (!cancelled) setForecast(d) })
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [])
if (loading) return <div className="monitor-loading"><DotLoading /> 加载中...</div>
if (err) return <ErrorBlock status="default" title="加载失败" description={err} />
const p = forecast?.prediction
return (
<Card className="oil-card">
<div className="oil-card-title">下次调价预测</div>
<div className="oil-prediction">
<div className="oil-pred-row">
<span className="oil-pred-label">调价日</span>
<span className="oil-pred-value">{forecast?.next_adjust_date || '-'}</span>
<span className="oil-pred-tag">剩 {forecast?.days_remaining ?? '-'} 天</span>
</div>
<div className="oil-pred-row">
<span className="oil-pred-label">方向</span>
<span className={'oil-pred-direction ' + (p?.direction || '')}>
{p?.direction_emoji || ''} {p?.direction || '-'}
</span>
<span className="oil-pred-tag">置信度 {p?.confidence || '-'}</span>
</div>
<div className="oil-pred-row">
<span className="oil-pred-label">预计幅度</span>
<span className="oil-pred-value">
{p?.estimated_change_per_ton != null
? `${p.estimated_change_per_ton > 0 ? '+' : ''}${p.estimated_change_per_ton} 元/吨`
: '-'}
{p?.estimated_change_per_liter != null && (
<span className="oil-pred-sub"> ({p.estimated_change_per_liter > 0 ? '+' : ''}{p.estimated_change_per_liter} 元/升)</span>
)}
</span>
</div>
{p?.analysis && (
<div className="oil-pred-analysis">{p.analysis}</div>
)}
</div>
</Card>
)
}
// 浙江省最近半年价格走势
function HistoryPanel() {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
const [err, setErr] = useState('')
useEffect(() => {
let cancelled = false
setLoading(true)
setErr('')
getOilHistory({ days: 180 })
.then((d) => { if (!cancelled) setData(d.items || []) })
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [])
// 整形: rows = [{date, fuel, price}] -> 3 油品并排
// pivot: [{date, 92, 95, 0}]
const chartData = useMemo(() => {
const m = new Map()
for (const r of data) {
if (!m.has(r.date)) m.set(r.date, { date: r.date })
m.get(r.date)[r.fuel] = r.price
}
return Array.from(m.values()).sort((a, b) => a.date.localeCompare(b.date))
}, [data])
// 3 油品都有数据?
const hasData = chartData.length > 0
return (
<Card className="oil-card">
<div className="oil-card-title">浙江省最近半年价格走势</div>
{loading && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{err && <ErrorBlock status="default" title="加载失败" description={err} />}
{!loading && !err && !hasData && (
<Empty description="暂无历史数据 (每天 04:00 自动抓取, 调价次日入库)" />
)}
{!loading && !err && hasData && (
<>
<div className="oil-chart">
<ResponsiveContainer width="100%" height={260}>
<LineChart data={chartData} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} domain={['dataMin - 0.1', 'dataMax + 0.1']} unit="" />
<Tooltip formatter={(v) => `${Number(v).toFixed(3)} 元/升`} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{Object.keys(FUEL_LABELS).map((k) => (
<Line
key={k}
type="monotone"
dataKey={k}
name={FUEL_LABELS[k]}
stroke={FUEL_COLORS[k]}
strokeWidth={2}
dot={{ r: 3 }}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<div className="oil-chart-tip">共 {chartData.length} 个数据点 (调价次日入库)</div>
</>
)}
</Card>
)
}
// 调价日历 (不变)
function SchedulePanel() {
const [year, setYear] = useState(new Date().getFullYear())
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState('')
useEffect(() => {
let cancelled = false
setLoading(true)
setErr('')
getOilSchedule(year)
.then((d) => { if (!cancelled) setData(d) })
.catch((e) => !cancelled && setErr(e.message || '加载失败'))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [year])
const byMonth = useMemo(() => {
const m = {}
if (!data?.schedule) return m
for (const it of data.schedule) {
const mm = it.date.slice(0, 7)
if (!m[mm]) m[mm] = []
m[mm].push(it)
}
return m
}, [data])
return (
<Card className="oil-card">
<div className="oil-card-title-row">
<div className="oil-card-title">调价日历</div>
<div className="oil-year-tabs">
{[2025, 2026].map((y) => (
<button
key={y}
className={'oil-year-tab ' + (y === year ? 'active' : '')}
onClick={() => setYear(y)}
>
{y}
</button>
))}
</div>
</div>
{data?._cached && <div className="oil-cache-hint">本次来自本地缓存, 不消耗 apizero 配额</div>}
{loading && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{err && <ErrorBlock status="default" title="加载失败" description={err} />}
{!loading && !err && data && (
<>
<div className="oil-schedule-meta">
{data.year} 年共 <b>{data.total_adjustments}</b> 个调价窗口
{data.next_adjust_date && (
<span className="oil-pred-tag">下一个: {data.next_adjust_date} (剩 {data.days_remaining} 天)</span>
)}
</div>
<div className="oil-schedule-list">
{Object.keys(byMonth).sort().map((m) => (
<div key={m} className="oil-month">
<div className="oil-month-label">{m}</div>
<div className="oil-month-items">
{byMonth[m].map((it) => (
<div key={it.date} className={'oil-day ' + (it.status === '已过' ? 'past' : 'future')}>
<div className="oil-day-date">{it.date.slice(8)}</div>
<div className="oil-day-status">{it.status}</div>
</div>
))}
</div>
</div>
))}
</div>
</>
)}
</Card>
)
}
// 油价页入口: 预测 + 走势 + 日历
export default function OilPage() {
return (
<div className="oil-page">
<ForecastPanel />
<HistoryPanel />
<SchedulePanel />
</div>
)
}
+319
View File
@@ -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>
)
}
+240 -5
View File
@@ -67,8 +67,10 @@ input, textarea, select { font: inherit; }
to { opacity: 1; transform: translateY(0); }
}
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Me/Article) */
.page-with-navbar { display: flex; flex-direction: column; min-height: 100%; }
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Macro/Article) */
/* height:100% 让页面占满 app-main 高度, 内部 tab-content 独立滚动,
头部 NavBar 与分类 tab 固定在顶部不上滚 */
.page-with-navbar { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.page-with-navbar .adm-error-block { flex: 1; }
/* 底部 antd-mobile TabBar 定位 */
@@ -92,7 +94,7 @@ input, textarea, select { font: inherit; }
.swipe-tabs-wrap {
display: flex;
flex-direction: column;
height: 100%;
flex: 1 1 auto;
min-height: 0;
}
.tab-content {
@@ -170,6 +172,9 @@ input, textarea, select { font: inherit; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--color-border); }
.data-table th { background: #f8f8f8; font-weight: 600; }
.data-table-wrap { position: relative; }
.data-table-wrap.scroll { max-height: 300px; overflow: auto; }
.table-more { text-align: center; padding: 10px 0 2px; font-size: 13px; color: var(--color-primary); cursor: pointer; }
/* 页面标题 */
.page-title { font-size: 20px; font-weight: 700; padding: 14px 16px 4px; }
@@ -206,7 +211,7 @@ input, textarea, select { font: inherit; }
.me-logout { color: var(--color-danger); }
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
/* 首页模块: 报警 + 新闻 */
.home-content { padding: 0 0 16px; }
.home-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.home-content .adm-tabs-content { --content-padding: 0; }
.home-content .tab-label { display: inline-flex; align-items: center; gap: 4px; }
.home-section { padding: 0 12px 16px; }
@@ -235,7 +240,7 @@ input, textarea, select { font: inherit; }
.news-content { font-size: 15px; line-height: 1.75; color: #333; white-space: pre-wrap; word-break: break-word; }
.news-content-empty { margin-top: 12px; font-size: 12px; color: var(--color-muted); }
/* ================== 监控页 ================== */
.monitor-content { padding: 0 0 16px; }
.monitor-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.monitor-content .adm-tabs-content { --content-padding: 0; }
.monitor-tip { font-size: 11px; color: var(--color-muted); padding: 8px 14px 2px; }
.monitor-loading { padding: 40px 0; text-align: center; color: var(--color-muted); font-size: 13px; }
@@ -265,3 +270,233 @@ input, textarea, select { font: inherit; }
.chart-card .adm-card-body { padding: 12px 8px 8px; }
.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 {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.me-ota-actions .adm-button {
--adm-button-border-radius: 6px;
min-width: 84px;
}
.me-ota-progress {
display: flex;
flex-direction: column;
gap: 6px;
padding: 2px 0;
}
.me-ota-progress-text {
font-size: 12px;
color: var(--color-muted);
font-variant-numeric: tabular-nums;
}
.me-ota-error {
color: var(--color-danger);
font-size: 12px;
word-break: break-word;
}
/* ================== 油价页 (Macro/Oil) ================== */
.oil-page {
padding: 0 0 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.oil-card {
margin: 0 12px;
border-radius: 10px;
}
.oil-card .adm-card-body {
padding: 12px;
}
.oil-card-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 8px;
}
.oil-card-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.oil-card-title-row .oil-card-title { margin-bottom: 0; }
.oil-fuel-picker {
font-size: 13px;
color: var(--color-primary);
text-decoration: none;
}
.oil-crude {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.oil-crude-item {
background: #f7f8fa;
border-radius: 8px;
padding: 10px;
text-align: center;
}
.oil-crude-label { font-size: 12px; color: var(--color-muted); }
.oil-crude-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin: 2px 0;
}
.oil-crude-value.up { color: #ff3141; }
.oil-crude-value.down { color: #00b578; }
.oil-crude-unit { font-size: 12px; font-weight: 400; color: var(--color-muted); margin-left: 2px; }
.oil-crude-change { font-size: 12px; font-variant-numeric: tabular-nums; }
.oil-crude-change.up { color: #ff3141; }
.oil-crude-change.down { color: #00b578; }
.oil-prediction {
border-top: 1px solid var(--color-border);
padding-top: 8px;
}
.oil-pred-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
}
.oil-pred-label { color: var(--color-muted); min-width: 76px; }
.oil-pred-value { color: var(--color-text); flex: 1; font-weight: 600; }
.oil-pred-sub { color: var(--color-muted); font-weight: 400; }
.oil-pred-tag {
font-size: 11px;
background: #f0f5ff;
color: var(--color-primary);
padding: 2px 6px;
border-radius: 4px;
}
.oil-pred-direction {
font-weight: 600;
font-size: 14px;
}
.oil-pred-direction.上涨 { color: #ff3141; }
.oil-pred-direction.下跌 { color: #00b578; }
.oil-pred-direction.搁浅 { color: #888; }
.oil-pred-analysis {
margin-top: 6px;
font-size: 12px;
color: var(--color-muted);
line-height: 1.55;
background: #f7f8fa;
padding: 8px 10px;
border-radius: 6px;
}
.oil-chart { margin: 0 -4px; }
.oil-chart-tip {
font-size: 11px;
color: var(--color-muted);
text-align: center;
margin-top: 4px;
}
.oil-year-tabs { display: flex; gap: 4px; }
.oil-year-tab {
border: 1px solid var(--color-border);
background: #fff;
color: var(--color-text);
padding: 4px 10px;
border-radius: 14px;
font-size: 12px;
cursor: pointer;
}
.oil-year-tab.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.oil-cache-hint {
font-size: 11px;
color: #00b578;
background: #e6f7ee;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 8px;
}
.oil-schedule-meta {
font-size: 13px;
color: var(--color-muted);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.oil-schedule-list { display: flex; flex-direction: column; gap: 10px; }
.oil-month-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 4px;
}
.oil-month-items {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.oil-day {
border-radius: 6px;
padding: 6px 4px;
text-align: center;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.oil-day-date { font-weight: 600; font-size: 13px; }
.oil-day-status { color: var(--color-muted); margin-top: 2px; }
.oil-day.past { background: #f5f5f5; color: #999; }
.oil-day.future {
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
color: #fff;
}
.oil-day.future .oil-day-status { color: rgba(255,255,255,.85); }