39 Commits
Author SHA1 Message Date
cheney 3140eae7db chore: 版本号统一为 V0.1 (package.json 1.0.0 -> 0.1.0, 与 tauri.conf.json/Cargo.toml 对齐) + readme 补充版本管理约定 2026-10-22 09:59:26 +08:00
jif2.zhang 7b4e59957b 修复容器内 news/oil 定时失效: 运行时镜像携带 scripts/ + axios 转生产依赖 + cron 脚本模块惰性加载降级
Docker Build and Push / build-image (push) Failing after 9m34s
2026-09-21 14:09:18 +08:00
jif2.zhang 1d69915cf3 米家调度独立模块注册, 避免被容器中无法加载的旧 cron.js 连带失效
Docker Build and Push / build-image (push) Successful in 4m36s
2026-09-21 11:52:24 +08:00
jif2.zhang 8e2c6ffa35 新增米家环境监控模块 (采集/聚合/历史/前端 EnvView) + CI 挂载米家凭证目录并自动应用迁移
Docker Build and Push / build-image (push) Successful in 6m54s
2026-09-21 11:42:58 +08:00
jif2.zhang 53f63e0986 新增 CI secrets 同步到注册中心的独立 workflow
Docker Build and Push / build-image (push) Successful in 10m57s
2026-09-20 20:32:54 +08:00
cheney 508419180c 调整App头部显示,固定头部和分类
Docker Build and Push / build-image (push) Successful in 4m56s
2026-08-08 19:40:39 +08:00
cheney 4f2becb99a 调整App头部显示,避免与系统消息栏重合
Docker Build and Push / build-image (push) Successful in 4m51s
2026-08-08 19:20:30 +08:00
cheney 2fa7c7fc70 remove pagespy
Docker Build and Push / build-image (push) Successful in 4m56s
2026-08-08 18:12:48 +08:00
cheney 1b330bdd4d 移除 reasonix.toml
Docker Build and Push / build-image (push) Successful in 5m0s
2026-08-08 11:44:12 +08:00
cheney 5822a01ed7 fix rust env
Android Build & Publish / build-android (push) Successful in 18m36s
2026-08-08 10:40:33 +08:00
cheney b0bc1e9727 开放 http
Android Build & Publish / build-android (push) Failing after 2m12s
2026-08-08 10:32:28 +08:00
cheney 68998fa07e token 名字修改
Android Build & Publish / build-android (push) Successful in 42m17s
2026-08-08 09:13:23 +08:00
cheney d5a31e5366 修改签名脚本
Android Build & Publish / build-android (push) Failing after 39m54s
2026-08-08 08:28:49 +08:00
cheney 05ad191913 环境变量
Android Build & Publish / build-android (push) Failing after 23m5s
2026-08-08 07:52:39 +08:00
cheney 9ecbf8d8e0 更新脚本
Android Build & Publish / build-android (push) Failing after 18m32s
2026-08-08 07:01:50 +08:00
cheney 44d60471ca fix: 修复登录成功但未跳转内部页面的竞态
Docker Build and Push / build-image (push) Successful in 6m12s
LoginPage.onSubmit 在 setUser 异步提交前手动 navigate(from),
切到受保护页时 user 仍为 null, 被 RequireAuth 立即踢回 /login,
导致提示登录成功却进不去内部页面。改为依赖组件顶部
<Navigate to={from} replace/> 在 user 就绪后可靠跳转。
2026-08-08 05:37:40 +08:00
cheney 60628775ac ota 升级地址
Docker Build and Push / build-image (push) Successful in 5m9s
2026-08-07 18:11:43 +08:00
cheney add3ff8831 修改 ota cicd
Docker Build and Push / build-image (push) Successful in 6m37s
2026-08-07 17:10:07 +08:00
cheney 22a36fb213 fix 打开 ota 授权
Docker Build and Push / build-image (push) Successful in 2m51s
2026-08-07 16:24:10 +08:00
cheney a5bc8ef1f1 功能完成
Docker Build and Push / build-image (push) Successful in 2m53s
2026-08-07 14:56:27 +08:00
cheney b9ec6fbe0b 油价集成 2026-08-07 14:36:57 +08:00
cheney 8999b29646 跟新 CICD,每次制作 ota 升级包
Docker Build and Push / build-image (push) Successful in 2m50s
2026-08-07 10:04:57 +08:00
cheney 4f5cdb4325 OTA
Docker Build and Push / build-image (push) Successful in 2m55s
2026-08-07 09:50:42 +08:00
cheney dbe04b7da3 ota 2026-08-07 09:49:26 +08:00
cheney ac8a4b8cd2 准备切换分支 2026-08-07 09:10:40 +08:00
cheney 6bacda05f8 fix bugs
Docker Build and Push / build-image (push) Successful in 3m2s
2026-08-06 17:43:21 +08:00
cheney f2e53df25b 怎加版本 2026-08-06 17:27:23 +08:00
cheney 4f39f428bd 热加载
Docker Build and Push / build-image (push) Successful in 3m0s
2026-08-06 17:12:32 +08:00
cheney eddddc56ae 完成基础功能
Docker Build and Push / build-image (push) Successful in 3m1s
2026-08-06 16:55:03 +08:00
cheney 4d02439ecb 更新首页 2026-08-06 16:41:47 +08:00
cheney 547181d333 更新首页 2026-08-06 15:39:59 +08:00
cheney ec15f40323 数据源修改 2026-08-06 15:24:55 +08:00
cheney b10b1c44db 修改数据获取方式 2026-08-06 14:00:11 +08:00
cheney 4da9489662 修改数据获取方法 2026-08-06 13:58:56 +08:00
cheney 7fcd0c9569 页面调整 2026-08-06 11:26:54 +08:00
Cheney 7f72bf809b 更换 UI 2026-08-06 09:32:15 +08:00
cheney 28d1b5d1c6 添加调试,网络正常
Docker Build and Push / build-image (push) Successful in 2m45s
2026-08-06 08:58:22 +08:00
cheney 789505fb34 fix
Docker Build and Push / build-image (push) Successful in 3m23s
2026-08-04 09:33:52 +08:00
Cheney 3d84e8d8f7 停止追踪 dist 目录 2026-08-04 09:33:31 +08:00
142 changed files with 29095 additions and 3208 deletions
-1
View File
@@ -1 +0,0 @@
DATABASE_URL="mysql://root:fullstack@192.168.111.111:3306/iboard"
+177
View File
@@ -0,0 +1,177 @@
name: Android Build & Publish
# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0)
# 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry
on:
push:
branches:
- 'android-*'
jobs:
build-android:
runs-on: ubuntu-latest
# 需要写权限: 上传 packages
permissions:
contents: read
packages: write
env:
# 固定工具链版本, 便于 actions/cache 命中
# Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致
VITE_API_BASE: http://bh.vps.honor3.com:3001
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Setup JDK 17
uses: https://gitea.com/actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
# ---------- 缓存: 命中后跳过下面的安装 ----------
- name: Cache Android SDK & NDK
id: cache-android
uses: https://gitea.com/actions/cache@v4
with:
path: ~/android-sdk
key: android-sdk-ndk-27.2-${{ runner.os }}
- name: Cache Rust toolchain
id: cache-rust
uses: https://gitea.com/actions/cache@v4
with:
path: |
~/.cargo
~/.rustup
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
key: rust-android-v1-${{ runner.os }}
- name: Cache Tauri cargo target
uses: https://gitea.com/actions/cache@v4
with:
path: src-tauri/target
key: tauri-target-android-${{ runner.os }}
# ---------- 安装工具链 (仅缓存未命中时) ----------
- name: Install Rust + Android targets
if: steps.cache-rust.outputs.cache-hit != 'true'
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
. "$HOME/.cargo/env"
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
cargo install cargo-ndk --locked
- name: Install Android SDK & NDK
if: steps.cache-android.outputs.cache-hit != 'true'
run: |
export ANDROID_HOME="$HOME/android-sdk"
sudo apt-get update
sudo apt-get install -y --no-install-recommends unzip
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip
unzip -q /tmp/cmdline-tools.zip -d /tmp/
mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
yes | sdkmanager --licenses >/dev/null || true
sdkmanager --install \
"platforms;android-36" \
"build-tools;36.0.0" \
"platform-tools" \
"ndk;27.2.12479018" \
"cmake;3.22.1"
- name: Install frontend deps
run: npm ci --no-audit --no-fund
# ---------- 全局 Android 环境 (对后续所有 step 生效, 无论缓存是否命中) ----------
- name: Set Android env
run: |
echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=$HOME/android-sdk/ndk/27.2.12479018" >> "$GITHUB_ENV"
echo "$HOME/android-sdk/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
echo "$HOME/android-sdk/platform-tools" >> "$GITHUB_PATH"
# ---------- 构建 ----------
- name: Init Android project
run: |
# tauri android init 需要 cargo (运行 cargo metadata)。
# Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH,
# 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。
. "$HOME/.cargo/env"
cd src-tauri
npx tauri android init --ci
- name: Inject release signing config
run: bash scripts/ci/android-signing.sh
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: Build release APK
run: |
export ANDROID_HOME="$HOME/android-sdk"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
export CARGO_HOME="$HOME/.cargo"
. "$HOME/.cargo/env"
cd src-tauri
npx tauri android build --apk
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
exit 1
fi
- name: Locate APK
id: apk
run: |
# 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK
APK=""
UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1)
if [ -n "$UNIVERSAL" ]; then
APK="$UNIVERSAL"
else
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1)
fi
if [ -z "$APK" ]; then
echo "ERROR: 未找到已签名 APK" >&2
exit 1
fi
echo "path=$APK" >> $GITHUB_OUTPUT
ls -lh "$APK"
# ---------- 上传到 Gitea Generic Package ----------
- name: Publish APK to Gitea packages
env:
# 注意: 不能用 GITEA_TOKEN (内置 job token, 无组织 packages 写权限, 且命名规则禁止 GITEA_ 前缀)
# 用一个自定义名 secret 存放有 packages 写权限的 user token
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
APK_PATH: ${{ steps.apk.outputs.path }}
run: |
set -euo pipefail
# 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一
BRANCH="${GITHUB_REF_NAME#android-}"
PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}"
FILE_NAME="iboard-$(basename "$APK_PATH")"
OWNER="${GITHUB_REPOSITORY_OWNER}"
PKG_NAME="iboard"
# Gitea Generic Package Registry 上传端点 (PUT)
# 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com
URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}"
echo "上传到: ${URL}"
curl -sS --fail-with-body \
-H "Authorization: token ${PACKAGE_TOKEN}" \
--upload-file "$APK_PATH" \
"${URL}"
echo ""
echo "已发布: ${URL}"
+53 -2
View File
@@ -1,13 +1,54 @@
name: Docker Build and Push
on: [push]
# 仅 main 分支 push 触发, 其他分支不触发
# 每次主分支提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 部署
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
on:
push:
branches:
- main
- master
jobs:
build-image:
runs-on: ubuntu-latest
env:
# 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
VITE_API_BASE: http://bh.vps.honor3.com:3001
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
- name: Install deps
run: npm ci --no-audit --no-fund
- name: Build frontend
# dist/ 供 OTA 打包用 (Dockerfile 内也会构建一份)
run: npm run build
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Build & sign OTA package
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
OTA_NOTES: ${{ github.sha }}
run: |
set -euo pipefail
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES}
echo "===== latest.json ====="
cat ota-package/latest.json
echo "===== bundle sha256 ====="
sha256sum ota-package/iboard-ota.tar.gz
- name: Build Docker image
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build .
@@ -15,6 +56,12 @@ jobs:
- name: List images
run: docker images
- name: Apply DB migrations
# 仓库内的 prisma 迁移自动应用 (migrate deploy 幂等, 无新迁移时为 no-op)
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npx prisma migrate deploy
- name: Remove old container
run: docker rm -vf iboard || true
@@ -23,11 +70,15 @@ jobs:
- name: Run container
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
# OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package
# 米家凭证 (auth.txt) 不进 git: 运维放宿主机 /app/iboard/cache, bind-mount 进容器 (MIHOME_CACHE_DIR=/app/cache)
run: |
docker run -d --name iboard -p 3001:3001 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-e JWT_SECRET="${{ secrets.JWT_SECRET }}" \
-e MIHOME_CACHE_DIR=/app/cache \
-v /app/iboard/cache:/app/cache \
iboard:latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
+75
View File
@@ -0,0 +1,75 @@
name: OTA Frontend Build & Sign
# 前端 OTA 升级包构建 + 签名 + 上传 artifact
# 触发: 仅手动 (workflow_dispatch), 发版前用
# - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建
# 输入: sequence / notes / min_binary (可覆盖默认)
# Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)
# 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey,
# 换密钥请手动改 conf 并 commit.
on:
workflow_dispatch:
inputs:
sequence:
description: 'OTA sequence (单调递增整数, 留空用 timestamp)'
required: false
type: string
notes:
description: '发布说明 (写入 latest.json.notes)'
required: false
type: string
default: ''
min_binary:
description: '最低 binary 版本 (留空用 src-tauri/tauri.conf.json 的 version)'
required: false
type: string
default: ''
jobs:
build-ota:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
- name: Install deps
run: npm ci --no-audit --no-fund
- name: Build frontend
# 复用 vite build 产物; 不打 Docker (本 workflow 只关心前端)
run: npm run build
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Build & sign OTA package
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
OTA_NOTES: ${{ inputs.notes }}
run: |
set -euo pipefail
OUT="ota-package"
ARGS="--out ${OUT}"
# inputs 留空时 → 不传 --sequence/--min-binary, 脚本默认 sequence=时间戳, min-binary=tauri.conf version
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
node scripts/build-ota.mjs ${ARGS}
echo "===== latest.json ====="
cat ${OUT}/latest.json
echo "===== bundle sha256 ====="
sha256sum ${OUT}/iboard-ota.tar.gz
- name: Upload OTA package as artifact
uses: https://gitea.com/actions/upload-artifact@v4
with:
name: iboard-ota-${{ inputs.sequence || github.run_number }}
path: ota-package/
retention-days: 30
if-no-files-found: error
+52
View File
@@ -0,0 +1,52 @@
name: Sync Secrets to Registry
# 将本仓库 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
# 目标: service-iboard 项目 (proj-msid70ql-6xbq4w)
# 触发: 仅手动 (workflow_dispatch)
# 依赖 secret:
# - REGISTRY_MCP_TOKEN : 注册中心 MCP 访问令牌 (在注册中心 🔑Token 管理 里生成)
# - 其余 secret 与三个构建 workflow 引用的保持一致 (未设置则跳过对应键)
# 注意: 传输为明文 http, 请保证注册中心仅内网可达; 若可访问则改用 https
on:
workflow_dispatch:
inputs:
dry_run:
description: '仅预演不写入 (true=只打印计划)'
required: false
default: 'false'
jobs:
sync-secrets:
runs-on: ubuntu-latest
permissions:
contents: read
env:
REGISTRY_MCP_URL: http://bh.vps.honor3.com:8008/mcp
REGISTRY_PROJECT_ID: proj-msid70ql-6xbq4w
REGISTRY_IDC: '1'
REGISTRY_ENVIRONMENT: '1'
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
- name: Sync secrets via MCP
run: |
if [ "${{ inputs.dry_run }}" = "true" ]; then
node scripts/sync-registry.mjs --dry-run
else
node scripts/sync-registry.mjs
fi
env:
REGISTRY_MCP_TOKEN: ${{ secrets.REGISTRY_MCP_TOKEN }}
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
+15
View File
@@ -30,3 +30,18 @@ lerna-debug.log*
*.swp
*.swo
*~
dist/*
# local env (contains API keys)
/.env
.reasonix/*
# OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交)
ota-package/*
!ota-package/README.md
# apizero 文件缓存 (运行时按需生成)
cache/apizero/
# Dockerfile 持久卷挂载点 (跟 ota-package 同类)
.cache/
# 米家凭证缓存 (含 token, 绝不能提交)
server/mihome/.cache/
+13
View File
@@ -1,6 +1,7 @@
# ===============================================================
# iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程
# 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest .
# 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址
# 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest
# ===============================================================
FROM node:22-alpine AS builder
@@ -24,6 +25,9 @@ COPY . .
# 构建前端 (输出到 dist/) 与 Prisma 客户端
RUN npx prisma generate
# build 时把 VITE_API_BASE 写进 bundle, 默认空 = 走相对路径 /api (同源 Express)
ARG VITE_API_BASE=
ENV VITE_API_BASE=$VITE_API_BASE
RUN npm run build
# 运行时阶段
@@ -44,9 +48,18 @@ COPY --from=builder /app/server ./server
COPY --from=builder /app/generated ./generated
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/content ./content
COPY --from=builder /app/public ./public
# OTA 升级包: CI (docker-build.yml) 生成的 ota-package/, 随镜像发布
# 本地构建时只有占位文件 (ota-package/README.md), server 找不到 latest.json 则 OTA 返回"无更新"
COPY ota-package ./ota-package
ENV OTA_PACKAGE_DIR=/app/ota-package
# OTA bundle 下载用的绝对 base (manifest.url 必须是绝对 URL, 插件不拼 base, 相对路径会导致下载 0% 失败)
# 生产为 http 明文 (无 https), 客户端 tauri.conf.json 的 hotswap.require_https 已设为 false 放行 http
ENV OTA_PUBLIC_BASE=http://bh.vps.honor3.com:3001
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1
+24
View File
@@ -0,0 +1,24 @@
node:events:497
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE: address already in use :::3001
at Server.setupListenHandle [as _listen2] (node:net:1940:16)
at listenInCluster (node:net:1997:12)
at Server.listen (node:net:2102:7)
at Function.listen (D:\workbench\iboard\node_modules\express\lib\application.js:635:24)
at file:///D:/workbench/iboard/server/index.js:57:5
at ModuleJob.run (node:internal/modules/esm/module_job:345:25)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:651:26)
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
Emitted 'error' event on Server instance at:
at emitErrorNT (node:net:1976:8)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
code: 'EADDRINUSE',
errno: -4091,
syscall: 'listen',
address: '::',
port: 3001
}
Node.js v22.19.0
+2
View File
@@ -0,0 +1,2 @@
[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测
[cron] 已注册: 每天 04:00 浙江油价历史抓取
-145
View File
@@ -1,145 +0,0 @@
import { useState, useEffect } from 'react'
const TimeRangePicker = ({
value = '1m',
onChange,
className = ''
}) => {
const [timeRange, setTimeRange] = useState(value)
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [showDropdown, setShowDropdown] = useState(false)
// 计算日期范围
const calculateDateRange = (range) => {
const end = new Date()
const start = new Date()
if (range === '1m') {
start.setMonth(start.getMonth() - 1)
} else if (range === '3m') {
start.setMonth(start.getMonth() - 3)
} else if (range === '1y') {
start.setFullYear(start.getFullYear() - 1)
}
const formatDate = (date) => {
return date.toISOString().split('T')[0]
}
return {
start: formatDate(start),
end: formatDate(end)
}
}
// 初始化时设置日期范围
useEffect(() => {
const range = calculateDateRange(value)
setStartDate(range.start)
setEndDate(range.end)
}, [value])
// 处理时间范围变化
const handleTimeRangeChange = (range) => {
const dateRange = calculateDateRange(range)
setStartDate(dateRange.start)
setEndDate(dateRange.end)
setTimeRange(range)
if (onChange) {
onChange(range, dateRange.start, dateRange.end)
}
setShowDropdown(false)
}
// 处理自定义日期范围
const handleCustomRangeApply = () => {
if (startDate && endDate) {
setTimeRange('custom')
if (onChange) {
onChange('custom', startDate, endDate)
}
setShowDropdown(false)
}
}
// 处理开始日期变化
const handleStartDateChange = (e) => {
setStartDate(e.target.value)
}
// 处理结束日期变化
const handleEndDateChange = (e) => {
setEndDate(e.target.value)
}
return (
<div className={`time-range-picker ${className}`}>
<button
className="time-range-btn"
onClick={() => setShowDropdown(!showDropdown)}
>
{timeRange === '1m' && '近一个月'}
{timeRange === '3m' && '近三个月'}
{timeRange === '1y' && '近一年'}
{timeRange === 'custom' && startDate && endDate && `${startDate} 至 ${endDate}`}
{timeRange === 'custom' && (!startDate || !endDate) && '自定义'}
<span className="dropdown-arrow">▼</span>
</button>
{showDropdown && (
<div className="time-range-dropdown">
<div className="dropdown-section">
<h4 className="dropdown-title">自定义范围</h4>
<div className="custom-date-range">
<input
type="date"
value={startDate}
onChange={handleStartDateChange}
max={endDate || new Date().toISOString().split('T')[0]}
/>
<span>至</span>
<input
type="date"
value={endDate}
onChange={handleEndDateChange}
min={startDate}
max={new Date().toISOString().split('T')[0]}
/>
<button
className="apply-btn"
onClick={handleCustomRangeApply}
>
确定
</button>
</div>
</div>
<div className="dropdown-section">
<h4 className="dropdown-title">快捷选项</h4>
<ul className="dropdown-options">
<li
className={`dropdown-option ${timeRange === '1m' ? 'active' : ''}`}
onClick={() => handleTimeRangeChange('1m')}
>
近一个月
</li>
<li
className={`dropdown-option ${timeRange === '3m' ? 'active' : ''}`}
onClick={() => handleTimeRangeChange('3m')}
>
近三个月
</li>
<li
className={`dropdown-option ${timeRange === '1y' ? 'active' : ''}`}
onClick={() => handleTimeRangeChange('1y')}
>
近一年
</li>
</ul>
</div>
</div>
)}
</div>
)
}
export default TimeRangePicker
File diff suppressed because one or more lines are too long
-127
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1f2937"/><text x="16" y="22" text-anchor="middle" font-family="Arial" font-size="16" font-weight="bold" fill="#fff">i</text></svg>

Before

Width:  |  Height:  |  Size: 233 B

-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1f2937" />
<title>iBoard - 经济数据与策略</title>
<script type="module" crossorigin src="/assets/index-z5hTmGIR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DU437yrj.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+392
View File
@@ -0,0 +1,392 @@
# 环境监控模块设计(米家温湿度长期记录)
> 目标项目: iboard(Tauri + React + Express + Prisma/MariaDB)
> 状态: 设计稿 v1.1 · 2026-09-20 · 待评审(v1.1: 视图新增 今天/月/年三档粒度 + 标题显示电量)
## 1. 需求与目标
在监控页新增「环境监控」tab,实现**多家庭**下温湿度**长期记录**:
1. 监控页新增一个 tab「环境监控」。
2. 多家庭支持:每个家庭一个可折叠 group,**手风琴**式展开(同一时间只能展开一个)。
3. 每个设备可独立修改**别名**(仅本应用展示用,不改动米家云端设备名)。
4. 数据**非实时**(定时批量,不做推送):**每小时采样 1 次**落库小时档,每日聚合成日档。
5. 记录保留**永久**(无清理任务)。
6. 缺失补齐语义(用户确认):
- **应用停机**导致的缺失 → 应用恢复后自动重新获取补齐(有统计的型号经云端聚合真实回填,
见 §2.2B;无统计的型号保持缺口标注);
- **设备自身原因**(离线/故障)的缺失 → 保持缺失,不伪造数据;
- 总原则:**本地数据不少于米家可提供的数据**。
7. 米家授权失效 → 前端显示「重新授权」按钮,点击弹出**二维码**,人工扫码后恢复。
8. 每设备数据**默认显示当天**,粒度**小时**。
9. 每设备数据可切换为**月**,粒度**天**。
10. 每设备数据可切换为**年**,粒度**周**。
11. 设备卡片**标题 = 别名 + 电量**(电量取当天缓存值,不必频繁更新)。
已确认的数据范围:仅**温湿度**(不含照度 / 人体存在)。
## 2. 已验证事实(调研结论,作为设计地基)
调研用的账号 `18814803393`,登录链路与数据能力已实测(脚本位于
`C:\Users\xydkj\AppData\Local\Temp\opencode\mibridge-research\`):
| 项目 | 结论 | 证据 |
| --- | --- | --- |
| 登录方式 | 密码登录被米家拒绝(`70016`);**扫码登录可行** | `qr-once.mjs` |
| Token 有效期 | 30 天,`DiskCache` 持久化,脚本自动加载刷新 | `.mijia-cache/` |
| 实时读值 | `api.getDevicesProp([{did,siid,piid}])` 稳定可用,100 项请求 10 轮 100% 成功 | `env-monitor.mjs` |
| 单轮延迟 | p50 ≈ 520ms,p95 ≈ 612ms(10 项属性/轮) | 同上 |
| VRF 空调目标温写入 | 整数生效但**生效延迟 5~6s**(updateTime 才更新);半度 26.5 被服务端拒收(`-704220043`) | `vrf-temp-test*.mjs` |
| 温湿度历史读取 | **不可读**:`history-data(8.1)`/`start-time(8.3)` 返回 `-704010000`;`start-synic` 动作要求参数;`/v2/home/device/prop_history` 类端点不可达 | `dev-history-probe.mjs` |
### 2.1 环境设备清单(共 3 台,均属家庭「黄金农场」505001017889)
| 家庭 | 设备 | 型号 | did | 温度 | 湿度 | 电量 |
| --- | --- | --- | --- | --- | --- | --- |
| 黄金农场 | 炎米智能温湿度计 | `ym001.sensor_ht.ymwsdj` | `blt.3.1nmu8afgt0g00` | 2.1001 | 2.1002 | `3.1003` |
| 黄金农场 | 炎米智能温湿度计 2 | `ym001.sensor_ht.ymwsdj` | `blt.3.1nmu8eunt0001` | 2.1001 | 2.1002 | `3.1003` |
| 黄金农场 | 闹钟(青萍,含温湿) | `cgllc.clock.dove` | `blt.3.12cq3lu8glo00` | 2.1 | 2.2 | `2.3` |
> 第二个家庭「韩家」(586001550228) 当前无环境设备,设计需容忍空家庭。
### 2.2 关键约束:逐点历史不可达,但云端「聚合统计历史」可用(实测反转)
原结论"云端历史完全不可达"已推翻。实测分两条:
**A. 逐点历史(设备本地日志)云 API 不可达**
- 炎米温湿度计:规格含 `8.1 history-data / 8.3 start-time / start-synic`,但 8.1/8.3 读取全返回
`-704010000`(不可读),`start-synic` 动作试了 8 种参数形态全部报 `-704220025 参数个数不匹配`,
判定为"局域网直连通道",云侧无暴露;
- 青萍闹钟:miot 规格仅 temperature / relative-humidity / battery-level,**无任何历史属性/动作**。
**B. 云端聚合统计历史「可用」(%@zythum02/mijia-api 可直接打 `POST /v2/user/statistics`)**
参数 `{ did, key(siid.piid), data_type, limit, time_start, time_end }`,用本库 `api.request()` 直连即可。
| data_type | 桶(Asia/Shanghai) | value 语义 | 实测(炎米 ym001.sensor_ht.ymwsdj) |
| --- | --- | --- | --- |
| `stat_hour_v3` | 1 小时整点 | 小时均值 | 连续入库(炎米1 有部分缺档),如 `[28.8]` |
| `stat_day_v3` | 1 天 00:00 | 当日 [min, max] | 45 天全有,如 `[28.1, 29.2]`,与实时读值吻合 |
| `stat_month_v3` | 1 月 | 当月 [min, max] | 回溯到 2026-01(联网注册后云端一直记账) |
| `stat_week_v3` | 1 周 | — | 该型号返回 `-8 invalid params`(未开通周档) |
| 回补深度 | — | — | 两台炎米均回溯到接入米家日起(实测到 2026-01-03);缺席日=设备当天无上报 |
- 青萍闹钟 `cgllc.clock.dove` 与 VRF `proxy.624017060.00001` 全部 data_type 报 `-8 invalid params`
→ **云侧未为其开通统计**(青萍与其无历史服务一致)。
- 证据:`mibridge-research/stats-probe.mjs` / `stats-probe2.json`。
因此:
- **停机缺口可补真值**:对开通统计的型号(炎米等),停机恢复后用 `stat_day_v3` 回填缺日
[min,max]、`stat_hour_v3` 补小时均值,`origin=vendor_history`(见 §5.3);
- 「精确到某个刻的逐点值」仍不可得(那是设备本地日志,须走局域网 BLE/网关,见 §10);
- 未开通统计的型号(青萍)维持「当天实时采样 + 缺口标注」。
## 3. 总体方案
- 后端新增 `server/mihome/` 模块,封装与米家的认证 + 采集 + 查询;
- 依赖新增 `@zythum02/mijia-api`(扫码登录、`getDevicesProp`、`DiskCache`),
认证缓存文件用 `DiskCache` 存于服务端磁盘(`.cache/`),与采集进程同机;
- **每小时**采样 1 次写小时档(`MihomeEnvHour`),**每日 03:00** (Asia/Shanghai) 聚合日档
(均值/min/max)+ 读一次电量;服务启动时做**追赶同步**(缺小时/缺天则按序补齐),
停机跨天的历史缺口由 `vendorHistoryFiller` 用 `/v2/user/statistics` 真实回填(有统计的型号);
- 前端 `MonitorPage` 增加 tab,`antd-mobile Collapse`(手风琴)+ `recharts` 趋势图,
别名用 `Dialog.Prompt` 编辑;每设备卡片标题=别名+电量,展开后 `Segmented` 切换
**今天(时)/月(天)/年(周)** 三档视图;
- 数据永久保存,小时档按 `(deviceId, hour)`、日档按 `(deviceId, date)` 唯一,天然防重。
## 4. 数据模型(Prisma)
```prisma
// ===== 环境监控:米家 =====
// 米家授权状态(DiskCache 存凭证, 本表只存状态/错误供前端展示)
model MihomeAccount {
id Int @id @default(autoincrement())
key String @unique @default("default")
userId String @default("")
status String @default("unknown") // ok | pending | expired
lastError String @default("")
updatedAt DateTime @updatedAt
}
model MihomeHome {
id Int @id @default(autoincrement())
homeId String @unique
name String
createdAt DateTime @default(now())
devices MihomeDevice[]
}
model MihomeDevice {
id Int @id @default(autoincrement())
did String @unique
homeId String
home MihomeHome @relation(fields: [homeId], references: [homeId])
model String
name String
alias String? // 用户别名, 仅本应用展示
tempSiid Int
tempPiid Int
humSiid Int
humPiid Int
battSiid Int // 电池电量属性(如炎米3.1003 / 青萍2.3)
battPiid Int
battery Int? // 最近一次电量(每天读一次, 标题展示用)
batteryAt DateTime? // 电量读取时间(当天缓存, 不频繁更新)
enabled Boolean @default(true)
lastSyncAt DateTime?
createdAt DateTime @default(now())
samples MihomeEnvSample[]
hours MihomeEnvHour[]
}
model MihomeEnvSample {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
date DateTime // 采样日(Asia/Shanghai 当日 00:00 存 UTC)
temperature Float? // 当日最后一次 live 值(或 vendor hour 均值)
humidity Float?
temperatureAvg Float? // 当日均值(由小时档聚合, 或 vendor_hour)
humidityAvg Float?
temperatureMin Float? // 当日最低
temperatureMax Float? // 当日最高
humidityMin Float?
humidityMax Float?
origin String @default("live") // live | vendor_history | backfill
sampledAt DateTime?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, date]) // 一天一条, 防重
@@index([deviceId, date])
}
model MihomeEnvHour {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
hour DateTime // Asia/Shanghai 整点(存 UTC)
temperatureAvg Float? // 该小时均值(live 采样点聚合, 或 vendor_hour)
humidityAvg Float?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, hour]) // 一小时一条, 防重
@@index([deviceId, hour])
}
```
要点:
- 凭证不进库,只在密钥盘(`server/mihome/.cache/`,需加入 `.gitignore`);
- `MihomeHome` / `MihomeDevice` 随每次同步 **upsert**(以米家为准,重扫不漏不错);
- 存储分三档:**小时档**(当天视图,逐小时)、**日档**(月视图,日均/min/max)、
**年视图按日档现场聚合周桶**(不落库,见 §5.6);
- 电量只存在 `MihomeDevice.battery/batteryAt`,每天读 1 次覆盖当天展示用(需求 11);
- `MihomeEnvSample.origin`:
- `live`——定时采样的真实值(小时档写入,日档由小时聚合);
- `vendor_history`——经 `/v2/user/statistics` 拉回的云端聚合历史(min/max/hour 均值);
- `backfill`——停机恢复后对**当天**的补记(仍用真实值,与 `live` 同字段)。
## 5. 后端设计
### 5.1 代码结构
```
server/mihome/
index.js # 模块入口: 初始化 MijiaAPI + DiskCache, 导出 service
auth.js # 认证管理 + 扫码流程(后台 QRlogin 循环 + pendingQr 状态机)
collector.js # 采集器: 每小时采样 / 每日聚合 / 追赶同步 / 电量 / 设备发现
vendorStats.js # /v2/user/statistics 封装(按型号探测可用性, 提供 hour/day 回填)
history.js # 查询聚合: today(时) / month(天) / year(周)
cache.js # DiskCache 路径与清理
server/routes/mihome.js # REST 路由 (authRequired)
```
### 5.2 认证与「重新授权」
- 启动时 `login()` 自动尝试加载/刷新 `DiskCache` 凭证;成功则 `status=ok`。
- 失败(30 天过期 / 风控)→ `MihomeAccount.status=expired`,前端弹授权横幅。
- 重新授权流程(服务端驱动,前端轮询):
```
POST /api/mihome/reauthorize
└─ 若 pendingQr 空闲 => 后台启动 QRlogin 循环(每轮 120s 超时自动续二维码)
└─ 立即返回 { state, qrImageUrl }
GET /api/mihome/reauthorize/status
└─ 返回 { state: idle|waiting|scanning|ok|failed, qrImageUrl?, lastError? }
└─ 扫码成功(qrlogin 返回) => 保存凭证 => 状态 ok => 触发一次立即同步
POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
```
- `pendingQr` 为模块级单例,防并发(同一时间只允许一个扫码流程)。
- 前端在弹窗内 `setInterval 3s` 轮询直到 `ok`,成功后关弹窗并刷新列表。
### 5.3 采集器 collector
1. **预检**:`whoami().login` 不为真 → 置 `expired`,本轮跳过(不产生记录)。
2. **设备发现**:`getDevicesList()` → upsert 家庭/设备;仅保留具备
`temperature` + `relative-humidity` 属性的型号(经 `getDeviceInfo`/miot-spec 判定,
当前命中 `ym001.sensor_ht.ymwsdj`、`cgllc.clock.dove` 白名单);记录其 `battSiid/battPiid`。
3. **小时采样(每小时)**:批量 `getDevicesProp` 读温度+湿度 → upsert
`MihomeEnvHour(deviceId, hour=当前整点)`,写 `temperatureAvg/humidityAvg`(此小时第一个有效点
即视为小时值)。每台独立 try/catch:
- 设备离线/超时 → 该小时不写(缺口保持);
- 网络/API 异常 → 整体捕获,本轮不误标,留待下次。
4. **电量(每天 1 次)**:每日首轮采样顺带读 `battery-level` → 写
`MihomeDevice.battery/batteryAt`(当天缓存,标题展示用,不频繁更新)。读不到不报错。
5. **每日聚合(03:00 + 启动追赶)**: upsert `MihomeEnvSample(deviceId, date)`:
- `temperatureAvg/humidityAvg` = 当日各小时均值;`temperatureMin/Max`、
`humidityMin/Max` = 当日小时 min/max;`temperature/humidity` = 当日最后一次 live 值;
- 当日采样小时数 < 8h 且设备在线的 → 判定硬件故障?**否**:仅当设备整天在线的阈值外仍缺,
视为设备离线缺档(保持缺口,不伪造)。
6. **追赶同步(补齐)**:每次轮询/同步结束后检查——对每个设备
「缺小时档且采集入口健康」→ 按序补齐小时档;「今天就无样本」→ 立即执行每日聚合。
服务启动时触发一轮,实现**停机恢复当天即补**。
7. **历史补齐(vendorHistoryFiller / vendorStats.js)**:每台设备先探测一次各 data_type
可用性(实测:炎米 ✅ / 青萍、VRF ❌`-8 invalid params`),仅对可用型号扫描近 45 天缺口:
- 缺小时 → `stat_hour_v3` 回填 `MihomeEnvHour.temperatureAvg/humidityAvg`;
- 缺天 → `stat_day_v3` 回填日档 min/max,再由小时档均值回填 avg;
- 命中即写 `origin=vendor_history`;对「服务端停机跨天」缺口尤其有效。
8. **缺口标记**:`GET /status` 计算每设备最近 30 天缺样本日期列表(`missingDays`):
今天缺=待采;历史缺=先尝试 vendor 统计补齐,补不了则标注不可得。
> 补数边界(诚实策略):已开通统计的型号(炎米等)停机跨天缺口可用**云端聚合真值**补齐
> ([min,max]/小时均值,`origin=vendor_history`);未开通的型号(青萍)保持缺口标注、不伪造。
> 「精确逐点历史」仍不可得,只能走局域网 BLE/网关(§10 可选项 C)。
### 5.4 路由表(均在 `/api/mihome`,`authRequired`)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果) |
| GET | `/device/:did/history?range=today` | 当天逐小时(见 §5.6);缺省 `range=today` |
| GET | `/device/:did/history?range=month` | 近 30 天逐日(日均 + min/max) |
| GET | `/device/:did/history?range=year` | 近 52 周逐周(周均值 + min/max,服务端按日档聚合) |
| PUT | `/device/:did` | `{ alias }` 改别名(可清空) |
| POST | `/sync` | 手动触发一次采集(前端「立即同步」) |
| POST | `/reauthorize` | 开始扫码(返回二维码 URL) |
| GET | `/reauthorize/status` | 轮询扫码状态 |
| POST | `/reauthorize/cancel` | 取消扫码 |
### 5.5 定时任务(并入 `server/cron.js`)
- `0 * * * *`(Asia/Shanghai):小时采样(温度+湿度,每小时整点 +5min 缓冲)。
- `5 0,3 * * *`:每日聚合 → `battery` 读取 → vendor 补齐扫描。
- 兼容表达式:`npm run data:mihome`(手动跑一次采集器)备用。
### 5.6 历史查询聚合(history.js)
| range | 时间窗 | 粒度 | 数据源/算法 |
| --- | --- | --- | --- |
| `today` | 当天 00:00→now | 小时 | `MihomeEnvHour` 当日行;未来小时返回 null |
| `month` | 近 30 天 | 天 | `MihomeEnvSample`:`temperatureAvg/humidityAvg`(折线)+ min/max(区间带) |
| `year` | 近 52 周 | 周 | 按日档**现场聚合**:每周 `avg`(日 avg 均值)、`min`(日 min 最小)、`max`(日 max 最大),不落库 |
响应统一形如 `[{ t, temperature, temperatureMin, temperatureMax, humidity, humidityMin, humidityMax }]`,
`t` 为桶起始(Asia/Shanghai),缺数据字段为 null。
## 6. 前端设计
`src/pages/MonitorPage.jsx` 的 `tabs` 增加 `{ key: 'env', label: '环境监控' }`,
对应 `src/pages/monitor/EnvView.jsx`。
### EnvView 结构
1. **授权横幅**(`status !== ok`):
`米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `<img src=qrImageUrl>`
3s 轮询 `/reauthorize/status` → 成功自动关闭并刷新。
2. **顶部操作**:`立即同步`(POST `/sync`)。
3. **家庭列表**:`antd-mobile <Collapse accordion>` 按家庭分组;组展开后显示该家庭
下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。
4. **设备卡片**(不可折叠,点击进入详情/趋势):
- 标题:**别名(无别名则原名)+ 电量**(如 `卧室 78%`,电量用 `battery` 当天缓存值,
不频繁请求);附带编辑别名按钮(`Dialog.Prompt` → `PUT`);
- 概览:`温度 28.1℃ · 湿度 71%`(最新样本)+ 「缺 N 天」暗红角标;
- 图表区:`Segmented`(antd-mobile) 切换 **今天/月/年**,默认**今天**;
`recharts LineChart` 双线(温度/湿度)+ 可选 min/max 区间带(`Area` band):
- `今天` → X 轴逐小时(00:00→当前),数据 `/device/:did/history?range=today`;
- `月` → X 轴逐日(近 30 天),数据 `?range=month`(日均折线 + 日 min/max 影带);
- `年` → X 轴逐周(近 52 周),数据 `?range=year`(周均值折线 + 周 min/max 影带);
- 视图切换/拉数据留在卡片内部(不用全局 tab),逻辑沿用现有 `ServerDetail.jsx` /
`DataView.jsx` 图表写法。
5. 样式复用 `globals.css` 的 `.card`/`.monitor-*` 体系,新增少量 collapse 样式。
> 手风琴用 `Collapse accordion`;「不同时打开」即一次仅一个家庭展开。
## 7. 迁移与部署
1. `npm i @zythum02/mijia-api`(加入 dependencies)。
2. `schema.prisma` 追加 5 个模型 → `npx prisma migrate dev --name add_mihome_env`。
3. `.env` 可选 `MIHOME_CACHE_DIR`(默认 `server/mihome/.cache`);
服务端放 `.cache/` 于 `.gitignore`。
4. 首次部署:在服务器上人工跑一次扫码授权(`npm run data:mihome` 的初始化提示
会输出二维码),写入服务器本地的 `.cache/`。
5. 数据库迁移走现有 `prisma migrate deploy` 流程。
## 8. 风险与开放问题
| # | 风险/问题 | 应对 |
| --- | --- | --- |
| 1 | **逐点历史不可读**(设备本地日志),停机期要精确到刻的值补不上 | 已找到云端聚合通道(`/v2/user/statistics`,min/max/时均值,实测可行);按模型开通情况补齐(炎米 ✅ / 青萍 ❌);精确逐点 → 可选 §10 C |
| 2 | token 30 天过期需人工扫码 | 需求 4 的重新授权流程已覆盖;扫码流程 120s 超时自动换码 |
| 3 | 采样时刻固定凌晨,代表性一般 | 已改**每小时采样**,当天小时视图/日均/周均代表性充分;03:00 仅做日档聚合 |
| 4 | 第三方 `@zythum02` 库上游变动 | 锁定版本;模块边界隔离,只暴露我们自己需要的函数 |
| 5 | VRF/其他设备写入延迟 5~6s | 仅影响将来的控制功能,本模块只读,无影响 |
## 9. 实施步骤(里程碑)
- **M1 后端地基**:依赖 + Prisma 迁移(5 模型)+ `server/mihome`
(认证 + collector 小时采样/每日聚合/电量 + vendorStats 补齐 + history.js 三段聚合 + 路由)+ cron。
- **M2 前端**:`EnvView`(授权弹窗 / 手风琴家庭组 / 设备卡=别名+电量 /
别名编辑 / Segmented 今天·月·年视图与图表 / 缺口角标)。
- **M3 联调**:真机/Web 端到端 —— 小时采样与日聚合、停机恢复追赶与 vendor 补齐、
扫码重授权、别名、电量、三个粒度的图表与缺口展示。
- **M4(可选,已基本闭环)**:`/v2/user/statistics` 云端聚合历史通道已实测打通,
直接并入 M1 的 `vendorHistoryFiller`(无需再抓包 App)。
- **M5(可选)**:可选项 C 家侧本地采样代理(见 §10),用于「精确逐点」与分钟级采样。
## 10. 可选项 C:家侧本地采样代理 + 缓冲补数
> 背景:主线「云 API 每日采样」的停机跨天缺口现可由云端聚合统计(§2.2B)补齐到 [min,max]/小时
> 粒度;可选项 C 是把它做到**精确逐点 / 分钟级**——家侧一个常驻采集进程(可选配合小米中枢网关),
> 本地连续采样并缓冲,服务端恢复后拉真值回填。
> 与主线**并行可选**,不影响 §1~§9 落地;仅在追求细粒度历史时投入。
### 10.1 事实依据(已调研)
| 事实 | 结论 |
| --- | --- |
| 家庭现有网关 | 无中枢/多模网关;`blt.*` 设备挂在「小爱音箱 Play 增强版」上转发,该音箱**不开放局域网 API** |
| 局域网直连设备本身 | 需本地 BLE 适配器;青萍闹钟(CGD1)协议已逆向但**仅有实时/设置**、无历史通道;炎米 ym001 **零社区支持**,需自行逆向厂商 GATT + 推导 MiKey,把握低 |
| 若有小米中枢网关 | 官方 HA 集成走网关**内置本地 MQTT Broker**,可本地化 WiFi+BLE 设备、断外网可用、延迟 10~50ms(社区实测 `ha_xiaomi_home`);但本地 Broker **只推实时 props,不提供历史** |
| 概率评估 | 本地实时读取:无网关走蓝牙 ~60~85%(青萍高/炎米中);加中枢网关 ~85%。补停机期历史:三条局域网路线均**不直接提供**,只能靠「本地连续采样 + 缓冲」自建 |
### 10.2 方案
```
家庭内常驻小机(现有 Windows 机 / 树莓派) 跑 server/mihome-agent/
├─ 采样源(可切换): a) 云 getDevicesProp (同 §5.3, 无需新硬件)
│ b) 加中枢网关后走本地 MQTT(断外网可用, 秒级粒度)
│ c) 未来本地 BLE 嗅探(青萍 CGD1 优先)
├─ 每 N 分钟采样 → 写本地缓冲 SQLite/JSONL(本地保留 180 天)
└─ 提供 GET /samples?from&to&did 供服务端拉取
服务端(iboard)
└─ 停机恢复/每日同步时: 调 agent 拉缺口区间真实值 → 写 MihomeEnvSample(origin=backfill)
(服务端主动拉 agent,agent 无需入公网,可用内网/隧道)
```
### 10.3 效果与代价
- **效果**:停机期 agent 缓冲持续采样,恢复后以**精确逐点/分钟级**真值回填(粒度高于 §2.2B 的
聚合统计);加中枢网关后数据链路断外网可用、秒级推送。
- **代价**:家中常驻进程(可复用现有 Windows 开发机);可选购买中枢网关(~¥349);
agent 与服务端的传输通道需内网/VPN/隧道。
- **决策点**:是否投入属产品决策。当前「补齐停机缺口」已被 §2.2B 的云端统计覆盖(满足
「数据不比米家少」);C 只对「要更细粒度」有增量价值。接入点:agent 值写入
`MihomeEnvSample`,`origin=backfill`。
- **本地直连协议现状**(中枢/蓝牙):官方 HA 集成走中枢内置 MQTT Broker(需 HA 或移植其
`MipsLocalClient` 的证书鉴权+微链路,无公开协议,自有 Node agent 直连把握中低);纯 BLE
嗅探见 §10.1 表格(青萍 CGD1 有逆向基础、炎米零资料)。
+60
View File
@@ -37,6 +37,36 @@ export type Strategy = Prisma.StrategyModel
*
*/
export type Indicator = Prisma.IndicatorModel
/**
* Model IndicatorPoint
*
*/
export type IndicatorPoint = Prisma.IndicatorPointModel
/**
* Model News
*
*/
export type News = Prisma.NewsModel
/**
* Model Alert
*
*/
export type Alert = Prisma.AlertModel
/**
* Model ServerNode
*
*/
export type ServerNode = Prisma.ServerNodeModel
/**
* Model ServerNodeHistory
*
*/
export type ServerNodeHistory = Prisma.ServerNodeHistoryModel
/**
* Model ServiceCheck
*
*/
export type ServiceCheck = Prisma.ServiceCheckModel
/**
* Model Econ_SowInventory
*
@@ -47,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
+60
View File
@@ -61,6 +61,36 @@ export type Strategy = Prisma.StrategyModel
*
*/
export type Indicator = Prisma.IndicatorModel
/**
* Model IndicatorPoint
*
*/
export type IndicatorPoint = Prisma.IndicatorPointModel
/**
* Model News
*
*/
export type News = Prisma.NewsModel
/**
* Model Alert
*
*/
export type Alert = Prisma.AlertModel
/**
* Model ServerNode
*
*/
export type ServerNode = Prisma.ServerNodeModel
/**
* Model ServerNodeHistory
*
*/
export type ServerNodeHistory = Prisma.ServerNodeHistoryModel
/**
* Model ServiceCheck
*
*/
export type ServiceCheck = Prisma.ServiceCheckModel
/**
* Model Econ_SowInventory
*
@@ -71,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
+293 -23
View File
@@ -150,6 +150,46 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
@@ -165,6 +205,56 @@ export type StringNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
search?: string
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type BigIntFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[]
notIn?: bigint[] | number[]
lt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
lte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
not?: Prisma.NestedBigIntFilter<$PrismaModel> | bigint | number
}
export type BigIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[]
notIn?: bigint[] | number[]
lt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
lte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
not?: Prisma.NestedBigIntWithAggregatesFilter<$PrismaModel> | bigint | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedBigIntFilter<$PrismaModel>
_min?: Prisma.NestedBigIntFilter<$PrismaModel>
_max?: Prisma.NestedBigIntFilter<$PrismaModel>
}
export type JsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
@@ -189,29 +279,6 @@ export type JsonNullableFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
search?: string
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
@@ -239,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[]
@@ -359,6 +505,35 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
@@ -403,6 +578,33 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedBigIntFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[]
notIn?: bigint[] | number[]
lt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
lte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
not?: Prisma.NestedBigIntFilter<$PrismaModel> | bigint | number
}
export type NestedBigIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[]
notIn?: bigint[] | number[]
lt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
lte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
not?: Prisma.NestedBigIntWithAggregatesFilter<$PrismaModel> | bigint | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedBigIntFilter<$PrismaModel>
_min?: Prisma.NestedBigIntFilter<$PrismaModel>
_max?: Prisma.NestedBigIntFilter<$PrismaModel>
}
export type NestedJsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
@@ -427,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
File diff suppressed because it is too large Load Diff
@@ -55,8 +55,20 @@ export const ModelName = {
Article: 'Article',
Strategy: 'Strategy',
Indicator: 'Indicator',
IndicatorPoint: 'IndicatorPoint',
News: 'News',
Alert: 'Alert',
ServerNode: 'ServerNode',
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]
@@ -116,7 +128,11 @@ export const IndicatorScalarFieldEnum = {
id: 'id',
name: 'name',
groupName: 'groupName',
data: 'data',
title: 'title',
unit: 'unit',
source: 'source',
frequency: 'frequency',
hidden: 'hidden',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -124,6 +140,113 @@ export const IndicatorScalarFieldEnum = {
export type IndicatorScalarFieldEnum = (typeof IndicatorScalarFieldEnum)[keyof typeof IndicatorScalarFieldEnum]
export const IndicatorPointScalarFieldEnum = {
id: 'id',
indicatorId: 'indicatorId',
period: 'period',
value: 'value',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type IndicatorPointScalarFieldEnum = (typeof IndicatorPointScalarFieldEnum)[keyof typeof IndicatorPointScalarFieldEnum]
export const NewsScalarFieldEnum = {
id: 'id',
title: 'title',
summary: 'summary',
content: 'content',
source: 'source',
url: 'url',
publishedAt: 'publishedAt',
impactRank: 'impactRank',
createdAt: 'createdAt'
} as const
export type NewsScalarFieldEnum = (typeof NewsScalarFieldEnum)[keyof typeof NewsScalarFieldEnum]
export const AlertScalarFieldEnum = {
id: 'id',
indicatorName: 'indicatorName',
period: 'period',
valueA: 'valueA',
valueB: 'valueB',
sourceA: 'sourceA',
sourceB: 'sourceB',
diff: 'diff',
diffRatio: 'diffRatio',
createdAt: 'createdAt'
} as const
export type AlertScalarFieldEnum = (typeof AlertScalarFieldEnum)[keyof typeof AlertScalarFieldEnum]
export const ServerNodeScalarFieldEnum = {
id: 'id',
uuid: 'uuid',
name: 'name',
cpuName: 'cpuName',
os: 'os',
region: 'region',
memTotal: 'memTotal',
diskTotal: 'diskTotal',
cpuCores: 'cpuCores',
cpuUsage: 'cpuUsage',
ramUsed: 'ramUsed',
swapUsed: 'swapUsed',
load1: 'load1',
load5: 'load5',
load15: 'load15',
diskUsed: 'diskUsed',
netIn: 'netIn',
netOut: 'netOut',
uptime: 'uptime',
process: 'process',
connections: 'connections',
status: 'status',
recordedAt: 'recordedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServerNodeScalarFieldEnum = (typeof ServerNodeScalarFieldEnum)[keyof typeof ServerNodeScalarFieldEnum]
export const ServerNodeHistoryScalarFieldEnum = {
id: 'id',
uuid: 'uuid',
period: 'period',
cpu: 'cpu',
ram: 'ram',
load: 'load',
disk: 'disk',
netIn: 'netIn',
netOut: 'netOut',
process: 'process',
connections: 'connections',
createdAt: 'createdAt'
} as const
export type ServerNodeHistoryScalarFieldEnum = (typeof ServerNodeHistoryScalarFieldEnum)[keyof typeof ServerNodeHistoryScalarFieldEnum]
export const ServiceCheckScalarFieldEnum = {
id: 'id',
name: 'name',
url: 'url',
status: 'status',
latencyMs: 'latencyMs',
message: 'message',
lastCheckAt: 'lastCheckAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceCheckScalarFieldEnum = (typeof ServiceCheckScalarFieldEnum)[keyof typeof ServiceCheckScalarFieldEnum]
export const Econ_SowInventoryScalarFieldEnum = {
id: 'id',
month: 'month',
@@ -148,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'
@@ -217,17 +432,21 @@ export type StrategyOrderByRelevanceFieldEnum = (typeof StrategyOrderByRelevance
export const IndicatorOrderByRelevanceFieldEnum = {
name: 'name',
groupName: 'groupName'
groupName: 'groupName',
title: 'title',
unit: 'unit',
source: 'source',
frequency: 'frequency'
} as const
export type IndicatorOrderByRelevanceFieldEnum = (typeof IndicatorOrderByRelevanceFieldEnum)[keyof typeof IndicatorOrderByRelevanceFieldEnum]
export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
month: 'month'
export const IndicatorPointOrderByRelevanceFieldEnum = {
period: 'period'
} as const
export type Econ_SowInventoryOrderByRelevanceFieldEnum = (typeof Econ_SowInventoryOrderByRelevanceFieldEnum)[keyof typeof Econ_SowInventoryOrderByRelevanceFieldEnum]
export type IndicatorPointOrderByRelevanceFieldEnum = (typeof IndicatorPointOrderByRelevanceFieldEnum)[keyof typeof IndicatorPointOrderByRelevanceFieldEnum]
export const NullsOrder = {
@@ -238,6 +457,63 @@ export const NullsOrder = {
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const NewsOrderByRelevanceFieldEnum = {
title: 'title',
summary: 'summary',
content: 'content',
source: 'source',
url: 'url'
} as const
export type NewsOrderByRelevanceFieldEnum = (typeof NewsOrderByRelevanceFieldEnum)[keyof typeof NewsOrderByRelevanceFieldEnum]
export const AlertOrderByRelevanceFieldEnum = {
indicatorName: 'indicatorName',
period: 'period',
sourceA: 'sourceA',
sourceB: 'sourceB'
} as const
export type AlertOrderByRelevanceFieldEnum = (typeof AlertOrderByRelevanceFieldEnum)[keyof typeof AlertOrderByRelevanceFieldEnum]
export const ServerNodeOrderByRelevanceFieldEnum = {
uuid: 'uuid',
name: 'name',
cpuName: 'cpuName',
os: 'os',
region: 'region',
status: 'status'
} as const
export type ServerNodeOrderByRelevanceFieldEnum = (typeof ServerNodeOrderByRelevanceFieldEnum)[keyof typeof ServerNodeOrderByRelevanceFieldEnum]
export const ServerNodeHistoryOrderByRelevanceFieldEnum = {
uuid: 'uuid'
} as const
export type ServerNodeHistoryOrderByRelevanceFieldEnum = (typeof ServerNodeHistoryOrderByRelevanceFieldEnum)[keyof typeof ServerNodeHistoryOrderByRelevanceFieldEnum]
export const ServiceCheckOrderByRelevanceFieldEnum = {
name: 'name',
url: 'url',
status: 'status',
message: 'message'
} as const
export type ServiceCheckOrderByRelevanceFieldEnum = (typeof ServiceCheckOrderByRelevanceFieldEnum)[keyof typeof ServiceCheckOrderByRelevanceFieldEnum]
export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
month: 'month'
} as const
export type Econ_SowInventoryOrderByRelevanceFieldEnum = (typeof Econ_SowInventoryOrderByRelevanceFieldEnum)[keyof typeof Econ_SowInventoryOrderByRelevanceFieldEnum]
export const ScraperCacheOrderByRelevanceFieldEnum = {
scraperName: 'scraperName',
lastDataHash: 'lastDataHash'
@@ -245,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]
+12
View File
@@ -12,6 +12,18 @@ export type * from './models/User.ts'
export type * from './models/Article.ts'
export type * from './models/Strategy.ts'
export type * from './models/Indicator.ts'
export type * from './models/IndicatorPoint.ts'
export type * from './models/News.ts'
export type * from './models/Alert.ts'
export type * from './models/ServerNode.ts'
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
+330 -22
View File
@@ -38,6 +38,11 @@ export type IndicatorMinAggregateOutputType = {
id: number | null
name: string | null
groupName: string | null
title: string | null
unit: string | null
source: string | null
frequency: string | null
hidden: boolean | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -46,6 +51,11 @@ export type IndicatorMaxAggregateOutputType = {
id: number | null
name: string | null
groupName: string | null
title: string | null
unit: string | null
source: string | null
frequency: string | null
hidden: boolean | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -54,7 +64,11 @@ export type IndicatorCountAggregateOutputType = {
id: number
name: number
groupName: number
data: number
title: number
unit: number
source: number
frequency: number
hidden: number
createdAt: number
updatedAt: number
_all: number
@@ -73,6 +87,11 @@ export type IndicatorMinAggregateInputType = {
id?: true
name?: true
groupName?: true
title?: true
unit?: true
source?: true
frequency?: true
hidden?: true
createdAt?: true
updatedAt?: true
}
@@ -81,6 +100,11 @@ export type IndicatorMaxAggregateInputType = {
id?: true
name?: true
groupName?: true
title?: true
unit?: true
source?: true
frequency?: true
hidden?: true
createdAt?: true
updatedAt?: true
}
@@ -89,7 +113,11 @@ export type IndicatorCountAggregateInputType = {
id?: true
name?: true
groupName?: true
data?: true
title?: true
unit?: true
source?: true
frequency?: true
hidden?: true
createdAt?: true
updatedAt?: true
_all?: true
@@ -185,7 +213,11 @@ export type IndicatorGroupByOutputType = {
id: number
name: string
groupName: string
data: runtime.JsonValue
title: string
unit: string
source: string
frequency: string
hidden: boolean
createdAt: Date
updatedAt: Date
_count: IndicatorCountAggregateOutputType | null
@@ -217,18 +249,28 @@ export type IndicatorWhereInput = {
id?: Prisma.IntFilter<"Indicator"> | number
name?: Prisma.StringFilter<"Indicator"> | string
groupName?: Prisma.StringFilter<"Indicator"> | string
data?: Prisma.JsonFilter<"Indicator">
title?: Prisma.StringFilter<"Indicator"> | string
unit?: Prisma.StringFilter<"Indicator"> | string
source?: Prisma.StringFilter<"Indicator"> | string
frequency?: Prisma.StringFilter<"Indicator"> | string
hidden?: Prisma.BoolFilter<"Indicator"> | boolean
createdAt?: Prisma.DateTimeFilter<"Indicator"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Indicator"> | Date | string
points?: Prisma.IndicatorPointListRelationFilter
}
export type IndicatorOrderByWithRelationInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
groupName?: Prisma.SortOrder
data?: Prisma.SortOrder
title?: Prisma.SortOrder
unit?: Prisma.SortOrder
source?: Prisma.SortOrder
frequency?: Prisma.SortOrder
hidden?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
points?: Prisma.IndicatorPointOrderByRelationAggregateInput
_relevance?: Prisma.IndicatorOrderByRelevanceInput
}
@@ -239,16 +281,25 @@ export type IndicatorWhereUniqueInput = Prisma.AtLeast<{
OR?: Prisma.IndicatorWhereInput[]
NOT?: Prisma.IndicatorWhereInput | Prisma.IndicatorWhereInput[]
groupName?: Prisma.StringFilter<"Indicator"> | string
data?: Prisma.JsonFilter<"Indicator">
title?: Prisma.StringFilter<"Indicator"> | string
unit?: Prisma.StringFilter<"Indicator"> | string
source?: Prisma.StringFilter<"Indicator"> | string
frequency?: Prisma.StringFilter<"Indicator"> | string
hidden?: Prisma.BoolFilter<"Indicator"> | boolean
createdAt?: Prisma.DateTimeFilter<"Indicator"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Indicator"> | Date | string
points?: Prisma.IndicatorPointListRelationFilter
}, "id" | "name">
export type IndicatorOrderByWithAggregationInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
groupName?: Prisma.SortOrder
data?: Prisma.SortOrder
title?: Prisma.SortOrder
unit?: Prisma.SortOrder
source?: Prisma.SortOrder
frequency?: Prisma.SortOrder
hidden?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
_count?: Prisma.IndicatorCountOrderByAggregateInput
@@ -265,7 +316,11 @@ export type IndicatorScalarWhereWithAggregatesInput = {
id?: Prisma.IntWithAggregatesFilter<"Indicator"> | number
name?: Prisma.StringWithAggregatesFilter<"Indicator"> | string
groupName?: Prisma.StringWithAggregatesFilter<"Indicator"> | string
data?: Prisma.JsonWithAggregatesFilter<"Indicator">
title?: Prisma.StringWithAggregatesFilter<"Indicator"> | string
unit?: Prisma.StringWithAggregatesFilter<"Indicator"> | string
source?: Prisma.StringWithAggregatesFilter<"Indicator"> | string
frequency?: Prisma.StringWithAggregatesFilter<"Indicator"> | string
hidden?: Prisma.BoolWithAggregatesFilter<"Indicator"> | boolean
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Indicator"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Indicator"> | Date | string
}
@@ -273,42 +328,66 @@ export type IndicatorScalarWhereWithAggregatesInput = {
export type IndicatorCreateInput = {
name: string
groupName: string
data: Prisma.JsonNullValueInput | runtime.InputJsonValue
title?: string
unit?: string
source?: string
frequency?: string
hidden?: boolean
createdAt?: Date | string
updatedAt?: Date | string
points?: Prisma.IndicatorPointCreateNestedManyWithoutIndicatorInput
}
export type IndicatorUncheckedCreateInput = {
id?: number
name: string
groupName: string
data: Prisma.JsonNullValueInput | runtime.InputJsonValue
title?: string
unit?: string
source?: string
frequency?: string
hidden?: boolean
createdAt?: Date | string
updatedAt?: Date | string
points?: Prisma.IndicatorPointUncheckedCreateNestedManyWithoutIndicatorInput
}
export type IndicatorUpdateInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
groupName?: Prisma.StringFieldUpdateOperationsInput | string
data?: Prisma.JsonNullValueInput | runtime.InputJsonValue
title?: Prisma.StringFieldUpdateOperationsInput | string
unit?: Prisma.StringFieldUpdateOperationsInput | string
source?: Prisma.StringFieldUpdateOperationsInput | string
frequency?: Prisma.StringFieldUpdateOperationsInput | string
hidden?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
points?: Prisma.IndicatorPointUpdateManyWithoutIndicatorNestedInput
}
export type IndicatorUncheckedUpdateInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
groupName?: Prisma.StringFieldUpdateOperationsInput | string
data?: Prisma.JsonNullValueInput | runtime.InputJsonValue
title?: Prisma.StringFieldUpdateOperationsInput | string
unit?: Prisma.StringFieldUpdateOperationsInput | string
source?: Prisma.StringFieldUpdateOperationsInput | string
frequency?: Prisma.StringFieldUpdateOperationsInput | string
hidden?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
points?: Prisma.IndicatorPointUncheckedUpdateManyWithoutIndicatorNestedInput
}
export type IndicatorCreateManyInput = {
id?: number
name: string
groupName: string
data: Prisma.JsonNullValueInput | runtime.InputJsonValue
title?: string
unit?: string
source?: string
frequency?: string
hidden?: boolean
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -316,7 +395,11 @@ export type IndicatorCreateManyInput = {
export type IndicatorUpdateManyMutationInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
groupName?: Prisma.StringFieldUpdateOperationsInput | string
data?: Prisma.JsonNullValueInput | runtime.InputJsonValue
title?: Prisma.StringFieldUpdateOperationsInput | string
unit?: Prisma.StringFieldUpdateOperationsInput | string
source?: Prisma.StringFieldUpdateOperationsInput | string
frequency?: Prisma.StringFieldUpdateOperationsInput | string
hidden?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -325,7 +408,11 @@ export type IndicatorUncheckedUpdateManyInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
groupName?: Prisma.StringFieldUpdateOperationsInput | string
data?: Prisma.JsonNullValueInput | runtime.InputJsonValue
title?: Prisma.StringFieldUpdateOperationsInput | string
unit?: Prisma.StringFieldUpdateOperationsInput | string
source?: Prisma.StringFieldUpdateOperationsInput | string
frequency?: Prisma.StringFieldUpdateOperationsInput | string
hidden?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -340,7 +427,11 @@ export type IndicatorCountOrderByAggregateInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
groupName?: Prisma.SortOrder
data?: Prisma.SortOrder
title?: Prisma.SortOrder
unit?: Prisma.SortOrder
source?: Prisma.SortOrder
frequency?: Prisma.SortOrder
hidden?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -353,6 +444,11 @@ export type IndicatorMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
groupName?: Prisma.SortOrder
title?: Prisma.SortOrder
unit?: Prisma.SortOrder
source?: Prisma.SortOrder
frequency?: Prisma.SortOrder
hidden?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -361,6 +457,11 @@ export type IndicatorMinOrderByAggregateInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
groupName?: Prisma.SortOrder
title?: Prisma.SortOrder
unit?: Prisma.SortOrder
source?: Prisma.SortOrder
frequency?: Prisma.SortOrder
hidden?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -369,15 +470,139 @@ export type IndicatorSumOrderByAggregateInput = {
id?: Prisma.SortOrder
}
export type IndicatorScalarRelationFilter = {
is?: Prisma.IndicatorWhereInput
isNot?: Prisma.IndicatorWhereInput
}
export type BoolFieldUpdateOperationsInput = {
set?: boolean
}
export type IndicatorCreateNestedOneWithoutPointsInput = {
create?: Prisma.XOR<Prisma.IndicatorCreateWithoutPointsInput, Prisma.IndicatorUncheckedCreateWithoutPointsInput>
connectOrCreate?: Prisma.IndicatorCreateOrConnectWithoutPointsInput
connect?: Prisma.IndicatorWhereUniqueInput
}
export type IndicatorUpdateOneRequiredWithoutPointsNestedInput = {
create?: Prisma.XOR<Prisma.IndicatorCreateWithoutPointsInput, Prisma.IndicatorUncheckedCreateWithoutPointsInput>
connectOrCreate?: Prisma.IndicatorCreateOrConnectWithoutPointsInput
upsert?: Prisma.IndicatorUpsertWithoutPointsInput
connect?: Prisma.IndicatorWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.IndicatorUpdateToOneWithWhereWithoutPointsInput, Prisma.IndicatorUpdateWithoutPointsInput>, Prisma.IndicatorUncheckedUpdateWithoutPointsInput>
}
export type IndicatorCreateWithoutPointsInput = {
name: string
groupName: string
title?: string
unit?: string
source?: string
frequency?: string
hidden?: boolean
createdAt?: Date | string
updatedAt?: Date | string
}
export type IndicatorUncheckedCreateWithoutPointsInput = {
id?: number
name: string
groupName: string
title?: string
unit?: string
source?: string
frequency?: string
hidden?: boolean
createdAt?: Date | string
updatedAt?: Date | string
}
export type IndicatorCreateOrConnectWithoutPointsInput = {
where: Prisma.IndicatorWhereUniqueInput
create: Prisma.XOR<Prisma.IndicatorCreateWithoutPointsInput, Prisma.IndicatorUncheckedCreateWithoutPointsInput>
}
export type IndicatorUpsertWithoutPointsInput = {
update: Prisma.XOR<Prisma.IndicatorUpdateWithoutPointsInput, Prisma.IndicatorUncheckedUpdateWithoutPointsInput>
create: Prisma.XOR<Prisma.IndicatorCreateWithoutPointsInput, Prisma.IndicatorUncheckedCreateWithoutPointsInput>
where?: Prisma.IndicatorWhereInput
}
export type IndicatorUpdateToOneWithWhereWithoutPointsInput = {
where?: Prisma.IndicatorWhereInput
data: Prisma.XOR<Prisma.IndicatorUpdateWithoutPointsInput, Prisma.IndicatorUncheckedUpdateWithoutPointsInput>
}
export type IndicatorUpdateWithoutPointsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
groupName?: Prisma.StringFieldUpdateOperationsInput | string
title?: Prisma.StringFieldUpdateOperationsInput | string
unit?: Prisma.StringFieldUpdateOperationsInput | string
source?: Prisma.StringFieldUpdateOperationsInput | string
frequency?: Prisma.StringFieldUpdateOperationsInput | string
hidden?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type IndicatorUncheckedUpdateWithoutPointsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
groupName?: Prisma.StringFieldUpdateOperationsInput | string
title?: Prisma.StringFieldUpdateOperationsInput | string
unit?: Prisma.StringFieldUpdateOperationsInput | string
source?: Prisma.StringFieldUpdateOperationsInput | string
frequency?: Prisma.StringFieldUpdateOperationsInput | string
hidden?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
/**
* Count Type IndicatorCountOutputType
*/
export type IndicatorCountOutputType = {
points: number
}
export type IndicatorCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
points?: boolean | IndicatorCountOutputTypeCountPointsArgs
}
/**
* IndicatorCountOutputType without action
*/
export type IndicatorCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the IndicatorCountOutputType
*/
select?: Prisma.IndicatorCountOutputTypeSelect<ExtArgs> | null
}
/**
* IndicatorCountOutputType without action
*/
export type IndicatorCountOutputTypeCountPointsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.IndicatorPointWhereInput
}
export type IndicatorSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
name?: boolean
groupName?: boolean
data?: boolean
title?: boolean
unit?: boolean
source?: boolean
frequency?: boolean
hidden?: boolean
createdAt?: boolean
updatedAt?: boolean
points?: boolean | Prisma.Indicator$pointsArgs<ExtArgs>
_count?: boolean | Prisma.IndicatorCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["indicator"]>
@@ -386,21 +611,35 @@ export type IndicatorSelectScalar = {
id?: boolean
name?: boolean
groupName?: boolean
data?: boolean
title?: boolean
unit?: boolean
source?: boolean
frequency?: boolean
hidden?: boolean
createdAt?: boolean
updatedAt?: boolean
}
export type IndicatorOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "groupName" | "data" | "createdAt" | "updatedAt", ExtArgs["result"]["indicator"]>
export type IndicatorOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "groupName" | "title" | "unit" | "source" | "frequency" | "hidden" | "createdAt" | "updatedAt", ExtArgs["result"]["indicator"]>
export type IndicatorInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
points?: boolean | Prisma.Indicator$pointsArgs<ExtArgs>
_count?: boolean | Prisma.IndicatorCountOutputTypeDefaultArgs<ExtArgs>
}
export type $IndicatorPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "Indicator"
objects: {}
objects: {
points: Prisma.$IndicatorPointPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: number
name: string
groupName: string
data: runtime.JsonValue
title: string
unit: string
source: string
frequency: string
hidden: boolean
createdAt: Date
updatedAt: Date
}, ExtArgs["result"]["indicator"]>
@@ -743,6 +982,7 @@ readonly fields: IndicatorFieldRefs;
*/
export interface Prisma__IndicatorClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
points<T extends Prisma.Indicator$pointsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Indicator$pointsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$IndicatorPointPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -775,7 +1015,11 @@ export interface IndicatorFieldRefs {
readonly id: Prisma.FieldRef<"Indicator", 'Int'>
readonly name: Prisma.FieldRef<"Indicator", 'String'>
readonly groupName: Prisma.FieldRef<"Indicator", 'String'>
readonly data: Prisma.FieldRef<"Indicator", 'Json'>
readonly title: Prisma.FieldRef<"Indicator", 'String'>
readonly unit: Prisma.FieldRef<"Indicator", 'String'>
readonly source: Prisma.FieldRef<"Indicator", 'String'>
readonly frequency: Prisma.FieldRef<"Indicator", 'String'>
readonly hidden: Prisma.FieldRef<"Indicator", 'Boolean'>
readonly createdAt: Prisma.FieldRef<"Indicator", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Indicator", 'DateTime'>
}
@@ -794,6 +1038,10 @@ export type IndicatorFindUniqueArgs<ExtArgs extends runtime.Types.Extensions.Int
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* Filter, which Indicator to fetch.
*/
@@ -812,6 +1060,10 @@ export type IndicatorFindUniqueOrThrowArgs<ExtArgs extends runtime.Types.Extensi
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* Filter, which Indicator to fetch.
*/
@@ -830,6 +1082,10 @@ export type IndicatorFindFirstArgs<ExtArgs extends runtime.Types.Extensions.Inte
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* Filter, which Indicator to fetch.
*/
@@ -878,6 +1134,10 @@ export type IndicatorFindFirstOrThrowArgs<ExtArgs extends runtime.Types.Extensio
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* Filter, which Indicator to fetch.
*/
@@ -926,6 +1186,10 @@ export type IndicatorFindManyArgs<ExtArgs extends runtime.Types.Extensions.Inter
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* Filter, which Indicators to fetch.
*/
@@ -974,6 +1238,10 @@ export type IndicatorCreateArgs<ExtArgs extends runtime.Types.Extensions.Interna
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* The data needed to create a Indicator.
*/
@@ -1003,6 +1271,10 @@ export type IndicatorUpdateArgs<ExtArgs extends runtime.Types.Extensions.Interna
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* The data needed to update a Indicator.
*/
@@ -1043,6 +1315,10 @@ export type IndicatorUpsertArgs<ExtArgs extends runtime.Types.Extensions.Interna
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* The filter to search for the Indicator to update in case it exists.
*/
@@ -1069,6 +1345,10 @@ export type IndicatorDeleteArgs<ExtArgs extends runtime.Types.Extensions.Interna
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
/**
* Filter which Indicator to delete.
*/
@@ -1089,6 +1369,30 @@ export type IndicatorDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Int
limit?: number
}
/**
* Indicator.points
*/
export type Indicator$pointsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the IndicatorPoint
*/
select?: Prisma.IndicatorPointSelect<ExtArgs> | null
/**
* Omit specific fields from the IndicatorPoint
*/
omit?: Prisma.IndicatorPointOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorPointInclude<ExtArgs> | null
where?: Prisma.IndicatorPointWhereInput
orderBy?: Prisma.IndicatorPointOrderByWithRelationInput | Prisma.IndicatorPointOrderByWithRelationInput[]
cursor?: Prisma.IndicatorPointWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.IndicatorPointScalarFieldEnum | Prisma.IndicatorPointScalarFieldEnum[]
}
/**
* Indicator without action
*/
@@ -1101,4 +1405,8 @@ export type IndicatorDefaultArgs<ExtArgs extends runtime.Types.Extensions.Intern
* Omit specific fields from the Indicator
*/
omit?: Prisma.IndicatorOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.IndicatorInclude<ExtArgs> | null
}
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-4
View File
@@ -391,10 +391,6 @@ export type ScraperCacheSumOrderByAggregateInput = {
id?: Prisma.SortOrder
}
export type NullableStringFieldUpdateOperationsInput = {
set?: string | null
}
export type ScraperCacheSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
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
+2 -2
View File
@@ -234,15 +234,15 @@ export type StrategyOrderByWithRelationInput = {
export type StrategyWhereUniqueInput = Prisma.AtLeast<{
id?: number
name?: string
AND?: Prisma.StrategyWhereInput | Prisma.StrategyWhereInput[]
OR?: Prisma.StrategyWhereInput[]
NOT?: Prisma.StrategyWhereInput | Prisma.StrategyWhereInput[]
name?: Prisma.StringFilter<"Strategy"> | string
description?: Prisma.StringFilter<"Strategy"> | string
data?: Prisma.JsonFilter<"Strategy">
createdAt?: Prisma.DateTimeFilter<"Strategy"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Strategy"> | Date | string
}, "id" | "name">
}, "id">
export type StrategyOrderByWithAggregationInput = {
id?: Prisma.SortOrder
+8
View File
@@ -0,0 +1,8 @@
# ota-package/ 目录占位文件
本目录是 OTA 升级包输出目录(git 忽略):
- **CI(docker-build.yml)**:每次主分支提交运行 `scripts/build-ota.mjs` 生成 `iboard-ota.tar.gz` + `latest.json` + `*.minisig`,随 Docker 镜像发布到容器内 `OTA_PACKAGE_DIR=/app/ota-package`。
- **本地手动构建**:只有本占位文件,Dockerfile 的 `COPY ota-package ./ota-package` 不会失败;容器内 `readLatest()` 找不到 `latest.json` 时 OTA 检查返回"无更新包"。
本文件保留在 git 中是为了让 `COPY` 在本地构建时不报错;真实 OTA 产物(含私钥 hotswap.key)永不提交。
+1413 -390
View File
File diff suppressed because it is too large Load Diff
+21 -8
View File
@@ -1,17 +1,21 @@
{
"name": "iboard",
"version": "1.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"tauri": "tauri",
"tauri": "tauri",
"dev:web": "vite",
"dev:server": "node --experimental-strip-types --no-warnings server/index.js",
"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",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
@@ -19,33 +23,42 @@
"tauri:android:dev": "tauri android dev",
"tauri:android:build": "tauri android build",
"tauri:android:apk": "tauri android build --apk",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"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": {
"@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",
"gray-matter": "^4.0.3",
"jsonwebtoken": "^9.0.2",
"marked": "^17.0.6",
"mariadb": "^3.5.3",
"marked": "^17.0.6",
"node-cron": "^3.0.3",
"prisma": "^7.7.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.8.1"
"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",
"sharp": "^0.33.0",
"vite": "^5.4.0"
}
}
}
-108
View File
@@ -1,108 +0,0 @@
import { createPool } from 'mariadb';
import fs from 'fs';
import path from 'path';
function loadDatabaseConfig() {
const envPath = path.join(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('DATABASE_URL=')) {
const url = trimmedLine.substring('DATABASE_URL='.length).replace(/^"|"$/g, '');
// 解析URL
const parts = url.split('://')[1].split('@');
const auth = parts[0].split(':');
const hostAndDb = parts[1].split('/');
const hostAndPort = hostAndDb[0].split(':');
return {
user: auth[0],
password: auth[1],
host: hostAndPort[0],
port: parseInt(hostAndPort[1]),
database: hostAndDb[1]
};
}
}
}
return {
host: '192.168.111.111',
port: 3306,
user: 'root',
password: 'fullstack',
database: 'iboard'
};
}
let pool;
async function initPool() {
if (!pool) {
const config = loadDatabaseConfig();
pool = createPool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
connectionLimit: 5
});
}
return pool;
}
export default async function handler(req, res) {
try {
await initPool();
// 获取查询参数
const { startDate, endDate, limit = 30 } = req.query;
let query = 'SELECT date, centerPrice FROM econ_ExchangeRate WHERE currency = ? ORDER BY date DESC';
const params = ['USD/CNY'];
if (startDate && endDate) {
query = 'SELECT date, centerPrice FROM econ_ExchangeRate WHERE currency = ? AND date BETWEEN ? AND ? ORDER BY date DESC';
params.push(startDate, endDate);
}
const result = await pool.query(query, params);
// 限制返回数量
const limitedResult = result.slice(0, parseInt(limit));
// 反转顺序,使日期从早到晚
const sortedResult = limitedResult.reverse();
// 格式化数据
const formattedData = sortedResult.map(row => ({
date: row.date instanceof Date ? row.date.toISOString().split('T')[0] : row.date,
rate: parseFloat(row.centerPrice)
}));
res.status(200).json({
success: true,
data: formattedData,
total: result.length
});
} catch (error) {
console.error('获取汇率数据失败:', error.message);
res.status(500).json({
success: false,
message: '获取汇率数据失败',
error: error.message
});
} finally {
// 不关闭连接池,保持连接池打开以提高性能
// 如果需要关闭连接池,应该在关闭后将 pool 变量设置为 null
// if (pool) {
// await pool.end();
// pool = null;
// }
}
}
-78
View File
@@ -1,78 +0,0 @@
import { createPool } from 'mariadb';
async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ message: '只支持GET请求' });
}
const { startDate, endDate, limit } = req.query;
let pool;
try {
pool = createPool({
host: process.env.DB_HOST || '192.168.111.111',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'fullstack',
database: process.env.DB_NAME || 'iboard',
connectionLimit: 5
});
let query = 'SELECT date, close FROM econ_SH300Index';
const params = [];
const conditions = [];
if (startDate && endDate) {
conditions.push('date BETWEEN ? AND ?');
params.push(startDate, endDate);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY date DESC';
const result = await pool.query(query, params);
// 限制返回数量,默认为30
const limitValue = limit ? parseInt(limit) : 30;
const limitedResult = result.slice(0, limitValue);
// 反转顺序,使日期从早到晚
const sortedResult = limitedResult.reverse();
const data = sortedResult.map(row => {
let dateStr;
if (row.date instanceof Date) {
dateStr = row.date.toISOString().split('T')[0];
} else if (typeof row.date === 'string') {
dateStr = row.date;
} else {
dateStr = String(row.date);
}
return {
date: dateStr,
close: parseFloat(row.close)
};
});
return res.status(200).json({
success: true,
data: data
});
} catch (error) {
console.error('获取沪深300数据失败:', error);
return res.status(500).json({
success: false,
message: '获取沪深300数据失败: ' + error.message
});
} finally {
if (pool) {
await pool.end();
}
}
}
export default handler;
-721
View File
@@ -1,721 +0,0 @@
import { useState, useEffect } from 'react'
import Head from 'next/head'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import TimeRangePicker from '../components/TimeRangePicker'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
const chinaBondData = [
{ month: '2024-01', oneYear: 1.85, fiveYear: 2.45, tenYear: 2.65 },
{ month: '2024-02', oneYear: 1.82, fiveYear: 2.42, tenYear: 2.62 },
{ month: '2024-03', oneYear: 1.78, fiveYear: 2.38, tenYear: 2.58 },
{ month: '2024-04', oneYear: 1.75, fiveYear: 2.35, tenYear: 2.55 },
{ month: '2024-05', oneYear: 1.72, fiveYear: 2.32, tenYear: 2.52 },
{ month: '2024-06', oneYear: 1.68, fiveYear: 2.28, tenYear: 2.48 },
{ month: '2024-07', oneYear: 1.65, fiveYear: 2.25, tenYear: 2.45 },
{ month: '2024-08', oneYear: 1.62, fiveYear: 2.22, tenYear: 2.42 },
{ month: '2024-09', oneYear: 1.58, fiveYear: 2.18, tenYear: 2.38 },
{ month: '2024-10', oneYear: 1.55, fiveYear: 2.15, tenYear: 2.35 },
{ month: '2024-11', oneYear: 1.52, fiveYear: 2.12, tenYear: 2.32 },
{ month: '2024-12', oneYear: 1.48, fiveYear: 2.08, tenYear: 2.28 }
]
const usBondData = [
{ month: '2024-01', oneYear: 4.85, fiveYear: 4.25, tenYear: 3.95 },
{ month: '2024-02', oneYear: 4.82, fiveYear: 4.22, tenYear: 3.92 },
{ month: '2024-03', oneYear: 4.78, fiveYear: 4.18, tenYear: 3.88 },
{ month: '2024-04', oneYear: 4.65, fiveYear: 4.05, tenYear: 3.75 },
{ month: '2024-05', oneYear: 4.55, fiveYear: 3.95, tenYear: 3.65 },
{ month: '2024-06', oneYear: 4.45, fiveYear: 3.85, tenYear: 3.55 },
{ month: '2024-07', oneYear: 4.35, fiveYear: 3.75, tenYear: 3.45 },
{ month: '2024-08', oneYear: 4.25, fiveYear: 3.65, tenYear: 3.35 },
{ month: '2024-09', oneYear: 4.15, fiveYear: 3.55, tenYear: 3.25 },
{ month: '2024-10', oneYear: 4.05, fiveYear: 3.45, tenYear: 3.15 },
{ month: '2024-11', oneYear: 3.95, fiveYear: 3.35, tenYear: 3.05 },
{ month: '2024-12', oneYear: 3.85, fiveYear: 3.25, tenYear: 2.95 }
]
const chinaLPRData = [
{ month: '2024-01', oneYear: 3.45, fiveYear: 3.95 },
{ month: '2024-02', oneYear: 3.45, fiveYear: 3.95 },
{ month: '2024-03', oneYear: 3.45, fiveYear: 3.95 },
{ month: '2024-04', oneYear: 3.40, fiveYear: 3.90 },
{ month: '2024-05', oneYear: 3.40, fiveYear: 3.90 },
{ month: '2024-06', oneYear: 3.40, fiveYear: 3.90 },
{ month: '2024-07', oneYear: 3.35, fiveYear: 3.85 },
{ month: '2024-08', oneYear: 3.35, fiveYear: 3.85 },
{ month: '2024-09', oneYear: 3.35, fiveYear: 3.85 },
{ month: '2024-10', oneYear: 3.30, fiveYear: 3.80 },
{ month: '2024-11', oneYear: 3.30, fiveYear: 3.80 },
{ month: '2024-12', oneYear: 3.30, fiveYear: 3.80 }
]
// 中国黄金储备月度数据(单位:吨)
const chinaGoldReserveMonthly = [
{ month: '2024-01', reserves: 2245.4 },
{ month: '2024-02', reserves: 2255.2 },
{ month: '2024-03', reserves: 2267.5 },
{ month: '2024-04', reserves: 2276.9 },
{ month: '2024-05', reserves: 2285.3 },
{ month: '2024-06', reserves: 2296.1 },
{ month: '2024-07', reserves: 2305.8 },
{ month: '2024-08', reserves: 2316.2 },
{ month: '2024-09', reserves: 2325.7 },
{ month: '2024-10', reserves: 2336.5 },
{ month: '2024-11', reserves: 2345.9 },
{ month: '2024-12', reserves: 2356.3 }
]
// 美国黄金储备月度数据(单位:吨)
const usGoldReserveMonthly = [
{ month: '2024-01', reserves: 8133.5 },
{ month: '2024-02', reserves: 8133.5 },
{ month: '2024-03', reserves: 8133.5 },
{ month: '2024-04', reserves: 8133.5 },
{ month: '2024-05', reserves: 8133.5 },
{ month: '2024-06', reserves: 8133.5 },
{ month: '2024-07', reserves: 8133.5 },
{ month: '2024-08', reserves: 8133.5 },
{ month: '2024-09', reserves: 8133.5 },
{ month: '2024-10', reserves: 8133.5 },
{ month: '2024-11', reserves: 8133.5 },
{ month: '2024-12', reserves: 8133.5 }
]
// 中国黄金储备年度数据(单位:吨)
const chinaGoldReserveYearly = [
{ year: '2019', reserves: 1948.3 },
{ year: '2020', reserves: 1948.3 },
{ year: '2021', reserves: 1948.3 },
{ year: '2022', reserves: 1948.3 },
{ year: '2023', reserves: 2190.2 },
{ year: '2024', reserves: 2356.3 }
]
// 美国黄金储备年度数据(单位:吨)
const usGoldReserveYearly = [
{ year: '2019', reserves: 8133.5 },
{ year: '2020', reserves: 8133.5 },
{ year: '2021', reserves: 8133.5 },
{ year: '2022', reserves: 8133.5 },
{ year: '2023', reserves: 8133.5 },
{ year: '2024', reserves: 8133.5 }
]
// 猪肉价格数据(单位:元/公斤)
const porkPriceData = [
{ month: '2023-01', price: 24.5 },
{ month: '2023-02', price: 25.2 },
{ month: '2023-03', price: 26.8 },
{ month: '2023-04', price: 27.5 },
{ month: '2023-05', price: 28.3 },
{ month: '2023-06', price: 29.1 },
{ month: '2023-07', price: 28.5 },
{ month: '2023-08', price: 27.8 },
{ month: '2023-09', price: 26.5 },
{ month: '2023-10', price: 25.8 },
{ month: '2023-11', price: 25.2 },
{ month: '2023-12', price: 24.8 },
{ month: '2024-01', price: 24.5 },
{ month: '2024-02', price: 25.1 },
{ month: '2024-03', price: 25.8 },
{ month: '2024-04', price: 26.5 },
{ month: '2024-05', price: 27.2 },
{ month: '2024-06', price: 27.8 },
{ month: '2024-07', price: 28.5 },
{ month: '2024-08', price: 29.2 },
{ month: '2024-09', price: 28.8 },
{ month: '2024-10', price: 28.2 },
{ month: '2024-11', price: 27.5 },
{ month: '2024-12', price: 26.8 }
]
// 能繁母猪数量数据(单位:万头)
const sowInventoryData = [
{ month: '2023-01', inventory: 4321 },
{ month: '2023-02', inventory: 4356 },
{ month: '2023-03', inventory: 4389 },
{ month: '2023-04', inventory: 4423 },
{ month: '2023-05', inventory: 4456 },
{ month: '2023-06', inventory: 4489 },
{ month: '2023-07', inventory: 4523 },
{ month: '2023-08', inventory: 4556 },
{ month: '2023-09', inventory: 4589 },
{ month: '2023-10', inventory: 4623 },
{ month: '2023-11', inventory: 4656 },
{ month: '2023-12', inventory: 4689 },
{ month: '2024-01', inventory: 4723 },
{ month: '2024-02', inventory: 4756 },
{ month: '2024-03', inventory: 4789 },
{ month: '2024-04', inventory: 4823 },
{ month: '2024-05', inventory: 4856 },
{ month: '2024-06', inventory: 4889 },
{ month: '2024-07', inventory: 4923 },
{ month: '2024-08', inventory: 4956 },
{ month: '2024-09', inventory: 4989 },
{ month: '2024-10', inventory: 5023 },
{ month: '2024-11', inventory: 5056 },
{ month: '2024-12', inventory: 5089 }
]
export default function Data() {
const [selectedIndicator, setSelectedIndicator] = useState('bond-china-treasury')
const [exchangeRateData, setExchangeRateData] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [timeRange, setTimeRange] = useState('1m') // 1m: 近一个月, 3m: 近三个月, 1y: 近一年, custom: 自定义
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
useEffect(() => {
if (selectedIndicator === 'central-bank-usd-cny-rate') {
fetchExchangeRateData()
}
}, [selectedIndicator, timeRange, startDate, endDate])
const handleTimeRangeChange = (range, start, end) => {
setTimeRange(range)
setStartDate(start)
setEndDate(end)
}
const fetchExchangeRateData = async () => {
setLoading(true)
setError(null)
try {
let url = '/api/exchange-rate'
if (timeRange === 'custom' && startDate && endDate) {
url += `?startDate=${startDate}&endDate=${endDate}`
} else if (timeRange === '1m') {
url += '?limit=30'
} else if (timeRange === '3m') {
url += '?limit=90'
} else if (timeRange === '1y') {
url += '?limit=365'
}
const response = await fetch(url)
const data = await response.json()
if (data.success) {
setExchangeRateData(data.data)
} else {
setError(data.message)
}
} catch (err) {
setError('获取汇率数据失败')
console.error('获取汇率数据失败:', err)
} finally {
setLoading(false)
}
}
const indicators = [
{
group: '债券',
items: [
{ id: 'bond-china-treasury', name: '中国国债' },
{ id: 'bond-us-treasury', name: '美国国债' }
]
},
{
group: '贷款',
items: [
{ id: 'loan-china-lpr', name: '中国LPR' }
]
},
{
group: '央行数据',
items: [
{ id: 'central-bank-china-gold-monthly', name: '中国黄金储备(月)' },
{ id: 'central-bank-us-gold-monthly', name: '美国黄金储备(月)' },
{ id: 'central-bank-china-gold-yearly', name: '中国黄金储备(年)' },
{ id: 'central-bank-us-gold-yearly', name: '美国黄金储备(年)' },
{ id: 'central-bank-usd-cny-rate', name: '人民币兑美元汇率' }
]
},
{
group: '商品',
items: [
{ id: 'commodity-pork-price', name: '猪肉价格' },
{ id: 'commodity-sow-inventory', name: '能繁母猪数量' }
]
}
]
return (
<div className="container">
<Head>
<title>数据 - iBoard</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Navbar />
<main>
<h1 className="title">
经济数据
</h1>
<p className="description">
各种经济数据和图表
</p>
<div className="data-layout">
<div className="data-sidebar">
{indicators.map((group, groupIndex) => (
<div key={groupIndex} className="indicator-group">
<h3 className="group-title">{group.group}</h3>
<ul className="indicator-list">
{group.items.map((item) => (
<li
key={item.id}
className={`indicator-item ${selectedIndicator === item.id ? 'active' : ''}`}
onClick={() => setSelectedIndicator(item.id)}
>
{item.name}
</li>
))}
</ul>
</div>
))}
</div>
<div className="data-content">
{selectedIndicator === 'bond-china-treasury' && (
<div className="indicator-detail">
<h2 className="indicator-title">中国国债收益率走势</h2>
<p className="indicator-description">展示一年期、五年期、十年期国债收益率的变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={chinaBondData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={['dataMin - 0.2', 'dataMax + 0.2']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="oneYear" name="一年期" stroke="#8884d8" strokeWidth={2} activeDot={{ r: 8 }} />
<Line type="monotone" dataKey="fiveYear" name="五年期" stroke="#82ca9d" strokeWidth={2} />
<Line type="monotone" dataKey="tenYear" name="十年期" stroke="#ffc658" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>月份</th>
<th>一年期(%)</th>
<th>五年期(%)</th>
<th>十年期(%)</th>
</tr>
</thead>
<tbody>
{chinaBondData.map((row, index) => (
<tr key={index}>
<td>{row.month}</td>
<td>{row.oneYear.toFixed(2)}</td>
<td>{row.fiveYear.toFixed(2)}</td>
<td>{row.tenYear.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedIndicator === 'bond-us-treasury' && (
<div className="indicator-detail">
<h2 className="indicator-title">美国国债收益率走势</h2>
<p className="indicator-description">展示一年期、五年期、十年期国债收益率的变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={usBondData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={['dataMin - 0.2', 'dataMax + 0.2']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="oneYear" name="一年期" stroke="#ff7300" strokeWidth={2} activeDot={{ r: 8 }} />
<Line type="monotone" dataKey="fiveYear" name="五年期" stroke="#0088fe" strokeWidth={2} />
<Line type="monotone" dataKey="tenYear" name="十年期" stroke="#00C49F" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>月份</th>
<th>一年期(%)</th>
<th>五年期(%)</th>
<th>十年期(%)</th>
</tr>
</thead>
<tbody>
{usBondData.map((row, index) => (
<tr key={index}>
<td>{row.month}</td>
<td>{row.oneYear.toFixed(2)}</td>
<td>{row.fiveYear.toFixed(2)}</td>
<td>{row.tenYear.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedIndicator === 'loan-china-lpr' && (
<div className="indicator-detail">
<h2 className="indicator-title">中国LPR走势</h2>
<p className="indicator-description">展示一年期和五年期贷款市场报价利率(LPR)的变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={chinaLPRData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={['dataMin - 0.2', 'dataMax + 0.2']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="oneYear" name="一年期LPR" stroke="#e6194b" strokeWidth={2} activeDot={{ r: 8 }} />
<Line type="monotone" dataKey="fiveYear" name="五年期LPR" stroke="#3cb44b" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>月份</th>
<th>一年期LPR(%)</th>
<th>五年期LPR(%)</th>
</tr>
</thead>
<tbody>
{chinaLPRData.map((row, index) => (
<tr key={index}>
<td>{row.month}</td>
<td>{row.oneYear.toFixed(2)}</td>
<td>{row.fiveYear.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedIndicator === 'central-bank-china-gold-monthly' && (
<div className="indicator-detail">
<h2 className="indicator-title">中国黄金储备(月度)</h2>
<p className="indicator-description">展示中国央行黄金储备的月度变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={chinaGoldReserveMonthly} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={['dataMin - 20', 'dataMax + 20']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="reserves" name="黄金储备(吨)" stroke="#ffd700" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>月份</th>
<th>黄金储备(吨)</th>
</tr>
</thead>
<tbody>
{chinaGoldReserveMonthly.map((row, index) => (
<tr key={index}>
<td>{row.month}</td>
<td>{row.reserves.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedIndicator === 'central-bank-us-gold-monthly' && (
<div className="indicator-detail">
<h2 className="indicator-title">美国黄金储备(月度)</h2>
<p className="indicator-description">展示美国央行黄金储备的月度变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={usGoldReserveMonthly} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={[8130, 8140]} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="reserves" name="黄金储备(吨)" stroke="#333333" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>月份</th>
<th>黄金储备(吨)</th>
</tr>
</thead>
<tbody>
{usGoldReserveMonthly.map((row, index) => (
<tr key={index}>
<td>{row.month}</td>
<td>{row.reserves.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedIndicator === 'central-bank-china-gold-yearly' && (
<div className="indicator-detail">
<h2 className="indicator-title">中国黄金储备(年度)</h2>
<p className="indicator-description">展示中国央行黄金储备的年度变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={chinaGoldReserveYearly} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 50', 'dataMax + 50']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="reserves" name="黄金储备(吨)" stroke="#ffd700" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>年份</th>
<th>黄金储备(吨)</th>
</tr>
</thead>
<tbody>
{chinaGoldReserveYearly.map((row, index) => (
<tr key={index}>
<td>{row.year}</td>
<td>{row.reserves.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedIndicator === 'central-bank-us-gold-yearly' && (
<div className="indicator-detail">
<h2 className="indicator-title">美国黄金储备(年度)</h2>
<p className="indicator-description">展示美国央行黄金储备的年度变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={usGoldReserveYearly} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={[8130, 8140]} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="reserves" name="黄金储备(吨)" stroke="#333333" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>年份</th>
<th>黄金储备(吨)</th>
</tr>
</thead>
<tbody>
{usGoldReserveYearly.map((row, index) => (
<tr key={index}>
<td>{row.year}</td>
<td>{row.reserves.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedIndicator === 'central-bank-usd-cny-rate' && (
<div className="indicator-detail">
<h2 className="indicator-title">人民币兑美元汇率</h2>
<p className="indicator-description">展示人民币兑美元汇率的变化趋势</p>
{loading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : (
<>
<div className="chart-header">
<TimeRangePicker
value={timeRange}
onChange={handleTimeRangeChange}
/>
</div>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={exchangeRateData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={['dataMin - 0.1', 'dataMax + 0.1']} tickFormatter={(value) => value.toFixed(4)} />
<Tooltip formatter={(value) => [value.toFixed(4), '汇率']} />
<Legend />
<Line type="monotone" dataKey="rate" name="汇率" stroke="#1e88e5" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>日期</th>
<th>汇率</th>
</tr>
</thead>
<tbody>
{exchangeRateData.map((row, index) => (
<tr key={index}>
<td>{row.date}</td>
<td>{row.rate.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{selectedIndicator === 'commodity-pork-price' && (
<div className="indicator-detail">
<h2 className="indicator-title">猪肉价格</h2>
<p className="indicator-description">展示中国猪肉价格的月度变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={porkPriceData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={['dataMin - 1', 'dataMax + 1']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="price" name="猪肉价格(元/公斤)" stroke="#ff6b6b" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>月份</th>
<th>猪肉价格(元/公斤)</th>
</tr>
</thead>
<tbody>
{porkPriceData.map((row, index) => (
<tr key={index}>
<td>{row.month}</td>
<td>{row.price.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedIndicator === 'commodity-sow-inventory' && (
<div className="indicator-detail">
<h2 className="indicator-title">能繁母猪数量</h2>
<p className="indicator-description">展示中国能繁母猪数量的月度变化趋势</p>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={sowInventoryData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={['dataMin - 50', 'dataMax + 50']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="inventory" name="能繁母猪数量(万头)" stroke="#4ecdc4" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>月份</th>
<th>能繁母猪数量(万头)</th>
</tr>
</thead>
<tbody>
{sowInventoryData.map((row, index) => (
<tr key={index}>
<td>{row.month}</td>
<td>{row.inventory}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</main>
<Footer />
</div>
)
}
-403
View File
@@ -1,403 +0,0 @@
import { useState, useEffect } from 'react'
import Head from 'next/head'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import TimeRangePicker from '../components/TimeRangePicker'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
const usDebtData = [
{ year: '2019', debt: 22.7, gdp: 21.4, debtToGdp: 106.1, interestToGdp: 1.8 },
{ year: '2020', debt: 26.9, gdp: 20.9, debtToGdp: 128.7, interestToGdp: 1.6 },
{ year: '2021', debt: 28.4, gdp: 23.0, debtToGdp: 123.5, interestToGdp: 1.5 },
{ year: '2022', debt: 30.9, gdp: 25.4, debtToGdp: 121.6, interestToGdp: 1.6 },
{ year: '2023', debt: 33.1, gdp: 26.9, debtToGdp: 123.0, interestToGdp: 1.9 },
{ year: '2024', debt: 35.3, gdp: 28.5, debtToGdp: 123.9, interestToGdp: 2.1 },
{ year: '2025', debt: 37.5, gdp: 30.2, debtToGdp: 124.2, interestToGdp: 2.3 },
{ year: '2026', debt: 39.8, gdp: 31.9, debtToGdp: 124.8, interestToGdp: 2.5 }
]
const chinaDebtData = [
{ year: '2019', debt: 8.8, gdp: 14.3, debtToGdp: 61.5, interestToGdp: 1.9 },
{ year: '2020', debt: 10.3, gdp: 14.7, debtToGdp: 70.1, interestToGdp: 1.8 },
{ year: '2021', debt: 11.7, gdp: 16.1, debtToGdp: 72.7, interestToGdp: 1.7 },
{ year: '2022', debt: 13.1, gdp: 17.9, debtToGdp: 73.2, interestToGdp: 1.6 },
{ year: '2023', debt: 14.5, gdp: 19.4, debtToGdp: 74.7, interestToGdp: 1.6 },
{ year: '2024', debt: 15.9, gdp: 21.0, debtToGdp: 75.7, interestToGdp: 1.5 },
{ year: '2025', debt: 17.3, gdp: 22.8, debtToGdp: 75.9, interestToGdp: 1.5 },
{ year: '2026', debt: 18.8, gdp: 24.7, debtToGdp: 76.1, interestToGdp: 1.4 }
]
export default function Strategy() {
const [selectedStrategy, setSelectedStrategy] = useState('us-debt-gdp')
const [sh300ExchangeData, setSh300ExchangeData] = useState([])
const [sh300Loading, setSh300Loading] = useState(false)
const [timeRange, setTimeRange] = useState('1m')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const strategies = [
{
id: 'us-debt-gdp',
name: '美国政府债务/GDP'
},
{
id: 'china-debt-gdp',
name: '中国政府债务/GDP'
},
{
id: 'sh300-exchange-rate',
name: '沪深300与汇率对比'
}
]
const handleTimeRangeChange = (range, start, end) => {
setTimeRange(range)
setStartDate(start)
setEndDate(end)
}
const fetchSh300ExchangeData = async () => {
console.log('fetchSh300ExchangeData called')
setSh300Loading(true)
try {
let sh300Url = '/api/sh300-index'
let exchangeUrl = '/api/exchange-rate'
const params = []
if (timeRange === 'custom' && startDate && endDate) {
params.push(`startDate=${startDate}`)
params.push(`endDate=${endDate}`)
} else if (timeRange === '1m') {
params.push('limit=30')
} else if (timeRange === '3m') {
params.push('limit=90')
} else if (timeRange === '1y') {
params.push('limit=365')
}
if (params.length > 0) {
sh300Url += '?' + params.join('&')
exchangeUrl += '?' + params.join('&')
}
console.log('Fetching from:', sh300Url, exchangeUrl)
const [sh300Res, exchangeRes] = await Promise.all([
fetch(sh300Url),
fetch(exchangeUrl)
])
console.log('Response status:', sh300Res.status, exchangeRes.status)
const sh300Json = await sh300Res.json()
const exchangeJson = await exchangeRes.json()
console.log('Response data:', sh300Json, exchangeJson)
if (sh300Json.success && exchangeJson.success) {
const sh300Map = new Map(sh300Json.data.map(item => [item.date, item.close]))
const exchangeMap = new Map(exchangeJson.data.map(item => [item.date, item.rate]))
const allDates = [...new Set([...sh300Map.keys(), ...exchangeMap.keys()])].sort()
const mergedData = allDates.map(date => ({
date,
sh300: sh300Map.get(date) || null,
exchangeRate: exchangeMap.get(date) || null
})).filter(item => item.sh300 !== null && item.exchangeRate !== null)
console.log('Merged data:', mergedData)
setSh300ExchangeData(mergedData)
}
} catch (error) {
console.error('获取数据失败:', error)
} finally {
setSh300Loading(false)
}
}
useEffect(() => {
if (selectedStrategy === 'sh300-exchange-rate') {
fetchSh300ExchangeData()
}
}, [selectedStrategy, timeRange, startDate, endDate])
return (
<div className="container">
<Head>
<title>策略 - iBoard</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Navbar />
<main>
<h1 className="title">
经济策略
</h1>
<p className="description">
按照经济理论组织的相关数据和图表的对比
</p>
<div className="strategy-layout">
<div className="strategy-sidebar">
<h3 className="sidebar-title">策略列表</h3>
<ul className="strategy-list">
{strategies.map((strategy) => (
<li
key={strategy.id}
className={`strategy-item ${selectedStrategy === strategy.id ? 'active' : ''}`}
onClick={() => setSelectedStrategy(strategy.id)}
>
{strategy.name}
</li>
))}
</ul>
</div>
<div className="strategy-content">
{selectedStrategy === 'us-debt-gdp' && (
<div className="strategy-detail">
<h2 className="strategy-title">美国政府债务/GDP分析</h2>
<p className="strategy-description">
分析美国政府债务、GDP、债务/GDP比率以及债务利息/GDP比率的走势
</p>
<div className="chart-section">
<h3 className="chart-title">美国政府债务走势</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={usDebtData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 2', 'dataMax + 2']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="debt" name="美国政府债务(万亿美元)" stroke="#8884d8" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="chart-section">
<h3 className="chart-title">美国GDP走势</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={usDebtData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 1', 'dataMax + 1']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="gdp" name="美国GDP(万亿美元)" stroke="#82ca9d" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="chart-section">
<h3 className="chart-title">美国政府债务/GDP走势</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={usDebtData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 5', 'dataMax + 5']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="debtToGdp" name="债务/GDP比率(%)" stroke="#ffc658" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="chart-section">
<h3 className="chart-title">美国政府债务利息/GDP</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={usDebtData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 0.2', 'dataMax + 0.2']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="interestToGdp" name="债务利息/GDP比率(%)" stroke="#ff7300" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>年份</th>
<th>政府债务(万亿美元)</th>
<th>GDP(万亿美元)</th>
<th>债务/GDP(%)</th>
<th>债务利息/GDP(%)</th>
</tr>
</thead>
<tbody>
{usDebtData.map((row, index) => (
<tr key={index}>
<td>{row.year}</td>
<td>{row.debt.toFixed(1)}</td>
<td>{row.gdp.toFixed(1)}</td>
<td>{row.debtToGdp.toFixed(1)}</td>
<td>{row.interestToGdp.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedStrategy === 'china-debt-gdp' && (
<div className="strategy-detail">
<h2 className="strategy-title">中国政府债务/GDP分析</h2>
<p className="strategy-description">
分析中国政府债务、GDP、债务/GDP比率以及债务利息/GDP比率的走势
</p>
<div className="chart-section">
<h3 className="chart-title">中国政府债务走势</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chinaDebtData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 1', 'dataMax + 1']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="debt" name="中国政府债务(万亿美元)" stroke="#e6194b" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="chart-section">
<h3 className="chart-title">中国GDP走势</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chinaDebtData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 1', 'dataMax + 1']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="gdp" name="中国GDP(万亿美元)" stroke="#3cb44b" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="chart-section">
<h3 className="chart-title">中国政府债务/GDP走势</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chinaDebtData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 5', 'dataMax + 5']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="debtToGdp" name="债务/GDP比率(%)" stroke="#4363d8" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="chart-section">
<h3 className="chart-title">中国政府债务利息/GDP</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chinaDebtData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis domain={['dataMin - 0.2', 'dataMax + 0.2']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="interestToGdp" name="债务利息/GDP比率(%)" stroke="#f58231" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>年份</th>
<th>政府债务(万亿美元)</th>
<th>GDP(万亿美元)</th>
<th>债务/GDP(%)</th>
<th>债务利息/GDP(%)</th>
</tr>
</thead>
<tbody>
{chinaDebtData.map((row, index) => (
<tr key={index}>
<td>{row.year}</td>
<td>{row.debt.toFixed(1)}</td>
<td>{row.gdp.toFixed(1)}</td>
<td>{row.debtToGdp.toFixed(1)}</td>
<td>{row.interestToGdp.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{selectedStrategy === 'sh300-exchange-rate' && (
<div className="strategy-detail">
<h2 className="strategy-title">沪深300与汇率对比分析</h2>
<p className="strategy-description">
对比沪深300指数收盘价与人民币兑美元汇率的走势关系
</p>
{sh300Loading ? (
<div className="loading">加载中...</div>
) : (
<>
<div className="chart-header">
<TimeRangePicker
value={timeRange}
onChange={handleTimeRangeChange}
/>
</div>
<div className="chart-section">
<h3 className="chart-title">沪深300与汇率对比(双Y轴)</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={400}>
<LineChart data={sh300ExchangeData} margin={{ top: 20, right: 60, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis yAxisId="left" domain={['auto', 'auto']} allowDataOverflow={true} />
<YAxis yAxisId="right" orientation="right" domain={['auto', 'auto']} allowDataOverflow={true} tickFormatter={(value) => value.toFixed(4)} />
<Tooltip />
<Legend />
<Line yAxisId="left" type="monotone" dataKey="sh300" name="沪深300收盘价" stroke="#8884d8" strokeWidth={2} activeDot={{ r: 8 }} />
<Line yAxisId="right" type="monotone" dataKey="exchangeRate" name="汇率" stroke="#82ca9d" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</>
)}
</div>
)}
</div>
</div>
</main>
<Footer />
</div>
)
}
@@ -0,0 +1,24 @@
-- 经济指标: 明细数据拆到独立表, 兼容年/季/月/日频率
-- 第一步: 建 IndicatorPoint 表 + Indicator 加元数据列 (data 列暂保留, 由迁移脚本搬运后删除)
-- CreateTable
CREATE TABLE `IndicatorPoint` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`indicatorId` INTEGER NOT NULL,
`period` VARCHAR(191) NOT NULL,
`value` DOUBLE NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `IndicatorPoint_indicatorId_period_key`(`indicatorId`, `period`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AlterTable: 元数据列 (data JSON 拆出)
ALTER TABLE `Indicator` ADD COLUMN `title` VARCHAR(191) NOT NULL DEFAULT '',
ADD COLUMN `unit` VARCHAR(191) NOT NULL DEFAULT '',
ADD COLUMN `source` VARCHAR(191) NOT NULL DEFAULT '',
ADD COLUMN `frequency` VARCHAR(191) NOT NULL DEFAULT 'month';
-- AddForeignKey
ALTER TABLE `IndicatorPoint` ADD CONSTRAINT `IndicatorPoint_indicatorId_fkey` FOREIGN KEY (`indicatorId`) REFERENCES `Indicator`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- 第二步: 明细数据已迁移到 IndicatorPoint, 删除 Indicator.data JSON 列
-- AlterTable
ALTER TABLE `Indicator` DROP COLUMN `data`;
@@ -0,0 +1,2 @@
-- 指标隐藏标记: 数据与获取脚本保留, 前端展示过滤 hidden=true
ALTER TABLE `Indicator` ADD COLUMN `hidden` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE `News` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`title` VARCHAR(191) NOT NULL,
`summary` VARCHAR(191) NOT NULL DEFAULT '',
`source` VARCHAR(191) NOT NULL DEFAULT '',
`url` VARCHAR(191) NOT NULL DEFAULT '',
`publishedAt` DATETIME(3) NOT NULL,
`impactRank` INTEGER NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `News_title_publishedAt_key`(`title`, `publishedAt`),
INDEX `News_publishedAt_idx`(`publishedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Alert` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`indicatorName` VARCHAR(191) NOT NULL,
`period` VARCHAR(191) NOT NULL,
`valueA` DOUBLE NOT NULL,
`valueB` DOUBLE NOT NULL,
`sourceA` VARCHAR(191) NOT NULL,
`sourceB` VARCHAR(191) NOT NULL,
`diff` DOUBLE NOT NULL,
`diffRatio` DOUBLE NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `Alert_createdAt_idx`(`createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,68 @@
-- 服务器监控 + 服务健康检查
-- CreateTable
CREATE TABLE `ServerNode` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`uuid` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`cpuName` VARCHAR(191) NOT NULL DEFAULT '',
`os` VARCHAR(191) NOT NULL DEFAULT '',
`region` VARCHAR(191) NOT NULL DEFAULT '',
`memTotal` BIGINT NOT NULL DEFAULT 0,
`diskTotal` BIGINT NOT NULL DEFAULT 0,
`cpuCores` INTEGER NOT NULL DEFAULT 0,
`cpuUsage` DOUBLE NOT NULL DEFAULT 0,
`ramUsed` BIGINT NOT NULL DEFAULT 0,
`swapUsed` BIGINT NOT NULL DEFAULT 0,
`load1` DOUBLE NOT NULL DEFAULT 0,
`load5` DOUBLE NOT NULL DEFAULT 0,
`load15` DOUBLE NOT NULL DEFAULT 0,
`diskUsed` BIGINT NOT NULL DEFAULT 0,
`netIn` BIGINT NOT NULL DEFAULT 0,
`netOut` BIGINT NOT NULL DEFAULT 0,
`uptime` BIGINT NOT NULL DEFAULT 0,
`process` INTEGER NOT NULL DEFAULT 0,
`connections` INTEGER NOT NULL DEFAULT 0,
`status` VARCHAR(191) NOT NULL DEFAULT 'unknown',
`recordedAt` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ServerNode_uuid_key`(`uuid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ServerNodeHistory` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`uuid` VARCHAR(191) NOT NULL,
`period` DATETIME(3) NOT NULL,
`cpu` DOUBLE NOT NULL DEFAULT 0,
`ram` BIGINT NOT NULL DEFAULT 0,
`load` DOUBLE NOT NULL DEFAULT 0,
`disk` BIGINT NOT NULL DEFAULT 0,
`netIn` BIGINT NOT NULL DEFAULT 0,
`netOut` BIGINT NOT NULL DEFAULT 0,
`process` INTEGER NOT NULL DEFAULT 0,
`connections` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `ServerNodeHistory_uuid_period_key`(`uuid`, `period`),
INDEX `ServerNodeHistory_uuid_period_idx`(`uuid`, `period`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ServiceCheck` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`url` VARCHAR(191) NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'unknown',
`latencyMs` INTEGER NOT NULL DEFAULT 0,
`message` VARCHAR(191) NOT NULL DEFAULT '',
`lastCheckAt` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ServiceCheck_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,2 @@
-- AlterTable (news.content 全文, 须在 news_and_alerts 建表之后执行)
ALTER TABLE `News` ADD COLUMN `content` TEXT NULL;
@@ -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;
+3 -3
View File
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
+220 -4
View File
@@ -41,12 +41,125 @@ model Strategy {
}
model Indicator {
id Int @id @default(autoincrement())
name String
id Int @id @default(autoincrement())
name String @unique
groupName String
data Json
title String @default("")
unit String @default("")
source String @default("")
frequency String @default("month") // year | quarter | month | day
hidden Boolean @default(false) // true = 数据/脚本保留, 但不在前端展示
points IndicatorPoint[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model IndicatorPoint {
id Int @id @default(autoincrement())
indicatorId Int
indicator Indicator @relation(fields: [indicatorId], references: [id], onDelete: Cascade)
period String // "2024" | "2024-Q1" | "2024-01" | "2024-01-15"
value Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([indicatorId, period])
}
// 新闻: 每天 3 点由 mx_finance_search_news 拉取, 按影响大小排序, 累加多日供首页无限滚动
model News {
id Int @id @default(autoincrement())
title String
summary String @default("")
content String? @db.Text
source String @default("")
url String @default("")
publishedAt DateTime
impactRank Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([title, publishedAt])
@@index([publishedAt])
}
// 报警: refresh 写库时比对同 (indicatorName, period) 的跨源数据, 差异超阈值记录
model Alert {
id Int @id @default(autoincrement())
indicatorName String
period String
valueA Float
valueB Float
sourceA String
sourceB String
diff Float
diffRatio Float
createdAt DateTime @default(now())
@@index([createdAt])
}
// 服务器监控: 对接 komari 公开 API
model ServerNode {
id Int @id @default(autoincrement())
uuid String @unique
name String
cpuName String @default("")
os String @default("")
region String @default("")
memTotal BigInt @default(0)
diskTotal BigInt @default(0)
cpuCores Int @default(0)
// 最新实时快照
cpuUsage Float @default(0)
ramUsed BigInt @default(0)
swapUsed BigInt @default(0)
load1 Float @default(0)
load5 Float @default(0)
load15 Float @default(0)
diskUsed BigInt @default(0)
netIn BigInt @default(0)
netOut BigInt @default(0)
uptime BigInt @default(0)
process Int @default(0)
connections Int @default(0)
status String @default("unknown") // online | offline | unknown
recordedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 时序历史: 每节点每分钟一条 (komari records 1 分钟粒度)
model ServerNodeHistory {
id Int @id @default(autoincrement())
uuid String
period DateTime
cpu Float @default(0)
ram BigInt @default(0)
load Float @default(0)
disk BigInt @default(0)
netIn BigInt @default(0)
netOut BigInt @default(0)
process Int @default(0)
connections Int @default(0)
createdAt DateTime @default(now())
@@unique([uuid, period])
@@index([uuid, period])
}
// 服务健康检查: 本应用自检 (健康端点 / DB / 第三方)
model ServiceCheck {
id Int @id @default(autoincrement())
name String @unique
url String
status String @default("unknown") // up | down | unknown
latencyMs Int @default(0)
message String @default("")
lastCheckAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Econ_SowInventory {
@@ -66,3 +179,106 @@ model ScraperCache {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 浙江省油价历史 (apizero price 接口每日抓取)
// 每天 04:00 cron 触发: 检查 schedule 里待定且 <= 昨天的调价日, 抓 price?province=浙江 存本表
// unique (date, province, fuel) 防重复
model OilPriceHistory {
id Int @id @default(autoincrement())
date DateTime // 调价日次日 (即数据实际抓取日)
province String // '浙江'
fuel String // '92' | '95' | '0'
price Float // 油价 (元/升, 4 位小数足够)
source String @default("apizero")
createdAt DateTime @default(now())
@@unique([date, province, fuel])
@@index([province, fuel, date])
}
// ==================== 环境监控:米家 ====================
// 米家授权状态 (DiskCache 存凭证, 本表只存状态/错误供前端展示)
model MihomeAccount {
id Int @id @default(autoincrement())
key String @unique @default("default")
userId String @default("")
status String @default("unknown") // ok | pending | expired
lastError String @default("")
updatedAt DateTime @updatedAt
}
model MihomeHome {
id Int @id @default(autoincrement())
homeId String @unique
name String
createdAt DateTime @default(now())
devices MihomeDevice[]
}
model MihomeDevice {
id Int @id @default(autoincrement())
did String @unique
homeId String
home MihomeHome @relation(fields: [homeId], references: [homeId])
model String
name String
alias String? // 用户别名, 仅本应用展示
tempSiid Int
tempPiid Int
humSiid Int
humPiid Int
battSiid Int // 电池电量属性 (炎米 3.1003 / 青萍 2.3)
battPiid Int
battery Int? // 最近一次电量 (每天读一次, 标题展示用)
batteryAt DateTime? // 电量读取时间 (当天缓存, 不频繁更新)
enabled Boolean @default(true)
lastSyncAt DateTime?
createdAt DateTime @default(now())
samples MihomeEnvSample[]
hours MihomeEnvHour[]
}
// 日档: 月视图(逐日)数据源; unique (deviceId, date) 防重
model MihomeEnvSample {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
date DateTime // 采样日 (Asia/Shanghai 当日 00:00 存 UTC)
temperature Float? // 当日最后一次实时值 (或 vendor hour 均值)
humidity Float?
temperatureAvg Float? // 当日均值 (由小时档聚合, 或 vendor_hour)
humidityAvg Float?
temperatureMin Float? // 当日最低
temperatureMax Float? // 当日最高
humidityMin Float?
humidityMax Float?
origin String @default("live") // live | vendor_history | backfill
sampledAt DateTime?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, date])
@@index([deviceId, date])
}
// 小时档: 当天视图(逐小时)数据源; unique (deviceId, hour) 防重
model MihomeEnvHour {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
hour DateTime // Asia/Shanghai 整点 (存 UTC)
temperatureAvg Float? // 该小时均值 (live 采样点, 或 vendor_hour)
humidityAvg Float?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, hour])
@@index([deviceId, hour])
}
+131 -6
View File
@@ -1,6 +1,131 @@
# iBoard
用于收集和整理经济数据,但是以策略为主体重新组织。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表。
- 策略是放按照经济理论组织的相关数据和图表的对比。
# iBoard
用于收集和整理经济数据,但是以策略为主体重新组织。
## 版本管理 (V0.1)
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.1.0`)。
发版时只改这一处,以下位置必须保持一致(改版本时一并核对):
| 文件 | 字段 | 说明 |
| --- | --- | --- |
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) |
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
说明:
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表。
- 策略是放按照经济理论组织的相关数据和图表的对比。
## 首页
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
### 新闻模块
增加一个新闻模块,数据来源是每日夜间 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 排除.
+72
View File
@@ -0,0 +1,72 @@
const fs = require('fs')
const content = `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'
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 openConn() {
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
}
// 每天凌晨 3:00 (本地时间) 拉取新闻 + 跑跨源对比
function scheduleNews() {
cron.schedule('0 3 * * *', async () => {
console.log('[cron] 3:00 触发')
try {
const list = await fetchNewsList()
const r = await persistNews(list)
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
const conn = await openConn()
try {
const a = await checkAfterRefresh(conn)
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
} finally { await conn.end() }
} catch (e) {
console.error('[cron] 任务失败:', e.message)
}
})
console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测')
}
// 每 2 分钟: 拉取 komari 节点 + 服务自检
function scheduleMonitors() {
cron.schedule('*/2 * * * *', async () => {
try {
const k = require('./komari-monitor.cjs')
const r = await k.runOnce()
console.log('[cron] komari: nodes=' + r.nodes + ' snapshots=' + r.snapshots + ' history=' + r.history + ' failed=' + r.failed)
} catch (e) { console.error('[cron] komari 任务失败:', e.message) }
try {
const h = require('./health-check.cjs')
const arr = await h.runOnce()
const down = arr.filter(x => x.status !== 'up').map(x => x.name)
console.log('[cron] health: ' + arr.length + ' 项, down=' + (down.length ? down.join(',') : '无'))
} catch (e) { console.error('[cron] health 任务失败:', e.message) }
})
console.log('[cron] 已注册: 每 2 分钟 komari + 服务自检')
}
// 每小时: 清理 24h 前的 komari 历史
function scheduleMonitorCleanup() {
cron.schedule('0 * * * *', async () => {
try {
const k = require('./komari-monitor.cjs')
const n = await k.cleanupOld()
console.log('[cron] 监控历史清理: 删除 ' + n + ' 条')
} catch (e) { console.error('[cron] 监控清理失败:', e.message) }
})
console.log('[cron] 已注册: 每小时清理 24h 前监控历史')
}
scheduleNews()
scheduleMonitors()
scheduleMonitorCleanup()
`
fs.writeFileSync('server/cron.js', content, 'utf8')
console.log('OK len=' + content.length)
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
// 用法:
// node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
// 流程:
// 1) 校验 dist/ 已构建
// 2) 生成 bundle.tar.gz (dist/ 内容)
// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
// 5) 全部产物输出到 out 目录 (默认 ota-package/)
//
// minisign 安装: 见 readme "OTA 升级" 章节
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const args = process.argv.slice(2)
function getArg(name, def) {
const i = args.indexOf(name)
return i >= 0 && args[i + 1] ? args[i + 1] : def
}
const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
const MIN_BINARY = getArg('--min-binary', '')
const NOTES = getArg('--notes', '')
const NO_SIGN = args.includes('--no-sign')
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const APP_VERSION = pkg.version || '0.0.0'
const DIST = path.join(ROOT, 'dist')
if (!fs.existsSync(path.join(DIST, 'index.html'))) {
console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build')
process.exit(1)
}
// 准备输出目录
fs.mkdirSync(OUT_DIR, { recursive: true })
// 1) 生成 bundle.tar.gz
const BUNDLE_NAME = 'iboard-ota.tar.gz'
const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME)
const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' })
if (r.status !== 0) {
console.error('[build-ota] tar 打包失败')
process.exit(1)
}
const bundleStat = fs.statSync(BUNDLE_PATH)
const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex')
// 2) 签名 (minisign)
let signature = ''
let pubkey = ''
if (!NO_SIGN) {
// CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
let KEY_PATH_TMP = null
let KEY_PATH_USE = KEY_PATH
if (KEY_FROM_ENV) {
KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
KEY_PATH_USE = KEY_PATH_TMP
}
if (!fs.existsSync(KEY_PATH_USE)) {
console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
console.error(' 注入方式二选一:')
console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key <path>')
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
process.exit(1)
}
// minisign -Sm <file> -s <key> 生成 detached signature
const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE]
const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' })
// 清理 CI 临时私钥 (无论成功失败都删)
if (KEY_PATH_TMP) {
try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
KEY_PATH_TMP = null
}
if (sigR.status !== 0) {
console.error('[build-ota] minisign 签名失败')
process.exit(1)
}
const sigPath = BUNDLE_PATH + '.minisig'
signature = fs.readFileSync(sigPath, 'utf8').trim()
// 尝试同步 pubkey 到 tauri.conf.json (如果存在)
const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
if (fs.existsSync(PUB_PATH)) {
pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
const lines = pubkey.split(/\r?\n/)
pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
}
}
// 3) 写 latest.json
const minBinary = MIN_BINARY || (() => {
// 默认从 src-tauri/tauri.conf.json 读 version
try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION }
catch { return APP_VERSION }
})()
const manifest = {
version: APP_VERSION + '-ota',
sequence: SEQUENCE,
min_binary_version: minBinary,
bundle: BUNDLE_NAME,
bundle_size: bundleStat.size,
bundle_sha256: bundleSha256,
signature,
notes: NOTES,
pub_date: new Date().toISOString(),
mandatory: false,
// 内部用: 前端 web 模式比对 build_id
build_id: 'seq-' + SEQUENCE,
}
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
// 4) 提示
console.log('')
console.log('=== OTA 包生成完成 ===')
console.log('输出目录: ', OUT_DIR)
console.log('版本: ', manifest.version)
console.log('sequence: ', manifest.sequence)
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
console.log('bundle sha256:', bundleSha256)
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
if (pubkey) {
console.log('')
console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
console.log(' ' + pubkey)
}
console.log('')
console.log('下一步: 把这两个文件发布到 OTA 存储:')
console.log(' ' + BUNDLE_NAME)
console.log(' latest.json')
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# CI 专用: 为 Tauri Android release 构建注入 release 签名配置
# 在 `tauri android init` 之后、`tauri android build` 之前运行。
#
# 依赖 (由调用方注入环境变量, 不要写死密钥到脚本/仓库):
# ANDROID_KEYSTORE_B64 - base64 编码的 keystore 文件内容 (CI secret)
# ANDROID_KEY_ALIAS - 密钥别名 (CI secret/variable)
# ANDROID_KEY_PASSWORD - store & key 密码 (CI secret; 本脚本两者用同一个)
#
# 作用:
# 1. 把 base64 密钥解码写到 $RUNNER_TEMP/iboard-release.keystore
# 2. 生成 gen/android/keystore.properties (Tauri 签名约定读取此文件)
# 3. 修改 gen/android/app/build.gradle.kts, 添加 signingConfigs.release
# 并让 buildTypes.release 使用它, 产出已签名 APK
set -euo pipefail
APP_GRADLE="src-tauri/gen/android/app/build.gradle.kts"
echo "[signing] 注入 Android release 签名配置..."
# --- 1. 解码 keystore ---
if [ -z "${ANDROID_KEYSTORE_B64:-}" ]; then
echo "[signing] ERROR: ANDROID_KEYSTORE_B64 未设置, 无法签名 release APK" >&2
exit 1
fi
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS 未设置}"
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD 未设置}"
KEYSTORE_FILE="${RUNNER_TEMP:-/tmp}/iboard-release.keystore"
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "$KEYSTORE_FILE"
# --- 2. 写 keystore.properties (Tauri 官方约定的签名配置文件位置) ---
# 注意: 不能写进 git, 这是运行时生成物
KEYSTORE_PROPERTIES="src-tauri/gen/android/keystore.properties"
cat > "$KEYSTORE_PROPERTIES" <<EOF
keyAlias=${ANDROID_KEY_ALIAS}
password=${ANDROID_KEY_PASSWORD}
storeFile=${KEYSTORE_FILE}
EOF
# --- 3. 修改 build.gradle.kts 注入 signingConfigs ---
if [ ! -f "$APP_GRADLE" ]; then
echo "[signing] ERROR: 找不到 $APP_GRADLE (请先运行 tauri android init)" >&2
exit 1
fi
# 幂等: 已注入则跳过
if grep -q 'create("release")' "$APP_GRADLE" 2>/dev/null; then
echo "[signing] build.gradle.kts 已有 release signingConfigs, 跳过注入"
else
# 在 android { 块内 buildTypes { 之前插入 signingConfigs 块
# 用 Python 处理 Kotlin DSL, 对模板结构做最小侵入
python3 - "$APP_GRADLE" <<'PYEOF'
import sys, re
path = sys.argv[1]
src = open(path, encoding='utf-8').read()
# Gradle Kotlin DSL 默认 import java.util.* (Properties 短名可用), 但没有 java.io.*
# 因此顶部需补 import java.io.FileInputStream, 注入块内全部用短名
if 'import java.io.FileInputStream' not in src:
# 在第一个 import 之后插入; 若无 import 则插到文件顶部
imp = re.search(r'^import .*$', src, flags=re.M)
new_import = 'import java.io.FileInputStream\n'
if imp:
src = src[:imp.end()] + '\n' + new_import + src[imp.end():]
else:
src = new_import + src
# 定位 android { 块内的 buildTypes { (注意 Kotlin 嵌套花括号)
m = re.search(r'(\n[ \t]*)(buildTypes\s*\{)', src)
if not m:
print('[signing] 未找到 buildTypes 块, 中止', file=sys.stderr)
sys.exit(1)
indent = m.group(1) # 含换行, e.g. "\n "
inner_indent = indent + ' '
signing_block = (
f'{inner_indent}signingConfigs {{\n'
f'{inner_indent} create("release") {{\n'
f'{inner_indent} val keystorePropertiesFile = rootProject.file("keystore.properties")\n'
f'{inner_indent} val keystoreProperties = Properties()\n'
f'{inner_indent} if (keystorePropertiesFile.exists()) {{\n'
f'{inner_indent} keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n'
f'{inner_indent} }}\n'
f'{inner_indent} keyAlias = keystoreProperties["keyAlias"] as? String ?: ""\n'
f'{inner_indent} keyPassword = keystoreProperties["password"] as? String ?: ""\n'
f'{inner_indent} storeFile = file(keystoreProperties["storeFile"] as? String ?: "")\n'
f'{inner_indent} storePassword = keystoreProperties["password"] as? String ?: ""\n'
f'{inner_indent} }}\n'
f'{inner_indent}}}\n'
)
# 在 buildTypes { 前插入 signingConfigs 块
src = src.replace(m.group(0), signing_block + m.group(0), 1)
# 让 buildTypes 里的 release 使用该 signingConfig
rel = re.search(r'(\n[ \t]*)(getByName\("release"\)\s*\{)', src)
if not rel:
rel = re.search(r'(\n[ \t]*)(release\s*\{)', src)
if rel:
rel_indent = rel.group(1) + ' '
inject = rel_indent + 'signingConfig = signingConfigs.getByName("release")\n'
src = src.replace(rel.group(0), rel.group(0) + inject, 1)
open(path, 'w', encoding='utf-8').write(src)
print('[signing] build.gradle.kts 已注入 import + signingConfigs + release signingConfig')
PYEOF
fi
# --- 4. 放行明文 HTTP (release APK 默认拦截 http://, 而服务器仅支持 HTTP) ---
# Tauri 模板默认 debug=true, release=false。Android 9+ 会拦截 release 的所有 http:// 明文请求
# 导致 Failed to fetch。这里把 release 的 usesCleartextTraffic 改为 "true"。
# 幂等: 第二次运行时模板已被改为 "true", 替换无 false 可改, 也不会重复注入。
if [ ! -f "$APP_GRADLE" ]; then
echo "[signing] ERROR: 找不到 $APP_GRADLE (明文放行跳过)" >&2
exit 1
fi
python3 - "$APP_GRADLE" <<'PYEOF'
import sys, re
path = sys.argv[1]
src = open(path, encoding='utf-8').read()
# 1) 把显式的 usesCleartextTraffic = "false" 全部改为 "true"
new = re.sub(
r'(manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*)"false"',
r'\1"true"',
src,
)
# 2) 若模板根本没有 usesCleartextTraffic 配置, 则在 release 构建块内注入一行 true
if 'usesCleartextTraffic' not in src:
m = re.search(r'^([ \t]*)getByName\("release"\)\s*\{', new, flags=re.M)
if not m:
m = re.search(r'^([ \t]*)release\s*\{', new, flags=re.M)
if m:
inner = m.group(1) + ' '
inject = f'\n{inner}manifestPlaceholders["usesCleartextTraffic"] = "true"'
new = new.replace(m.group(0), m.group(0) + inject, 1)
else:
print('[signing] 警告: 未找到 release 构建块, 明文放行注入跳过', file=sys.stderr)
open(path, 'w', encoding='utf-8').write(new)
print('[signing] usesCleartextTraffic 已设为 true (release 放行明文 HTTP)')
PYEOF
echo "[signing] 完成: keystore.properties 已写, build.gradle.kts 已注入"
+44
View File
@@ -0,0 +1,44 @@
require('dotenv').config()
const axios = require('axios')
async function call(tool, query) {
const base = process.env.EASTMONEY_MCP_BASE || 'https://mxapi.eastmoney.com/mxds/mcp'
const payload = {
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: tool, arguments: { query } },
}
const resp = await axios.post(base, payload, {
timeout: 60000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'em_api_key': process.env.EM_API_KEY,
},
validateStatus: () => true,
responseType: 'text',
transformResponse: [(d) => d],
})
const body = String(resp.data)
try {
const j = JSON.parse(body)
const text = j.result.content[0].text
const parsed = JSON.parse(text)
console.log(`\n=== ${tool}: "${query}" ===`)
console.log('sheets 数量:', parsed.data.length)
parsed.data.forEach((s, i) => {
console.log(`\nsheet[${i}]:`)
console.log(' sheetName:', s.sheetName)
console.log(' columns[0..3]:', s.columns.slice(0, 4))
console.log(' items[0][0..3]:', s.items[0] ? s.items[0].slice(0, 4) : '(无)')
})
} catch (e) {
console.log('解析失败:', e.message)
console.log(body.slice(0, 1000))
}
}
async function main() {
await call('mx_macro_data', '查询中国 2020-2025 年 CPI 月度同比数据')
await call('mx_macro_data', '查询中国 2015-2024 年 GDP 同比增速年度数据')
}
main().catch(console.error)
+33
View File
@@ -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) })
+32
View File
@@ -0,0 +1,32 @@
/**
* 列出东方财富妙想 MCP 暴露的所有工具
* 用法: npm run data:discover
* 输出: tool 名称 + 描述 + 输入 schema
*/
require('dotenv').config()
async function main() {
if (!process.env.EM_API_KEY) {
console.error('缺少 EM_API_KEY (在 .env 配)')
process.exit(1)
}
const { listAvailableTools } = await import('./fetchers/eastmoney-mcp.fetcher.js')
console.log('[Discover] 调妙想 MCP tools/list...')
const tools = await listAvailableTools()
console.log(`[Discover] 找到 ${tools.length} 个工具\n`)
for (const t of tools) {
console.log(`── ${t.name} ${'─'.repeat(Math.max(0, 60 - t.name.length))}`)
if (t.description) console.log(` ${t.description}`)
if (t.inputSchema) {
console.log(' inputSchema:')
const s = JSON.stringify(t.inputSchema, null, 2)
s.split('\n').forEach((line) => console.log(' ' + line))
}
console.log('')
}
}
main().catch((e) => {
console.error('[Discover] 失败:', e.message)
process.exit(1)
})
+303
View File
@@ -0,0 +1,303 @@
// 东方财富妙想 MCP 数据源
// 协议: StreamableHttp (MCP 2025-03-26 spec), HTTP POST + JSON-RPC 2.0
// - 认证: header `em_api_key: <EM_API_KEY>`
// - 妙想 MCP 是"自然语言驱动": 传 query 字符串, 内部 LLM 解析 + 调底层数据接口
// - 重要: 每次调用 5-20s + LLM token 成本, 必须配合"DB 优先" (refresh 时已存在的指标跳过)
//
// 需要的 .env:
// EM_API_KEY=xxx 必填
// EASTMONEY_MCP_BASE=... 可选, 默认 https://mxapi.eastmoney.com/mxds/mcp
//
// INDICATORS 数组: 配置要拉的指标
// 每项:
// name → Indicator.name (主键)
// groupName → Indicator.groupName
// title → data.title
// unit → data.unit
// tool → MCP tool 名
// query → 自然语言查询
// pickSheet(sheets) → 选一张 sheet (默认取第一张)
// parseSheet(sheet) → [{period, value}, ...] 默认按宽表转长表 (列=日期, 行=值)
import axios from 'axios'
const DEFAULT_BASE = 'https://mxapi.eastmoney.com/mxds/mcp'
const TIMEOUT = 60000
// ====== INDICATORS 配置 ======
// 跑 npm run data:discover 看可用 tool, 再加新指标
const INDICATORS = [
{
name: 'china-cpi-yoy',
groupName: 'china-macro',
title: '中国 CPI 同比',
unit: '%',
tool: 'mx_macro_data',
query: '查询中国 2015 年至今 CPI 月度同比数据,每一个月的数值',
pickSheet: (sheets) =>
sheets.find((s) => s.items?.[0]?.[0]?.includes('CPI') && s.items[0][0].includes('同比'))
|| sheets.find((s) => s.sheetName?.includes('宏观数据'))
|| sheets[0],
},
{
name: 'china-gdp-yoy',
groupName: 'china-macro',
title: '中国 GDP 同比',
unit: '%',
tool: 'mx_macro_data',
query: '查询中国 2015-2025 年 GDP 不变价同比增速,季度数据,每一季度的数值',
pickSheet: (sheets) =>
sheets.find((s) => s.items?.[0]?.[0]?.includes('GDP') && s.items[0][0].includes('同比'))
|| sheets[0],
},
{
// 国债收益率: MCP 返回日频, 用月末降采样成月频点
name: 'china-bond-10y',
groupName: 'china-macro',
title: '中国国债 10 年期收益率',
unit: '%',
frequency: 'month',
tool: 'mx_macro_data',
query: '查询中国10年期国债到期收益率 2024年至今 每日数据',
pickSheet: (sheets) =>
sheets.find((s) => s.items?.some?.((row) => String(row[0] || '').includes('国债到期收益率:10年')))
|| sheets[0],
parseSheet: (sheet) => monthEndSample(sheet, '国债到期收益率:10年'),
},
{
name: 'china-bond-1y',
groupName: 'china-macro',
title: '中国国债 1 年期收益率',
unit: '%',
frequency: 'month',
tool: 'mx_macro_data',
query: '查询中国1年期国债到期收益率 2024年至今 每日数据',
pickSheet: (sheets) =>
sheets.find((s) => s.items?.some?.((row) => String(row[0] || '').includes('国债到期收益率:1年')))
|| sheets[0],
parseSheet: (sheet) => monthEndSample(sheet, '国债到期收益率:1年'),
},
{
name: 'china-lpr-1y',
groupName: 'china-macro',
title: '中国 LPR 1 年期',
unit: '%',
frequency: 'month',
tool: 'mx_macro_data',
query: '查询中国LPR 1年期贷款市场报价利率 2024年至今 月度数据,每一个月的数值',
pickSheet: (sheets) =>
sheets.find((s) => s.sheetName?.includes('宏观数据(月)'))
|| sheets[0],
parseSheet: (sheet) => pickRowByKeyword(sheet, '(LPR):1年'),
},
{
name: 'china-lpr-5y',
groupName: 'china-macro',
title: '中国 LPR 5 年期',
unit: '%',
frequency: 'month',
tool: 'mx_macro_data',
query: '查询中国LPR 5年期以上贷款市场报价利率 2024年至今 月度数据,每一个月的数值',
pickSheet: (sheets) =>
sheets.find((s) => s.sheetName?.includes('宏观数据(月)'))
|| sheets[0],
parseSheet: (sheet) => pickRowByKeyword(sheet, '(LPR):5年'),
},
]
export const META = {
name: 'eastmoney-mcp',
description: '东方财富妙想 MCP (StreamableHttp, NL 查询)',
docs: 'https://mxapi.eastmoney.com/mxds/doc/mcp-install.md',
}
// ====== StreamableHttp 客户端 ======
class McpClient {
constructor(baseUrl, apiKey) {
this.baseUrl = baseUrl
this.apiKey = apiKey
this.id = 0
}
async _request(method, params) {
const payload = { jsonrpc: '2.0', id: ++this.id, method, ...(params ? { params } : {}) }
const resp = await axios.post(this.baseUrl, payload, {
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'em_api_key': this.apiKey,
},
responseType: 'text',
transformResponse: [(d) => d],
validateStatus: () => true,
})
if (resp.status >= 400) {
throw new Error(`HTTP ${resp.status}: ${String(resp.data).slice(0, 300)}`)
}
const parsed = parseMcpResponse(String(resp.data || ''))
// JSON-RPC 信封 {jsonrpc,id,result|error}, 统一解包 result
if (parsed.error) {
throw new Error(`MCP error ${parsed.error.code}: ${parsed.error.message}`)
}
return parsed.result || parsed
}
async listTools() {
const r = await this._request('tools/list')
return r.tools || []
}
async callTool(name, args = {}) {
const r = await this._request('tools/call', { name, arguments: args })
if (r.isError) {
const msg = (r.content || []).map((c) => c.text || '').join('; ')
throw new Error(`tool ${name} error: ${msg || JSON.stringify(r).slice(0, 200)}`)
}
return r
}
}
// 解析响应: JSON 或 SSE
function parseMcpResponse(text) {
const t = text.trim()
if (t.startsWith('{')) return JSON.parse(t)
if (t.startsWith('data:')) {
const lines = t.split('\n').filter((l) => l.startsWith('data:'))
let last = null
for (const line of lines) {
const json = line.slice(5).trim()
if (!json || json === '[DONE]') continue
try { last = JSON.parse(json) } catch { /* skip */ }
}
if (!last) throw new Error('SSE 无有效 data 块: ' + text.slice(0, 200))
return last
}
throw new Error('未知响应格式: ' + t.slice(0, 200))
}
// 默认 sheet 解析: 宽表 (列=日期, 行=值) → 长表
// 例: { columns:["指标","来源","2024-12","2024-11"], items:[["CPI","统计局","0.1","0.2"]] }
// → [{period:"2024-12", value:0.1}, {period:"2024-11", value:0.2}]
// 返回时反转成从老到新
function defaultParseSheet(sheet) {
if (!sheet || !sheet.columns || !sheet.items || !sheet.items[0]) return []
const cols = sheet.columns
const row = sheet.items[0]
const dates = cols.slice(2) // 跳过 [指标名, 来源]
const values = row.slice(2)
const out = []
for (let i = 0; i < dates.length; i++) {
const v = parseFloat(values[i])
if (!isNaN(v)) out.push({ period: dates[i], value: v })
}
return out.reverse() // 从老到新
}
// 宽表 → 长表, 指定 item 行 (按指标全名前缀匹配), 日频列降采样为月末值
// 例: columns=["指标","来源","2026-08-05","2026-08-04",...] → [{period:"2024-01",value},...]
function monthEndSample(sheet, keyword) {
if (!sheet || !sheet.columns || !sheet.items) return []
const row = sheet.items.find((r) => String(r[0] || '').includes(keyword))
if (!row) return []
const cols = sheet.columns
const dates = cols.slice(2) // 新 → 老
const values = row.slice(2)
const out = []
const seen = new Set()
for (let i = 0; i < dates.length; i++) {
const v = parseFloat(values[i])
const m = String(dates[i] || '').slice(0, 7) // "2024-01-02" → "2024-01"
if (!/^\d{4}-\d{2}$/.test(m) || Number.isNaN(v)) continue
if (seen.has(m)) continue // 列是降序, 第一次遇到的即当月最后一个交易日
seen.add(m)
out.push({ period: m, value: v })
}
return out.reverse() // 从老到新
}
// 宽表 → 长表, 指定 item 行 (按指标全名前缀匹配), 保留原频率 (月/季/年)
function pickRowByKeyword(sheet, keyword) {
if (!sheet || !sheet.columns || !sheet.items) return []
const row = sheet.items.find((r) => String(r[0] || '').includes(keyword))
if (!row) return []
const cols = sheet.columns
const dates = cols.slice(2)
const values = row.slice(2)
const out = []
for (let i = 0; i < dates.length; i++) {
const v = parseFloat(values[i])
if (!Number.isNaN(v)) out.push({ period: String(dates[i]), value: v })
}
return out.reverse() // 从老到新
}
// ====== fetcher 入口 ======
export async function listAvailableTools() {
const key = process.env.EM_API_KEY
if (!key) throw new Error('缺少 EM_API_KEY')
const base = process.env.EASTMONEY_MCP_BASE || DEFAULT_BASE
const client = new McpClient(base, key)
return client.listTools()
}
// opts.skipNames: Set<string> — DB 已存在的指标名, 跳过不调 MCP (省 token)
// opts.onlyNames: Set<string> — 白名单, 只跑这些 (用于补跑单个)
export async function fetchAll(opts = {}) {
if (INDICATORS.length === 0) {
throw new Error('eastmoney-mcp: INDICATORS 数组为空, 加新指标见文件顶部注释')
}
const skip = opts.skipNames || new Set()
const force = opts.forceNames || new Set()
const only = opts.onlyNames || null
const key = process.env.EM_API_KEY
if (!key) throw new Error('缺少 EM_API_KEY')
const base = process.env.EASTMONEY_MCP_BASE || DEFAULT_BASE
const client = new McpClient(base, key)
const results = []
for (const cfg of INDICATORS) {
if (skip.has(cfg.name) && !force.has(cfg.name)) {
console.log(`[MCP] ${cfg.name} 跳过 (DB 已存在)`)
continue
}
if (only && !only.has(cfg.name)) continue
try {
console.log(`[MCP] ${cfg.name} 调用 ${cfg.tool}: ${cfg.query}`)
const r = await client.callTool(cfg.tool, { query: cfg.query })
const text = (r.content || []).map((c) => c.text || '').join('')
let payload
try { payload = JSON.parse(text) } catch { payload = { raw: text } }
const sheets = payload.data || (Array.isArray(payload) ? payload : [payload])
const sheet = cfg.pickSheet ? cfg.pickSheet(sheets) : sheets[0]
const parsed = cfg.parseSheet ? cfg.parseSheet(sheet) : defaultParseSheet(sheet)
if (parsed.length === 0) {
console.log(`[MCP] ${cfg.name} 返回空 rows, 跳过`)
continue
}
results.push({
name: cfg.name,
groupName: cfg.groupName,
data: {
unit: cfg.unit,
title: cfg.title,
source: 'eastmoney-mcp',
rows: parsed,
},
})
console.log(`[MCP] ${cfg.name} OK, ${parsed.length} rows (${parsed[0]?.period}..${parsed.at(-1)?.period})`)
} catch (e) {
console.error(`[MCP] ${cfg.name} FAIL: ${e.message}`)
}
}
// 全部被 DB 优先跳过时, 返回显式标记而不是空数组 (避免被误判为失败)
if (results.length === 0 && INDICATORS.every((cfg) => skip.has(cfg.name) && !force.has(cfg.name))) {
return { skipped: true, reason: 'DB 已存在全部指标' }
}
return results
}
+83
View File
@@ -0,0 +1,83 @@
// Fetcher 注册表: 列出所有启用的数据源
// 加新数据源只要:
// 1. 写一个 scripts/fetchers/<source>.fetcher.js, 导出 META + fetchAll
// 2. 在本文件 FETCHERS 数组里加一行
// 3. 完事, refresh 脚本会自动跑
// 每项的 schema:
// name: 数据源标识 (用于日志)
// module: fetcher 模块路径 (相对 scripts/fetchers/)
// enabled: true/false, 可由 env 关闭 (如 ENABLE_EASTMONEY_MCP=0)
// env: 需要的环境变量名列表, 缺一即跳过
export const FETCHERS = [
{
name: 'wb-indicators',
module: './wb-indicators.fetcher.js',
enabled: true,
env: [],
},
{
name: 'eastmoney-mcp',
module: './eastmoney-mcp.fetcher.js',
// 默认开启, 有 APIKey 才真正能跑 (fetcher 内部会自检)
enabled: process.env.ENABLE_EASTMONEY_MCP !== '0',
env: ['EM_API_KEY'],
},
// 后续在这里追加:
// { name: 'fred', module: './fred.fetcher.js', enabled: true, env: ['FRED_API_KEY'] },
// { name: 'tushare', module: './tushare.fetcher.js', enabled: true, env: ['TUSHARE_TOKEN'] },
]
// 校验环境变量, 返回 { ok, missing[] }
export function checkEnv(required) {
const missing = required.filter((k) => !process.env[k] || !process.env[k].trim())
return { ok: missing.length === 0, missing }
}
// 加载并运行所有启用的 fetcher, 返回 [{ name, ok, items, error, skipped }]
// 错误隔离: 一个 fetcher 挂了不影响其他
// opts.skipNames: Set<string> — DB 优先, 已存在的指标名, 透传给 fetcher
// opts.forceNames: Set<string> — 强制刷新名单, 即使 skipNames 里有也照拉
export async function runFetchers(opts = {}) {
const results = []
for (const meta of FETCHERS) {
if (!meta.enabled) {
results.push({ name: meta.name, skipped: true, reason: 'disabled' })
console.log(`[Fetcher] ${meta.name} 跳过 (disabled)`)
continue
}
const envCheck = checkEnv(meta.env)
if (!envCheck.ok) {
results.push({ name: meta.name, skipped: true, reason: `缺少 env: ${envCheck.missing.join(', ')}` })
console.log(`[Fetcher] ${meta.name} 跳过 (${envCheck.missing.join(', ')})`)
continue
}
try {
const mod = await import(meta.module)
if (!mod.META || !mod.fetchAll) {
throw new Error('fetcher 必须导出 META 和 fetchAll')
}
console.log(`[Fetcher] ${meta.name} 开始...`)
const out = await mod.fetchAll({ skipNames: opts.skipNames, onlyNames: opts.onlyNames, forceNames: opts.forceNames })
// fetcher 可返回 { skipped: true, reason } 表示"全部被 DB 优先跳过", 不算失败
if (out && out.skipped) {
results.push({ name: meta.name, skipped: true, reason: out.reason })
console.log(`[Fetcher] ${meta.name} 跳过 (${out.reason})`)
continue
}
const items = Array.isArray(out) ? out : []
// 校验返回结构
const valid = items.filter((it) => it && it.name && it.data && Array.isArray(it.data.rows))
if (valid.length === 0) {
throw new Error('fetcher 未返回任何有效指标 (需包含 name/data.rows)')
}
console.log(`[Fetcher] ${meta.name} 拉取 ${valid.length} 个指标`)
results.push({ name: meta.name, ok: true, items: valid })
} catch (e) {
results.push({ name: meta.name, ok: false, error: e.message })
console.error(`[Fetcher] ${meta.name} 失败: ${e.message}`)
}
}
return results
}
+137
View File
@@ -0,0 +1,137 @@
// 新闻 fetcher: 每天 3 点调用 mx_finance_search_news 拉国内外经济政治新闻
// 按影响大小排序, 取前 10 条 (LLM 可能多/少几条, 全部入 DB, 按 impactRank 标记)
// 数据直接写 News 表, 与 Indicator 解耦
import axios from 'axios'
import mysql from 'mysql2/promise'
const MCP_BASE_DEFAULT = 'https://mxapi.eastmoney.com/mxds/mcp'
const TIMEOUT = 90000
const TOP_N = 10
const QUERIES = [
'今天国内外最新重大经济政治新闻, 按影响大小排序, 给出前 ' + TOP_N + ' 条, 每条含标题、摘要、发布时间、来源、链接',
]
class McpClient {
constructor(baseUrl, apiKey) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.id = 0 }
async _request(method, params) {
const payload = { jsonrpc: '2.0', id: ++this.id, method, ...(params ? { params } : {}) }
const resp = await axios.post(this.baseUrl, payload, {
timeout: TIMEOUT,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', 'em_api_key': this.apiKey },
responseType: 'text', transformResponse: [(d) => d], validateStatus: () => true,
})
if (resp.status >= 400) throw new Error('HTTP ' + resp.status + ': ' + String(resp.data).slice(0, 300))
const parsed = JSON.parse(String(resp.data || ''))
if (parsed.error) throw new Error('MCP error ' + parsed.error.code + ': ' + parsed.error.message)
return parsed.result || parsed
}
async callTool(name, args = {}) {
const r = await this._request('tools/call', { name, arguments: args })
if (r.isError) throw new Error('tool error: ' + (r.content || []).map((c) => c.text || '').join('; '))
return r
}
}
function parsePublishedAt(s) {
if (!s) return null
const d = new Date(String(s).replace(/-/g, '/'))
return isNaN(d.getTime()) ? null : d
}
function pickNewsSheet(sheets) {
if (!Array.isArray(sheets) || sheets.length === 0) return null
return sheets.find((s) => s.sheetName && (s.sheetName.includes('资讯') || s.sheetName.includes('新闻'))) || sheets[0]
}
// items[i] = [title, summary, publishedAt, source, url]
function parseNewsSheet(sheet, impactBase = 0) {
if (!sheet || !sheet.items) return []
const out = []
for (let i = 0; i < sheet.items.length; i++) {
const r = sheet.items[i]
const title = String(r[0] || '').trim().slice(0, 190)
if (!title) continue
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),
content: body.slice(0, 8000), // 正文全文 (MCP "摘要"列实际是正文), 上限防异常超长
source: String(r[3] || '').trim().slice(0, 80),
url: String(r[4] || '').trim().slice(0, 190), // 列宽 varchar(191), 超长会被 MySQL 拒绝
publishedAt,
impactRank: impactBase + i + 1,
})
}
return out
}
export const META = { name: 'news-mcp', description: '妙想 MCP 新闻资讯 (mx_finance_search_news)' }
export async function fetchNewsList() {
const key = process.env.EM_API_KEY
if (!key) throw new Error('缺少 EM_API_KEY')
const base = process.env.EASTMONEY_MCP_BASE || MCP_BASE_DEFAULT
const client = new McpClient(base, key)
const all = []
let impactBase = 0
for (const q of QUERIES) {
try {
console.log('[News] 调用: ' + q)
const r = await client.callTool('mx_finance_search_news', { query: q })
const text = (r.content || []).map((c) => c.text || '').join('')
const payload = JSON.parse(text)
const sheets = payload.data || (Array.isArray(payload) ? payload : [payload])
const sheet = pickNewsSheet(sheets)
const news = parseNewsSheet(sheet, impactBase)
console.log('[News] 解析 ' + news.length + ' 条')
all.push(...news)
impactBase = all.length
} catch (e) {
console.error('[News] 拉取失败:', e.message)
}
}
return all
}
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 persistNews(newsList) {
if (!process.env.DATABASE_URL) throw new Error('缺少 DATABASE_URL')
if (!newsList || newsList.length === 0) return { inserted: 0, skipped: 0 }
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
let inserted = 0, skipped = 0, failed = 0
try {
for (const n of newsList) {
try {
// 注意: content 列依赖迁移 add_news_content 已应用, 发布顺序须先迁移后跑本脚本
// 重复命中 (title, publishedAt) 时只回填仍为 NULL 的 content, 避免覆盖已有全文
const [r] = await conn.execute(
'INSERT INTO `News` (`title`, `summary`, `content`, `source`, `url`, `publishedAt`, `impactRank`, `createdAt`) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3)) ' +
'AS new ON DUPLICATE KEY UPDATE `impactRank` = new.`impactRank`, `content` = IFNULL(News.`content`, new.`content`)',
[n.title, n.summary, n.content || null, n.source, n.url, n.publishedAt, n.impactRank],
)
if (r.affectedRows === 1) inserted++
else skipped++
} catch (e) {
failed++
console.error('[News] 单条入库失败:', n.title, e.message)
}
}
} finally {
await conn.end()
}
return { inserted, skipped, failed }
}
+113
View File
@@ -0,0 +1,113 @@
import axios from 'axios'
// World Bank API 数据源
// 文档: https://datahelpdesk.worldbank.org/knowledgebase/articles/898581
// 优点: 官方、JSON、免 key、稳定, 中国数据滞后约 1 年 (这是全球宏观数据物理上限)
// 接口: GET /v2/country/{code}/indicator/{id}?format=json&date={startYear}:{endYear}&per_page=100
const WB_BASE = 'https://api.worldbank.org/v2'
const TIMEOUT = 15000
// 6 个核心宏观指标
// - 中国 GDP (现价美元) - 国民经济总规模
// - 中国 GDP 增速 - 增长动能
// - 中国 CPI 同比 - 通胀
// - 中国失业率 - 就业
// - 美国 GDP (现价美元) - 对标
// - 美国 CPI 同比 - 对标
export const WB_INDICATORS = [
{ id: 'NY.GDP.MKTP.CD', name: 'china-gdp-usd', groupName: 'china-macro', title: '中国 GDP', unit: '万亿美元', scale: 1e-8, digits: 2 },
{ id: 'NY.GDP.MKTP.KD.ZG',name: 'china-gdp-growth', groupName: 'china-macro', title: '中国 GDP 增速', unit: '%', scale: 1, digits: 2 },
{ id: 'FP.CPI.TOTL.ZG', name: 'china-cpi', groupName: 'china-macro', title: '中国 CPI', unit: '%', scale: 1, digits: 2 },
{ id: 'SL.UEM.TOTL.ZS', name: 'china-unemploy', groupName: 'china-macro', title: '中国失业率', unit: '%', scale: 1, digits: 2 },
{ id: 'NY.GDP.MKTP.CD', name: 'usa-gdp-usd', groupName: 'usa-macro', title: '美国 GDP', unit: '万亿美元', scale: 1e-8, digits: 2 },
{ id: 'FP.CPI.TOTL.ZG', name: 'usa-cpi', groupName: 'usa-macro', title: '美国 CPI', unit: '%', scale: 1, digits: 2 },
]
// World Bank 中国是 CHN, 美国是 USA
function countryCodeOf(name) {
if (name.startsWith('china-')) return 'CHN'
if (name.startsWith('usa-')) return 'USA'
throw new Error('Unknown country for ' + name)
}
// 拉单个指标从 startYear 到当前年
// 返回 { name, groupName, title, unit, rows: [{ year, value }] }
// 注意: WB 对连续请求会限流返回 400 Invalid value, 加 3 次退避重试
export async function fetchIndicator(meta, startYear = 2015) {
const country = countryCodeOf(meta.name)
const currentYear = new Date().getFullYear()
const url = `${WB_BASE}/country/${country}/indicator/${meta.id}?format=json&date=${startYear}:${currentYear}&per_page=200`
let resp
const delays = [0, 1500, 4000]
for (let attempt = 0; attempt < delays.length; attempt++) {
if (delays[attempt]) await new Promise((r) => setTimeout(r, delays[attempt]))
try {
resp = await axios.get(url, { timeout: TIMEOUT, headers: { 'User-Agent': 'iboard-refresh/1.0' } })
break
} catch (e) {
if (attempt === delays.length - 1) throw e
console.log(`[WB] ${meta.name} 请求失败, ${delays[attempt + 1] / 1000}s 后重试 (${e.message})`)
}
}
// WB 返回: [{page,pages,per_page,total,...}, [{indicator, country, value, date}, ...]]
const data = resp.data
if (!Array.isArray(data) || data.length < 2 || !Array.isArray(data[1])) {
throw new Error('World Bank 返回结构异常: ' + JSON.stringify(data).slice(0, 200))
}
const points = data[1]
.filter((p) => p.value !== null && p.value !== undefined)
.map((p) => ({
year: String(p.date),
value: Number((Number(p.value) * meta.scale).toFixed(meta.digits)),
}))
.sort((a, b) => Number(a.year) - Number(b.year))
return {
name: meta.name,
groupName: meta.groupName,
data: {
unit: meta.unit,
title: meta.title,
source: 'World Bank API',
rows: points,
},
}
}
export const META = {
name: 'wb-indicators',
description: 'World Bank 官方 API 宏观数据',
docs: 'https://datahelpdesk.worldbank.org/knowledgebase/articles/898581',
}
// 拉全部 (返回符合 fetcher 契约的 [{name, groupName, data}, ...])
// 错误隔离: 单个指标失败 -> 跳过 + 记日志, 不影响其他
// opts.skipNames: Set<string> — DB 已存在的指标名, 跳过不拉 (WB 免费但也要省请求)
export async function fetchAll(opts = {}) {
const skip = opts.skipNames || new Set()
const startYear = 2015
const results = []
for (const m of WB_INDICATORS) {
if (skip.has(m.name)) {
console.log(`[WB] ${m.name} 跳过 (DB 已存在)`)
continue
}
try {
const r = await fetchIndicator(m, startYear)
console.log(`[WB] ${r.name} OK, ${r.data.rows.length} rows (${r.data.rows[0]?.year}..${r.data.rows.at(-1)?.year})`)
results.push(r)
} catch (e) {
console.error(`[WB] ${m.name} FAIL: ${e.message}`)
}
}
// 全部被 DB 优先跳过时, 返回显式标记而不是空数组 (避免被误判为失败)
if (results.length === 0 && WB_INDICATORS.every((m) => skip.has(m.name))) {
return { skipped: true, reason: 'DB 已存在全部指标' }
}
return results
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// 一次性导入浙江省油价历史 (2025-2026)
// 来源: 手工整理 (省发改委公告 / 潮新闻 / 钱江晚报 / 杭州日报 / 杭州网 等)
// 用法: node --experimental-strip-types --no-warnings scripts/import-oil-history.mjs [--dry]
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import 'dotenv/config'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
// 接入 prisma (用 file:// URL 避免 Windows 绝对路径 import 报错)
const adapterMod = await import('@prisma/adapter-mariadb')
const clientPath = pathToFileURL(path.join(ROOT, 'generated/prisma/client.ts')).href
const { PrismaClient } = await import(clientPath)
const adapter = new adapterMod.PrismaMariaDb((() => {
const u = new URL(process.env.DATABASE_URL)
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
}
})())
const prisma = new PrismaClient({ adapter })
// 数据: [调价日, 92, 95, 0, source]
const RAW = [
['2026-07-31', 7.94, 8.44, 7.62, 'manual'],
['2026-07-17', 7.39, 7.86, 7.06, 'manual'],
['2026-06-18', 7.91, 8.41, 7.59, 'manual'],
['2026-06-04', 8.32, 8.85, 8.02, 'manual'],
['2026-05-21', 8.74, 9.30, 8.45, 'manual'],
['2026-02-03', 6.91, 7.35, 6.56, 'manual'],
['2025-12-22', 6.68, 7.11, 6.32, 'manual'],
['2025-12-08', 6.82, 7.25, 6.46, 'manual'],
['2025-11-24', 6.86, null, null, 'manual'],
['2025-11-10', 6.92, 7.36, 6.57, 'manual'],
['2025-10-13', 7.03, 7.48, 6.68, 'manual'],
['2025-06-17', 7.15, 7.60, 6.81, 'manual'],
['2025-06-03', 6.94, 7.38, 6.59, 'manual'],
]
const PROVINCE = '浙江'
const FUEL_COLS = [
{ fuel: '92', idx: 1 },
{ fuel: '95', idx: 2 },
{ fuel: '0', idx: 3 },
]
const DRY = process.argv.includes('--dry')
async function main() {
console.log('=== 浙江省油价历史导入 ===')
console.log('dry run:', DRY)
console.log('')
let inserted = 0
let updated = 0
let skipped = 0
for (const row of RAW) {
const dateStr = row[0]
const source = row[4]
for (const { fuel, idx } of FUEL_COLS) {
const price = row[idx]
if (price == null || Number.isNaN(price)) {
console.log('[SKIP] ' + dateStr + ' ' + fuel + ': 价格为空')
skipped++
continue
}
const date = new Date(dateStr + 'T00:00:00')
if (DRY) {
console.log('[DRY] ' + dateStr + ' ' + fuel + ' = ' + price + ' (source=' + source + ')')
inserted++
continue
}
try {
await prisma.oilPriceHistory.create({
data: { date, province: PROVINCE, fuel, price, source },
})
console.log('[OK] ' + dateStr + ' ' + fuel + ' = ' + price)
inserted++
} catch (e) {
if (e?.code === 'P2002') {
try {
await prisma.oilPriceHistory.update({
where: { date_province_fuel: { date, province: PROVINCE, fuel } },
data: { price, source },
})
console.log('[UPD] ' + dateStr + ' ' + fuel + ' = ' + price)
updated++
} catch (e2) {
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e2.message)
}
} else {
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e.message)
}
}
}
}
console.log('')
console.log('=== 汇总 ===')
console.log('插入: ' + inserted)
console.log('更新: ' + updated)
console.log('跳过: ' + skipped)
await prisma.$disconnect()
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+36
View File
@@ -0,0 +1,36 @@
// 定时任务失败报警: 向 Alert 表插入一条错误记录
// 供 server/cron.js 与 scripts/scheduler/index.js 共用
// 首页报警 tab 会展示 Alert 表, 字段按错误语义填充 (indicatorName=任务+错误摘要)
import 'dotenv/config'
import mysql from 'mysql2/promise'
function parseUrl(url) {
const u = new URL(url)
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
}
export async function insertCronAlert(taskName, message) {
if (!process.env.DATABASE_URL) {
console.error('[cron-alert] 缺少 DATABASE_URL, 跳过报警写入')
return
}
let conn = null
try {
conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
// 用 Asia/Shanghai 时区的当天日期, 避免 UTC 偏移导致 period 显示成昨天
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
const cleanMsg = String(message || '').replace(/\s+/g, ' ').trim()
const name = ('[cron] ' + taskName + ' 失败: ' + cleanMsg).slice(0, 180)
await conn.execute(
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) ' +
'VALUES (?, ?, 0, 0, ?, ?, 0, 0, NOW(3))',
[name, today, 'cron', 'error'],
)
console.error('[cron-alert] 已写入报警:', name)
} catch (e) {
// 报警写入本身失败 (如 DB 不可达) 不能反噬主流程/再次抛错, 只记录
console.error('[cron-alert] 写入报警失败:', e.message)
} finally {
if (conn) await conn.end().catch(() => {})
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 一次性数据迁移: 把 Indicator.data (JSON) 拆分到 IndicatorPoint 明细表
* 用法: 在迁移 A (建表+加列) 之后、迁移 B (删 data 列) 之前运行
* 幂等: 同 (indicatorId, period) 重复插入会更新 value, 可安全重跑
*/
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(/^\//, ''),
}
}
// 根据 row 的时间字段推断频率: year | quarter | month | day
function inferFrequency(row) {
if (row && row.month !== undefined) return 'month'
if (row && row.year !== undefined) return 'year'
const p = String((row && row.period) || '')
if (/^\d{4}$/.test(p)) return 'year'
if (/^\d{4}-Q[1-4]$/.test(p)) return 'quarter'
if (/^\d{4}-\d{2}$/.test(p)) return 'month'
if (/^\d{4}-\d{2}-\d{2}$/.test(p)) return 'day'
return 'month'
}
// 统一 row 的时间字段为 period
function periodOf(row) {
if (!row) return ''
return String(row.period !== undefined ? row.period : (row.month !== undefined ? row.month : row.year))
}
async function main() {
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
try {
const [rows] = await conn.execute('SELECT id, name, groupName, data FROM Indicator')
console.log('[Migrate] 读取', rows.length, '个 Indicator')
let points = 0, updated = 0
for (const ind of rows) {
// mysql2 对 JSON 列已自动解析为对象, 兼容字符串兜底
let data = ind.data
if (typeof data === 'string') {
try { data = JSON.parse(data) } catch { data = null }
}
const title = (data && data.title) || ind.name
const unit = (data && data.unit) || ''
const source = (data && data.source) || ''
const rowsArr = (data && Array.isArray(data.rows)) ? data.rows : []
const freq = rowsArr.length ? inferFrequency(rowsArr[0]) : 'month'
// 更新 Indicator 元数据列
await conn.execute(
'UPDATE Indicator SET title=?, unit=?, source=?, frequency=?, updatedAt=NOW(3) WHERE id=?',
[title, unit, source, freq, ind.id],
)
updated++
// 写入明细
for (const r of rowsArr) {
const period = periodOf(r)
const value = Number(r.value)
if (!period || Number.isNaN(value)) continue
const [res] = await conn.execute(
'INSERT INTO IndicatorPoint (indicatorId, period, value, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3)) ' +
'ON DUPLICATE KEY UPDATE value=VALUES(value), updatedAt=NOW(3)',
[ind.id, period, value],
)
points += (res.affectedRows > 0 ? 1 : 0)
}
console.log('[Migrate]', ind.name, '->', rowsArr.length, '个点, 频率', freq)
}
console.log('[Migrate] 完成: 更新', updated, '个 Indicator, 写入/更新', points, '个明细点')
} finally {
await conn.end()
}
}
main().catch((e) => { console.error('[Migrate] 失败:', e); process.exit(1) })
+29
View File
@@ -0,0 +1,29 @@
// 手动跑一次米家采集 (备用): npm run data:mihome
// 带文件日志, 便于定位卡点
import fs from 'node:fs'
const LOG = 'D:\\workbench\\iboard\\server\\mihome\\.cache\\cli.log'
const log = (m) => fs.appendFileSync(LOG, new Date().toISOString() + ' ' + m + '\n')
process.on('uncaughtException', (e) => { log('uncaughtException: ' + (e.stack || e)); process.exit(1) })
process.on('unhandledRejection', (e) => { log('unhandledRejection: ' + (e && e.stack ? e.stack : String(e))); process.exit(1) })
log('start')
const m = await import('../server/mihome/index.js')
log('imported; calling dailyMaintenance')
try {
const r = await m.dailyMaintenance()
log('done: ' + JSON.stringify(r))
console.log('[data:mihome] 完成:', JSON.stringify(r))
} catch (e) {
if (e instanceof m.MihomeAuthError) {
log('auth error: ' + e.message)
console.log('[data:mihome] 米家授权不可用: ' + e.message)
console.log('[data:mihome] 请运行 npm run data:mihome:login 扫码授权')
process.exit(2)
}
log('failed: ' + (e && e.stack ? e.stack : String(e)))
console.error('[data:mihome] 失败:', e.message)
process.exit(1)
}
log('exit(0)')
process.exit(0)
+38
View File
@@ -0,0 +1,38 @@
// 手动扫码授权: npm run data:mihome:login
// 启动后台 QRlogin, 轮询状态; 出二维码后打印地址, 扫码成功后自动结束.
import { startQrLogin, getQrState, checkAuth } from '../server/mihome/client.js'
const auth = await checkAuth(true)
if (auth.status === 'ok') {
console.log('[米家] 已授权有效, 无需重扫 (本地 .cache 凭证可用)')
process.exit(0)
}
await startQrLogin()
const started = Date.now()
console.log('[米家] 等待扫码... (120s 超时)')
for (;;) {
const s = getQrState()
if (s.qrImageUrl && s.state === 'waiting') {
console.log('[米家] 二维码(需米家 App 扫):')
console.log(' image: ' + s.qrImageUrl)
}
if (s.state === 'failed') {
console.log('[米家] 扫码失败: ' + (s.lastError || '未知'))
process.exit(2)
}
if (s.state === 'idle' && s.running === false && !s.qrImageUrl) {
// 启动即失败(可能直接完成/或没权限)
console.log('[米家] 流程结束(无二维码产生), 请检查日志')
process.exit(2)
}
if (!s.running && s.qrImageUrl) {
console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/')
process.exit(0)
}
if (Date.now() - started > 150000) {
console.log('[米家] 超时, 已停止; 可重跑.')
process.exit(3)
}
await new Promise((r) => setTimeout(r, 2000))
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
// 生成 minisign 密钥对 (ota-package/hotswap.{key,pub})
// 首次跑一遍: node scripts/ota-genkey.mjs
// - hotswap.key: 私钥, 不要提交, 部署时由 CI 注入 (环境变量 / secret)
// - hotswap.pub: 公钥, 写到 src-tauri/tauri.conf.json 的 plugins.hotswap.pubkey
//
// 依赖: minisign (https://jedisct1.github.io/minisign/)
// - Windows: scoop install minisign 或 choco install minisign
// - macOS: brew install minisign
// - Linux: apt install minisign
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const OUT_DIR = path.join(ROOT, 'ota-package')
fs.mkdirSync(OUT_DIR, { recursive: true })
const KEY = path.join(OUT_DIR, 'hotswap.key')
const PUB = path.join(OUT_DIR, 'hotswap.pub')
if (fs.existsSync(KEY) || fs.existsSync(PUB)) {
console.error('[ota-genkey] 已存在密钥, 如需重新生成请先删除 ota-package/hotswap.{key,pub}')
console.error(' 注意: 重新生成密钥会导致旧 bundle 无法被客户端验证, 慎用!')
process.exit(1)
}
// 检查 minisign 是否存在
const which = spawnSync('minisign', ['-v'], { stdio: 'pipe' })
if (which.status !== 0 && which.error) {
console.error('[ota-genkey] 未找到 minisign 命令, 请先安装:')
console.error(' Windows: scoop install minisign')
console.error(' macOS: brew install minisign')
console.error(' Linux: apt install minisign')
process.exit(1)
}
// -G: 生成 -W: 无密码 -p: 公钥输出 -s: 私钥输出
const r = spawnSync('minisign', ['-G', '-W', '-p', PUB, '-s', KEY], { stdio: 'inherit' })
if (r.status !== 0) {
console.error('[ota-genkey] minisign 生成失败')
process.exit(1)
}
console.log('\n=== 密钥生成完成 ===')
console.log('私钥: ' + KEY + ' (不要提交到 git!)')
console.log('公钥: ' + PUB)
const pubText = fs.readFileSync(PUB, 'utf8')
const lines = pubText.split(/\r?\n/)
const pubB64 = lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ'))
console.log('\n请把下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
console.log(' ' + (pubB64 || '').trim())
// 自动写 .gitignore 提醒
const gitignore = path.join(ROOT, '.gitignore')
if (fs.existsSync(gitignore)) {
let txt = fs.readFileSync(gitignore, 'utf8')
if (!txt.includes('ota-package/hotswap.key')) {
if (!txt.endsWith('\n')) txt += '\n'
txt += '\n# OTA 升级私钥, 不要提交\nota-package/hotswap.key\n'
fs.writeFileSync(gitignore, txt)
console.log('\n[ota-genkey] 已把 ota-package/hotswap.key 加入 .gitignore')
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* 拉所有启用的数据源, upsert 到 Indicator + IndicatorPoint 明细表
* 用法:
* npm run data:refresh # 只拉 DB 里没有的指标
* npm run data:refresh -- --force a,b,c # 强制刷新指定指标 (忽略 DB 已有)
*
* 数据结构 (2026-08 重构):
* Indicator 元数据: name/groupName/title/unit/source/frequency/hidden
* IndicatorPoint 明细: (indicatorId, period, value), period 兼容年/季/月/日
*
* DB 优先策略:
* 1. 先从 DB 查已存在的 name 集合
* 2. 把集合传给每个 fetcher, fetcher 跳过已存在的
* 3. 只对 DB 没有的指标调远程拉取, 节省 token (尤其妙想 MCP 这种 LLM 接口)
*
* 加新数据源: 写 scripts/fetchers/<name>.fetcher.js 导出 META + fetchAll(opts),
* 然后在 scripts/fetchers/index.js 的 FETCHERS 注册
*/
require('dotenv').config()
const mysql = require('mysql2/promise')
// 隐藏指标: 数据与获取脚本保留, 但不参与前端展示 (API 过滤)
const HIDDEN_INDICATORS = new Set([
'china-gdp-usd', // 中国 GDP (现价美元)
'china-gdp-growth', // 中国 GDP 同比 (World Bank)
'china-gdp-yoy', // 中国 GDP 同比 (妙想季度)
'china-unemploy', // 中国失业率
'usa-gdp-usd', // 美国 GDP
])
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(/^\//, ''),
}
}
function parseForceArg(argv) {
const i = argv.indexOf('--force')
if (i === -1) return new Set()
return new Set(String(argv[i + 1] || '').split(',').map((s) => s.trim()).filter(Boolean))
}
// 推断频率: year | quarter | month | day (优先用 fetcher 显式声明)
function inferFrequency(data) {
if (data && data.frequency) return data.frequency
const p = String(((data && data.rows) || [])[0]?.period || '')
if (/^\d{4}$/.test(p)) return 'year'
if (/^\d{4}-Q[1-4]$/.test(p)) return 'quarter'
if (/^\d{4}-\d{2}$/.test(p)) return 'month'
if (/^\d{4}-\d{2}-\d{2}$/.test(p)) return 'day'
return 'month'
}
// 写 Indicator 元数据 + 全量替换明细点 (每指标一个事务)
async function upsertIndicatorWithPoints(conn, payload) {
const { name, groupName, data, hidden } = payload
const title = data.title || name
const unit = data.unit || ''
const source = data.source || ''
const frequency = inferFrequency(data)
const rows = Array.isArray(data.rows) ? data.rows : []
await conn.beginTransaction()
try {
const [res] = await conn.execute(
'INSERT INTO `Indicator` (`name`, `groupName`, `title`, `unit`, `source`, `frequency`, `hidden`, `createdAt`, `updatedAt`) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3), NOW(3)) ' +
'ON DUPLICATE KEY UPDATE `groupName` = VALUES(`groupName`), `title` = VALUES(`title`), ' +
'`unit` = VALUES(`unit`), `source` = VALUES(`source`), `frequency` = VALUES(`frequency`), ' +
'`hidden` = VALUES(`hidden`), `updatedAt` = NOW(3)',
[name, groupName, title, unit, source, frequency, hidden ? 1 : 0],
)
const indicatorId = res.insertId
// 明细: 先删旧点再插入新点 (全量替换, 保证与数据源一致)
await conn.execute('DELETE FROM `IndicatorPoint` WHERE `indicatorId` = ?', [indicatorId])
let points = 0
for (const r of rows) {
const period = String(r.period !== undefined ? r.period : (r.month !== undefined ? r.month : r.year))
const value = Number(r.value)
if (!period || Number.isNaN(value)) continue
await conn.execute(
'INSERT INTO `IndicatorPoint` (`indicatorId`, `period`, `value`, `createdAt`, `updatedAt`) VALUES (?, ?, ?, NOW(3), NOW(3))',
[indicatorId, period, value],
)
points++
}
await conn.commit()
return { indicatorId, points }
} catch (e) {
await conn.rollback()
throw e
}
}
async function fetchExistingNames(conn) {
const [rows] = await conn.execute('SELECT `name` FROM `Indicator`')
return new Set(rows.map((r) => r.name))
}
async function main() {
if (!process.env.DATABASE_URL) {
console.error('缺少 DATABASE_URL 环境变量')
process.exit(1)
}
const force = parseForceArg(process.argv.slice(2))
if (force.size) console.log('[Refresh] 强制刷新: ' + [...force].join(', '))
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
try {
// 1) 查 DB 已存在的指标名
const existing = await fetchExistingNames(conn)
console.log('[Refresh] DB 已有 ' + existing.size + ' 个指标: ' + ([...existing].join(', ') || '(空)'))
// 2) 跑所有 fetcher, 传入 existing + force (DB 优先, force 覆盖)
const { runFetchers } = await import('./fetchers/index.js')
const fetcherResults = await runFetchers({ skipNames: existing, forceNames: force })
// 3) 汇总: force 名单里的指标即使 DB 已有也写入
const allItems = []
let skipped = 0, failed = 0
for (const r of fetcherResults) {
if (r.skipped) { skipped++; continue }
if (!r.ok) { failed++; continue }
for (const it of r.items) {
if (existing.has(it.name) && !force.has(it.name)) continue
allItems.push(it)
}
}
console.log('\n[Refresh] 汇总: ' + allItems.length + ' 个指标待写 (跳过 ' + skipped + ' 个数据源, 失败 ' + failed + ' 个, DB 已存在 ' + existing.size + ' 个)')
if (failed > 0) {
console.error('[Refresh] 警告: 有数据源失败, 仅写入成功的指标')
}
// 5) 同步隐藏标记: 即使指标被 DB 优先跳过, 名单内的也确保 hidden=true
if (HIDDEN_INDICATORS.size > 0) {
const names = [...HIDDEN_INDICATORS]
const ph = names.map(() => '?').join(',')
const sql = "UPDATE `Indicator` SET `hidden` = 1, `updatedAt` = NOW(3) WHERE `name` IN (" + ph + ")"
await conn.execute(sql, names)
console.log('[Refresh] 同步隐藏标记: ' + names.length + ' 个指标 hidden=true (数据保留, 不展示)')
}
// 6) 跨源对比报警: 全量跑一次 (即便无新指标也跑, 防止上次漏检)
try {
const m = await import('../server/alert-engine.js')
const a = await m.checkAfterRefresh(conn)
console.log('[Refresh] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
} catch (e) { console.error('[Refresh] 报警检测异常:', e.message) }
if (allItems.length === 0) {
console.log('[Refresh] 无指标待写, 不写库 (DB 优先策略生效)')
return
}
// 4) 写库
console.log('[Refresh] 写入数据库...')
let ins = 0, upd = 0, totalPoints = 0
for (const it of allItems) {
const hidden = HIDDEN_INDICATORS.has(it.name)
const { points } = await upsertIndicatorWithPoints(conn, { name: it.name, groupName: it.groupName, data: it.data, hidden })
if (points > 0) ins++
else upd++
totalPoints += points
console.log('[Refresh] ' + it.name + (hidden ? ' (hidden)' : '') + ': ' + points + ' 个明细点')
}
} finally {
await conn.end()
}
}
main().catch((e) => {
console.error('[Refresh] 异常:', e)
process.exit(1)
})
+12
View File
@@ -0,0 +1,12 @@
require('dotenv').config()
async function main() {
console.log('[News] 开始拉取...')
const t0 = Date.now()
const mod = await import('./fetchers/news.fetcher.js')
const list = await mod.fetchNewsList()
console.log('[News] 拉取 ' + list.length + ' 条, 耗时 ' + ((Date.now() - t0) / 1000).toFixed(1) + 's')
if (list.length === 0) { console.log('[News] 无数据, 跳过'); return }
const r = await mod.persistNews(list)
console.log('[News] 入库: 新增 ' + r.inserted + ' 条, 重复跳过 ' + r.skipped + ' 条' + (r.failed ? ', 失败 ' + r.failed + ' 条' : ''))
}
main().catch((e) => { console.error('[News] 失败:', e); process.exit(1) })
+11 -8
View File
@@ -1,13 +1,14 @@
import cron from 'node-cron';
import { trigger as triggerUSDCNYRate } from '../scraper/usd-cny-rate.scraper.js';
import { trigger as triggerSH300Data } from '../scraper/sh300.scraper.js';
import { insertCronAlert } from '../lib/cron-alert.js';
console.log('='.repeat(70));
console.log('🚀 启动定时任务调度器');
console.log('='.repeat(70));
// 注册定时任务 - USD-CNY 汇率数据
const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
// 注册定时任务 - USD-CNY 汇率数据 (每天 6:20, 出错生成报警)
const jobUSDCNY = cron.schedule('20 6 * * *', async () => {
console.log('\n' + '='.repeat(70));
console.log('⏰ 执行定时任务: USD-CNY 汇率数据更新');
console.log('📅 执行时间:', new Date().toISOString());
@@ -18,6 +19,7 @@ const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
console.log('\n✅ 任务执行成功!');
} catch (error) {
console.error('\n❌ 任务执行失败:', error.message);
await insertCronAlert('USD-CNY 汇率更新', error.message);
} finally {
console.log('='.repeat(70));
}
@@ -26,8 +28,8 @@ const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
timezone: 'Asia/Shanghai'
});
// 注册定时任务 - 沪深300指数数据
const jobSH300 = cron.schedule('0 0 * * *', async () => {
// 注册定时任务 - 沪深300指数数据 (每天 6:30, 出错生成报警)
const jobSH300 = cron.schedule('30 6 * * *', async () => {
console.log('\n' + '='.repeat(70));
console.log('⏰ 执行定时任务: 沪深300指数数据更新');
console.log('📅 执行时间:', new Date().toISOString());
@@ -38,6 +40,7 @@ const jobSH300 = cron.schedule('0 0 * * *', async () => {
console.log('\n✅ 任务执行成功!');
} catch (error) {
console.error('\n❌ 任务执行失败:', error.message);
await insertCronAlert('沪深300指数更新', error.message);
} finally {
console.log('='.repeat(70));
}
@@ -47,12 +50,12 @@ const jobSH300 = cron.schedule('0 0 * * *', async () => {
});
console.log('✅ 定时任务已启动: USD-CNY 汇率数据更新');
console.log('📅 执行表达式: 0 0 * * * (每天 0 点)');
console.log('📅 下次执行时间: 明天 00:00');
console.log('📅 执行表达式: 20 6 * * * (每天 6:20)');
console.log('📅 下次执行时间: 明天 06:20');
console.log('');
console.log('✅ 定时任务已启动: 沪深300指数数据更新');
console.log('📅 执行表达式: 0 0 * * * (每天 0 点)');
console.log('📅 下次执行时间: 明天 00:00');
console.log('📅 执行表达式: 30 6 * * * (每天 6:30)');
console.log('📅 下次执行时间: 明天 06:30');
console.log('');
console.log('按 Ctrl+C 停止...');
console.log('='.repeat(70));
+2 -33
View File
@@ -92,39 +92,8 @@ async function main() {
}
console.log('[Strategy] upsert 策略: ' + n2 + ' 行 (1=新增, 2=更新)');
const indicators = [
{ name: 'china-bond-1y', groupName: 'china-bond',
data: { unit: '%', title: '中国国债1年', rows: [
{ month: '2024-01', value: 1.85 }, { month: '2024-02', value: 1.82 },
{ month: '2024-03', value: 1.78 }, { month: '2024-04', value: 1.75 },
{ month: '2024-05', value: 1.72 }, { month: '2024-06', value: 1.68 },
]}},
{ name: 'china-bond-10y', groupName: 'china-bond',
data: { unit: '%', title: '中国国债10年', rows: [
{ month: '2024-01', value: 2.65 }, { month: '2024-02', value: 2.62 },
{ month: '2024-03', value: 2.58 }, { month: '2024-04', value: 2.55 },
{ month: '2024-05', value: 2.52 }, { month: '2024-06', value: 2.48 },
]}},
{ name: 'us-bond-1y', groupName: 'us-bond',
data: { unit: '%', title: '美国国债1年', rows: [
{ month: '2024-01', value: 4.85 }, { month: '2024-02', value: 4.82 },
{ month: '2024-03', value: 4.78 }, { month: '2024-04', value: 4.65 },
{ month: '2024-05', value: 4.55 }, { month: '2024-06', value: 4.45 },
]}},
{ name: 'china-lpr-1y', groupName: 'china-lpr',
data: { unit: '%', title: '中国LPR 1年期', rows: [
{ month: '2024-01', value: 3.45 }, { month: '2024-02', value: 3.45 },
{ month: '2024-03', value: 3.45 }, { month: '2024-04', value: 3.40 },
{ month: '2024-05', value: 3.40 }, { month: '2024-06', value: 3.40 },
]}},
];
let n3 = 0;
for (const i of indicators) {
n3 += await upsertByName(conn, 'Indicator',
{ name: i.name, groupName: i.groupName, data: JSON.stringify(i.data), updatedAt: new Date() },
'name');
}
console.log('[Indicator] upsert 指标: ' + n3 + ' 行 (1=新增, 2=更新)');
// Indicator 表: 改由 scripts/refresh-indicators.cjs 管理 (2026-08 重构后数据存 IndicatorPoint 明细表)
// 本脚本不再写入 Indicator, 如需初始化指标请运行 npm run data:refresh
const article = {
title: '欢迎使用 iBoard',
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
// 将 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
// 传输层: MCP streamable HTTP (POST /mcp), 认证 Bearer <REGISTRY_MCP_TOKEN>
// 详见 workflow: .gitea/workflows/sync-registry.yml
//
// 用法:
// REGISTRY_MCP_URL=http://bh.vps.honor3.com:8008/mcp \
// REGISTRY_MCP_TOKEN=<MCP 访问令牌> \
// REGISTRY_PROJECT_ID=proj-msid70ql-6xbq4w \
// node scripts/sync-registry.mjs [--dry-run]
//
// 安全: 本脚本不会把任何 secret 的值打印到日志, 只输出 键名 + 结果。
// 注意: 当前通过明文 http 传输, 请确保注册中心仅在内网可达 (或改用 https)。
const MCP_URL = process.env.REGISTRY_MCP_URL || 'http://bh.vps.honor3.com:8008/mcp'
const MCP_TOKEN = process.env.REGISTRY_MCP_TOKEN || ''
const PROJECT_ID = process.env.REGISTRY_PROJECT_ID || 'proj-msid70ql-6xbq4w'
const IDC = process.env.REGISTRY_IDC || '1'
const ENV = process.env.REGISTRY_ENVIRONMENT || '1'
const DRY_RUN = process.argv.includes('--dry-run')
// CI 环境变量 -> 注册中心配置键 (key 支持 / 嵌套, 本项目分组映射为 path => group/key)
const SECRET_MAP = [
{ env: 'MINISIGN_PRIVATE_KEY', key: 'minisign.privateKey', group: 'secret', type: 'string', description: 'minisign 签名 OTA 升级包的私钥' },
{ env: 'DATABASE_URL', key: 'database.url', group: 'secret', type: 'string', description: 'MySQL 连接串' },
{ env: 'JWT_SECRET', key: 'jwt.secret', group: 'secret', type: 'string', description: 'JWT 签名密钥' },
{ env: 'ANDROID_KEYSTORE_B64', key: 'android.keystore.b64', group: 'secret', type: 'string', description: 'Android release keystore (base64)' },
{ env: 'ANDROID_KEY_ALIAS', key: 'android.key.alias', group: 'secret', type: 'string', description: 'Android keystore 别名' },
{ env: 'ANDROID_KEY_PASSWORD', key: 'android.key.password', group: 'secret', type: 'string', description: 'Android keystore 密码' },
{ env: 'PACKAGE_TOKEN', key: 'package.token', group: 'secret', type: 'string', description: 'Gitea Generic Package 写权限 token' },
]
if (!MCP_TOKEN) {
console.error('缺少 REGISTRY_MCP_TOKEN (MCP 访问令牌)')
process.exit(2)
}
let sessionId = null
let nextId = 0
function parseSse(text) {
let data = ''
for (const line of text.split(/\r?\n/)) {
if (line.startsWith('data:')) data += line.slice(5).trim()
}
if (!data) throw new Error(`MCP 响应没有 data 字段: ${text.slice(0, 200)}`)
const obj = JSON.parse(data)
if (obj.error) {
const e = new Error(`${obj.error.code}: ${obj.error.message}`)
e.mcpError = obj.error
throw e
}
return obj
}
async function rpc(method, params) {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
}
if (MCP_TOKEN) headers.Authorization = `Bearer ${MCP_TOKEN}`
if (sessionId) headers['mcp-session-id'] = sessionId
const res = await fetch(MCP_URL, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: ++nextId, method, params }),
})
const sid = res.headers.get('mcp-session-id')
if (sid) sessionId = sid
const text = await res.text()
return parseSse(text)
}
async function callTool(name, args) {
const payload = await rpc('tools/call', { name, arguments: args })
if (payload.result?.isError) {
throw new Error(`工具 ${name} 执行失败: ${JSON.stringify(payload.result.content || {})}`)
}
const sc = payload.result?.structuredContent
if (sc !== undefined && sc !== null) return sc
const content = payload.result?.content || []
const text = content.map((c) => c.text || '').join('\n').trim()
if (text) {
try { return JSON.parse(text) } catch { return { text } }
}
return payload.result || {}
}
async function main() {
console.log(`== 同步 CI secrets -> 注册中心 (project=${PROJECT_ID})${DRY_RUN ? ' [dry-run]' : ''}`)
// 1) initialize 拿 mcp-session-id (rpc 内部已缓存 header)
await rpc('initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'iboard-ci', version: '1.0.0' },
})
// 2) 记录当前模式, 非 edit 则临时切到 edit (写 value 需要), 结束后恢复
const mode = (await callTool('get_mode', {})).mode
console.log(`当前模式: ${mode}`)
const needEdit = mode !== 'edit'
if (needEdit) {
if (DRY_RUN) {
console.log('(dry-run) 待切换到 edit 模式')
} else {
await callTool('set_mode', { mode: 'edit' })
console.log('已临时切换为 edit 模式')
}
}
try {
// 3) 拉取现有配置, 键为 group/idc/environment/key
const list = await callTool('list_configs', { projectId: PROJECT_ID })
const configs = Array.isArray(list.configs) ? list.configs : list
const existing = new Map()
for (const c of configs || []) {
if (!c || !c.key) continue
existing.set(`${c.idc || ''}|${c.environment || ''}|${c.group || ''}|${c.key}`, c)
}
// 4) 逐个 upsert
let updated = 0
let created = 0
let skipped = 0
for (const { env, key, group, type, description } of SECRET_MAP) {
const value = process.env[env]
if (value === undefined || value === '') {
console.log(`- [skip] ${group}/${key} <- 环境变量 ${env} 未设置`)
skipped++
continue
}
const found = existing.get(`${IDC}|${ENV}|${group}|${key}`)
const target = `${group}/${key}`
if (found) {
if (DRY_RUN) {
console.log(`- [dry-run] update ${target} (cfg ${found.id}) <- ${env}`)
} else {
await callTool('update_config', { projectId: PROJECT_ID, configId: found.id, value })
console.log(`- [update] ${target} (cfg ${found.id}) <- ${env}`)
}
updated++
} else {
if (DRY_RUN) {
console.log(`- [dry-run] create ${target} <- ${env}`)
} else {
await callTool('create_config', {
projectId: PROJECT_ID,
key,
value,
type,
description,
idc: IDC,
environment: ENV,
group,
})
console.log(`- [create] ${target} <- ${env}`)
}
created++
}
}
console.log(`\n== 完成: ${DRY_RUN ? 'dry-run' : '写入'} ${updated} 更新 / ${created} 新建 / ${skipped} 跳过 (值不回显)`)
} finally {
// 5) 恢复原模式 (本次未实际切模式时无操作)
if (needEdit && !DRY_RUN) {
await callTool('set_mode', { mode })
console.log(`已恢复模式: ${mode}`)
}
}
}
main().catch((err) => {
console.error(`\n同步失败: ${err.message}`)
process.exit(1)
})
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# 临时验证脚本: 只编 armv7 一个 target, 验证 cc-rs 的 NDK 命名问题已修复 (用完即删)
set -euo pipefail
export ANDROID_HOME="$HOME/android-sdk"
export ANDROID_NDK_HOME="$HOME/android-sdk/ndk/27.2.12479018"
export PATH="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$HOME/.cargo/bin:$PATH"
cd /mnt/d/workbench/iboard/src-tauri
cargo build --target armv7-linux-androideabi --features custom-protocol --lib --release 2>&1 | tail -30
+89 -31
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# iBoard Android APK 打包
# 用法: bash scripts/wsl/build-apk.sh [debug|release]
# release 模式自动构建 4 个 ABI 并出 universal APK
set -euo pipefail
WIN_PROJECT='/mnt/d/workbench/iboard'
@@ -12,7 +13,15 @@ echo "构建变体: $VARIANT"
# 环境变量
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/27.2.12479018}"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
# 关键: 把 NDK toolchain bin 加进 PATH, .cargo/config.toml 里
# linker = "aarch64-linux-android24-clang" 这种短名才能被解析到
NDK_TOOLCHAIN_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
if [ ! -d "$NDK_TOOLCHAIN_BIN" ]; then
echo "ERROR: NDK toolchain bin 不存在: $NDK_TOOLCHAIN_BIN"
echo "请确认 ANDROID_NDK_HOME 正确指向 NDK 27.2.x 目录"
exit 1
fi
export PATH="$NDK_TOOLCHAIN_BIN:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
# 0. 首次需要 android 工程
cd src-tauri
@@ -22,58 +31,107 @@ if [ ! -d "gen/android" ]; then
fi
cd "$WIN_PROJECT"
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
export VITE_API_BASE="${VITE_API_BASE:-http://bh.vps.honor3.com:3001}"
echo "VITE_API_BASE=$VITE_API_BASE"
# 1. 先构建前端, 让 Tauri android 工程能拿到 dist/
echo "=== 1. 构建前端 ==="
npm run build
# 1.5 拷贝 dist/ 到 Android 工程 assets/, 绕过 Tauri CLI 时这步要做
# Tauri CLI 默认在 android build 前会干这事, 我们走直 gradle 要自己补上
ANDROID_ASSETS="$WIN_PROJECT/src-tauri/gen/android/app/src/main/assets"
mkdir -p "$ANDROID_ASSETS"
# 清空旧的 (避免上次的残余)
find "$ANDROID_ASSETS" -mindepth 1 -maxdepth 1 ! -name dexopt ! -name tauri.conf.json -exec rm -rf {} +
cp -r dist/. "$ANDROID_ASSETS/"
echo "前端 bundle 已拷贝到 $ANDROID_ASSETS"
ls -la "$ANDROID_ASSETS" | head -10
# 2. 走不同路径
cd src-tauri
# 全部 4 个 Android 架构的 rustBuild 任务 (Tauri 2 默认会编 4 个)
RUST_BUILD_TASKS=(
":app:rustBuildArm64Release"
":app:rustBuildArmv7Release"
":app:rustBuildArmRelease"
":app:rustBuildX86_64Release"
":app:rustBuildI686Release"
)
RUST_BUILD_TASKS_DBG=(
":app:rustBuildArm64Debug"
":app:rustBuildArmv7Debug"
":app:rustBuildX86_64Debug"
":app:rustBuildI686Debug"
":app:rustBuildX86Release"
)
SKIP_ARGS=()
for t in "${RUST_BUILD_TASKS[@]}"; do SKIP_ARGS+=(-x "${t#:}"); done
if [ "$VARIANT" = "release" ]; then
echo "[release] 手工 cargo 编 lib, gradle 只跑 packaging (跳过 rustBuild* 避免 --release 重复 bug)"
# 4 个 Android 目标的 cargo target triple
ANDROID_TARGETS=(
"aarch64-linux-android"
"armv7-linux-androideabi"
"x86_64-linux-android"
"i686-linux-android"
)
echo " [cargo] build lib release ..."
cargo build \
--target aarch64-linux-android \
--features custom-protocol \
--lib \
--release
if [ "$VARIANT" = "release" ]; then
echo "[release] 手工 cargo 编 lib (4 个 ABI), gradle 只跑 packaging (跳过 rustBuild* 避免 --release 重复 bug)"
for target in "${ANDROID_TARGETS[@]}"; do
echo " [cargo] build lib release for $target ..."
cargo build \
--target "$target" \
--features custom-protocol \
--lib \
--release
done
echo " [gradle] assembleRelease -x rustBuild* ..."
cd gen/android
./gradlew --no-daemon assembleRelease "${SKIP_ARGS[@]}"
APK_DIR="$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/release"
echo
echo "=== 产物 (未签名) ==="
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
APK_DIR="$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/universal/release"
# Gradle 配了 signingConfig 就输出 app-universal-release.apk,
# 否则输出 app-universal-release-unsigned.apk, 按文件名判断是否已签名
SIGNED_APK="$APK_DIR/app-universal-release.apk"
UNSIGNED_APK="$APK_DIR/app-universal-release-unsigned.apk"
if [ -f "$SIGNED_APK" ]; then
APK_PATH="$SIGNED_APK"
APK_STATUS="已签名"
elif [ -f "$UNSIGNED_APK" ]; then
APK_PATH="$UNSIGNED_APK"
APK_STATUS="未签名"
else
APK_PATH=""
APK_STATUS="未找到"
fi
echo
echo "=== Release 签名 (按需) ==="
echo "1. 生成 keystore (一次性):"
echo " keytool -genkey -v -keystore ~/iboard-release.keystore -alias iboard -keyalg RSA -keysize 2048 -validity 10000"
echo "2. 在 ~/.gradle/gradle.properties 写:"
echo " IBOARD_UPLOAD_STORE_FILE=/home/$(whoami)/iboard-release.keystore"
echo " IBOARD_UPLOAD_KEY_ALIAS=iboard"
echo " IBOARD_UPLOAD_STORE_PASSWORD=..."
echo " IBOARD_UPLOAD_KEY_PASSWORD=..."
echo "3. 重跑本脚本"
echo "=== 产物 ($APK_STATUS) ==="
if [ -n "$APK_PATH" ]; then
ls -lh "$APK_PATH"
else
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
fi
if [ "$APK_STATUS" = "已签名" ]; then
echo
echo "=== 安装到设备 ==="
echo " adb install -r $APK_PATH"
elif [ "$APK_STATUS" = "未签名" ]; then
echo
echo "=== Release 签名 (按需) ==="
echo "1. 生成 keystore (一次性):"
echo " keytool -genkey -v -keystore ~/iboard-release.keystore -alias iboard -keyalg RSA -keysize 2048 -validity 10000"
echo "2. 在 ~/.gradle/gradle.properties 写:"
echo " IBOARD_UPLOAD_STORE_FILE=/home/$(whoami)/iboard-release.keystore"
echo " IBOARD_UPLOAD_KEY_ALIAS=iboard"
echo " IBOARD_UPLOAD_STORE_PASSWORD=..."
echo " IBOARD_UPLOAD_KEY_PASSWORD=..."
echo "3. 重跑本脚本 (Gradle 读 gradle.properties 里的 IBOARD_UPLOAD_* 自动用 releaseSigning)"
echo
echo "=== 临时用 debug keystore 手动签 (仅测试) ==="
echo " \$ANDROID_HOME/build-tools/*/apksigner sign --ks ~/.android/debug.keystore \"
echo " --ks-pass pass:android --key-pass pass:android \"
echo " $APK_PATH"
fi
else
echo "[debug] Tauri CLI debug 路径 (不传 --release 不会触发 bug), 直接走"
npx tauri android build --apk
@@ -82,4 +140,4 @@ else
echo
echo "=== 产物 ==="
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
fi
fi
+28 -3
View File
@@ -6,7 +6,32 @@ VARIANT="${1:-debug}"
WIN_PROJECT='/mnt/d/workbench/iboard'
export PATH="$HOME/android-sdk/platform-tools:$PATH"
APK_DIR="$WIN_PROJECT/src-tauri/target/$( [ "$VARIANT" = "release" ] && echo release || echo debug )/bundle/android/app/build/outputs/apk/$VARIANT"
APK=$(ls -1 "$APK_DIR"/*.apk | head -1)
echo "安装: $APK"
# 候选 APK 路径 (按优先级, 找到第一个就用)
CANDIDATES=(
# release 通用包 (脚本 build-apk.sh release 产物)
"$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
"$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk"
# 各 ABI 单独包
"$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/arm64-v8a/release/app-arm64-v8a-release.apk"
"$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/arm64-v8a/release/app-arm64-v8a-release-unsigned.apk"
# debug 通用包
"$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
# Tauri CLI debug 默认路径
"$WIN_PROJECT/src-tauri/target/debug/bundle/android/app/build/outputs/apk/debug/app-debug.apk"
"$WIN_PROJECT/src-tauri/target/release/bundle/android/app/build/outputs/apk/release/app-release.apk"
)
APK=""
for p in "${CANDIDATES[@]}"; do
if [ -f "$p" ]; then APK="$p"; break; fi
done
if [ -z "$APK" ]; then
echo "ERROR: 找不到 $VARIANT APK, 请先 bash scripts/wsl/build-apk.sh $VARIANT"
echo "扫描过路径:"
for p in "${CANDIDATES[@]}"; do echo " $p"; done
exit 1
fi
echo "安装 ($VARIANT): $APK"
adb install -r "$APK"
+26 -16
View File
@@ -13,9 +13,7 @@ sudo apt update
sudo apt install -y \
build-essential curl wget file libssl-dev pkg-config \
unzip zip git \
openjdk-17-jdk-headless \
clang libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \
libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
openjdk-17-jdk-headless
echo "=== 2. Rust ==="
if ! command -v cargo >/dev/null 2>&1; then
@@ -24,30 +22,33 @@ if ! command -v cargo >/dev/null 2>&1; then
fi
rustc --version
cargo --version
# Tauri 2 + Android 需要 rustc 1.77+, 当前默认 toolchain 即可
echo "=== 3. Android SDK + NDK ==="
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$(ls -1 $ANDROID_HOME/ndk 2>/dev/null | sort -V | tail -1 || echo 26.1.10909125)"
mkdir -p "$HOME/android-sdk/cmdline-tools"
cd /tmp
if [ ! -d "$HOME/android-sdk/cmdline-tools/latest" ]; then
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
if [ ! -d "$ANDROID_HOME/cmdline-tools/latest" ]; then
cd /tmp
echo "下载 Android cmdline-tools..."
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdline-tools.zip
unzip -q cmdline-tools.zip
mv cmdline-tools "$HOME/android-sdk/cmdline-tools/latest"
mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
fi
export PATH="$HOME/android-sdk/cmdline-tools/latest/bin:$PATH"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
yes | sdkmanager --licenses >/dev/null
sdkmanager --install "platforms;android-34" "build-tools;34.0.0" "platform-tools" \
"ndk;26.1.10909125" "cmake;3.22.1"
yes | sdkmanager --licenses >/dev/null || true
# 与 src-tauri/gen/android 中 Tauri 生成的配置对齐: compileSdk=36, NDK 27.2
sdkmanager --install "platforms;android-36" "build-tools;36.0.0" "platform-tools" \
"ndk;27.2.12479018" "cmake;3.22.1"
# 默认 NDK 版本 (build-apk.sh 也用这个)
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018"
echo "ANDROID_HOME=$ANDROID_HOME"
echo "ANDROID_NDK_HOME=$ANDROID_NDK_HOME"
echo "=== 4. Node 依赖 (与 Windows 共用 node_modules 也行, 这里重新装) ==="
cd "$WIN_PROJECT"
# 跳过 Windows 二进制不兼容的 sharp 等, npm 会自动 rebuild
npm install
echo "=== 5. Tauri Android 工程初始化 (首次执行) ==="
@@ -60,9 +61,18 @@ echo
echo "=== 搭建完成 ==="
echo "接下来:"
echo " bash scripts/wsl/build-apk.sh # 打 debug APK"
echo " bash scripts/wsl/build-apk.sh release # 打 release APK (需要签名)"
echo " bash scripts/wsl/build-apk.sh release # 打 release APK (默认未签名, 需配置 keystore 才能签名)"
echo
echo "Release 签名 (可选, 仅 release 用):"
echo " 1. keytool -genkey -v -keystore ~/iboard-release.keystore -alias iboard -keyalg RSA -keysize 2048 -validity 10000"
echo " 2. 在 ~/.gradle/gradle.properties 写:"
echo " IBOARD_UPLOAD_STORE_FILE=/home/\$USER/iboard-release.keystore"
echo " IBOARD_UPLOAD_KEY_ALIAS=iboard"
echo " IBOARD_UPLOAD_STORE_PASSWORD=..."
echo " IBOARD_UPLOAD_KEY_PASSWORD=..."
echo " 3. 重跑 build-apk.sh release 即可"
echo
echo "如要永久生效, 把下面加进 ~/.bashrc:"
echo " export ANDROID_HOME=$ANDROID_HOME"
echo " export ANDROID_NDK_HOME=$ANDROID_NDK_HOME"
echo " export PATH=\$PATH:$HOME/android-sdk/cmdline-tools/latest/bin:$HOME/android-sdk/platform-tools"
echo " export PATH=\$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:\$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
+46
View File
@@ -0,0 +1,46 @@
import { prisma } from './db.js'
// 跨源对比: 同 (indicatorName, period) 多源数据差异超阈值则插 Alert
// conn: mysql2 连接
// 阈值: 绝对差 >= 0.01 且 相对差 >= 1%
// 返回 { inserted, scanned }
const ABS_TOL = 0.01
const REL_TOL = 0.01
export async function checkAfterRefresh(conn) {
// conn 是 mysql2 连接, 走原始 SQL
const [rows] = await conn.execute(`
SELECT ia.name AS indicatorName, a.period, ia.source, a.value
FROM IndicatorPoint a
JOIN Indicator ia ON a.indicatorId = ia.id
ORDER BY ia.name, a.period
`)
const groups = new Map()
for (const r of rows) {
const k = r.indicatorName + '|' + r.period
if (!groups.has(k)) groups.set(k, [])
groups.get(k).push({ source: r.source, value: Number(r.value) })
}
let inserted = 0
for (const [k, arr] of groups) {
if (arr.length < 2) continue
const sources = new Set(arr.map((x) => x.source))
if (sources.size < 2) continue
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
const A = arr[i], B = arr[j]
const diff = Math.abs(A.value - B.value)
const base = Math.max(Math.abs(A.value), Math.abs(B.value), 1e-9)
const ratio = diff / base
if (diff < ABS_TOL || ratio < REL_TOL) continue
const [name, period] = k.split('|')
const [r] = await conn.execute(
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(3))',
[name, period, A.value, B.value, A.source, B.source, diff, ratio],
)
if (r.affectedRows === 1) inserted++
}
}
}
return { inserted, scanned: rows.length }
}
+46
View File
@@ -0,0 +1,46 @@
// apizero 接口的文件缓存
// - 按 key 存 JSON 到磁盘: <APIZERO_CACHE_DIR>/<key>.json
// - 存在性检查: 文件在即视为有效 (用户要求: "同一个年份只要存在就不再调用接口")
// - 不做 TTL: 用户没要求, 也避免误命中过期数据
//
// 环境变量:
// APIZERO_CACHE_DIR 缓存目录 (默认 <cwd>/cache/apizero)
//
// 未来若要加 TTL / DB 后端, 替换本文件即可, 上层调用方不变.
import fs from 'node:fs'
import path from 'node:path'
function dir() {
return process.env.APIZERO_CACHE_DIR
? path.resolve(process.env.APIZERO_CACHE_DIR)
: path.resolve(process.cwd(), 'cache', 'apizero')
}
// 读缓存, 不存在返回 null
export function read(key) {
const p = path.join(dir(), key + '.json')
if (!fs.existsSync(p)) return null
try {
const raw = fs.readFileSync(p, 'utf8')
return JSON.parse(raw)
} catch (e) {
console.warn(`[apizero-cache] 读 ${key} 失败, 忽略:`, e.message)
return null
}
}
// 写缓存 (原子: 先写 .tmp 再 rename)
export function write(key, data) {
const d = dir()
fs.mkdirSync(d, { recursive: true })
const final = path.join(d, key + '.json')
const tmp = final + '.tmp.' + process.pid
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8')
fs.renameSync(tmp, final)
return final
}
// 存在性检查
export function has(key) {
return fs.existsSync(path.join(dir(), key + '.json'))
}
+77
View File
@@ -0,0 +1,77 @@
// apizero (https://apizero.cn) 通用 HTTP 客户端
// - 统一从 env APIZERO_KEY 读取 API key (test key 也能用)
// - 统一 QPS 节流 (apizero 限制 3 req/s)
// - 统一错误格式: 抛出 ApizeroError(code, message, httpStatus)
// - 所有 apizero 接口都进这一层, 后续新增模块复用
//
// 依赖: 原生 fetch (Node 18+)
// 简易令牌桶, 限速 3 req/s
let lastCallAt = 0
const MIN_INTERVAL_MS = 1000 / 3 // ~333ms
async function throttle() {
const now = Date.now()
const wait = lastCallAt + MIN_INTERVAL_MS - now
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
lastCallAt = Date.now()
}
// apizero 标准错误
export class ApizeroError extends Error {
constructor(code, message, httpStatus) {
super(message || `apizero error code=${code}`)
this.name = 'ApizeroError'
this.code = code
this.httpStatus = httpStatus
}
}
const BASE_URL = 'https://v1.apizero.cn/api'
// 读 key: 优先 env APIZERO_KEY, 否则用空 (匿名调用, 配额更小)
function getKey() {
return process.env.APIZERO_KEY || ''
}
// 通用 GET 入口
// - endpoint: 'oil-price-forecast' (不带 /api 前缀)
// - params: 普通对象
// 返回: apizero 的 data 字段 (已解一层)
export async function get(endpoint, params = {}) {
await throttle()
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
}
const key = getKey()
if (key) qs.set('key', key)
const url = `${BASE_URL}/${endpoint}?${qs.toString()}`
let res
try {
res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } })
} catch (e) {
throw new ApizeroError('NETWORK', `apizero 网络错误: ${e.message}`, 0)
}
// HTTP 层错误
if (!res.ok) {
let body = ''
try { body = await res.text() } catch { /* ignore */ }
throw new ApizeroError('HTTP', `apizero HTTP ${res.status}: ${body.substring(0, 200)}`, res.status)
}
// 解析 body
let json
try { json = await res.json() }
catch (e) { throw new ApizeroError('PARSE', 'apizero 响应非 JSON: ' + e.message, res.status) }
// apizero 业务层错误: code !== 0
if (json && typeof json.code === 'number' && json.code !== 0) {
throw new ApizeroError(json.code, json.msg || 'apizero 业务错误', res.status)
}
return json && json.data !== undefined ? json.data : json
}
+6
View File
@@ -0,0 +1,6 @@
// apizero 模块统一出口
// 后续新增 apizero 接口就在这里再 export 一行
export * as oil from './oil-price-forecast.js'
export * as history from './oil-history.js'
export { ApizeroError } from './client.js'
+87
View File
@@ -0,0 +1,87 @@
// 浙江省油价历史: 走 Prisma 落库 + apizero price 接口写入
// 设计:
// - 每天 04:00 cron 检查 schedule, 待定且 <= 昨天的调价日 -> 抓 price?province=浙江 存本表
// - unique (date, province, fuel) 防重复
// - 查询 API: 取某省某油品最近 N 天 (默认 180)
import { prisma } from '../db.js'
import * as client from './client.js'
const PROVINCE = '浙江'
// 油品 -> apizero prices 对象里的 key (price action 返回 "92号汽油" 这种带后缀的 key)
const FUEL_KEYS = {
'92': '92号汽油',
'95': '95号汽油',
'0': '0号柴油',
}
// 解析 apizero price action 返回的 prices (中文 key) -> 标准 fuel code
function parsePrice(data) {
if (!data || !data.prices) throw new Error('apizero 响应无 prices 字段')
const out = {}
for (const [fuel, label] of Object.entries(FUEL_KEYS)) {
const raw = data.prices[label]
if (typeof raw === 'string') {
// "7.64 元/升" -> 7.64
const m = raw.match(/^([\d.]+)/)
if (m) out[fuel] = parseFloat(m[1])
} else if (typeof raw === 'number') {
out[fuel] = raw
}
}
return out
}
// 抓一次浙江当前价并入库 (date = 今日)
// 返回 { inserted, skipped } (按 unique 约束: 已存在则 skip)
export async function fetchAndStore() {
const data = await client.get('oil-price-forecast', { action: 'price', province: PROVINCE })
const prices = parsePrice(data)
if (Object.keys(prices).length === 0) {
throw new Error('apizero 返回价格为空')
}
const date = new Date()
// 抹掉时分秒, 便于按天比较
date.setHours(0, 0, 0, 0)
let inserted = 0
let skipped = 0
for (const [fuel, price] of Object.entries(prices)) {
try {
await prisma.oilPriceHistory.create({
data: { date, province: PROVINCE, fuel, price, source: 'apizero' },
})
inserted++
} catch (e) {
// P2002: unique 冲突 -> 跳过
if (e?.code === 'P2002') skipped++
else throw e
}
}
return { inserted, skipped, date: date.toISOString().slice(0, 10) }
}
// 查询某省某油品最近 N 天
// province 必填, 默认 浙江
// fuel 可选, 留空返回 4 个油品
// days 默认 180
export async function query({ province = PROVINCE, fuel, days = 180 }) {
const since = new Date()
since.setDate(since.getDate() - days)
since.setHours(0, 0, 0, 0)
const where = { province, date: { gte: since } }
if (fuel) where.fuel = fuel
const rows = await prisma.oilPriceHistory.findMany({
where,
orderBy: [{ date: 'asc' }],
})
// 整形: Decimal -> number
return rows.map((r) => ({
date: r.date.toISOString().slice(0, 10),
province: r.province,
fuel: r.fuel,
price: Number(r.price),
}))
}
+41
View File
@@ -0,0 +1,41 @@
// apizero: 今日油价 (oil-price-forecast)
// 4 个 action:
// forecast - 油价 + 国际原油 + 下次调价预测 (每次都查实时, 不缓存)
// price - 单省油价
// price-all - 32 省全部油价
// schedule - 某年调价日历 (按 year 缓存到文件, 同年份不重复调)
//
// 文档: https://apizero.cn/aidocs/oil-price-forecast/raw.md
import * as client from './client.js'
import * as cache from './cache.js'
// 调价日历: 按年份缓存, 文件存在即视为有效, 不重复调用
// cache key 形如 "oil-schedule-2026"
export async function getSchedule(year) {
const y = Number(year)
if (!Number.isInteger(y) || y < 2025 || y > 2026) {
throw new client.ApizeroError(4000, `year 不在 2025-2026 范围: ${year}`, 400)
}
const cacheKey = `oil-schedule-${y}`
const hit = cache.read(cacheKey)
if (hit) return { ...hit, _cached: true }
const data = await client.get('oil-price-forecast', { action: 'schedule', year: y })
cache.write(cacheKey, data)
return { ...data, _cached: false }
}
// 实时: 国际原油 + 下次调价预测
export async function getForecast() {
return client.get('oil-price-forecast', { action: 'forecast' })
}
// 单省油价
export async function getPrice(province) {
if (!province) throw new client.ApizeroError(4000, 'province 必填', 400)
return client.get('oil-price-forecast', { action: 'price', province })
}
// 32 省全部油价
export async function getPriceAll() {
return client.get('oil-price-forecast', { action: 'price-all' })
}
+94
View File
@@ -0,0 +1,94 @@
import cron from 'node-cron'
import { prisma } from './db.js'
import mysql from 'mysql2/promise'
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)
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
}
async function openConn() {
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
}
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
function scheduleNews() {
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)
const conn = await openConn()
try {
const a = await checkAfterRefresh(conn)
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
} finally { await conn.end() }
} catch (e) {
console.error('[cron] 任务失败:', e.message)
await alertError('新闻拉取+报警检测', e.message)
}
}, { 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()
+58
View File
@@ -0,0 +1,58 @@
// ===== 已停用 =====
// 服务自检的定时任务已从 server/cron.js 移除 (服务监控暂留空)。
// 如恢复服务监控, 重新在 cron.js 注册 runOnce 并复用本文件。
// 服务健康检查: 探测本应用各关键端点, 写入 ServiceCheck 表
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(/^\//, '') } }
const TARGETS = [
{ name: 'api-health', url: 'http://127.0.0.1:' + (process.env.PORT || 3001) + '/api/health' },
{ name: 'auth-login', url: 'http://127.0.0.1:' + (process.env.PORT || 3001) + '/api/auth/login' },
]
async function probeOne(target) {
const t0 = Date.now()
try {
const ctrl = new AbortController()
setTimeout(() => ctrl.abort(), 8000)
const r = await fetch(target.url, { method: 'GET', signal: ctrl.signal })
const latency = Date.now() - t0
const status = (r.ok || r.status === 400 || r.status === 401) ? 'up' : 'down'
const msg = 'HTTP ' + r.status
return { name: target.name, url: target.url, status, latencyMs: latency, message: msg }
} catch (e) {
return { name: target.name, url: target.url, status: 'down', latencyMs: Date.now() - t0, message: e.message || String(e) }
}
}
async function checkDb() {
const t0 = Date.now()
try {
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
await conn.execute('SELECT 1')
await conn.end()
return { name: 'database', url: 'mysql://' + (new URL(process.env.DATABASE_URL)).host, status: 'up', latencyMs: Date.now() - t0, message: 'ok' }
} catch (e) {
return { name: 'database', url: 'mysql', status: 'down', latencyMs: Date.now() - t0, message: e.message }
}
}
async function runOnce() {
const results = await Promise.all([...TARGETS.map(probeOne), checkDb()])
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
try {
for (const r of results) {
await conn.execute(
'INSERT INTO `ServiceCheck` (`name`,`url`,`status`,`latencyMs`,`message`,`lastCheckAt`,`createdAt`,`updatedAt`) VALUES (?,?,?,?,?,NOW(3),NOW(3),NOW(3)) ' +
'ON DUPLICATE KEY UPDATE `url`=VALUES(`url`),`status`=VALUES(`status`),`latencyMs`=VALUES(`latencyMs`),`message`=VALUES(`message`),`lastCheckAt`=NOW(3),`updatedAt`=NOW(3)',
[r.name, r.url, r.status, r.latencyMs, r.message.slice(0, 180)],
)
}
} finally {
await conn.end()
}
return results
}
module.exports = { runOnce, TARGETS }
+22 -1
View File
@@ -5,7 +5,13 @@ import express from 'express'
import cors from 'cors'
import authRouter from './routes/auth.js'
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')
@@ -20,8 +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) {
@@ -41,7 +55,14 @@ app.use((err, _req, res, _next) => {
res.status(500).json({ error: err.message || '服务器内部错误' })
})
// 启动定时任务: 每天凌晨 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)'}`)
})
})
+94
View File
@@ -0,0 +1,94 @@
// komari 实时监控客户端: 公开 API, 无鉴权, 请求时实时获取, 不落库
// GET /api/nodes 节点列表
// GET /api/recent/{uuid} 最近实时状态 (数组, 多个时间点)
// GET /api/records/load?uuid=... 1 分钟粒度历史 (komari 保留 24h)
const BASE = process.env.KOMARI_BASE || 'https://komari.honor3.com'
const TIMEOUT = 15000
async function jget(path) {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), TIMEOUT)
try {
const r = await fetch(BASE + path, { headers: { 'User-Agent': 'iboard-monitor/1.0' }, signal: ctrl.signal })
if (!r.ok) throw new Error('komari ' + path + ' HTTP ' + r.status)
const j = await r.json()
if (j.status && j.status !== 'success') throw new Error('komari ' + path + ' status: ' + j.status + ' ' + (j.message || ''))
return j.data
} finally {
clearTimeout(timer)
}
}
// 节点列表 + 每节点最新实时快照 (并行拉取, 单节点失败不拖垮整体)
export async function fetchServers() {
const nodes = await jget('/api/nodes')
if (!Array.isArray(nodes)) return []
const snaps = await Promise.all(
nodes
.filter((n) => n.uuid)
.map(async (n) => {
try {
const recent = await jget('/api/recent/' + n.uuid)
return { node: n, snap: Array.isArray(recent) && recent.length ? recent[recent.length - 1] : null }
} catch (e) {
console.error('[komari] 节点 ' + n.uuid + ' 快照失败:', e.message)
return { node: n, snap: null }
}
}),
)
return snaps.map(({ node: n, snap }) => {
const uuid = n.uuid
return {
uuid,
name: n.name || uuid,
cpuName: n.cpu_name || '',
os: n.os || '',
region: n.region || '',
memTotal: snap ? Number(snap.ram?.total || 0) : Number(n.mem_total || 0),
diskTotal: snap ? Number(snap.disk?.total || 0) : Number(n.disk_total || 0),
cpuCores: Number(n.cpu_cores || 0),
cpuUsage: snap ? Number(snap.cpu?.usage || 0) : 0,
ramUsed: snap ? Number(snap.ram?.used || 0) : 0,
swapUsed: snap ? Number(snap.swap?.used || 0) : 0,
load1: snap ? Number(snap.load?.load1 || 0) : 0,
load5: snap ? Number(snap.load?.load5 || 0) : 0,
load15: snap ? Number(snap.load?.load15 || 0) : 0,
diskUsed: snap ? Number(snap.disk?.used || 0) : 0,
netIn: snap ? Number(snap.network?.down || 0) : 0,
netOut: snap ? Number(snap.network?.up || 0) : 0,
uptime: snap ? Number(snap.uptime || 0) : 0,
process: snap ? Number(snap.process || 0) : 0,
connections: snap ? Number(snap.connections?.tcp || 0) : 0,
status: snap ? 'online' : 'unknown',
recordedAt: snap && snap.updated_at ? new Date(snap.updated_at).toISOString() : new Date().toISOString(),
}
})
}
// 节点时序: komari 保留最近 24h, 1 分钟粒度
// 节点未开启 record 或已下线 (komari 404) 时返回空列表, 不向上抛 500
export async function fetchHistory(uuid) {
let data
try {
data = await jget('/api/records/load?uuid=' + encodeURIComponent(uuid))
} catch (e) {
console.error('[komari] ' + uuid + ' history 获取失败:', e.message)
return []
}
const records = data && Array.isArray(data.records) ? data.records : []
return records
.map((r) => ({
period: new Date(r.time).toISOString(),
cpu: Number(r.cpu || 0),
ram: Number(r.ram || 0),
load: Number(r.load || 0),
disk: Number(r.disk || 0),
// net_in/net_out 为 komari 原始值 (字节口径), 前端展示时再按需换算
netIn: Number(r.net_in || 0),
netOut: Number(r.net_out || 0),
process: Number(r.process || 0),
connections: Number(r.connections || 0),
}))
.filter((r) => !isNaN(Date.parse(r.period)))
}
+123
View File
@@ -0,0 +1,123 @@
// ===== 已停用 =====
// 监控数据不再落库 (用户要求实时从 komari 获取), 本脚本的定时入库调用已从 server/cron.js 移除。
// 如需恢复"数据库记录监控数据"模式, 重新在 cron.js 注册 runOnce/cleanupOld 即可。
// komari 服务器监控拉取: 公开 API (无需 key)
// GET /api/nodes 节点列表
// GET /api/recent/{uuid} 最近实时状态 (数组, 多个时间点)
// GET /api/records/load?uuid=... 1 分钟粒度历史 (保留 24h)
const mysql = require('mysql2/promise')
const BASE = process.env.KOMARI_BASE || 'https://komari.honor3.com'
const TIMEOUT = 20000
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 jget(path) {
const ctrl = new AbortController()
setTimeout(() => ctrl.abort(), TIMEOUT)
const r = await fetch(BASE + path, { headers: { 'User-Agent': 'iboard-monitor/1.0' }, signal: ctrl.signal })
if (!r.ok) throw new Error('komari ' + path + ' HTTP ' + r.status)
const j = await r.json()
if (j.status && j.status !== 'success') throw new Error('komari ' + path + ' status: ' + j.status + ' ' + (j.message || ''))
return j.data
}
// 拉一次所有节点 + 最近状态 + 1 分钟历史, 入库
async function runOnce() {
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
const stats = { nodes: 0, snapshots: 0, history: 0, failed: 0 }
try {
const nodes = await jget('/api/nodes')
if (!Array.isArray(nodes)) throw new Error('nodes 格式异常')
stats.nodes = nodes.length
for (const n of nodes) {
const uuid = n.uuid
if (!uuid) continue
try {
const recent = await jget('/api/recent/' + uuid)
let snap = null
if (Array.isArray(recent) && recent.length) snap = recent[recent.length - 1]
const recordedAt = snap ? new Date(snap.updated_at) : new Date()
const ramTotal = snap && snap.ram ? Number(snap.ram.total) : Number(n.mem_total || 0)
const diskTotal = snap && snap.disk ? Number(snap.disk.total) : Number(n.disk_total || 0)
await conn.execute(
'INSERT INTO `ServerNode` (`uuid`,`name`,`cpuName`,`os`,`region`,`memTotal`,`diskTotal`,`cpuCores`,' +
'`cpuUsage`,`ramUsed`,`swapUsed`,`load1`,`load5`,`load15`,`diskUsed`,`netIn`,`netOut`,`uptime`,`process`,`connections`,`status`,`recordedAt`,`createdAt`,`updatedAt`) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(3),NOW(3)) ' +
'ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`cpuName`=VALUES(`cpuName`),`os`=VALUES(`os`),`region`=VALUES(`region`),' +
'`memTotal`=VALUES(`memTotal`),`diskTotal`=VALUES(`diskTotal`),`cpuCores`=VALUES(`cpuCores`),' +
'`cpuUsage`=VALUES(`cpuUsage`),`ramUsed`=VALUES(`ramUsed`),`swapUsed`=VALUES(`swapUsed`),' +
'`load1`=VALUES(`load1`),`load5`=VALUES(`load5`),`load15`=VALUES(`load15`),`diskUsed`=VALUES(`diskUsed`),' +
'`netIn`=VALUES(`netIn`),`netOut`=VALUES(`netOut`),`uptime`=VALUES(`uptime`),' +
'`process`=VALUES(`process`),`connections`=VALUES(`connections`),`status`=VALUES(`status`),' +
'`recordedAt`=VALUES(`recordedAt`),`updatedAt`=NOW(3)',
[
uuid, n.name || '', n.cpu_name || '', n.os || '', n.region || '',
ramTotal, diskTotal, Number(n.cpu_cores || 0),
snap ? Number(snap.cpu?.usage || 0) : 0,
snap ? Number(snap.ram?.used || 0) : 0,
snap ? Number(snap.swap?.used || 0) : 0,
snap ? Number(snap.load?.load1 || 0) : 0,
snap ? Number(snap.load?.load5 || 0) : 0,
snap ? Number(snap.load?.load15 || 0) : 0,
snap ? Number(snap.disk?.used || 0) : 0,
snap ? Number(snap.network?.down || 0) : 0,
snap ? Number(snap.network?.up || 0) : 0,
snap ? Number(snap.uptime || 0) : 0,
snap ? Number(snap.process || 0) : 0,
snap ? Number(snap.connections?.tcp || 0) : 0,
snap ? 'online' : 'unknown',
recordedAt,
],
)
stats.snapshots++
let rec = null
try { rec = await jget('/api/records/load?uuid=' + uuid) } catch (e) { /* 节点未开启 record */ }
if (rec && Array.isArray(rec.records)) {
for (const r of rec.records) {
const period = new Date(r.time)
if (isNaN(period.getTime())) continue
const [res] = await conn.execute(
'INSERT INTO `ServerNodeHistory` (`uuid`,`period`,`cpu`,`ram`,`load`,`disk`,`netIn`,`netOut`,`process`,`connections`,`createdAt`) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,NOW(3)) ' +
'ON DUPLICATE KEY UPDATE `cpu`=VALUES(`cpu`),`ram`=VALUES(`ram`),`load`=VALUES(`load`),`disk`=VALUES(`disk`),' +
'`netIn`=VALUES(`netIn`),`netOut`=VALUES(`netOut`),`process`=VALUES(`process`),`connections`=VALUES(`connections`)',
[
uuid, period,
Number(r.cpu || 0), Number(r.ram || 0), Number(r.load || 0),
Number(r.disk || 0), Number(r.net_in || 0), Number(r.net_out || 0),
Number(r.process || 0), Number(r.connections || 0),
],
)
if (res.affectedRows === 1) stats.history++
}
}
} catch (e) {
stats.failed++
console.error('[komari] 节点 ' + uuid + ' 失败:', e.message)
}
}
} finally {
await conn.end()
}
return stats
}
// 清理 24h 之前的历史
async function cleanupOld() {
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
try {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
const [r] = await conn.execute('DELETE FROM `ServerNodeHistory` WHERE `period` < ?', [cutoff])
return r.affectedRows
} finally {
await conn.end()
}
}
module.exports = { runOnce, cleanupOld, BASE }
+10
View File
@@ -0,0 +1,10 @@
// 米家凭证缓存目录: 认证 token 只落本地磁盘, 不进库不进 git
// 环境变量:
// MIHOME_CACHE_DIR 覆盖 (默认 <cwd>/server/mihome/.cache)
import path from 'node:path'
export function cacheDir() {
return process.env.MIHOME_CACHE_DIR
? path.resolve(process.env.MIHOME_CACHE_DIR)
: path.resolve(process.cwd(), 'server', 'mihome', '.cache')
}
+112
View File
@@ -0,0 +1,112 @@
// 米家客户端单例 + 认证状态 + 扫码流程状态机
import { MijiaAPI } from '@zythum02/mijia-api'
import { DiskCache } from '@zythum02/mijia-api/cache/disk-cache'
import { prisma } from '../db.js'
import { cacheDir } from './cache.js'
// 未授权/已过期 (模块内部错误, 路由层做 4xx 映射)
export class MihomeAuthError extends Error {
constructor(message = '米家未授权或已过期') {
super(message)
this.name = 'MihomeAuthError'
}
}
let _api = null
// 惰性单例: 首次调用时才构造并加载磁盘凭证
export function getApi() {
if (!_api) _api = new MijiaAPI(new DiskCache(cacheDir()))
return _api
}
// 认证状态内存缓存 (5 分钟 TTL, /status 不每次打米家账号服务)
let authCache = { status: 'unknown', lastError: '', checkedAt: 0 }
function persistAccount(status, lastError) {
return prisma.mihomeAccount
.upsert({
where: { key: 'default' },
create: { key: 'default', status, lastError: lastError || '' },
update: { status, lastError: lastError || '' },
})
.catch((e) => {
console.warn('[mihome] 写入授权状态失败(忽略):', e.message)
})
}
// 校验米家是否可用: 有凭证 + 能刷新 token (刷新会自动处理 30 天过期)
// force=true 忽略 TTL 强查
export async function checkAuth(force = false) {
const now = Date.now()
if (!force && now - authCache.checkedAt < 5 * 60 * 1000) return authCache
const api = getApi()
let status = 'expired'
let lastError = ''
try {
if (!api.available) throw new Error('无授权凭证')
await api.refreshToken()
status = 'ok'
} catch (e) {
status = 'expired'
lastError = e?.message || String(e)
}
authCache = { status, lastError, checkedAt: now }
await persistAccount(status, lastError)
return authCache
}
// ==================== 扫码重授权流程 (模块级单例) ====================
// 状态: idle | waiting(已出二维码等扫码) | failed
// 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB
const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
export function getQrState() {
return { ...qrState }
}
export function getQrImageUrl() {
return qrState.qrImageUrl || ''
}
export async function startQrLogin() {
if (qrState.running) return getQrState()
const api = getApi()
qrState.running = true
qrState.canceled = false
qrState.lastError = ''
qrState.qrImageUrl = ''
qrState.startedAt = Date.now()
;(async () => {
try {
await api.QRlogin(async (info) => {
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
qrState.state = 'waiting'
})
if (qrState.canceled) return
qrState.state = 'idle'
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
await persistAccount('ok', '')
} catch (e) {
if (qrState.canceled) {
qrState.state = 'idle'
} else {
qrState.state = 'failed'
qrState.lastError = e?.message || String(e)
}
} finally {
qrState.running = false
}
})()
return getQrState()
}
export function cancelQrLogin() {
qrState.canceled = true
if (qrState.running) {
// 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果
} else if (qrState.state !== 'failed') {
qrState.state = 'idle'
}
return getQrState()
}
+285
View File
@@ -0,0 +1,285 @@
// 采集器: 家庭/设备发现 + 每小时采样 + 电量 + 日聚合 + vendor 补齐编排
import { prisma } from '../db.js'
import { checkAuth, getApi, MihomeAuthError } from './client.js'
import { getSpec } from './specs.js'
import { dayStart, hourStart, addDays } from './time.js'
import * as vendorStats from './vendorStats.js'
const BACKFILL_DAYS = 45 // 停机/首次接入时, 云端统计补齐窗口
const round2 = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : null)
// 互斥锁: 同一时刻只允许一个同步在跑 (cron 与手动/启动触发并发时合并)
let runLock = null
function withLock(fn) {
if (runLock) return runLock
runLock = Promise.resolve()
.then(fn)
.finally(() => {
runLock = null
})
return runLock
}
// ==================== 设备发现 ====================
async function syncHomes() {
const api = getApi()
const homes = await api.getHomesList()
for (const h of homes) {
const homeId = String(h.id)
await prisma.mihomeHome.upsert({
where: { homeId },
create: { homeId, name: h.name || homeId },
update: { name: h.name || homeId },
})
}
return homes.length
}
async function syncDevices() {
const api = getApi()
const list = await api.getDevicesList()
let kept = 0
for (const d of list) {
const spec = getSpec(d.model)
if (!spec) continue // 非环境设备白名单, 跳过
const homeId = String(d.home_id || '')
if (!homeId) continue
const data = {
homeId,
model: d.model,
name: String(d.name || d.did),
tempSiid: spec.temp[0],
tempPiid: spec.temp[1],
humSiid: spec.hum[0],
humPiid: spec.hum[1],
battSiid: spec.batt[0],
battPiid: spec.batt[1],
}
await prisma.mihomeDevice.upsert({
where: { did: d.did },
create: { did: d.did, ...data },
update: data,
})
kept++
}
return kept
}
async function listEnabledDevices() {
return prisma.mihomeDevice.findMany({ where: { enabled: true } })
}
// ==================== 采样 ====================
// 每小时采样: 批量读温湿度 -> upsert 小时档
async function sampleHour(devices) {
const api = getApi()
const bucket = hourStart()
const props = []
for (const d of devices) {
props.push({ did: d.did, siid: d.tempSiid, piid: d.tempPiid })
props.push({ did: d.did, siid: d.humSiid, piid: d.humPiid })
}
if (props.length === 0) return 0
const results = []
try {
const res = await api.getDevicesProp(props)
results.push(...(Array.isArray(res) ? res : [res]))
} catch (e) {
// 整体失败: 不写入任何小时档, 缺档留给整点补采/vendor 补齐, 本次直接抛给上层记录
throw e
}
// did -> { temp, hum }
const byDevice = new Map(devices.map((d) => [d.did, d]))
const perDevice = new Map()
for (const r of results) {
if (!r || !r.did) continue
const ok = Number(r.code) === 0 && r.value !== undefined && r.value !== null
if (!ok) continue
const d = byDevice.get(r.did)
if (!d) continue
const cur = perDevice.get(r.did) || {}
if (r.siid === d.tempSiid && r.piid === d.tempPiid) {
cur.temperatureAvg = round2(r.value)
} else if (r.siid === d.humSiid && r.piid === d.humPiid) {
cur.humidityAvg = round2(r.value)
}
perDevice.set(r.did, cur)
}
let written = 0
for (const d of devices) {
const v = perDevice.get(d.did)
if (!v || (v.temperatureAvg === undefined && v.humidityAvg === undefined)) continue
await prisma.mihomeEnvHour.upsert({
where: { deviceId_hour: { deviceId: d.id, hour: bucket } },
create: {
deviceId: d.id,
hour: bucket,
temperatureAvg: v.temperatureAvg ?? null,
humidityAvg: v.humidityAvg ?? null,
raw: null, // 自采
},
update: {
temperatureAvg: v.temperatureAvg ?? null,
humidityAvg: v.humidityAvg ?? null,
raw: null,
},
})
await prisma.mihomeDevice.update({
where: { id: d.id },
data: { lastSyncAt: new Date() },
})
written++
}
return written
}
// 电量: 每天读一次 (batteryAt 不在今天就刷新), 标题展示用
async function refreshBattery(devices) {
const api = getApi()
const today = dayStart()
for (const d of devices) {
const isFresh = d.batteryAt && d.batteryAt.getTime() >= today.getTime()
if (isFresh) continue
try {
const res = await api.getDevicesProp({ did: d.did, siid: d.battSiid, piid: d.battPiid })
if (res && Number(res.code) === 0 && typeof res.value === 'number') {
await prisma.mihomeDevice.update({
where: { id: d.id },
data: { battery: Math.round(res.value), batteryAt: new Date() },
})
}
} catch (e) {
// 电量读不到不报错, 下次再试
}
}
}
// ==================== 日聚合 ====================
// 按日档重算: 读该上海日小时内档 -> avg/min/max + 最后一次值
export async function aggregateDay(device, dayDate) {
const start = dayStart(dayDate.getTime())
const end = addDays(start, 1)
const hours = await prisma.mihomeEnvHour.findMany({
where: { deviceId: device.id, hour: { gte: start, lt: end } },
orderBy: { hour: 'asc' },
})
if (hours.length === 0) return false
const temps = hours.filter((h) => h.temperatureAvg != null).map((h) => h.temperatureAvg)
const hums = hours.filter((h) => h.humidityAvg != null).map((h) => h.humidityAvg)
const last = hours[hours.length - 1]
const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null)
const allVendor = hours.every((h) => h.raw && h.raw.vendor === true)
await prisma.mihomeEnvSample.upsert({
where: { deviceId_date: { deviceId: device.id, date: start } },
create: {
deviceId: device.id,
date: start,
temperature: temps.length ? temps[temps.length - 1] : null,
humidity: hums.length ? hums[hums.length - 1] : null,
temperatureAvg: round2(avg(temps)),
humidityAvg: round2(avg(hums)),
temperatureMin: round2(temps.length ? Math.min(...temps) : null),
temperatureMax: round2(temps.length ? Math.max(...temps) : null),
humidityMin: round2(hums.length ? Math.min(...hums) : null),
humidityMax: round2(hums.length ? Math.max(...hums) : null),
origin: allVendor ? 'vendor_history' : 'live',
sampledAt: last.hour,
raw: { bucketCount: hours.length },
},
update: {
temperature: temps.length ? temps[temps.length - 1] : null,
humidity: hums.length ? hums[hums.length - 1] : null,
temperatureAvg: round2(avg(temps)),
humidityAvg: round2(avg(hums)),
temperatureMin: round2(temps.length ? Math.min(...temps) : null),
temperatureMax: round2(temps.length ? Math.max(...temps) : null),
humidityMin: round2(hums.length ? Math.min(...hums) : null),
humidityMax: round2(hums.length ? Math.max(...hums) : null),
origin: allVendor ? 'vendor_history' : 'live',
sampledAt: last.hour,
},
})
return true
}
// 补聚合缺口日: [since, today) 有小时档却没有日档的日子
export async function aggregateGapDays(device, since) {
const today = dayStart()
const existing = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: since, lt: today } },
select: { date: true },
})
const have = new Set(existing.map((s) => s.date.getTime()))
let agg = 0
for (let t = since.getTime(); t < today.getTime(); t += 86400000) {
if (!have.has(t)) {
const ok = await aggregateDay(device, new Date(t))
if (ok) agg++
}
}
return agg
}
// ==================== 对外入口 ====================
// 每次同步: 发现 + 当前小时采样 + 今天/昨天聚合 + 电量 (轻量, 每小时跑)
// 注意: 单独不包 withLock, 由 syncNow/dailyMaintenance 统一加锁, 避免嵌套自锁
async function syncLocked() {
const auth = await checkAuth()
if (auth.status !== 'ok') throw new MihomeAuthError(auth.lastError || '米家未授权或已过期')
const began = Date.now()
const homes = await syncHomes()
const devices = await syncDevices()
const kept = await listEnabledDevices()
const sampled = await sampleHour(kept)
await refreshBattery(kept)
// 今天 + 昨天都聚合 (保证 00 点边界不丢)
const nowMs = Date.now()
const today = dayStart(nowMs)
let agg = 0
for (const d of kept) {
if (await aggregateDay(d, today)) agg++
const y = addDays(today, -1)
if (await aggregateDay(d, y)) agg++
}
return {
ok: true,
homes,
devices,
sampled,
aggregated: agg,
ms: Date.now() - began,
}
}
export function syncNow() {
return withLock(syncLocked)
}
// 每日维护: 轻量同步 + vendor 补齐历史缺口 (45 天窗口) + 缺口日聚合
export function dailyMaintenance() {
return withLock(async () => {
const r = await syncLocked()
const auth = await checkAuth()
if (auth.status !== 'ok') throw new MihomeAuthError(auth.lastError || '米家未授权或已过期')
const since = addDays(dayStart(), -BACKFILL_DAYS)
const devices = await listEnabledDevices()
let hFilled = 0
let dFilled = 0
let agg = 0
for (const d of devices) {
hFilled += await vendorStats.fillHourGaps(d, since)
dFilled += await vendorStats.fillDayGaps(d, since)
agg += await aggregateGapDays(d, since)
}
return { ...r, vendorHours: hFilled, vendorDays: dFilled, gapAggregated: agg }
})
}
+142
View File
@@ -0,0 +1,142 @@
// 历史查询聚合: today(时) / month(天) / year(周)
// 响应统一: [{ t(桶起始 ms, Asia/Shanghai), temperature, temperatureAvg, temperatureMin, temperatureMax,
// humidity, humidityAvg, humidityMin, humidityMax, sampleCount?, missing? }]
import { prisma } from '../db.js'
import { dayStart, hourStart, monthStart, yearStart, weekStart, addDays, addHours, cnDateStr } from './time.js'
// 设备是否存在 (路由层校验)
export async function getDevice(did) {
return prisma.mihomeDevice.findUnique({ where: { did } })
}
// ---------- 当天逐小时 ----------
export async function todayBuckets(device) {
const start = dayStart()
const now = hourStart()
const rows = await prisma.mihomeEnvHour.findMany({
where: { deviceId: device.id, hour: { gte: start, lt: addDays(start, 1) } },
orderBy: { hour: 'asc' },
})
const byHour = new Map(rows.map((r) => [r.hour.getTime(), r]))
const out = []
for (let t = start.getTime(); t <= now.getTime(); t += 3600000) {
const r = byHour.get(t)
out.push({
t,
temperature: r?.temperatureAvg ?? null,
humidity: r?.humidityAvg ?? null,
sampleCount: r ? 1 : 0,
})
}
return out
}
// ---------- 当前月逐日 ----------
export async function monthBuckets(device) {
const start = monthStart()
const today = dayStart()
const end = addDays(today, 1)
const rows = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
orderBy: { date: 'asc' },
})
const byDay = new Map(rows.map((r) => [r.date.getTime(), r]))
const out = []
for (let t = start.getTime(); t < end.getTime(); t += 86400000) {
const r = byDay.get(t)
out.push(r
? {
t,
temperature: r.temperature,
temperatureAvg: r.temperatureAvg,
temperatureMin: r.temperatureMin,
temperatureMax: r.temperatureMax,
humidity: r.humidity,
humidityAvg: r.humidityAvg,
humidityMin: r.humidityMin,
humidityMax: r.humidityMax,
origin: r.origin,
sampleCount: 1,
}
: { t, sampleCount: 0, missing: true })
}
return out
}
// ---------- 当前年逐周 (按日档现场聚合, 不落库) ----------
export async function yearBuckets(device) {
const start = yearStart()
const today = dayStart()
const end = addDays(today, 1)
const rows = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
orderBy: { date: 'asc' },
})
// 日 -> 周 (上海周一为一周起点)
const weekOf = (dayStartMs) => weekStart(new Date(dayStartMs)).getTime()
const weeks = new Map() // weekStartMs -> { tempA: [], tempMin: [], tempMax: [], humA: [], humMin: [], humMax: [], days: [] }
for (const r of rows) {
const k = weekOf(r.date.getTime())
if (!weeks.has(k)) {
weeks.set(k, { tempA: [], tempMin: [], tempMax: [], humA: [], humMin: [], humMax: [], days: [] })
}
const w = weeks.get(k)
if (r.temperatureAvg != null) w.tempA.push(r.temperatureAvg)
if (r.temperatureMin != null) w.tempMin.push(r.temperatureMin)
if (r.temperatureMax != null) w.tempMax.push(r.temperatureMax)
if (r.humidityAvg != null) w.humA.push(r.humidityAvg)
if (r.humidityMin != null) w.humMin.push(r.humidityMin)
if (r.humidityMax != null) w.humMax.push(r.humidityMax)
w.days.push(r.date)
}
const avg = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null)
const out = []
for (const [k, w] of [...weeks.entries()].sort((a, b) => a[0] - b[0])) {
// 补齐周内缺的天数 (统计无数据的日算缺失)
const wkStart = new Date(k)
const missing = addDays(wkStart, 7)
const haveMap = new Set(w.days.map((d) => d.getTime()))
let missingDays = 0
for (let t = k; t < missing.getTime(); t += 86400000) {
// 只统计当年/到今天的日
if (t >= start.getTime() && t < end.getTime() && !haveMap.has(t)) missingDays++
}
out.push({
t: k,
label: cnDateStr(wkStart).slice(5) + '~' + cnDateStr(addDays(wkStart, 6)).slice(5),
temperatureAvg: avg(w.tempA),
temperatureMin: w.tempMin.length ? Math.min(...w.tempMin) : null,
temperatureMax: w.tempMax.length ? Math.max(...w.tempMax) : null,
humidityAvg: avg(w.humA),
humidityMin: w.humMin.length ? Math.min(...w.humMin) : null,
humidityMax: w.humMax.length ? Math.max(...w.humMax) : null,
sampleCount: w.days.length,
missingDays,
})
}
return out
}
// ---------- 设备概览 (状态页用): 最新样本 + 近 30 天缺日列表 ----------
export async function deviceOverview(device) {
const latest = await prisma.mihomeEnvSample.findFirst({
where: { deviceId: device.id },
orderBy: { date: 'desc' },
})
// 近 30 天 (含今天) 缺少样本的上海日
const start = addDays(dayStart(), -29)
const end = addDays(dayStart(), 1)
const have = await prisma.mihomeEnvSample.findMany({
where: { deviceId: device.id, date: { gte: start, lt: end } },
select: { date: true },
})
const haveSet = new Set(have.map((h) => h.date.getTime()))
const missingDays = []
for (let t = start.getTime(); t < end.getTime(); t += 86400000) {
if (!haveSet.has(t)) missingDays.push(cnDateStr(new Date(t)))
}
return {
latest,
missingDays,
}
}
+68
View File
@@ -0,0 +1,68 @@
// server/mihome 模块入口: 汇总导出采集/认证/查询, 并提供 /status 聚合
import { prisma } from '../db.js'
import * as vendorStats from './vendorStats.js'
import * as history from './history.js'
import * as auth from './client.js'
export { checkAuth, startQrLogin, cancelQrLogin, getQrState, getQrImageUrl, MihomeAuthError } from './client.js'
export { syncNow, dailyMaintenance } from './collector.js'
export { getDevice, todayBuckets, monthBuckets, yearBuckets, deviceOverview } from './history.js'
export { getSpec, ENV_MODELS } from './specs.js'
// GET /api/mihome/status 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日)
export async function getStatus() {
const authState = await auth.checkAuth()
const [homes, devices, account] = await Promise.all([
prisma.mihomeHome.findMany({ orderBy: { id: 'asc' } }),
prisma.mihomeDevice.findMany({ orderBy: { id: 'asc' } }),
prisma.mihomeAccount.findUnique({ where: { key: 'default' } }),
])
const overviews = await Promise.all(
devices.map(async (d) => {
const ov = await history.deviceOverview(d)
return {
did: d.did,
latest: ov.latest,
missingDays: ov.missingDays,
}
})
)
const perDevice = new Map(overviews.map((o) => [o.did, o]))
// 统计可用性 (探测过的型号才有值, 未探测 undefined)
const capability = {}
for (const d of devices) {
const v = vendorStats.capabilityOf(d.model)
if (v !== undefined) capability[d.model] = v
}
const homeList = homes.map((h) => ({
homeId: h.homeId,
name: h.name,
devices: devices
.filter((d) => d.homeId === h.homeId)
.map((d) => ({
did: d.did,
model: d.model,
name: d.name,
alias: d.alias,
title: d.alias || d.name,
battery: d.battery,
batteryAt: d.batteryAt,
lastSyncAt: d.lastSyncAt,
latest: perDevice.get(d.did)?.latest ?? null,
missingDays: perDevice.get(d.did)?.missingDays ?? [],
})),
}))
return {
auth: {
status: account?.status || authState.status,
lastError: account?.lastError || authState.lastError,
checkedAt: authState.checkedAt,
},
capability,
homes: homeList,
}
}
+38
View File
@@ -0,0 +1,38 @@
// 米家环境监控定时调度
// 独立于 server/cron.js 注册: 容器镜像不含 scripts/, 旧 cron.js 顶层 import 脚本模块会整体加载失败,
// 故米家节拍不依赖它, 由 server/index.js 单独加载本模块, 确保采样/聚合在部署环境一定生效。
import cron from 'node-cron'
import * as mihome from './index.js'
export function registerMihomeScheduler() {
cron.schedule('5 * * * *', async () => {
console.log('[cron] 米家小时采样触发 @ 上海 xx:05')
try {
const r = await mihome.syncNow()
console.log('[cron] 米家采样完成: 家庭=' + r.homes + ' 设备=' + r.devices + ' 采样=' + r.sampled + ' 聚合=' + r.aggregated + ' (' + r.ms + 'ms)')
} catch (e) {
console.error('[cron] 米家小时采样失败:', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每小时 xx:05 米家温湿度采样')
cron.schedule('5 0,3 * * *', async () => {
console.log('[cron] 米家每日维护触发 (00:05 / 03:05)')
try {
const r = await mihome.dailyMaintenance()
console.log('[cron] 米家聚合完成: 采样=' + r.sampled + ' 聚合=' + r.aggregated
+ ' vendor小时=' + r.vendorHours + ' vendor天=' + r.vendorDays + ' 缺口聚合=' + r.gapAggregated)
} catch (e) {
console.error('[cron] 米家每日维护失败:', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 00:05/03:05 米家日聚合 + vendor 补齐')
// 启动追赶: 服务重启/停机恢复后立即补一次 (不打断 5 分钟内的整点采样)
setTimeout(() => {
mihome.dailyMaintenance().catch((e) => console.error('[cron] 米家启动追赶失败:', e.message))
}, 8000)
console.log('[cron] 已注册: 启动后 8s 执行米家追赶同步')
}
registerMihomeScheduler()
+26
View File
@@ -0,0 +1,26 @@
// 环境设备白名单: 型号 -> 属性定义 (siid.piid), 均为调研阶段实测确认
// - temp/hum: 采集用; batt: 电量 (每天读一次, 标题展示)
// - vendorStats: 是否已实测该型号支持 /v2/user/statistics 云端聚合历史
export const ENV_MODELS = {
// 炎米智能温湿度计: 实测 stat_hour_v3/stat_day_v3 可用, 回溯到接入米家日
'ym001.sensor_ht.ymwsdj': {
temp: [2, 1001],
hum: [2, 1002],
batt: [3, 1003],
vendorStats: true,
},
// 青萍蓝牙闹钟: 云端统计返回 -8 invalid params (未开通), 只走实时采样
'cgllc.clock.dove': {
temp: [2, 1],
hum: [2, 2],
batt: [2, 3],
vendorStats: false,
},
}
export function getSpec(model) {
return ENV_MODELS[model] || null
}
export const XIAOMI_APP_UA =
'APP/com.xiaomi.mihome APPV/6.0.89 Channel/MI-COM-BD-00059-00 OSVersion/MIUI-12.0.1 Android/28'
+78
View File
@@ -0,0 +1,78 @@
// Asia/Shanghai 时区工具 (中国无夏令时, 固定 +08:00)
// 桶语义: 小时档/日档桶的 \"边界时刻\" 都按上海时区对齐, 存储统一存 UTC (Date)
const OFFSET_MS = 8 * 3600 * 1000
// 把时刻按上海时区拆成 \"假装 UTC 的本地部件\"
function cnParts(ts) {
const d = new Date(ts + OFFSET_MS)
return {
y: d.getUTCFullYear(),
mo: d.getUTCMonth() + 1,
da: d.getUTCDate(),
h: d.getUTCHours(),
min: d.getUTCMinutes(),
sec: d.getUTCSeconds(),
dow: d.getUTCDay(), // 0=Sun
}
}
// 上海当日 00:00 (返回对应 UTC 时刻的 Date)
export function dayStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, p.da) - OFFSET_MS)
}
// 上海整点 (返回对应 UTC 时刻的 Date)
export function hourStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, p.da, p.h) - OFFSET_MS)
}
// 上海当周起始 (周一 00:00)
export function weekStart(date) {
const dow = cnParts(date.getTime()).dow
const diff = dow === 0 ? 6 : dow - 1 // 距周一的天数
return addDays(dayStart(date.getTime()), -diff)
}
export function addDays(date, n) {
return new Date(date.getTime() + n * 86400000)
}
// 上海当月 1 号 00:00
export function monthStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, p.mo - 1, 1) - OFFSET_MS)
}
// 上海当年 1 月 1 日 00:00
export function yearStart(ts = Date.now()) {
const p = cnParts(ts)
return new Date(Date.UTC(p.y, 0, 1) - OFFSET_MS)
}
export function addHours(date, n) {
return new Date(date.getTime() + n * 3600000)
}
// 上海本地 \"YYYY-MM-DD\"
export function cnDateStr(date) {
const p = cnParts(date.getTime())
return `${p.y}-${String(p.mo).padStart(2, '0')}-${String(p.da).padStart(2, '0')}`
}
// 上海本地 \"HH:mm\"
export function cnTimeStr(date) {
const p = cnParts(date.getTime())
return `${String(p.h).padStart(2, '0')}:${String(p.min).padStart(2, '0')}`
}
// 上海本地 \"YYYY-MM-DD HH:mm\" (用于日志)
export function cnFullStr(date) {
return `${cnDateStr(date)} ${cnTimeStr(date)}`
}
// 判断两个日期是否同一上海日
export function sameCnDay(a, b) {
return dayStart(a.getTime()).getTime() === dayStart(b.getTime()).getTime()
}

Some files were not shown because too many files have changed in this diff Show More