Compare commits
42
Commits
1c07b417ce
...
dev-0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3140eae7db | ||
|
|
7b4e59957b | ||
|
|
1d69915cf3 | ||
|
|
8e2c6ffa35 | ||
|
|
53f63e0986 | ||
|
|
508419180c | ||
|
|
4f2becb99a | ||
|
|
2fa7c7fc70 | ||
|
|
1b330bdd4d | ||
|
|
5822a01ed7 | ||
|
|
b0bc1e9727 | ||
|
|
68998fa07e | ||
|
|
d5a31e5366 | ||
|
|
05ad191913 | ||
|
|
9ecbf8d8e0 | ||
|
|
44d60471ca | ||
|
|
60628775ac | ||
|
|
add3ff8831 | ||
|
|
22a36fb213 | ||
|
|
a5bc8ef1f1 | ||
|
|
b9ec6fbe0b | ||
|
|
8999b29646 | ||
|
|
4f5cdb4325 | ||
|
|
dbe04b7da3 | ||
|
|
ac8a4b8cd2 | ||
|
|
6bacda05f8 | ||
|
|
f2e53df25b | ||
|
|
4f39f428bd | ||
|
|
eddddc56ae | ||
|
|
4d02439ecb | ||
|
|
547181d333 | ||
|
|
ec15f40323 | ||
|
|
b10b1c44db | ||
|
|
4da9489662 | ||
|
|
7fcd0c9569 | ||
|
|
7f72bf809b | ||
|
|
28d1b5d1c6 | ||
|
|
789505fb34 | ||
|
|
3d84e8d8f7 | ||
|
|
79341bb8cc | ||
|
|
0f74602036 | ||
|
|
08b3655a38 |
@@ -0,0 +1,177 @@
|
||||
name: Android Build & Publish
|
||||
|
||||
# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0)
|
||||
# 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'android-*'
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
# 需要写权限: 上传 packages
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
# 固定工具链版本, 便于 actions/cache 命中
|
||||
# Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致
|
||||
VITE_API_BASE: http://bh.vps.honor3.com:3001
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: https://gitea.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
# ---------- 缓存: 命中后跳过下面的安装 ----------
|
||||
- name: Cache Android SDK & NDK
|
||||
id: cache-android
|
||||
uses: https://gitea.com/actions/cache@v4
|
||||
with:
|
||||
path: ~/android-sdk
|
||||
key: android-sdk-ndk-27.2-${{ runner.os }}
|
||||
|
||||
- name: Cache Rust toolchain
|
||||
id: cache-rust
|
||||
uses: https://gitea.com/actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
~/.rustup
|
||||
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
|
||||
key: rust-android-v1-${{ runner.os }}
|
||||
|
||||
- name: Cache Tauri cargo target
|
||||
uses: https://gitea.com/actions/cache@v4
|
||||
with:
|
||||
path: src-tauri/target
|
||||
key: tauri-target-android-${{ runner.os }}
|
||||
|
||||
# ---------- 安装工具链 (仅缓存未命中时) ----------
|
||||
- name: Install Rust + Android targets
|
||||
if: steps.cache-rust.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
. "$HOME/.cargo/env"
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
|
||||
cargo install cargo-ndk --locked
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
if: steps.cache-android.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends unzip
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip
|
||||
unzip -q /tmp/cmdline-tools.zip -d /tmp/
|
||||
mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager --install \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0" \
|
||||
"platform-tools" \
|
||||
"ndk;27.2.12479018" \
|
||||
"cmake;3.22.1"
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# ---------- 全局 Android 环境 (对后续所有 step 生效, 无论缓存是否命中) ----------
|
||||
- name: Set Android env
|
||||
run: |
|
||||
echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_NDK_HOME=$HOME/android-sdk/ndk/27.2.12479018" >> "$GITHUB_ENV"
|
||||
echo "$HOME/android-sdk/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||
echo "$HOME/android-sdk/platform-tools" >> "$GITHUB_PATH"
|
||||
|
||||
# ---------- 构建 ----------
|
||||
- name: Init Android project
|
||||
run: |
|
||||
# tauri android init 需要 cargo (运行 cargo metadata)。
|
||||
# Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH,
|
||||
# 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。
|
||||
. "$HOME/.cargo/env"
|
||||
cd src-tauri
|
||||
npx tauri android init --ci
|
||||
|
||||
- name: Inject release signing config
|
||||
run: bash scripts/ci/android-signing.sh
|
||||
env:
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
- name: Build release APK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
|
||||
export CARGO_HOME="$HOME/.cargo"
|
||||
. "$HOME/.cargo/env"
|
||||
cd src-tauri
|
||||
npx tauri android build --apk
|
||||
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
|
||||
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
|
||||
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Locate APK
|
||||
id: apk
|
||||
run: |
|
||||
# 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK
|
||||
APK=""
|
||||
UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1)
|
||||
if [ -n "$UNIVERSAL" ]; then
|
||||
APK="$UNIVERSAL"
|
||||
else
|
||||
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1)
|
||||
fi
|
||||
if [ -z "$APK" ]; then
|
||||
echo "ERROR: 未找到已签名 APK" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$APK" >> $GITHUB_OUTPUT
|
||||
ls -lh "$APK"
|
||||
|
||||
# ---------- 上传到 Gitea Generic Package ----------
|
||||
- name: Publish APK to Gitea packages
|
||||
env:
|
||||
# 注意: 不能用 GITEA_TOKEN (内置 job token, 无组织 packages 写权限, 且命名规则禁止 GITEA_ 前缀)
|
||||
# 用一个自定义名 secret 存放有 packages 写权限的 user token
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
APK_PATH: ${{ steps.apk.outputs.path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一
|
||||
BRANCH="${GITHUB_REF_NAME#android-}"
|
||||
PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}"
|
||||
FILE_NAME="iboard-$(basename "$APK_PATH")"
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER}"
|
||||
PKG_NAME="iboard"
|
||||
# Gitea Generic Package Registry 上传端点 (PUT)
|
||||
# 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com
|
||||
URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}"
|
||||
echo "上传到: ${URL}"
|
||||
curl -sS --fail-with-body \
|
||||
-H "Authorization: token ${PACKAGE_TOKEN}" \
|
||||
--upload-file "$APK_PATH" \
|
||||
"${URL}"
|
||||
echo ""
|
||||
echo "已发布: ${URL}"
|
||||
@@ -1,19 +1,67 @@
|
||||
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
|
||||
run: docker build -t iboard:latest .
|
||||
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
|
||||
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build .
|
||||
|
||||
- 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
|
||||
|
||||
@@ -21,4 +69,16 @@ jobs:
|
||||
run: docker system prune --volumes -f
|
||||
|
||||
- name: Run container
|
||||
run: docker run -d --name iboard -p 3080:3000 iboard
|
||||
# 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 }}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: OTA Frontend Build & Sign
|
||||
|
||||
# 前端 OTA 升级包构建 + 签名 + 上传 artifact
|
||||
# 触发: 仅手动 (workflow_dispatch), 发版前用
|
||||
# - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建
|
||||
# 输入: sequence / notes / min_binary (可覆盖默认)
|
||||
# Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)
|
||||
# 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey,
|
||||
# 换密钥请手动改 conf 并 commit.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sequence:
|
||||
description: 'OTA sequence (单调递增整数, 留空用 timestamp)'
|
||||
required: false
|
||||
type: string
|
||||
notes:
|
||||
description: '发布说明 (写入 latest.json.notes)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
min_binary:
|
||||
description: '最低 binary 版本 (留空用 src-tauri/tauri.conf.json 的 version)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-ota:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Build frontend
|
||||
# 复用 vite build 产物; 不打 Docker (本 workflow 只关心前端)
|
||||
run: npm run build
|
||||
|
||||
- name: Install minisign
|
||||
run: sudo apt-get update && sudo apt-get install -y minisign
|
||||
|
||||
- name: Build & sign OTA package
|
||||
env:
|
||||
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
||||
OTA_NOTES: ${{ inputs.notes }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OUT="ota-package"
|
||||
ARGS="--out ${OUT}"
|
||||
# inputs 留空时 → 不传 --sequence/--min-binary, 脚本默认 sequence=时间戳, min-binary=tauri.conf version
|
||||
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
|
||||
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
|
||||
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
|
||||
node scripts/build-ota.mjs ${ARGS}
|
||||
echo "===== latest.json ====="
|
||||
cat ${OUT}/latest.json
|
||||
echo "===== bundle sha256 ====="
|
||||
sha256sum ${OUT}/iboard-ota.tar.gz
|
||||
|
||||
- name: Upload OTA package as artifact
|
||||
uses: https://gitea.com/actions/upload-artifact@v4
|
||||
with:
|
||||
name: iboard-ota-${{ inputs.sequence || github.run_number }}
|
||||
path: ota-package/
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Sync Secrets to Registry
|
||||
|
||||
# 将本仓库 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
|
||||
# 目标: service-iboard 项目 (proj-msid70ql-6xbq4w)
|
||||
# 触发: 仅手动 (workflow_dispatch)
|
||||
# 依赖 secret:
|
||||
# - REGISTRY_MCP_TOKEN : 注册中心 MCP 访问令牌 (在注册中心 🔑Token 管理 里生成)
|
||||
# - 其余 secret 与三个构建 workflow 引用的保持一致 (未设置则跳过对应键)
|
||||
# 注意: 传输为明文 http, 请保证注册中心仅内网可达; 若可访问则改用 https
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: '仅预演不写入 (true=只打印计划)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
sync-secrets:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
REGISTRY_MCP_URL: http://bh.vps.honor3.com:8008/mcp
|
||||
REGISTRY_PROJECT_ID: proj-msid70ql-6xbq4w
|
||||
REGISTRY_IDC: '1'
|
||||
REGISTRY_ENVIRONMENT: '1'
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Sync secrets via MCP
|
||||
run: |
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
node scripts/sync-registry.mjs --dry-run
|
||||
else
|
||||
node scripts/sync-registry.mjs
|
||||
fi
|
||||
env:
|
||||
REGISTRY_MCP_TOKEN: ${{ secrets.REGISTRY_MCP_TOKEN }}
|
||||
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
+46
-31
@@ -1,32 +1,47 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/out
|
||||
/.next
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
dist/*
|
||||
|
||||
# local env (contains API keys)
|
||||
/.env
|
||||
.reasonix/*
|
||||
# OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交)
|
||||
ota-package/*
|
||||
!ota-package/README.md
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/out
|
||||
/.next
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
# apizero 文件缓存 (运行时按需生成)
|
||||
cache/apizero/
|
||||
# Dockerfile 持久卷挂载点 (跟 ota-package 同类)
|
||||
.cache/
|
||||
# 米家凭证缓存 (含 token, 绝不能提交)
|
||||
server/mihome/.cache/
|
||||
|
||||
+57
-23
@@ -1,39 +1,73 @@
|
||||
# 构建阶段
|
||||
FROM node:24-alpine AS builder
|
||||
# ===============================================================
|
||||
# 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
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制package.json和package-lock.json
|
||||
# 国内构建时可换源
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 先拷贝 package 文件, 利用 Docker 缓存
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
COPY prisma.config.ts ./
|
||||
COPY generated ./generated/
|
||||
|
||||
# 设置淘宝源并安装依赖
|
||||
RUN npm config set registry https://registry.npmmirror.com && npm install
|
||||
# 装全量依赖 (dev 也装, 后面 build 要用)
|
||||
RUN npm install
|
||||
|
||||
# 复制项目文件
|
||||
# 拷贝源码
|
||||
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
|
||||
|
||||
# 生产阶段
|
||||
FROM node:24-alpine
|
||||
# 运行时阶段
|
||||
FROM node:22-alpine
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制必要的文件
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/content ./content/
|
||||
# 复制public目录(如果存在)
|
||||
COPY --from=builder /app/public* ./public/
|
||||
# 阿里源加速
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 设置淘宝源并安装生产依赖
|
||||
RUN npm config set registry https://registry.npmmirror.com && npm install --only=production
|
||||
# 只装运行时依赖
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# 暴露 3000 端口
|
||||
EXPOSE 3000
|
||||
# 拷贝构建产物
|
||||
COPY --from=builder /app/dist ./dist
|
||||
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
|
||||
|
||||
# 启动生产服务器
|
||||
CMD ["npm", "start"]
|
||||
# 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
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
|
||||
# Node 22 默认未开 strip-types, 显式开启以便加载 generated/prisma/*.ts
|
||||
CMD ["node", "--experimental-strip-types", "--no-warnings", "server/index.js"]
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
node:events:497
|
||||
throw er; // Unhandled 'error' event
|
||||
^
|
||||
|
||||
Error: listen EADDRINUSE: address already in use :::3001
|
||||
at Server.setupListenHandle [as _listen2] (node:net:1940:16)
|
||||
at listenInCluster (node:net:1997:12)
|
||||
at Server.listen (node:net:2102:7)
|
||||
at Function.listen (D:\workbench\iboard\node_modules\express\lib\application.js:635:24)
|
||||
at file:///D:/workbench/iboard/server/index.js:57:5
|
||||
at ModuleJob.run (node:internal/modules/esm/module_job:345:25)
|
||||
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:651:26)
|
||||
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
|
||||
Emitted 'error' event on Server instance at:
|
||||
at emitErrorNT (node:net:1976:8)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -4091,
|
||||
syscall: 'listen',
|
||||
address: '::',
|
||||
port: 3001
|
||||
}
|
||||
|
||||
Node.js v22.19.0
|
||||
@@ -0,0 +1,2 @@
|
||||
[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测
|
||||
[cron] 已注册: 每天 04:00 浙江油价历史抓取
|
||||
@@ -1,11 +0,0 @@
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer-content">
|
||||
<p>© 2026 iBoard. 收集和整理经济数据,以策略为主体重新组织。</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -1,18 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const Navbar = () => {
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="logo">
|
||||
<Link href="/">iBoard</Link>
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<Link href="/">首页</Link>
|
||||
<Link href="/data">经济数据</Link>
|
||||
<Link href="/strategy">策略研究</Link>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default Navbar
|
||||
@@ -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
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
title: GDP增长分析与展望
|
||||
---
|
||||
|
||||
GDP是衡量一个国家经济规模和增长速度的重要指标。本文将分析当前全球主要经济体的GDP增长情况,探讨影响GDP增长的因素,并对未来经济走势进行展望。
|
||||
|
||||
首先,我们来看一下全球GDP增长的整体情况。根据国际货币基金组织(IMF)的最新数据,全球GDP增长率预计将在2026年达到3.5%左右,较2025年有所提升。
|
||||
|
||||
美国作为全球最大的经济体,其GDP增长率预计将保持在2.5%左右。消费、投资和出口是美国经济增长的主要驱动力。美联储的货币政策、政府的财政政策以及地缘政治风险等因素将影响美国经济的表现。
|
||||
|
||||
中国的GDP增长率预计将在5.0%左右。中国经济正在从投资驱动型向消费驱动型转变,科技创新、绿色发展等新动能正在成为经济增长的重要支撑。
|
||||
|
||||
欧元区的GDP增长率预计将在1.8%左右。欧元区经济面临着人口老龄化、结构性改革缓慢等挑战,但也受益于欧洲央行的宽松货币政策和复苏基金的支持。
|
||||
|
||||
影响GDP增长的因素是多方面的,包括货币政策、财政政策、技术进步、人口结构、地缘政治等。在当前全球经济不确定性增加的背景下,各国需要采取适当的政策措施来促进经济增长。
|
||||
|
||||
---
|
||||
title: GDP增长分析与展望
|
||||
---
|
||||
|
||||
GDP是衡量一个国家经济规模和增长速度的重要指标。本文将分析当前全球主要经济体的GDP增长情况,探讨影响GDP增长的因素,并对未来经济走势进行展望。
|
||||
|
||||
首先,我们来看一下全球GDP增长的整体情况。根据国际货币基金组织(IMF)的最新数据,全球GDP增长率预计将在2026年达到3.5%左右,较2025年有所提升。
|
||||
|
||||
美国作为全球最大的经济体,其GDP增长率预计将保持在2.5%左右。消费、投资和出口是美国经济增长的主要驱动力。美联储的货币政策、政府的财政政策以及地缘政治风险等因素将影响美国经济的表现。
|
||||
|
||||
中国的GDP增长率预计将在5.0%左右。中国经济正在从投资驱动型向消费驱动型转变,科技创新、绿色发展等新动能正在成为经济增长的重要支撑。
|
||||
|
||||
欧元区的GDP增长率预计将在1.8%左右。欧元区经济面临着人口老龄化、结构性改革缓慢等挑战,但也受益于欧洲央行的宽松货币政策和复苏基金的支持。
|
||||
|
||||
影响GDP增长的因素是多方面的,包括货币政策、财政政策、技术进步、人口结构、地缘政治等。在当前全球经济不确定性增加的背景下,各国需要采取适当的政策措施来促进经济增长。
|
||||
|
||||
展望未来,全球经济增长有望保持稳定,但仍面临着诸多挑战。科技创新、数字经济、绿色转型等将成为推动经济增长的重要力量。同时,各国需要加强国际合作,共同应对全球性挑战,如气候变化、贸易保护主义等。
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
title: 消费趋势的变化与商业机会
|
||||
---
|
||||
|
||||
消费趋势的变化正在重塑商业格局。本文将分析当前消费市场的主要趋势,探讨这些变化对企业的影响,并为企业提供相应的战略建议。
|
||||
|
||||
首先,我们来看一下消费市场的整体情况。随着经济的发展和居民收入水平的提高,消费结构正在从生存型消费向发展型和享受型消费转变。
|
||||
|
||||
数字化消费已经成为主流。电子商务、移动支付、在线娱乐等数字消费方式正在改变人们的消费习惯。消费者越来越倾向于通过互联网和移动设备进行购物、娱乐和社交。
|
||||
|
||||
健康消费成为新的增长点。消费者对健康食品、健身服务、医疗保健等健康相关产品和服务的需求不断增加。这一趋势在疫情后更加明显,人们对健康的重视程度提高。
|
||||
|
||||
可持续消费理念逐渐深入人心。越来越多的消费者开始关注产品的环保性能,愿意为可持续产品支付溢价。企业的ESG表现也成为消费者选择品牌的重要因素。
|
||||
|
||||
个性化消费需求日益凸显。消费者希望获得个性化的产品和服务,而不是千篇一律的标准化产品。企业需要通过大数据、人工智能等技术来满足消费者的个性化需求。
|
||||
|
||||
这些消费趋势的变化对企业提出了新的挑战和机遇。企业需要调整产品和服务策略,以适应消费者需求的变化。同时,企业也需要利用数字技术来提升运营效率,改善客户体验。
|
||||
|
||||
---
|
||||
title: 消费趋势的变化与商业机会
|
||||
---
|
||||
|
||||
消费趋势的变化正在重塑商业格局。本文将分析当前消费市场的主要趋势,探讨这些变化对企业的影响,并为企业提供相应的战略建议。
|
||||
|
||||
首先,我们来看一下消费市场的整体情况。随着经济的发展和居民收入水平的提高,消费结构正在从生存型消费向发展型和享受型消费转变。
|
||||
|
||||
数字化消费已经成为主流。电子商务、移动支付、在线娱乐等数字消费方式正在改变人们的消费习惯。消费者越来越倾向于通过互联网和移动设备进行购物、娱乐和社交。
|
||||
|
||||
健康消费成为新的增长点。消费者对健康食品、健身服务、医疗保健等健康相关产品和服务的需求不断增加。这一趋势在疫情后更加明显,人们对健康的重视程度提高。
|
||||
|
||||
可持续消费理念逐渐深入人心。越来越多的消费者开始关注产品的环保性能,愿意为可持续产品支付溢价。企业的ESG表现也成为消费者选择品牌的重要因素。
|
||||
|
||||
个性化消费需求日益凸显。消费者希望获得个性化的产品和服务,而不是千篇一律的标准化产品。企业需要通过大数据、人工智能等技术来满足消费者的个性化需求。
|
||||
|
||||
这些消费趋势的变化对企业提出了新的挑战和机遇。企业需要调整产品和服务策略,以适应消费者需求的变化。同时,企业也需要利用数字技术来提升运营效率,改善客户体验。
|
||||
|
||||
对于投资者而言,消费趋势的变化也意味着投资机会的转移。需要关注那些能够适应消费趋势变化、具有创新能力的企业。
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
title: 科技创新与经济增长
|
||||
---
|
||||
|
||||
科技创新是推动经济增长的重要动力。本文将分析当前科技创新的趋势,探讨其对经济增长的影响,并为政策制定者和投资者提供参考。
|
||||
|
||||
首先,我们来看一下当前科技创新的主要领域。人工智能、区块链、生物技术、可再生能源等领域的技术突破正在改变我们的生活和工作方式。
|
||||
|
||||
人工智能技术的快速发展正在各个行业产生深远影响。从自动驾驶到医疗诊断,从金融服务到制造业,AI正在提高效率,创造新的商业模式。
|
||||
|
||||
区块链技术的应用也在不断扩展。除了加密货币,区块链在供应链管理、数字身份、金融服务等领域的应用正在逐步落地。
|
||||
|
||||
生物技术的进步为医疗健康、农业等领域带来了新的机遇。基因编辑、精准医疗、合成生物学等技术的发展有望解决许多传统医学难以解决的问题。
|
||||
|
||||
可再生能源技术的突破正在加速能源转型。太阳能、风能、储能技术的成本不断下降,为实现碳中和目标提供了技术支持。
|
||||
|
||||
这些科技创新对经济增长的影响是多方面的。它们不仅提高了生产效率,还创造了新的产业和就业机会。同时,科技创新也带来了一些挑战,如就业结构的变化、隐私保护等问题。
|
||||
|
||||
---
|
||||
title: 科技创新与经济增长
|
||||
---
|
||||
|
||||
科技创新是推动经济增长的重要动力。本文将分析当前科技创新的趋势,探讨其对经济增长的影响,并为政策制定者和投资者提供参考。
|
||||
|
||||
首先,我们来看一下当前科技创新的主要领域。人工智能、区块链、生物技术、可再生能源等领域的技术突破正在改变我们的生活和工作方式。
|
||||
|
||||
人工智能技术的快速发展正在各个行业产生深远影响。从自动驾驶到医疗诊断,从金融服务到制造业,AI正在提高效率,创造新的商业模式。
|
||||
|
||||
区块链技术的应用也在不断扩展。除了加密货币,区块链在供应链管理、数字身份、金融服务等领域的应用正在逐步落地。
|
||||
|
||||
生物技术的进步为医疗健康、农业等领域带来了新的机遇。基因编辑、精准医疗、合成生物学等技术的发展有望解决许多传统医学难以解决的问题。
|
||||
|
||||
可再生能源技术的突破正在加速能源转型。太阳能、风能、储能技术的成本不断下降,为实现碳中和目标提供了技术支持。
|
||||
|
||||
这些科技创新对经济增长的影响是多方面的。它们不仅提高了生产效率,还创造了新的产业和就业机会。同时,科技创新也带来了一些挑战,如就业结构的变化、隐私保护等问题。
|
||||
|
||||
对于政策制定者而言,需要制定有利于科技创新的政策环境,包括加大研发投入、培养创新人才、完善知识产权保护等。对于投资者而言,需要关注科技创新的趋势,寻找具有增长潜力的投资机会。
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
title: 全球贸易格局的变化与影响
|
||||
---
|
||||
|
||||
全球贸易格局正在经历深刻的变化。本文将分析当前全球贸易的现状,探讨主要经济体的贸易政策,以及这些变化对全球经济的影响。
|
||||
|
||||
首先,我们来看一下全球贸易的整体情况。尽管贸易保护主义有所抬头,但全球贸易总额仍然保持增长。新兴经济体在全球贸易中的比重不断提高,正在改变传统的贸易格局。
|
||||
|
||||
美国、欧盟、中国等主要经济体的贸易政策对全球贸易产生重要影响。美国的贸易保护主义措施、欧盟的绿色贸易政策、中国的扩大开放政策等,都在塑造着新的贸易规则。
|
||||
|
||||
全球供应链的重构也是当前贸易格局变化的重要特征。出于安全和效率的考虑,许多企业正在调整供应链布局,推动区域化和多元化。
|
||||
|
||||
这些变化对全球经济产生了深远的影响。一方面,贸易格局的调整可能会导致短期的经济波动;另一方面,新的贸易规则和供应链布局也为经济发展带来了新的机遇。
|
||||
|
||||
---
|
||||
title: 全球贸易格局的变化与影响
|
||||
---
|
||||
|
||||
全球贸易格局正在经历深刻的变化。本文将分析当前全球贸易的现状,探讨主要经济体的贸易政策,以及这些变化对全球经济的影响。
|
||||
|
||||
首先,我们来看一下全球贸易的整体情况。尽管贸易保护主义有所抬头,但全球贸易总额仍然保持增长。新兴经济体在全球贸易中的比重不断提高,正在改变传统的贸易格局。
|
||||
|
||||
美国、欧盟、中国等主要经济体的贸易政策对全球贸易产生重要影响。美国的贸易保护主义措施、欧盟的绿色贸易政策、中国的扩大开放政策等,都在塑造着新的贸易规则。
|
||||
|
||||
全球供应链的重构也是当前贸易格局变化的重要特征。出于安全和效率的考虑,许多企业正在调整供应链布局,推动区域化和多元化。
|
||||
|
||||
这些变化对全球经济产生了深远的影响。一方面,贸易格局的调整可能会导致短期的经济波动;另一方面,新的贸易规则和供应链布局也为经济发展带来了新的机遇。
|
||||
|
||||
对于企业和投资者而言,需要密切关注贸易格局的变化,调整战略以适应新的环境。同时,政府也需要制定相应的政策,促进贸易的健康发展。
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: 利率走势分析与投资策略
|
||||
---
|
||||
|
||||
近期全球主要经济体的利率走势引起了市场的广泛关注。本文将分析当前利率环境,探讨未来可能的变化趋势,并为投资者提供相应的投资策略建议。
|
||||
|
||||
首先,我们来看一下美联储的货币政策走向。随着通胀压力的缓解,美联储已经开始考虑降息,但步伐可能会比较谨慎。欧洲央行和日本央行也在调整各自的货币政策,以适应经济形势的变化。
|
||||
|
||||
在中国,央行通过调整政策利率和LPR(贷款市场报价利率)来引导市场利率水平。近期LPR的下调反映了货币政策的宽松取向,这将有利于降低企业融资成本,刺激经济增长。
|
||||
|
||||
对于债券投资者而言,利率下行意味着债券价格上涨,这为债券投资提供了机会。但需要注意的是,不同期限的债券对利率变化的敏感度不同,投资者需要根据自己的风险承受能力选择合适的债券品种。
|
||||
|
||||
---
|
||||
title: 利率走势分析与投资策略
|
||||
---
|
||||
|
||||
近期全球主要经济体的利率走势引起了市场的广泛关注。本文将分析当前利率环境,探讨未来可能的变化趋势,并为投资者提供相应的投资策略建议。
|
||||
|
||||
首先,我们来看一下美联储的货币政策走向。随着通胀压力的缓解,美联储已经开始考虑降息,但步伐可能会比较谨慎。欧洲央行和日本央行也在调整各自的货币政策,以适应经济形势的变化。
|
||||
|
||||
在中国,央行通过调整政策利率和LPR(贷款市场报价利率)来引导市场利率水平。近期LPR的下调反映了货币政策的宽松取向,这将有利于降低企业融资成本,刺激经济增长。
|
||||
|
||||
对于债券投资者而言,利率下行意味着债券价格上涨,这为债券投资提供了机会。但需要注意的是,不同期限的债券对利率变化的敏感度不同,投资者需要根据自己的风险承受能力选择合适的债券品种。
|
||||
|
||||
对于股票投资者而言,利率下行通常有利于估值提升,特别是对成长型股票更为有利。但同时也需要关注经济基本面的变化,选择具有良好成长性和估值合理的股票。
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: 2026年中国经济展望
|
||||
---
|
||||
|
||||
2026年中国经济将面临诸多挑战与机遇。随着全球经济格局的变化,中国经济正在从高速增长向高质量发展转型。本文将分析当前经济形势,预测未来趋势,并提出相应的政策建议。
|
||||
|
||||
首先,我们来看一下当前的宏观经济指标。GDP增长率、通胀水平、失业率等关键指标都显示中国经济正在稳步复苏。然而,外部环境的不确定性仍然存在,需要密切关注。
|
||||
|
||||
在产业结构方面,数字经济、绿色经济等新兴产业正在成为经济增长的新动力。传统产业的转型升级也在加速,这将为经济可持续发展注入新的活力。
|
||||
|
||||
对于投资者而言,2026年的投资策略需要更加注重长期价值,关注科技创新、消费升级等领域的投资机会。同时,风险管理也变得尤为重要,需要建立多元化的投资组合。
|
||||
|
||||
---
|
||||
title: 2026年中国经济展望
|
||||
---
|
||||
|
||||
2026年中国经济将面临诸多挑战与机遇。随着全球经济格局的变化,中国经济正在从高速增长向高质量发展转型。本文将分析当前经济形势,预测未来趋势,并提出相应的政策建议。
|
||||
|
||||
首先,我们来看一下当前的宏观经济指标。GDP增长率、通胀水平、失业率等关键指标都显示中国经济正在稳步复苏。然而,外部环境的不确定性仍然存在,需要密切关注。
|
||||
|
||||
在产业结构方面,数字经济、绿色经济等新兴产业正在成为经济增长的新动力。传统产业的转型升级也在加速,这将为经济可持续发展注入新的活力。
|
||||
|
||||
对于投资者而言,2026年的投资策略需要更加注重长期价值,关注科技创新、消费升级等领域的投资机会。同时,风险管理也变得尤为重要,需要建立多元化的投资组合。
|
||||
|
||||
总的来说,2026年中国经济有望保持稳健增长,但需要应对来自内外部的各种挑战。通过深化改革、扩大开放,中国经济将迎来新的发展机遇。
|
||||
@@ -1,117 +1,117 @@
|
||||
# 城投债化债:机制、影响与市场展望
|
||||
|
||||
## 引言
|
||||
|
||||
城投债作为地方政府融资的重要工具,在过去几十年为我国基础设施建设和经济发展做出了重要贡献。然而,随着债务规模的不断扩大,城投债风险逐渐引起市场关注。近期,市场对城投债化债的讨论升温,本文将深入分析城投债化债的核心机制、市场影响以及未来走势。
|
||||
|
||||
## 化债的核心逻辑:高利率换低利率
|
||||
|
||||
### 化债不是还本金
|
||||
|
||||
市场对城投债化债存在一个常见误解,认为化债就是偿还本金。事实上,化债的核心逻辑并非偿还本金,而是通过债务重组和利率优化,降低债务成本和风险。
|
||||
|
||||
城投债的本质是地方政府的隐性债务,其规模庞大,短期内完全偿还本金并不现实。化债的目标是通过调整债务结构,将高成本债务置换为低成本债务,从而减轻地方政府的利息负担,为经济发展创造更有利的金融环境。
|
||||
|
||||
### 高利率向低利率的转化
|
||||
|
||||
过去几年,城投债发行利率普遍较高,部分地区甚至超过6%。高利率不仅增加了地方政府的偿债压力,也限制了地方财政的可持续性。化债的关键在于将这些高利率债务转换为低利率债务。
|
||||
|
||||
具体而言,化债通过以下方式实现利率优化:
|
||||
- **债务置换**:将高利率城投债置换为低利率的政府债券或银行贷款
|
||||
- **展期续作**:通过债务展期,延长还款期限,同时降低票面利率
|
||||
- **重组整合**:对城投平台进行整合,提高信用评级,从而降低融资成本
|
||||
|
||||
## 银行在化债中的角色:存款利率下调的逻辑
|
||||
|
||||
### 银行降低存款利率的必要性
|
||||
|
||||
为了支持城投债化债,银行体系需要提供低成本资金。降低存款利率成为银行吸储并提供低利率资金的重要手段。
|
||||
|
||||
银行降低存款利率主要基于以下考虑:
|
||||
1. **降低负债成本**:通过下调存款利率,银行可以降低资金成本,为后续的低利率贷款创造空间
|
||||
2. **引导资金流向**:存款利率下调后,部分资金可能流向债券市场,增加债券需求,从而降低债券发行利率
|
||||
3. **支持实体经济**:低利率环境有助于降低企业融资成本,支持经济复苏
|
||||
|
||||
### 低利率置换城投债的机制
|
||||
|
||||
银行通过以下步骤实现低利率置换城投债:
|
||||
|
||||
1. **吸收低成本存款**:通过降低存款利率,吸引储户存款,获取低成本资金
|
||||
2. **提供低利率贷款**:向城投平台提供低利率贷款,用于置换高利率债务
|
||||
3. **参与债务重组**:直接购买或承销城投债,为城投平台提供资金支持
|
||||
|
||||
这种机制不仅帮助城投平台降低了融资成本,也为银行提供了相对安全的资产配置渠道,实现了双赢。
|
||||
|
||||
## 市场影响:中期低利率环境的形成
|
||||
|
||||
### 化债目标下的利率政策
|
||||
|
||||
为了实现城投债化债的目标,货币政策需要维持宽松,市场中期将保持低利率或利率下行趋势。
|
||||
|
||||
从政策层面看,央行通过以下措施引导利率下行:
|
||||
- **降准降息**:通过降低存款准备金率和政策利率,向市场释放流动性
|
||||
- **结构性货币政策**:通过再贷款、再贴现等工具,定向支持城投平台等重点领域
|
||||
- **预期管理**:通过政策沟通,引导市场形成利率下行的预期
|
||||
|
||||
### 对不同市场主体的影响
|
||||
|
||||
#### 对城投平台的影响
|
||||
- **融资成本下降**:直接减轻利息负担,改善财务状况
|
||||
- **再融资环境改善**:低利率环境有助于城投债的顺利发行
|
||||
- **债务可持续性增强**:降低了债务违约风险,提高了信用评级
|
||||
|
||||
#### 对银行的影响
|
||||
- **利差收窄**:存款利率下调的同时,贷款利率也会下降,可能压缩银行利差
|
||||
- **资产质量改善**:城投债风险降低,银行资产质量得到提升
|
||||
- **业务结构调整**:需要寻找新的利润增长点,如中间业务收入
|
||||
|
||||
#### 对投资者的影响
|
||||
- **固定收益产品收益下降**:存款、债券等固定收益产品的收益率可能持续走低
|
||||
- **风险偏好变化**:部分投资者可能转向权益市场寻求更高收益
|
||||
- **资产配置调整**:需要重新平衡资产配置,寻找风险与收益的平衡点
|
||||
|
||||
## 市场展望与投资策略
|
||||
|
||||
### 短期展望(1-2年)
|
||||
|
||||
短期内,为了确保城投债化债的顺利推进,货币政策将保持宽松,市场利率有望继续下行。城投债违约风险将显著降低,信用利差可能收窄。
|
||||
|
||||
### 中期展望(2-5年)
|
||||
|
||||
中期来看,随着化债工作的推进,城投债风险将进一步降低,但市场利率可能维持在较低水平。银行体系的资产负债结构将发生调整,金融市场的风险偏好可能逐步回升。
|
||||
|
||||
### 长期展望(5年以上)
|
||||
|
||||
长期而言,城投债化债完成后,地方政府的债务结构将更加合理,财政可持续性将得到提升。市场利率可能回归正常水平,但仍将保持相对稳定。
|
||||
|
||||
### 投资策略建议
|
||||
|
||||
针对当前的市场环境,投资者可以考虑以下策略:
|
||||
|
||||
1. **固定收益投资**:
|
||||
- 关注高评级城投债,受益于信用利差收窄
|
||||
- 适当配置利率债,受益于利率下行
|
||||
- 谨慎对待低评级城投债,仍需关注个体风险
|
||||
|
||||
2. **权益投资**:
|
||||
- 关注受益于低利率环境的行业,如房地产、公用事业等
|
||||
- 关注地方政府投资重点领域,如基础设施、新能源等
|
||||
- 关注银行板块,受益于资产质量改善
|
||||
|
||||
3. **资产配置**:
|
||||
- 保持多元化配置,分散风险
|
||||
- 根据自身风险承受能力,调整股债比例
|
||||
- 密切关注政策变化,及时调整投资策略
|
||||
|
||||
## 结论
|
||||
|
||||
城投债化债是一个长期而复杂的过程,其核心逻辑是通过高利率换低利率来降低债务成本和风险。银行体系通过降低存款利率,为化债提供低成本资金支持。为了实现化债目标,市场中期将维持低利率或利率下行趋势。
|
||||
|
||||
对于投资者而言,需要认识到化债过程中的投资机会和风险,制定相应的投资策略。同时,也要看到化债对经济长期发展的积极意义,为市场的稳定和繁荣创造有利条件。
|
||||
|
||||
## 参考文献
|
||||
|
||||
1. 《中国城投债市场发展报告》
|
||||
2. 《地方政府债务风险防控研究》
|
||||
3. 央行货币政策执行报告
|
||||
4. 财政部关于地方政府债务管理的相关文件
|
||||
# 城投债化债:机制、影响与市场展望
|
||||
|
||||
## 引言
|
||||
|
||||
城投债作为地方政府融资的重要工具,在过去几十年为我国基础设施建设和经济发展做出了重要贡献。然而,随着债务规模的不断扩大,城投债风险逐渐引起市场关注。近期,市场对城投债化债的讨论升温,本文将深入分析城投债化债的核心机制、市场影响以及未来走势。
|
||||
|
||||
## 化债的核心逻辑:高利率换低利率
|
||||
|
||||
### 化债不是还本金
|
||||
|
||||
市场对城投债化债存在一个常见误解,认为化债就是偿还本金。事实上,化债的核心逻辑并非偿还本金,而是通过债务重组和利率优化,降低债务成本和风险。
|
||||
|
||||
城投债的本质是地方政府的隐性债务,其规模庞大,短期内完全偿还本金并不现实。化债的目标是通过调整债务结构,将高成本债务置换为低成本债务,从而减轻地方政府的利息负担,为经济发展创造更有利的金融环境。
|
||||
|
||||
### 高利率向低利率的转化
|
||||
|
||||
过去几年,城投债发行利率普遍较高,部分地区甚至超过6%。高利率不仅增加了地方政府的偿债压力,也限制了地方财政的可持续性。化债的关键在于将这些高利率债务转换为低利率债务。
|
||||
|
||||
具体而言,化债通过以下方式实现利率优化:
|
||||
- **债务置换**:将高利率城投债置换为低利率的政府债券或银行贷款
|
||||
- **展期续作**:通过债务展期,延长还款期限,同时降低票面利率
|
||||
- **重组整合**:对城投平台进行整合,提高信用评级,从而降低融资成本
|
||||
|
||||
## 银行在化债中的角色:存款利率下调的逻辑
|
||||
|
||||
### 银行降低存款利率的必要性
|
||||
|
||||
为了支持城投债化债,银行体系需要提供低成本资金。降低存款利率成为银行吸储并提供低利率资金的重要手段。
|
||||
|
||||
银行降低存款利率主要基于以下考虑:
|
||||
1. **降低负债成本**:通过下调存款利率,银行可以降低资金成本,为后续的低利率贷款创造空间
|
||||
2. **引导资金流向**:存款利率下调后,部分资金可能流向债券市场,增加债券需求,从而降低债券发行利率
|
||||
3. **支持实体经济**:低利率环境有助于降低企业融资成本,支持经济复苏
|
||||
|
||||
### 低利率置换城投债的机制
|
||||
|
||||
银行通过以下步骤实现低利率置换城投债:
|
||||
|
||||
1. **吸收低成本存款**:通过降低存款利率,吸引储户存款,获取低成本资金
|
||||
2. **提供低利率贷款**:向城投平台提供低利率贷款,用于置换高利率债务
|
||||
3. **参与债务重组**:直接购买或承销城投债,为城投平台提供资金支持
|
||||
|
||||
这种机制不仅帮助城投平台降低了融资成本,也为银行提供了相对安全的资产配置渠道,实现了双赢。
|
||||
|
||||
## 市场影响:中期低利率环境的形成
|
||||
|
||||
### 化债目标下的利率政策
|
||||
|
||||
为了实现城投债化债的目标,货币政策需要维持宽松,市场中期将保持低利率或利率下行趋势。
|
||||
|
||||
从政策层面看,央行通过以下措施引导利率下行:
|
||||
- **降准降息**:通过降低存款准备金率和政策利率,向市场释放流动性
|
||||
- **结构性货币政策**:通过再贷款、再贴现等工具,定向支持城投平台等重点领域
|
||||
- **预期管理**:通过政策沟通,引导市场形成利率下行的预期
|
||||
|
||||
### 对不同市场主体的影响
|
||||
|
||||
#### 对城投平台的影响
|
||||
- **融资成本下降**:直接减轻利息负担,改善财务状况
|
||||
- **再融资环境改善**:低利率环境有助于城投债的顺利发行
|
||||
- **债务可持续性增强**:降低了债务违约风险,提高了信用评级
|
||||
|
||||
#### 对银行的影响
|
||||
- **利差收窄**:存款利率下调的同时,贷款利率也会下降,可能压缩银行利差
|
||||
- **资产质量改善**:城投债风险降低,银行资产质量得到提升
|
||||
- **业务结构调整**:需要寻找新的利润增长点,如中间业务收入
|
||||
|
||||
#### 对投资者的影响
|
||||
- **固定收益产品收益下降**:存款、债券等固定收益产品的收益率可能持续走低
|
||||
- **风险偏好变化**:部分投资者可能转向权益市场寻求更高收益
|
||||
- **资产配置调整**:需要重新平衡资产配置,寻找风险与收益的平衡点
|
||||
|
||||
## 市场展望与投资策略
|
||||
|
||||
### 短期展望(1-2年)
|
||||
|
||||
短期内,为了确保城投债化债的顺利推进,货币政策将保持宽松,市场利率有望继续下行。城投债违约风险将显著降低,信用利差可能收窄。
|
||||
|
||||
### 中期展望(2-5年)
|
||||
|
||||
中期来看,随着化债工作的推进,城投债风险将进一步降低,但市场利率可能维持在较低水平。银行体系的资产负债结构将发生调整,金融市场的风险偏好可能逐步回升。
|
||||
|
||||
### 长期展望(5年以上)
|
||||
|
||||
长期而言,城投债化债完成后,地方政府的债务结构将更加合理,财政可持续性将得到提升。市场利率可能回归正常水平,但仍将保持相对稳定。
|
||||
|
||||
### 投资策略建议
|
||||
|
||||
针对当前的市场环境,投资者可以考虑以下策略:
|
||||
|
||||
1. **固定收益投资**:
|
||||
- 关注高评级城投债,受益于信用利差收窄
|
||||
- 适当配置利率债,受益于利率下行
|
||||
- 谨慎对待低评级城投债,仍需关注个体风险
|
||||
|
||||
2. **权益投资**:
|
||||
- 关注受益于低利率环境的行业,如房地产、公用事业等
|
||||
- 关注地方政府投资重点领域,如基础设施、新能源等
|
||||
- 关注银行板块,受益于资产质量改善
|
||||
|
||||
3. **资产配置**:
|
||||
- 保持多元化配置,分散风险
|
||||
- 根据自身风险承受能力,调整股债比例
|
||||
- 密切关注政策变化,及时调整投资策略
|
||||
|
||||
## 结论
|
||||
|
||||
城投债化债是一个长期而复杂的过程,其核心逻辑是通过高利率换低利率来降低债务成本和风险。银行体系通过降低存款利率,为化债提供低成本资金支持。为了实现化债目标,市场中期将维持低利率或利率下行趋势。
|
||||
|
||||
对于投资者而言,需要认识到化债过程中的投资机会和风险,制定相应的投资策略。同时,也要看到化债对经济长期发展的积极意义,为市场的稳定和繁荣创造有利条件。
|
||||
|
||||
## 参考文献
|
||||
|
||||
1. 《中国城投债市场发展报告》
|
||||
2. 《地方政府债务风险防控研究》
|
||||
3. 央行货币政策执行报告
|
||||
4. 财政部关于地方政府债务管理的相关文件
|
||||
|
||||
@@ -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 有逆向基础、炎米零资料)。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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,38 +249,57 @@ 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
|
||||
}
|
||||
|
||||
export type IndicatorWhereUniqueInput = Prisma.AtLeast<{
|
||||
id?: number
|
||||
name?: string
|
||||
AND?: Prisma.IndicatorWhereInput | Prisma.IndicatorWhereInput[]
|
||||
OR?: Prisma.IndicatorWhereInput[]
|
||||
NOT?: Prisma.IndicatorWhereInput | Prisma.IndicatorWhereInput[]
|
||||
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
|
||||
}, "id">
|
||||
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
@@ -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
+14
@@ -0,0 +1,14 @@
|
||||
<!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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,14 +0,0 @@
|
||||
import { PrismaClient } from '../generated/prisma'
|
||||
|
||||
let prisma
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient()
|
||||
} else {
|
||||
if (!global.prisma) {
|
||||
global.prisma = new PrismaClient()
|
||||
}
|
||||
prisma = global.prisma
|
||||
}
|
||||
|
||||
export default prisma
|
||||
@@ -0,0 +1,8 @@
|
||||
# ota-package/ 目录占位文件
|
||||
|
||||
本目录是 OTA 升级包输出目录(git 忽略):
|
||||
|
||||
- **CI(docker-build.yml)**:每次主分支提交运行 `scripts/build-ota.mjs` 生成 `iboard-ota.tar.gz` + `latest.json` + `*.minisig`,随 Docker 镜像发布到容器内 `OTA_PACKAGE_DIR=/app/ota-package`。
|
||||
- **本地手动构建**:只有本占位文件,Dockerfile 的 `COPY ota-package ./ota-package` 不会失败;容器内 `readLatest()` 找不到 `latest.json` 时 OTA 检查返回"无更新包"。
|
||||
|
||||
本文件保留在 git 中是为了让 `COPY` 在本地构建时不报错;真实 OTA 产物(含私钥 hotswap.key)永不提交。
|
||||
Generated
+4612
-419
File diff suppressed because it is too large
Load Diff
+47
-16
@@ -1,33 +1,64 @@
|
||||
{
|
||||
"name": "iboard",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"tauri": "tauri",
|
||||
"dev:web": "vite",
|
||||
"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",
|
||||
"tauri:android:init": "tauri android init",
|
||||
"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",
|
||||
"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.7.0",
|
||||
"@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",
|
||||
"mariadb": "^3.5.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mariadb": "^3.5.3",
|
||||
"marked": "^17.0.6",
|
||||
"next": "^14.2.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"recharts": "^3.8.1"
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tauri-plugin-hotswap-api": "^0.0.4",
|
||||
"ws": "^8.21.2"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"csv-parser": "^3.2.0"
|
||||
"concurrently": "^9.0.0",
|
||||
"csv-parser": "^3.2.0",
|
||||
"sharp": "^0.33.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import '../styles/globals.css'
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
@@ -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;
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,71 +0,0 @@
|
||||
import Head from 'next/head'
|
||||
import Navbar from '../../components/Navbar'
|
||||
import Footer from '../../components/Footer'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import matter from 'gray-matter'
|
||||
import { marked } from 'marked'
|
||||
|
||||
export default function Article({ article }) {
|
||||
return (
|
||||
<div className="container">
|
||||
<Head>
|
||||
<title>{article.title} - iBoard</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main>
|
||||
<div className="article-detail">
|
||||
<h1 className="article-detail-title">{article.title}</h1>
|
||||
<div className="article-detail-date">{article.date}</div>
|
||||
<div className="article-detail-content" dangerouslySetInnerHTML={{ __html: article.contentHtml }} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const articlesDirectory = path.join(process.cwd(), 'content', 'articles')
|
||||
const fileNames = fs.readdirSync(articlesDirectory)
|
||||
|
||||
const paths = fileNames
|
||||
.filter(fileName => fileName.endsWith('.md'))
|
||||
.map(fileName => ({
|
||||
params: { slug: fileName.replace('.md', '') }
|
||||
}))
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: false
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }) {
|
||||
const articlesDirectory = path.join(process.cwd(), 'content', 'articles')
|
||||
const fullPath = path.join(articlesDirectory, `${params.slug}.md`)
|
||||
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
||||
const { data, content } = matter(fileContents)
|
||||
|
||||
// 从文件名中提取日期
|
||||
const dateMatch = params.slug.match(/^(\d{4}-\d{2}-\d{2})-/)
|
||||
const date = dateMatch ? dateMatch[1] : ''
|
||||
|
||||
// 将markdown转换为HTML
|
||||
const contentHtml = marked(content)
|
||||
|
||||
return {
|
||||
props: {
|
||||
article: {
|
||||
id: params.slug,
|
||||
title: data.title || params.slug.replace(/\d{4}-\d{2}-\d{2}-/, ''),
|
||||
date,
|
||||
contentHtml
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-721
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { getPagedArticles } from '../utils/articleUtils'
|
||||
|
||||
export default function Home({ articles, totalPages, currentPage }) {
|
||||
return (
|
||||
<div className="container">
|
||||
<Head>
|
||||
<title>iBoard - 经济数据与理论</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main>
|
||||
<h1 className="title">
|
||||
欢迎来到 iBoard
|
||||
</h1>
|
||||
|
||||
<p className="description">
|
||||
收集和整理经济数据,以策略为主体重新组织。
|
||||
</p>
|
||||
|
||||
<div className="grid">
|
||||
<div className="card">
|
||||
<a href="#articles-section">
|
||||
<h2>经济文章</h2>
|
||||
</a>
|
||||
<p>最新的经济分析和研究文章</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<Link href="/data">
|
||||
<h2>经济数据</h2>
|
||||
</Link>
|
||||
<p>深入分析经济数据和趋势</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<Link href="/strategy">
|
||||
<h2>策略研究</h2>
|
||||
</Link>
|
||||
<p>基于经济理论的数据分析框架</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="articles-section" className="articles-section">
|
||||
|
||||
<div className="articles-grid">
|
||||
{articles.map((article) => (
|
||||
<Link key={article.id} href={`/articles/${article.id}`} className="article-card">
|
||||
<div className="article-date">{article.date}</div>
|
||||
<h3 className="article-title">{article.title}</h3>
|
||||
<p className="article-excerpt">{article.excerpt}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
{Array.from({ length: totalPages }, (_, index) => (
|
||||
<Link key={index + 1} href={`/?page=${index + 1}`} className={`page-link ${currentPage === index + 1 ? 'active' : ''}`}>
|
||||
{index + 1}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getServerSideProps({ query }) {
|
||||
const page = parseInt(query.page) || 1
|
||||
const { articles, total, totalPages } = getPagedArticles(page, 5)
|
||||
|
||||
return {
|
||||
props: {
|
||||
articles,
|
||||
total,
|
||||
totalPages,
|
||||
currentPage: page
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+14
-14
@@ -1,14 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `User` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`password` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `User_email_key`(`email`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Article` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`slug` VARCHAR(191) NOT NULL,
|
||||
`content` VARCHAR(191) NOT NULL,
|
||||
`date` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Article_slug_key`(`slug`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Strategy` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(191) NOT NULL,
|
||||
`data` JSON NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Indicator` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`groupName` VARCHAR(191) NOT NULL,
|
||||
`data` JSON NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `SowInventory` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`month` VARCHAR(191) NOT NULL,
|
||||
`inventory` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `SowInventory_month_key`(`month`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
-- CreateTable
|
||||
CREATE TABLE `User` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`password` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `User_email_key`(`email`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Article` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`slug` VARCHAR(191) NOT NULL,
|
||||
`content` VARCHAR(191) NOT NULL,
|
||||
`date` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Article_slug_key`(`slug`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Strategy` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(191) NOT NULL,
|
||||
`data` JSON NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Indicator` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`groupName` VARCHAR(191) NOT NULL,
|
||||
`data` JSON NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `SowInventory` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`month` VARCHAR(191) NOT NULL,
|
||||
`inventory` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `SowInventory_month_key`(`month`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `SowInventory` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE `SowInventory`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Econ_SowInventory` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`month` VARCHAR(191) NOT NULL,
|
||||
`inventory` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Econ_SowInventory_month_key`(`month`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `SowInventory` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE `SowInventory`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Econ_SowInventory` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`month` VARCHAR(191) NOT NULL,
|
||||
`inventory` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Econ_SowInventory_month_key`(`month`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add unique constraint on Strategy.name to enable idempotent upsert in seed script
|
||||
ALTER TABLE `Strategy` ADD UNIQUE INDEX `Strategy_name_key`(`name`);
|
||||
|
||||
-- Add unique constraint on Indicator.name to enable idempotent upsert in seed script
|
||||
ALTER TABLE `Indicator` ADD UNIQUE INDEX `Indicator_name_key`(`name`);
|
||||
@@ -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;
|
||||
|
||||
+220
-4
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 233 B |
@@ -1,19 +1,131 @@
|
||||
# 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` 为运行时产物,不纳入版本管理。
|
||||
|
||||
- 首页主要是放经济文章
|
||||
- 数据主要是放各种经济数据和图表。
|
||||
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
||||
|
||||
|
||||
## 爬虫
|
||||
- 基于 bun 的定时任务
|
||||
- 统一获取接口
|
||||
- 统一爬取接口
|
||||
```
|
||||
async function fetch(startDate = null, endDate = null)
|
||||
async function trigger(startDate = null, endDate = null)
|
||||
```
|
||||
不传递startDate和endDate,默认,默认获取最近30天的数据。
|
||||
|
||||
fecher 负责从目标网站获取原始数据
|
||||
scraper 负责检查数据库中的数据是否完整,调用 fecher 将数据存储到数据库。
|
||||
## 首页
|
||||
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
|
||||
|
||||
|
||||
### 新闻模块
|
||||
增加一个新闻模块,数据来源是每日夜间 3点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序10条。
|
||||
存入数据库。
|
||||
|
||||
|
||||
### 报警模块
|
||||
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
|
||||
|
||||
## 监控
|
||||
监控分为服务器监控和服务监控两部分。
|
||||
服务器监控对接 komari,地址:https://komari.honor3.com/
|
||||
|
||||
|
||||
|
||||
## 前端 OTA 升级
|
||||
|
||||
只升级前端资源 (`dist/`), **不触碰** Tauri 框架/原生层, 不重打 APK.
|
||||
底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap)
|
||||
(签名/回滚/资源替换全部由插件处理).
|
||||
|
||||
### 工作流
|
||||
|
||||
```
|
||||
开发者 服务端
|
||||
npm run build ──┐
|
||||
scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
|
||||
│ 输出 latest.json (sequence/version/...)
|
||||
▼
|
||||
ota-package/ ── 部署到服务器 ──▶ /srv/iboard/ota-package
|
||||
│
|
||||
▼
|
||||
GET /api/ota/check/<seq>
|
||||
客户端 (Tauri) │
|
||||
启动 ─▶ notifyReady() ▼
|
||||
我页 ─▶ checkUpdate() ◀──── 200 + manifest (含 url/signature) ─────┘
|
||||
applyUpdate() ─▶ 下载 tar.gz ─▶ minisign 校验 ─▶ 解压到 AppData/ota-dist
|
||||
location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包
|
||||
```
|
||||
|
||||
### 首次部署
|
||||
|
||||
1. **生成签名密钥** (一次性):
|
||||
```bash
|
||||
# 安装 minisign: Windows (scoop) / macOS (brew) / Linux (apt)
|
||||
node scripts/ota-genkey.mjs
|
||||
# 产物: ota-package/hotswap.{key, pub}
|
||||
```
|
||||
2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段
|
||||
(脚本会打印).
|
||||
3. **配置 endpoint** (`src-tauri/tauri.conf.json`):
|
||||
```json
|
||||
"hotswap": {
|
||||
"endpoint": "https://your-server/api/ota/check/{{current_sequence}}",
|
||||
"pubkey": "RWR...",
|
||||
"require_https": true
|
||||
}
|
||||
```
|
||||
4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录.
|
||||
|
||||
### 发布新版前端
|
||||
|
||||
```bash
|
||||
npm run build # 生成 dist/
|
||||
node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
|
||||
# sequence 默认 = Date.now(), 也可 --sequence 43 手动指定
|
||||
# 也可加 --notes "修复登录按钮" --min-binary 0.1.0
|
||||
```
|
||||
|
||||
然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到
|
||||
服务端 `OTA_PACKAGE_DIR` 指向的目录. sequence 单调递增即可.
|
||||
|
||||
### 客户端使用
|
||||
|
||||
打开 App 的"我"页, 点 [检查更新] → [立即升级] 即可. 失败会自动回滚到内置 dist.
|
||||
|
||||
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**.
|
||||
> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物.
|
||||
|
||||
### Web 端
|
||||
|
||||
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
|
||||
Web 端不会自动下载包, 也不会替换前端代码.
|
||||
|
||||
|
||||
### CI 自动签名 (Gitea Actions)
|
||||
|
||||
.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流 (每次发版前点一次).
|
||||
|
||||
**配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets):
|
||||
- MINISIGN_PRIVATE_KEY: 完整 .key 文件内容, **整段** (含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行), 整段作为多行 secret 粘贴即可.
|
||||
|
||||
**使用流程**:
|
||||
1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow
|
||||
2. 填 sequence (可选, 不填用时间戳) 和
|
||||
otes (可选)
|
||||
3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig
|
||||
4. 下载 artifact, scp 到服务器 OTA_PACKAGE_DIR 目录
|
||||
5. 客户端"我"页 [检查更新] 即可触发升级
|
||||
|
||||
**注意**:
|
||||
- CI 用的私钥必须**与** auri.conf.json 里 plugins.hotswap.pubkey **配对**; 换密钥时需要同时更新 CI secret 和 conf.
|
||||
- 不要把私钥 commit 到 git, 整个 ota-package/hotswap.key 已被 .gitignore 排除.
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
|
||||
// 用法:
|
||||
// node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
|
||||
// 流程:
|
||||
// 1) 校验 dist/ 已构建
|
||||
// 2) 生成 bundle.tar.gz (dist/ 内容)
|
||||
// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
|
||||
// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
|
||||
// 5) 全部产物输出到 out 目录 (默认 ota-package/)
|
||||
//
|
||||
// minisign 安装: 见 readme "OTA 升级" 章节
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function getArg(name, def) {
|
||||
const i = args.indexOf(name)
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : def
|
||||
}
|
||||
const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
|
||||
const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
|
||||
const MIN_BINARY = getArg('--min-binary', '')
|
||||
const NOTES = getArg('--notes', '')
|
||||
const NO_SIGN = args.includes('--no-sign')
|
||||
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
|
||||
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
|
||||
const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
|
||||
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
|
||||
const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
|
||||
const APP_VERSION = pkg.version || '0.0.0'
|
||||
|
||||
const DIST = path.join(ROOT, 'dist')
|
||||
if (!fs.existsSync(path.join(DIST, 'index.html'))) {
|
||||
console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 准备输出目录
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
|
||||
// 1) 生成 bundle.tar.gz
|
||||
const BUNDLE_NAME = 'iboard-ota.tar.gz'
|
||||
const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME)
|
||||
const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' })
|
||||
if (r.status !== 0) {
|
||||
console.error('[build-ota] tar 打包失败')
|
||||
process.exit(1)
|
||||
}
|
||||
const bundleStat = fs.statSync(BUNDLE_PATH)
|
||||
const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex')
|
||||
|
||||
// 2) 签名 (minisign)
|
||||
let signature = ''
|
||||
let pubkey = ''
|
||||
if (!NO_SIGN) {
|
||||
// CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
|
||||
let KEY_PATH_TMP = null
|
||||
let KEY_PATH_USE = KEY_PATH
|
||||
if (KEY_FROM_ENV) {
|
||||
KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
|
||||
fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
|
||||
KEY_PATH_USE = KEY_PATH_TMP
|
||||
}
|
||||
if (!fs.existsSync(KEY_PATH_USE)) {
|
||||
console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
|
||||
console.error(' 注入方式二选一:')
|
||||
console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
|
||||
console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key <path>')
|
||||
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
|
||||
process.exit(1)
|
||||
}
|
||||
// minisign -Sm <file> -s <key> 生成 detached signature
|
||||
const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE]
|
||||
const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' })
|
||||
// 清理 CI 临时私钥 (无论成功失败都删)
|
||||
if (KEY_PATH_TMP) {
|
||||
try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
|
||||
KEY_PATH_TMP = null
|
||||
}
|
||||
if (sigR.status !== 0) {
|
||||
console.error('[build-ota] minisign 签名失败')
|
||||
process.exit(1)
|
||||
}
|
||||
const sigPath = BUNDLE_PATH + '.minisig'
|
||||
signature = fs.readFileSync(sigPath, 'utf8').trim()
|
||||
|
||||
// 尝试同步 pubkey 到 tauri.conf.json (如果存在)
|
||||
const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
|
||||
if (fs.existsSync(PUB_PATH)) {
|
||||
pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
|
||||
const lines = pubkey.split(/\r?\n/)
|
||||
pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 写 latest.json
|
||||
const minBinary = MIN_BINARY || (() => {
|
||||
// 默认从 src-tauri/tauri.conf.json 读 version
|
||||
try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION }
|
||||
catch { return APP_VERSION }
|
||||
})()
|
||||
const manifest = {
|
||||
version: APP_VERSION + '-ota',
|
||||
sequence: SEQUENCE,
|
||||
min_binary_version: minBinary,
|
||||
bundle: BUNDLE_NAME,
|
||||
bundle_size: bundleStat.size,
|
||||
bundle_sha256: bundleSha256,
|
||||
signature,
|
||||
notes: NOTES,
|
||||
pub_date: new Date().toISOString(),
|
||||
mandatory: false,
|
||||
// 内部用: 前端 web 模式比对 build_id
|
||||
build_id: 'seq-' + SEQUENCE,
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
|
||||
|
||||
// 4) 提示
|
||||
console.log('')
|
||||
console.log('=== OTA 包生成完成 ===')
|
||||
console.log('输出目录: ', OUT_DIR)
|
||||
console.log('版本: ', manifest.version)
|
||||
console.log('sequence: ', manifest.sequence)
|
||||
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
|
||||
console.log('bundle sha256:', bundleSha256)
|
||||
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
|
||||
if (pubkey) {
|
||||
console.log('')
|
||||
console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
|
||||
console.log(' ' + pubkey)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('下一步: 把这两个文件发布到 OTA 存储:')
|
||||
console.log(' ' + BUNDLE_NAME)
|
||||
console.log(' latest.json')
|
||||
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI 专用: 为 Tauri Android release 构建注入 release 签名配置
|
||||
# 在 `tauri android init` 之后、`tauri android build` 之前运行。
|
||||
#
|
||||
# 依赖 (由调用方注入环境变量, 不要写死密钥到脚本/仓库):
|
||||
# ANDROID_KEYSTORE_B64 - base64 编码的 keystore 文件内容 (CI secret)
|
||||
# ANDROID_KEY_ALIAS - 密钥别名 (CI secret/variable)
|
||||
# ANDROID_KEY_PASSWORD - store & key 密码 (CI secret; 本脚本两者用同一个)
|
||||
#
|
||||
# 作用:
|
||||
# 1. 把 base64 密钥解码写到 $RUNNER_TEMP/iboard-release.keystore
|
||||
# 2. 生成 gen/android/keystore.properties (Tauri 签名约定读取此文件)
|
||||
# 3. 修改 gen/android/app/build.gradle.kts, 添加 signingConfigs.release
|
||||
# 并让 buildTypes.release 使用它, 产出已签名 APK
|
||||
set -euo pipefail
|
||||
|
||||
APP_GRADLE="src-tauri/gen/android/app/build.gradle.kts"
|
||||
|
||||
echo "[signing] 注入 Android release 签名配置..."
|
||||
|
||||
# --- 1. 解码 keystore ---
|
||||
if [ -z "${ANDROID_KEYSTORE_B64:-}" ]; then
|
||||
echo "[signing] ERROR: ANDROID_KEYSTORE_B64 未设置, 无法签名 release APK" >&2
|
||||
exit 1
|
||||
fi
|
||||
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS 未设置}"
|
||||
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD 未设置}"
|
||||
|
||||
KEYSTORE_FILE="${RUNNER_TEMP:-/tmp}/iboard-release.keystore"
|
||||
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "$KEYSTORE_FILE"
|
||||
|
||||
# --- 2. 写 keystore.properties (Tauri 官方约定的签名配置文件位置) ---
|
||||
# 注意: 不能写进 git, 这是运行时生成物
|
||||
KEYSTORE_PROPERTIES="src-tauri/gen/android/keystore.properties"
|
||||
cat > "$KEYSTORE_PROPERTIES" <<EOF
|
||||
keyAlias=${ANDROID_KEY_ALIAS}
|
||||
password=${ANDROID_KEY_PASSWORD}
|
||||
storeFile=${KEYSTORE_FILE}
|
||||
EOF
|
||||
|
||||
# --- 3. 修改 build.gradle.kts 注入 signingConfigs ---
|
||||
if [ ! -f "$APP_GRADLE" ]; then
|
||||
echo "[signing] ERROR: 找不到 $APP_GRADLE (请先运行 tauri android init)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 幂等: 已注入则跳过
|
||||
if grep -q 'create("release")' "$APP_GRADLE" 2>/dev/null; then
|
||||
echo "[signing] build.gradle.kts 已有 release signingConfigs, 跳过注入"
|
||||
else
|
||||
# 在 android { 块内 buildTypes { 之前插入 signingConfigs 块
|
||||
# 用 Python 处理 Kotlin DSL, 对模板结构做最小侵入
|
||||
python3 - "$APP_GRADLE" <<'PYEOF'
|
||||
import sys, re
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path, encoding='utf-8').read()
|
||||
|
||||
# Gradle Kotlin DSL 默认 import java.util.* (Properties 短名可用), 但没有 java.io.*
|
||||
# 因此顶部需补 import java.io.FileInputStream, 注入块内全部用短名
|
||||
if 'import java.io.FileInputStream' not in src:
|
||||
# 在第一个 import 之后插入; 若无 import 则插到文件顶部
|
||||
imp = re.search(r'^import .*$', src, flags=re.M)
|
||||
new_import = 'import java.io.FileInputStream\n'
|
||||
if imp:
|
||||
src = src[:imp.end()] + '\n' + new_import + src[imp.end():]
|
||||
else:
|
||||
src = new_import + src
|
||||
|
||||
# 定位 android { 块内的 buildTypes { (注意 Kotlin 嵌套花括号)
|
||||
m = re.search(r'(\n[ \t]*)(buildTypes\s*\{)', src)
|
||||
if not m:
|
||||
print('[signing] 未找到 buildTypes 块, 中止', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
indent = m.group(1) # 含换行, e.g. "\n "
|
||||
inner_indent = indent + ' '
|
||||
|
||||
signing_block = (
|
||||
f'{inner_indent}signingConfigs {{\n'
|
||||
f'{inner_indent} create("release") {{\n'
|
||||
f'{inner_indent} val keystorePropertiesFile = rootProject.file("keystore.properties")\n'
|
||||
f'{inner_indent} val keystoreProperties = Properties()\n'
|
||||
f'{inner_indent} if (keystorePropertiesFile.exists()) {{\n'
|
||||
f'{inner_indent} keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n'
|
||||
f'{inner_indent} }}\n'
|
||||
f'{inner_indent} keyAlias = keystoreProperties["keyAlias"] as? String ?: ""\n'
|
||||
f'{inner_indent} keyPassword = keystoreProperties["password"] as? String ?: ""\n'
|
||||
f'{inner_indent} storeFile = file(keystoreProperties["storeFile"] as? String ?: "")\n'
|
||||
f'{inner_indent} storePassword = keystoreProperties["password"] as? String ?: ""\n'
|
||||
f'{inner_indent} }}\n'
|
||||
f'{inner_indent}}}\n'
|
||||
)
|
||||
|
||||
# 在 buildTypes { 前插入 signingConfigs 块
|
||||
src = src.replace(m.group(0), signing_block + m.group(0), 1)
|
||||
|
||||
# 让 buildTypes 里的 release 使用该 signingConfig
|
||||
rel = re.search(r'(\n[ \t]*)(getByName\("release"\)\s*\{)', src)
|
||||
if not rel:
|
||||
rel = re.search(r'(\n[ \t]*)(release\s*\{)', src)
|
||||
if rel:
|
||||
rel_indent = rel.group(1) + ' '
|
||||
inject = rel_indent + 'signingConfig = signingConfigs.getByName("release")\n'
|
||||
src = src.replace(rel.group(0), rel.group(0) + inject, 1)
|
||||
|
||||
open(path, 'w', encoding='utf-8').write(src)
|
||||
print('[signing] build.gradle.kts 已注入 import + signingConfigs + release signingConfig')
|
||||
PYEOF
|
||||
fi
|
||||
|
||||
# --- 4. 放行明文 HTTP (release APK 默认拦截 http://, 而服务器仅支持 HTTP) ---
|
||||
# Tauri 模板默认 debug=true, release=false。Android 9+ 会拦截 release 的所有 http:// 明文请求
|
||||
# 导致 Failed to fetch。这里把 release 的 usesCleartextTraffic 改为 "true"。
|
||||
# 幂等: 第二次运行时模板已被改为 "true", 替换无 false 可改, 也不会重复注入。
|
||||
if [ ! -f "$APP_GRADLE" ]; then
|
||||
echo "[signing] ERROR: 找不到 $APP_GRADLE (明文放行跳过)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$APP_GRADLE" <<'PYEOF'
|
||||
import sys, re
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path, encoding='utf-8').read()
|
||||
|
||||
# 1) 把显式的 usesCleartextTraffic = "false" 全部改为 "true"
|
||||
new = re.sub(
|
||||
r'(manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*)"false"',
|
||||
r'\1"true"',
|
||||
src,
|
||||
)
|
||||
|
||||
# 2) 若模板根本没有 usesCleartextTraffic 配置, 则在 release 构建块内注入一行 true
|
||||
if 'usesCleartextTraffic' not in src:
|
||||
m = re.search(r'^([ \t]*)getByName\("release"\)\s*\{', new, flags=re.M)
|
||||
if not m:
|
||||
m = re.search(r'^([ \t]*)release\s*\{', new, flags=re.M)
|
||||
if m:
|
||||
inner = m.group(1) + ' '
|
||||
inject = f'\n{inner}manifestPlaceholders["usesCleartextTraffic"] = "true"'
|
||||
new = new.replace(m.group(0), m.group(0) + inject, 1)
|
||||
else:
|
||||
print('[signing] 警告: 未找到 release 构建块, 明文放行注入跳过', file=sys.stderr)
|
||||
|
||||
open(path, 'w', encoding='utf-8').write(new)
|
||||
print('[signing] usesCleartextTraffic 已设为 true (release 放行明文 HTTP)')
|
||||
PYEOF
|
||||
|
||||
echo "[signing] 完成: keystore.properties 已写, build.gradle.kts 已注入"
|
||||
@@ -0,0 +1,17 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
(async () => {
|
||||
const u = new URL(process.env.DATABASE_URL);
|
||||
const c = await mysql.createConnection({
|
||||
host: u.hostname, port: Number(u.port),
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
});
|
||||
// 保留 id 最小的, 删掉其余重名行
|
||||
for (const t of ['Strategy', 'Indicator']) {
|
||||
const [r] = await c.query('DELETE FROM ' + t + ' WHERE id NOT IN (SELECT min_id FROM (SELECT MIN(id) AS min_id FROM ' + t + ' GROUP BY name) AS keep)');
|
||||
console.log('[' + t + '] 删除 ' + r.affectedRows + ' 条重复行');
|
||||
}
|
||||
await c.end();
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -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)
|
||||
@@ -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) })
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// 从 public/favicon.svg 派生 Tauri 需要的各尺寸 PNG 图标
|
||||
// 用法: node scripts/generate-icons.mjs
|
||||
// 一次性脚本, 生成的图标随仓库提交, 不需要每次都跑
|
||||
import sharp from 'sharp'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const SVG = path.join(process.cwd(), 'public', 'favicon.svg')
|
||||
const OUT = path.join(process.cwd(), 'src-tauri', 'icons')
|
||||
fs.mkdirSync(OUT, { recursive: true })
|
||||
|
||||
const svg = fs.readFileSync(SVG)
|
||||
const base = sharp(svg)
|
||||
|
||||
// Tauri 桌面 + Android 需要的尺寸
|
||||
const TARGETS = [
|
||||
{ name: '32x32.png', size: 32 },
|
||||
{ name: '128x128.png', size: 128 },
|
||||
{ name: '128x128@2x.png', size: 256 },
|
||||
{ name: 'icon.png', size: 1024 },
|
||||
]
|
||||
|
||||
for (const t of TARGETS) {
|
||||
const buf = await base.clone().resize(t.size, t.size).png().toBuffer()
|
||||
fs.writeFileSync(path.join(OUT, t.name), buf)
|
||||
console.log('wrote', t.name, t.size + 'x' + t.size)
|
||||
}
|
||||
|
||||
// Windows .ico (单 256x256 frame, 简单够用)
|
||||
const ico = await sharp(svg).resize(256, 256).png().toBuffer()
|
||||
fs.writeFileSync(path.join(OUT, 'icon.ico'), ico)
|
||||
console.log('wrote icon.ico (256x256 PNG-as-ICO)')
|
||||
|
||||
// macOS .icns: TaurCli 可用 png2icns 工具, 这里只放占位
|
||||
// 如果未来要打 macOS DMG, 跑 cargo tauri icon 重新生成
|
||||
fs.copyFileSync(path.join(OUT, 'icon.png'), path.join(OUT, 'icon.icns'))
|
||||
console.log('wrote icon.icns (copy of icon.png, run cargo tauri icon to replace)')
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// 一次性导入浙江省油价历史 (2025-2026)
|
||||
// 来源: 手工整理 (省发改委公告 / 潮新闻 / 钱江晚报 / 杭州日报 / 杭州网 等)
|
||||
// 用法: node --experimental-strip-types --no-warnings scripts/import-oil-history.mjs [--dry]
|
||||
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import 'dotenv/config'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
// 接入 prisma (用 file:// URL 避免 Windows 绝对路径 import 报错)
|
||||
const adapterMod = await import('@prisma/adapter-mariadb')
|
||||
const clientPath = pathToFileURL(path.join(ROOT, 'generated/prisma/client.ts')).href
|
||||
const { PrismaClient } = await import(clientPath)
|
||||
|
||||
const adapter = new adapterMod.PrismaMariaDb((() => {
|
||||
const u = new URL(process.env.DATABASE_URL)
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: Number(u.port) || 3306,
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
}
|
||||
})())
|
||||
const prisma = new PrismaClient({ adapter })
|
||||
|
||||
// 数据: [调价日, 92, 95, 0, source]
|
||||
const RAW = [
|
||||
['2026-07-31', 7.94, 8.44, 7.62, 'manual'],
|
||||
['2026-07-17', 7.39, 7.86, 7.06, 'manual'],
|
||||
['2026-06-18', 7.91, 8.41, 7.59, 'manual'],
|
||||
['2026-06-04', 8.32, 8.85, 8.02, 'manual'],
|
||||
['2026-05-21', 8.74, 9.30, 8.45, 'manual'],
|
||||
['2026-02-03', 6.91, 7.35, 6.56, 'manual'],
|
||||
['2025-12-22', 6.68, 7.11, 6.32, 'manual'],
|
||||
['2025-12-08', 6.82, 7.25, 6.46, 'manual'],
|
||||
['2025-11-24', 6.86, null, null, 'manual'],
|
||||
['2025-11-10', 6.92, 7.36, 6.57, 'manual'],
|
||||
['2025-10-13', 7.03, 7.48, 6.68, 'manual'],
|
||||
['2025-06-17', 7.15, 7.60, 6.81, 'manual'],
|
||||
['2025-06-03', 6.94, 7.38, 6.59, 'manual'],
|
||||
]
|
||||
|
||||
const PROVINCE = '浙江'
|
||||
const FUEL_COLS = [
|
||||
{ fuel: '92', idx: 1 },
|
||||
{ fuel: '95', idx: 2 },
|
||||
{ fuel: '0', idx: 3 },
|
||||
]
|
||||
|
||||
const DRY = process.argv.includes('--dry')
|
||||
|
||||
async function main() {
|
||||
console.log('=== 浙江省油价历史导入 ===')
|
||||
console.log('dry run:', DRY)
|
||||
console.log('')
|
||||
|
||||
let inserted = 0
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const row of RAW) {
|
||||
const dateStr = row[0]
|
||||
const source = row[4]
|
||||
for (const { fuel, idx } of FUEL_COLS) {
|
||||
const price = row[idx]
|
||||
if (price == null || Number.isNaN(price)) {
|
||||
console.log('[SKIP] ' + dateStr + ' ' + fuel + ': 价格为空')
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
if (DRY) {
|
||||
console.log('[DRY] ' + dateStr + ' ' + fuel + ' = ' + price + ' (source=' + source + ')')
|
||||
inserted++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await prisma.oilPriceHistory.create({
|
||||
data: { date, province: PROVINCE, fuel, price, source },
|
||||
})
|
||||
console.log('[OK] ' + dateStr + ' ' + fuel + ' = ' + price)
|
||||
inserted++
|
||||
} catch (e) {
|
||||
if (e?.code === 'P2002') {
|
||||
try {
|
||||
await prisma.oilPriceHistory.update({
|
||||
where: { date_province_fuel: { date, province: PROVINCE, fuel } },
|
||||
data: { price, source },
|
||||
})
|
||||
console.log('[UPD] ' + dateStr + ' ' + fuel + ' = ' + price)
|
||||
updated++
|
||||
} catch (e2) {
|
||||
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e2.message)
|
||||
}
|
||||
} else {
|
||||
console.log('[ERR] ' + dateStr + ' ' + fuel + ': ' + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('=== 汇总 ===')
|
||||
console.log('插入: ' + inserted)
|
||||
console.log('更新: ' + updated)
|
||||
console.log('跳过: ' + skipped)
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
// 定时任务失败报警: 向 Alert 表插入一条错误记录
|
||||
// 供 server/cron.js 与 scripts/scheduler/index.js 共用
|
||||
// 首页报警 tab 会展示 Alert 表, 字段按错误语义填充 (indicatorName=任务+错误摘要)
|
||||
import 'dotenv/config'
|
||||
import mysql from 'mysql2/promise'
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||
}
|
||||
|
||||
export async function insertCronAlert(taskName, message) {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('[cron-alert] 缺少 DATABASE_URL, 跳过报警写入')
|
||||
return
|
||||
}
|
||||
let conn = null
|
||||
try {
|
||||
conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
// 用 Asia/Shanghai 时区的当天日期, 避免 UTC 偏移导致 period 显示成昨天
|
||||
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
|
||||
const cleanMsg = String(message || '').replace(/\s+/g, ' ').trim()
|
||||
const name = ('[cron] ' + taskName + ' 失败: ' + cleanMsg).slice(0, 180)
|
||||
await conn.execute(
|
||||
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) ' +
|
||||
'VALUES (?, ?, 0, 0, ?, ?, 0, 0, NOW(3))',
|
||||
[name, today, 'cron', 'error'],
|
||||
)
|
||||
console.error('[cron-alert] 已写入报警:', name)
|
||||
} catch (e) {
|
||||
// 报警写入本身失败 (如 DB 不可达) 不能反噬主流程/再次抛错, 只记录
|
||||
console.error('[cron-alert] 写入报警失败:', e.message)
|
||||
} finally {
|
||||
if (conn) await conn.end().catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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) })
|
||||
@@ -0,0 +1,29 @@
|
||||
// 手动跑一次米家采集 (备用): npm run data:mihome
|
||||
// 带文件日志, 便于定位卡点
|
||||
import fs from 'node:fs'
|
||||
|
||||
const LOG = 'D:\\workbench\\iboard\\server\\mihome\\.cache\\cli.log'
|
||||
const log = (m) => fs.appendFileSync(LOG, new Date().toISOString() + ' ' + m + '\n')
|
||||
process.on('uncaughtException', (e) => { log('uncaughtException: ' + (e.stack || e)); process.exit(1) })
|
||||
process.on('unhandledRejection', (e) => { log('unhandledRejection: ' + (e && e.stack ? e.stack : String(e))); process.exit(1) })
|
||||
|
||||
log('start')
|
||||
const m = await import('../server/mihome/index.js')
|
||||
log('imported; calling dailyMaintenance')
|
||||
try {
|
||||
const r = await m.dailyMaintenance()
|
||||
log('done: ' + JSON.stringify(r))
|
||||
console.log('[data:mihome] 完成:', JSON.stringify(r))
|
||||
} catch (e) {
|
||||
if (e instanceof m.MihomeAuthError) {
|
||||
log('auth error: ' + e.message)
|
||||
console.log('[data:mihome] 米家授权不可用: ' + e.message)
|
||||
console.log('[data:mihome] 请运行 npm run data:mihome:login 扫码授权')
|
||||
process.exit(2)
|
||||
}
|
||||
log('failed: ' + (e && e.stack ? e.stack : String(e)))
|
||||
console.error('[data:mihome] 失败:', e.message)
|
||||
process.exit(1)
|
||||
}
|
||||
log('exit(0)')
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,38 @@
|
||||
// 手动扫码授权: npm run data:mihome:login
|
||||
// 启动后台 QRlogin, 轮询状态; 出二维码后打印地址, 扫码成功后自动结束.
|
||||
import { startQrLogin, getQrState, checkAuth } from '../server/mihome/client.js'
|
||||
|
||||
const auth = await checkAuth(true)
|
||||
if (auth.status === 'ok') {
|
||||
console.log('[米家] 已授权有效, 无需重扫 (本地 .cache 凭证可用)')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await startQrLogin()
|
||||
const started = Date.now()
|
||||
console.log('[米家] 等待扫码... (120s 超时)')
|
||||
for (;;) {
|
||||
const s = getQrState()
|
||||
if (s.qrImageUrl && s.state === 'waiting') {
|
||||
console.log('[米家] 二维码(需米家 App 扫):')
|
||||
console.log(' image: ' + s.qrImageUrl)
|
||||
}
|
||||
if (s.state === 'failed') {
|
||||
console.log('[米家] 扫码失败: ' + (s.lastError || '未知'))
|
||||
process.exit(2)
|
||||
}
|
||||
if (s.state === 'idle' && s.running === false && !s.qrImageUrl) {
|
||||
// 启动即失败(可能直接完成/或没权限)
|
||||
console.log('[米家] 流程结束(无二维码产生), 请检查日志')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!s.running && s.qrImageUrl) {
|
||||
console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/')
|
||||
process.exit(0)
|
||||
}
|
||||
if (Date.now() - started > 150000) {
|
||||
console.log('[米家] 超时, 已停止; 可重跑.')
|
||||
process.exit(3)
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
// 生成 minisign 密钥对 (ota-package/hotswap.{key,pub})
|
||||
// 首次跑一遍: node scripts/ota-genkey.mjs
|
||||
// - hotswap.key: 私钥, 不要提交, 部署时由 CI 注入 (环境变量 / secret)
|
||||
// - hotswap.pub: 公钥, 写到 src-tauri/tauri.conf.json 的 plugins.hotswap.pubkey
|
||||
//
|
||||
// 依赖: minisign (https://jedisct1.github.io/minisign/)
|
||||
// - Windows: scoop install minisign 或 choco install minisign
|
||||
// - macOS: brew install minisign
|
||||
// - Linux: apt install minisign
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const OUT_DIR = path.join(ROOT, 'ota-package')
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
|
||||
const KEY = path.join(OUT_DIR, 'hotswap.key')
|
||||
const PUB = path.join(OUT_DIR, 'hotswap.pub')
|
||||
|
||||
if (fs.existsSync(KEY) || fs.existsSync(PUB)) {
|
||||
console.error('[ota-genkey] 已存在密钥, 如需重新生成请先删除 ota-package/hotswap.{key,pub}')
|
||||
console.error(' 注意: 重新生成密钥会导致旧 bundle 无法被客户端验证, 慎用!')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 检查 minisign 是否存在
|
||||
const which = spawnSync('minisign', ['-v'], { stdio: 'pipe' })
|
||||
if (which.status !== 0 && which.error) {
|
||||
console.error('[ota-genkey] 未找到 minisign 命令, 请先安装:')
|
||||
console.error(' Windows: scoop install minisign')
|
||||
console.error(' macOS: brew install minisign')
|
||||
console.error(' Linux: apt install minisign')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// -G: 生成 -W: 无密码 -p: 公钥输出 -s: 私钥输出
|
||||
const r = spawnSync('minisign', ['-G', '-W', '-p', PUB, '-s', KEY], { stdio: 'inherit' })
|
||||
if (r.status !== 0) {
|
||||
console.error('[ota-genkey] minisign 生成失败')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('\n=== 密钥生成完成 ===')
|
||||
console.log('私钥: ' + KEY + ' (不要提交到 git!)')
|
||||
console.log('公钥: ' + PUB)
|
||||
|
||||
const pubText = fs.readFileSync(PUB, 'utf8')
|
||||
const lines = pubText.split(/\r?\n/)
|
||||
const pubB64 = lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ'))
|
||||
console.log('\n请把下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
|
||||
console.log(' ' + (pubB64 || '').trim())
|
||||
|
||||
// 自动写 .gitignore 提醒
|
||||
const gitignore = path.join(ROOT, '.gitignore')
|
||||
if (fs.existsSync(gitignore)) {
|
||||
let txt = fs.readFileSync(gitignore, 'utf8')
|
||||
if (!txt.includes('ota-package/hotswap.key')) {
|
||||
if (!txt.endsWith('\n')) txt += '\n'
|
||||
txt += '\n# OTA 升级私钥, 不要提交\nota-package/hotswap.key\n'
|
||||
fs.writeFileSync(gitignore, txt)
|
||||
console.log('\n[ota-genkey] 已把 ota-package/hotswap.key 加入 .gitignore')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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) })
|
||||
@@ -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));
|
||||
|
||||
+62
-62
@@ -1,62 +1,62 @@
|
||||
# 数据爬取脚本
|
||||
|
||||
本目录包含用于从各种数据源爬取经济数据的脚本。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `sow-inventory-scraper.js` - 爬取能繁母猪数量数据
|
||||
- `template-scraper.js` - 通用爬取脚本模板
|
||||
|
||||
## 依赖
|
||||
|
||||
- axios - 用于发送HTTP请求
|
||||
- cheerio - 用于解析HTML内容
|
||||
- csv-parser - 用于解析CSV格式数据
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. **修改脚本配置**:
|
||||
- 打开相应的爬取脚本
|
||||
- 修改数据源URL和数据提取逻辑
|
||||
|
||||
2. **运行爬取脚本**:
|
||||
```bash
|
||||
node scripts/scraper/sow-inventory-scraper.js
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据来源合规性**:确保爬取的数据来源符合相关法律法规
|
||||
2. **爬虫礼仪**:设置合理的请求间隔,避免对目标网站造成压力
|
||||
3. **数据验证**:爬取后对数据进行验证,确保数据准确性
|
||||
4. **错误处理**:脚本包含基本的错误处理,但可能需要根据实际情况进行调整
|
||||
|
||||
## 扩展指南
|
||||
|
||||
1. **添加新的爬取脚本**:
|
||||
- 复制 `template-scraper.js` 模板
|
||||
- 修改数据源URL和数据提取逻辑
|
||||
- 更新数据库存储逻辑
|
||||
|
||||
2. **修改数据提取逻辑**:
|
||||
- 使用Cheerio选择器提取HTML中的数据
|
||||
- 对于CSV数据,使用csv-parser解析
|
||||
- 对于JSON数据,直接处理响应数据
|
||||
|
||||
3. **数据存储**:
|
||||
- 脚本会自动将数据存储到数据库
|
||||
- 会检查数据是否已存在,存在则更新,不存在则创建
|
||||
|
||||
## 示例
|
||||
|
||||
爬取浙江省能繁母猪数量数据:
|
||||
|
||||
```bash
|
||||
node scripts/scraper/sow-inventory-scraper.js
|
||||
```
|
||||
# 数据爬取脚本
|
||||
|
||||
本目录包含用于从各种数据源爬取经济数据的脚本。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `sow-inventory-scraper.js` - 爬取能繁母猪数量数据
|
||||
- `template-scraper.js` - 通用爬取脚本模板
|
||||
|
||||
## 依赖
|
||||
|
||||
- axios - 用于发送HTTP请求
|
||||
- cheerio - 用于解析HTML内容
|
||||
- csv-parser - 用于解析CSV格式数据
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. **修改脚本配置**:
|
||||
- 打开相应的爬取脚本
|
||||
- 修改数据源URL和数据提取逻辑
|
||||
|
||||
2. **运行爬取脚本**:
|
||||
```bash
|
||||
node scripts/scraper/sow-inventory-scraper.js
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据来源合规性**:确保爬取的数据来源符合相关法律法规
|
||||
2. **爬虫礼仪**:设置合理的请求间隔,避免对目标网站造成压力
|
||||
3. **数据验证**:爬取后对数据进行验证,确保数据准确性
|
||||
4. **错误处理**:脚本包含基本的错误处理,但可能需要根据实际情况进行调整
|
||||
|
||||
## 扩展指南
|
||||
|
||||
1. **添加新的爬取脚本**:
|
||||
- 复制 `template-scraper.js` 模板
|
||||
- 修改数据源URL和数据提取逻辑
|
||||
- 更新数据库存储逻辑
|
||||
|
||||
2. **修改数据提取逻辑**:
|
||||
- 使用Cheerio选择器提取HTML中的数据
|
||||
- 对于CSV数据,使用csv-parser解析
|
||||
- 对于JSON数据,直接处理响应数据
|
||||
|
||||
3. **数据存储**:
|
||||
- 脚本会自动将数据存储到数据库
|
||||
- 会检查数据是否已存在,存在则更新,不存在则创建
|
||||
|
||||
## 示例
|
||||
|
||||
爬取浙江省能繁母猪数量数据:
|
||||
|
||||
```bash
|
||||
node scripts/scraper/sow-inventory-scraper.js
|
||||
```
|
||||
|
||||
@@ -1,352 +1,95 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
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)) {
|
||||
console.log('读取 .env 文件...');
|
||||
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, '');
|
||||
console.log('从 .env 文件获取数据库配置成功!');
|
||||
console.log(`DATABASE_URL: ${url}`);
|
||||
|
||||
// 解析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]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('未找到 .env 文件或 DATABASE_URL 配置,使用默认配置');
|
||||
return {
|
||||
host: '192.168.111.111',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: 'fullstack',
|
||||
database: 'iboard'
|
||||
};
|
||||
}
|
||||
|
||||
// 创建数据库连接池
|
||||
const config = loadDatabaseConfig();
|
||||
console.log('数据库配置:');
|
||||
console.log(` 主机: ${config.host}`);
|
||||
console.log(` 端口: ${config.port}`);
|
||||
console.log(` 用户: ${config.user}`);
|
||||
console.log(` 数据库: ${config.database}`);
|
||||
|
||||
let pool;
|
||||
|
||||
async function initPool() {
|
||||
if (!pool) {
|
||||
pool = createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
connectionLimit: 5
|
||||
});
|
||||
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
console.log('数据库连接成功!');
|
||||
conn.release();
|
||||
} catch (error) {
|
||||
console.error('数据库连接失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
// 浙江数据开放网站URL(示例,实际需要根据真实网站修改)
|
||||
const ZHEJIANG_OPEN_DATA_URL = 'https://data.zj.gov.cn/';
|
||||
|
||||
// 爬虫名称,用于缓存表中的标识
|
||||
const SCRAPER_NAME = 'sow-inventory';
|
||||
|
||||
// 最小更新间隔(毫秒),默认30分钟
|
||||
const MIN_UPDATE_INTERVAL = 30 * 60 * 1000;
|
||||
|
||||
// 从数据库读取缓存
|
||||
async function readCache() {
|
||||
try {
|
||||
await initPool();
|
||||
const result = await pool.query('SELECT * FROM ScraperCache WHERE scraperName = ?', [SCRAPER_NAME]);
|
||||
return result.length > 0 ? result[0] : { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null };
|
||||
} catch (error) {
|
||||
console.error('读取数据库缓存失败:', error.message);
|
||||
return { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
// 写入数据库缓存
|
||||
async function writeCache(lastDataHash) {
|
||||
try {
|
||||
await initPool();
|
||||
await pool.query(
|
||||
'INSERT INTO ScraperCache (scraperName, lastDataHash, lastUpdateAt) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE lastDataHash = VALUES(lastDataHash), lastUpdateAt = VALUES(lastUpdateAt)',
|
||||
[SCRAPER_NAME, lastDataHash, new Date()]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('写入数据库缓存失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算数据hash,用于检测数据是否变化
|
||||
function computeDataHash(data) {
|
||||
const str = JSON.stringify(data);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash.toString();
|
||||
}
|
||||
|
||||
// 获取数据库中最新的月份
|
||||
async function getLatestMonth() {
|
||||
try {
|
||||
await initPool();
|
||||
const result = await pool.query('SELECT MAX(month) as latestMonth FROM Econ_SowInventory');
|
||||
return result[0].latestMonth || null;
|
||||
} catch (error) {
|
||||
console.error('查询最新月份失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 判断月份是否需要更新(只有比数据库中更新的月份才需要处理)
|
||||
function shouldUpdateMonth(month, latestDbMonth) {
|
||||
if (!latestDbMonth) return true;
|
||||
return month > latestDbMonth;
|
||||
}
|
||||
|
||||
// 检查是否需要发起HTTP请求(基于更新间隔)
|
||||
function shouldSkipRequest(cache) {
|
||||
if (!cache.lastUpdateAt) return false;
|
||||
|
||||
const now = new Date();
|
||||
const lastUpdate = new Date(cache.lastUpdateAt);
|
||||
const interval = now - lastUpdate;
|
||||
|
||||
return interval < MIN_UPDATE_INTERVAL;
|
||||
}
|
||||
|
||||
// 能繁母猪数量数据爬取函数(增量更新版本)
|
||||
async function scrapeSowInventory(incremental = true) {
|
||||
try {
|
||||
await initPool();
|
||||
console.log('='.repeat(50));
|
||||
console.log('开始爬取浙江省能繁母猪数量数据...');
|
||||
console.log(`增量更新模式: ${incremental ? '开启' : '关闭'}`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// 0. 检查是否需要发起请求
|
||||
if (incremental) {
|
||||
const cache = await readCache();
|
||||
|
||||
if (shouldSkipRequest(cache)) {
|
||||
const lastUpdate = cache.lastUpdateAt ? new Date(cache.lastUpdateAt) : null;
|
||||
const hoursDiff = lastUpdate ? ((new Date() - lastUpdate) / (1000 * 60 * 60)).toFixed(2) : '未知';
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('跳过请求:距离上次更新不足30分钟');
|
||||
console.log(`上次更新时间: ${cache.lastUpdateAt}`);
|
||||
console.log(`距离现在: ${hoursDiff} 小时`);
|
||||
console.log('='.repeat(50));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取数据库中最新的月份
|
||||
const latestDbMonth = await getLatestMonth();
|
||||
if (latestDbMonth) {
|
||||
console.log(`数据库中最新月份: ${latestDbMonth}`);
|
||||
} else {
|
||||
console.log('数据库为空,将获取所有数据');
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 发送HTTP请求获取页面内容
|
||||
console.log('\n正在请求数据源...');
|
||||
const response = await axios.get(ZHEJIANG_OPEN_DATA_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 解析HTML内容
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 3. 提取数据(这里需要根据实际网站结构修改选择器)
|
||||
const allData = [];
|
||||
|
||||
$('table').each((tableIndex, table) => {
|
||||
$(table).find('tr').each((rowIndex, row) => {
|
||||
if (rowIndex > 0) { // 跳过表头
|
||||
const cells = $(row).find('td');
|
||||
if (cells.length >= 2) {
|
||||
const month = $(cells[0]).text().trim();
|
||||
const inventory = parseInt($(cells[1]).text().trim());
|
||||
|
||||
if (month && !isNaN(inventory)) {
|
||||
allData.push({
|
||||
month,
|
||||
inventory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`页面总数据量: ${allData.length} 条`);
|
||||
|
||||
// 4. 读取数据库缓存
|
||||
const cache = await readCache();
|
||||
|
||||
// 5. 计算当前数据hash,检测数据是否变化
|
||||
const currentDataHash = computeDataHash(allData);
|
||||
|
||||
// 6. 如果是增量更新且数据未变化,跳过更新
|
||||
if (incremental && cache.lastDataHash === currentDataHash) {
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('数据未发生变化,跳过数据库更新!');
|
||||
console.log(`上次更新时间: ${cache.lastUpdateAt || '未知'}`);
|
||||
console.log('='.repeat(50));
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. 如果是增量更新,获取数据库中最新的月份用于过滤
|
||||
let latestDbMonth = null;
|
||||
if (incremental) {
|
||||
latestDbMonth = await getLatestMonth();
|
||||
}
|
||||
|
||||
// 8. 过滤需要更新的数据
|
||||
let dataToUpdate = allData;
|
||||
let skippedCount = 0;
|
||||
|
||||
if (incremental && latestDbMonth) {
|
||||
dataToUpdate = allData.filter(item => shouldUpdateMonth(item.month, latestDbMonth));
|
||||
skippedCount = allData.length - dataToUpdate.length;
|
||||
console.log(`\n增量更新: 跳过 ${skippedCount} 条已存在的数据`);
|
||||
console.log(`需要处理: ${dataToUpdate.length} 条新数据`);
|
||||
}
|
||||
|
||||
// 9. 如果没有需要更新的数据
|
||||
if (dataToUpdate.length === 0) {
|
||||
console.log('\n没有需要更新的数据!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 10. 按月份排序(从旧到新)
|
||||
dataToUpdate.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
console.log('\n开始处理数据...');
|
||||
console.log('-'.repeat(50));
|
||||
|
||||
let newCount = 0;
|
||||
let updateCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// 11. 存储数据到数据库
|
||||
for (const item of dataToUpdate) {
|
||||
try {
|
||||
// 检查数据是否已存在
|
||||
const existing = await pool.query('SELECT * FROM Econ_SowInventory WHERE month = ?', [item.month]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// 检查数据是否真的需要更新(值变化了才更新)
|
||||
if (existing[0].inventory !== item.inventory) {
|
||||
await pool.query(
|
||||
'UPDATE Econ_SowInventory SET inventory = ? WHERE month = ?',
|
||||
[item.inventory, item.month]
|
||||
);
|
||||
updateCount++;
|
||||
console.log(`更新数据: ${item.month} - ${item.inventory} (原值: ${existing[0].inventory})`);
|
||||
} else {
|
||||
console.log(`数据未变化,跳过: ${item.month}`);
|
||||
}
|
||||
} else {
|
||||
await pool.query(
|
||||
'INSERT INTO Econ_SowInventory (month, inventory) VALUES (?, ?)',
|
||||
[item.month, item.inventory]
|
||||
);
|
||||
newCount++;
|
||||
console.log(`新增数据: ${item.month} - ${item.inventory}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
console.error(`处理数据 ${item.month} 时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 更新数据库缓存
|
||||
await writeCache(currentDataHash);
|
||||
|
||||
// 13. 输出统计信息
|
||||
const now = new Date().toISOString();
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('数据爬取和存储完成!');
|
||||
console.log('统计信息:');
|
||||
console.log(` - 新增数据: ${newCount} 条`);
|
||||
console.log(` - 更新数据: ${updateCount} 条`);
|
||||
console.log(` - 跳过数据: ${skippedCount} 条`);
|
||||
console.log(` - 错误数量: ${errorCount} 条`);
|
||||
console.log(` - 本次更新时间: ${now}`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
} catch (error) {
|
||||
console.error('爬取数据时出错:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
// 关闭数据库连接池
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
console.log('数据库连接池已关闭');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 强制全量更新函数
|
||||
async function scrapeSowInventoryFull() {
|
||||
console.log('警告: 即将执行全量更新,这将更新所有数据!');
|
||||
console.log('如果只是想获取新数据,请使用 scrapeSowInventory() 函数。\n');
|
||||
await scrapeSowInventory(false);
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
export {
|
||||
scrapeSowInventory,
|
||||
scrapeSowInventoryFull
|
||||
};
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// 默认执行增量更新
|
||||
// 如果需要强制全量更新,使用: scrapeSowInventoryFull()
|
||||
scrapeSowInventory().catch(error => {
|
||||
console.error('执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('../../generated/prisma');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 浙江数据开放网站URL(示例,实际需要根据真实网站修改)
|
||||
const ZHEJIANG_OPEN_DATA_URL = 'https://data.zj.gov.cn/';
|
||||
|
||||
// 能繁母猪数量数据爬取函数
|
||||
async function scrapeSowInventory() {
|
||||
try {
|
||||
console.log('开始爬取浙江省能繁母猪数量数据...');
|
||||
|
||||
// 1. 发送HTTP请求获取页面内容
|
||||
const response = await axios.get(ZHEJIANG_OPEN_DATA_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 解析HTML内容
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 3. 提取数据(这里需要根据实际网站结构修改选择器)
|
||||
// 示例:假设数据在表格中
|
||||
const data = [];
|
||||
|
||||
$('table').each((tableIndex, table) => {
|
||||
$(table).find('tr').each((rowIndex, row) => {
|
||||
if (rowIndex > 0) { // 跳过表头
|
||||
const cells = $(row).find('td');
|
||||
if (cells.length >= 2) {
|
||||
const month = $(cells[0]).text().trim();
|
||||
const inventory = parseInt($(cells[1]).text().trim());
|
||||
|
||||
if (month && !isNaN(inventory)) {
|
||||
data.push({
|
||||
month,
|
||||
inventory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`爬取到 ${data.length} 条数据`);
|
||||
|
||||
// 4. 存储数据到数据库
|
||||
for (const item of data) {
|
||||
try {
|
||||
// 检查数据是否已存在
|
||||
const existing = await prisma.econ_SowInventory.findUnique({
|
||||
where: { month: item.month }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 更新现有数据
|
||||
await prisma.econ_SowInventory.update({
|
||||
where: { month: item.month },
|
||||
data: { inventory: item.inventory }
|
||||
});
|
||||
console.log(`更新数据: ${item.month} - ${item.inventory}`);
|
||||
} else {
|
||||
// 创建新数据
|
||||
await prisma.econ_SowInventory.create({
|
||||
data: item
|
||||
});
|
||||
console.log(`新增数据: ${item.month} - ${item.inventory}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`处理数据 ${item.month} 时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('数据爬取和存储完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('爬取数据时出错:', error.message);
|
||||
} finally {
|
||||
// 关闭Prisma连接
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
module.exports = { scrapeSowInventory };
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
scrapeSowInventory();
|
||||
}
|
||||
|
||||
+100
-100
@@ -1,100 +1,100 @@
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('../../generated/prisma');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 数据源URL(需要根据实际情况修改)
|
||||
const DATA_SOURCE_URL = 'https://example.com/data';
|
||||
|
||||
// 通用数据爬取函数
|
||||
async function scrapeData() {
|
||||
try {
|
||||
console.log('开始爬取数据...');
|
||||
|
||||
// 1. 发送HTTP请求获取页面内容
|
||||
const response = await axios.get(DATA_SOURCE_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 解析HTML内容
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 3. 提取数据(需要根据实际网站结构修改)
|
||||
const data = [];
|
||||
|
||||
// 示例:从表格中提取数据
|
||||
$('table').each((tableIndex, table) => {
|
||||
$(table).find('tr').each((rowIndex, row) => {
|
||||
if (rowIndex > 0) { // 跳过表头
|
||||
const cells = $(row).find('td');
|
||||
// 根据实际表格结构提取数据
|
||||
// 示例:假设第一列是日期,第二列是数值
|
||||
if (cells.length >= 2) {
|
||||
const date = $(cells[0]).text().trim();
|
||||
const value = parseFloat($(cells[1]).text().trim());
|
||||
|
||||
if (date && !isNaN(value)) {
|
||||
data.push({
|
||||
date,
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`爬取到 ${data.length} 条数据`);
|
||||
|
||||
// 4. 存储数据到数据库(需要根据实际表结构修改)
|
||||
for (const item of data) {
|
||||
try {
|
||||
// 这里需要根据实际表结构修改
|
||||
// 示例:存储到某个表
|
||||
/*
|
||||
const existing = await prisma.someTable.findUnique({
|
||||
where: { date: item.date }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 更新现有数据
|
||||
await prisma.someTable.update({
|
||||
where: { date: item.date },
|
||||
data: { value: item.value }
|
||||
});
|
||||
console.log(`更新数据: ${item.date} - ${item.value}`);
|
||||
} else {
|
||||
// 创建新数据
|
||||
await prisma.someTable.create({
|
||||
data: item
|
||||
});
|
||||
console.log(`新增数据: ${item.date} - ${item.value}`);
|
||||
}
|
||||
*/
|
||||
} catch (error) {
|
||||
console.error(`处理数据时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('数据爬取和存储完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('爬取数据时出错:', error.message);
|
||||
} finally {
|
||||
// 关闭Prisma连接
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
module.exports = { scrapeData };
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
scrapeData();
|
||||
}
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('../../generated/prisma');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 数据源URL(需要根据实际情况修改)
|
||||
const DATA_SOURCE_URL = 'https://example.com/data';
|
||||
|
||||
// 通用数据爬取函数
|
||||
async function scrapeData() {
|
||||
try {
|
||||
console.log('开始爬取数据...');
|
||||
|
||||
// 1. 发送HTTP请求获取页面内容
|
||||
const response = await axios.get(DATA_SOURCE_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 解析HTML内容
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 3. 提取数据(需要根据实际网站结构修改)
|
||||
const data = [];
|
||||
|
||||
// 示例:从表格中提取数据
|
||||
$('table').each((tableIndex, table) => {
|
||||
$(table).find('tr').each((rowIndex, row) => {
|
||||
if (rowIndex > 0) { // 跳过表头
|
||||
const cells = $(row).find('td');
|
||||
// 根据实际表格结构提取数据
|
||||
// 示例:假设第一列是日期,第二列是数值
|
||||
if (cells.length >= 2) {
|
||||
const date = $(cells[0]).text().trim();
|
||||
const value = parseFloat($(cells[1]).text().trim());
|
||||
|
||||
if (date && !isNaN(value)) {
|
||||
data.push({
|
||||
date,
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`爬取到 ${data.length} 条数据`);
|
||||
|
||||
// 4. 存储数据到数据库(需要根据实际表结构修改)
|
||||
for (const item of data) {
|
||||
try {
|
||||
// 这里需要根据实际表结构修改
|
||||
// 示例:存储到某个表
|
||||
/*
|
||||
const existing = await prisma.someTable.findUnique({
|
||||
where: { date: item.date }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 更新现有数据
|
||||
await prisma.someTable.update({
|
||||
where: { date: item.date },
|
||||
data: { value: item.value }
|
||||
});
|
||||
console.log(`更新数据: ${item.date} - ${item.value}`);
|
||||
} else {
|
||||
// 创建新数据
|
||||
await prisma.someTable.create({
|
||||
data: item
|
||||
});
|
||||
console.log(`新增数据: ${item.date} - ${item.value}`);
|
||||
}
|
||||
*/
|
||||
} catch (error) {
|
||||
console.error(`处理数据时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('数据爬取和存储完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('爬取数据时出错:', error.message);
|
||||
} finally {
|
||||
// 关闭Prisma连接
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
module.exports = { scrapeData };
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
scrapeData();
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* iBoard 基础数据初始化脚本
|
||||
* 用法: npm run db:seed
|
||||
* 幂等: 主键/唯一键冲突跳过, Strategy/Indicator 按 name 存在则更新
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const crypto = require('crypto');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
function hashPassword(plain) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync(plain, salt, 64).toString('hex');
|
||||
return 'scrypt$' + salt + '$' + hash;
|
||||
}
|
||||
|
||||
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 insertIgnore(conn, sql, params) {
|
||||
try {
|
||||
const [r] = await conn.execute(sql, params);
|
||||
return r.affectedRows;
|
||||
} catch (e) {
|
||||
if (e.code === 'ER_DUP_ENTRY') return 0;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertByName(conn, table, payload, keyField) {
|
||||
const fields = Object.keys(payload);
|
||||
const placeholders = fields.map(() => '?').join(', ');
|
||||
const updateClause = fields
|
||||
.filter((f) => f !== keyField)
|
||||
.map((f) => '`' + f + '` = VALUES(`' + f + '`)')
|
||||
.join(', ');
|
||||
const sql = 'INSERT INTO `' + table + '` (' + fields.map((f) => '`' + f + '`').join(', ') + ') VALUES (' + placeholders + ') ON DUPLICATE KEY UPDATE ' + updateClause;
|
||||
const params = fields.map((f) => payload[f]);
|
||||
const [r] = await conn.execute(sql, params);
|
||||
return r.affectedRows;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cfg = parseUrl(process.env.DATABASE_URL);
|
||||
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
|
||||
const conn = await mysql.createConnection(cfg);
|
||||
console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database);
|
||||
|
||||
const adminPwd = hashPassword('admin123');
|
||||
const n1 = await insertIgnore(
|
||||
conn,
|
||||
'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))',
|
||||
['Admin', 'admin@iboard.local', adminPwd],
|
||||
);
|
||||
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
|
||||
|
||||
const strategies = [
|
||||
{ name: 'us-debt-gdp', description: '美国政府债务/GDP分析',
|
||||
data: { source: 'strategy.js', unit: '万亿美元', rows: [
|
||||
{ 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 },
|
||||
]},
|
||||
},
|
||||
{ name: 'china-debt-gdp', description: '中国政府债务/GDP分析',
|
||||
data: { source: 'strategy.js', unit: '万亿美元', rows: [
|
||||
{ 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 },
|
||||
]},
|
||||
},
|
||||
];
|
||||
let n2 = 0;
|
||||
for (const s of strategies) {
|
||||
n2 += await upsertByName(conn, 'Strategy',
|
||||
{ name: s.name, description: s.description, data: JSON.stringify(s.data), updatedAt: new Date() },
|
||||
'name');
|
||||
}
|
||||
console.log('[Strategy] upsert 策略: ' + n2 + ' 行 (1=新增, 2=更新)');
|
||||
|
||||
// Indicator 表: 改由 scripts/refresh-indicators.cjs 管理 (2026-08 重构后数据存 IndicatorPoint 明细表)
|
||||
// 本脚本不再写入 Indicator, 如需初始化指标请运行 npm run data:refresh
|
||||
|
||||
const article = {
|
||||
title: '欢迎使用 iBoard',
|
||||
slug: 'welcome-to-iboard',
|
||||
content: 'iBoard 用于收集和整理经济数据,以策略为主体重新组织。\n\n本文为示例条目,正式文章仍由 content/articles/*.md 文件承载。',
|
||||
date: '2026-08-03',
|
||||
};
|
||||
const n4 = await insertIgnore(
|
||||
conn,
|
||||
'INSERT INTO `Article` (title, slug, content, date, createdAt, updatedAt) VALUES (?, ?, ?, ?, NOW(3), NOW(3))',
|
||||
[article.title, article.slug, article.content, article.date],
|
||||
);
|
||||
console.log('[Article] 新增示例文章: ' + n4 + ' 行');
|
||||
|
||||
const sowRows = [
|
||||
{ month: '2024-01', inventory: 4067 },
|
||||
{ month: '2024-02', inventory: 4042 },
|
||||
{ month: '2024-03', inventory: 3992 },
|
||||
{ month: '2024-04', inventory: 3986 },
|
||||
{ month: '2024-05', inventory: 3996 },
|
||||
{ month: '2024-06', inventory: 4038 },
|
||||
];
|
||||
let n5 = 0;
|
||||
for (const r of sowRows) {
|
||||
n5 += await insertIgnore(
|
||||
conn,
|
||||
'INSERT INTO `Econ_SowInventory` (month, inventory, createdAt, updatedAt) VALUES (?, ?, NOW(3), NOW(3))',
|
||||
[r.month, r.inventory],
|
||||
);
|
||||
}
|
||||
console.log('[Econ_SowInventory] 新增: ' + n5 + ' 行');
|
||||
|
||||
await conn.end();
|
||||
console.log('\nSeed 完成.');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('Seed 失败:', e); process.exit(1); });
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
// 将 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
|
||||
// 传输层: MCP streamable HTTP (POST /mcp), 认证 Bearer <REGISTRY_MCP_TOKEN>
|
||||
// 详见 workflow: .gitea/workflows/sync-registry.yml
|
||||
//
|
||||
// 用法:
|
||||
// REGISTRY_MCP_URL=http://bh.vps.honor3.com:8008/mcp \
|
||||
// REGISTRY_MCP_TOKEN=<MCP 访问令牌> \
|
||||
// REGISTRY_PROJECT_ID=proj-msid70ql-6xbq4w \
|
||||
// node scripts/sync-registry.mjs [--dry-run]
|
||||
//
|
||||
// 安全: 本脚本不会把任何 secret 的值打印到日志, 只输出 键名 + 结果。
|
||||
// 注意: 当前通过明文 http 传输, 请确保注册中心仅在内网可达 (或改用 https)。
|
||||
|
||||
const MCP_URL = process.env.REGISTRY_MCP_URL || 'http://bh.vps.honor3.com:8008/mcp'
|
||||
const MCP_TOKEN = process.env.REGISTRY_MCP_TOKEN || ''
|
||||
const PROJECT_ID = process.env.REGISTRY_PROJECT_ID || 'proj-msid70ql-6xbq4w'
|
||||
const IDC = process.env.REGISTRY_IDC || '1'
|
||||
const ENV = process.env.REGISTRY_ENVIRONMENT || '1'
|
||||
const DRY_RUN = process.argv.includes('--dry-run')
|
||||
|
||||
// CI 环境变量 -> 注册中心配置键 (key 支持 / 嵌套, 本项目分组映射为 path => group/key)
|
||||
const SECRET_MAP = [
|
||||
{ env: 'MINISIGN_PRIVATE_KEY', key: 'minisign.privateKey', group: 'secret', type: 'string', description: 'minisign 签名 OTA 升级包的私钥' },
|
||||
{ env: 'DATABASE_URL', key: 'database.url', group: 'secret', type: 'string', description: 'MySQL 连接串' },
|
||||
{ env: 'JWT_SECRET', key: 'jwt.secret', group: 'secret', type: 'string', description: 'JWT 签名密钥' },
|
||||
{ env: 'ANDROID_KEYSTORE_B64', key: 'android.keystore.b64', group: 'secret', type: 'string', description: 'Android release keystore (base64)' },
|
||||
{ env: 'ANDROID_KEY_ALIAS', key: 'android.key.alias', group: 'secret', type: 'string', description: 'Android keystore 别名' },
|
||||
{ env: 'ANDROID_KEY_PASSWORD', key: 'android.key.password', group: 'secret', type: 'string', description: 'Android keystore 密码' },
|
||||
{ env: 'PACKAGE_TOKEN', key: 'package.token', group: 'secret', type: 'string', description: 'Gitea Generic Package 写权限 token' },
|
||||
]
|
||||
|
||||
if (!MCP_TOKEN) {
|
||||
console.error('缺少 REGISTRY_MCP_TOKEN (MCP 访问令牌)')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let sessionId = null
|
||||
let nextId = 0
|
||||
|
||||
function parseSse(text) {
|
||||
let data = ''
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (line.startsWith('data:')) data += line.slice(5).trim()
|
||||
}
|
||||
if (!data) throw new Error(`MCP 响应没有 data 字段: ${text.slice(0, 200)}`)
|
||||
const obj = JSON.parse(data)
|
||||
if (obj.error) {
|
||||
const e = new Error(`${obj.error.code}: ${obj.error.message}`)
|
||||
e.mcpError = obj.error
|
||||
throw e
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
async function rpc(method, params) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
}
|
||||
if (MCP_TOKEN) headers.Authorization = `Bearer ${MCP_TOKEN}`
|
||||
if (sessionId) headers['mcp-session-id'] = sessionId
|
||||
|
||||
const res = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: ++nextId, method, params }),
|
||||
})
|
||||
const sid = res.headers.get('mcp-session-id')
|
||||
if (sid) sessionId = sid
|
||||
const text = await res.text()
|
||||
return parseSse(text)
|
||||
}
|
||||
|
||||
async function callTool(name, args) {
|
||||
const payload = await rpc('tools/call', { name, arguments: args })
|
||||
if (payload.result?.isError) {
|
||||
throw new Error(`工具 ${name} 执行失败: ${JSON.stringify(payload.result.content || {})}`)
|
||||
}
|
||||
const sc = payload.result?.structuredContent
|
||||
if (sc !== undefined && sc !== null) return sc
|
||||
const content = payload.result?.content || []
|
||||
const text = content.map((c) => c.text || '').join('\n').trim()
|
||||
if (text) {
|
||||
try { return JSON.parse(text) } catch { return { text } }
|
||||
}
|
||||
return payload.result || {}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`== 同步 CI secrets -> 注册中心 (project=${PROJECT_ID})${DRY_RUN ? ' [dry-run]' : ''}`)
|
||||
|
||||
// 1) initialize 拿 mcp-session-id (rpc 内部已缓存 header)
|
||||
await rpc('initialize', {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'iboard-ci', version: '1.0.0' },
|
||||
})
|
||||
|
||||
// 2) 记录当前模式, 非 edit 则临时切到 edit (写 value 需要), 结束后恢复
|
||||
const mode = (await callTool('get_mode', {})).mode
|
||||
console.log(`当前模式: ${mode}`)
|
||||
const needEdit = mode !== 'edit'
|
||||
if (needEdit) {
|
||||
if (DRY_RUN) {
|
||||
console.log('(dry-run) 待切换到 edit 模式')
|
||||
} else {
|
||||
await callTool('set_mode', { mode: 'edit' })
|
||||
console.log('已临时切换为 edit 模式')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 3) 拉取现有配置, 键为 group/idc/environment/key
|
||||
const list = await callTool('list_configs', { projectId: PROJECT_ID })
|
||||
const configs = Array.isArray(list.configs) ? list.configs : list
|
||||
const existing = new Map()
|
||||
for (const c of configs || []) {
|
||||
if (!c || !c.key) continue
|
||||
existing.set(`${c.idc || ''}|${c.environment || ''}|${c.group || ''}|${c.key}`, c)
|
||||
}
|
||||
|
||||
// 4) 逐个 upsert
|
||||
let updated = 0
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
for (const { env, key, group, type, description } of SECRET_MAP) {
|
||||
const value = process.env[env]
|
||||
if (value === undefined || value === '') {
|
||||
console.log(`- [skip] ${group}/${key} <- 环境变量 ${env} 未设置`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const found = existing.get(`${IDC}|${ENV}|${group}|${key}`)
|
||||
const target = `${group}/${key}`
|
||||
if (found) {
|
||||
if (DRY_RUN) {
|
||||
console.log(`- [dry-run] update ${target} (cfg ${found.id}) <- ${env}`)
|
||||
} else {
|
||||
await callTool('update_config', { projectId: PROJECT_ID, configId: found.id, value })
|
||||
console.log(`- [update] ${target} (cfg ${found.id}) <- ${env}`)
|
||||
}
|
||||
updated++
|
||||
} else {
|
||||
if (DRY_RUN) {
|
||||
console.log(`- [dry-run] create ${target} <- ${env}`)
|
||||
} else {
|
||||
await callTool('create_config', {
|
||||
projectId: PROJECT_ID,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
description,
|
||||
idc: IDC,
|
||||
environment: ENV,
|
||||
group,
|
||||
})
|
||||
console.log(`- [create] ${target} <- ${env}`)
|
||||
}
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n== 完成: ${DRY_RUN ? 'dry-run' : '写入'} ${updated} 更新 / ${created} 新建 / ${skipped} 跳过 (值不回显)`)
|
||||
} finally {
|
||||
// 5) 恢复原模式 (本次未实际切模式时无操作)
|
||||
if (needEdit && !DRY_RUN) {
|
||||
await callTool('set_mode', { mode })
|
||||
console.log(`已恢复模式: ${mode}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n同步失败: ${err.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
(async () => {
|
||||
const u = new URL(process.env.DATABASE_URL);
|
||||
const c = await mysql.createConnection({
|
||||
host: u.hostname, port: Number(u.port),
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ''),
|
||||
});
|
||||
const [users] = await c.query('SELECT id,name,email,LEFT(password,20) AS pwd_prefix,createdAt FROM User');
|
||||
console.log('User:'); console.table(users);
|
||||
const [sow] = await c.query('SELECT * FROM Econ_SowInventory ORDER BY month');
|
||||
console.log('Econ_SowInventory:'); console.table(sow);
|
||||
const [str] = await c.query('SELECT id,name,description,JSON_LENGTH(data,"$.rows") AS row_count FROM Strategy');
|
||||
console.log('Strategy:'); console.table(str);
|
||||
const [ind] = await c.query('SELECT id,name,groupName,JSON_LENGTH(data,"$.rows") AS row_count FROM Indicator');
|
||||
console.log('Indicator:'); console.table(ind);
|
||||
const [art] = await c.query('SELECT id,title,slug,date FROM Article');
|
||||
console.log('Article:'); console.table(art);
|
||||
await c.end();
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dotenv/config'
|
||||
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
|
||||
import { PrismaClient } from '../generated/prisma/client.ts'
|
||||
|
||||
function parseDatabaseUrl(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 p = new PrismaClient({ adapter: new PrismaMariaDb(parseDatabaseUrl(process.env.DATABASE_URL)) })
|
||||
try {
|
||||
const users = await p.user.findMany()
|
||||
console.log('User count:', users.length, '->', users.map((u) => u.email).join(','))
|
||||
const strategies = await p.strategy.findMany({ select: { id: true, name: true } })
|
||||
console.log('Strategy count:', strategies.length, '->', strategies.map((s) => s.name).join(','))
|
||||
const indicators = await p.indicator.findMany({ select: { id: true, name: true } })
|
||||
console.log('Indicator count:', indicators.length, '->', indicators.map((i) => i.name).join(','))
|
||||
const sow = await p.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
|
||||
console.log('SowInventory count:', sow.length, '->', sow.map((s) => s.month + '=' + s.inventory).join(','))
|
||||
} catch (e) {
|
||||
console.error('ERR:', e.message)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await p.$disconnect()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
(async () => {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
const conn = await mysql.createConnection({
|
||||
host: url.hostname,
|
||||
port: Number(url.port),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ''),
|
||||
});
|
||||
const [tables] = await conn.query('SHOW TABLES');
|
||||
console.log('TABLES:');
|
||||
for (const row of tables) console.log(' -', Object.values(row)[0]);
|
||||
const tableNames = ['User', 'Article', 'Strategy', 'Indicator', 'Econ_SowInventory'];
|
||||
for (const t of tableNames) {
|
||||
const [r] = await conn.query('SELECT COUNT(*) AS c FROM ' + t + '');
|
||||
console.log(t + ': ' + r[0].c + ' rows');
|
||||
}
|
||||
await conn.end();
|
||||
})().catch((e) => { console.error('ERR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,15 @@
|
||||
const { PrismaClient } = require('../generated/prisma');
|
||||
(async () => {
|
||||
const p = new PrismaClient();
|
||||
const tables = await p.$queryRawUnsafe('SHOW TABLES');
|
||||
console.log('TABLES:', JSON.stringify(tables, null, 2));
|
||||
const counts = {
|
||||
User: await p.user.count(),
|
||||
Article: await p.article.count(),
|
||||
Strategy: await p.strategy.count(),
|
||||
Indicator: await p.indicator.count(),
|
||||
Econ_SowInventory: await p.econ_SowInventory.count(),
|
||||
};
|
||||
console.log('COUNTS:', JSON.stringify(counts, null, 2));
|
||||
await p.$disconnect();
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient } from '../generated/prisma/client.ts';
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
const p = new PrismaClient({ datasourceUrl: url });
|
||||
try {
|
||||
const tables = await p.$queryRawUnsafe('SHOW TABLES');
|
||||
console.log('TABLES:', JSON.stringify(tables, null, 2));
|
||||
const counts = {
|
||||
User: await p.user.count(),
|
||||
Article: await p.article.count(),
|
||||
Strategy: await p.strategy.count(),
|
||||
Indicator: await p.indicator.count(),
|
||||
Econ_SowInventory: await p.econ_SowInventory.count(),
|
||||
};
|
||||
console.log('COUNTS:', JSON.stringify(counts, null, 2));
|
||||
} catch (e) {
|
||||
console.error('ERR:', e.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.$disconnect();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user