Compare commits
27
Commits
4f39f428bd
...
dev-0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3140eae7db | ||
|
|
7b4e59957b | ||
|
|
1d69915cf3 | ||
|
|
8e2c6ffa35 | ||
|
|
53f63e0986 | ||
|
|
508419180c | ||
|
|
4f2becb99a | ||
|
|
2fa7c7fc70 | ||
|
|
1b330bdd4d | ||
|
|
5822a01ed7 | ||
|
|
b0bc1e9727 | ||
|
|
68998fa07e | ||
|
|
d5a31e5366 | ||
|
|
05ad191913 | ||
|
|
9ecbf8d8e0 | ||
|
|
44d60471ca | ||
|
|
60628775ac | ||
|
|
add3ff8831 | ||
|
|
22a36fb213 | ||
|
|
a5bc8ef1f1 | ||
|
|
b9ec6fbe0b | ||
|
|
8999b29646 | ||
|
|
4f5cdb4325 | ||
|
|
dbe04b7da3 | ||
|
|
ac8a4b8cd2 | ||
|
|
6bacda05f8 | ||
|
|
f2e53df25b |
@@ -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}"
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测
|
||||
[cron] 已注册: 每天 04:00 浙江油价历史抓取
|
||||
@@ -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 有逆向基础、炎米零资料)。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
|
||||
@@ -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
@@ -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)永不提交。
|
||||
Generated
+1032
-458
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 排除.
|
||||
|
||||
@@ -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 指向该目录。')
|
||||
@@ -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 已注入"
|
||||
@@ -0,0 +1,33 @@
|
||||
// 临时诊断: 检查 News 表 content/summary 的换行与长度情况 (用完即删)
|
||||
require('dotenv').config()
|
||||
const mysql = require('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(/^\//, '') }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
const [rows] = await conn.execute(
|
||||
'SELECT id, title, summary, content, CHAR_LENGTH(content) AS clen, CHAR_LENGTH(summary) AS slen FROM `News` ORDER BY id DESC LIMIT 10'
|
||||
)
|
||||
console.log('总条数检查:')
|
||||
const [[cnt]] = await conn.execute('SELECT COUNT(*) AS c, SUM(content IS NULL) AS nullContent FROM `News`')
|
||||
console.log(JSON.stringify(cnt))
|
||||
for (const r of rows) {
|
||||
const content = r.content || ''
|
||||
const summary = r.summary || ''
|
||||
const lf = (content.match(/\n/g) || []).length
|
||||
const cr = (content.match(/\r/g) || []).length
|
||||
const sLf = (summary.match(/\n/g) || []).length
|
||||
console.log('---')
|
||||
console.log('id=' + r.id + ' clen=' + r.clen + ' slen=' + r.slen + ' contentLF=' + lf + ' contentCR=' + cr + ' summaryLF=' + sLf)
|
||||
console.log('title: ' + r.title)
|
||||
console.log('summary: ' + JSON.stringify(summary.slice(0, 120)))
|
||||
console.log('content(hex首200): ' + content.slice(0, 200).replace(/[^\x20-\x7E\u4e00-\u9fa5]/g, (c) => '\\x' + c.charCodeAt(0).toString(16)))
|
||||
console.log('content(带转义首300): ' + JSON.stringify(content.slice(0, 300)))
|
||||
}
|
||||
await conn.end()
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -55,6 +55,12 @@ function parseNewsSheet(sheet, impactBase = 0) {
|
||||
const publishedAt = parsePublishedAt(r[2])
|
||||
if (!publishedAt) continue
|
||||
const body = String(r[1] || '').trim()
|
||||
// 数据源段落间用全角空格连接, 入库前统一转成换行, 保证详情页有段落感
|
||||
.replace(/\u3000+/g, '\n')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
out.push({
|
||||
title,
|
||||
summary: body.slice(0, 180),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)'}`)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+658
-8
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { SafeArea } from 'antd-mobile'
|
||||
import BottomNav from '../components/BottomNav.jsx'
|
||||
|
||||
@@ -8,11 +9,23 @@ import BottomNav from '../components/BottomNav.jsx'
|
||||
// - 底部 TabBar: antd-mobile TabBar 自带底部安全区
|
||||
// 解决: 状态栏遮挡/底部 TabBar 遮挡/左右贴边
|
||||
export default function MobileShell() {
|
||||
const location = useLocation()
|
||||
// pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树,
|
||||
// 避免破坏 MacroPage 等页内子路由的挂载状态与滚动位置)
|
||||
const [animating, setAnimating] = useState(false)
|
||||
useEffect(() => {
|
||||
setAnimating(true)
|
||||
const t = setTimeout(() => setAnimating(false), 320)
|
||||
return () => clearTimeout(t)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
<SafeArea position="top" />
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
<div className={'page-transition' + (animating ? ' active' : '')}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
|
||||
+24
-21
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+167
-4
@@ -1,13 +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
|
||||
@@ -16,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"
|
||||
@@ -30,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() : '-'}>
|
||||
@@ -44,6 +206,7 @@ export default function MePage() {
|
||||
退出登录
|
||||
</List.Item>
|
||||
</List>
|
||||
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -51,17 +54,21 @@ export default function MonitorPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 主机监控列表模块级缓存: 切走再进入时先展示上次数据, 拉取成功后再静默更新
|
||||
let cachedServers = null
|
||||
|
||||
// 主机监控: komari 实时节点列表 (30s 轮询, 静默刷新)
|
||||
function ServerView() {
|
||||
const [items, setItems] = useState(null) // null = 加载中
|
||||
const [items, setItems] = useState(cachedServers) // 有缓存直接先展示, 无缓存为 null 进入加载态
|
||||
const [err, setErr] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const load = useCallback((silent) => {
|
||||
if (!silent) { setErr(''); setItems(null) }
|
||||
if (!silent && cachedServers === null) setItems(null) // 仅无缓存时显示加载中
|
||||
if (!silent) setErr('')
|
||||
api('/servers')
|
||||
.then((data) => { setErr(''); setItems(data.items || []) })
|
||||
.catch((e) => { if (!silent) { setErr(e.message); setItems([]) } })
|
||||
.then((data) => { cachedServers = data.items || []; setErr(''); setItems(data.items || []) })
|
||||
.catch((e) => { if (!silent) { setErr(e.message); if (cachedServers === null) setItems([]) } })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,13 +77,16 @@ function ServerView() {
|
||||
return () => clearInterval(timer)
|
||||
}, [load])
|
||||
|
||||
// 后台回前台时立即静默刷新 (不闪 loading), 优于等下一轮 30s 轮询
|
||||
useRefreshOnVisible(() => load(true))
|
||||
|
||||
return (
|
||||
<div className="server-list">
|
||||
{items === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
{err && (
|
||||
<div className="error-banner">
|
||||
{err}
|
||||
<span className="retry-link" onClick={load}>重试</span>
|
||||
<span className="retry-link" onClick={() => load(false)}>重试</span>
|
||||
</div>
|
||||
)}
|
||||
{items !== null && !err && items.length === 0 && <Empty description="暂无主机" />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,18 @@ function fmtDate(s) {
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||
}
|
||||
|
||||
// 正文规范化: 数据源(妙想 MCP)的段落间用全角空格 \u3000 连接而非换行,
|
||||
// 这里统一转为换行, 保证详情页有段落感; 换行本身存储/传输是完好的
|
||||
function normalizeContent(s) {
|
||||
if (!s) return s
|
||||
return s
|
||||
.replace(/\u3000+/g, '\n') // 连续全角空格(段落分隔) → 换行
|
||||
.replace(/\u00a0/g, ' ') // 不换行空格 → 普通空格
|
||||
.replace(/[ \t]+\n/g, '\n') // 行尾多余空白
|
||||
.replace(/\n{3,}/g, '\n\n') // 压缩连续空行
|
||||
.trim()
|
||||
}
|
||||
|
||||
export default function NewsDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -46,7 +58,7 @@ export default function NewsDetail() {
|
||||
)}
|
||||
</div>
|
||||
<div className="news-content">
|
||||
{item.content ? item.content : item.summary}
|
||||
{normalizeContent(item.content ? item.content : item.summary)}
|
||||
{!item.content && <div className="news-content-empty">(本条暂无全文,仅摘要)</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+251
-6
@@ -58,8 +58,19 @@ input, textarea, select { font: inherit; }
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Me/Article) */
|
||||
.page-with-navbar { display: flex; flex-direction: column; min-height: 100%; }
|
||||
/* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */
|
||||
.page-transition.active {
|
||||
animation: page-in 0.28s ease;
|
||||
}
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 带有 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 定位 */
|
||||
@@ -83,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 {
|
||||
@@ -161,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; }
|
||||
@@ -195,8 +209,9 @@ input, textarea, select { font: inherit; }
|
||||
.me-list { margin-top: 12px; }
|
||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||
.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; }
|
||||
@@ -213,7 +228,7 @@ input, textarea, select { font: inherit; }
|
||||
.news-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.news-card .adm-card-body { padding: 10px 12px; }
|
||||
.news-title { font-size: 14px; font-weight: 600; line-height: 1.4; color: var(--color-text); }
|
||||
.news-summary { font-size: 12px; color: var(--color-muted); margin-top: 4px; line-height: 1.5; max-height: 3.6em; overflow: hidden; }
|
||||
.news-summary { font-size: 12px; color: var(--color-muted); margin-top: 4px; line-height: 1.5; word-break: break-word; overflow-wrap: anywhere; }
|
||||
.news-meta { display: flex; gap: 10px; font-size: 11px; color: var(--color-muted); margin-top: 6px; }
|
||||
.news-rank { color: var(--color-primary); font-weight: 600; }
|
||||
.news-card:active .adm-card-body { background: #f5f5f5; }
|
||||
@@ -225,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; }
|
||||
@@ -255,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); }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// 应用版本信息唯一汇聚点
|
||||
// - APP_VERSION: 唯一手动管理点 = package.json 的 version 字段 (发版时改那里)
|
||||
// - BUILD_ID: 每次编译自动生成 (vite.config.js define 注入 __BUILD_ID__), 无需手动维护
|
||||
import pkg from '../package.json'
|
||||
|
||||
export const APP_VERSION = pkg.version || '0.0.0'
|
||||
// __BUILD_ID__ 机制:
|
||||
// - build 时: vite:define 编译期替换为字符串字面量
|
||||
// - dev 时: vite 通过 /@vite/env 把值挂到 globalThis, 源码保持原样;
|
||||
// typeof 防御避免 /@vite/env 未执行时 ReferenceError, 回退 'dev'
|
||||
export const BUILD_ID = typeof __BUILD_ID__ !== 'undefined' ? __BUILD_ID__ : 'dev'
|
||||
@@ -1,12 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// 每次编译生成唯一 build 号 (YYYYMMDD.HHmmss), 通过 define 注入到 __BUILD_ID__
|
||||
// 版本号 APP_VERSION 唯一管理点在 package.json 的 version 字段 (手动修改)
|
||||
function makeBuildId() {
|
||||
const d = new Date()
|
||||
const p = (n, w) => String(n).padStart(w || 2, '0')
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||
}
|
||||
const BUILD_ID = makeBuildId()
|
||||
|
||||
// 移动端优先: 默认按移动视口开发, 同时兼容桌面浏览器
|
||||
// HMR 走 WebSocket; 某些受限 webview (VS Code Simple Browser / 老版 Android WebView) 不支持,
|
||||
// 此时 Vite 会自动降级为整页刷新兜底 (full-reload 与 HMR 共用同一 WS 通道)。
|
||||
// 注意: 不能设 hmr: false —— 那会连 WS 通道一起关掉, 文件变更不再触发任何刷新。
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__BUILD_ID__: JSON.stringify(BUILD_ID), // 构建时生成的唯一 build 号
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user