Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2a1deb1a | ||
|
|
7b52220aad | ||
|
|
3730e55e93 | ||
|
|
902c6a0ebe | ||
|
|
ec6c9c7677 | ||
|
|
fa172d8974 | ||
|
|
5622e4b3d4 | ||
|
|
308ace8559 | ||
|
|
6d560583ae | ||
|
|
f2c7dae4a8 | ||
|
|
afbb9bc603 | ||
|
|
82c4ae8a84 | ||
|
|
c5fc328f2b | ||
|
|
77e057c580 | ||
|
|
75cabe8339 | ||
|
|
58879024a2 | ||
|
|
96f4e09bd5 | ||
|
|
fd4079c9c3 | ||
|
|
86335dfaa4 | ||
|
|
a23b10959c | ||
|
|
c460f09821 | ||
|
|
1d800c536e | ||
|
|
0fc45aa6ec | ||
|
|
32f404ac90 | ||
|
|
06e9a3e694 | ||
|
|
317072539d | ||
|
|
08dd766886 | ||
|
|
c9477b562e | ||
|
|
50283269fb | ||
|
|
04bc48b6a7 | ||
|
|
702c8daf6c | ||
|
|
a1c267b0d4 | ||
|
|
836035f4cd | ||
|
|
b29be62747 | ||
|
|
a9dc632986 | ||
|
|
cc2677ce06 | ||
|
|
4ded8e30ed | ||
|
|
be70868d0b | ||
|
|
49583599b3 | ||
|
|
8cfa3802d5 |
@@ -60,9 +60,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo
|
~/.cargo
|
||||||
~/.rustup/toolchains
|
~/.rustup
|
||||||
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
|
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
|
||||||
key: rust-android-v1-${{ runner.os }}
|
# v2: v1 缓存是从只缓存 toolchains 的旧配置生成的, 缺 cargo-ndk/settings.toml,
|
||||||
|
# 且缓存命中后不会回写, 所以 bump 一次重建完整缓存。
|
||||||
|
# 注意: 必须缓存整个 ~/.rustup。只缓存 ~/.rustup/toolchains 会丢掉
|
||||||
|
# settings.toml (默认 toolchain 记录), 缓存命中时 cargo 无法选择版本。
|
||||||
|
key: rust-android-v2-${{ runner.os }}
|
||||||
|
|
||||||
- name: Cache Tauri cargo target
|
- name: Cache Tauri cargo target
|
||||||
uses: https://gitea.com/actions/cache@v4
|
uses: https://gitea.com/actions/cache@v4
|
||||||
@@ -72,16 +76,22 @@ jobs:
|
|||||||
key: tauri-target-android-${{ hashFiles('src-tauri/Cargo.lock') }}
|
key: tauri-target-android-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||||
restore-keys: tauri-target-android-
|
restore-keys: tauri-target-android-
|
||||||
|
|
||||||
# ---------- 安装工具链 (仅缓存未命中时) ----------
|
# ---------- 安装/修复工具链 (缓存命中时也要执行) ----------
|
||||||
- name: Install Rust + Android targets
|
# 缓存命中时不能只是跳过安装: 缓存里可能缺少 ~/.rustup/settings.toml,
|
||||||
if: steps.cache-rust.outputs.cache-hit != 'true'
|
# rustup 代理会报 "no default is configured", 导致 cargo / tauri 全部失败。
|
||||||
|
# 因此这一步无条件运行, 但都是幂等操作 (已安装时不会重新下载)。
|
||||||
|
- name: Setup Rust toolchain
|
||||||
run: |
|
run: |
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
if [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
fi
|
||||||
. "$HOME/.cargo/env"
|
. "$HOME/.cargo/env"
|
||||||
|
# 幂等: stable 已在 ~/.rustup/toolchains 时只补写 settings.toml
|
||||||
|
rustup default stable
|
||||||
# 只构建 aarch64 (现代 Android 手机); 需要模拟器/x86 设备时再加回其他 target
|
# 只构建 aarch64 (现代 Android 手机); 需要模拟器/x86 设备时再加回其他 target
|
||||||
rustup target add aarch64-linux-android
|
rustup target add aarch64-linux-android
|
||||||
cargo install cargo-ndk --locked
|
command -v cargo-ndk >/dev/null 2>&1 || cargo install cargo-ndk --locked
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
- name: Install Android SDK & NDK
|
||||||
if: steps.cache-android.outputs.cache-hit != 'true'
|
if: steps.cache-android.outputs.cache-hit != 'true'
|
||||||
@@ -117,12 +127,16 @@ jobs:
|
|||||||
- name: Init Android project
|
- name: Init Android project
|
||||||
run: |
|
run: |
|
||||||
# tauri android init 需要 cargo (运行 cargo metadata)。
|
# tauri android init 需要 cargo (运行 cargo metadata)。
|
||||||
# Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH,
|
# Setup Rust toolchain 已把 ~/.cargo/bin 写入 GITHUB_PATH, 这里再 source 一次兜底。
|
||||||
# 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。
|
|
||||||
. "$HOME/.cargo/env"
|
. "$HOME/.cargo/env"
|
||||||
|
cargo --version
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
npx tauri android init --ci
|
npx tauri android init --ci
|
||||||
|
|
||||||
|
# 软键盘避让: 必须紧跟 init (gen/android 每次全新生成), 在 build 之前完成
|
||||||
|
- name: Inject keyboard insets fix
|
||||||
|
run: bash scripts/ci/android-softinput.sh
|
||||||
|
|
||||||
- name: Inject release signing config
|
- name: Inject release signing config
|
||||||
run: bash scripts/ci/android-signing.sh
|
run: bash scripts/ci/android-signing.sh
|
||||||
env:
|
env:
|
||||||
|
|||||||
+132
-114
@@ -1,117 +1,135 @@
|
|||||||
name: Docker Build and Push
|
name: Docker Build and Push
|
||||||
|
|
||||||
# 仅主分支 (master) push 触发部署; 其他分支一律不触发 (见 readme「分支规则」)
|
# 仅主分支 (master) push 触发部署; 其他分支一律不触发 (见 readme「分支规则」)
|
||||||
# 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署
|
# 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署
|
||||||
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
|
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
|
||||||
#
|
#
|
||||||
# 分支策略 (2026-10 调整):
|
# 分支策略 (2026-10 调整):
|
||||||
# - master = 主分支, 唯一部署出口, push 即发布生产 (容器 iboard, 宿主 3001)
|
# - master = 主分支, 唯一部署出口, push 即发布生产 (容器 iboard, 宿主 3001)
|
||||||
# - dev-* = 开发集成分支, 只做 CI 校验 (构建/版本一致性), **不再自动部署**;
|
# - dev-* = 开发集成分支, 只做 CI 校验 (构建/版本一致性), **不再自动部署**;
|
||||||
# 要上线必须先合并到 master 再推 master
|
# 要上线必须先合并到 master 再推 master
|
||||||
# - tmp-* = 单个问题的临时分支, 合到 dev-* 后删除
|
# - tmp-* = 单个问题的临时分支, 合到 dev-* 后删除
|
||||||
# - android-*= 只用于触发 APK 外壳构建, 不部署; 改动前须与 master 同步
|
# - android-*= 只用于触发 APK 外壳构建, 不部署; 改动前须与 master 同步
|
||||||
#
|
#
|
||||||
# 历史: 之前 dev-* 与 master 共用同一套生产环境 (推送即发布), 容易把半成品带上线,
|
# 历史: 之前 dev-* 与 master 共用同一套生产环境 (推送即发布), 容易把半成品带上线,
|
||||||
# 现已取消 dev-* 的 push 触发。
|
# 现已取消 dev-* 的 push 触发。
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
# 连续 push master 时取消还在跑的旧部署, 避免排队执行过时版本的完整部署
|
# 连续 push master 时取消还在跑的旧部署, 避免排队执行过时版本的完整部署
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-master
|
group: deploy-master
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-image:
|
build-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
# 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的
|
# 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的
|
||||||
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
|
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
|
||||||
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
|
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
|
||||||
VITE_API_BASE: https://iboard.honor3.com
|
VITE_API_BASE: https://iboard.honor3.com
|
||||||
# OTA bundle 要求的最低原生二进制版本。
|
# OTA bundle 要求的最低原生二进制版本 (hotswap 据此拒绝不兼容的旧外壳)。
|
||||||
# 与 package.json 的应用版本是**两个概念**: 前端 OTA 不碰原生层,
|
# 0.2.1 起必须为 0.2.1: 旧外壳 (0.1.0) 的 CSP 白名单编译进了原生二进制,
|
||||||
# 现有 APK 仍是 0.1.0, 所以这里固定 0.1.0 (否则旧 APK 会被判定为不兼容而收不到更新)。
|
# 不含 https://iboard.honor3.com, 若让它拉到 0.2.1 前端会因 CSP 拦截而白屏。
|
||||||
# 重打 APK 并确认原生层无破坏后, 再提升此值。
|
# 旧外壳请整包升级 (Windows 重装 exe / Android 换装 APK), 不要靠 OTA。
|
||||||
OTA_MIN_BINARY: '0.1.0'
|
OTA_MIN_BINARY: '0.2.1'
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: https://gitea.com/actions/setup-node@v4
|
uses: https://gitea.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Check version consistency & branch
|
- name: Check version consistency & branch
|
||||||
# 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota
|
# 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota
|
||||||
# (tauri.conf.json 的 version 就是 APK 的 versionName)
|
# (tauri.conf.json 的 version 就是 APK 的 versionName)
|
||||||
# 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json,
|
# 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json,
|
||||||
# dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号
|
# dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号
|
||||||
# (master 分支跳过分支前缀校验, 但四处一致性照样强制)
|
# (master 分支跳过分支前缀校验, 但四处一致性照样强制)
|
||||||
env:
|
env:
|
||||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||||
run: node scripts/ci/check-version.mjs
|
run: node scripts/ci/check-version.mjs
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci --no-audit --no-fund
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
# 全流程唯一一次前端构建: dist/ 既供 OTA 打包, 也通过 build context 进入镜像
|
# 全流程唯一一次前端构建: dist/ 既供 OTA 打包, 也通过 build context 进入镜像
|
||||||
# (docker build 传 PREBUILT_DIST=true 跳过镜像内的重复构建)
|
# (docker build 传 PREBUILT_DIST=true 跳过镜像内的重复构建)
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install minisign
|
- name: Install minisign
|
||||||
# 静态二进制直接下载, 免去每次 apt-get update + install
|
# 静态二进制直接下载, 免去每次 apt-get update + install
|
||||||
# 注意: release tarball 是 macOS 上打包的, 混有多个平台的 minisign 二进制,
|
# 注意: release tarball 是 macOS 上打包的, 混有多个平台的 minisign 二进制,
|
||||||
# 逐个试运行挑出本机可执行的, 都不行再回退 apt
|
# 逐个试运行挑出本机可执行的, 都不行再回退 apt
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mkdir -p /tmp/ms
|
mkdir -p /tmp/ms
|
||||||
curl -sL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz | tar xz -C /tmp/ms
|
curl -sL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz | tar xz -C /tmp/ms
|
||||||
BIN=""
|
BIN=""
|
||||||
for c in $(find /tmp/ms -type f -name minisign); do
|
for c in $(find /tmp/ms -type f -name minisign); do
|
||||||
if "$c" -v >/dev/null 2>&1; then BIN="$c"; break; fi
|
if "$c" -v >/dev/null 2>&1; then BIN="$c"; break; fi
|
||||||
done
|
done
|
||||||
if [ -n "$BIN" ]; then
|
if [ -n "$BIN" ]; then
|
||||||
sudo install -m755 "$BIN" /usr/local/bin/minisign
|
sudo install -m755 "$BIN" /usr/local/bin/minisign
|
||||||
else
|
else
|
||||||
echo "未找到可执行的静态二进制, 回退 apt 安装"
|
echo "未找到可执行的静态二进制, 回退 apt 安装"
|
||||||
sudo apt-get update && sudo apt-get install -y minisign
|
sudo apt-get update && sudo apt-get install -y minisign
|
||||||
fi
|
fi
|
||||||
minisign -v
|
minisign -v
|
||||||
|
|
||||||
- name: Build & sign OTA package
|
- name: Build & sign OTA package
|
||||||
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
|
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
|
||||||
# 发布说明 = 触发构建的 commit message (标题+正文; 打包脚本内安全截断 400 字符),
|
# 发布说明 = 触发构建的 commit message (标题+正文; 打包脚本内安全截断 400 字符),
|
||||||
# commit sha 通过 --commit 单独写入 manifest, 前端分行展示
|
# commit sha 通过 --commit 单独写入 manifest, 前端分行展示
|
||||||
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
|
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
|
||||||
env:
|
env:
|
||||||
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# 不用 head -c 截断: 那会把多字节汉字切成非法 UTF-8, latest.json 解析失败
|
# 不用 head -c 截断: 那会把多字节汉字切成非法 UTF-8, latest.json 解析失败
|
||||||
MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d')"
|
MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d')"
|
||||||
echo "notes: ${MSG}"
|
echo "notes: ${MSG}"
|
||||||
node scripts/build-ota.mjs --out ota-package --notes "${MSG}" --commit "${GITHUB_SHA}" --min-binary ${OTA_MIN_BINARY}
|
node scripts/build-ota.mjs --out ota-package --notes "${MSG}" --commit "${GITHUB_SHA}" --min-binary ${OTA_MIN_BINARY}
|
||||||
echo "===== latest.json ====="
|
echo "===== latest.json ====="
|
||||||
cat ota-package/latest.json
|
cat ota-package/latest.json
|
||||||
echo "===== bundle sha256 ====="
|
echo "===== bundle sha256 ====="
|
||||||
sha256sum ota-package/iboard-ota.tar.gz
|
sha256sum ota-package/iboard-ota.tar.gz
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Publish OTA package to server
|
||||||
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
|
# CI 跑在容器内, 无法直接写宿主文件系统; 用 docker cp 经bind-mount容器
|
||||||
# PREBUILT_DIST=true: 复用上一步构建好的 dist/, 镜像内跳过前端构建
|
# 把 ota-package 落到宿主 /app/iboard-prod/deploy/prod/data/ota
|
||||||
# DOCKER_BUILDKIT=1: Dockerfile 用了 cache mount 语法
|
# (该目录被生产 app 容器挂载为 /app/ota-package)。
|
||||||
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build --build-arg PREBUILT_DIST=true .
|
# docker 不可用或目录缺失时仅告警, 不影响发布。
|
||||||
env:
|
run: |
|
||||||
DOCKER_BUILDKIT: '1'
|
set -euo pipefail
|
||||||
|
HOST_DIR=/app/iboard-prod
|
||||||
- name: List images
|
if ! docker info >/dev/null 2>&1; then
|
||||||
run: docker images
|
echo "::warning::docker 不可用, 跳过 OTA 包同步"; exit 0
|
||||||
|
fi
|
||||||
|
docker run -d --name ota-bridge -v "${HOST_DIR}/deploy/prod/data/ota:/dst" alpine:3.20 sleep 30 >/dev/null
|
||||||
|
docker cp ota-package/. ota-bridge:/dst/ || echo "::warning::OTA 包复制失败"
|
||||||
|
docker rm -f ota-bridge >/dev/null 2>&1 || true
|
||||||
|
echo "== 宿主 OTA 目录 =="
|
||||||
|
docker run --rm -v "${HOST_DIR}/deploy/prod/data/ota:/d" alpine:3.20 sh -c 'ls -la /d | head -6'
|
||||||
|
docker restart iboard-prod-app-1 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
|
||||||
|
# PREBUILT_DIST=true: 复用上一步构建好的 dist/, 镜像内跳过前端构建
|
||||||
|
# DOCKER_BUILDKIT=1: Dockerfile 用了 cache mount 语法
|
||||||
|
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build --build-arg PREBUILT_DIST=true .
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: '1'
|
||||||
|
|
||||||
|
- name: List images
|
||||||
|
run: docker images
|
||||||
|
|
||||||
- name: Deploy 0.2.1 production stack
|
- name: Deploy 0.2.1 production stack
|
||||||
# 0.2.1 起生产为单端口架构: app(3001 对外) + pg(内网) + powersync(内网),
|
# 0.2.1 起生产为单端口架构: app(3001 对外) + pg(内网) + powersync(内网),
|
||||||
# 客户端同步经 app 的 /sync 反代进入同步服务 (无独立同步端口)。
|
# 客户端同步经 app 的 /sync 反代进入同步服务 (无独立同步端口)。
|
||||||
|
|||||||
@@ -67,3 +67,8 @@ deploy/0.2.1/.env
|
|||||||
/deploy/prod/.env
|
/deploy/prod/.env
|
||||||
/deploy/prod/data/
|
/deploy/prod/data/
|
||||||
|
|
||||||
|
|
||||||
|
# 本地临时脚本 (含口令, 不入库)
|
||||||
|
scripts/_*.mjs
|
||||||
|
.dsh-ops/
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,22 @@
|
|||||||
|
|
||||||
跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。
|
跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||||
|
|
||||||
- 首页主要是放经济文章
|
- 宏观:新闻 / 经济文章(本地),以及外接 ifinance 模块的分类(经济数据 / 策略研究 / 看板,运行时动态挂载为 tab)
|
||||||
- 数据主要是放各种经济数据和图表
|
- 数据与策略由外接 ifinance 模块承担(静态包,见 `../ifinance/readme.md`)
|
||||||
- 策略是放按照经济理论组织的相关数据和图表的对比
|
- 报警并入「监控」;油价模块已下线
|
||||||
|
- AI 接入:内置 MCP 服务,个人 Token 授权后可查询 / 新增 / 修改 / 删除 / 排序项目、任务、清单和打卡(见 [`docs/MCP.md`](docs/MCP.md))
|
||||||
|
|
||||||
## 首页
|
## 宏观
|
||||||
|
|
||||||
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
|
宏观页固定「新闻 / 经济文章」两个本地 tab,并动态挂载外接 ifinance `info.json` 的 `categories[]` 作为额外 tab:
|
||||||
|
|
||||||
### 新闻模块
|
- 新闻:每日夜间 3 点通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条,存入数据库;列表按时间倒序无限滚动。
|
||||||
|
- 经济文章:由 `content/articles/*.md` 承载的长文。
|
||||||
增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。
|
- 经济数据 / 策略研究 / …:交给外接 ifinance 模块,宿主只读取 `info.json` 并用 iframe 打开条目页(纯前端直连,见 `ifinance/readme.md` §9)。iBoard 本地不再保存指标 / 策略数据。
|
||||||
|
|
||||||
### 报警模块
|
|
||||||
|
|
||||||
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
|
|
||||||
|
|
||||||
## 监控
|
## 监控
|
||||||
|
|
||||||
监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址:<https://komari.honor3.com/>
|
监控分为主机监控 / 服务监控 / 环境监控 / 报警四个 tab。主机监控对接 komari:<https://komari.honor3.com/>;报警 tab 展示跨源数据差异与定时任务异常,按时间倒序。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -274,3 +271,47 @@ Web 端不会自动下载包,也不会替换前端代码。
|
|||||||
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
|
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
|
||||||
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
|
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
|
||||||
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
|
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows 本地实时预览(不打包)
|
||||||
|
|
||||||
|
改完前端 UI 想立刻在 **Windows 外壳**里看效果,不必 `tauri build`、不必打 NSIS/便携包、也不必重装。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run win:dev # 构建(shell 模式) + 挂载 + 重启外壳
|
||||||
|
npm run win:dev -- --no-build # 复用现有 dist, 只挂载+重启 (最快, 约 2 秒)
|
||||||
|
npm run win:dev -- --no-launch # 只挂载, 自己手动启动外壳
|
||||||
|
npm run win:dev -- --exe "D:\path\iBoard.exe"
|
||||||
|
npm run win:dev -- --clear # 卸载覆盖, 外壳回到内置前端
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原理
|
||||||
|
|
||||||
|
外壳用 `tauri-plugin-hotswap`,启动时**先看覆盖目录有没有生效的前端包,没有才回退到 exe 内置的 `dist`**。
|
||||||
|
这和线上 OTA 走的是同一条路径,所以只要把新构建的 `dist` 写进覆盖目录再重启外壳即可:
|
||||||
|
|
||||||
|
```
|
||||||
|
%APPDATA%\com.iboard.app\hotswap\
|
||||||
|
current ← 文本文件, 内容为生效的目录名 (如 seq-1793068631298)
|
||||||
|
seq-1793068631298\ ← 本次前端 (dist 内容) + hotswap-meta.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`hotswap-meta.json` 里有两个字段是**硬约束**,改错会导致覆盖包被丢弃或立刻回滚:
|
||||||
|
|
||||||
|
| 字段 | 取值 | 原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `min_binary_version` | **必须等于**外壳版本(当前 `0.2.1`) | 默认策略 `DiscardOnUpgrade` 在 `binary > min` 时丢弃缓存;安全线又要求 `binary >= min`,故只能取等号 |
|
||||||
|
| `confirmed` | **必须为 `true`** | 默认策略 `SingleLaunch` 对未确认版本会在启动时立即回滚 |
|
||||||
|
|
||||||
|
另外 `sequence` 取 `Date.now()`(大值),让 OTA 检查认为"已是最新",避免线上更新把本地版本顶掉。
|
||||||
|
|
||||||
|
### 注意
|
||||||
|
|
||||||
|
- 覆盖目录就是外壳**真实的 OTA 落地目录**,挂载期间"我的"页会显示本地版本(`0.2.1-local`);
|
||||||
|
测完建议 `npm run win:dev -- --clear` 复原,避免影响后续正常 OTA。
|
||||||
|
- 脚本按 `src-tauri/tauri.conf.json` 的 `identifier` 推导覆盖目录,换 identifier 后无需改脚本。
|
||||||
|
- 这只适用于**前端**改动。改了 `src-tauri/`(Rust/原生层、权限、CSP)必须重新打包外壳。
|
||||||
|
- 想要带 HMR 的完整开发循环(改代码即时刷新、免手动重启)用 `npm run tauri:dev`,
|
||||||
|
代价是首次要编译一次 debug 版 Rust 二进制。
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ services:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
image: iboard:latest
|
image: iboard:latest
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
pg:
|
pg:
|
||||||
@@ -51,8 +54,10 @@ services:
|
|||||||
MIHOME_CACHE_DIR: /app/cache
|
MIHOME_CACHE_DIR: /app/cache
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/cache:/app/cache
|
- ./data/cache:/app/cache
|
||||||
|
# OTA 热更包 (CI 构建产物, 由流水线写入宿主 data/ota)
|
||||||
|
- ./data/ota:/app/ota-package
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "${HOST_PORT:-3001}:3001"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/api/health || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/api/health || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
-- iBoard 0.2.1: 外键 uuid 镜像列触发器
|
||||||
|
-- 由 0.2.1 验证库导出; 服务端写入时自动维护 projectUuid/columnUuid/parentUuid/taskUuid,
|
||||||
|
-- 同步流据此把镜像列以 projectId/columnId/parentId/taskId 的别名下发给客户端。
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.func_boardcolumn_uuid_map()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
SELECT "uuid" INTO NEW."projectUuid" FROM "Project" WHERE "id" = NEW."projectId";
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Project % 不存在', NEW."projectId"; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.func_projectmember_uuid_map()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
SELECT "uuid" INTO NEW."projectUuid" FROM "Project" WHERE "id" = NEW."projectId";
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Project % 不存在', NEW."projectId"; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.func_task_uuid_map()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
SELECT "uuid" INTO NEW."projectUuid" FROM "Project" WHERE "id" = NEW."projectId";
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Project % 不存在', NEW."projectId"; END IF;
|
||||||
|
SELECT "uuid" INTO NEW."columnUuid" FROM "BoardColumn" WHERE "id" = NEW."columnId";
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'BoardColumn % 不存在', NEW."columnId"; END IF;
|
||||||
|
IF NEW."parentId" IS NULL THEN
|
||||||
|
NEW."parentUuid" := NULL;
|
||||||
|
ELSE
|
||||||
|
SELECT "uuid" INTO NEW."parentUuid" FROM "Task" WHERE "id" = NEW."parentId";
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Task % 不存在', NEW."parentId"; END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.func_taskmember_uuid_map()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
SELECT "uuid" INTO NEW."taskUuid" FROM "Task" WHERE "id" = NEW."taskId";
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Task % 不存在', NEW."taskId"; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.func_tasktag_uuid_map()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
SELECT "uuid" INTO NEW."taskUuid" FROM "Task" WHERE "id" = NEW."taskId";
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Task % 不存在', NEW."taskId"; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_boardcolumn_uuid_map BEFORE INSERT OR UPDATE ON public."BoardColumn" FOR EACH ROW EXECUTE FUNCTION func_boardcolumn_uuid_map();
|
||||||
|
CREATE TRIGGER trg_projectmember_uuid_map BEFORE INSERT OR UPDATE ON public."ProjectMember" FOR EACH ROW EXECUTE FUNCTION func_projectmember_uuid_map();
|
||||||
|
CREATE TRIGGER trg_task_uuid_map BEFORE INSERT OR UPDATE ON public."Task" FOR EACH ROW EXECUTE FUNCTION func_task_uuid_map();
|
||||||
|
CREATE TRIGGER trg_taskmember_uuid_map BEFORE INSERT OR UPDATE ON public."TaskMember" FOR EACH ROW EXECUTE FUNCTION func_taskmember_uuid_map();
|
||||||
|
CREATE TRIGGER trg_tasktag_uuid_map BEFORE INSERT OR UPDATE ON public."TaskTag" FOR EACH ROW EXECUTE FUNCTION func_tasktag_uuid_map();
|
||||||
+379
@@ -0,0 +1,379 @@
|
|||||||
|
# 0.2.1 发布任务 — 目标与现状
|
||||||
|
|
||||||
|
> 最后更新:2026-10-27 08:20
|
||||||
|
> 分支:`dev-0.2` 与 `master` 均已发布(`a1c267b`)
|
||||||
|
> 状态:**全部完成** —— 生产已切 0.2.1 单端口架构,master 发布 CI 通过,Android APK 与 Windows 安装包均已产出
|
||||||
|
|
||||||
|
## 零、执行结果速览(2026-10-27 已完成)
|
||||||
|
|
||||||
|
| 项目 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 生产地址 | `https://iboard.honor3.com` → baihu **3001**(NPM + 有效证书) |
|
||||||
|
| 生产架构 | `iboard-prod-app-1`(3001 对外) + `iboard-prod-pg-1`(内网) + `iboard-prod-powersync-1`(内网) |
|
||||||
|
| 端口 | **只剩 3001**;13001 / 13002 / 13443 全部下线 |
|
||||||
|
| 数据迁移 | MySQL → PG 完成:Project 3、Task 25、BoardColumn 12、User 1、CheckIn 17、News 19、IndicatorPoint 245、Mihome 等全部迁入 |
|
||||||
|
| 旧生产 | 容器 `iboard` 已 **stop(未删除)**,`docker start iboard` 可秒级回滚 |
|
||||||
|
| 登录态 | `JWT_SECRET` 沿用 `iboard`,现有用户无需重新登录 |
|
||||||
|
| 米家凭证 | `/app/iboard/cache` → `deploy/prod/data/cache`(`auth.txt` + `apizero/`) |
|
||||||
|
| E2E 验证 | 在线:项目卡片 + `ADMIN-16`/`ADMIN-18` 编号;断网:徽标出现、任务页数据完整、无 fail fetch;恢复:徽标消失 |
|
||||||
|
| master 发布 | 已推 `master`(`a1c267b`);CI `build-image` **10/10 步成功**(版本校验 / 前端 / OTA 签名包 / 镜像 / 部署步按设计跳过) |
|
||||||
|
| Android | `android-1.0` CI **16/16 步成功**,APK 20MB(versionName 0.2.1)已发布到 Gitea packages |
|
||||||
|
| Windows | 本地 `npm run build:win` 成功:NSIS 6.9MB + 便携 zip 7.9MB |
|
||||||
|
| OTA 热更 | **已打通**:`https://iboard.honor3.com/api/ota/check/0` 返回 `0.2.1-ota`、`min_binary_version: 0.2.1`、签名与下载地址齐全 |
|
||||||
|
|
||||||
|
### 迁移过程中踩到并修掉的坑
|
||||||
|
|
||||||
|
1. `prisma/migrations/` 是 **MySQL 语法**(反引号),PG 无法 `migrate deploy` → 改用 `prisma db push --accept-data-loss` 按 schema 建表。
|
||||||
|
2. `db push` 首次因参数不被识别而输出帮助(表结构停在旧 schema,缺 `autoArchive`)→ 去掉错误参数重跑,列已补齐。
|
||||||
|
3. 新库缺 **uuid 镜像触发器**(`projectUuid`/`columnUuid`/`parentUuid`/`taskUuid`)→ 从 0.2.1 验证库导出 5 个函数 + 5 个触发器,落库并入库 `deploy/prod/sql/uuid-triggers.sql`。
|
||||||
|
4. bash 把 `$$` 展开成 PID 导致 TRUNCATE 脚本报错 → 改用 SQL 文件传入。
|
||||||
|
5. baihu 上 `docker build` 缺 buildx → 改用 `docker compose build`。
|
||||||
|
6. **OTA 包此前完全没有**:手工构建的镜像内 `/app/ota-package` 只有 README(`ota-package/` 被 `.gitignore` 排除,CI 部署段又跳过),线上 OTA 接口返回空 → 已给 app 容器加 `data/ota` 挂载,并新增 CI 步骤用 `docker cp` 经 bind-mount 容器写入宿主(CI 在容器内无法直接写宿主文件)。
|
||||||
|
7. CI 首次写入路径多写了一层 `data/ota`(与 compose 的 `deploy/prod/data/ota` 不一致)→ 路径已对齐。
|
||||||
|
8. `OTA_MIN_BINARY` 由 `0.1.0` 提升到 **`0.2.1`**,防止旧外壳拉到新域名前端后被 CSP 拦截白屏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、任务目标(用户原始要求)
|
||||||
|
|
||||||
|
1. 域名 `iboard.honor3.com` 已指向 3000 端口、HTTPS 证书已配好 → 客户端全面切到该域名。
|
||||||
|
2. **Windows 外壳 + 离线编辑功能正式合并到 `dev-0.2`**,完整版本号 **0.2.1**。
|
||||||
|
3. 确保 **Android** 功能正常(通过 CI 编译验证,不在本地装 Android 工具链)。
|
||||||
|
4. 确保 **CI/CD** 正常。
|
||||||
|
5. Windows **本地打包正常**即可。
|
||||||
|
6. `dev-0.2` 更新完成后 **通过 `master` 分支发布**。
|
||||||
|
7. 追加要求:**不要再有 13002 这类独立端口**,所有版本合并成一套。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、已完成
|
||||||
|
|
||||||
|
### 2.1 分支合并
|
||||||
|
|
||||||
|
| 内容 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dev-0.2.1`(离线编辑 + 项目编号 + PG/PowerSync)→ `dev-0.2` | 已合并 `e689e44` |
|
||||||
|
| `dev-windows-0.1`(Windows 外壳:托盘/悬浮球/吸附/自启动)→ `dev-0.2` | 已按路径迁入 `eef2969` |
|
||||||
|
| `dev-0.2` → `android-1.0`(触发 APK 构建) | 已合并 `4958359` |
|
||||||
|
| 冲突处理 | `projects.js`/`tasks.js`/`api/task.js` 手工合并,**两边功能都保留**(`autoArchive` 归档 + `code` 编号) |
|
||||||
|
| 归档功能补齐到本地库 | `src/api/task.js` 新增 `archive`/`unarchive`/`archivedList`;`schema.js` 加 `archived/archivedAt/archiveSource/autoArchive`;`sync-config.yaml` 与 upload 处理器同步 |
|
||||||
|
| Prisma 客户端补齐 | dev-0.2 原来缺任务模块 6 个模型,已重新 generate `fc23919` |
|
||||||
|
|
||||||
|
### 2.2 版本与域名
|
||||||
|
|
||||||
|
- 版本统一 **0.2.1**:`package.json` / `src-tauri/tauri.conf.json` / `Cargo.toml` / `Cargo.lock`(`scripts/ci/check-version.mjs` 通过)
|
||||||
|
- 域名切 `https://iboard.honor3.com`:
|
||||||
|
- `src/api/client.js` 注释、`.env.shell`、`scripts/wsl/build-apk.sh`、`.gitea/workflows/android-build.yml`、`.gitea/workflows/docker-build.yml`、`Dockerfile`(`OTA_PUBLIC_BASE`)、`tauri.conf.json`(OTA endpoint + CSP)
|
||||||
|
- 旧域名 `bh.vps.honor3.com:3001` 仅保留在 CSP 白名单里做滚动兼容
|
||||||
|
|
||||||
|
### 2.3 单端口架构(去掉 13002)
|
||||||
|
|
||||||
|
| 改动 | 位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| Express 增加 `/sync`、`/socket` 同域反代到内网 powersync | `server/powersync-proxy.js` + `server/index.js`(挂在 `express.json()` 之前,支持长连接) |
|
||||||
|
| 生产编排:app(3001 对外) + pg(内网) + powersync(内网) | `deploy/prod/docker-compose.yml` |
|
||||||
|
| 生产部署脚本(幂等:迁移 → publication → 起栈 → 健康检查) | `scripts/deploy/prod.sh` |
|
||||||
|
| PG 初始化(powersync_storage 用户) | `deploy/prod/initdb/01-powersync-storage.sh` |
|
||||||
|
| 逻辑复制 publication(**建表后**执行) | `deploy/prod/sql/publication.sql` |
|
||||||
|
| 部署文档 | `deploy/prod/README.md`、`.env.example` |
|
||||||
|
| CI 部署段改为调用宿主脚本,未配 PG secrets 时自动跳过 | `.gitea/workflows/docker-build.yml` |
|
||||||
|
|
||||||
|
### 2.4 Windows 本地打包 — 通过 ✅
|
||||||
|
|
||||||
|
`npm run build:win` 全流程成功(本机需 Node 20.19+;已用便携 Node 20.18.1 之外的 Node 24 验证过 vite 构建):
|
||||||
|
|
||||||
|
| 产物 | 路径 | 大小 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 安装包 | `artifacts/windows/iBoard_0.2.1_x64-setup.exe` | 6.9 MB |
|
||||||
|
| 便携版 zip | `artifacts/windows/iBoard_0.2.1_x64-portable.zip` | 7.9 MB |
|
||||||
|
|
||||||
|
外壳能力(继承 dev-windows-0.1):托盘、悬浮球吸附、自动隐藏、单实例、自启动、窗口尺寸单值配置
|
||||||
|
(`%APPDATA%\com.iboard.app\shell-config.json` 的 `window_width`,高度按 405:720 锁定推导)。
|
||||||
|
|
||||||
|
离线能力(继承 0.2.1):GET 响应本地缓存、同步凭证持久化(离线不清库)、左上角「离线」徽标。
|
||||||
|
|
||||||
|
### 2.5 Android CI — 通过 ✅
|
||||||
|
|
||||||
|
- 触发:`android-1.0` 分支 push(`4ded8e3`)
|
||||||
|
- 结果:**16 个步骤全部 success**,`Build release APK` + `Publish APK to Gitea packages` 成功
|
||||||
|
- 产物:`app-universal-release.apk`(20 MB),versionName = **0.2.1**
|
||||||
|
已发布到 `http://git.honor3.com/api/packages/Fullstack/generic/iboard/1.0-4ded8e30/`
|
||||||
|
- 首次失败原因:runner 宿主机(baihu)磁盘 **95%** → `No space left on device`;
|
||||||
|
清理悬空镜像 + 构建缓存后释放 11.5 GB,重跑通过。**磁盘现已 60%(39 GB 可用)**
|
||||||
|
|
||||||
|
### 2.6 CI/CD 现状
|
||||||
|
|
||||||
|
| Workflow | 触发 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `docker-build.yml` | `master` push | 改造完成,未在 master 上验证(部署段会因缺 secrets 跳过) |
|
||||||
|
| `android-build.yml` | `android-*` push | **已验证通过** |
|
||||||
|
| `ota-build.yml` / `sync-registry.yml` | 参照原逻辑 | 未改动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、当前卡点(生产切换未执行)
|
||||||
|
|
||||||
|
生产镜像 **已构建成功**:`iboard:latest` = `db6b27b851b8`(924 MB,2026-10-26 22:53)
|
||||||
|
|
||||||
|
baihu 上当前跑着两套:
|
||||||
|
|
||||||
|
| 容器 | 用途 | 端口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `iboard`(旧) | **现网生产**,MySQL(宿主 3306) | 3001 |
|
||||||
|
| `iboard-021-app-1` 等 4 个 | 0.2.1 验证栈 | 13001 / 13002 / 13443 |
|
||||||
|
| (未启动)`iboard-prod` | **新生产栈**(PG + PowerSync + app) | 3001(计划) |
|
||||||
|
|
||||||
|
`/app/iboard-prod` 已上传源码、`deploy/prod/.env` 已生成(6 个密钥,600 权限)。
|
||||||
|
|
||||||
|
**剩余步骤(约 4 步)**:
|
||||||
|
|
||||||
|
1. 起新栈(先用 `HOST_PORT=13011` 灰度,不动现网)
|
||||||
|
2. 数据迁移:现网 MySQL → 新 PG(`scripts/etl-mariadb-to-pg.mjs`,只读源库)
|
||||||
|
3. 验证:健康检查、登录、任务列表、离线编辑(断网后仍可读)、同步回流
|
||||||
|
4. 切换:停 `iboard` 旧容器 → 新栈改绑 3001 → 域名 `iboard.honor3.com` 生效 → 下线验证栈(13001/13002/13443)
|
||||||
|
|
||||||
|
然后 `dev-0.2` → `master` 合并推送,触发 CI 发布。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、需要确认的信息(阻塞项)
|
||||||
|
|
||||||
|
1. **Gitea Actions Secrets 是否要配 PG 密钥?**
|
||||||
|
需配:`PG_PASSWORD`、`PS_STORAGE_PASSWORD`、`JWT_SECRET`、`POWERSYNC_JWT_SECRET`、`POWERSYNC_JWT_KEY_B64`。
|
||||||
|
不配的话:master 的 CI 只构建镜像不部署,生产靠手工执行 `scripts/deploy/prod.sh`(当前默认路径)。
|
||||||
|
|
||||||
|
2. **生产数据迁移的时间窗口**:迁移需短暂停写(现网 MySQL 只读快照)。是否可以挑一个低峰时间、期间 (~10 分钟) 站点只读?
|
||||||
|
|
||||||
|
3. **现网 MySQL 里的米家凭证/缓存**:`/app/iboard/cache`(`auth.txt` 等)是否要一并搬到新栈的 `deploy/prod/data/cache`?
|
||||||
|
|
||||||
|
4. **用户密码**:迁移只搬数据,`JWT_SECRET` 若换新,所有人需重新登录;是否沿用现网 `JWT_SECRET`(`iboard`)以保持登录态?**建议换新**(当前值过弱)。
|
||||||
|
|
||||||
|
5. **域名端口说明**:`iboard.honor3.com` 实际反代到 baihu **3001**(NPM 配置:`114.134.187.170:3001`,SSL 强制)。
|
||||||
|
你提到"已转向 3000 端口"——请确认指的是 `honor3.com` 根域(→ homepage)而非 `iboard` 子域,
|
||||||
|
否则需要调整 NPM 的 forward_port。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、关键路径与命令速查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 仓库
|
||||||
|
C:\Users\Rabbit\Documents\workbetch\iboard # dev-0.2 主工作区
|
||||||
|
C:\Users\Rabbit\Documents\workbetch\iboard-dev-0.2.1 # 研究分支 worktree
|
||||||
|
C:\Users\Rabbit\Documents\workbetch\iboard-dev-windows # Windows 外壳 worktree
|
||||||
|
|
||||||
|
# 服务器
|
||||||
|
ssh root@baihu # 生产 + CI runner(baihu 上跑 act_runner,注册名 baishe)
|
||||||
|
ssh root@baishe # 反代(NPM)、Gitea、homepage
|
||||||
|
|
||||||
|
# 生产目录
|
||||||
|
/app/iboard-prod/deploy/prod/{docker-compose.yml,.env,data/,powersync/,sql/,initdb/}
|
||||||
|
|
||||||
|
# 常用
|
||||||
|
docker compose -f /app/iboard-prod/deploy/prod/docker-compose.yml --env-file /app/iboard-prod/deploy/prod/.env ps
|
||||||
|
bash /app/iboard-prod/scripts/deploy/prod.sh
|
||||||
|
curl -s https://iboard.honor3.com/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五之二、后续动作执行步骤(已确认方案,按序执行)
|
||||||
|
|
||||||
|
### 决策确认(2026-10-27)
|
||||||
|
|
||||||
|
| 项 | 决定 |
|
||||||
|
| --- | --- |
|
||||||
|
| PG 密钥 | **不进 Gitea Secrets**,直接写在服务器 `/app/iboard-prod/deploy/prod/.env`(600) |
|
||||||
|
| 迁移时机 | 立即执行 |
|
||||||
|
| 米家凭证 | 保留,`/app/iboard/cache` 搬到新栈 `deploy/prod/data/cache` |
|
||||||
|
| `JWT_SECRET` | 沿用现网值(`iboard`),保持现有登录态 |
|
||||||
|
| 域名 | `iboard.honor3.com` → baihu **3001**(NPM 已配好,SSL 强制) |
|
||||||
|
|
||||||
|
### 步骤 A:准备(不改动现网)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# A1. 沿用现网 JWT_SECRET (其余密钥保持已生成的随机值)
|
||||||
|
cd /app/iboard-prod/deploy/prod
|
||||||
|
sed -i 's/^JWT_SECRET=.*/JWT_SECRET=iboard/' .env
|
||||||
|
|
||||||
|
# A2. 搬米家缓存 (auth.txt 等)
|
||||||
|
mkdir -p data/cache
|
||||||
|
rsync -a /app/iboard/cache/ data/cache/
|
||||||
|
|
||||||
|
# A3. 保留旧镜像用于回滚
|
||||||
|
docker tag iboard:latest iboard:mysql-final # 需在重新构建前执行; 若已覆盖见"回滚预案"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 B:起灰度栈(不动现网 3001)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /app/iboard-prod/deploy/prod
|
||||||
|
HOST_PORT=13011 docker compose --project-name iboard-prod \
|
||||||
|
--env-file .env -f docker-compose.yml up -d pg
|
||||||
|
|
||||||
|
# 迁移建表
|
||||||
|
docker run --rm --network iboard-prod_default \
|
||||||
|
-e DATABASE_URL="postgresql://iboard:$(grep ^PG_PASSWORD .env | cut -d= -f2)@pg:5432/iboard" \
|
||||||
|
iboard:latest npx prisma migrate deploy
|
||||||
|
|
||||||
|
# 建 publication (必须在建表之后)
|
||||||
|
docker compose --project-name iboard-prod --env-file .env -f docker-compose.yml exec -T pg \
|
||||||
|
psql -U iboard -d iboard -v ON_ERROR_STOP=1 -f /dev/stdin < sql/publication.sql
|
||||||
|
|
||||||
|
# 起 app + powersync (只有 13011 对外)
|
||||||
|
HOST_PORT=13011 docker compose --project-name iboard-prod \
|
||||||
|
--env-file .env -f docker-compose.yml up -d app powersync
|
||||||
|
|
||||||
|
# 验收
|
||||||
|
curl -s http://127.0.0.1:13011/api/health # {"ok":true,...}
|
||||||
|
ss -tlnp | grep -E ':(13011|5432|8080)\b' # 只应看到 13011
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 C:数据迁移(MySQL → PG)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --network iboard-prod_default \
|
||||||
|
-e DATABASE_URL="postgresql://iboard:<PG_PASSWORD>@pg:5432/iboard" \
|
||||||
|
-e SRC_DATABASE_URL="mysql://root:<MYSQL_PASSWORD>@<host>:3306/iboard" \
|
||||||
|
iboard:latest node scripts/etl-mariadb-to-pg.mjs
|
||||||
|
|
||||||
|
# 迁移后重启同步服务重建快照
|
||||||
|
docker compose --project-name iboard-prod --env-file .env -f docker-compose.yml restart powersync
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收**:项目 3 个(ADMIN / IBOARD / P3F921)、任务 ~22 条、用户 1 个(admin@iboard.local);
|
||||||
|
`docker compose logs powersync` 显示 replication 已激活且无报错。
|
||||||
|
|
||||||
|
### 步骤 D:功能验证(13011)
|
||||||
|
|
||||||
|
- 登录 `admin@iboard.local`
|
||||||
|
- 任务页:项目卡片、任务编号(如 `ADMIN-16`)
|
||||||
|
- **离线验证**:断网后列表仍可读、左上角出现「离线」徽标,恢复网络自动同步回流
|
||||||
|
- 监控 / 宏观 / 打卡页有数据
|
||||||
|
|
||||||
|
### 步骤 E:切换生产
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# E1. 停旧容器 (MySQL 版)
|
||||||
|
docker rm -vf iboard || true
|
||||||
|
|
||||||
|
# E2. 新栈改绑 3001
|
||||||
|
cd /app/iboard-prod/deploy/prod
|
||||||
|
HOST_PORT=3001 docker compose --project-name iboard-prod \
|
||||||
|
--env-file .env -f docker-compose.yml up -d app
|
||||||
|
curl -s https://iboard.honor3.com/api/health # 经 NPM + 证书
|
||||||
|
|
||||||
|
# E3. 下线 0.2.1 验证栈 (释放 13001/13002/13443)
|
||||||
|
cd /app/iboard-0.2.1/deploy/0.2.1 && docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收**:`https://iboard.honor3.com` 可登录、任务正常、离线可编辑;
|
||||||
|
`ss -tlnp` 只剩 3001(外加仅供本机的 3306)。
|
||||||
|
|
||||||
|
### 步骤 F:发布
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master && git merge dev-0.2 --no-edit
|
||||||
|
git push origin master # 触发 CI: 构建镜像 + OTA
|
||||||
|
```
|
||||||
|
|
||||||
|
CI 未配 PG secrets → 只构建不部署;生产已由步骤 B~E 手工就绪。
|
||||||
|
|
||||||
|
### 回滚预案
|
||||||
|
|
||||||
|
| 场景 | 动作 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新栈起不来 | 旧容器未停,现网不受影响,回步骤 B 排查 |
|
||||||
|
| 迁移数据不对 | 清空 `deploy/prod/data/pg` 后重跑 ETL |
|
||||||
|
| 切换后严重故障 | **`docker start iboard`**(旧容器仍在,见 6.2 警告:旧镜像已被 prune,不能重建) |
|
||||||
|
|
||||||
|
## 六、遗留清单与清理建议(2026-10-27 实测)
|
||||||
|
|
||||||
|
### 6.1 本机(Windows)
|
||||||
|
|
||||||
|
| 路径 | 占用 | 建议 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `workbetch/iboard`(主 worktree) | 3.81 GB | **保留** | 含 `node_modules` 0.49 GB + `src-tauri/target` 2.56 GB |
|
||||||
|
| `workbetch/iboard-dev-0.2.1` | 0.53 GB | **可删** | 内容已全部并入 `dev-0.2` |
|
||||||
|
| `workbetch/iboard-dev-windows` | 8.88 GB | **可删** | 同上,最大占用(含 Rust 编译产物) |
|
||||||
|
| `iboard/src-tauri/target` | 2.56 GB | 可选 `cargo clean` | 删后下次打包需重编译(约 8 分钟) |
|
||||||
|
| `iboard/artifacts/windows` | 32 MB | 保留最新安装包 | 0.2.1 exe/zip |
|
||||||
|
| `%TEMP%\node-portable` | 78 MB | **保留** | 本机无系统 Node/npm,`npm run build:win` 依赖它 |
|
||||||
|
| `%TEMP%\esbuild-*`(4 个) | 8 MB | 可删 | 构建残留 |
|
||||||
|
| `%TEMP%\iboard-e2e`、`iboard-shell-offline` | < 1 MB | 可删 | 验证截图 |
|
||||||
|
| `%TEMP%\edge-e2e-profile` | ~100 MB | 可删 | 无头浏览器测试配置 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 删除 worktree 与已合并分支(内容已在 dev-0.2)
|
||||||
|
git worktree remove ../iboard-dev-0.2.1
|
||||||
|
git worktree remove ../iboard-dev-windows
|
||||||
|
git branch -d dev-0.2.1 dev-windows-0.1
|
||||||
|
git branch -D tmp-offline-edit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 服务器 baihu
|
||||||
|
|
||||||
|
| 路径/对象 | 占用 | 建议 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/app/iboard-prod` | 72 MB | **保留** | 当前生产 |
|
||||||
|
| `/app/iboard` | 24 KB | 暂留 → 可删 | 旧生产只剩 `cache/`(`auth.txt` 已复制到新栈)+ `start.sh` |
|
||||||
|
| `/app/iboard-0.2.1` | 71 MB | **可删** | 验证栈已 down;其中 `deploy/0.2.1/data` 67 MB 是废弃 PG 数据 |
|
||||||
|
| `/app/cicd` | 36 KB | **保留** | act_runner 配置 |
|
||||||
|
| 容器 `iboard`(Exited) | — | **暂留** | 当前唯一回滚手段(见下方警告) |
|
||||||
|
| 镜像 `iboard-021-app:latest` | 924 MB | **可删** | 验证栈镜像 |
|
||||||
|
| 镜像 `iboard:latest` | 934 MB | 保留 | CI 构建产物 |
|
||||||
|
| 悬空镜像 + 构建缓存 | 18.7 GB + 2.4 GB | 可清 | `docker image prune -f && docker builder prune -af` |
|
||||||
|
|
||||||
|
> **⚠ 回滚能力警告(实测更正)**
|
||||||
|
> 原计划保留的 `iboard:mysql-final` 旧镜像**已被 `docker image prune` 清除** —— 旧镜像失去 tag 后成为悬空镜像被回收。
|
||||||
|
> 现在唯一回滚途径是重启仍存在的旧容器 `iboard`(镜像层随容器保留):
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> docker stop iboard-prod-app-1
|
||||||
|
> docker start iboard # 旧 MySQL 版立即恢复 3001
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **在再次执行 `docker image prune` 前,务必先确认旧容器仍在**;容器一旦删除,MySQL 版镜像只能从 git 历史重新构建。
|
||||||
|
|
||||||
|
### 6.3 代码与其他
|
||||||
|
|
||||||
|
- 临时测试脚本(`scripts/_prod-e2e.mjs`、`_browser-test.mjs`、`_shell-offline-test.mjs`)含真实口令,已加入 `.gitignore`(`scripts/_*.mjs`),**不要入库**
|
||||||
|
- `0.2.1` 验证库里的测试任务(如 `浏览器验证任务`)已按 MySQL 现状一并迁入 PG,如需清理直接在 PG 删除
|
||||||
|
- `prisma/migrations/` 仍是 MySQL 语法,**新环境建表必须用 `prisma db push --accept-data-loss`**(已写入 `deploy/prod/README.md`)
|
||||||
|
- `JWT_SECRET` 仍为弱值(`iboard`),建议择机换新(换新需全员重新登录)
|
||||||
|
- `node_modules` 在多个 Node 版本间切换过,遇 esbuild 权限报错时清空重装
|
||||||
|
|
||||||
|
## 七、外壳升级策略:必须整包升级,不能只靠 OTA
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
|
||||||
|
| 平台 | 结论 | 关键原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Windows 外壳(0.1.0) | **必须重装 exe** | ① CSP 白名单编译进原生二进制,0.1.0 不含 `https://iboard.honor3.com`;② 旧外壳 OTA 检查地址指向 `13001`,**该端口已下线**,连检查都失败 |
|
||||||
|
| Android(0.1.0 APK) | **必须换装 APK** | CSP 同样是原生配置;旧 APK 的 OTA 端点(3001)虽然有效,但拉到 0.2.1 前端会被 CSP 拦截 → 白屏 |
|
||||||
|
|
||||||
|
### 为什么 OTA 不够
|
||||||
|
|
||||||
|
`tauri-plugin-hotswap` 的 OTA 包内容只有 `bundle.tar.gz`(即 `dist/` 前端资源),**不触碰原生层**:
|
||||||
|
|
||||||
|
| 配置项 | 位置 | OTA 能否修改 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CSP 白名单(`connect-src`) | `tauri.conf.json` → 编译期 `generate_context!` 注入二进制 | ❌ |
|
||||||
|
| OTA 检查 endpoint | 同上 | ❌ |
|
||||||
|
| 窗口尺寸/最小尺寸 | 同上(另可被 `shell-config.json` 覆盖) | ❌ |
|
||||||
|
| 托盘 / 悬浮球 / 自启动 | Rust `src-tauri/src/shell/*` | ❌ |
|
||||||
|
| 页面 JS/CSS/WASM(含离线编辑、徽标、编号展示) | `dist/` | ✅ |
|
||||||
|
|
||||||
|
因此旧外壳无法通过 OTA 获得访问新域名的能力。
|
||||||
|
|
||||||
|
### 已采取的保护措施
|
||||||
|
|
||||||
|
CI 的 `OTA_MIN_BINARY` 已从 `0.1.0` 提升到 **`0.2.1`**:
|
||||||
|
|
||||||
|
- 旧外壳(0.1.0 < 0.2.1)会被 hotswap 判定为不兼容 → **拒绝接收新前端**
|
||||||
|
- 旧外壳继续运行旧前端,仍访问 `http://bh.vps.honor3.com:3001`(该地址现指向新栈,`/api` 完全兼容)
|
||||||
|
- 结果:**旧外壳不会白屏**,只是缺少离线编辑、项目编号等 0.2.1 新能力
|
||||||
|
|
||||||
|
### 升级后
|
||||||
|
|
||||||
|
- 新外壳版本 0.2.1,CSP 含 `https://iboard.honor3.com`,OTA 恢复正常(后续纯前端改动可继续走 OTA)
|
||||||
|
- Windows 窗口尺寸通过 `%APPDATA%\com.iboard.app\shell-config.json` 的 `window_width` 单值配置
|
||||||
|
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# AI 接入 (MCP)
|
||||||
|
|
||||||
|
iBoard 内置一个 MCP (Model Context Protocol) 服务,AI 客户端可以通过个人授权 Token 直接查询、新增、修改、删除和排序当前用户的数据。
|
||||||
|
|
||||||
|
## 1. 创建 Token
|
||||||
|
|
||||||
|
1. 登录 iBoard,进入「我的」。
|
||||||
|
2. 找到「AI 接入 (MCP)」。
|
||||||
|
3. 输入 Token 名称(例如 `R1`、`家里电脑`),点击「新建 Token」。
|
||||||
|
4. 复制弹窗中的 Token。**明文只显示一次**,关闭后无法再次查看。
|
||||||
|
|
||||||
|
每个 Token 独立命名,并与创建它的用户绑定。使用该 Token 的 AI 拥有该用户的全部业务权限;删除 Token 后立即失效。
|
||||||
|
|
||||||
|
## 2. 连接方式
|
||||||
|
|
||||||
|
- 端点:`https://<你的 iBoard 域名>/api/mcp`(页面上的「MCP 服务地址」可直接复制)
|
||||||
|
- 协议:Streamable HTTP(无状态)
|
||||||
|
- 鉴权头:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <Token>
|
||||||
|
```
|
||||||
|
|
||||||
|
通用 MCP 客户端配置示例(以支持 HTTP transport 的客户端为例):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"iboard": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://<你的 iBoard 域名>/api/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ibm_xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 可用能力
|
||||||
|
|
||||||
|
- 项目:查询 / 新建 / 修改 / 删除;成员添加与移除
|
||||||
|
- 看板列:查询 / 新建 / 修改 / 删除;列排序
|
||||||
|
- 任务:查询 / 新建 / 修改 / 删除 / 归档 / 恢复 / 排序 / 跨项目拷贝
|
||||||
|
- 检查清单:查询 / 新建 / 修改 / 删除 / 复制;检查项增删改、排序、一键勾选;模板查询
|
||||||
|
- 打卡:查询 / 新建 / 修改 / 删除 / 排序;打卡与撤销打卡
|
||||||
|
|
||||||
|
工具名与参数以 MCP `tools/list` 返回为准,所有描述均为中文,便于 AI 选择。
|
||||||
|
|
||||||
|
## 4. 安全建议
|
||||||
|
|
||||||
|
- Token 等同于账号密码,请勿提交到 Git 或发送到公开群聊。
|
||||||
|
- 给不同设备 / 不同 AI 客户端分别创建具名 Token,便于单独吊销。
|
||||||
|
- 不再使用时在「我的 → AI 接入 (MCP)」中删除。
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 |
|
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 |
|
||||||
| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 |
|
| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 |
|
||||||
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(关键词/类型/列/归档时段)+ 分页「加载更多」+ 行内「恢复 / 删除」 |
|
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(**关键词 / 看板列 / 归档时段**;时段含「本周 / 本月 / 本季度」快捷项与自定义区间;条件编辑后点「筛选」统一提交,另有「重置」;**不提供类型过滤**)+ 已应用条件标签(可单独撤销)与命中条数 + 分页「加载更多」+ 行内「恢复 / 删除」 |
|
||||||
| 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 |
|
| 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 |
|
||||||
|
|
||||||
### 15.5 边界
|
### 15.5 边界
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>已归档任务管理 · 界面重设计(原型待评审)</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#f0f2f5; --page:#fff; --card:#fff;
|
||||||
|
--text:#333; --muted:#666; --weak:#999; --line:#eee;
|
||||||
|
--primary:#1677ff; --danger:#ff3141; --success:#00b578;
|
||||||
|
--nav-h:45px; --tab-h:72px;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{
|
||||||
|
background:var(--bg); color:#1f2328; font-size:14px; line-height:1.6;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',Roboto,sans-serif;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
.wrap{max-width:1400px;margin:0 auto;padding:36px 24px 72px}
|
||||||
|
.doc-head{border-bottom:1px solid #e3e6ea;padding-bottom:20px;margin-bottom:28px}
|
||||||
|
.doc-head h1{font-size:26px;font-weight:700;letter-spacing:.5px}
|
||||||
|
.doc-head p{color:var(--muted);font-size:13px;margin-top:6px}
|
||||||
|
.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
||||||
|
.meta span{font-size:12px;background:#fff;border:1px solid #e3e6ea;border-radius:999px;padding:3px 12px;color:var(--muted)}
|
||||||
|
h2{font-size:19px;font-weight:700;margin:40px 0 6px;display:flex;align-items:center;gap:10px}
|
||||||
|
h2::before{content:"";width:4px;height:17px;background:var(--primary);border-radius:2px}
|
||||||
|
h2 .sub{font-size:12px;font-weight:400;color:var(--weak)}
|
||||||
|
p.desc{color:var(--muted);font-size:13px;margin-bottom:16px}
|
||||||
|
|
||||||
|
/* ---------- 改动要点 ---------- */
|
||||||
|
.changes{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}
|
||||||
|
.chg{background:#fff;border:1px solid #e6e8eb;border-radius:12px;padding:14px 16px}
|
||||||
|
.chg .k{font-size:11px;color:var(--primary);font-weight:700;letter-spacing:.6px}
|
||||||
|
.chg h4{font-size:14px;font-weight:700;margin:5px 0 6px}
|
||||||
|
.chg p{font-size:12.5px;color:var(--muted);line-height:1.65}
|
||||||
|
.chg .old{color:var(--danger);font-size:12px;text-decoration:line-through}
|
||||||
|
|
||||||
|
/* ---------- 手机框 ---------- */
|
||||||
|
.stage{display:flex;gap:34px;flex-wrap:wrap;align-items:flex-start;margin-top:18px}
|
||||||
|
.phone{
|
||||||
|
width:400px;height:812px;background:var(--page);position:relative;overflow:hidden;
|
||||||
|
border:1px solid #d8dde3;border-radius:22px;box-shadow:0 10px 24px rgba(15,23,42,.08);
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
}
|
||||||
|
.phone::after{content:"";position:absolute;top:8px;left:50%;transform:translateX(-50%);width:112px;height:19px;background:#111827;border-radius:12px;opacity:.9;z-index:5}
|
||||||
|
.phone.sm{width:340px;height:700px}
|
||||||
|
.phone .nav{
|
||||||
|
height:var(--nav-h);margin-top:26px;flex-shrink:0;display:flex;align-items:center;gap:10px;
|
||||||
|
padding:0 14px;background:#fff;border-bottom:1px solid var(--line);
|
||||||
|
}
|
||||||
|
.phone .nav .back{font-size:20px;color:#666;line-height:1;cursor:pointer}
|
||||||
|
.phone .nav .title{font-size:16px;font-weight:700}
|
||||||
|
.phone .nav .proj{margin-left:auto;font-size:11px;color:var(--weak);background:#f5f6f8;border-radius:999px;padding:2px 9px;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.phone .body{flex:1;min-height:0;overflow-y:auto;background:#f5f5f5;padding-bottom:18px}
|
||||||
|
|
||||||
|
/* ---------- 筛选卡 ---------- */
|
||||||
|
.fcard{background:#fff;margin:10px 12px 0;border-radius:12px;padding:4px 14px 12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||||
|
.frow{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid #f4f5f7}
|
||||||
|
.frow:last-of-type{border-bottom:0}
|
||||||
|
.frow > .lbl{width:56px;flex-shrink:0;font-size:12.5px;font-weight:600;color:var(--text)}
|
||||||
|
.frow > .ctl{flex:1;min-width:0}
|
||||||
|
/* 标题输入框前的提示 */
|
||||||
|
.kwbox{display:flex;align-items:center;gap:7px;background:#f7f8fa;border:1px solid #eceef1;border-radius:8px;padding:6px 9px}
|
||||||
|
.kwbox:focus-within{border-color:var(--primary);background:#fff}
|
||||||
|
.kwbox .ph{font-size:11.5px;color:var(--weak);flex-shrink:0}
|
||||||
|
.kwbox input{flex:1;min-width:0;border:0;background:transparent;font-size:13px;outline:none;color:var(--text)}
|
||||||
|
.kwbox input::placeholder{color:#c2c6cc}
|
||||||
|
.kwbox .clr{font-size:12px;color:#c2c6cc;cursor:pointer;flex-shrink:0}
|
||||||
|
|
||||||
|
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
.chip{
|
||||||
|
font-size:12px;padding:4px 11px;border-radius:999px;border:1px solid #e6e8eb;
|
||||||
|
background:#fafbfc;color:var(--muted);cursor:pointer;white-space:nowrap;transition:.15s;
|
||||||
|
}
|
||||||
|
.chip:hover{border-color:#c9d3e0}
|
||||||
|
.chip.on{background:#eaf3ff;border-color:var(--primary);color:var(--primary);font-weight:600}
|
||||||
|
.chip .x{margin-left:5px;color:#9aa4b2}
|
||||||
|
|
||||||
|
.range{display:flex;align-items:center;gap:7px;margin-top:8px}
|
||||||
|
.range .dp{font-size:12px;padding:4px 10px;border:1px dashed #d9dee5;border-radius:7px;color:var(--weak);cursor:pointer;background:#fff}
|
||||||
|
.range .dp.on{color:var(--text);border-style:solid;border-color:#d9dee5}
|
||||||
|
.range .sep{color:#c2c6cc;font-size:12px}
|
||||||
|
|
||||||
|
.factions{display:flex;gap:9px;padding-top:12px}
|
||||||
|
.btn{flex:1;height:36px;border-radius:9px;border:0;font-size:13.5px;font-weight:600;cursor:pointer;transition:.15s}
|
||||||
|
.btn.ghost{background:#f4f5f7;color:var(--muted);flex:0 0 84px}
|
||||||
|
.btn.primary{background:var(--primary);color:#fff;box-shadow:0 2px 8px rgba(22,119,255,.26)}
|
||||||
|
.btn.primary:active{transform:translateY(1px)}
|
||||||
|
.btn .badge{display:inline-block;min-width:16px;height:16px;line-height:16px;border-radius:8px;background:rgba(255,255,255,.28);font-size:10.5px;margin-left:5px;padding:0 4px}
|
||||||
|
|
||||||
|
/* ---------- 已应用条件 ---------- */
|
||||||
|
.applied{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:11px 14px 3px}
|
||||||
|
.applied .tag{font-size:11.5px;background:#eaf3ff;color:var(--primary);border-radius:6px;padding:2px 7px;display:flex;align-items:center;gap:5px}
|
||||||
|
.applied .tag b{font-weight:600}
|
||||||
|
.applied .tag .x{cursor:pointer;opacity:.6}
|
||||||
|
.applied .cnt{margin-left:auto;font-size:11.5px;color:var(--weak)}
|
||||||
|
|
||||||
|
/* ---------- 列表 ---------- */
|
||||||
|
.list{padding:8px 12px 0;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.item{background:#fff;border-radius:10px;padding:11px 12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||||
|
.item .top{display:flex;gap:8px;align-items:flex-start}
|
||||||
|
.item .ttl{flex:1;min-width:0;font-size:13.5px;font-weight:600;line-height:1.42;word-break:break-word}
|
||||||
|
.item .num{font-size:10px;color:#9aa4b2;background:#f5f6f8;border-radius:4px;padding:1px 5px;flex-shrink:0;margin-top:1px}
|
||||||
|
.item .meta{display:flex;gap:7px;align-items:center;flex-wrap:wrap;margin-top:7px;font-size:10.5px;color:var(--weak)}
|
||||||
|
.item .meta .col{color:var(--muted)}
|
||||||
|
.item .meta .dot{width:3px;height:3px;border-radius:50%;background:#d5d9e0}
|
||||||
|
.item .acts{display:flex;gap:7px;justify-content:flex-end;margin-top:9px;padding-top:9px;border-top:1px dashed #f0f1f3}
|
||||||
|
.mini{font-size:11.5px;padding:3px 12px;border-radius:999px;border:1px solid var(--primary);color:var(--primary);background:#fff;cursor:pointer;white-space:nowrap}
|
||||||
|
.mini.danger{border-color:#ffd4d7;color:var(--danger)}
|
||||||
|
.kind{font-size:10px;line-height:1;font-weight:600;color:#fff;border-radius:4px;padding:3px 5px}
|
||||||
|
|
||||||
|
.empty{text-align:center;padding:56px 20px;color:#b8bcc4;font-size:13px}
|
||||||
|
.empty .ico{font-size:34px;opacity:.35;margin-bottom:10px}
|
||||||
|
.more{display:block;width:calc(100% - 24px);margin:12px auto 0;height:34px;border-radius:9px;border:1px solid #e6e8eb;background:#fff;color:var(--muted);font-size:12.5px;cursor:pointer}
|
||||||
|
|
||||||
|
.toast{
|
||||||
|
position:absolute;left:50%;bottom:56px;transform:translateX(-50%) translateY(8px);
|
||||||
|
background:rgba(0,0,0,.78);color:#fff;font-size:12.5px;padding:8px 16px;border-radius:8px;
|
||||||
|
opacity:0;transition:.22s;z-index:9;pointer-events:none;white-space:nowrap;
|
||||||
|
}
|
||||||
|
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||||
|
|
||||||
|
.caption{margin-top:12px;background:#fff;border:1px solid #e6e8eb;border-radius:10px;padding:11px 14px;width:400px}
|
||||||
|
.caption b{font-size:13px;display:block;margin-bottom:4px}
|
||||||
|
.caption span{font-size:12px;color:var(--muted);line-height:1.65}
|
||||||
|
|
||||||
|
.notes{flex:1;min-width:300px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.note{background:#fff;border:1px solid #e6e8eb;border-radius:12px;padding:14px 16px}
|
||||||
|
.note h4{font-size:13px;font-weight:700;margin-bottom:7px;display:flex;align-items:center;gap:7px}
|
||||||
|
.note h4 i{width:18px;height:18px;border-radius:50%;background:#eaf3ff;color:var(--primary);font-size:11px;font-style:normal;display:flex;align-items:center;justify-content:center;font-weight:700}
|
||||||
|
.note ul{list-style:none;font-size:12.5px;color:var(--muted);line-height:1.85}
|
||||||
|
.note ul li::before{content:"·";color:#c2c6cc;margin-right:6px}
|
||||||
|
.note code{background:#f5f6f8;border-radius:4px;padding:1px 5px;font-size:11.5px;color:#c7254e}
|
||||||
|
|
||||||
|
table{width:100%;border-collapse:collapse;background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e6e8eb;font-size:12.5px}
|
||||||
|
th,td{padding:10px 14px;text-align:left;border-bottom:1px solid #f0f1f3;vertical-align:top}
|
||||||
|
th{background:#fafbfc;font-size:12px;color:var(--muted);font-weight:600}
|
||||||
|
tr:last-child td{border-bottom:0}
|
||||||
|
td .no{color:var(--danger)}
|
||||||
|
td .yes{color:var(--success);font-weight:600}
|
||||||
|
.variants{display:flex;gap:26px;flex-wrap:wrap;margin-top:16px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<div class="doc-head">
|
||||||
|
<h1>已归档任务管理 · 界面重设计</h1>
|
||||||
|
<p>对应需求 T-ARC-04/05/06。本文件是<strong>评审用原型</strong>,不含业务代码改动;确认后再落地。</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>原型 · 待评审</span>
|
||||||
|
<span>2026-10-28</span>
|
||||||
|
<span>可交互:点预设 / 筛选 / 恢复</span>
|
||||||
|
<span>范围:任务模块 › 项目 › 已归档</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>改动要点 <span class="sub">对照你提出的 4 点</span></h2>
|
||||||
|
<div class="changes">
|
||||||
|
<div class="chg">
|
||||||
|
<div class="k">01</div>
|
||||||
|
<h4>去掉「类型」过滤</h4>
|
||||||
|
<p>现版本的「全部类型」下拉整体移除。任务类型是弱维度,归档场景几乎不按类型找东西,反而占掉一横行高。</p>
|
||||||
|
<p class="old">全部类型 ▾</p>
|
||||||
|
</div>
|
||||||
|
<div class="chg">
|
||||||
|
<div class="k">02</div>
|
||||||
|
<h4>输入框前加提示</h4>
|
||||||
|
<p>关键词输入框前加固定标签「标题」,输入框内用灰字占位「输入关键词」——标签常驻,占位随输入消失,不会出现"不知道搜什么"。</p>
|
||||||
|
<p class="old">搜索任务标题(无标签)</p>
|
||||||
|
</div>
|
||||||
|
<div class="chg">
|
||||||
|
<div class="k">03</div>
|
||||||
|
<h4>归档时间快捷选择</h4>
|
||||||
|
<p>新增 <b>全部 / 本周 / 本月 / 本季度</b> 四个一键预设,选完自动算出起止日期并显示;需要精确区间时再选「自定义」。</p>
|
||||||
|
<p class="old">开始日期 ~ 结束日期(纯手点)</p>
|
||||||
|
</div>
|
||||||
|
<div class="chg">
|
||||||
|
<div class="k">04</div>
|
||||||
|
<h4>补上「筛选」按钮</h4>
|
||||||
|
<p>现版本改任何条件都立即查询、没有确认动作。改为:改条件 → 点<b>筛选</b>才生效,按钮上显示生效条件数;另给「重置」。</p>
|
||||||
|
<p class="old">无按钮(自动即时查询)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>主界面 <span class="sub">可交互 · 点一下试试</span></h2>
|
||||||
|
<div class="stage">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="phone" id="phone">
|
||||||
|
<div class="nav">
|
||||||
|
<span class="back">‹</span>
|
||||||
|
<span class="title">已归档任务</span>
|
||||||
|
<span class="proj">Admin的任务</span>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<!-- 筛选卡 -->
|
||||||
|
<div class="fcard">
|
||||||
|
<div class="frow">
|
||||||
|
<span class="lbl">标题</span>
|
||||||
|
<span class="ctl">
|
||||||
|
<span class="kwbox">
|
||||||
|
<input id="kw" placeholder="输入关键词" />
|
||||||
|
<span class="clr" id="kwClear" style="display:none">✕</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="frow">
|
||||||
|
<span class="lbl">看板列</span>
|
||||||
|
<span class="ctl">
|
||||||
|
<span class="chips" id="colChips">
|
||||||
|
<span class="chip on" data-col="">全部</span>
|
||||||
|
<span class="chip" data-col="Plan">Plan</span>
|
||||||
|
<span class="chip" data-col="Doing">Doing</span>
|
||||||
|
<span class="chip" data-col="Done">Done</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="frow">
|
||||||
|
<span class="lbl">归档时间</span>
|
||||||
|
<span class="ctl">
|
||||||
|
<span class="chips" id="timeChips">
|
||||||
|
<span class="chip on" data-t="all">全部</span>
|
||||||
|
<span class="chip" data-t="week">本周</span>
|
||||||
|
<span class="chip" data-t="month">本月</span>
|
||||||
|
<span class="chip" data-t="quarter">本季度</span>
|
||||||
|
<span class="chip" data-t="custom">自定义</span>
|
||||||
|
</span>
|
||||||
|
<span class="range" id="rangeRow" style="display:none">
|
||||||
|
<span class="dp" id="dpFrom">开始日期</span>
|
||||||
|
<span class="sep">~</span>
|
||||||
|
<span class="dp" id="dpTo">结束日期</span>
|
||||||
|
</span>
|
||||||
|
<div id="rangeHint" style="font-size:11px;color:#9aa4b2;margin-top:6px"></div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="factions">
|
||||||
|
<button class="btn ghost" id="resetBtn">重置</button>
|
||||||
|
<button class="btn primary" id="filterBtn">筛选</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已应用条件 -->
|
||||||
|
<div class="applied" id="appliedBar" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<div class="list" id="list"></div>
|
||||||
|
<button class="more" id="moreBtn">加载更多</button>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
</div>
|
||||||
|
<div class="caption">
|
||||||
|
<b>主界面</b>
|
||||||
|
<span>筛选卡固定三行:标题 / 看板列 / 归档时间,外加操作行。改动不再即时生效,点「筛选」统一提交。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="note">
|
||||||
|
<h4><i>1</i>为什么改成"点按钮才筛选"</h4>
|
||||||
|
<ul>
|
||||||
|
<li>现在每改一个条件就重查一次,连点三个条件就发三次请求</li>
|
||||||
|
<li>输入关键词时每敲一个字都可能触发(失焦也查),列表会闪</li>
|
||||||
|
<li>改成显式提交后,用户能先把条件填完整,再一次性看结果</li>
|
||||||
|
<li>按钮上带数字徽标,一眼知道当前生效了几个条件</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<h4><i>2</i>快捷时间怎么算</h4>
|
||||||
|
<ul>
|
||||||
|
<li><b>本周</b>:周一 00:00 ~ 今天(不跨到未来)</li>
|
||||||
|
<li><b>本月</b>:1 号 00:00 ~ 今天</li>
|
||||||
|
<li><b>本季度</b>:当季首月 1 号 00:00 ~ 今天</li>
|
||||||
|
<li>选完在下方灰字直接显示实际区间,避免"以为选的是整周"</li>
|
||||||
|
<li>「自定义」才展开两个日期选择器,默认收起</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<h4><i>3</i>已应用条件可单独撤销</h4>
|
||||||
|
<ul>
|
||||||
|
<li>筛选后在列表上方显示条件标签,每个带 <code>×</code> 可单独去掉</li>
|
||||||
|
<li>右侧显示命中条数,避免"筛完不知道有没有筛到"</li>
|
||||||
|
<li>无结果时给明确空状态,而不是一片留白</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="note">
|
||||||
|
<h4><i>4</i>列表行重排</h4>
|
||||||
|
<ul>
|
||||||
|
<li>标题独占一行(原来和类型/列/时间挤在下面)</li>
|
||||||
|
<li>元信息收成一行:<code>#编号</code> · 看板列 · 归档于 日期</li>
|
||||||
|
<li>「恢复 / 删除」从右侧竖排改到底部右侧横排,减少每行高度</li>
|
||||||
|
<li>类型标签保留展示(只是不再作为筛选项)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>状态变体</h2>
|
||||||
|
<div class="variants">
|
||||||
|
<div>
|
||||||
|
<div class="phone sm">
|
||||||
|
<div class="nav"><span class="back">‹</span><span class="title">已归档任务</span></div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="fcard">
|
||||||
|
<div class="frow"><span class="lbl">标题</span><span class="ctl"><span class="kwbox"><input placeholder="输入关键词" /></span></span></div>
|
||||||
|
<div class="frow"><span class="lbl">看板列</span><span class="ctl"><span class="chips"><span class="chip on">全部</span><span class="chip">Plan</span><span class="chip">Done</span></span></span></div>
|
||||||
|
<div class="frow"><span class="lbl">归档时间</span><span class="ctl">
|
||||||
|
<span class="chips"><span class="chip">全部</span><span class="chip on">本周</span><span class="chip">本月</span><span class="chip">本季度</span><span class="chip">自定义</span></span>
|
||||||
|
<div style="font-size:11px;color:#9aa4b2;margin-top:6px">2026-10-26 ~ 2026-10-28</div>
|
||||||
|
</span></div>
|
||||||
|
<div class="factions"><button class="btn ghost">重置</button><button class="btn primary">筛选<span class="badge">2</span></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="applied">
|
||||||
|
<span class="tag"><b>本周</b><span class="x">×</span></span>
|
||||||
|
<span class="tag"><b>Done</b><span class="x">×</span></span>
|
||||||
|
<span class="cnt">共 3 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="list">
|
||||||
|
<div class="item">
|
||||||
|
<div class="top"><div class="ttl">任务详情页滚动优化</div><span class="num">#22</span></div>
|
||||||
|
<div class="meta"><span class="kind" style="background:#1677ff">优化</span><span class="col">Done</span><span class="dot"></span><span>归档于 10-27</span></div>
|
||||||
|
<div class="acts"><span class="mini">恢复</span><span class="mini danger">删除</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="top"><div class="ttl">桌面端。</div><span class="num">#5</span></div>
|
||||||
|
<div class="meta"><span class="kind" style="background:#fa8c16">需求</span><span class="col">Done</span><span class="dot"></span><span>归档于 10-26</span></div>
|
||||||
|
<div class="acts"><span class="mini">恢复</span><span class="mini danger">删除</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="caption"><b>已应用条件 + 命中条数</b><span>条件可逐个撤销;时间预设自动显示实际区间。</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="phone sm">
|
||||||
|
<div class="nav"><span class="back">‹</span><span class="title">已归档任务</span></div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="fcard">
|
||||||
|
<div class="frow"><span class="lbl">标题</span><span class="ctl"><span class="kwbox"><input value="日报" /><span class="clr">✕</span></span></span></div>
|
||||||
|
<div class="frow"><span class="lbl">看板列</span><span class="ctl"><span class="chips"><span class="chip on">全部</span><span class="chip">Done</span></span></span></div>
|
||||||
|
<div class="frow"><span class="lbl">归档时间</span><span class="ctl">
|
||||||
|
<span class="chips"><span class="chip">全部</span><span class="chip">本周</span><span class="chip">本月</span><span class="chip">本季度</span><span class="chip on">自定义</span></span>
|
||||||
|
<span class="range"><span class="dp on">2026-09-01</span><span class="sep">~</span><span class="dp on">2026-09-30</span></span>
|
||||||
|
</span></div>
|
||||||
|
<div class="factions"><button class="btn ghost">重置</button><button class="btn primary">筛选<span class="badge">3</span></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="applied">
|
||||||
|
<span class="tag"><b>标题: 日报</b><span class="x">×</span></span>
|
||||||
|
<span class="tag"><b>09-01~09-30</b><span class="x">×</span></span>
|
||||||
|
<span class="cnt">共 0 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="empty"><div class="ico">🗂</div>没有符合条件的归档任务<br /><span style="font-size:11.5px;color:#c2c6cc">试试放宽时间范围或清空关键词</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="caption"><b>自定义区间 + 空结果</b><span>选「自定义」才展开日期;空结果给可操作的建议文案。</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="phone sm">
|
||||||
|
<div class="nav"><span class="back">‹</span><span class="title">已归档任务</span></div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="fcard">
|
||||||
|
<div class="frow"><span class="lbl">标题</span><span class="ctl"><span class="kwbox"><input placeholder="输入关键词" /></span></span></div>
|
||||||
|
<div class="frow"><span class="lbl">看板列</span><span class="ctl"><span class="chips"><span class="chip on">全部</span><span class="chip">Plan</span><span class="chip">Doing</span><span class="chip">Done</span></span></span></div>
|
||||||
|
<div class="frow"><span class="lbl">归档时间</span><span class="ctl"><span class="chips"><span class="chip on">全部</span><span class="chip">本周</span><span class="chip">本月</span><span class="chip">本季度</span><span class="chip">自定义</span></span></span></div>
|
||||||
|
<div class="factions"><button class="btn ghost">重置</button><button class="btn primary">筛选</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="empty"><div class="ico">📭</div>这个项目还没有归档任务<br /><span style="font-size:11.5px;color:#c2c6cc">任务完成后可在 ⋯ 菜单里归档</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="caption"><b>初始空状态</b><span>一个都没归档时,告诉用户归档入口在哪。</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>与现版本对照</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th style="width:130px">项</th><th style="width:38%">现在</th><th>建议</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>类型过滤</td><td class="no">「全部类型」下拉 + 独占一行</td><td class="yes">移除</td></tr>
|
||||||
|
<tr><td>关键词</td><td class="no">无标签,占位「搜索任务标题」</td><td class="yes">左侧常驻标签「标题」+ 占位「输入关键词」</td></tr>
|
||||||
|
<tr><td>归档时间</td><td class="no">两个日期按钮,无快捷项</td><td class="yes">全部 / 本周 / 本月 / 本季度 / 自定义</td></tr>
|
||||||
|
<tr><td>查询触发</td><td class="no">改条件即时查询,无按钮</td><td class="yes">「重置 / 筛选」,按钮带生效条件数</td></tr>
|
||||||
|
<tr><td>条件可见性</td><td class="no">条件散在控件里,看不出生效了什么</td><td class="yes">列表上方条件标签 + 命中条数,可单独撤销</td></tr>
|
||||||
|
<tr><td>列表行</td><td class="no">标题与元信息挤在一起,右侧竖排两按钮</td><td class="yes">标题独占行;元信息一行;按钮移到底部右侧</td></tr>
|
||||||
|
<tr><td>空状态</td><td class="no">一行灰字「没有符合条件的归档任务」</td><td class="yes">区分「还没归档过」与「筛选无结果」,给出下一步建议</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>待你确认 <span class="sub">确认后我再改代码</span></h2>
|
||||||
|
<div class="notes" style="max-width:820px">
|
||||||
|
<div class="note">
|
||||||
|
<ul>
|
||||||
|
<li>「筛选」改为显式提交,是否符合你的使用习惯?(也可以保留"改预设即时生效、只有关键词需要点按钮")</li>
|
||||||
|
<li>看板列是否也保留?还是只留 标题 + 归档时间 两个条件更清爽?</li>
|
||||||
|
<li>列表行的「恢复 / 删除」放底部右侧可以吗?还是希望点行进入详情/预览?</li>
|
||||||
|
<li>快捷时间是否需要再加「近 7 天 / 近 30 天」?</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// ---- 原型状态 ----
|
||||||
|
const state = { kw:'', col:'', time:'all', from:'', to:'', applied:{ kw:'', col:'', time:'all', from:'', to:'' } }
|
||||||
|
|
||||||
|
const MOCK = [
|
||||||
|
{ id:1, title:'任务详情页滚动优化', num:'#22', kind:'优化', kindColor:'#1677ff', col:'Done', at:'2026-10-27' },
|
||||||
|
{ id:2, title:'桌面端。', num:'#5', kind:'需求', kindColor:'#fa8c16', col:'Done', at:'2026-10-26' },
|
||||||
|
{ id:3, title:'任务详情中切换看板列是提示不存在。', num:'#23', kind:'Bug', kindColor:'#ff3141', col:'Done', at:'2026-10-25' },
|
||||||
|
{ id:4, title:'windows 电脑加入监控', num:'#24', kind:'需求', kindColor:'#fa8c16', col:'Plan', at:'2026-10-21' },
|
||||||
|
{ id:5, title:'补日报', num:'#28', kind:'需求', kindColor:'#fa8c16', col:'Doing', at:'2026-10-12' },
|
||||||
|
]
|
||||||
|
let rows = MOCK.slice()
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id)
|
||||||
|
const toastEl = $('toast')
|
||||||
|
let toastTimer = null
|
||||||
|
function toast(msg){
|
||||||
|
toastEl.textContent = msg
|
||||||
|
toastEl.classList.add('show')
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 快捷时间区间 ----
|
||||||
|
function pad(n){ return String(n).padStart(2,'0') }
|
||||||
|
function fmt(d){ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}` }
|
||||||
|
function presetRange(t){
|
||||||
|
const now = new Date()
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
if (t === 'week') {
|
||||||
|
const dow = (today.getDay() + 6) % 7 // 周一=0
|
||||||
|
const mon = new Date(today); mon.setDate(today.getDate() - dow)
|
||||||
|
return { from: fmt(mon), to: fmt(today) }
|
||||||
|
}
|
||||||
|
if (t === 'month') return { from: fmt(new Date(today.getFullYear(), today.getMonth(), 1)), to: fmt(today) }
|
||||||
|
if (t === 'quarter') {
|
||||||
|
const qm = Math.floor(today.getMonth() / 3) * 3
|
||||||
|
return { from: fmt(new Date(today.getFullYear(), qm, 1)), to: fmt(today) }
|
||||||
|
}
|
||||||
|
return { from:'', to:'' }
|
||||||
|
}
|
||||||
|
function rangeLabel(t){
|
||||||
|
if (t === 'custom') return ''
|
||||||
|
const r = presetRange(t)
|
||||||
|
return r.from ? `${r.from} ~ ${r.to}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 渲染 ----
|
||||||
|
function render(){
|
||||||
|
// 时间行
|
||||||
|
const custom = state.time === 'custom'
|
||||||
|
$('rangeRow').style.display = custom ? 'flex' : 'none'
|
||||||
|
const hint = rangeLabel(state.time)
|
||||||
|
$('rangeHint').textContent = hint
|
||||||
|
$('dpFrom').textContent = state.from || '开始日期'
|
||||||
|
$('dpTo').textContent = state.to || '结束日期'
|
||||||
|
$('dpFrom').classList.toggle('on', !!state.from)
|
||||||
|
$('dpTo').classList.toggle('on', !!state.to)
|
||||||
|
$('kwClear').style.display = state.kw ? 'block' : 'none'
|
||||||
|
|
||||||
|
// 生效条件数
|
||||||
|
const n = countApplied()
|
||||||
|
const btn = $('filterBtn')
|
||||||
|
btn.innerHTML = n ? `筛选<span class="badge">${n}</span>` : '筛选'
|
||||||
|
|
||||||
|
// 已应用条
|
||||||
|
const bar = $('appliedBar')
|
||||||
|
const tags = []
|
||||||
|
const a = state.applied
|
||||||
|
if (a.kw) tags.push(`<span class="tag"><b>标题: ${a.kw}</b><span class="x" data-clear="kw">×</span></span>`)
|
||||||
|
if (a.col) tags.push(`<span class="tag"><b>看板列: ${a.col}</b><span class="x" data-clear="col">×</span></span>`)
|
||||||
|
if (a.time === 'custom') {
|
||||||
|
if (a.from || a.to) tags.push(`<span class="tag"><b>${a.from || '…'} ~ ${a.to || '…'}</b><span class="x" data-clear="time">×</span></span>`)
|
||||||
|
} else if (a.time !== 'all') {
|
||||||
|
const name = { week:'本周', month:'本月', quarter:'本季度' }[a.time]
|
||||||
|
tags.push(`<span class="tag"><b>${name}</b><span class="x" data-clear="time">×</span></span>`)
|
||||||
|
}
|
||||||
|
if (tags.length) {
|
||||||
|
bar.style.display = 'flex'
|
||||||
|
bar.innerHTML = tags.join('') + `<span class="cnt">共 ${rows.length} 条</span>`
|
||||||
|
} else {
|
||||||
|
bar.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
const list = $('list')
|
||||||
|
if (!rows.length) {
|
||||||
|
const never = !tags.length
|
||||||
|
list.innerHTML = never
|
||||||
|
? `<div class="empty"><div class="ico">📭</div>这个项目还没有归档任务<br /><span style="font-size:11.5px;color:#c2c6cc">任务完成后可在 ⋯ 菜单里归档</span></div>`
|
||||||
|
: `<div class="empty"><div class="ico">🗂</div>没有符合条件的归档任务<br /><span style="font-size:11.5px;color:#c2c6cc">试试放宽时间范围或清空关键词</span></div>`
|
||||||
|
$('moreBtn').style.display = 'none'
|
||||||
|
} else {
|
||||||
|
$('moreBtn').style.display = 'block'
|
||||||
|
list.innerHTML = rows.map(r => `
|
||||||
|
<div class="item" data-id="${r.id}">
|
||||||
|
<div class="top"><div class="ttl">${r.title}</div><span class="num">${r.num}</span></div>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="kind" style="background:${r.kindColor}">${r.kind}</span>
|
||||||
|
<span class="col">${r.col}</span><span class="dot"></span><span>归档于 ${r.at}</span>
|
||||||
|
</div>
|
||||||
|
<div class="acts"><span class="mini" data-act="restore">恢复</span><span class="mini danger" data-act="remove">删除</span></div>
|
||||||
|
</div>`).join('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countApplied(){
|
||||||
|
const a = state.applied
|
||||||
|
let n = 0
|
||||||
|
if (a.kw) n++
|
||||||
|
if (a.col) n++
|
||||||
|
if (a.time === 'custom') { if (a.from || a.to) n++ } else if (a.time !== 'all') n++
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(){
|
||||||
|
state.applied = { kw: state.kw, col: state.col, time: state.time, from: state.from, to: state.to }
|
||||||
|
const a = state.applied
|
||||||
|
rows = MOCK.filter(r => {
|
||||||
|
if (a.kw && !r.title.includes(a.kw)) return false
|
||||||
|
if (a.col && r.col !== a.col) return false
|
||||||
|
if (a.time !== 'custom' && a.time !== 'all') {
|
||||||
|
const rg = presetRange(a.time)
|
||||||
|
if (rg.from && (r.at < rg.from || r.at > rg.to)) return false
|
||||||
|
}
|
||||||
|
if (a.time === 'custom') {
|
||||||
|
if (a.from && r.at < a.from) return false
|
||||||
|
if (a.to && r.at > a.to) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
render()
|
||||||
|
toast(countApplied() ? `已应用 ${countApplied()} 个条件 · ${rows.length} 条` : `显示全部 ${rows.length} 条`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 事件 ----
|
||||||
|
$('kw').addEventListener('input', (e) => { state.kw = e.target.value.trim(); $('kwClear').style.display = state.kw ? 'block' : 'none' })
|
||||||
|
$('kw').addEventListener('keydown', (e) => { if (e.key === 'Enter') applyFilter() })
|
||||||
|
$('kwClear').addEventListener('click', () => { state.kw = ''; $('kw').value = ''; render() })
|
||||||
|
|
||||||
|
document.querySelectorAll('#colChips .chip').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#colChips .chip').forEach(x => x.classList.remove('on'))
|
||||||
|
el.classList.add('on')
|
||||||
|
state.col = el.dataset.col || ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.querySelectorAll('#timeChips .chip').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#timeChips .chip').forEach(x => x.classList.remove('on'))
|
||||||
|
el.classList.add('on')
|
||||||
|
state.time = el.dataset.t
|
||||||
|
if (state.time !== 'custom') { state.from = ''; state.to = '' }
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
$('dpFrom').addEventListener('click', () => {
|
||||||
|
const v = prompt('开始日期 (YYYY-MM-DD)', state.from || '2026-10-01')
|
||||||
|
if (v !== null) { state.from = v.trim(); render() }
|
||||||
|
})
|
||||||
|
$('dpTo').addEventListener('click', () => {
|
||||||
|
const v = prompt('结束日期 (YYYY-MM-DD)', state.to || '2026-10-28')
|
||||||
|
if (v !== null) { state.to = v.trim(); render() }
|
||||||
|
})
|
||||||
|
|
||||||
|
$('filterBtn').addEventListener('click', applyFilter)
|
||||||
|
|
||||||
|
$('resetBtn').addEventListener('click', () => {
|
||||||
|
state.kw = ''; state.col = ''; state.time = 'all'; state.from = ''; state.to = ''
|
||||||
|
state.applied = { kw:'', col:'', time:'all', from:'', to:'' }
|
||||||
|
$('kw').value = ''
|
||||||
|
document.querySelectorAll('#colChips .chip').forEach((x, i) => x.classList.toggle('on', i === 0))
|
||||||
|
document.querySelectorAll('#timeChips .chip').forEach((x, i) => x.classList.toggle('on', i === 0))
|
||||||
|
rows = MOCK.slice()
|
||||||
|
render()
|
||||||
|
toast('已重置')
|
||||||
|
})
|
||||||
|
|
||||||
|
$('appliedBar').addEventListener('click', (e) => {
|
||||||
|
const x = e.target.closest('.x'); if (!x) return
|
||||||
|
const k = x.dataset.clear
|
||||||
|
if (k === 'kw') state.applied.kw = ''
|
||||||
|
if (k === 'col') state.applied.col = ''
|
||||||
|
if (k === 'time') { state.applied.time = 'all'; state.applied.from = ''; state.applied.to = '' }
|
||||||
|
const a = state.applied
|
||||||
|
rows = MOCK.filter(r => {
|
||||||
|
if (a.kw && !r.title.includes(a.kw)) return false
|
||||||
|
if (a.col && r.col !== a.col) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
render()
|
||||||
|
toast('已移除该条件')
|
||||||
|
})
|
||||||
|
|
||||||
|
$('list').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-act]'); if (!btn) return
|
||||||
|
const item = btn.closest('.item')
|
||||||
|
const id = Number(item.dataset.id)
|
||||||
|
const row = rows.find(r => r.id === id)
|
||||||
|
if (btn.dataset.act === 'restore') {
|
||||||
|
toast(`已恢复「${row.title}」`)
|
||||||
|
} else {
|
||||||
|
toast(`已删除「${row.title}」(原型演示,不实际删除)`)
|
||||||
|
}
|
||||||
|
item.style.opacity = '.35'
|
||||||
|
item.style.pointerEvents = 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
$('moreBtn').addEventListener('click', () => toast('加载更多(原型演示)'))
|
||||||
|
|
||||||
|
render()
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# 任务模块「即时同步」方案
|
||||||
|
|
||||||
|
> 状态:**方案设计,未实施**(本文只描述做法,不含代码改动)
|
||||||
|
> 范围:任务模块(Project / BoardColumn / Task / TaskMember / TaskTag)
|
||||||
|
> 相关文档:[0.2.1 实施方案](./0.2.1实施方案.md)、[PowerSync 可行性研究报告](./PowerSync可行性研究报告.md)
|
||||||
|
> 编写时间:2026-10-27
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 问题与现状
|
||||||
|
|
||||||
|
### 1.1 现象
|
||||||
|
|
||||||
|
多端(Windows 外壳 / Android / Web)同时打开同一项目时:
|
||||||
|
|
||||||
|
- A 端点完成或改状态,**B 端不会自动更新**;
|
||||||
|
- 必须**切到别的页面再切回来**(组件重新挂载触发重新查询)才能看到;
|
||||||
|
- 看板分组(按 `columnId`)与详情状态(按 `status`)也会出现一段时间的显示不一致。
|
||||||
|
|
||||||
|
### 1.2 根因:不是"同步没到",而是"到了没人通知界面"
|
||||||
|
|
||||||
|
PowerSync 的下行链路本身是**推送式**的(服务端 PG 逻辑复制 → sync service → WebSocket →
|
||||||
|
客户端写入本地 SQLite),延迟通常在亚秒级。问题出在**消费端**:
|
||||||
|
|
||||||
|
| 层 | 现状 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 传输层 | `PowerSyncProvider` 已在 `src/App.jsx` 挂载,连接正常 | ✅ 正常 |
|
||||||
|
| 数据层 | 变化已写入本地 SQLite | ✅ 正常 |
|
||||||
|
| **UI 层** | **全部是命令式读取**:`db().getAll(...)`,读完就不再看 | ❌ **断点在这** |
|
||||||
|
|
||||||
|
具体地:
|
||||||
|
|
||||||
|
- `src/api/task.js` 的 `projectApi.list/detail`、`taskApi.list` 都是一次性 `getAll()`,
|
||||||
|
且在 JS 里做二次装配(子任务、成员、标签、列),**没有任何订阅**;
|
||||||
|
- `src/lib/powersync/react.jsx` 虽然 re-export 了 `useQuery/useStatus`,
|
||||||
|
但**全项目一次都没用过**(`grep useQuery src` 只命中该文件的导出语句);
|
||||||
|
- `src/pages/task/TaskPage.jsx`、`ProjectView.jsx` 只在 `useEffect(..., [load])` 里加载一次;
|
||||||
|
- 唯一的刷新机制是 `src/hooks/useRefreshOnVisible.js`(回前台/窗口聚焦时重查),
|
||||||
|
而且**任务模块这两个页面根本没有用它**(只有打卡 `MergedView/ListView` 与宏观各页在用)。
|
||||||
|
|
||||||
|
所以「切面板能刷新」只是因为它**重新挂载组件 → 重新执行一次 `load()`**。
|
||||||
|
|
||||||
|
### 1.3 现状刷新机制盘点
|
||||||
|
|
||||||
|
| 页面 | 首次加载 | 回前台刷新 | 数据变化实时反映 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `TaskPage` | `useEffect` | ❌ 无 | ❌ |
|
||||||
|
| `ProjectView` | `useEffect` | ❌ 无 | ❌(仅本地操作后手动 `load()`) |
|
||||||
|
| `TaskDetail` | 打开时传入快照 | — | ❌(快照可能过期) |
|
||||||
|
| `MergedView`(打卡) | `useEffect` | ✅ | ❌(且打卡走 REST,非 PowerSync) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
### 2.1 目标
|
||||||
|
|
||||||
|
1. **跨端即时**:一端修改,其它端在 **≤1s** 内自动呈现(不含网络中断场景);
|
||||||
|
2. **不打断操作**:正在输入的详情表单、正在拖拽的卡片**不被远端变更打断**;
|
||||||
|
3. **离线可用**:断网期间照常读写本地库,恢复后自动补齐并刷新;
|
||||||
|
4. **改动可控**:优先复用现有装配逻辑(`taskApi` 的 shape),不做大规模重写;
|
||||||
|
5. **可回滚**:通过开关或逐步替换,出问题能退回"手动刷新"。
|
||||||
|
|
||||||
|
### 2.2 非目标
|
||||||
|
|
||||||
|
- 不做**多人协同编辑**(同一字段并发写仍按 LWW 后到覆盖);
|
||||||
|
- 不解决打卡/清单模块的实时性(它们走 REST,见 §7);
|
||||||
|
- 不引入 WebSocket 之外的额外实时通道(如 SSE)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 方案选型
|
||||||
|
|
||||||
|
| 方案 | 做法 | 优点 | 缺点 | 结论 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **A. 轮询** | 定时(如 3s)重跑 `load()` | 改动最小 | 无谓查询、电量/流量、仍不"即时" | ❌ |
|
||||||
|
| **B. 全量响应式** | 把 `taskApi.list` 整体改成一条大 SQL + `useQuery` | 最"正统",细粒度 | 现有装配(子任务/成员/标签)要在 SQL 里重写;改动面大、回归风险高 | ⏳ 后续 |
|
||||||
|
| **C. 变更信号 + 重算**(推荐) | 用 `db.watch()` 订阅一个**轻量"信号查询"**,信号变化时重跑现有 `load()` | 复用现有装配逻辑;改动集中且小;天然合并多次变更 | 每次重算一次列表(数据量小,可接受) | ✅ **P0 采用** |
|
||||||
|
| **D. 表级广播** | 监听"某表有变更"事件触发重查 | 实现最简单 | 无法区分项目/范围,易全页面重查 | 作为 C 的退化实现 |
|
||||||
|
|
||||||
|
**结论**:P0 用方案 C;跑稳后再评估把高频视图(看板)迁到 B 的增量查询。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 推荐方案详设(方案 C)
|
||||||
|
|
||||||
|
### 4.1 核心思路
|
||||||
|
|
||||||
|
```
|
||||||
|
本地 SQLite ←(PowerSync 下行写入)— 任一端/服务端变更
|
||||||
|
│
|
||||||
|
└─▶ db.watch(<信号查询>) ── 变化 ──▶ 节流合并 ──▶ 重跑 load()
|
||||||
|
└─▶ setState 渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
**"信号查询"** 是一段**廉价但能覆盖所有相关表**的 SQL:只要任务域的任一相关行变化,
|
||||||
|
其结果就会变;它不承担业务语义,只负责触发。
|
||||||
|
|
||||||
|
### 4.2 订阅范围:用 SDK 的表级过滤(已实施)
|
||||||
|
|
||||||
|
> 初版设计是手写一段"信号 SQL"(count / sum / max(updatedAt) 汇总),
|
||||||
|
> 实施时发现 `db.watch()` 原生支持 **`tables` 表名过滤** 与 **`throttleMs` 节流**,
|
||||||
|
> 由 SDK 判断"结果是否真的变了",比自己做汇总更可靠,因此改为表级过滤。
|
||||||
|
|
||||||
|
`CommonPowerSyncDatabase.watch(sql, params, handler, options)` 的 `options`:
|
||||||
|
|
||||||
|
| 选项 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `tables: string[]` | 只关心这些表的变更,SDK 内部按表失效 |
|
||||||
|
| `throttleMs` | 两次回调之间的最小间隔,天然合并同一批同步写入 |
|
||||||
|
| `signal: AbortSignal` | 卸载时取消订阅 |
|
||||||
|
|
||||||
|
订阅内容(`src/lib/powersync/useDbSignal.js`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
db.watch('SELECT 1 AS s', [], { onResult, onError }, {
|
||||||
|
tables: ['Task', 'BoardColumn', 'TaskMember', 'TaskTag'],
|
||||||
|
throttleMs: 250,
|
||||||
|
signal: ac.signal,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
查询本身(`SELECT 1`)没有业务含义,只借 watch 的变更通知能力。表名集合定义在
|
||||||
|
`src/api/task.js`:
|
||||||
|
|
||||||
|
| 常量 | 内容 | 用于 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `TASK_TABLES` | `Task, BoardColumn, TaskMember, TaskTag` | 项目内任务列表 / 看板 |
|
||||||
|
| `PROJECT_TABLES` | `Project, ProjectMember` | 项目列表、成员 |
|
||||||
|
|
||||||
|
之所以不需要 `max(updatedAt)`:表级过滤关心的是"行有没有被写过",
|
||||||
|
而不是"结果集内容是否变化",所以改标题、改状态这类不增删的写入同样会触发。
|
||||||
|
|
||||||
|
### 4.3 UI 层接入
|
||||||
|
|
||||||
|
提供两个小工具,避免每个页面各写一套:
|
||||||
|
|
||||||
|
| 工具 | 位置(建议) | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `useDbSignal(sql, params, onChange)` | `src/lib/powersync/useDbSignal.js`(新增) | 封装 `db.watch()` + 节流 + 卸载清理 |
|
||||||
|
| `useTaskListSignal(projectId, onChange)` | `src/api/task.js` 或同目录(新增) | 拼好上述信号 SQL,页面只关心"变了要干嘛" |
|
||||||
|
|
||||||
|
接入示意(`ProjectView`):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// 现状
|
||||||
|
useEffect(() => { setTasks(null); load() }, [load])
|
||||||
|
|
||||||
|
// 改造后(示意)
|
||||||
|
useEffect(() => { setTasks(null); load() }, [load])
|
||||||
|
useDbSignal(TASK_LIST_SIGNAL_SQL, [projectId, projectId, projectId, projectId, projectId, projectId], () => {
|
||||||
|
if (dragActiveRef.current) return // 见 §4.4
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`TaskPage` 同理,信号为项目表。
|
||||||
|
|
||||||
|
### 4.4 变更合并与"不打断"策略
|
||||||
|
|
||||||
|
这是本方案的关键,否则会出现「打字打到一半表单被重置」「拖到一半卡片跳走」。
|
||||||
|
|
||||||
|
| 场景 | 策略 |
|
||||||
|
| --- | --- |
|
||||||
|
| 连续多次变更(一次同步写多行) | `useDbSignal` 内部 **150~300ms 防抖**,合并成一次 `load()` |
|
||||||
|
| 看板/列表**拖拽进行中** | 用 `dragActiveRef` 跳过本次刷新;拖拽结束后补一次 `load()` |
|
||||||
|
| 任务**详情页打开中** | 不自动刷新表单;改为监听该任务是否变化,变化时在详情顶部显示「**该任务已在他端更新,点击刷新**」 |
|
||||||
|
| 正在编辑标题/描述 | 同上传:不覆盖输入框,只提示 |
|
||||||
|
| 本地乐观更新进行中(`updateTask` 未完成) | 以本地 patch 为准,`load()` 延后到请求结束(现有 `updateTask` 已有 snapshot 回滚,保留) |
|
||||||
|
|
||||||
|
### 4.5 同步状态指示
|
||||||
|
|
||||||
|
用已导出的 `useStatus()` 在任务页顶部显示一个极轻量的状态点:
|
||||||
|
|
||||||
|
| 状态 | 展示 |
|
||||||
|
| --- | --- |
|
||||||
|
| `connected && dataFlowStatus.connected` | 不显示(无干扰) |
|
||||||
|
| 正在下载/上传 | 细小的同步图标 |
|
||||||
|
| 离线 / 连接失败 | 「离线,改动将在联网后同步」 |
|
||||||
|
|
||||||
|
> 说明:当前外壳里 PowerSync 会打 `Multiple tab support is not enabled` 的提示,
|
||||||
|
> 属预期(已在 0.2.1 关闭多标签以避免 Windows reload 后本地库挂起),不影响本方案。
|
||||||
|
|
||||||
|
### 4.6 离线与重连
|
||||||
|
|
||||||
|
- 断网:`db.watch()` 仍监听**本地库**,本地写入照常触发刷新(体验一致);
|
||||||
|
- 恢复:PowerSync 自动重连并回放,写入本地库后信号触发刷新,**无需额外逻辑**;
|
||||||
|
- 应用回到前台:保留 `useRefreshOnVisible` 作为**兜底**(WebView 可能挂起渲染线程),
|
||||||
|
与 `db.watch()` 并存不冲突(两者都只是调 `load()`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 改造清单(按文件)
|
||||||
|
|
||||||
|
| 文件 | 改动 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/lib/powersync/useDbSignal.js` | **新增**:`db.watch` + 表过滤 + 节流 + 卸载清理 | ✅ P0 已完成 |
|
||||||
|
| `src/api/task.js` | 新增 `TASK_TABLES` / `PROJECT_TABLES` 常量 | ✅ P0 |
|
||||||
|
| `src/pages/task/ProjectView.jsx` | 订阅任务域;拖拽中延后刷新;回前台兜底 | ✅ P0 |
|
||||||
|
| `src/pages/task/TaskPage.jsx` | 订阅项目域;回前台兜底 | ✅ P0 |
|
||||||
|
| `src/pages/task/BoardView.jsx` / `ListView.jsx` | 上报 `onDragActiveChange` | ✅ P0 |
|
||||||
|
| `src/pages/task/TaskDetail.jsx` | 「已在他端更新」提示(不覆盖表单) | ⏳ P1 |
|
||||||
|
| 同步状态指示 | 用 `useStatus()` 显示离线/同步中 | ⏳ P1 |
|
||||||
|
|
||||||
|
**不需要改动**:`server/`、`sync-config.yaml`、数据库结构。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 分期与验收
|
||||||
|
|
||||||
|
### 6.1 分期
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 预估 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **P0** | `useDbSignal` + `ProjectView`/`TaskPage` 接入 + 拖拽保护 | 0.5~1 天 |
|
||||||
|
| **P1** | 详情页「已在他端更新」提示 + 同步状态指示 | 0.5 天 |
|
||||||
|
| **P2** | 看板改增量查询(方案 B);打卡/清单的实时性另议 | 按需 |
|
||||||
|
|
||||||
|
### 6.2 验收标准(可测)
|
||||||
|
|
||||||
|
1. **跨端延迟**:A 端改状态/拖列/改标题,B 端自动更新。实测 **约 1~2s**(数据进本地库约 1s,渲染 +0.3s,见 §7.0);
|
||||||
|
2. **不打断**:B 端正在输入标题时,A 端改同任务描述 → B 端输入框内容**不被清空**,仅出现提示;
|
||||||
|
3. **拖拽安全**:B 端拖拽中收到远端变更 → 卡片**不跳位**,松手后列表更新;
|
||||||
|
4. **离线**:断网期间本地操作正常,恢复后自动补齐并刷新;
|
||||||
|
5. **回滚**:`localStorage['iboard_realtime'] = '0'` 后重启应用,退回"手动刷新",无副作用;
|
||||||
|
6. **性能**:单项目 100 条任务、连续 20 次远端写入,页面无卡顿、无重复渲染风暴(可用 Performance 面板核对)。
|
||||||
|
|
||||||
|
### 6.3 度量
|
||||||
|
|
||||||
|
- 在 `load()` 内打点(可临时 `console.time`),统计重算耗时与触发次数;
|
||||||
|
- 目标:一次远端变更只触发 **1 次** 重算;100 条任务时单次重算 < 60ms。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 已知边界与后续
|
||||||
|
|
||||||
|
### 7.0 实测:端到端约 1~2s(达标)
|
||||||
|
|
||||||
|
P0 落地后在真实 Windows 外壳上实测,服务端直接改库模拟另一端:
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 是否还需要切页面 | **不需要** —— 服务端改完,页面在无任何操作的情况下自动更新 ✅ |
|
||||||
|
| 端到端延迟 | **约 1~2s**(数据进本地库约 1s,渲染再 +0.3s)✅ |
|
||||||
|
|
||||||
|
**测量方法(关键:早期结论曾被测量方式误导两次,务必按此方法复测)**
|
||||||
|
|
||||||
|
测量必须在**页面内**自计时,不能用 CDP 轮询 DOM:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 页面内: 50ms 轮询本地库 + MutationObserver 观察 DOM, 各自记录 performance.now()
|
||||||
|
const P = { startedAt: performance.now(), dbAt: null, domAt: null }
|
||||||
|
setInterval(async () => {
|
||||||
|
if (P.dbAt !== null) return
|
||||||
|
const row = await window.__psdb.get('SELECT title FROM Task WHERE number = ?', [16])
|
||||||
|
if (row && row.title.includes(MARK)) P.dbAt = performance.now() - P.startedAt
|
||||||
|
}, 50)
|
||||||
|
new MutationObserver(check).observe(document.body, { childList: true, subtree: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
一次干净测量的结果(已重载页面、无残留定时器):
|
||||||
|
|
||||||
|
| 度量 | 数值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `psql` 命令总耗时(ssh + docker exec + UPDATE) | 2138ms |
|
||||||
|
| 数据出现在客户端本地库(相对计时起点) | 2251ms → **≈ 命令返回后 113ms** |
|
||||||
|
| 界面更新(相对计时起点) | 2580ms |
|
||||||
|
| **同步数据到达 → 渲染完成** | **329ms** |
|
||||||
|
|
||||||
|
结论:**服务端改库到界面呈现约 1~2s(含 SSH/psql 开销在内的保守上界),其中 UI 渲染只占 ~0.33s**,
|
||||||
|
`useDbSignal` 的表级 watch 工作正常,无需改 `useQuery`。
|
||||||
|
|
||||||
|
**两次被推翻的中间结论(留档,避免重复踩坑)**
|
||||||
|
|
||||||
|
| 曾经的判断 | 为何是错的 |
|
||||||
|
| --- | --- |
|
||||||
|
| ① "nginx `proxy_buffering` 导致按批下发" | 加了 `proxy_buffering off` 等配置并热重载(`nginx -T` 确认生效),延迟毫无变化。**该配置已回滚**(`advanced_config` 清空、生效 conf 还原、nginx 已重载)—— 保留它反而会让 OTA 大包下载失去缓冲保护 |
|
||||||
|
| ② "同步数据早在服务端发出,客户端 `db.watch`/渲染滞后 15~30s" | 错在测量:用 CDP 反复读 `document.body.innerText`(长列表页上开销很大)轮询,且把 `ssh + docker exec` 的 ~6s 开销算进了延迟。改为页面内自计时后,实测只有 ~1~2s |
|
||||||
|
|
||||||
|
**服务端侧的旁证**(与上述一致):powersync 日志 `checkpoint_complete` 与改库同一秒完成;
|
||||||
|
`stream_ms` 达 42 分钟、`close_reason: client closing stream`,长连接稳定,非频繁重连。
|
||||||
|
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 打卡 / 清单 | 走 REST(`src/api/checkin.js`),不经过本地库,**本项目方案不覆盖**;如需实时,可复用现有 `useRefreshOnVisible` + 定时轮询,或后续把这些模块也迁到 PowerSync |
|
||||||
|
| 并发写冲突 | 仍为 LWW(`updatedAt` 后到覆盖);本文不引入人工合并 |
|
||||||
|
| 排序并发 | `taskApi.reorder` 会整批重写 `sortOrder`,两端同时拖拽可能相互覆盖;建议 P2 再引入"版本号/操作序列" |
|
||||||
|
| 大列表 | 方案 C 每次全量重算,任务数极大(>1000)时需转方案 B 的增量查询 |
|
||||||
|
| WebView 后台节流 | 应用在后台时 `watch` 回调可能被延后,回前台由 `useRefreshOnVisible` 兜底 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 附:关键 API 备忘
|
||||||
|
|
||||||
|
| API | 来源 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `db.watch(sql, params, options)` | `@powersync/common` `CommonPowerSyncDatabase` | 订阅查询结果,返回 `AsyncIterable` |
|
||||||
|
| `useQuery(sql, params, options)` | `@powersync/react` | React 版订阅;`options.rowComparator` 可做**增量**更新(P2 用) |
|
||||||
|
| `useStatus()` | `@powersync/react` | 连接/同步状态 |
|
||||||
|
| `getPowersyncDb()` | `src/lib/powersync/db.js` | 本地库单例(当前唯一入口) |
|
||||||
|
|
||||||
|
> 注:`src/lib/powersync/react.jsx` 已 re-export `useQuery/useStatus/usePowerSync`,
|
||||||
|
> 本次改造**无需新增依赖**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 结论
|
||||||
|
|
||||||
|
当前"必须切面板才刷新"**不是同步没到,而是 UI 从未订阅本地库变化**。
|
||||||
|
采用「**变更信号 + 复用现有装配重算**」(方案 C)可在改动面很小的情况下获得亚秒级跨端同步,
|
||||||
|
并把"不打断编辑/拖拽"作为硬性要求一并纳入。P0 约半天到一天即可上线验证。
|
||||||
@@ -27,21 +27,6 @@ export type User = Prisma.UserModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type Article = Prisma.ArticleModel
|
export type Article = Prisma.ArticleModel
|
||||||
/**
|
|
||||||
* Model Strategy
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type Strategy = Prisma.StrategyModel
|
|
||||||
/**
|
|
||||||
* Model Indicator
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type Indicator = Prisma.IndicatorModel
|
|
||||||
/**
|
|
||||||
* Model IndicatorPoint
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type IndicatorPoint = Prisma.IndicatorPointModel
|
|
||||||
/**
|
/**
|
||||||
* Model News
|
* Model News
|
||||||
*
|
*
|
||||||
@@ -77,11 +62,6 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type ScraperCache = Prisma.ScraperCacheModel
|
export type ScraperCache = Prisma.ScraperCacheModel
|
||||||
/**
|
|
||||||
* Model OilPriceHistory
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type OilPriceHistory = Prisma.OilPriceHistoryModel
|
|
||||||
/**
|
/**
|
||||||
* Model MihomeAccount
|
* Model MihomeAccount
|
||||||
*
|
*
|
||||||
@@ -162,3 +142,8 @@ export type TaskMember = Prisma.TaskMemberModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type TaskTag = Prisma.TaskTagModel
|
export type TaskTag = Prisma.TaskTagModel
|
||||||
|
/**
|
||||||
|
* Model McpToken
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type McpToken = Prisma.McpTokenModel
|
||||||
|
|||||||
@@ -51,21 +51,6 @@ export type User = Prisma.UserModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type Article = Prisma.ArticleModel
|
export type Article = Prisma.ArticleModel
|
||||||
/**
|
|
||||||
* Model Strategy
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type Strategy = Prisma.StrategyModel
|
|
||||||
/**
|
|
||||||
* Model Indicator
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type Indicator = Prisma.IndicatorModel
|
|
||||||
/**
|
|
||||||
* Model IndicatorPoint
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type IndicatorPoint = Prisma.IndicatorPointModel
|
|
||||||
/**
|
/**
|
||||||
* Model News
|
* Model News
|
||||||
*
|
*
|
||||||
@@ -101,11 +86,6 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type ScraperCache = Prisma.ScraperCacheModel
|
export type ScraperCache = Prisma.ScraperCacheModel
|
||||||
/**
|
|
||||||
* Model OilPriceHistory
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type OilPriceHistory = Prisma.OilPriceHistoryModel
|
|
||||||
/**
|
/**
|
||||||
* Model MihomeAccount
|
* Model MihomeAccount
|
||||||
*
|
*
|
||||||
@@ -186,3 +166,8 @@ export type TaskMember = Prisma.TaskMemberModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type TaskTag = Prisma.TaskTagModel
|
export type TaskTag = Prisma.TaskTagModel
|
||||||
|
/**
|
||||||
|
* Model McpToken
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type McpToken = Prisma.McpTokenModel
|
||||||
|
|||||||
@@ -99,97 +99,6 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JsonFilter<$PrismaModel = never> =
|
|
||||||
| Prisma.PatchUndefined<
|
|
||||||
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
|
|
||||||
Required<JsonFilterBase<$PrismaModel>>
|
|
||||||
>
|
|
||||||
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
|
|
||||||
|
|
||||||
export type JsonFilterBase<$PrismaModel = never> = {
|
|
||||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
|
||||||
path?: string[]
|
|
||||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
|
||||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JsonWithAggregatesFilter<$PrismaModel = never> =
|
|
||||||
| Prisma.PatchUndefined<
|
|
||||||
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
|
||||||
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
|
|
||||||
>
|
|
||||||
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
|
||||||
|
|
||||||
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
|
||||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
|
||||||
path?: string[]
|
|
||||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
|
||||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
|
||||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
|
||||||
_min?: Prisma.NestedJsonFilter<$PrismaModel>
|
|
||||||
_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[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
|
||||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
|
||||||
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[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
|
||||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
|
||||||
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> = {
|
export type StringNullableFilter<$PrismaModel = never> = {
|
||||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
@@ -228,6 +137,33 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FloatFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
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[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
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 BigIntFilter<$PrismaModel = never> = {
|
export type BigIntFilter<$PrismaModel = never> = {
|
||||||
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
|
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
|
||||||
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
|
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
|
||||||
@@ -328,6 +264,11 @@ export type DateTimeNullableFilter<$PrismaModel = never> = {
|
|||||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BoolFilter<$PrismaModel = never> = {
|
||||||
|
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||||
@@ -358,6 +299,14 @@ export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 FloatNullableFilter<$PrismaModel = never> = {
|
export type FloatNullableFilter<$PrismaModel = never> = {
|
||||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
@@ -385,6 +334,57 @@ export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JsonFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<JsonFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type JsonFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonWithAggregatesFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
export type NestedIntFilter<$PrismaModel = never> = {
|
export type NestedIntFilter<$PrismaModel = never> = {
|
||||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
@@ -479,59 +479,6 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NestedJsonFilter<$PrismaModel = never> =
|
|
||||||
| Prisma.PatchUndefined<
|
|
||||||
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
|
|
||||||
Required<NestedJsonFilterBase<$PrismaModel>>
|
|
||||||
>
|
|
||||||
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
|
|
||||||
|
|
||||||
export type NestedJsonFilterBase<$PrismaModel = never> = {
|
|
||||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
|
||||||
path?: string[]
|
|
||||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
|
||||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
||||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
|
||||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
|
||||||
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[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
|
||||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
|
||||||
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> = {
|
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
@@ -574,6 +521,22 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
|
|||||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
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 NestedBigIntFilter<$PrismaModel = never> = {
|
export type NestedBigIntFilter<$PrismaModel = never> = {
|
||||||
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
|
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
|
||||||
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
|
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
|
||||||
@@ -636,6 +599,11 @@ export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
|||||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||||
|
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||||
@@ -677,6 +645,14 @@ export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
@@ -693,4 +669,28 @@ export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NestedJsonFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<NestedJsonFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -386,9 +386,6 @@ type FieldRefInputType<Model, FieldType> = Model extends never ? never : FieldRe
|
|||||||
export const ModelName = {
|
export const ModelName = {
|
||||||
User: 'User',
|
User: 'User',
|
||||||
Article: 'Article',
|
Article: 'Article',
|
||||||
Strategy: 'Strategy',
|
|
||||||
Indicator: 'Indicator',
|
|
||||||
IndicatorPoint: 'IndicatorPoint',
|
|
||||||
News: 'News',
|
News: 'News',
|
||||||
Alert: 'Alert',
|
Alert: 'Alert',
|
||||||
ServerNode: 'ServerNode',
|
ServerNode: 'ServerNode',
|
||||||
@@ -396,7 +393,6 @@ export const ModelName = {
|
|||||||
ServiceCheck: 'ServiceCheck',
|
ServiceCheck: 'ServiceCheck',
|
||||||
Econ_SowInventory: 'Econ_SowInventory',
|
Econ_SowInventory: 'Econ_SowInventory',
|
||||||
ScraperCache: 'ScraperCache',
|
ScraperCache: 'ScraperCache',
|
||||||
OilPriceHistory: 'OilPriceHistory',
|
|
||||||
MihomeAccount: 'MihomeAccount',
|
MihomeAccount: 'MihomeAccount',
|
||||||
MihomeHome: 'MihomeHome',
|
MihomeHome: 'MihomeHome',
|
||||||
MihomeDevice: 'MihomeDevice',
|
MihomeDevice: 'MihomeDevice',
|
||||||
@@ -412,7 +408,8 @@ export const ModelName = {
|
|||||||
BoardColumn: 'BoardColumn',
|
BoardColumn: 'BoardColumn',
|
||||||
Task: 'Task',
|
Task: 'Task',
|
||||||
TaskMember: 'TaskMember',
|
TaskMember: 'TaskMember',
|
||||||
TaskTag: 'TaskTag'
|
TaskTag: 'TaskTag',
|
||||||
|
McpToken: 'McpToken'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
@@ -428,7 +425,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour" | "habit" | "checkIn" | "checkList" | "checkListItem" | "checkListTemplate" | "project" | "projectMember" | "boardColumn" | "task" | "taskMember" | "taskTag"
|
modelProps: "user" | "article" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour" | "habit" | "checkIn" | "checkList" | "checkListItem" | "checkListTemplate" | "project" | "projectMember" | "boardColumn" | "task" | "taskMember" | "taskTag" | "mcpToken"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
@@ -580,228 +577,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Strategy: {
|
|
||||||
payload: Prisma.$StrategyPayload<ExtArgs>
|
|
||||||
fields: Prisma.StrategyFieldRefs
|
|
||||||
operations: {
|
|
||||||
findUnique: {
|
|
||||||
args: Prisma.StrategyFindUniqueArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload> | null
|
|
||||||
}
|
|
||||||
findUniqueOrThrow: {
|
|
||||||
args: Prisma.StrategyFindUniqueOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
|
||||||
}
|
|
||||||
findFirst: {
|
|
||||||
args: Prisma.StrategyFindFirstArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload> | null
|
|
||||||
}
|
|
||||||
findFirstOrThrow: {
|
|
||||||
args: Prisma.StrategyFindFirstOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
|
||||||
}
|
|
||||||
findMany: {
|
|
||||||
args: Prisma.StrategyFindManyArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>[]
|
|
||||||
}
|
|
||||||
create: {
|
|
||||||
args: Prisma.StrategyCreateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
|
||||||
}
|
|
||||||
createMany: {
|
|
||||||
args: Prisma.StrategyCreateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
createManyAndReturn: {
|
|
||||||
args: Prisma.StrategyCreateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>[]
|
|
||||||
}
|
|
||||||
delete: {
|
|
||||||
args: Prisma.StrategyDeleteArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
|
||||||
}
|
|
||||||
update: {
|
|
||||||
args: Prisma.StrategyUpdateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
|
||||||
}
|
|
||||||
deleteMany: {
|
|
||||||
args: Prisma.StrategyDeleteManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateMany: {
|
|
||||||
args: Prisma.StrategyUpdateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateManyAndReturn: {
|
|
||||||
args: Prisma.StrategyUpdateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>[]
|
|
||||||
}
|
|
||||||
upsert: {
|
|
||||||
args: Prisma.StrategyUpsertArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$StrategyPayload>
|
|
||||||
}
|
|
||||||
aggregate: {
|
|
||||||
args: Prisma.StrategyAggregateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.AggregateStrategy>
|
|
||||||
}
|
|
||||||
groupBy: {
|
|
||||||
args: Prisma.StrategyGroupByArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.StrategyGroupByOutputType>[]
|
|
||||||
}
|
|
||||||
count: {
|
|
||||||
args: Prisma.StrategyCountArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.StrategyCountAggregateOutputType> | number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Indicator: {
|
|
||||||
payload: Prisma.$IndicatorPayload<ExtArgs>
|
|
||||||
fields: Prisma.IndicatorFieldRefs
|
|
||||||
operations: {
|
|
||||||
findUnique: {
|
|
||||||
args: Prisma.IndicatorFindUniqueArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload> | null
|
|
||||||
}
|
|
||||||
findUniqueOrThrow: {
|
|
||||||
args: Prisma.IndicatorFindUniqueOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
|
||||||
}
|
|
||||||
findFirst: {
|
|
||||||
args: Prisma.IndicatorFindFirstArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload> | null
|
|
||||||
}
|
|
||||||
findFirstOrThrow: {
|
|
||||||
args: Prisma.IndicatorFindFirstOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
|
||||||
}
|
|
||||||
findMany: {
|
|
||||||
args: Prisma.IndicatorFindManyArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>[]
|
|
||||||
}
|
|
||||||
create: {
|
|
||||||
args: Prisma.IndicatorCreateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
|
||||||
}
|
|
||||||
createMany: {
|
|
||||||
args: Prisma.IndicatorCreateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
createManyAndReturn: {
|
|
||||||
args: Prisma.IndicatorCreateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>[]
|
|
||||||
}
|
|
||||||
delete: {
|
|
||||||
args: Prisma.IndicatorDeleteArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
|
||||||
}
|
|
||||||
update: {
|
|
||||||
args: Prisma.IndicatorUpdateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
|
||||||
}
|
|
||||||
deleteMany: {
|
|
||||||
args: Prisma.IndicatorDeleteManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateMany: {
|
|
||||||
args: Prisma.IndicatorUpdateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateManyAndReturn: {
|
|
||||||
args: Prisma.IndicatorUpdateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>[]
|
|
||||||
}
|
|
||||||
upsert: {
|
|
||||||
args: Prisma.IndicatorUpsertArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPayload>
|
|
||||||
}
|
|
||||||
aggregate: {
|
|
||||||
args: Prisma.IndicatorAggregateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.AggregateIndicator>
|
|
||||||
}
|
|
||||||
groupBy: {
|
|
||||||
args: Prisma.IndicatorGroupByArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.IndicatorGroupByOutputType>[]
|
|
||||||
}
|
|
||||||
count: {
|
|
||||||
args: Prisma.IndicatorCountArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.IndicatorCountAggregateOutputType> | number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IndicatorPoint: {
|
|
||||||
payload: Prisma.$IndicatorPointPayload<ExtArgs>
|
|
||||||
fields: Prisma.IndicatorPointFieldRefs
|
|
||||||
operations: {
|
|
||||||
findUnique: {
|
|
||||||
args: Prisma.IndicatorPointFindUniqueArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload> | null
|
|
||||||
}
|
|
||||||
findUniqueOrThrow: {
|
|
||||||
args: Prisma.IndicatorPointFindUniqueOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
|
||||||
}
|
|
||||||
findFirst: {
|
|
||||||
args: Prisma.IndicatorPointFindFirstArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload> | null
|
|
||||||
}
|
|
||||||
findFirstOrThrow: {
|
|
||||||
args: Prisma.IndicatorPointFindFirstOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
|
||||||
}
|
|
||||||
findMany: {
|
|
||||||
args: Prisma.IndicatorPointFindManyArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>[]
|
|
||||||
}
|
|
||||||
create: {
|
|
||||||
args: Prisma.IndicatorPointCreateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
|
||||||
}
|
|
||||||
createMany: {
|
|
||||||
args: Prisma.IndicatorPointCreateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
createManyAndReturn: {
|
|
||||||
args: Prisma.IndicatorPointCreateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>[]
|
|
||||||
}
|
|
||||||
delete: {
|
|
||||||
args: Prisma.IndicatorPointDeleteArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
|
||||||
}
|
|
||||||
update: {
|
|
||||||
args: Prisma.IndicatorPointUpdateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
|
||||||
}
|
|
||||||
deleteMany: {
|
|
||||||
args: Prisma.IndicatorPointDeleteManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateMany: {
|
|
||||||
args: Prisma.IndicatorPointUpdateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateManyAndReturn: {
|
|
||||||
args: Prisma.IndicatorPointUpdateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>[]
|
|
||||||
}
|
|
||||||
upsert: {
|
|
||||||
args: Prisma.IndicatorPointUpsertArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$IndicatorPointPayload>
|
|
||||||
}
|
|
||||||
aggregate: {
|
|
||||||
args: Prisma.IndicatorPointAggregateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.AggregateIndicatorPoint>
|
|
||||||
}
|
|
||||||
groupBy: {
|
|
||||||
args: Prisma.IndicatorPointGroupByArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.IndicatorPointGroupByOutputType>[]
|
|
||||||
}
|
|
||||||
count: {
|
|
||||||
args: Prisma.IndicatorPointCountArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.IndicatorPointCountAggregateOutputType> | number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
News: {
|
News: {
|
||||||
payload: Prisma.$NewsPayload<ExtArgs>
|
payload: Prisma.$NewsPayload<ExtArgs>
|
||||||
fields: Prisma.NewsFieldRefs
|
fields: Prisma.NewsFieldRefs
|
||||||
@@ -1320,80 +1095,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OilPriceHistory: {
|
|
||||||
payload: Prisma.$OilPriceHistoryPayload<ExtArgs>
|
|
||||||
fields: Prisma.OilPriceHistoryFieldRefs
|
|
||||||
operations: {
|
|
||||||
findUnique: {
|
|
||||||
args: Prisma.OilPriceHistoryFindUniqueArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload> | null
|
|
||||||
}
|
|
||||||
findUniqueOrThrow: {
|
|
||||||
args: Prisma.OilPriceHistoryFindUniqueOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
|
||||||
}
|
|
||||||
findFirst: {
|
|
||||||
args: Prisma.OilPriceHistoryFindFirstArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload> | null
|
|
||||||
}
|
|
||||||
findFirstOrThrow: {
|
|
||||||
args: Prisma.OilPriceHistoryFindFirstOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
|
||||||
}
|
|
||||||
findMany: {
|
|
||||||
args: Prisma.OilPriceHistoryFindManyArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>[]
|
|
||||||
}
|
|
||||||
create: {
|
|
||||||
args: Prisma.OilPriceHistoryCreateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
|
||||||
}
|
|
||||||
createMany: {
|
|
||||||
args: Prisma.OilPriceHistoryCreateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
createManyAndReturn: {
|
|
||||||
args: Prisma.OilPriceHistoryCreateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>[]
|
|
||||||
}
|
|
||||||
delete: {
|
|
||||||
args: Prisma.OilPriceHistoryDeleteArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
|
||||||
}
|
|
||||||
update: {
|
|
||||||
args: Prisma.OilPriceHistoryUpdateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
|
||||||
}
|
|
||||||
deleteMany: {
|
|
||||||
args: Prisma.OilPriceHistoryDeleteManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateMany: {
|
|
||||||
args: Prisma.OilPriceHistoryUpdateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateManyAndReturn: {
|
|
||||||
args: Prisma.OilPriceHistoryUpdateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>[]
|
|
||||||
}
|
|
||||||
upsert: {
|
|
||||||
args: Prisma.OilPriceHistoryUpsertArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OilPriceHistoryPayload>
|
|
||||||
}
|
|
||||||
aggregate: {
|
|
||||||
args: Prisma.OilPriceHistoryAggregateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.AggregateOilPriceHistory>
|
|
||||||
}
|
|
||||||
groupBy: {
|
|
||||||
args: Prisma.OilPriceHistoryGroupByArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.OilPriceHistoryGroupByOutputType>[]
|
|
||||||
}
|
|
||||||
count: {
|
|
||||||
args: Prisma.OilPriceHistoryCountArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.OilPriceHistoryCountAggregateOutputType> | number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MihomeAccount: {
|
MihomeAccount: {
|
||||||
payload: Prisma.$MihomeAccountPayload<ExtArgs>
|
payload: Prisma.$MihomeAccountPayload<ExtArgs>
|
||||||
fields: Prisma.MihomeAccountFieldRefs
|
fields: Prisma.MihomeAccountFieldRefs
|
||||||
@@ -2578,6 +2279,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
McpToken: {
|
||||||
|
payload: Prisma.$McpTokenPayload<ExtArgs>
|
||||||
|
fields: Prisma.McpTokenFieldRefs
|
||||||
|
operations: {
|
||||||
|
findUnique: {
|
||||||
|
args: Prisma.McpTokenFindUniqueArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload> | null
|
||||||
|
}
|
||||||
|
findUniqueOrThrow: {
|
||||||
|
args: Prisma.McpTokenFindUniqueOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>
|
||||||
|
}
|
||||||
|
findFirst: {
|
||||||
|
args: Prisma.McpTokenFindFirstArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload> | null
|
||||||
|
}
|
||||||
|
findFirstOrThrow: {
|
||||||
|
args: Prisma.McpTokenFindFirstOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>
|
||||||
|
}
|
||||||
|
findMany: {
|
||||||
|
args: Prisma.McpTokenFindManyArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>[]
|
||||||
|
}
|
||||||
|
create: {
|
||||||
|
args: Prisma.McpTokenCreateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>
|
||||||
|
}
|
||||||
|
createMany: {
|
||||||
|
args: Prisma.McpTokenCreateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
createManyAndReturn: {
|
||||||
|
args: Prisma.McpTokenCreateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>[]
|
||||||
|
}
|
||||||
|
delete: {
|
||||||
|
args: Prisma.McpTokenDeleteArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>
|
||||||
|
}
|
||||||
|
update: {
|
||||||
|
args: Prisma.McpTokenUpdateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>
|
||||||
|
}
|
||||||
|
deleteMany: {
|
||||||
|
args: Prisma.McpTokenDeleteManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateMany: {
|
||||||
|
args: Prisma.McpTokenUpdateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateManyAndReturn: {
|
||||||
|
args: Prisma.McpTokenUpdateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>[]
|
||||||
|
}
|
||||||
|
upsert: {
|
||||||
|
args: Prisma.McpTokenUpsertArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$McpTokenPayload>
|
||||||
|
}
|
||||||
|
aggregate: {
|
||||||
|
args: Prisma.McpTokenAggregateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.AggregateMcpToken>
|
||||||
|
}
|
||||||
|
groupBy: {
|
||||||
|
args: Prisma.McpTokenGroupByArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.McpTokenGroupByOutputType>[]
|
||||||
|
}
|
||||||
|
count: {
|
||||||
|
args: Prisma.McpTokenCountArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.McpTokenCountAggregateOutputType> | number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} & {
|
} & {
|
||||||
other: {
|
other: {
|
||||||
@@ -2642,46 +2417,6 @@ export const ArticleScalarFieldEnum = {
|
|||||||
export type ArticleScalarFieldEnum = (typeof ArticleScalarFieldEnum)[keyof typeof ArticleScalarFieldEnum]
|
export type ArticleScalarFieldEnum = (typeof ArticleScalarFieldEnum)[keyof typeof ArticleScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const StrategyScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
name: 'name',
|
|
||||||
description: 'description',
|
|
||||||
data: 'data',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type StrategyScalarFieldEnum = (typeof StrategyScalarFieldEnum)[keyof typeof StrategyScalarFieldEnum]
|
|
||||||
|
|
||||||
|
|
||||||
export const IndicatorScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
name: 'name',
|
|
||||||
groupName: 'groupName',
|
|
||||||
title: 'title',
|
|
||||||
unit: 'unit',
|
|
||||||
source: 'source',
|
|
||||||
frequency: 'frequency',
|
|
||||||
hidden: 'hidden',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
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 = {
|
export const NewsScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
@@ -2801,19 +2536,6 @@ export const ScraperCacheScalarFieldEnum = {
|
|||||||
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof 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 = {
|
export const MihomeAccountScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
key: 'key',
|
key: 'key',
|
||||||
@@ -3068,6 +2790,20 @@ export const TaskTagScalarFieldEnum = {
|
|||||||
export type TaskTagScalarFieldEnum = (typeof TaskTagScalarFieldEnum)[keyof typeof TaskTagScalarFieldEnum]
|
export type TaskTagScalarFieldEnum = (typeof TaskTagScalarFieldEnum)[keyof typeof TaskTagScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const McpTokenScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
userId: 'userId',
|
||||||
|
name: 'name',
|
||||||
|
prefix: 'prefix',
|
||||||
|
tokenHash: 'tokenHash',
|
||||||
|
lastUsedAt: 'lastUsedAt',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type McpTokenScalarFieldEnum = (typeof McpTokenScalarFieldEnum)[keyof typeof McpTokenScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const SortOrder = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -3076,13 +2812,6 @@ export const SortOrder = {
|
|||||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||||
|
|
||||||
|
|
||||||
export const JsonNullValueInput = {
|
|
||||||
JsonNull: JsonNull
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
|
||||||
|
|
||||||
|
|
||||||
export const NullableJsonNullValueInput = {
|
export const NullableJsonNullValueInput = {
|
||||||
DbNull: DbNull,
|
DbNull: DbNull,
|
||||||
JsonNull: JsonNull
|
JsonNull: JsonNull
|
||||||
@@ -3091,6 +2820,13 @@ export const NullableJsonNullValueInput = {
|
|||||||
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||||
|
|
||||||
|
|
||||||
|
export const JsonNullValueInput = {
|
||||||
|
JsonNull: JsonNull
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||||
|
|
||||||
|
|
||||||
export const QueryMode = {
|
export const QueryMode = {
|
||||||
default: 'default',
|
default: 'default',
|
||||||
insensitive: 'insensitive'
|
insensitive: 'insensitive'
|
||||||
@@ -3099,6 +2835,14 @@ export const QueryMode = {
|
|||||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||||
|
|
||||||
|
|
||||||
|
export const NullsOrder = {
|
||||||
|
first: 'first',
|
||||||
|
last: 'last'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||||
|
|
||||||
|
|
||||||
export const JsonNullValueFilter = {
|
export const JsonNullValueFilter = {
|
||||||
DbNull: DbNull,
|
DbNull: DbNull,
|
||||||
JsonNull: JsonNull,
|
JsonNull: JsonNull,
|
||||||
@@ -3108,14 +2852,6 @@ export const JsonNullValueFilter = {
|
|||||||
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||||
|
|
||||||
|
|
||||||
export const NullsOrder = {
|
|
||||||
first: 'first',
|
|
||||||
last: 'last'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Field references
|
* Field references
|
||||||
@@ -3164,27 +2900,6 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reference to a field of type 'Json'
|
|
||||||
*/
|
|
||||||
export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Json'>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reference to a field of type 'QueryMode'
|
|
||||||
*/
|
|
||||||
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reference to a field of type 'Boolean'
|
|
||||||
*/
|
|
||||||
export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference to a field of type 'Float'
|
* Reference to a field of type 'Float'
|
||||||
*/
|
*/
|
||||||
@@ -3212,6 +2927,27 @@ export type BigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel,
|
|||||||
export type ListBigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BigInt[]'>
|
export type ListBigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BigInt[]'>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to a field of type 'Json'
|
||||||
|
*/
|
||||||
|
export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Json'>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to a field of type 'QueryMode'
|
||||||
|
*/
|
||||||
|
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to a field of type 'Boolean'
|
||||||
|
*/
|
||||||
|
export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'>
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch Payload for updateMany & deleteMany & createMany
|
* Batch Payload for updateMany & deleteMany & createMany
|
||||||
*/
|
*/
|
||||||
@@ -3309,9 +3045,6 @@ export type PrismaClientOptions = ({
|
|||||||
export type GlobalOmitConfig = {
|
export type GlobalOmitConfig = {
|
||||||
user?: Prisma.UserOmit
|
user?: Prisma.UserOmit
|
||||||
article?: Prisma.ArticleOmit
|
article?: Prisma.ArticleOmit
|
||||||
strategy?: Prisma.StrategyOmit
|
|
||||||
indicator?: Prisma.IndicatorOmit
|
|
||||||
indicatorPoint?: Prisma.IndicatorPointOmit
|
|
||||||
news?: Prisma.NewsOmit
|
news?: Prisma.NewsOmit
|
||||||
alert?: Prisma.AlertOmit
|
alert?: Prisma.AlertOmit
|
||||||
serverNode?: Prisma.ServerNodeOmit
|
serverNode?: Prisma.ServerNodeOmit
|
||||||
@@ -3319,7 +3052,6 @@ export type GlobalOmitConfig = {
|
|||||||
serviceCheck?: Prisma.ServiceCheckOmit
|
serviceCheck?: Prisma.ServiceCheckOmit
|
||||||
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
|
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
|
||||||
scraperCache?: Prisma.ScraperCacheOmit
|
scraperCache?: Prisma.ScraperCacheOmit
|
||||||
oilPriceHistory?: Prisma.OilPriceHistoryOmit
|
|
||||||
mihomeAccount?: Prisma.MihomeAccountOmit
|
mihomeAccount?: Prisma.MihomeAccountOmit
|
||||||
mihomeHome?: Prisma.MihomeHomeOmit
|
mihomeHome?: Prisma.MihomeHomeOmit
|
||||||
mihomeDevice?: Prisma.MihomeDeviceOmit
|
mihomeDevice?: Prisma.MihomeDeviceOmit
|
||||||
@@ -3336,6 +3068,7 @@ export type GlobalOmitConfig = {
|
|||||||
task?: Prisma.TaskOmit
|
task?: Prisma.TaskOmit
|
||||||
taskMember?: Prisma.TaskMemberOmit
|
taskMember?: Prisma.TaskMemberOmit
|
||||||
taskTag?: Prisma.TaskTagOmit
|
taskTag?: Prisma.TaskTagOmit
|
||||||
|
mcpToken?: Prisma.McpTokenOmit
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Types for Logging */
|
/* Types for Logging */
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ export const AnyNull = runtime.AnyNull
|
|||||||
export const ModelName = {
|
export const ModelName = {
|
||||||
User: 'User',
|
User: 'User',
|
||||||
Article: 'Article',
|
Article: 'Article',
|
||||||
Strategy: 'Strategy',
|
|
||||||
Indicator: 'Indicator',
|
|
||||||
IndicatorPoint: 'IndicatorPoint',
|
|
||||||
News: 'News',
|
News: 'News',
|
||||||
Alert: 'Alert',
|
Alert: 'Alert',
|
||||||
ServerNode: 'ServerNode',
|
ServerNode: 'ServerNode',
|
||||||
@@ -63,7 +60,6 @@ export const ModelName = {
|
|||||||
ServiceCheck: 'ServiceCheck',
|
ServiceCheck: 'ServiceCheck',
|
||||||
Econ_SowInventory: 'Econ_SowInventory',
|
Econ_SowInventory: 'Econ_SowInventory',
|
||||||
ScraperCache: 'ScraperCache',
|
ScraperCache: 'ScraperCache',
|
||||||
OilPriceHistory: 'OilPriceHistory',
|
|
||||||
MihomeAccount: 'MihomeAccount',
|
MihomeAccount: 'MihomeAccount',
|
||||||
MihomeHome: 'MihomeHome',
|
MihomeHome: 'MihomeHome',
|
||||||
MihomeDevice: 'MihomeDevice',
|
MihomeDevice: 'MihomeDevice',
|
||||||
@@ -79,7 +75,8 @@ export const ModelName = {
|
|||||||
BoardColumn: 'BoardColumn',
|
BoardColumn: 'BoardColumn',
|
||||||
Task: 'Task',
|
Task: 'Task',
|
||||||
TaskMember: 'TaskMember',
|
TaskMember: 'TaskMember',
|
||||||
TaskTag: 'TaskTag'
|
TaskTag: 'TaskTag',
|
||||||
|
McpToken: 'McpToken'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
@@ -123,46 +120,6 @@ export const ArticleScalarFieldEnum = {
|
|||||||
export type ArticleScalarFieldEnum = (typeof ArticleScalarFieldEnum)[keyof typeof ArticleScalarFieldEnum]
|
export type ArticleScalarFieldEnum = (typeof ArticleScalarFieldEnum)[keyof typeof ArticleScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const StrategyScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
name: 'name',
|
|
||||||
description: 'description',
|
|
||||||
data: 'data',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type StrategyScalarFieldEnum = (typeof StrategyScalarFieldEnum)[keyof typeof StrategyScalarFieldEnum]
|
|
||||||
|
|
||||||
|
|
||||||
export const IndicatorScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
name: 'name',
|
|
||||||
groupName: 'groupName',
|
|
||||||
title: 'title',
|
|
||||||
unit: 'unit',
|
|
||||||
source: 'source',
|
|
||||||
frequency: 'frequency',
|
|
||||||
hidden: 'hidden',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
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 = {
|
export const NewsScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
@@ -282,19 +239,6 @@ export const ScraperCacheScalarFieldEnum = {
|
|||||||
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof 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 = {
|
export const MihomeAccountScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
key: 'key',
|
key: 'key',
|
||||||
@@ -549,6 +493,20 @@ export const TaskTagScalarFieldEnum = {
|
|||||||
export type TaskTagScalarFieldEnum = (typeof TaskTagScalarFieldEnum)[keyof typeof TaskTagScalarFieldEnum]
|
export type TaskTagScalarFieldEnum = (typeof TaskTagScalarFieldEnum)[keyof typeof TaskTagScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const McpTokenScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
userId: 'userId',
|
||||||
|
name: 'name',
|
||||||
|
prefix: 'prefix',
|
||||||
|
tokenHash: 'tokenHash',
|
||||||
|
lastUsedAt: 'lastUsedAt',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type McpTokenScalarFieldEnum = (typeof McpTokenScalarFieldEnum)[keyof typeof McpTokenScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const SortOrder = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -557,13 +515,6 @@ export const SortOrder = {
|
|||||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||||
|
|
||||||
|
|
||||||
export const JsonNullValueInput = {
|
|
||||||
JsonNull: JsonNull
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
|
||||||
|
|
||||||
|
|
||||||
export const NullableJsonNullValueInput = {
|
export const NullableJsonNullValueInput = {
|
||||||
DbNull: DbNull,
|
DbNull: DbNull,
|
||||||
JsonNull: JsonNull
|
JsonNull: JsonNull
|
||||||
@@ -572,6 +523,13 @@ export const NullableJsonNullValueInput = {
|
|||||||
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||||
|
|
||||||
|
|
||||||
|
export const JsonNullValueInput = {
|
||||||
|
JsonNull: JsonNull
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||||
|
|
||||||
|
|
||||||
export const QueryMode = {
|
export const QueryMode = {
|
||||||
default: 'default',
|
default: 'default',
|
||||||
insensitive: 'insensitive'
|
insensitive: 'insensitive'
|
||||||
@@ -580,6 +538,14 @@ export const QueryMode = {
|
|||||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||||
|
|
||||||
|
|
||||||
|
export const NullsOrder = {
|
||||||
|
first: 'first',
|
||||||
|
last: 'last'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||||
|
|
||||||
|
|
||||||
export const JsonNullValueFilter = {
|
export const JsonNullValueFilter = {
|
||||||
DbNull: DbNull,
|
DbNull: DbNull,
|
||||||
JsonNull: JsonNull,
|
JsonNull: JsonNull,
|
||||||
@@ -588,11 +554,3 @@ export const JsonNullValueFilter = {
|
|||||||
|
|
||||||
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||||
|
|
||||||
|
|
||||||
export const NullsOrder = {
|
|
||||||
first: 'first',
|
|
||||||
last: 'last'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
*/
|
*/
|
||||||
export type * from './models/User.ts'
|
export type * from './models/User.ts'
|
||||||
export type * from './models/Article.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/News.ts'
|
||||||
export type * from './models/Alert.ts'
|
export type * from './models/Alert.ts'
|
||||||
export type * from './models/ServerNode.ts'
|
export type * from './models/ServerNode.ts'
|
||||||
@@ -20,7 +17,6 @@ export type * from './models/ServerNodeHistory.ts'
|
|||||||
export type * from './models/ServiceCheck.ts'
|
export type * from './models/ServiceCheck.ts'
|
||||||
export type * from './models/Econ_SowInventory.ts'
|
export type * from './models/Econ_SowInventory.ts'
|
||||||
export type * from './models/ScraperCache.ts'
|
export type * from './models/ScraperCache.ts'
|
||||||
export type * from './models/OilPriceHistory.ts'
|
|
||||||
export type * from './models/MihomeAccount.ts'
|
export type * from './models/MihomeAccount.ts'
|
||||||
export type * from './models/MihomeHome.ts'
|
export type * from './models/MihomeHome.ts'
|
||||||
export type * from './models/MihomeDevice.ts'
|
export type * from './models/MihomeDevice.ts'
|
||||||
@@ -37,4 +33,5 @@ export type * from './models/BoardColumn.ts'
|
|||||||
export type * from './models/Task.ts'
|
export type * from './models/Task.ts'
|
||||||
export type * from './models/TaskMember.ts'
|
export type * from './models/TaskMember.ts'
|
||||||
export type * from './models/TaskTag.ts'
|
export type * from './models/TaskTag.ts'
|
||||||
|
export type * from './models/McpToken.ts'
|
||||||
export type * from './commonInputTypes.ts'
|
export type * from './commonInputTypes.ts'
|
||||||
@@ -480,6 +480,14 @@ export type AlertSumOrderByAggregateInput = {
|
|||||||
diffRatio?: Prisma.SortOrder
|
diffRatio?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FloatFieldUpdateOperationsInput = {
|
||||||
|
set?: number
|
||||||
|
increment?: number
|
||||||
|
decrement?: number
|
||||||
|
multiply?: number
|
||||||
|
divide?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type AlertSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type AlertSelect<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
@@ -737,6 +737,10 @@ export type NullableDateTimeFieldUpdateOperationsInput = {
|
|||||||
set?: Date | string | null
|
set?: Date | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BoolFieldUpdateOperationsInput = {
|
||||||
|
set?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type MihomeDeviceCreateNestedOneWithoutSamplesInput = {
|
export type MihomeDeviceCreateNestedOneWithoutSamplesInput = {
|
||||||
create?: Prisma.XOR<Prisma.MihomeDeviceCreateWithoutSamplesInput, Prisma.MihomeDeviceUncheckedCreateWithoutSamplesInput>
|
create?: Prisma.XOR<Prisma.MihomeDeviceCreateWithoutSamplesInput, Prisma.MihomeDeviceUncheckedCreateWithoutSamplesInput>
|
||||||
connectOrCreate?: Prisma.MihomeDeviceCreateOrConnectWithoutSamplesInput
|
connectOrCreate?: Prisma.MihomeDeviceCreateOrConnectWithoutSamplesInput
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -233,6 +233,7 @@ export type UserWhereInput = {
|
|||||||
assignedTasks?: Prisma.TaskListRelationFilter
|
assignedTasks?: Prisma.TaskListRelationFilter
|
||||||
createdTasks?: Prisma.TaskListRelationFilter
|
createdTasks?: Prisma.TaskListRelationFilter
|
||||||
taskMembers?: Prisma.TaskMemberListRelationFilter
|
taskMembers?: Prisma.TaskMemberListRelationFilter
|
||||||
|
mcpTokens?: Prisma.McpTokenListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserOrderByWithRelationInput = {
|
export type UserOrderByWithRelationInput = {
|
||||||
@@ -251,6 +252,7 @@ export type UserOrderByWithRelationInput = {
|
|||||||
assignedTasks?: Prisma.TaskOrderByRelationAggregateInput
|
assignedTasks?: Prisma.TaskOrderByRelationAggregateInput
|
||||||
createdTasks?: Prisma.TaskOrderByRelationAggregateInput
|
createdTasks?: Prisma.TaskOrderByRelationAggregateInput
|
||||||
taskMembers?: Prisma.TaskMemberOrderByRelationAggregateInput
|
taskMembers?: Prisma.TaskMemberOrderByRelationAggregateInput
|
||||||
|
mcpTokens?: Prisma.McpTokenOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||||
@@ -272,6 +274,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
assignedTasks?: Prisma.TaskListRelationFilter
|
assignedTasks?: Prisma.TaskListRelationFilter
|
||||||
createdTasks?: Prisma.TaskListRelationFilter
|
createdTasks?: Prisma.TaskListRelationFilter
|
||||||
taskMembers?: Prisma.TaskMemberListRelationFilter
|
taskMembers?: Prisma.TaskMemberListRelationFilter
|
||||||
|
mcpTokens?: Prisma.McpTokenListRelationFilter
|
||||||
}, "id" | "email">
|
}, "id" | "email">
|
||||||
|
|
||||||
export type UserOrderByWithAggregationInput = {
|
export type UserOrderByWithAggregationInput = {
|
||||||
@@ -315,6 +318,7 @@ export type UserCreateInput = {
|
|||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateInput = {
|
export type UserUncheckedCreateInput = {
|
||||||
@@ -333,6 +337,7 @@ export type UserUncheckedCreateInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpdateInput = {
|
export type UserUpdateInput = {
|
||||||
@@ -350,6 +355,7 @@ export type UserUpdateInput = {
|
|||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateInput = {
|
export type UserUncheckedUpdateInput = {
|
||||||
@@ -368,6 +374,7 @@ export type UserUncheckedUpdateInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateManyInput = {
|
export type UserCreateManyInput = {
|
||||||
@@ -587,6 +594,20 @@ export type UserUpdateOneRequiredWithoutTaskMembersNestedInput = {
|
|||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutTaskMembersInput, Prisma.UserUpdateWithoutTaskMembersInput>, Prisma.UserUncheckedUpdateWithoutTaskMembersInput>
|
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutTaskMembersInput, Prisma.UserUpdateWithoutTaskMembersInput>, Prisma.UserUncheckedUpdateWithoutTaskMembersInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserCreateNestedOneWithoutMcpTokensInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.UserCreateWithoutMcpTokensInput, Prisma.UserUncheckedCreateWithoutMcpTokensInput>
|
||||||
|
connectOrCreate?: Prisma.UserCreateOrConnectWithoutMcpTokensInput
|
||||||
|
connect?: Prisma.UserWhereUniqueInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUpdateOneRequiredWithoutMcpTokensNestedInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.UserCreateWithoutMcpTokensInput, Prisma.UserUncheckedCreateWithoutMcpTokensInput>
|
||||||
|
connectOrCreate?: Prisma.UserCreateOrConnectWithoutMcpTokensInput
|
||||||
|
upsert?: Prisma.UserUpsertWithoutMcpTokensInput
|
||||||
|
connect?: Prisma.UserWhereUniqueInput
|
||||||
|
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutMcpTokensInput, Prisma.UserUpdateWithoutMcpTokensInput>, Prisma.UserUncheckedUpdateWithoutMcpTokensInput>
|
||||||
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutHabitsInput = {
|
export type UserCreateWithoutHabitsInput = {
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
@@ -601,6 +622,7 @@ export type UserCreateWithoutHabitsInput = {
|
|||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutHabitsInput = {
|
export type UserUncheckedCreateWithoutHabitsInput = {
|
||||||
@@ -618,6 +640,7 @@ export type UserUncheckedCreateWithoutHabitsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutHabitsInput = {
|
export type UserCreateOrConnectWithoutHabitsInput = {
|
||||||
@@ -650,6 +673,7 @@ export type UserUpdateWithoutHabitsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutHabitsInput = {
|
export type UserUncheckedUpdateWithoutHabitsInput = {
|
||||||
@@ -667,6 +691,7 @@ export type UserUncheckedUpdateWithoutHabitsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutCheckInsInput = {
|
export type UserCreateWithoutCheckInsInput = {
|
||||||
@@ -683,6 +708,7 @@ export type UserCreateWithoutCheckInsInput = {
|
|||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutCheckInsInput = {
|
export type UserUncheckedCreateWithoutCheckInsInput = {
|
||||||
@@ -700,6 +726,7 @@ export type UserUncheckedCreateWithoutCheckInsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutCheckInsInput = {
|
export type UserCreateOrConnectWithoutCheckInsInput = {
|
||||||
@@ -732,6 +759,7 @@ export type UserUpdateWithoutCheckInsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutCheckInsInput = {
|
export type UserUncheckedUpdateWithoutCheckInsInput = {
|
||||||
@@ -749,6 +777,7 @@ export type UserUncheckedUpdateWithoutCheckInsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutCheckListsInput = {
|
export type UserCreateWithoutCheckListsInput = {
|
||||||
@@ -765,6 +794,7 @@ export type UserCreateWithoutCheckListsInput = {
|
|||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutCheckListsInput = {
|
export type UserUncheckedCreateWithoutCheckListsInput = {
|
||||||
@@ -782,6 +812,7 @@ export type UserUncheckedCreateWithoutCheckListsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutCheckListsInput = {
|
export type UserCreateOrConnectWithoutCheckListsInput = {
|
||||||
@@ -814,6 +845,7 @@ export type UserUpdateWithoutCheckListsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutCheckListsInput = {
|
export type UserUncheckedUpdateWithoutCheckListsInput = {
|
||||||
@@ -831,6 +863,7 @@ export type UserUncheckedUpdateWithoutCheckListsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutTemplatesInput = {
|
export type UserCreateWithoutTemplatesInput = {
|
||||||
@@ -847,6 +880,7 @@ export type UserCreateWithoutTemplatesInput = {
|
|||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutTemplatesInput = {
|
export type UserUncheckedCreateWithoutTemplatesInput = {
|
||||||
@@ -864,6 +898,7 @@ export type UserUncheckedCreateWithoutTemplatesInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutTemplatesInput = {
|
export type UserCreateOrConnectWithoutTemplatesInput = {
|
||||||
@@ -896,6 +931,7 @@ export type UserUpdateWithoutTemplatesInput = {
|
|||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutTemplatesInput = {
|
export type UserUncheckedUpdateWithoutTemplatesInput = {
|
||||||
@@ -913,6 +949,7 @@ export type UserUncheckedUpdateWithoutTemplatesInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutOwnedProjectsInput = {
|
export type UserCreateWithoutOwnedProjectsInput = {
|
||||||
@@ -929,6 +966,7 @@ export type UserCreateWithoutOwnedProjectsInput = {
|
|||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutOwnedProjectsInput = {
|
export type UserUncheckedCreateWithoutOwnedProjectsInput = {
|
||||||
@@ -946,6 +984,7 @@ export type UserUncheckedCreateWithoutOwnedProjectsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutOwnedProjectsInput = {
|
export type UserCreateOrConnectWithoutOwnedProjectsInput = {
|
||||||
@@ -978,6 +1017,7 @@ export type UserUpdateWithoutOwnedProjectsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutOwnedProjectsInput = {
|
export type UserUncheckedUpdateWithoutOwnedProjectsInput = {
|
||||||
@@ -995,6 +1035,7 @@ export type UserUncheckedUpdateWithoutOwnedProjectsInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutProjectMembersInput = {
|
export type UserCreateWithoutProjectMembersInput = {
|
||||||
@@ -1011,6 +1052,7 @@ export type UserCreateWithoutProjectMembersInput = {
|
|||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutProjectMembersInput = {
|
export type UserUncheckedCreateWithoutProjectMembersInput = {
|
||||||
@@ -1028,6 +1070,7 @@ export type UserUncheckedCreateWithoutProjectMembersInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutProjectMembersInput = {
|
export type UserCreateOrConnectWithoutProjectMembersInput = {
|
||||||
@@ -1060,6 +1103,7 @@ export type UserUpdateWithoutProjectMembersInput = {
|
|||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutProjectMembersInput = {
|
export type UserUncheckedUpdateWithoutProjectMembersInput = {
|
||||||
@@ -1077,6 +1121,7 @@ export type UserUncheckedUpdateWithoutProjectMembersInput = {
|
|||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutAssignedTasksInput = {
|
export type UserCreateWithoutAssignedTasksInput = {
|
||||||
@@ -1093,6 +1138,7 @@ export type UserCreateWithoutAssignedTasksInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
|
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutAssignedTasksInput = {
|
export type UserUncheckedCreateWithoutAssignedTasksInput = {
|
||||||
@@ -1110,6 +1156,7 @@ export type UserUncheckedCreateWithoutAssignedTasksInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
|
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutAssignedTasksInput = {
|
export type UserCreateOrConnectWithoutAssignedTasksInput = {
|
||||||
@@ -1131,6 +1178,7 @@ export type UserCreateWithoutCreatedTasksInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
|
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
|
||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutCreatedTasksInput = {
|
export type UserUncheckedCreateWithoutCreatedTasksInput = {
|
||||||
@@ -1148,6 +1196,7 @@ export type UserUncheckedCreateWithoutCreatedTasksInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
|
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutCreatedTasksInput = {
|
export type UserCreateOrConnectWithoutCreatedTasksInput = {
|
||||||
@@ -1180,6 +1229,7 @@ export type UserUpdateWithoutAssignedTasksInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
|
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutAssignedTasksInput = {
|
export type UserUncheckedUpdateWithoutAssignedTasksInput = {
|
||||||
@@ -1197,6 +1247,7 @@ export type UserUncheckedUpdateWithoutAssignedTasksInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
|
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpsertWithoutCreatedTasksInput = {
|
export type UserUpsertWithoutCreatedTasksInput = {
|
||||||
@@ -1224,6 +1275,7 @@ export type UserUpdateWithoutCreatedTasksInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
|
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
|
||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutCreatedTasksInput = {
|
export type UserUncheckedUpdateWithoutCreatedTasksInput = {
|
||||||
@@ -1241,6 +1293,7 @@ export type UserUncheckedUpdateWithoutCreatedTasksInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
|
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutTaskMembersInput = {
|
export type UserCreateWithoutTaskMembersInput = {
|
||||||
@@ -1257,6 +1310,7 @@ export type UserCreateWithoutTaskMembersInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
|
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
|
||||||
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
|
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutTaskMembersInput = {
|
export type UserUncheckedCreateWithoutTaskMembersInput = {
|
||||||
@@ -1274,6 +1328,7 @@ export type UserUncheckedCreateWithoutTaskMembersInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
|
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutTaskMembersInput = {
|
export type UserCreateOrConnectWithoutTaskMembersInput = {
|
||||||
@@ -1306,6 +1361,7 @@ export type UserUpdateWithoutTaskMembersInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
|
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
|
||||||
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutTaskMembersInput = {
|
export type UserUncheckedUpdateWithoutTaskMembersInput = {
|
||||||
@@ -1323,6 +1379,93 @@ export type UserUncheckedUpdateWithoutTaskMembersInput = {
|
|||||||
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
|
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
|
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserCreateWithoutMcpTokensInput = {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
|
||||||
|
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
|
||||||
|
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
|
||||||
|
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
|
||||||
|
ownedProjects?: Prisma.ProjectCreateNestedManyWithoutOwnerInput
|
||||||
|
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
|
||||||
|
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
|
||||||
|
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
|
||||||
|
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUncheckedCreateWithoutMcpTokensInput = {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
ownedProjects?: Prisma.ProjectUncheckedCreateNestedManyWithoutOwnerInput
|
||||||
|
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
|
||||||
|
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
|
||||||
|
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserCreateOrConnectWithoutMcpTokensInput = {
|
||||||
|
where: Prisma.UserWhereUniqueInput
|
||||||
|
create: Prisma.XOR<Prisma.UserCreateWithoutMcpTokensInput, Prisma.UserUncheckedCreateWithoutMcpTokensInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUpsertWithoutMcpTokensInput = {
|
||||||
|
update: Prisma.XOR<Prisma.UserUpdateWithoutMcpTokensInput, Prisma.UserUncheckedUpdateWithoutMcpTokensInput>
|
||||||
|
create: Prisma.XOR<Prisma.UserCreateWithoutMcpTokensInput, Prisma.UserUncheckedCreateWithoutMcpTokensInput>
|
||||||
|
where?: Prisma.UserWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUpdateToOneWithWhereWithoutMcpTokensInput = {
|
||||||
|
where?: Prisma.UserWhereInput
|
||||||
|
data: Prisma.XOR<Prisma.UserUpdateWithoutMcpTokensInput, Prisma.UserUncheckedUpdateWithoutMcpTokensInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUpdateWithoutMcpTokensInput = {
|
||||||
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
password?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
|
||||||
|
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
|
||||||
|
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
|
||||||
|
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
|
||||||
|
ownedProjects?: Prisma.ProjectUpdateManyWithoutOwnerNestedInput
|
||||||
|
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
|
||||||
|
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
|
||||||
|
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
|
||||||
|
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUncheckedUpdateWithoutMcpTokensInput = {
|
||||||
|
id?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
password?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
ownedProjects?: Prisma.ProjectUncheckedUpdateManyWithoutOwnerNestedInput
|
||||||
|
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
|
||||||
|
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
|
||||||
|
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1340,6 +1483,7 @@ export type UserCountOutputType = {
|
|||||||
assignedTasks: number
|
assignedTasks: number
|
||||||
createdTasks: number
|
createdTasks: number
|
||||||
taskMembers: number
|
taskMembers: number
|
||||||
|
mcpTokens: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
@@ -1352,6 +1496,7 @@ export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.I
|
|||||||
assignedTasks?: boolean | UserCountOutputTypeCountAssignedTasksArgs
|
assignedTasks?: boolean | UserCountOutputTypeCountAssignedTasksArgs
|
||||||
createdTasks?: boolean | UserCountOutputTypeCountCreatedTasksArgs
|
createdTasks?: boolean | UserCountOutputTypeCountCreatedTasksArgs
|
||||||
taskMembers?: boolean | UserCountOutputTypeCountTaskMembersArgs
|
taskMembers?: boolean | UserCountOutputTypeCountTaskMembersArgs
|
||||||
|
mcpTokens?: boolean | UserCountOutputTypeCountMcpTokensArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1427,6 +1572,13 @@ export type UserCountOutputTypeCountTaskMembersArgs<ExtArgs extends runtime.Type
|
|||||||
where?: Prisma.TaskMemberWhereInput
|
where?: Prisma.TaskMemberWhereInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserCountOutputType without action
|
||||||
|
*/
|
||||||
|
export type UserCountOutputTypeCountMcpTokensArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
|
where?: Prisma.McpTokenWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
@@ -1444,6 +1596,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
|||||||
assignedTasks?: boolean | Prisma.User$assignedTasksArgs<ExtArgs>
|
assignedTasks?: boolean | Prisma.User$assignedTasksArgs<ExtArgs>
|
||||||
createdTasks?: boolean | Prisma.User$createdTasksArgs<ExtArgs>
|
createdTasks?: boolean | Prisma.User$createdTasksArgs<ExtArgs>
|
||||||
taskMembers?: boolean | Prisma.User$taskMembersArgs<ExtArgs>
|
taskMembers?: boolean | Prisma.User$taskMembersArgs<ExtArgs>
|
||||||
|
mcpTokens?: boolean | Prisma.User$mcpTokensArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["user"]>
|
}, ExtArgs["result"]["user"]>
|
||||||
|
|
||||||
@@ -1485,6 +1638,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
assignedTasks?: boolean | Prisma.User$assignedTasksArgs<ExtArgs>
|
assignedTasks?: boolean | Prisma.User$assignedTasksArgs<ExtArgs>
|
||||||
createdTasks?: boolean | Prisma.User$createdTasksArgs<ExtArgs>
|
createdTasks?: boolean | Prisma.User$createdTasksArgs<ExtArgs>
|
||||||
taskMembers?: boolean | Prisma.User$taskMembersArgs<ExtArgs>
|
taskMembers?: boolean | Prisma.User$taskMembersArgs<ExtArgs>
|
||||||
|
mcpTokens?: boolean | Prisma.User$mcpTokensArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
|
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
|
||||||
@@ -1502,6 +1656,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
assignedTasks: Prisma.$TaskPayload<ExtArgs>[]
|
assignedTasks: Prisma.$TaskPayload<ExtArgs>[]
|
||||||
createdTasks: Prisma.$TaskPayload<ExtArgs>[]
|
createdTasks: Prisma.$TaskPayload<ExtArgs>[]
|
||||||
taskMembers: Prisma.$TaskMemberPayload<ExtArgs>[]
|
taskMembers: Prisma.$TaskMemberPayload<ExtArgs>[]
|
||||||
|
mcpTokens: Prisma.$McpTokenPayload<ExtArgs>[]
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: number
|
id: number
|
||||||
@@ -1913,6 +2068,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
|
|||||||
assignedTasks<T extends Prisma.User$assignedTasksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$assignedTasksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TaskPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
assignedTasks<T extends Prisma.User$assignedTasksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$assignedTasksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TaskPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
createdTasks<T extends Prisma.User$createdTasksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$createdTasksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TaskPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
createdTasks<T extends Prisma.User$createdTasksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$createdTasksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TaskPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
taskMembers<T extends Prisma.User$taskMembersArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$taskMembersArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TaskMemberPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
taskMembers<T extends Prisma.User$taskMembersArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$taskMembersArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TaskMemberPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
|
mcpTokens<T extends Prisma.User$mcpTokensArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$mcpTokensArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$McpTokenPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
/**
|
/**
|
||||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||||
@@ -2556,6 +2712,30 @@ export type User$taskMembersArgs<ExtArgs extends runtime.Types.Extensions.Intern
|
|||||||
distinct?: Prisma.TaskMemberScalarFieldEnum | Prisma.TaskMemberScalarFieldEnum[]
|
distinct?: Prisma.TaskMemberScalarFieldEnum | Prisma.TaskMemberScalarFieldEnum[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User.mcpTokens
|
||||||
|
*/
|
||||||
|
export type User$mcpTokensArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
|
/**
|
||||||
|
* Select specific fields to fetch from the McpToken
|
||||||
|
*/
|
||||||
|
select?: Prisma.McpTokenSelect<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Omit specific fields from the McpToken
|
||||||
|
*/
|
||||||
|
omit?: Prisma.McpTokenOmit<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Choose, which related nodes to fetch as well
|
||||||
|
*/
|
||||||
|
include?: Prisma.McpTokenInclude<ExtArgs> | null
|
||||||
|
where?: Prisma.McpTokenWhereInput
|
||||||
|
orderBy?: Prisma.McpTokenOrderByWithRelationInput | Prisma.McpTokenOrderByWithRelationInput[]
|
||||||
|
cursor?: Prisma.McpTokenWhereUniqueInput
|
||||||
|
take?: number
|
||||||
|
skip?: number
|
||||||
|
distinct?: Prisma.McpTokenScalarFieldEnum | Prisma.McpTokenScalarFieldEnum[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User without action
|
* User without action
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#1f2937" />
|
<meta name="theme-color" content="#1f2937" />
|
||||||
<title>iBoard - 经济数据与策略</title>
|
<title>iBoard - 个人看板</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+93
-55
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "iboard",
|
"name": "iboard",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "iboard",
|
"name": "iboard",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@journeyapps/wa-sqlite": "^2.0.7",
|
"@journeyapps/wa-sqlite": "^2.0.7",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"@powersync/react": "^2.1.0",
|
"@powersync/react": "^2.1.0",
|
||||||
"@powersync/web": "^2.5.0",
|
"@powersync/web": "^2.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -29,12 +30,15 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
|
"swiper": "^14.4.0",
|
||||||
"tauri-plugin-hotswap-api": "^0.0.4",
|
"tauri-plugin-hotswap-api": "^0.0.4",
|
||||||
"ws": "^8.21.2"
|
"ws": "^8.21.2",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.12.4",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.12.4",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
@@ -2525,9 +2529,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.11.1",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.4.tgz",
|
||||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
"integrity": "sha512-pVmfis+4zT8H0Qhp3xPZx9Ju7kpzvA6rr/PSJPyuwfvucTQ8sVt61NYU9KMkK703UFlYadFmjKA4HhybOtN0gg==",
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2535,9 +2539,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli": {
|
"node_modules/@tauri-apps/cli": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.4.tgz",
|
||||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
"integrity": "sha512-JI9bHfudSRJikGOrjbXEm6RjzzvmKuLc+JixB1NE8hNzQkNlCCIC3pTES3uypkR9lxHrsbxzcnSwhI2IxBQphw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2551,23 +2555,23 @@
|
|||||||
"url": "https://opencollective.com/tauri"
|
"url": "https://opencollective.com/tauri"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
"@tauri-apps/cli-darwin-arm64": "2.12.4",
|
||||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
"@tauri-apps/cli-darwin-x64": "2.12.4",
|
||||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.4",
|
||||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
"@tauri-apps/cli-linux-arm64-gnu": "2.12.4",
|
||||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
"@tauri-apps/cli-linux-arm64-musl": "2.12.4",
|
||||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.4",
|
||||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
"@tauri-apps/cli-linux-x64-gnu": "2.12.4",
|
||||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
"@tauri-apps/cli-linux-x64-musl": "2.12.4",
|
||||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
"@tauri-apps/cli-win32-arm64-msvc": "2.12.4",
|
||||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
"@tauri-apps/cli-win32-ia32-msvc": "2.12.4",
|
||||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
"@tauri-apps/cli-win32-x64-msvc": "2.12.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.4.tgz",
|
||||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
"integrity": "sha512-OxqKSckIkoJ6vX2AjifIJpTasFC0Hbdoo7gEiCmAjauABO3U8m2MRsLWe+QTiBwDx1mIrvgA+rvI7tvDnbnHVg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2582,9 +2586,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.4.tgz",
|
||||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
"integrity": "sha512-fz76/77wMMJOaaUN6n3CybRG0P/MTEjjqYk81mGPyhi9m6Ue5T+LWBnJP/AkPooNatBPI2vtulGqomyMJ44lCQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2599,9 +2603,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.4.tgz",
|
||||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
"integrity": "sha512-K0w8Bof6Lt8QKLAYhRy0yy36ZH5iA/35VuSOvnxyjvCUZ+A3JKsUTlALviH/sVgAlisaPzzmeJSH8XOn4Q2HBA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2616,9 +2620,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.4.tgz",
|
||||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
"integrity": "sha512-j5rOnjmRUwWrm1oosf0zBYl4D+fqydgUROFCk0hvKZxvbTT8SOERes0QB7rBV/XWNy+kM6pCKzb3oSm2sRglzg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2633,9 +2637,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.4.tgz",
|
||||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
"integrity": "sha512-aOue3Fy6Jb7+oWcfAVUsVHfeTybSCt2CZoU06aO4jv4d2Ks/KnaTPwMAK3CLEduGOvBLY1lbBBrIyomqLLH5Sg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2650,9 +2654,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.4.tgz",
|
||||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
"integrity": "sha512-Fx6GShY/RvST+t6LMlTHAwd66+1tEaAAGrnU7cysVfaiRP1BKng5Vl6rVmO6rSLOA1Sp1dRzNYM4f0OWQGb7qA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2667,9 +2671,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.4.tgz",
|
||||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
"integrity": "sha512-tNzZSa79Z1nRpHxEpQ38lsAFpKlFlYvoXzjTV+lRRn/zSf3wrdUC3wnLcq2XEadchPu4ttK0zivhYzr+XGD19Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2684,9 +2688,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.4.tgz",
|
||||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
"integrity": "sha512-jmZ1pr0xEqUhuEusWtt9Yt+GF6lCnGHUmIL+KIknpbjK/XQ2qkhDBhaxK/JMHM2A8dAacVXCxXJk2EXpwhQUfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2701,9 +2705,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.4.tgz",
|
||||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
"integrity": "sha512-fGo8pgEHZxdSYgZ/J98m1usJ7NHKmKbq7k6uT0x1rpOq6Bg2YVzzpaAblWk710f5LHYBJIL5QIFS5IrD+4gmTg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2718,9 +2722,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.4.tgz",
|
||||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
"integrity": "sha512-ezl8sQIcjYsye3TUnTH5VcWmWzccZASlQm7hlZt0dYyj1XD0qpVW47u2xYVrIKui0F8uQv/7QGZixdWiClBsbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2735,9 +2739,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
"version": "2.11.4",
|
"version": "2.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.4.tgz",
|
||||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
"integrity": "sha512-DJhDeCqjLDNMWYFy7ZS8cfeAHX2RK0RbQMLiA3bMVVkXRDJ4NBYiHriGqsUhsr9iG41aTb+dJGkFPmPw1gWwVw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3636,6 +3640,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compute-scroll-into-view": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "9.2.4",
|
"version": "9.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
|
||||||
@@ -6391,6 +6401,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/scroll-into-view-if-needed": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"compute-scroll-into-view": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/section-matter": {
|
"node_modules/section-matter": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||||
@@ -6771,6 +6790,25 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swiper": {
|
||||||
|
"version": "14.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/swiper/-/swiper-14.4.0.tgz",
|
||||||
|
"integrity": "sha512-6axnAuBsttFJe0gwsM5tT203/Bts0vcI+FwIX6dqFE4pUFVW6qrdRprNFQVxOYMW7V0S6A/KIqJ6JaPpNN2MCw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "custom",
|
||||||
|
"url": "https://sponsors.nolimits4web.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nolimits4web"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tauri-plugin-hotswap-api": {
|
"node_modules/tauri-plugin-hotswap-api": {
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/tauri-plugin-hotswap-api/-/tauri-plugin-hotswap-api-0.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/tauri-plugin-hotswap-api/-/tauri-plugin-hotswap-api-0.0.4.tgz",
|
||||||
|
|||||||
+6
-3
@@ -11,14 +11,13 @@
|
|||||||
"build:shell": "vite build --mode shell",
|
"build:shell": "vite build --mode shell",
|
||||||
"build:win": "node scripts/build-windows.mjs",
|
"build:win": "node scripts/build-windows.mjs",
|
||||||
"build:win:debug": "node scripts/build-windows.mjs --debug",
|
"build:win:debug": "node scripts/build-windows.mjs --debug",
|
||||||
|
"win:dev": "node scripts/dev-windows-live.mjs",
|
||||||
"build:ota": "node scripts/build-ota.mjs",
|
"build:ota": "node scripts/build-ota.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "node --experimental-strip-types --no-warnings server/index.js",
|
"start": "node --experimental-strip-types --no-warnings server/index.js",
|
||||||
"db:migrate": "prisma migrate deploy",
|
"db:migrate": "prisma migrate deploy",
|
||||||
"db:seed": "node scripts/seed.cjs",
|
"db:seed": "node scripts/seed.cjs",
|
||||||
"registry:sync": "node scripts/sync-registry.mjs",
|
"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",
|
"icons": "node scripts/generate-icons.mjs",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build",
|
"tauri:build": "tauri build",
|
||||||
@@ -34,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@journeyapps/wa-sqlite": "^2.0.7",
|
"@journeyapps/wa-sqlite": "^2.0.7",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"@powersync/react": "^2.1.0",
|
"@powersync/react": "^2.1.0",
|
||||||
"@powersync/web": "^2.5.0",
|
"@powersync/web": "^2.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -54,8 +54,11 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
|
"swiper": "^14.4.0",
|
||||||
"tauri-plugin-hotswap-api": "^0.0.4",
|
"tauri-plugin-hotswap-api": "^0.0.4",
|
||||||
"ws": "^8.21.2"
|
"ws": "^8.21.2",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/api": "^2.12.4",
|
"@tauri-apps/api": "^2.12.4",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- V0.2.2: 宏观「经济数据 / 策略研究」迁至外接 ifinance 模块, 油模块下线
|
||||||
|
-- 彻底移除本地指标/策略/油价数据表 (数据源改由 tortoise/ifinance 承担)
|
||||||
|
DROP TABLE IF EXISTS "IndicatorPoint" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "Indicator" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "Strategy" CASCADE;
|
||||||
|
DROP TABLE IF EXISTS "OilPriceHistory" CASCADE;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AI 接入 (MCP): 个人授权 token, 与用户绑定, 独立命名
|
||||||
|
CREATE TABLE "McpToken" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"name" VARCHAR(32) NOT NULL,
|
||||||
|
"prefix" VARCHAR(16) NOT NULL,
|
||||||
|
"tokenHash" VARCHAR(64) NOT NULL,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "McpToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "McpToken_tokenHash_key" ON "McpToken"("tokenHash");
|
||||||
|
CREATE UNIQUE INDEX "McpToken_userId_name_key" ON "McpToken"("userId", "name");
|
||||||
|
CREATE INDEX "McpToken_userId_idx" ON "McpToken"("userId");
|
||||||
|
|
||||||
|
ALTER TABLE "McpToken" ADD CONSTRAINT "McpToken_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+23
-56
@@ -32,6 +32,9 @@ model User {
|
|||||||
assignedTasks Task[] @relation("TaskAssignee")
|
assignedTasks Task[] @relation("TaskAssignee")
|
||||||
createdTasks Task[] @relation("TaskCreator")
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
taskMembers TaskMember[]
|
taskMembers TaskMember[]
|
||||||
|
|
||||||
|
// AI 接入 (MCP): 每人可创建多个具名授权 token
|
||||||
|
mcpTokens McpToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Article {
|
model Article {
|
||||||
@@ -44,41 +47,6 @@ model Article {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model Strategy {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String
|
|
||||||
description String
|
|
||||||
data Json
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Indicator {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String @unique
|
|
||||||
groupName String
|
|
||||||
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 拉取, 按影响大小排序, 累加多日供首页无限滚动
|
// 新闻: 每天 3 点由 mx_finance_search_news 拉取, 按影响大小排序, 累加多日供首页无限滚动
|
||||||
model News {
|
model News {
|
||||||
@@ -96,7 +64,7 @@ model News {
|
|||||||
@@index([publishedAt])
|
@@index([publishedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 报警: refresh 写库时比对同 (indicatorName, period) 的跨源数据, 差异超阈值记录
|
// 报警: 定时任务失败等异常写入 (展示于「监控」报警 tab)
|
||||||
model Alert {
|
model Alert {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
indicatorName String
|
indicatorName String
|
||||||
@@ -194,25 +162,6 @@ model ScraperCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 浙江省油价历史 (apizero price 接口每日抓取)
|
|
||||||
// 每天 04:00 cron 触发: 检查 schedule 里待定且 <= 昨天的调价日, 抓 price?province=浙江 存本表
|
|
||||||
// unique (date, province, fuel) 防重复
|
|
||||||
model OilPriceHistory {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
date DateTime // 调价日次日 (即数据实际抓取日)
|
|
||||||
province String // '浙江'
|
|
||||||
fuel String // '92' | '95' | '0'
|
|
||||||
price Float // 油价 (元/升, 4 位小数足够)
|
|
||||||
source String @default("apizero")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@unique([date, province, fuel])
|
|
||||||
@@index([province, fuel, date])
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ==================== 环境监控:米家 ====================
|
// ==================== 环境监控:米家 ====================
|
||||||
|
|
||||||
// 米家授权状态 (DiskCache 存凭证, 本表只存状态/错误供前端展示)
|
// 米家授权状态 (DiskCache 存凭证, 本表只存状态/错误供前端展示)
|
||||||
@@ -513,4 +462,22 @@ model TaskTag {
|
|||||||
name String @db.VarChar(20)
|
name String @db.VarChar(20)
|
||||||
|
|
||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== AI 接入 (MCP) ====================
|
||||||
|
// 个人授权 token: 与用户绑定, 可独立命名 (如 R1), 每个 token 拥有该用户全部业务权限.
|
||||||
|
// 安全: 数据库只存 SHA-256 哈希, 明文仅在创建时返回一次; 前缀用于界面辨识.
|
||||||
|
model McpToken {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
name String @db.VarChar(32)
|
||||||
|
prefix String @db.VarChar(16)
|
||||||
|
tokenHash String @unique @db.VarChar(64)
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
{
|
||||||
|
"schema": 2,
|
||||||
|
"name": "ifinance",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"buildId": "20261030.1442",
|
||||||
|
"buildHash": "76353f01070db7938dd7225ea9719c004052f0f5c740d4bcddb13ca880328fe0",
|
||||||
|
"generatedAt": "2026-10-30T14:42:19+08:00",
|
||||||
|
"generator": "tortoise/tools/ifinance/build.py",
|
||||||
|
"source": {
|
||||||
|
"project": "tortoise",
|
||||||
|
"upstream": "iboard-prod-pg-1"
|
||||||
|
},
|
||||||
|
"cache": {
|
||||||
|
"mode": "hash",
|
||||||
|
"digest": "sha256",
|
||||||
|
"manifest": "files",
|
||||||
|
"key": "path",
|
||||||
|
"revalidate": "info.json"
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "econ",
|
||||||
|
"name": "经济数据",
|
||||||
|
"count": 8,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "china-bond-10y",
|
||||||
|
"kind": "indicator",
|
||||||
|
"title": "中国国债 10 年期收益率",
|
||||||
|
"group": "china-macro",
|
||||||
|
"unit": "%",
|
||||||
|
"frequency": "month",
|
||||||
|
"subtitle": "1.71% · 2026-08",
|
||||||
|
"points": 32,
|
||||||
|
"html": "public/econ/china-bond-10y.html",
|
||||||
|
"json": "public/econ/china-bond-10y.json",
|
||||||
|
"hash": "3b5fef761b59afb6869fa2a73fe117fb69dcbefea3d39d7cd1859004162a52e7",
|
||||||
|
"bytes": 5630,
|
||||||
|
"jsonHash": "72b493417070df9c772d6afc8604b6db99e5889d36c59d45bcd92f2dafa261ca"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "china-bond-1y",
|
||||||
|
"kind": "indicator",
|
||||||
|
"title": "中国国债 1 年期收益率",
|
||||||
|
"group": "china-macro",
|
||||||
|
"unit": "%",
|
||||||
|
"frequency": "month",
|
||||||
|
"subtitle": "1.17% · 2026-08",
|
||||||
|
"points": 32,
|
||||||
|
"html": "public/econ/china-bond-1y.html",
|
||||||
|
"json": "public/econ/china-bond-1y.json",
|
||||||
|
"hash": "7639c558bba083cc9a25f8a156419138937d8712612dee34ed2b89890a4d9fec",
|
||||||
|
"bytes": 5628,
|
||||||
|
"jsonHash": "1eb0575b5eac06c78104974b75b8f106cb92bc6d87113652f7f457ee5c988fc2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "china-cpi",
|
||||||
|
"kind": "indicator",
|
||||||
|
"title": "中国 CPI",
|
||||||
|
"group": "china-macro",
|
||||||
|
"unit": "%",
|
||||||
|
"frequency": "year",
|
||||||
|
"subtitle": "0.06% · 2025",
|
||||||
|
"points": 11,
|
||||||
|
"html": "public/econ/china-cpi.html",
|
||||||
|
"json": "public/econ/china-cpi.json",
|
||||||
|
"hash": "9ff611705ff82a56acbbf96e337e0a79bfe2f2a22fc8c5b852928af85d7b3de2",
|
||||||
|
"bytes": 4236,
|
||||||
|
"jsonHash": "6007ad45542047c0e343c0cc590d9fcde63d9c820ff99f0a93f61a8560209ebf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "china-cpi-yoy",
|
||||||
|
"kind": "indicator",
|
||||||
|
"title": "中国 CPI 同比",
|
||||||
|
"group": "china-macro",
|
||||||
|
"unit": "%",
|
||||||
|
"frequency": "month",
|
||||||
|
"subtitle": "1% · 2026-06",
|
||||||
|
"points": 36,
|
||||||
|
"html": "public/econ/china-cpi-yoy.html",
|
||||||
|
"json": "public/econ/china-cpi-yoy.json",
|
||||||
|
"hash": "cb51b54b2f6bf172aa3e87ac368ce67c7398dbc51e2f033b0eac0d367cb62a0e",
|
||||||
|
"bytes": 5627,
|
||||||
|
"jsonHash": "16735f992a47c448fe1b175abd15174c4c6a19df4e8f1084580f9d1882009d44"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "china-lpr-1y",
|
||||||
|
"kind": "indicator",
|
||||||
|
"title": "中国 LPR 1 年期",
|
||||||
|
"group": "china-macro",
|
||||||
|
"unit": "%",
|
||||||
|
"frequency": "month",
|
||||||
|
"subtitle": "3% · 2026-07",
|
||||||
|
"points": 31,
|
||||||
|
"html": "public/econ/china-lpr-1y.html",
|
||||||
|
"json": "public/econ/china-lpr-1y.json",
|
||||||
|
"hash": "5f7254dc86f98bf32cfd06d37d9b329bcff87513057583bdf669b8691a3f4bcf",
|
||||||
|
"bytes": 5327,
|
||||||
|
"jsonHash": "7300679627550b75fa2fae62888b79e6f12a8386d2d3f2dec9bd2925387d8aa7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "china-lpr-5y",
|
||||||
|
"kind": "indicator",
|
||||||
|
"title": "中国 LPR 5 年期",
|
||||||
|
"group": "china-macro",
|
||||||
|
"unit": "%",
|
||||||
|
"frequency": "month",
|
||||||
|
"subtitle": "3.5% · 2026-07",
|
||||||
|
"points": 31,
|
||||||
|
"html": "public/econ/china-lpr-5y.html",
|
||||||
|
"json": "public/econ/china-lpr-5y.json",
|
||||||
|
"hash": "e32519d06f0ca7a601e933ce704d44d59a0ab3b2fb046a6754ad4ef3ed71b742",
|
||||||
|
"bytes": 5498,
|
||||||
|
"jsonHash": "99d4b4d900e5a99f60b294eb9fca87145ad20b44995ef2178061cc832c2599e8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "us-bond-1y",
|
||||||
|
"kind": "indicator",
|
||||||
|
"title": "美国国债1年",
|
||||||
|
"group": "us-bond",
|
||||||
|
"unit": "%",
|
||||||
|
"frequency": "month",
|
||||||
|
"subtitle": "4.45% · 2024-06",
|
||||||
|
"points": 6,
|
||||||
|
"html": "public/econ/us-bond-1y.html",
|
||||||
|
"json": "public/econ/us-bond-1y.json",
|
||||||
|
"hash": "c972cd658b5db614ec94135dc8f7816366a8ed526360514cfb53c64f9df09dfa",
|
||||||
|
"bytes": 3736,
|
||||||
|
"jsonHash": "82a7af636dd597bde08bc9cb7e22ab4e9de6754ffc30614d78ba1a96e6715a4a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "usa-cpi",
|
||||||
|
"kind": "indicator",
|
||||||
|
"title": "美国 CPI",
|
||||||
|
"group": "usa-macro",
|
||||||
|
"unit": "%",
|
||||||
|
"frequency": "year",
|
||||||
|
"subtitle": "2.95% · 2024",
|
||||||
|
"points": 10,
|
||||||
|
"html": "public/econ/usa-cpi.html",
|
||||||
|
"json": "public/econ/usa-cpi.json",
|
||||||
|
"hash": "f4aec9f8c2aee8835166e71a7799c87c8f0288f0fc9760b85620bc98c7bd9721",
|
||||||
|
"bytes": 4187,
|
||||||
|
"jsonHash": "0273f88e376d166528e60338beff1e0878e98336c26c759f36daa078acb92d87"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "strategy",
|
||||||
|
"name": "策略研究",
|
||||||
|
"count": 5,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "china-debt-gdp",
|
||||||
|
"kind": "strategy",
|
||||||
|
"title": "中国政府债务/GDP分析",
|
||||||
|
"subtitle": "债务/GDP 75.7% · 2024",
|
||||||
|
"points": 6,
|
||||||
|
"html": "public/strategy/china-debt-gdp.html",
|
||||||
|
"json": "public/strategy/china-debt-gdp.json",
|
||||||
|
"hash": "acffe06524dbfee764411b8690d02393bbaa1d7ad2275a58ac132fe256ff69c8",
|
||||||
|
"bytes": 7331,
|
||||||
|
"jsonHash": "6b2c3da5e4751c0c13dc2cc10d706c08ee6400eca8a9e6636ea0a25dca374590"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "us-debt-gdp",
|
||||||
|
"kind": "strategy",
|
||||||
|
"title": "美国政府债务/GDP分析",
|
||||||
|
"subtitle": "债务/GDP 123.9% · 2024",
|
||||||
|
"points": 6,
|
||||||
|
"html": "public/strategy/us-debt-gdp.html",
|
||||||
|
"json": "public/strategy/us-debt-gdp.json",
|
||||||
|
"hash": "cfb9ec15e457633e6d13689ab9802d646201a8fba9be3fb69710370a40ace365",
|
||||||
|
"bytes": 7477,
|
||||||
|
"jsonHash": "69d6d977057faac7ab10a5f43e4f5feb5e47539d6dc77e7fd93b533653abc50a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a500_chart",
|
||||||
|
"kind": "dashboard",
|
||||||
|
"title": "中证 A500 综合看板",
|
||||||
|
"subtitle": "价格 / PE-PB-股息率 四面板 + 持仓 + ETF",
|
||||||
|
"html": "public/dashboards/a500_chart.html",
|
||||||
|
"hash": "43d3a8849a6a83137f07d51602972b3f51226aa4ffaf765d4423b82298dd07c2",
|
||||||
|
"bytes": 1508412
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cni_fcf_chart",
|
||||||
|
"kind": "dashboard",
|
||||||
|
"title": "国证自由现金流看板",
|
||||||
|
"subtitle": "980092 · 可与中证自由现金流叠加对比",
|
||||||
|
"html": "public/dashboards/cni_fcf_chart.html",
|
||||||
|
"hash": "9c239f41f525959a41594578efa041dfca5c76f652b0777fe79caae023643ed3",
|
||||||
|
"bytes": 1392856
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "csi_fcf_chart",
|
||||||
|
"kind": "dashboard",
|
||||||
|
"title": "中证自由现金流看板",
|
||||||
|
"subtitle": "932365 · 可与国证自由现金流叠加对比",
|
||||||
|
"html": "public/dashboards/csi_fcf_chart.html",
|
||||||
|
"hash": "4b0b6e24b01550ae7e1ab2d85237ff6cda9349d004ec35df73511d2a32bd88ec",
|
||||||
|
"bytes": 1344984
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "public/dashboards/a500_chart.html",
|
||||||
|
"hash": "43d3a8849a6a83137f07d51602972b3f51226aa4ffaf765d4423b82298dd07c2",
|
||||||
|
"bytes": 1508412,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/dashboards/cni_fcf_chart.html",
|
||||||
|
"hash": "9c239f41f525959a41594578efa041dfca5c76f652b0777fe79caae023643ed3",
|
||||||
|
"bytes": 1392856,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/dashboards/csi_fcf_chart.html",
|
||||||
|
"hash": "4b0b6e24b01550ae7e1ab2d85237ff6cda9349d004ec35df73511d2a32bd88ec",
|
||||||
|
"bytes": 1344984,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-bond-10y.html",
|
||||||
|
"hash": "3b5fef761b59afb6869fa2a73fe117fb69dcbefea3d39d7cd1859004162a52e7",
|
||||||
|
"bytes": 5630,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-bond-10y.json",
|
||||||
|
"hash": "72b493417070df9c772d6afc8604b6db99e5889d36c59d45bcd92f2dafa261ca",
|
||||||
|
"bytes": 1803,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-bond-1y.html",
|
||||||
|
"hash": "7639c558bba083cc9a25f8a156419138937d8712612dee34ed2b89890a4d9fec",
|
||||||
|
"bytes": 5628,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-bond-1y.json",
|
||||||
|
"hash": "1eb0575b5eac06c78104974b75b8f106cb92bc6d87113652f7f457ee5c988fc2",
|
||||||
|
"bytes": 1806,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-cpi-yoy.html",
|
||||||
|
"hash": "cb51b54b2f6bf172aa3e87ac368ce67c7398dbc51e2f033b0eac0d367cb62a0e",
|
||||||
|
"bytes": 5627,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-cpi-yoy.json",
|
||||||
|
"hash": "16735f992a47c448fe1b175abd15174c4c6a19df4e8f1084580f9d1882009d44",
|
||||||
|
"bytes": 1932,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-cpi.html",
|
||||||
|
"hash": "9ff611705ff82a56acbbf96e337e0a79bfe2f2a22fc8c5b852928af85d7b3de2",
|
||||||
|
"bytes": 4236,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-cpi.json",
|
||||||
|
"hash": "6007ad45542047c0e343c0cc590d9fcde63d9c820ff99f0a93f61a8560209ebf",
|
||||||
|
"bytes": 667,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-lpr-1y.html",
|
||||||
|
"hash": "5f7254dc86f98bf32cfd06d37d9b329bcff87513057583bdf669b8691a3f4bcf",
|
||||||
|
"bytes": 5327,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-lpr-1y.json",
|
||||||
|
"hash": "7300679627550b75fa2fae62888b79e6f12a8386d2d3f2dec9bd2925387d8aa7",
|
||||||
|
"bytes": 1664,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-lpr-5y.html",
|
||||||
|
"hash": "e32519d06f0ca7a601e933ce704d44d59a0ab3b2fb046a6754ad4ef3ed71b742",
|
||||||
|
"bytes": 5498,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/china-lpr-5y.json",
|
||||||
|
"hash": "99d4b4d900e5a99f60b294eb9fca87145ad20b44995ef2178061cc832c2599e8",
|
||||||
|
"bytes": 1693,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/us-bond-1y.html",
|
||||||
|
"hash": "c972cd658b5db614ec94135dc8f7816366a8ed526360514cfb53c64f9df09dfa",
|
||||||
|
"bytes": 3736,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/us-bond-1y.json",
|
||||||
|
"hash": "82a7af636dd597bde08bc9cb7e22ab4e9de6754ffc30614d78ba1a96e6715a4a",
|
||||||
|
"bytes": 444,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/usa-cpi.html",
|
||||||
|
"hash": "f4aec9f8c2aee8835166e71a7799c87c8f0288f0fc9760b85620bc98c7bd9721",
|
||||||
|
"bytes": 4187,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/econ/usa-cpi.json",
|
||||||
|
"hash": "0273f88e376d166528e60338beff1e0878e98336c26c759f36daa078acb92d87",
|
||||||
|
"bytes": 616,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/strategy/china-debt-gdp.html",
|
||||||
|
"hash": "acffe06524dbfee764411b8690d02393bbaa1d7ad2275a58ac132fe256ff69c8",
|
||||||
|
"bytes": 7331,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/strategy/china-debt-gdp.json",
|
||||||
|
"hash": "6b2c3da5e4751c0c13dc2cc10d706c08ee6400eca8a9e6636ea0a25dca374590",
|
||||||
|
"bytes": 841,
|
||||||
|
"type": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/strategy/us-debt-gdp.html",
|
||||||
|
"hash": "cfb9ec15e457633e6d13689ab9802d646201a8fba9be3fb69710370a40ace365",
|
||||||
|
"bytes": 7477,
|
||||||
|
"type": "text/html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "public/strategy/us-debt-gdp.json",
|
||||||
|
"hash": "69d6d977057faac7ab10a5f43e4f5feb5e47539d6dc77e7fd93b533653abc50a",
|
||||||
|
"bytes": 843,
|
||||||
|
"type": "application/json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// ifinance 文件缓存 Service Worker
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 宿主把 iframe 指向 /__ifinance__/<path>?h=<sha256>&t=<mime>, 由本 SW:
|
||||||
|
// 1. 按 hash 从 Cache Storage 取本地缓存 (内容寻址, 索引更新后自然失效)
|
||||||
|
// 2. 未命中时只拉当前这一个文件 (按需加载, 不预下载整个模块包)
|
||||||
|
// 3. 用 sha256 校验后再写入缓存; 校验失败仍然返回内容, 但不落盘
|
||||||
|
//
|
||||||
|
// 为什么不用 blob: URL 直接渲染: blob: 文档会继承宿主页面的 CSP
|
||||||
|
// (script-src 'self'), 内嵌看板的 <script> 会被拦截, 图表无法渲染。
|
||||||
|
// SW 返回的是普通 http(s) 子文档, 不受宿主 CSP 的脚本限制。
|
||||||
|
//
|
||||||
|
// 文件缓存里以 /__ifinance_file__/<hash> 为 key, 供宿主侧清理旧版本。
|
||||||
|
const PAGE_PREFIX = '/__ifinance__/'
|
||||||
|
const FILE_CACHE_NAME = 'ifinance-files-v1'
|
||||||
|
const FILE_KEY_PREFIX = '/__ifinance_file__/'
|
||||||
|
const REMOTE_BASE = 'https://ifinance.honor3.com'
|
||||||
|
const NETWORK_TIMEOUT = 20000
|
||||||
|
|
||||||
|
self.addEventListener('install', () => {
|
||||||
|
// 新版本 SW 立刻接管, 避免用户必须再打开一次页面
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
if (url.origin !== self.location.origin) return
|
||||||
|
if (!url.pathname.startsWith(PAGE_PREFIX)) return
|
||||||
|
if (event.request.method !== 'GET') return
|
||||||
|
event.respondWith(serve(url))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function serve(url) {
|
||||||
|
const rel = decodeURIComponent(url.pathname.slice(PAGE_PREFIX.length))
|
||||||
|
const hash = (url.searchParams.get('h') || '').toLowerCase()
|
||||||
|
const type = url.searchParams.get('t') || guessType(rel)
|
||||||
|
const keyUrl = self.location.origin + FILE_KEY_PREFIX + (hash || encodeURIComponent(rel))
|
||||||
|
|
||||||
|
// 缓存不可用时 (隐私模式/配额/被策略禁用) 仍然正常联网加载
|
||||||
|
let cache = null
|
||||||
|
if (typeof caches !== 'undefined') {
|
||||||
|
try { cache = await caches.open(FILE_CACHE_NAME) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache && hash) {
|
||||||
|
try {
|
||||||
|
const hit = await cache.match(keyUrl)
|
||||||
|
if (hit) return respond(await hit.arrayBuffer(), type, 'hit')
|
||||||
|
} catch { /* 读取失败按未命中处理 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await fetchRemote(rel)
|
||||||
|
if (cache && hash) {
|
||||||
|
try {
|
||||||
|
const digest = await sha256Hex(payload)
|
||||||
|
if (digest === hash) {
|
||||||
|
await cache.put(keyUrl, new Response(payload, {
|
||||||
|
headers: { 'Content-Type': withCharset(type), 'Cache-Control': 'no-store' },
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
console.warn('[ifinance-sw] hash 不匹配, 不写入缓存:', rel, digest, '!=', hash)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// crypto.subtle 不可用等: 只跳过落盘, 不影响本次展示
|
||||||
|
console.warn('[ifinance-sw] 校验失败, 跳过缓存:', rel, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return respond(payload, type, 'network')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ifinance-sw] 加载失败:', rel, e)
|
||||||
|
return respondText(errorPage(rel, e), 'text/html; charset=utf-8', 502)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRemote(rel) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT)
|
||||||
|
try {
|
||||||
|
const res = await fetch(REMOTE_BASE + '/' + rel, {
|
||||||
|
cache: 'no-cache',
|
||||||
|
mode: 'cors',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
return await res.arrayBuffer()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function respond(buf, type, source) {
|
||||||
|
return new Response(buf, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': withCharset(type),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Ifinance-Cache': source,
|
||||||
|
// 同源内容用 CSP sandbox 隔离成不透明源: 行内脚本照常运行 (看板需要),
|
||||||
|
// 但读不到宿主 localStorage/cookie, 也不能访问 parent。
|
||||||
|
'Content-Security-Policy': 'sandbox allow-scripts',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondText(text, type, status = 200) {
|
||||||
|
return new Response(text, {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': withCharset(type),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Content-Security-Policy': 'sandbox allow-scripts',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCharset(type) {
|
||||||
|
const t = String(type || 'text/html')
|
||||||
|
return /charset=/i.test(t) ? t : t + '; charset=utf-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessType(rel) {
|
||||||
|
return /\.json$/i.test(rel) ? 'application/json' : 'text/html'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(buf) {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', buf)
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorPage(rel, err) {
|
||||||
|
const msg = String(err?.message || err || '加载失败')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
return '<!doctype html><html><head><meta charset="utf-8">' +
|
||||||
|
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
||||||
|
'<style>body{margin:0;padding:24px;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;' +
|
||||||
|
'color:#666;background:#f5f5f5}h3{margin:0 0 8px;color:#333;font-size:15px}code{font-size:12px;color:#999;word-break:break-all}</style>' +
|
||||||
|
'</head><body><h3>内容加载失败</h3><div>请检查网络后重试。</div><p><code>' +
|
||||||
|
msg + '<br>' + rel.replace(/&/g, '&').replace(/</g, '<') +
|
||||||
|
'</code></p></body></html>'
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CI / 本地共用: 修复 Android 软键盘遮挡输入框
|
||||||
|
# 在 `tauri android init` 之后、`tauri android build` 之前运行。
|
||||||
|
#
|
||||||
|
# 背景:
|
||||||
|
# Tauri 2 生成的 MainActivity 调用 enableEdgeToEdge(), 且不处理 IME Insets。
|
||||||
|
# Android 15+ (targetSdk 35+) 强制 edge-to-edge 后, 系统不再因软键盘缩小窗口,
|
||||||
|
# 清单里的 adjustResize 也随之失效 → 键盘直接盖住 WebView, 底部弹层输入框被遮挡。
|
||||||
|
#
|
||||||
|
# 做法:
|
||||||
|
# 1. AndroidManifest.xml 的 MainActivity 补 windowSoftInputMode="adjustResize"
|
||||||
|
# (兼容 API < 35 的旧行为)
|
||||||
|
# 2. 重写 MainActivity.kt: 监听 WindowInsetsCompat.Type.ime(), 键盘可见时
|
||||||
|
# 把 IME 高度作为底部内边距加给内容容器 (等价恢复旧版 adjustResize),
|
||||||
|
# 这样 WebView 真正变矮, 所有 position:fixed / bottom:0 的弹层自动上移。
|
||||||
|
#
|
||||||
|
# 幂等: 重复执行不会重复注入。
|
||||||
|
# 注意: gen/android 被 gitignore, CI 每次 init 都重新生成, 所以每次构建都要跑本脚本。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MANIFEST="src-tauri/gen/android/app/src/main/AndroidManifest.xml"
|
||||||
|
MAIN_ACTIVITY_DIR="src-tauri/gen/android/app/src/main/java"
|
||||||
|
|
||||||
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
|
echo "[softinput] ERROR: 找不到 $MANIFEST (请先运行 tauri android init)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MAIN_ACTIVITY=$(find "$MAIN_ACTIVITY_DIR" -name MainActivity.kt -print -quit 2>/dev/null || true)
|
||||||
|
if [ -z "$MAIN_ACTIVITY" ]; then
|
||||||
|
echo "[softinput] ERROR: 找不到 MainActivity.kt (请先运行 tauri android init)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[softinput] 注入 Android 软键盘避让配置..."
|
||||||
|
|
||||||
|
python3 - "$MANIFEST" "$MAIN_ACTIVITY" <<'PYEOF'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
manifest_path, activity_path = sys.argv[1], sys.argv[2]
|
||||||
|
|
||||||
|
# ---------- 1. AndroidManifest.xml: 补 windowSoftInputMode ----------
|
||||||
|
src = open(manifest_path, encoding='utf-8').read()
|
||||||
|
if 'windowSoftInputMode' not in src:
|
||||||
|
m = re.search(r'<activity\b', src)
|
||||||
|
if not m:
|
||||||
|
print('[softinput] ERROR: AndroidManifest.xml 中找不到 <activity>', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
src = src[:m.end()] + '\n android:windowSoftInputMode="adjustResize"' + src[m.end():]
|
||||||
|
open(manifest_path, 'w', encoding='utf-8').write(src)
|
||||||
|
print('[softinput] AndroidManifest.xml: 已注入 windowSoftInputMode="adjustResize"')
|
||||||
|
else:
|
||||||
|
print('[softinput] AndroidManifest.xml: 已有 windowSoftInputMode, 跳过')
|
||||||
|
|
||||||
|
# ---------- 2. MainActivity.kt: IME Insets 处理 ----------
|
||||||
|
src = open(activity_path, encoding='utf-8').read()
|
||||||
|
if '[iboard] keyboard-insets' in src:
|
||||||
|
print('[softinput] MainActivity.kt: 已注入键盘避让, 跳过')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
m = re.search(r'^\s*package\s+([\w.]+)\s*$', src, flags=re.M)
|
||||||
|
if not m:
|
||||||
|
print('[softinput] ERROR: MainActivity.kt 中找不到 package 声明', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
package = m.group(1)
|
||||||
|
|
||||||
|
kotlin = f'''package {package}
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {{
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// [iboard] keyboard-insets
|
||||||
|
// Android 15+ 强制 edge-to-edge 后, 系统不再因软键盘缩小窗口, adjustResize 失效,
|
||||||
|
// 键盘会直接盖住 WebView 底部。这里监听 IME Insets, 键盘可见时把键盘高度
|
||||||
|
// 作为内容容器的底部内边距, 等价恢复旧版 adjustResize 行为。
|
||||||
|
// API < 30 由清单里的 adjustResize 处理, 避免系统已缩小窗口时重复留白。
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {{
|
||||||
|
val content = findViewById<View>(android.R.id.content)
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(content) {{ view, insets ->
|
||||||
|
// 键盘隐藏时 getInsets(ime).bottom 为 0 (AndroidX 在 API < 30 也做了兼容)
|
||||||
|
val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||||
|
view.setPadding(0, 0, 0, imeBottom)
|
||||||
|
insets
|
||||||
|
}}
|
||||||
|
ViewCompat.requestApplyInsets(content)
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
'''
|
||||||
|
open(activity_path, 'w', encoding='utf-8').write(kotlin)
|
||||||
|
print(f'[softinput] MainActivity.kt: 已注入 IME Insets 处理 ({activity_path})')
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
# 断言: 防止注入被模板覆盖或路径写错
|
||||||
|
if ! grep -q 'windowSoftInputMode' "$MANIFEST"; then
|
||||||
|
echo "[softinput] ERROR: AndroidManifest.xml 注入未生效" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q '\[iboard\] keyboard-insets' "$MAIN_ACTIVITY"; then
|
||||||
|
echo "[softinput] ERROR: MainActivity.kt 注入未生效" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[softinput] 完成"
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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,172 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Windows 外壳「本地实时预览」: 不打包, 直接把新前端挂到已安装外壳上跑
|
||||||
|
//
|
||||||
|
// 原理: 外壳用 tauri-plugin-hotswap, 启动时先看 %APPDATA%\<identifier>\hotswap\
|
||||||
|
// 是否有生效的覆盖包, 有就从该目录读前端资源, 没有才回退到 exe 内置的 dist。
|
||||||
|
// 所以只要构建 dist 并写进覆盖目录, 重启外壳即可看到改动 —— 无需 tauri build,
|
||||||
|
// 无需重打 NSIS/APK, 也不用重装。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// npm run win:dev # 构建(shell 模式) + 挂载 + 重启外壳
|
||||||
|
// npm run win:dev -- --no-build # 复用现有 dist, 只挂载+重启 (最快)
|
||||||
|
// npm run win:dev -- --no-launch # 只挂载, 不重启
|
||||||
|
// npm run win:dev -- --exe "D:\path\iBoard.exe"
|
||||||
|
// npm run win:dev -- --clear # 卸载覆盖, 外壳回到内置版本
|
||||||
|
//
|
||||||
|
// 覆盖目录里生成的东西 (全部可删):
|
||||||
|
// hotswap/seq-<时间戳>/ 本次前端 (dist 内容) + hotswap-meta.json
|
||||||
|
// hotswap/current 指向 seq-<时间戳> 的指针文件
|
||||||
|
//
|
||||||
|
// 注意: 覆盖目录是外壳的真实 OTA 落地目录, 挂载期间「我的」页会显示本地版本;
|
||||||
|
// --clear 会清掉本脚本产生的覆盖, 恢复正常 OTA 行为。
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const has = (f) => args.includes(f)
|
||||||
|
const val = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d }
|
||||||
|
|
||||||
|
const CLEAR = has('--clear')
|
||||||
|
const NO_BUILD = has('--no-build')
|
||||||
|
const NO_LAUNCH = has('--no-launch')
|
||||||
|
|
||||||
|
const conf = JSON.parse(fs.readFileSync(path.join(root, 'src-tauri', 'tauri.conf.json'), 'utf8'))
|
||||||
|
const APP_ID = conf.identifier // 覆盖目录名 = 外壳 identifier
|
||||||
|
const BINARY_VERSION = conf.version // 外壳版本: min_binary_version 必须与它相等
|
||||||
|
const APP_VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version
|
||||||
|
|
||||||
|
const HOTSWAP_DIR = path.join(os.homedir(), 'AppData', 'Roaming', APP_ID, 'hotswap')
|
||||||
|
|
||||||
|
function log(msg) { console.log(`[win:dev] ${msg}`) }
|
||||||
|
function die(msg) { console.error(`[win:dev] 错误: ${msg}`); process.exit(1) }
|
||||||
|
|
||||||
|
/** 找已安装的外壳 exe */
|
||||||
|
function findExe() {
|
||||||
|
const explicit = val('--exe', '')
|
||||||
|
if (explicit) return explicit
|
||||||
|
const candidates = [
|
||||||
|
'C:\\Program Files\\iboard\\iBoard.exe',
|
||||||
|
'C:\\Program Files\\iBoard\\iBoard.exe',
|
||||||
|
path.join(root, 'src-tauri', 'target', 'release', 'iBoard.exe'),
|
||||||
|
path.join(root, 'src-tauri', 'target', 'debug', 'iBoard.exe'),
|
||||||
|
]
|
||||||
|
return candidates.find((p) => fs.existsSync(p)) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清掉覆盖目录里本脚本(或上次 OTA)留下的生效版本 */
|
||||||
|
function clearOverride() {
|
||||||
|
if (!fs.existsSync(HOTSWAP_DIR)) { log('覆盖目录不存在, 无需清理'); return }
|
||||||
|
const current = path.join(HOTSWAP_DIR, 'current')
|
||||||
|
if (fs.existsSync(current)) { fs.rmSync(current); log('已移除 current 指针') }
|
||||||
|
let n = 0
|
||||||
|
for (const e of fs.readdirSync(HOTSWAP_DIR)) {
|
||||||
|
if (/^seq-\d+$/.test(e)) { fs.rmSync(path.join(HOTSWAP_DIR, e), { recursive: true, force: true }); n++ }
|
||||||
|
}
|
||||||
|
log(`已移除 ${n} 个覆盖版本目录 -> 外壳将回退到内置前端`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建前端 (shell 模式: VITE_API_BASE 指向远端后端, 与打包口径一致) */
|
||||||
|
function buildFrontend() {
|
||||||
|
const vite = path.join(root, 'node_modules', 'vite', 'bin', 'vite.js')
|
||||||
|
if (!fs.existsSync(vite)) die('未找到 vite, 请先 npm install')
|
||||||
|
// esbuild 需要可写的临时目录; 指向仓库内, 避免系统 TEMP 权限问题
|
||||||
|
const tmp = path.join(root, '.esbuild-tmp')
|
||||||
|
fs.mkdirSync(tmp, { recursive: true })
|
||||||
|
log('构建前端 (vite build --mode shell) ...')
|
||||||
|
const r = spawnSync(process.execPath, [vite, 'build', '--mode', 'shell'], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, TEMP: tmp, TMP: tmp },
|
||||||
|
})
|
||||||
|
if (r.status !== 0) die('前端构建失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 dist 挂成生效的 hotswap 覆盖包 */
|
||||||
|
function installOverride() {
|
||||||
|
const dist = path.join(root, 'dist')
|
||||||
|
if (!fs.existsSync(path.join(dist, 'index.html'))) die('dist/index.html 不存在, 请先构建')
|
||||||
|
|
||||||
|
fs.mkdirSync(HOTSWAP_DIR, { recursive: true })
|
||||||
|
const seq = Date.now() // 单调递增; 取大值可让 OTA 检查认为"已是最新", 不会覆盖本次本地版本
|
||||||
|
const dirName = `seq-${seq}`
|
||||||
|
const dir = path.join(HOTSWAP_DIR, dirName)
|
||||||
|
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
fs.cpSync(dist, dir, { recursive: true })
|
||||||
|
|
||||||
|
// 元数据字段见 tauri-plugin-hotswap/src/manifest.rs
|
||||||
|
// min_binary_version 必须 == 外壳版本: 默认 DiscardOnUpgrade 策略在
|
||||||
|
// binary > min 时会丢弃缓存; 而安全线又要求 binary >= min, 故只能取等号
|
||||||
|
// confirmed 必须为 true: 默认 SingleLaunch 策略对未确认版本会在启动时立刻回滚
|
||||||
|
fs.writeFileSync(path.join(dir, 'hotswap-meta.json'), JSON.stringify({
|
||||||
|
version: `${APP_VERSION}-local`,
|
||||||
|
sequence: seq,
|
||||||
|
min_binary_version: BINARY_VERSION,
|
||||||
|
confirmed: true,
|
||||||
|
unconfirmed_launch_count: 0,
|
||||||
|
}, null, 2))
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(HOTSWAP_DIR, 'current'), dirName)
|
||||||
|
|
||||||
|
// 保留策略只留最近 2 个, 顺手清掉更早的本地版本
|
||||||
|
const olds = fs.readdirSync(HOTSWAP_DIR)
|
||||||
|
.filter((e) => /^seq-\d+$/.test(e) && e !== dirName)
|
||||||
|
.sort()
|
||||||
|
.slice(0, -1)
|
||||||
|
for (const o of olds) fs.rmSync(path.join(HOTSWAP_DIR, o), { recursive: true, force: true })
|
||||||
|
|
||||||
|
log(`已挂载覆盖包: ${dirName}`)
|
||||||
|
log(` 版本 ${APP_VERSION}-local (外壳 ${BINARY_VERSION})`)
|
||||||
|
return dirName
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结束正在运行的外壳 (需要重启才会读取覆盖包) */
|
||||||
|
function stopRunning(exe) {
|
||||||
|
const r = spawnSync('tasklist', ['/FI', 'IMAGENAME eq iBoard.exe', '/NH'], { encoding: 'utf8' })
|
||||||
|
const out = r.stdout || ''
|
||||||
|
if (!/iBoard\.exe/i.test(out)) return false
|
||||||
|
log('检测到外壳正在运行, 结束它以便重启 ...')
|
||||||
|
spawnSync('taskkill', ['/IM', 'iBoard.exe', '/F'], { stdio: 'inherit' })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(exe) {
|
||||||
|
if (!exe) {
|
||||||
|
log('未找到外壳 exe, 跳过启动。可用 --exe 指定路径, 或手动启动后再验证。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log(`启动: ${exe}`)
|
||||||
|
// 用 Start-Process 让外壳脱离本进程, 脚本退出后应用继续运行
|
||||||
|
const ps = `Start-Process -FilePath '${exe.replace(/'/g, "''")}'`
|
||||||
|
const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'inherit' })
|
||||||
|
if (r.status !== 0) log('启动命令返回非 0, 请手动启动外壳')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- main ----------
|
||||||
|
// --no-launch 表示"不碰外壳进程": 既不结束也不启动, 只改覆盖目录
|
||||||
|
if (CLEAR) {
|
||||||
|
if (!NO_LAUNCH) stopRunning()
|
||||||
|
clearOverride()
|
||||||
|
if (!NO_LAUNCH) launch(findExe())
|
||||||
|
else log('已清理。外壳下次启动即回到内置前端 (当前若在运行, 重启后生效)。')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!NO_BUILD) buildFrontend()
|
||||||
|
installOverride()
|
||||||
|
|
||||||
|
const exe = findExe()
|
||||||
|
if (!NO_LAUNCH) {
|
||||||
|
stopRunning(exe)
|
||||||
|
launch(exe)
|
||||||
|
} else {
|
||||||
|
log('已挂载。外壳下次启动生效 (当前若在运行, 重启后生效)。')
|
||||||
|
}
|
||||||
|
|
||||||
|
log('完成。改完 UI 后重跑 `npm run win:dev` 即可看到新版本 (想更快可加 --no-build 复用 dist)。')
|
||||||
|
log('恢复到内置版本: `npm run win:dev -- --clear`')
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* 列出东方财富妙想 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)
|
|
||||||
})
|
|
||||||
@@ -27,9 +27,6 @@ const TABLES = [
|
|||||||
['TaskMember', 'taskMember'],
|
['TaskMember', 'taskMember'],
|
||||||
['TaskTag', 'taskTag'],
|
['TaskTag', 'taskTag'],
|
||||||
['Article', 'article'],
|
['Article', 'article'],
|
||||||
['Strategy', 'strategy'],
|
|
||||||
['Indicator', 'indicator'],
|
|
||||||
['IndicatorPoint', 'indicatorPoint'],
|
|
||||||
['News', 'news'],
|
['News', 'news'],
|
||||||
['Alert', 'alert'],
|
['Alert', 'alert'],
|
||||||
['ServerNode', 'serverNode'],
|
['ServerNode', 'serverNode'],
|
||||||
@@ -37,7 +34,6 @@ const TABLES = [
|
|||||||
['ServiceCheck', 'serviceCheck'],
|
['ServiceCheck', 'serviceCheck'],
|
||||||
['Econ_SowInventory', 'econ_SowInventory'],
|
['Econ_SowInventory', 'econ_SowInventory'],
|
||||||
['ScraperCache', 'scraperCache'],
|
['ScraperCache', 'scraperCache'],
|
||||||
['OilPriceHistory', 'oilPriceHistory'],
|
|
||||||
['MihomeAccount', 'mihomeAccount'],
|
['MihomeAccount', 'mihomeAccount'],
|
||||||
['MihomeHome', 'mihomeHome'],
|
['MihomeHome', 'mihomeHome'],
|
||||||
['MihomeDevice', 'mihomeDevice'],
|
['MihomeDevice', 'mihomeDevice'],
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
|
|
||||||
async function runScraper(scraperPath, startDate, endDate) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const args = [scraperPath];
|
|
||||||
if (startDate) {
|
|
||||||
args.push('--start', startDate);
|
|
||||||
}
|
|
||||||
if (endDate) {
|
|
||||||
args.push('--end', endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = spawn('bun', args, { stdio: 'inherit' });
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Process exited with code ${code}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
proc.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchOneYearData() {
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log('📊 获取近一年数据');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
|
|
||||||
const currentDate = new Date();
|
|
||||||
const startDate = new Date(currentDate);
|
|
||||||
startDate.setFullYear(startDate.getFullYear() - 1);
|
|
||||||
|
|
||||||
const startDateStr = startDate.toISOString().split('T')[0];
|
|
||||||
const endDateStr = currentDate.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
console.log(`时间范围: ${startDateStr} 至 ${endDateStr}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log('💱 开始获取 USD-CNY 汇率数据...');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
|
|
||||||
let usdSuccess = false;
|
|
||||||
try {
|
|
||||||
await runScraper('./scraper/usd-cny-rate.scraper.js', startDateStr, endDateStr);
|
|
||||||
console.log(`✅ USD-CNY 汇率数据更新完成`);
|
|
||||||
usdSuccess = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ USD-CNY 汇率数据更新失败:`, error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log('📈 开始获取沪深300指数数据...');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
|
|
||||||
let sh300Success = false;
|
|
||||||
try {
|
|
||||||
await runScraper('./scraper/sh300.scraper.js', startDateStr, endDateStr);
|
|
||||||
console.log(`✅ 沪深300指数数据更新完成`);
|
|
||||||
sh300Success = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ 沪深300指数数据更新失败:`, error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log('📊 数据获取完成');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log(`USD-CNY 汇率: ${usdSuccess ? '✅ 成功' : '❌ 失败'}`);
|
|
||||||
console.log(`沪深300指数: ${sh300Success ? '✅ 成功' : '❌ 失败'}`);
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchOneYearData();
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
// 东方财富妙想 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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
async function fetch(startDate = null, endDate = null, retryCount = 3) {
|
|
||||||
try {
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
if (!startDate) {
|
|
||||||
startDate = new Date(currentDate);
|
|
||||||
startDate.setDate(startDate.getDate() - 30);
|
|
||||||
startDate = startDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endDate) {
|
|
||||||
endDate = currentDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('正在获取沪深300指数数据...');
|
|
||||||
console.log(`时间范围: ${startDate} 至 ${endDate}`);
|
|
||||||
console.log(`数据源: 腾讯财经API`);
|
|
||||||
|
|
||||||
// 沪深300指数代码
|
|
||||||
const symbol = '000300';
|
|
||||||
|
|
||||||
// 使用腾讯财经的API端点获取历史数据
|
|
||||||
const response = await axios.get('https://web.ifzq.gtimg.cn/appstock/app/kline/kline?param=sh' + symbol + ',day,' + startDate + ',' + endDate + ',640', {
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'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'
|
|
||||||
},
|
|
||||||
timeout: 10000 // 设置10秒超时
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\nAPI状态: ${response.status}`);
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
console.log(`响应数据:`, JSON.stringify(data, null, 2));
|
|
||||||
|
|
||||||
const allData = [];
|
|
||||||
|
|
||||||
if (data && data.data && data.data['sh' + symbol] && data.data['sh' + symbol].day) {
|
|
||||||
const klineData = data.data['sh' + symbol].day;
|
|
||||||
console.log(`数据条数: ${klineData.length}`);
|
|
||||||
|
|
||||||
for (const item of klineData) {
|
|
||||||
allData.push({
|
|
||||||
date: item[0],
|
|
||||||
open: parseFloat(item[1]),
|
|
||||||
close: parseFloat(item[2]),
|
|
||||||
high: parseFloat(item[3]),
|
|
||||||
low: parseFloat(item[4]),
|
|
||||||
volume: parseFloat(item[5]),
|
|
||||||
amount: 0, // 腾讯财经API没有返回成交额数据,设置为0
|
|
||||||
source: '腾讯财经API',
|
|
||||||
fetchTime: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('数据条数: 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
allData.sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
|
|
||||||
console.log(`\n共获取到 ${allData.length} 条沪深300指数数据`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
symbol: 'sh000300',
|
|
||||||
name: '沪深300指数',
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
count: allData.length,
|
|
||||||
data: allData,
|
|
||||||
fetchTime: new Date().toISOString(),
|
|
||||||
note: '数据来源: 腾讯财经API'
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取沪深300指数数据失败:', error.message);
|
|
||||||
if (error.response) {
|
|
||||||
console.error(`HTTP状态码: ${error.response.status}`);
|
|
||||||
console.error(`响应数据:`, error.response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重试机制
|
|
||||||
if (retryCount > 0) {
|
|
||||||
console.log(`正在重试... (剩余重试次数: ${retryCount - 1})`);
|
|
||||||
// 等待1秒后重试
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
return fetch(startDate, endDate, retryCount - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
fetch
|
|
||||||
};
|
|
||||||
|
|
||||||
const isMainModule = () => {
|
|
||||||
const currentFile = fileURLToPath(import.meta.url);
|
|
||||||
const mainFile = process.argv[1];
|
|
||||||
return currentFile === mainFile;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseCommandLineArgs() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
let startDate = null;
|
|
||||||
let endDate = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
if (args[i] === '--start' && i + 1 < args.length) {
|
|
||||||
startDate = args[i + 1];
|
|
||||||
i++;
|
|
||||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
|
||||||
endDate = args[i + 1];
|
|
||||||
i++;
|
|
||||||
} else if (args[i] === '--help') {
|
|
||||||
console.log('使用方法:');
|
|
||||||
console.log(' bun sh300.fetcher.js [选项]');
|
|
||||||
console.log('');
|
|
||||||
console.log('选项:');
|
|
||||||
console.log(' --start YYYY-MM-DD 指定开始日期(默认:30天前)');
|
|
||||||
console.log(' --end YYYY-MM-DD 指定结束日期(默认:今天)');
|
|
||||||
console.log(' --help 显示帮助信息');
|
|
||||||
console.log('');
|
|
||||||
console.log('示例:');
|
|
||||||
console.log(' bun sh300.fetcher.js');
|
|
||||||
console.log(' bun sh300.fetcher.js --start 2024-01-01 --end 2024-01-31');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startDate, endDate };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMainModule()) {
|
|
||||||
const { startDate, endDate } = parseCommandLineArgs();
|
|
||||||
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
console.log('沪深300指数数据获取脚本');
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
|
|
||||||
fetch(startDate, endDate)
|
|
||||||
.then(result => {
|
|
||||||
console.log('\n' + '='.repeat(50));
|
|
||||||
console.log('获取结果:');
|
|
||||||
console.log(` 指数代码: ${result.symbol}`);
|
|
||||||
console.log(` 指数名称: ${result.name}`);
|
|
||||||
console.log(` 开始日期: ${result.startDate}`);
|
|
||||||
console.log(` 结束日期: ${result.endDate}`);
|
|
||||||
console.log(` 数据条数: ${result.count}`);
|
|
||||||
console.log(` 获取时间: ${result.fetchTime}`);
|
|
||||||
console.log(` 提示: ${result.note}`);
|
|
||||||
if (result.count > 0) {
|
|
||||||
console.log('-'.repeat(50));
|
|
||||||
console.log('指数数据:');
|
|
||||||
result.data.forEach(item => {
|
|
||||||
console.log(` ${item.date} 开盘: ${item.open} 最高: ${item.high} 最低: ${item.low} 收盘: ${item.close}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const FRANKFURTER_API = 'https://api.frankfurter.app';
|
|
||||||
|
|
||||||
async function fetch(startDate = null, endDate = null) {
|
|
||||||
try {
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
if (!startDate) {
|
|
||||||
startDate = new Date(currentDate);
|
|
||||||
startDate.setDate(startDate.getDate() - 30);
|
|
||||||
startDate = startDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endDate) {
|
|
||||||
endDate = currentDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('正在获取人民币美元汇率数据...');
|
|
||||||
console.log(`时间范围: ${startDate} 至 ${endDate}`);
|
|
||||||
console.log(`数据源: Frankfurter API (欧洲央行汇率数据)`);
|
|
||||||
|
|
||||||
const response = await axios.get(`${FRANKFURTER_API}/${startDate}..${endDate}`, {
|
|
||||||
params: {
|
|
||||||
from: 'USD',
|
|
||||||
to: 'CNY'
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
console.log(`\nAPI状态: ${response.status}`);
|
|
||||||
console.log(`数据条数: ${Object.keys(data.rates || {}).length}`);
|
|
||||||
|
|
||||||
const allRates = [];
|
|
||||||
|
|
||||||
if (data.rates) {
|
|
||||||
for (const [date, rateObj] of Object.entries(data.rates)) {
|
|
||||||
const rateValue = rateObj.CNY;
|
|
||||||
allRates.push({
|
|
||||||
date: date,
|
|
||||||
currency: 'USD/CNY',
|
|
||||||
centerPrice: parseFloat(rateValue.toFixed(4)),
|
|
||||||
sellingRate: parseFloat(rateValue.toFixed(4)),
|
|
||||||
buyingRate: parseFloat(rateValue.toFixed(4)),
|
|
||||||
source: '欧洲央行 (Frankfurter API)',
|
|
||||||
fetchTime: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allRates.sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
|
|
||||||
console.log(`\n共获取到 ${allRates.length} 条汇率数据`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
currency: 'USD/CNY',
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
count: allRates.length,
|
|
||||||
data: allRates,
|
|
||||||
fetchTime: new Date().toISOString(),
|
|
||||||
note: '数据来源: 欧洲央行, 通过 Frankfurter API 提供'
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取汇率数据失败:', error.message);
|
|
||||||
if (error.response) {
|
|
||||||
console.error(`HTTP状态码: ${error.response.status}`);
|
|
||||||
console.error(`响应数据:`, error.response.data);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
fetch
|
|
||||||
};
|
|
||||||
|
|
||||||
const isMainModule = () => {
|
|
||||||
const currentFile = fileURLToPath(import.meta.url);
|
|
||||||
const mainFile = process.argv[1];
|
|
||||||
return currentFile === mainFile;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseCommandLineArgs() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
let startDate = null;
|
|
||||||
let endDate = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
if (args[i] === '--start' && i + 1 < args.length) {
|
|
||||||
startDate = args[i + 1];
|
|
||||||
i++;
|
|
||||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
|
||||||
endDate = args[i + 1];
|
|
||||||
i++;
|
|
||||||
} else if (args[i] === '--help') {
|
|
||||||
console.log('使用方法:');
|
|
||||||
console.log(' bun usd-cny-rate.fetcher.js [选项]');
|
|
||||||
console.log('');
|
|
||||||
console.log('选项:');
|
|
||||||
console.log(' --start YYYY-MM-DD 指定开始日期(默认:30天前)');
|
|
||||||
console.log(' --end YYYY-MM-DD 指定结束日期(默认:今天)');
|
|
||||||
console.log(' --help 显示帮助信息');
|
|
||||||
console.log('');
|
|
||||||
console.log('示例:');
|
|
||||||
console.log(' bun usd-cny-rate.fetcher.js');
|
|
||||||
console.log(' bun usd-cny-rate.fetcher.js --start 2024-01-01 --end 2024-01-31');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startDate, endDate };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMainModule()) {
|
|
||||||
const { startDate, endDate } = parseCommandLineArgs();
|
|
||||||
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
console.log('人民币美元汇率获取脚本');
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
|
|
||||||
fetch(startDate, endDate)
|
|
||||||
.then(result => {
|
|
||||||
console.log('\n' + '='.repeat(50));
|
|
||||||
console.log('获取结果:');
|
|
||||||
console.log(` 货币对: ${result.currency}`);
|
|
||||||
console.log(` 开始日期: ${result.startDate}`);
|
|
||||||
console.log(` 结束日期: ${result.endDate}`);
|
|
||||||
console.log(` 数据条数: ${result.count}`);
|
|
||||||
console.log(` 获取时间: ${result.fetchTime}`);
|
|
||||||
console.log(` 提示: ${result.note}`);
|
|
||||||
if (result.count > 0) {
|
|
||||||
console.log('-'.repeat(50));
|
|
||||||
console.log('汇率数据:');
|
|
||||||
result.data.forEach(item => {
|
|
||||||
console.log(` ${item.date} 中间价: ${item.centerPrice}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log('='.repeat(50));
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
})
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* 一次性数据迁移: 把 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) })
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* 拉所有启用的数据源, 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)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
import { createPool } from 'mariadb';
|
|
||||||
import { fetch as fetchSH300Data } from '../fetchers/sh300.fetcher.js';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
let pool;
|
|
||||||
|
|
||||||
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'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initPool() {
|
|
||||||
if (!pool) {
|
|
||||||
console.log('初始化数据库连接池...');
|
|
||||||
const config = loadDatabaseConfig();
|
|
||||||
|
|
||||||
console.log('数据库配置:');
|
|
||||||
console.log(` 主机: ${config.host}`);
|
|
||||||
console.log(` 端口: ${config.port}`);
|
|
||||||
console.log(` 用户: ${config.user}`);
|
|
||||||
console.log(` 数据库: ${config.database}`);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkAndFillSH300Data(startDate = null, endDate = null) {
|
|
||||||
try {
|
|
||||||
await initPool();
|
|
||||||
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log('开始检查和补充沪深300指数数据...');
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
if (!startDate) {
|
|
||||||
startDate = new Date(currentDate);
|
|
||||||
startDate.setDate(startDate.getDate() - 30);
|
|
||||||
startDate = startDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endDate) {
|
|
||||||
endDate = currentDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`检查时间范围: ${startDate} 至 ${endDate}`);
|
|
||||||
|
|
||||||
// 1. 检查数据库中是否存在沪深300指数数据表
|
|
||||||
const hasTable = await checkTableExists();
|
|
||||||
if (!hasTable) {
|
|
||||||
console.log('创建沪深300指数数据表...');
|
|
||||||
await createSH300Table();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 检查数据库中已有的数据
|
|
||||||
const existingData = await getExistingSH300Data(startDate, endDate);
|
|
||||||
console.log(`数据库中已有 ${existingData.length} 条数据`);
|
|
||||||
|
|
||||||
// 3. 计算缺失的日期
|
|
||||||
const missingDates = calculateMissingDates(startDate, endDate, existingData);
|
|
||||||
console.log(`缺失 ${missingDates.length} 天的数据`);
|
|
||||||
|
|
||||||
// 4. 如果有缺失,调用 fetcher 补足
|
|
||||||
if (missingDates.length > 0) {
|
|
||||||
console.log('\n开始获取缺失的沪深300指数数据...');
|
|
||||||
|
|
||||||
const firstMissingDate = missingDates[0];
|
|
||||||
const lastMissingDate = missingDates[missingDates.length - 1];
|
|
||||||
|
|
||||||
console.log(`获取范围: ${firstMissingDate} 至 ${lastMissingDate}`);
|
|
||||||
|
|
||||||
const fetchedData = await fetchSH300Data(firstMissingDate, lastMissingDate);
|
|
||||||
|
|
||||||
console.log(`\n获取到 ${fetchedData.count} 条数据`);
|
|
||||||
|
|
||||||
// 5. 存储新数据到数据库
|
|
||||||
const storedCount = await storeSH300Data(fetchedData.data);
|
|
||||||
console.log(`成功存储 ${storedCount} 条数据`);
|
|
||||||
} else {
|
|
||||||
console.log('\n数据库数据完整,无需补充');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 验证最终数据完整性
|
|
||||||
const finalData = await getExistingSH300Data(startDate, endDate);
|
|
||||||
console.log(`\n验证后的数据总量: ${finalData.length} 条`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
existingCount: existingData.length,
|
|
||||||
missingCount: missingDates.length,
|
|
||||||
finalCount: finalData.length,
|
|
||||||
success: true
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检查和补充沪深300指数数据失败:', error.message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (pool) {
|
|
||||||
await pool.end();
|
|
||||||
console.log('数据库连接池已关闭');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkTableExists() {
|
|
||||||
try {
|
|
||||||
console.log('检查沪深300指数数据表是否存在...');
|
|
||||||
const result = await pool.query('SHOW TABLES LIKE ?', ['econ_SH300Index']);
|
|
||||||
return result.length > 0;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检查表存在失败:', error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSH300Table() {
|
|
||||||
try {
|
|
||||||
console.log('执行创建沪深300指数数据表SQL...');
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS econ_SH300Index (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
date DATE UNIQUE NOT NULL,
|
|
||||||
symbol VARCHAR(10) NOT NULL,
|
|
||||||
open DECIMAL(10,2) NOT NULL,
|
|
||||||
close DECIMAL(10,2) NOT NULL,
|
|
||||||
high DECIMAL(10,2) NOT NULL,
|
|
||||||
low DECIMAL(10,2) NOT NULL,
|
|
||||||
volume DECIMAL(20,2) NOT NULL,
|
|
||||||
amount DECIMAL(20,2) NOT NULL,
|
|
||||||
source VARCHAR(100) NOT NULL,
|
|
||||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
`);
|
|
||||||
console.log('沪深300指数数据表创建成功');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建沪深300指数数据表失败:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getExistingSH300Data(startDate, endDate) {
|
|
||||||
try {
|
|
||||||
console.log('查询已有沪深300指数数据...');
|
|
||||||
const result = await pool.query(
|
|
||||||
'SELECT date FROM econ_SH300Index WHERE date BETWEEN ? AND ? ORDER BY date',
|
|
||||||
[startDate, endDate]
|
|
||||||
);
|
|
||||||
const dates = result.map(row => {
|
|
||||||
if (row.date instanceof Date) {
|
|
||||||
return row.date.toISOString().split('T')[0];
|
|
||||||
} else if (typeof row.date === 'string') {
|
|
||||||
return row.date;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}).filter(Boolean);
|
|
||||||
console.log(`查询到 ${dates.length} 条数据`);
|
|
||||||
return dates;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('查询已有沪深300指数数据失败:', error.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateMissingDates(startDate, endDate, existingDates) {
|
|
||||||
const existingSet = new Set(existingDates);
|
|
||||||
const missingDates = [];
|
|
||||||
|
|
||||||
const currentDate = new Date(startDate);
|
|
||||||
const finalDate = new Date(endDate);
|
|
||||||
|
|
||||||
while (currentDate <= finalDate) {
|
|
||||||
const dateStr = currentDate.toISOString().split('T')[0];
|
|
||||||
const dayOfWeek = currentDate.getDay();
|
|
||||||
|
|
||||||
// 跳过周末(周末没有股市数据)
|
|
||||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
|
||||||
if (!existingSet.has(dateStr)) {
|
|
||||||
missingDates.push(dateStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return missingDates;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storeSH300Data(data) {
|
|
||||||
let storedCount = 0;
|
|
||||||
|
|
||||||
for (const item of data) {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO econ_SH300Index (date, symbol, open, close, high, low, volume, amount, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE open = VALUES(open), close = VALUES(close), high = VALUES(high), low = VALUES(low), volume = VALUES(volume), amount = VALUES(amount), source = VALUES(source)',
|
|
||||||
[item.date, 'sh000300', item.open, item.close, item.high, item.low, item.volume, item.amount, item.source]
|
|
||||||
);
|
|
||||||
storedCount++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`存储沪深300指数数据 ${item.date} 失败:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return storedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function trigger(startDate = null, endDate = null) {
|
|
||||||
return await checkAndFillSH300Data(startDate, endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCommandLineArgs() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
let startDate = null;
|
|
||||||
let endDate = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
if (args[i] === '--start' && i + 1 < args.length) {
|
|
||||||
startDate = args[i + 1];
|
|
||||||
i++;
|
|
||||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
|
||||||
endDate = args[i + 1];
|
|
||||||
i++;
|
|
||||||
} else if (args[i] === '--help') {
|
|
||||||
console.log('使用方法:');
|
|
||||||
console.log(' bun sh300.scraper.js [选项]');
|
|
||||||
console.log('');
|
|
||||||
console.log('选项:');
|
|
||||||
console.log(' --start YYYY-MM-DD 指定开始日期');
|
|
||||||
console.log(' --end YYYY-MM-DD 指定结束日期');
|
|
||||||
console.log(' --help 显示帮助信息');
|
|
||||||
console.log('');
|
|
||||||
console.log('示例:');
|
|
||||||
console.log(' bun sh300.scraper.js');
|
|
||||||
console.log(' bun sh300.scraper.js --start 2024-01-01 --end 2024-01-31');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startDate, endDate };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTest() {
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log('测试 sh300.scraper.js');
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
|
|
||||||
const { startDate, endDate } = parseCommandLineArgs();
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
console.log(`\n使用命令行参数:`);
|
|
||||||
console.log(` 开始日期: ${startDate}`);
|
|
||||||
console.log(` 结束日期: ${endDate || '默认'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n测试数据库配置读取...');
|
|
||||||
const config = loadDatabaseConfig();
|
|
||||||
console.log('配置读取结果:');
|
|
||||||
console.log(` 主机: ${config.host}`);
|
|
||||||
console.log(` 端口: ${config.port}`);
|
|
||||||
console.log(` 用户: ${config.user}`);
|
|
||||||
console.log(` 数据库: ${config.database}`);
|
|
||||||
console.log('配置读取测试完成!');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await trigger(startDate, endDate);
|
|
||||||
console.log('\n' + '='.repeat(60));
|
|
||||||
console.log('测试结果:');
|
|
||||||
console.log(` 开始日期: ${result.startDate}`);
|
|
||||||
console.log(` 结束日期: ${result.endDate}`);
|
|
||||||
console.log(` 原有数据: ${result.existingCount} 条`);
|
|
||||||
console.log(` 缺失数据: ${result.missingCount} 条`);
|
|
||||||
console.log(` 最终数据: ${result.finalCount} 条`);
|
|
||||||
console.log(` 操作成功: ${result.success}`);
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('测试失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runTest();
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
import { createPool } from 'mariadb';
|
|
||||||
import { fetch as fetchUSDToCNYRate } from '../fetchers/usd-cny-rate.fetcher.js';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
let pool;
|
|
||||||
|
|
||||||
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'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initPool() {
|
|
||||||
if (!pool) {
|
|
||||||
console.log('初始化数据库连接池...');
|
|
||||||
const config = loadDatabaseConfig();
|
|
||||||
|
|
||||||
console.log('数据库配置:');
|
|
||||||
console.log(` 主机: ${config.host}`);
|
|
||||||
console.log(` 端口: ${config.port}`);
|
|
||||||
console.log(` 用户: ${config.user}`);
|
|
||||||
console.log(` 数据库: ${config.database}`);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkAndFillUSDCNYRate(startDate = null, endDate = null) {
|
|
||||||
try {
|
|
||||||
await initPool();
|
|
||||||
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log('开始检查和补充人民币美元汇率数据...');
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
if (!startDate) {
|
|
||||||
startDate = new Date(currentDate);
|
|
||||||
startDate.setDate(startDate.getDate() - 30);
|
|
||||||
startDate = startDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endDate) {
|
|
||||||
endDate = currentDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`检查时间范围: ${startDate} 至 ${endDate}`);
|
|
||||||
|
|
||||||
// 1. 检查数据库中是否存在汇率数据表
|
|
||||||
const hasTable = await checkTableExists();
|
|
||||||
if (!hasTable) {
|
|
||||||
console.log('创建汇率数据表...');
|
|
||||||
await createExchangeRateTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 检查数据库中已有的数据
|
|
||||||
const existingData = await getExistingExchangeRates(startDate, endDate);
|
|
||||||
console.log(`数据库中已有 ${existingData.length} 条数据`);
|
|
||||||
|
|
||||||
// 3. 计算缺失的日期
|
|
||||||
const missingDates = calculateMissingDates(startDate, endDate, existingData);
|
|
||||||
console.log(`缺失 ${missingDates.length} 天的数据`);
|
|
||||||
|
|
||||||
// 4. 如果有缺失,调用 fetcher 补足
|
|
||||||
if (missingDates.length > 0) {
|
|
||||||
console.log('\n开始获取缺失的汇率数据...');
|
|
||||||
|
|
||||||
const firstMissingDate = missingDates[0];
|
|
||||||
const lastMissingDate = missingDates[missingDates.length - 1];
|
|
||||||
|
|
||||||
console.log(`获取范围: ${firstMissingDate} 至 ${lastMissingDate}`);
|
|
||||||
|
|
||||||
const fetchedData = await fetchUSDToCNYRate(firstMissingDate, lastMissingDate);
|
|
||||||
|
|
||||||
console.log(`\n获取到 ${fetchedData.count} 条数据`);
|
|
||||||
|
|
||||||
// 5. 存储新数据到数据库
|
|
||||||
const storedCount = await storeExchangeRates(fetchedData.data);
|
|
||||||
console.log(`成功存储 ${storedCount} 条数据`);
|
|
||||||
} else {
|
|
||||||
console.log('\n数据库数据完整,无需补充');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 验证最终数据完整性
|
|
||||||
const finalData = await getExistingExchangeRates(startDate, endDate);
|
|
||||||
console.log(`\n验证后的数据总量: ${finalData.length} 条`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
existingCount: existingData.length,
|
|
||||||
missingCount: missingDates.length,
|
|
||||||
finalCount: finalData.length,
|
|
||||||
success: true
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检查和补充汇率数据失败:', error.message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (pool) {
|
|
||||||
await pool.end();
|
|
||||||
console.log('数据库连接池已关闭');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkTableExists() {
|
|
||||||
try {
|
|
||||||
console.log('检查汇率数据表是否存在...');
|
|
||||||
const result = await pool.query('SHOW TABLES LIKE ?', ['econ_ExchangeRate']);
|
|
||||||
return result.length > 0;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检查表存在失败:', error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createExchangeRateTable() {
|
|
||||||
try {
|
|
||||||
console.log('执行创建汇率数据表SQL...');
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS econ_ExchangeRate (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
date DATE UNIQUE NOT NULL,
|
|
||||||
currency VARCHAR(10) NOT NULL,
|
|
||||||
centerPrice DECIMAL(10,4) NOT NULL,
|
|
||||||
sellingRate DECIMAL(10,4) NOT NULL,
|
|
||||||
buyingRate DECIMAL(10,4) NOT NULL,
|
|
||||||
source VARCHAR(100) NOT NULL,
|
|
||||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
`);
|
|
||||||
console.log('汇率数据表创建成功');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建汇率数据表失败:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getExistingExchangeRates(startDate, endDate) {
|
|
||||||
try {
|
|
||||||
console.log('查询已有汇率数据...');
|
|
||||||
const result = await pool.query(
|
|
||||||
'SELECT date FROM econ_ExchangeRate WHERE date BETWEEN ? AND ? ORDER BY date',
|
|
||||||
[startDate, endDate]
|
|
||||||
);
|
|
||||||
const dates = result.map(row => {
|
|
||||||
if (row.date instanceof Date) {
|
|
||||||
return row.date.toISOString().split('T')[0];
|
|
||||||
} else if (typeof row.date === 'string') {
|
|
||||||
return row.date;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}).filter(Boolean);
|
|
||||||
console.log(`查询到 ${dates.length} 条数据`);
|
|
||||||
return dates;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('查询已有汇率数据失败:', error.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateMissingDates(startDate, endDate, existingDates) {
|
|
||||||
const existingSet = new Set(existingDates);
|
|
||||||
const missingDates = [];
|
|
||||||
|
|
||||||
const currentDate = new Date(startDate);
|
|
||||||
const finalDate = new Date(endDate);
|
|
||||||
|
|
||||||
while (currentDate <= finalDate) {
|
|
||||||
const dateStr = currentDate.toISOString().split('T')[0];
|
|
||||||
const dayOfWeek = currentDate.getDay();
|
|
||||||
|
|
||||||
// 跳过周末(周末没有汇率数据)
|
|
||||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
|
||||||
if (!existingSet.has(dateStr)) {
|
|
||||||
missingDates.push(dateStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return missingDates;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storeExchangeRates(rates) {
|
|
||||||
let storedCount = 0;
|
|
||||||
|
|
||||||
for (const rate of rates) {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO econ_ExchangeRate (date, currency, centerPrice, sellingRate, buyingRate, source) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE centerPrice = VALUES(centerPrice), sellingRate = VALUES(sellingRate), buyingRate = VALUES(buyingRate), source = VALUES(source)',
|
|
||||||
[rate.date, rate.currency, rate.centerPrice, rate.sellingRate, rate.buyingRate, rate.source]
|
|
||||||
);
|
|
||||||
storedCount++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`存储汇率数据 ${rate.date} 失败:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return storedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function trigger(startDate = null, endDate = null) {
|
|
||||||
return await checkAndFillUSDCNYRate(startDate, endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCommandLineArgs() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
let startDate = null;
|
|
||||||
let endDate = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
if (args[i] === '--start' && i + 1 < args.length) {
|
|
||||||
startDate = args[i + 1];
|
|
||||||
i++;
|
|
||||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
|
||||||
endDate = args[i + 1];
|
|
||||||
i++;
|
|
||||||
} else if (args[i] === '--help') {
|
|
||||||
console.log('使用方法:');
|
|
||||||
console.log(' bun usd-cny-rate.scraper.js [选项]');
|
|
||||||
console.log('');
|
|
||||||
console.log('选项:');
|
|
||||||
console.log(' --start YYYY-MM-DD 指定开始日期');
|
|
||||||
console.log(' --end YYYY-MM-DD 指定结束日期');
|
|
||||||
console.log(' --help 显示帮助信息');
|
|
||||||
console.log('');
|
|
||||||
console.log('示例:');
|
|
||||||
console.log(' bun usd-cny-rate.scraper.js');
|
|
||||||
console.log(' bun usd-cny-rate.scraper.js --start 2024-01-01 --end 2024-01-31');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startDate, endDate };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTest() {
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log('测试 usd-cny-rate.scraper.js');
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
|
|
||||||
const { startDate, endDate } = parseCommandLineArgs();
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
console.log(`\n使用命令行参数:`);
|
|
||||||
console.log(` 开始日期: ${startDate}`);
|
|
||||||
console.log(` 结束日期: ${endDate || '默认'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n测试数据库配置读取...');
|
|
||||||
const config = loadDatabaseConfig();
|
|
||||||
console.log('配置读取结果:');
|
|
||||||
console.log(` 主机: ${config.host}`);
|
|
||||||
console.log(` 端口: ${config.port}`);
|
|
||||||
console.log(` 用户: ${config.user}`);
|
|
||||||
console.log(` 数据库: ${config.database}`);
|
|
||||||
console.log('配置读取测试完成!');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await trigger(startDate, endDate);
|
|
||||||
console.log('\n' + '='.repeat(60));
|
|
||||||
console.log('测试结果:');
|
|
||||||
console.log(` 开始日期: ${result.startDate}`);
|
|
||||||
console.log(` 结束日期: ${result.endDate}`);
|
|
||||||
console.log(` 原有数据: ${result.existingCount} 条`);
|
|
||||||
console.log(` 缺失数据: ${result.missingCount} 条`);
|
|
||||||
console.log(` 最终数据: ${result.finalCount} 条`);
|
|
||||||
console.log(` 操作成功: ${result.success}`);
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('测试失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runTest();
|
|
||||||
File diff suppressed because one or more lines are too long
+86
-132
@@ -1,133 +1,87 @@
|
|||||||
/**
|
/**
|
||||||
* iBoard 基础数据初始化脚本
|
* iBoard 基础数据初始化脚本
|
||||||
* 用法: npm run db:seed
|
* 用法: npm run db:seed
|
||||||
* 幂等: 主键/唯一键冲突跳过, Strategy/Indicator 按 name 存在则更新
|
* 幂等: 主键/唯一键冲突跳过
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
function hashPassword(plain) {
|
function hashPassword(plain) {
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
const hash = crypto.scryptSync(plain, salt, 64).toString('hex');
|
const hash = crypto.scryptSync(plain, salt, 64).toString('hex');
|
||||||
return 'scrypt$' + salt + '$' + hash;
|
return 'scrypt$' + salt + '$' + hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUrl(url) {
|
function parseUrl(url) {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
return {
|
return {
|
||||||
host: u.hostname,
|
host: u.hostname,
|
||||||
port: Number(u.port) || 3306,
|
port: Number(u.port) || 3306,
|
||||||
user: decodeURIComponent(u.username),
|
user: decodeURIComponent(u.username),
|
||||||
password: decodeURIComponent(u.password),
|
password: decodeURIComponent(u.password),
|
||||||
database: u.pathname.replace(/^\//, ''),
|
database: u.pathname.replace(/^\//, ''),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertIgnore(conn, sql, params) {
|
async function insertIgnore(conn, sql, params) {
|
||||||
try {
|
try {
|
||||||
const [r] = await conn.execute(sql, params);
|
const [r] = await conn.execute(sql, params);
|
||||||
return r.affectedRows;
|
return r.affectedRows;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === 'ER_DUP_ENTRY') return 0;
|
if (e.code === 'ER_DUP_ENTRY') return 0;
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertByName(conn, table, payload, keyField) {
|
async function main() {
|
||||||
const fields = Object.keys(payload);
|
const cfg = parseUrl(process.env.DATABASE_URL);
|
||||||
const placeholders = fields.map(() => '?').join(', ');
|
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
|
||||||
const updateClause = fields
|
const conn = await mysql.createConnection(cfg);
|
||||||
.filter((f) => f !== keyField)
|
console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database);
|
||||||
.map((f) => '`' + f + '` = VALUES(`' + f + '`)')
|
|
||||||
.join(', ');
|
const adminPwd = hashPassword('admin123');
|
||||||
const sql = 'INSERT INTO `' + table + '` (' + fields.map((f) => '`' + f + '`').join(', ') + ') VALUES (' + placeholders + ') ON DUPLICATE KEY UPDATE ' + updateClause;
|
const n1 = await insertIgnore(
|
||||||
const params = fields.map((f) => payload[f]);
|
conn,
|
||||||
const [r] = await conn.execute(sql, params);
|
'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))',
|
||||||
return r.affectedRows;
|
['Admin', 'admin@iboard.local', adminPwd],
|
||||||
}
|
);
|
||||||
|
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
|
||||||
async function main() {
|
|
||||||
const cfg = parseUrl(process.env.DATABASE_URL);
|
const article = {
|
||||||
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
|
title: '欢迎使用 iBoard',
|
||||||
const conn = await mysql.createConnection(cfg);
|
slug: 'welcome-to-iboard',
|
||||||
console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database);
|
content: 'iBoard 用于收集和整理经济数据,以策略为主体重新组织。\n\n本文为示例条目,正式文章仍由 content/articles/*.md 文件承载。',
|
||||||
|
date: '2026-08-03',
|
||||||
const adminPwd = hashPassword('admin123');
|
};
|
||||||
const n1 = await insertIgnore(
|
const n4 = await insertIgnore(
|
||||||
conn,
|
conn,
|
||||||
'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))',
|
'INSERT INTO `Article` (title, slug, content, date, createdAt, updatedAt) VALUES (?, ?, ?, ?, NOW(3), NOW(3))',
|
||||||
['Admin', 'admin@iboard.local', adminPwd],
|
[article.title, article.slug, article.content, article.date],
|
||||||
);
|
);
|
||||||
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
|
console.log('[Article] 新增示例文章: ' + n4 + ' 行');
|
||||||
|
|
||||||
const strategies = [
|
const sowRows = [
|
||||||
{ name: 'us-debt-gdp', description: '美国政府债务/GDP分析',
|
{ month: '2024-01', inventory: 4067 },
|
||||||
data: { source: 'strategy.js', unit: '万亿美元', rows: [
|
{ month: '2024-02', inventory: 4042 },
|
||||||
{ year: '2019', debt: 22.7, gdp: 21.4, debtToGdp: 106.1, interestToGdp: 1.8 },
|
{ month: '2024-03', inventory: 3992 },
|
||||||
{ year: '2020', debt: 26.9, gdp: 20.9, debtToGdp: 128.7, interestToGdp: 1.6 },
|
{ month: '2024-04', inventory: 3986 },
|
||||||
{ year: '2021', debt: 28.4, gdp: 23.0, debtToGdp: 123.5, interestToGdp: 1.5 },
|
{ month: '2024-05', inventory: 3996 },
|
||||||
{ year: '2022', debt: 30.9, gdp: 25.4, debtToGdp: 121.6, interestToGdp: 1.6 },
|
{ month: '2024-06', inventory: 4038 },
|
||||||
{ 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 },
|
let n5 = 0;
|
||||||
]},
|
for (const r of sowRows) {
|
||||||
},
|
n5 += await insertIgnore(
|
||||||
{ name: 'china-debt-gdp', description: '中国政府债务/GDP分析',
|
conn,
|
||||||
data: { source: 'strategy.js', unit: '万亿美元', rows: [
|
'INSERT INTO `Econ_SowInventory` (month, inventory, createdAt, updatedAt) VALUES (?, ?, NOW(3), NOW(3))',
|
||||||
{ year: '2019', debt: 8.8, gdp: 14.3, debtToGdp: 61.5, interestToGdp: 1.9 },
|
[r.month, r.inventory],
|
||||||
{ 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 },
|
console.log('[Econ_SowInventory] 新增: ' + n5 + ' 行');
|
||||||
{ 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 },
|
await conn.end();
|
||||||
]},
|
console.log('\nSeed 完成.');
|
||||||
},
|
}
|
||||||
];
|
|
||||||
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); });
|
main().catch((e) => { console.error('Seed 失败:', e); process.exit(1); });
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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)});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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); });
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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); });
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,9 @@ if [ ! -d "gen/android" ]; then
|
|||||||
fi
|
fi
|
||||||
cd "$WIN_PROJECT"
|
cd "$WIN_PROJECT"
|
||||||
|
|
||||||
|
# 0.5 软键盘避让: 注入 MainActivity 的 IME Insets 处理 (幂等, 每次都要跑)
|
||||||
|
bash scripts/ci/android-softinput.sh
|
||||||
|
|
||||||
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
|
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
|
||||||
export VITE_API_BASE="${VITE_API_BASE:-https://iboard.honor3.com}"
|
export VITE_API_BASE="${VITE_API_BASE:-https://iboard.honor3.com}"
|
||||||
echo "VITE_API_BASE=$VITE_API_BASE"
|
echo "VITE_API_BASE=$VITE_API_BASE"
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { prisma } from './db.js'
|
|
||||||
|
|
||||||
// 跨源对比: 同 (indicatorName, period) 多源数据差异超阈值则插 Alert
|
|
||||||
// conn: mysql2 连接
|
|
||||||
// 阈值: 绝对差 >= 0.01 且 相对差 >= 1%
|
|
||||||
// 返回 { inserted, scanned }
|
|
||||||
const ABS_TOL = 0.01
|
|
||||||
const REL_TOL = 0.01
|
|
||||||
|
|
||||||
export async function checkAfterRefresh(conn) {
|
|
||||||
// conn 是 mysql2 连接, 走原始 SQL
|
|
||||||
const [rows] = await conn.execute(`
|
|
||||||
SELECT ia.name AS indicatorName, a.period, ia.source, a.value
|
|
||||||
FROM IndicatorPoint a
|
|
||||||
JOIN Indicator ia ON a.indicatorId = ia.id
|
|
||||||
ORDER BY ia.name, a.period
|
|
||||||
`)
|
|
||||||
const groups = new Map()
|
|
||||||
for (const r of rows) {
|
|
||||||
const k = r.indicatorName + '|' + r.period
|
|
||||||
if (!groups.has(k)) groups.set(k, [])
|
|
||||||
groups.get(k).push({ source: r.source, value: Number(r.value) })
|
|
||||||
}
|
|
||||||
let inserted = 0
|
|
||||||
for (const [k, arr] of groups) {
|
|
||||||
if (arr.length < 2) continue
|
|
||||||
const sources = new Set(arr.map((x) => x.source))
|
|
||||||
if (sources.size < 2) continue
|
|
||||||
for (let i = 0; i < arr.length; i++) {
|
|
||||||
for (let j = i + 1; j < arr.length; j++) {
|
|
||||||
const A = arr[i], B = arr[j]
|
|
||||||
const diff = Math.abs(A.value - B.value)
|
|
||||||
const base = Math.max(Math.abs(A.value), Math.abs(B.value), 1e-9)
|
|
||||||
const ratio = diff / base
|
|
||||||
if (diff < ABS_TOL || ratio < REL_TOL) continue
|
|
||||||
const [name, period] = k.split('|')
|
|
||||||
const [r] = await conn.execute(
|
|
||||||
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(3))',
|
|
||||||
[name, period, A.value, B.value, A.source, B.source, diff, ratio],
|
|
||||||
)
|
|
||||||
if (r.affectedRows === 1) inserted++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { inserted, scanned: rows.length }
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// apizero 接口的文件缓存
|
|
||||||
// - 按 key 存 JSON 到磁盘: <APIZERO_CACHE_DIR>/<key>.json
|
|
||||||
// - 存在性检查: 文件在即视为有效 (用户要求: "同一个年份只要存在就不再调用接口")
|
|
||||||
// - 不做 TTL: 用户没要求, 也避免误命中过期数据
|
|
||||||
//
|
|
||||||
// 环境变量:
|
|
||||||
// APIZERO_CACHE_DIR 缓存目录 (默认 <cwd>/cache/apizero)
|
|
||||||
//
|
|
||||||
// 未来若要加 TTL / DB 后端, 替换本文件即可, 上层调用方不变.
|
|
||||||
import fs from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
function dir() {
|
|
||||||
return process.env.APIZERO_CACHE_DIR
|
|
||||||
? path.resolve(process.env.APIZERO_CACHE_DIR)
|
|
||||||
: path.resolve(process.cwd(), 'cache', 'apizero')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读缓存, 不存在返回 null
|
|
||||||
export function read(key) {
|
|
||||||
const p = path.join(dir(), key + '.json')
|
|
||||||
if (!fs.existsSync(p)) return null
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(p, 'utf8')
|
|
||||||
return JSON.parse(raw)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[apizero-cache] 读 ${key} 失败, 忽略:`, e.message)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写缓存 (原子: 先写 .tmp 再 rename)
|
|
||||||
export function write(key, data) {
|
|
||||||
const d = dir()
|
|
||||||
fs.mkdirSync(d, { recursive: true })
|
|
||||||
const final = path.join(d, key + '.json')
|
|
||||||
const tmp = final + '.tmp.' + process.pid
|
|
||||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8')
|
|
||||||
fs.renameSync(tmp, final)
|
|
||||||
return final
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存在性检查
|
|
||||||
export function has(key) {
|
|
||||||
return fs.existsSync(path.join(dir(), key + '.json'))
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// apizero (https://apizero.cn) 通用 HTTP 客户端
|
|
||||||
// - 统一从 env APIZERO_KEY 读取 API key (test key 也能用)
|
|
||||||
// - 统一 QPS 节流 (apizero 限制 3 req/s)
|
|
||||||
// - 统一错误格式: 抛出 ApizeroError(code, message, httpStatus)
|
|
||||||
// - 所有 apizero 接口都进这一层, 后续新增模块复用
|
|
||||||
//
|
|
||||||
// 依赖: 原生 fetch (Node 18+)
|
|
||||||
|
|
||||||
// 简易令牌桶, 限速 3 req/s
|
|
||||||
let lastCallAt = 0
|
|
||||||
const MIN_INTERVAL_MS = 1000 / 3 // ~333ms
|
|
||||||
|
|
||||||
async function throttle() {
|
|
||||||
const now = Date.now()
|
|
||||||
const wait = lastCallAt + MIN_INTERVAL_MS - now
|
|
||||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
|
||||||
lastCallAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// apizero 标准错误
|
|
||||||
export class ApizeroError extends Error {
|
|
||||||
constructor(code, message, httpStatus) {
|
|
||||||
super(message || `apizero error code=${code}`)
|
|
||||||
this.name = 'ApizeroError'
|
|
||||||
this.code = code
|
|
||||||
this.httpStatus = httpStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_URL = 'https://v1.apizero.cn/api'
|
|
||||||
|
|
||||||
// 读 key: 优先 env APIZERO_KEY, 否则用空 (匿名调用, 配额更小)
|
|
||||||
function getKey() {
|
|
||||||
return process.env.APIZERO_KEY || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通用 GET 入口
|
|
||||||
// - endpoint: 'oil-price-forecast' (不带 /api 前缀)
|
|
||||||
// - params: 普通对象
|
|
||||||
// 返回: apizero 的 data 字段 (已解一层)
|
|
||||||
export async function get(endpoint, params = {}) {
|
|
||||||
await throttle()
|
|
||||||
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
|
|
||||||
}
|
|
||||||
const key = getKey()
|
|
||||||
if (key) qs.set('key', key)
|
|
||||||
|
|
||||||
const url = `${BASE_URL}/${endpoint}?${qs.toString()}`
|
|
||||||
let res
|
|
||||||
try {
|
|
||||||
res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } })
|
|
||||||
} catch (e) {
|
|
||||||
throw new ApizeroError('NETWORK', `apizero 网络错误: ${e.message}`, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP 层错误
|
|
||||||
if (!res.ok) {
|
|
||||||
let body = ''
|
|
||||||
try { body = await res.text() } catch { /* ignore */ }
|
|
||||||
throw new ApizeroError('HTTP', `apizero HTTP ${res.status}: ${body.substring(0, 200)}`, res.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 body
|
|
||||||
let json
|
|
||||||
try { json = await res.json() }
|
|
||||||
catch (e) { throw new ApizeroError('PARSE', 'apizero 响应非 JSON: ' + e.message, res.status) }
|
|
||||||
|
|
||||||
// apizero 业务层错误: code !== 0
|
|
||||||
if (json && typeof json.code === 'number' && json.code !== 0) {
|
|
||||||
throw new ApizeroError(json.code, json.msg || 'apizero 业务错误', res.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return json && json.data !== undefined ? json.data : json
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// apizero 模块统一出口
|
|
||||||
// 后续新增 apizero 接口就在这里再 export 一行
|
|
||||||
export * as oil from './oil-price-forecast.js'
|
|
||||||
export * as history from './oil-history.js'
|
|
||||||
export { ApizeroError } from './client.js'
|
|
||||||
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
// 浙江省油价历史: 走 Prisma 落库 + apizero price 接口写入
|
|
||||||
// 设计:
|
|
||||||
// - 每天 04:00 cron 检查 schedule, 待定且 <= 昨天的调价日 -> 抓 price?province=浙江 存本表
|
|
||||||
// - unique (date, province, fuel) 防重复
|
|
||||||
// - 查询 API: 取某省某油品最近 N 天 (默认 180)
|
|
||||||
import { prisma } from '../db.js'
|
|
||||||
import * as client from './client.js'
|
|
||||||
|
|
||||||
const PROVINCE = '浙江'
|
|
||||||
// 油品 -> apizero prices 对象里的 key (price action 返回 "92号汽油" 这种带后缀的 key)
|
|
||||||
const FUEL_KEYS = {
|
|
||||||
'92': '92号汽油',
|
|
||||||
'95': '95号汽油',
|
|
||||||
'0': '0号柴油',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 apizero price action 返回的 prices (中文 key) -> 标准 fuel code
|
|
||||||
function parsePrice(data) {
|
|
||||||
if (!data || !data.prices) throw new Error('apizero 响应无 prices 字段')
|
|
||||||
const out = {}
|
|
||||||
for (const [fuel, label] of Object.entries(FUEL_KEYS)) {
|
|
||||||
const raw = data.prices[label]
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
// "7.64 元/升" -> 7.64
|
|
||||||
const m = raw.match(/^([\d.]+)/)
|
|
||||||
if (m) out[fuel] = parseFloat(m[1])
|
|
||||||
} else if (typeof raw === 'number') {
|
|
||||||
out[fuel] = raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// 抓一次浙江当前价并入库 (date = 今日)
|
|
||||||
// 返回 { inserted, skipped } (按 unique 约束: 已存在则 skip)
|
|
||||||
export async function fetchAndStore() {
|
|
||||||
const data = await client.get('oil-price-forecast', { action: 'price', province: PROVINCE })
|
|
||||||
const prices = parsePrice(data)
|
|
||||||
if (Object.keys(prices).length === 0) {
|
|
||||||
throw new Error('apizero 返回价格为空')
|
|
||||||
}
|
|
||||||
const date = new Date()
|
|
||||||
// 抹掉时分秒, 便于按天比较
|
|
||||||
date.setHours(0, 0, 0, 0)
|
|
||||||
|
|
||||||
let inserted = 0
|
|
||||||
let skipped = 0
|
|
||||||
for (const [fuel, price] of Object.entries(prices)) {
|
|
||||||
try {
|
|
||||||
await prisma.oilPriceHistory.create({
|
|
||||||
data: { date, province: PROVINCE, fuel, price, source: 'apizero' },
|
|
||||||
})
|
|
||||||
inserted++
|
|
||||||
} catch (e) {
|
|
||||||
// P2002: unique 冲突 -> 跳过
|
|
||||||
if (e?.code === 'P2002') skipped++
|
|
||||||
else throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { inserted, skipped, date: date.toISOString().slice(0, 10) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询某省某油品最近 N 天
|
|
||||||
// province 必填, 默认 浙江
|
|
||||||
// fuel 可选, 留空返回 4 个油品
|
|
||||||
// days 默认 180
|
|
||||||
export async function query({ province = PROVINCE, fuel, days = 180 }) {
|
|
||||||
const since = new Date()
|
|
||||||
since.setDate(since.getDate() - days)
|
|
||||||
since.setHours(0, 0, 0, 0)
|
|
||||||
|
|
||||||
const where = { province, date: { gte: since } }
|
|
||||||
if (fuel) where.fuel = fuel
|
|
||||||
|
|
||||||
const rows = await prisma.oilPriceHistory.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: [{ date: 'asc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 整形: Decimal -> number
|
|
||||||
return rows.map((r) => ({
|
|
||||||
date: r.date.toISOString().slice(0, 10),
|
|
||||||
province: r.province,
|
|
||||||
fuel: r.fuel,
|
|
||||||
price: Number(r.price),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// apizero: 今日油价 (oil-price-forecast)
|
|
||||||
// 4 个 action:
|
|
||||||
// forecast - 油价 + 国际原油 + 下次调价预测 (每次都查实时, 不缓存)
|
|
||||||
// price - 单省油价
|
|
||||||
// price-all - 32 省全部油价
|
|
||||||
// schedule - 某年调价日历 (按 year 缓存到文件, 同年份不重复调)
|
|
||||||
//
|
|
||||||
// 文档: https://apizero.cn/aidocs/oil-price-forecast/raw.md
|
|
||||||
import * as client from './client.js'
|
|
||||||
import * as cache from './cache.js'
|
|
||||||
|
|
||||||
// 调价日历: 按年份缓存, 文件存在即视为有效, 不重复调用
|
|
||||||
// cache key 形如 "oil-schedule-2026"
|
|
||||||
export async function getSchedule(year) {
|
|
||||||
const y = Number(year)
|
|
||||||
if (!Number.isInteger(y) || y < 2025 || y > 2026) {
|
|
||||||
throw new client.ApizeroError(4000, `year 不在 2025-2026 范围: ${year}`, 400)
|
|
||||||
}
|
|
||||||
const cacheKey = `oil-schedule-${y}`
|
|
||||||
const hit = cache.read(cacheKey)
|
|
||||||
if (hit) return { ...hit, _cached: true }
|
|
||||||
const data = await client.get('oil-price-forecast', { action: 'schedule', year: y })
|
|
||||||
cache.write(cacheKey, data)
|
|
||||||
return { ...data, _cached: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实时: 国际原油 + 下次调价预测
|
|
||||||
export async function getForecast() {
|
|
||||||
return client.get('oil-price-forecast', { action: 'forecast' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单省油价
|
|
||||||
export async function getPrice(province) {
|
|
||||||
if (!province) throw new client.ApizeroError(4000, 'province 必填', 400)
|
|
||||||
return client.get('oil-price-forecast', { action: 'price', province })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 32 省全部油价
|
|
||||||
export async function getPriceAll() {
|
|
||||||
return client.get('oil-price-forecast', { action: 'price-all' })
|
|
||||||
}
|
|
||||||
+52
-110
@@ -1,110 +1,52 @@
|
|||||||
import cron from 'node-cron'
|
import cron from 'node-cron'
|
||||||
import { prisma } from './db.js'
|
|
||||||
import mysql from 'mysql2/promise'
|
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
|
||||||
import { checkAfterRefresh } from './alert-engine.js'
|
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
|
||||||
import { ApizeroError, oil, history } from './apizero/index.js'
|
async function importNewsFetcher() {
|
||||||
|
const mod = await import('../scripts/fetchers/news.fetcher.js')
|
||||||
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
|
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
|
||||||
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
|
}
|
||||||
async function importNewsFetcher() {
|
|
||||||
const mod = await import('../scripts/fetchers/news.fetcher.js')
|
async function alertError(taskName, message) {
|
||||||
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
|
try {
|
||||||
}
|
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
|
||||||
|
await insertCronAlert(taskName, message)
|
||||||
async function alertError(taskName, message) {
|
} catch (e) {
|
||||||
try {
|
console.error('[cron] 报警写入失败(忽略):', e.message)
|
||||||
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
|
}
|
||||||
await insertCronAlert(taskName, message)
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('[cron] 报警写入失败(忽略):', e.message)
|
// 每天 6:00 (Asia/Shanghai) 拉取新闻, 出错生成报警
|
||||||
}
|
function scheduleNews() {
|
||||||
}
|
cron.schedule('0 6 * * *', async () => {
|
||||||
|
console.log('[cron] 6:00 触发')
|
||||||
function parseUrl(url) {
|
try {
|
||||||
const u = new URL(url)
|
const { fetchNewsList, persistNews } = await importNewsFetcher()
|
||||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
const list = await fetchNewsList()
|
||||||
}
|
const r = await persistNews(list)
|
||||||
|
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||||
async function openConn() {
|
} catch (e) {
|
||||||
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
console.error('[cron] 任务失败:', e.message)
|
||||||
}
|
await alertError('新闻拉取', e.message)
|
||||||
|
}
|
||||||
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
|
}, { timezone: 'Asia/Shanghai' })
|
||||||
function scheduleNews() {
|
console.log('[cron] 已注册: 每天 06:00 拉取新闻')
|
||||||
cron.schedule('0 6 * * *', async () => {
|
}
|
||||||
console.log('[cron] 6:00 触发')
|
|
||||||
try {
|
// 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
|
||||||
const { fetchNewsList, persistNews } = await importNewsFetcher()
|
function scheduleAutoArchive() {
|
||||||
const list = await fetchNewsList()
|
cron.schedule('30 * * * *', async () => {
|
||||||
const r = await persistNews(list)
|
try {
|
||||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
const { runAutoArchiveSweep } = await import('./task-domain/archive.js')
|
||||||
const conn = await openConn()
|
const n = await runAutoArchiveSweep()
|
||||||
try {
|
if (n > 0) console.log('[cron] 自动归档:', n, '条')
|
||||||
const a = await checkAfterRefresh(conn)
|
} catch (e) {
|
||||||
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
console.error('[cron] 自动归档失败:', e.message)
|
||||||
} finally { await conn.end() }
|
await alertError('任务自动归档', e.message)
|
||||||
} catch (e) {
|
}
|
||||||
console.error('[cron] 任务失败:', e.message)
|
}, { timezone: 'Asia/Shanghai' })
|
||||||
await alertError('新闻拉取+报警检测', e.message)
|
console.log('[cron] 已注册: 每小时 30 分 任务自动归档扫描')
|
||||||
}
|
}
|
||||||
}, { timezone: 'Asia/Shanghai' })
|
|
||||||
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
|
scheduleNews()
|
||||||
}
|
scheduleAutoArchive()
|
||||||
|
|
||||||
// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
|
|
||||||
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
|
|
||||||
// - 0 命中: 不调 apizero (省配额)
|
|
||||||
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
|
|
||||||
function scheduleOilHistory() {
|
|
||||||
cron.schedule('10 6 * * *', async () => {
|
|
||||||
console.log('[cron] 6:10 油价历史触发')
|
|
||||||
try {
|
|
||||||
const year = new Date().getFullYear()
|
|
||||||
const sched = await oil.getSchedule(year)
|
|
||||||
const list = sched.schedule || []
|
|
||||||
// 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
|
|
||||||
const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
|
|
||||||
const today = new Date(todayStr + 'T00:00:00+08:00')
|
|
||||||
const yesterdayMs = today.getTime() - 86400000
|
|
||||||
const pendingPast = list.filter((it) => {
|
|
||||||
if (it.status !== '待定') return false
|
|
||||||
const t = new Date(it.date + 'T00:00:00').getTime()
|
|
||||||
return Number.isFinite(t) && t <= yesterdayMs
|
|
||||||
})
|
|
||||||
console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
|
|
||||||
if (pendingPast.length === 0) {
|
|
||||||
console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const r = await history.fetchAndStore()
|
|
||||||
console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApizeroError) {
|
|
||||||
console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
|
|
||||||
} else {
|
|
||||||
console.error('[cron] 油价历史任务失败:', e.message)
|
|
||||||
}
|
|
||||||
await alertError('浙江油价历史抓取', e.message)
|
|
||||||
}
|
|
||||||
}, { timezone: 'Asia/Shanghai' })
|
|
||||||
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
|
|
||||||
function scheduleAutoArchive() {
|
|
||||||
cron.schedule('30 * * * *', async () => {
|
|
||||||
try {
|
|
||||||
const { runAutoArchiveSweep } = await import('./task-domain/archive.js')
|
|
||||||
const n = await runAutoArchiveSweep()
|
|
||||||
if (n > 0) console.log('[cron] 自动归档:', n, '条')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[cron] 自动归档失败:', e.message)
|
|
||||||
await alertError('任务自动归档', e.message)
|
|
||||||
}
|
|
||||||
}, { timezone: 'Asia/Shanghai' })
|
|
||||||
console.log('[cron] 已注册: 每小时 30 分 任务自动归档扫描')
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleNews()
|
|
||||||
scheduleOilHistory()
|
|
||||||
scheduleAutoArchive()
|
|
||||||
|
|||||||
+5
-2
@@ -9,7 +9,6 @@ import newsRouter from './routes/news.js'
|
|||||||
import alertsRouter from './routes/alerts.js'
|
import alertsRouter from './routes/alerts.js'
|
||||||
import articlesRouter from './routes/articles.js'
|
import articlesRouter from './routes/articles.js'
|
||||||
import otaRouter from './routes/ota.js'
|
import otaRouter from './routes/ota.js'
|
||||||
import apizeroRouter from './routes/apizero.js'
|
|
||||||
import monitorsRouter from './routes/monitors.js'
|
import monitorsRouter from './routes/monitors.js'
|
||||||
import mihomeRouter from './routes/mihome.js'
|
import mihomeRouter from './routes/mihome.js'
|
||||||
import checkinRouter from './routes/checkin.js'
|
import checkinRouter from './routes/checkin.js'
|
||||||
@@ -17,6 +16,8 @@ import checklistsRouter from './routes/checklists.js'
|
|||||||
import projectsRouter, { columnRouter } from './routes/projects.js'
|
import projectsRouter, { columnRouter } from './routes/projects.js'
|
||||||
import tasksRouter from './routes/tasks.js'
|
import tasksRouter from './routes/tasks.js'
|
||||||
import powersyncRouter from './routes/powersync.js'
|
import powersyncRouter from './routes/powersync.js'
|
||||||
|
import mcpTokensRouter from './routes/mcp-tokens.js'
|
||||||
|
import mcpRouter from './mcp/index.js'
|
||||||
import { powersyncProxy } from './powersync-proxy.js'
|
import { powersyncProxy } from './powersync-proxy.js'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3001
|
const PORT = Number(process.env.PORT) || 3001
|
||||||
@@ -39,11 +40,13 @@ app.use('/api/auth', authRouter)
|
|||||||
app.use('/api', otaRouter)
|
app.use('/api', otaRouter)
|
||||||
// PowerSync: token 签发 + 离线上行队列 (自带 authRequired, 同样必须挂在带 authRequired 的业务 router 之前)
|
// PowerSync: token 签发 + 离线上行队列 (自带 authRequired, 同样必须挂在带 authRequired 的业务 router 之前)
|
||||||
app.use('/api/powersync', powersyncRouter)
|
app.use('/api/powersync', powersyncRouter)
|
||||||
|
// AI 接入 (MCP): 个人 token 管理 + MCP 端点 (自带 token 鉴权, 必须在 dataRouter 之前)
|
||||||
|
app.use('/api/mcp-tokens', mcpTokensRouter)
|
||||||
|
app.use('/api/mcp', mcpRouter)
|
||||||
app.use('/api', dataRouter)
|
app.use('/api', dataRouter)
|
||||||
app.use('/api', newsRouter)
|
app.use('/api', newsRouter)
|
||||||
app.use('/api', alertsRouter)
|
app.use('/api', alertsRouter)
|
||||||
app.use('/api', articlesRouter)
|
app.use('/api', articlesRouter)
|
||||||
app.use('/api', apizeroRouter)
|
|
||||||
app.use('/api', monitorsRouter)
|
app.use('/api', monitorsRouter)
|
||||||
app.use('/api/mihome', mihomeRouter)
|
app.use('/api/mihome', mihomeRouter)
|
||||||
// V0.2 打卡 / 日历 / 清单
|
// V0.2 打卡 / 日历 / 清单
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// MCP 个人授权 token 的生成与校验
|
||||||
|
// - 明文只在创建时返回一次, 数据库仅存 SHA-256 哈希
|
||||||
|
// - token 与用户绑定, 使用该 token 调用 MCP 时拥有该用户的全部业务权限
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
|
||||||
|
const TOKEN_PREFIX = 'ibm_'
|
||||||
|
const BEARER_RE = /^Bearer\s+(.+)$/i
|
||||||
|
|
||||||
|
/** 生成新的 MCP token 明文 (256 bit 随机, base64url) */
|
||||||
|
export function generateMcpToken() {
|
||||||
|
return TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** token 明文 -> 数据库存储的哈希 */
|
||||||
|
export function hashMcpToken(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Authorization: Bearer xxx 中取出明文 token */
|
||||||
|
export function extractBearer(req) {
|
||||||
|
const h = req.headers?.authorization || ''
|
||||||
|
const m = String(h).match(BEARER_RE)
|
||||||
|
return m ? m[1].trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 MCP 请求的 token
|
||||||
|
* @returns {Promise<null | { tokenId: number, userId: number, user: { id: number, name: string, email: string } }>}
|
||||||
|
*/
|
||||||
|
export async function authenticateMcpToken(req) {
|
||||||
|
const raw = extractBearer(req)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const row = await prisma.mcpToken.findUnique({
|
||||||
|
where: { tokenHash: hashMcpToken(raw) },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
})
|
||||||
|
if (!row || !row.user) return null
|
||||||
|
|
||||||
|
// 最近使用时间: 每分钟最多写一次, 避免每次工具调用都产生写操作
|
||||||
|
const last = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0
|
||||||
|
if (Date.now() - last > 60_000) {
|
||||||
|
prisma.mcpToken
|
||||||
|
.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } })
|
||||||
|
.catch((e) => console.warn('[mcp] 更新 lastUsedAt 失败:', e.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tokenId: row.id, userId: row.userId, user: row.user }
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// MCP 工具注册与结果序列化辅助
|
||||||
|
|
||||||
|
/** 把任意可 JSON 化数据包装为 MCP 文本结果 (BigInt 转 Number) */
|
||||||
|
export function jsonResult(data) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(data, (_key, value) => (typeof value === 'bigint' ? Number(value) : value), 2),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具执行失败: 统一返回 isError, 由 AI 自行读取错误信息 */
|
||||||
|
export function errorResult(message) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '错误: ' + (message || '未知错误') }],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册工具并统一处理异常
|
||||||
|
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
||||||
|
* @param {string} name
|
||||||
|
* @param {object} config
|
||||||
|
* @param {(args: object) => Promise<unknown>} handler
|
||||||
|
*/
|
||||||
|
export function tool(server, name, config, handler) {
|
||||||
|
server.registerTool(name, config, async (args) => {
|
||||||
|
try {
|
||||||
|
return jsonResult(await handler(args || {}))
|
||||||
|
} catch (e) {
|
||||||
|
// 业务校验错误直接回传; Prisma / 连接错误不向 AI 泄露 SQL 细节
|
||||||
|
if (String(e?.name || '').startsWith('Prisma')) {
|
||||||
|
return errorResult(`数据库操作失败${e?.code ? ' (' + e.code + ')' : ''}`)
|
||||||
|
}
|
||||||
|
return errorResult(e?.message || String(e))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非空字符串 + 最大长度 */
|
||||||
|
export function reqStr(v, label, max) {
|
||||||
|
const s = String(v ?? '').trim()
|
||||||
|
if (!s) throw new Error(`${label}不能为空`)
|
||||||
|
if (max && s.length > max) throw new Error(`${label}不能超过 ${max} 字`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间标记: YYYY-MM-DD 或 ISO; null/'' 清空 */
|
||||||
|
export function parseMarkDate(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const d = new Date(v)
|
||||||
|
if (Number.isNaN(d.getTime())) throw new Error('时间标记格式非法')
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// MCP 服务 (Streamable HTTP, 无状态)
|
||||||
|
// 入口: POST/GET/DELETE /api/mcp
|
||||||
|
// 鉴权: Authorization: Bearer <个人 MCP Token>, 与用户绑定, 拥有该用户全部业务权限.
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||||
|
import { authenticateMcpToken } from './auth.js'
|
||||||
|
import { registerTaskToolsGroup } from './tools/tasks.js'
|
||||||
|
import { registerChecklistTools } from './tools/checklists.js'
|
||||||
|
import { registerCheckinTools } from './tools/checkin.js'
|
||||||
|
|
||||||
|
const SERVER_INFO = { name: 'iboard', version: '0.2.1' }
|
||||||
|
|
||||||
|
export function buildMcpServer(ctx) {
|
||||||
|
const server = new McpServer(SERVER_INFO, {
|
||||||
|
instructions: [
|
||||||
|
'本服务连接 iBoard 个人工作台, 当前 token 已绑定到具体用户。',
|
||||||
|
'可查询、新建、修改、删除、排序项目 / 看板列 / 任务, 以及检查清单和打卡。',
|
||||||
|
'所有写操作立即生效, 删除类操作不可恢复, 调用前请确认用户意图。',
|
||||||
|
'任务状态: todo / doing / done; 看板列可通过 boundStatus 与状态联动。',
|
||||||
|
].join('\n'),
|
||||||
|
})
|
||||||
|
registerTaskToolsGroup(server, ctx)
|
||||||
|
registerChecklistTools(server, ctx)
|
||||||
|
registerCheckinTools(server, ctx)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.all('/', async (req, res) => {
|
||||||
|
let auth
|
||||||
|
try {
|
||||||
|
auth = await authenticateMcpToken(req)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[mcp] token 校验失败:', e)
|
||||||
|
return res.status(500).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: req.body?.id ?? null,
|
||||||
|
error: { code: -32603, message: 'MCP 鉴权失败' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
res.set('WWW-Authenticate', 'Bearer realm="iboard-mcp"')
|
||||||
|
return res.status(401).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: req.body?.id ?? null,
|
||||||
|
error: {
|
||||||
|
code: -32001,
|
||||||
|
message: '未授权: 请在「我的 → AI 接入 (MCP)」创建 Token, 并使用 Authorization: Bearer <Token>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = buildMcpServer({ userId: auth.userId, user: auth.user })
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined,
|
||||||
|
enableJsonResponse: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.on('close', () => {
|
||||||
|
transport.close().catch(() => {})
|
||||||
|
server.close().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.connect(transport)
|
||||||
|
await transport.handleRequest(req, res, req.body)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[mcp] 请求处理失败:', e)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: req.body?.id ?? null,
|
||||||
|
error: { code: -32603, message: e?.message || 'MCP 处理失败' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// MCP 工具: 打卡 (打卡项 / 打卡记录)
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { prisma } from '../../db.js'
|
||||||
|
import { toDateStr, addDays, periodRange, PERIODS } from '../../checkin/period.js'
|
||||||
|
import { tool, reqStr } from '../helpers.js'
|
||||||
|
|
||||||
|
const BACKFILL_DAYS = 7
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
|
||||||
|
function normDate(v) {
|
||||||
|
const s = String(v || '')
|
||||||
|
return DATE_RE.test(s) ? s : toDateStr()
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCounts(records) {
|
||||||
|
const map = new Map()
|
||||||
|
for (const r of records) {
|
||||||
|
if (!map.has(r.habitId)) map.set(r.habitId, new Map())
|
||||||
|
const byDate = map.get(r.habitId)
|
||||||
|
byDate.set(r.date, (byDate.get(r.date) || 0) + 1)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumRange(byDate, start, end) {
|
||||||
|
let n = 0
|
||||||
|
let cursor = start
|
||||||
|
while (cursor <= end) {
|
||||||
|
n += byDate.get(cursor) || 0
|
||||||
|
cursor = addDays(cursor, 1)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCheckinTools(server, ctx) {
|
||||||
|
const userId = ctx.userId
|
||||||
|
|
||||||
|
tool(server, 'list_habits', {
|
||||||
|
title: '查询打卡项',
|
||||||
|
description: '列出打卡项及其指定日期所在周期的完成进度; 默认只返回未归档项。',
|
||||||
|
inputSchema: {
|
||||||
|
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||||
|
includeArchived: z.boolean().optional().describe('是否包含已归档打卡项, 默认 false'),
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async ({ date, includeArchived = false }) => {
|
||||||
|
const today = toDateStr()
|
||||||
|
const day = normDate(date)
|
||||||
|
const habits = await prisma.habit.findMany({
|
||||||
|
where: { userId, ...(includeArchived ? {} : { archived: false }) },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
if (!habits.length) return { date: day, today, items: [] }
|
||||||
|
|
||||||
|
const ranges = new Map(habits.map((h) => [h.id, periodRange(h.period, day)]))
|
||||||
|
let min = null
|
||||||
|
let max = null
|
||||||
|
for (const r of ranges.values()) {
|
||||||
|
if (!min || r.start < min) min = r.start
|
||||||
|
if (!max || r.end > max) max = r.end
|
||||||
|
}
|
||||||
|
const records = await prisma.checkIn.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
habitId: { in: habits.map((h) => h.id) },
|
||||||
|
date: { gte: min, lte: max },
|
||||||
|
},
|
||||||
|
select: { habitId: true, date: true },
|
||||||
|
})
|
||||||
|
const grouped = groupCounts(records)
|
||||||
|
|
||||||
|
const items = habits.map((h) => {
|
||||||
|
const range = ranges.get(h.id)
|
||||||
|
const byDate = grouped.get(h.id) || new Map()
|
||||||
|
const periodDone = sumRange(byDate, range.start, range.end)
|
||||||
|
const target = Math.max(1, h.target)
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
target,
|
||||||
|
periodStart: range.start,
|
||||||
|
periodEnd: range.end,
|
||||||
|
periodDone,
|
||||||
|
completed: periodDone >= target,
|
||||||
|
countOnDate: byDate.get(day) || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { date: day, today, items }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'create_habit', {
|
||||||
|
title: '新建打卡项',
|
||||||
|
description: '新建一个周期性打卡目标, 例如每天喝 8 杯水、每周运动 3 次。',
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().describe('打卡项名称, 最多 20 字'),
|
||||||
|
period: z.enum(PERIODS).optional().describe('周期: day/week/month/year, 默认 day'),
|
||||||
|
target: z.coerce.number().int().min(1).optional().describe('每周期目标次数, 默认 1'),
|
||||||
|
color: z.string().optional().describe('颜色, 默认 #1677ff'),
|
||||||
|
icon: z.string().optional().describe('图标 (emoji 或图标名)'),
|
||||||
|
note: z.string().optional().describe('备注'),
|
||||||
|
},
|
||||||
|
}, async ({ name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' }) => {
|
||||||
|
const cleanName = reqStr(name, '名称', 20)
|
||||||
|
if (!PERIODS.includes(period)) throw new Error('周期类型不合法')
|
||||||
|
const t = Math.floor(Number(target))
|
||||||
|
if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数')
|
||||||
|
|
||||||
|
const last = await prisma.habit.findFirst({
|
||||||
|
where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true },
|
||||||
|
})
|
||||||
|
const row = await prisma.habit.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: cleanName,
|
||||||
|
period,
|
||||||
|
target: t,
|
||||||
|
color: String(color),
|
||||||
|
icon: String(icon),
|
||||||
|
note: String(note || ''),
|
||||||
|
sortOrder: (last?.sortOrder ?? -1) + 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { item: row }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'update_habit', {
|
||||||
|
title: '修改打卡项',
|
||||||
|
description: '修改打卡项名称、周期、目标次数、颜色、图标、备注、归档状态或排序值。',
|
||||||
|
inputSchema: {
|
||||||
|
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||||
|
name: z.string().optional().describe('新名称, 最多 20 字'),
|
||||||
|
period: z.enum(PERIODS).optional().describe('新周期'),
|
||||||
|
target: z.coerce.number().int().min(1).optional().describe('新目标次数'),
|
||||||
|
color: z.string().optional().describe('新颜色'),
|
||||||
|
icon: z.string().optional().describe('新图标'),
|
||||||
|
note: z.string().optional().describe('新备注'),
|
||||||
|
archived: z.boolean().optional().describe('是否归档'),
|
||||||
|
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
||||||
|
},
|
||||||
|
}, async ({ habitId, name, period, target, color, icon, note, archived, sortOrder }) => {
|
||||||
|
const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||||
|
if (!exist) throw new Error('打卡项不存在')
|
||||||
|
const data = {}
|
||||||
|
if (name !== undefined) data.name = reqStr(name, '名称', 20)
|
||||||
|
if (period !== undefined) {
|
||||||
|
if (!PERIODS.includes(period)) throw new Error('周期类型不合法')
|
||||||
|
data.period = period
|
||||||
|
}
|
||||||
|
if (target !== undefined) {
|
||||||
|
const t = Math.floor(Number(target))
|
||||||
|
if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数')
|
||||||
|
data.target = t
|
||||||
|
}
|
||||||
|
if (color !== undefined) data.color = String(color)
|
||||||
|
if (icon !== undefined) data.icon = String(icon)
|
||||||
|
if (note !== undefined) data.note = String(note)
|
||||||
|
if (archived !== undefined) data.archived = !!archived
|
||||||
|
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
|
||||||
|
const row = await prisma.habit.update({ where: { id: Number(habitId) }, data })
|
||||||
|
return { item: row }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'delete_habit', {
|
||||||
|
title: '删除打卡项',
|
||||||
|
description: '默认归档并保留历史记录; hard=true 时永久删除打卡项及其全部打卡记录。',
|
||||||
|
inputSchema: {
|
||||||
|
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||||
|
hard: z.boolean().optional().describe('true 永久删除, 默认 false 仅归档'),
|
||||||
|
},
|
||||||
|
annotations: { destructiveHint: true },
|
||||||
|
}, async ({ habitId, hard = false }) => {
|
||||||
|
const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||||
|
if (!exist) throw new Error('打卡项不存在')
|
||||||
|
if (hard) {
|
||||||
|
await prisma.checkIn.deleteMany({ where: { habitId: Number(habitId) } })
|
||||||
|
await prisma.habit.delete({ where: { id: Number(habitId) } })
|
||||||
|
return { ok: true, removed: true }
|
||||||
|
}
|
||||||
|
const row = await prisma.habit.update({
|
||||||
|
where: { id: Number(habitId) }, data: { archived: true },
|
||||||
|
})
|
||||||
|
return { ok: true, removed: false, item: row }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'reorder_habits', {
|
||||||
|
title: '打卡项排序',
|
||||||
|
description: '按传入的打卡项 ID 数组重排, 数组顺序即新顺序。',
|
||||||
|
inputSchema: {
|
||||||
|
ids: z.array(z.coerce.number().int().positive()).describe('打卡项 ID 的新顺序'),
|
||||||
|
},
|
||||||
|
}, async ({ ids }) => {
|
||||||
|
const uniq = [...new Set(ids.map(Number))]
|
||||||
|
if (!uniq.length) throw new Error('ids 不能为空')
|
||||||
|
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
||||||
|
const rows = await prisma.habit.findMany({
|
||||||
|
where: { userId, id: { in: uniq } }, select: { id: true },
|
||||||
|
})
|
||||||
|
if (rows.length !== uniq.length) throw new Error('包含无效的打卡项')
|
||||||
|
await prisma.$transaction(
|
||||||
|
ids.map((hid, idx) => prisma.habit.update({
|
||||||
|
where: { id: Number(hid) }, data: { sortOrder: idx },
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
return { ok: true, orderedIds: ids.map(Number) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'check_habit', {
|
||||||
|
title: '打卡',
|
||||||
|
description: '为打卡项记录一次完成; 可为最近 7 天补打, 不能打未来日期。',
|
||||||
|
inputSchema: {
|
||||||
|
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||||
|
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||||
|
note: z.string().optional().describe('打卡备注, 最多 200 字'),
|
||||||
|
},
|
||||||
|
}, async ({ habitId, date, note }) => {
|
||||||
|
const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||||
|
if (!habit) throw new Error('打卡项不存在')
|
||||||
|
|
||||||
|
const today = toDateStr()
|
||||||
|
const day = normDate(date)
|
||||||
|
if (day > today) throw new Error('不能为未来日期打卡')
|
||||||
|
if (day < addDays(today, -BACKFILL_DAYS)) throw new Error(`最多只能补打最近 ${BACKFILL_DAYS} 天`)
|
||||||
|
|
||||||
|
const dayRecords = await prisma.checkIn.findMany({
|
||||||
|
where: { habitId: habit.id, date: day }, select: { seq: true },
|
||||||
|
})
|
||||||
|
const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1
|
||||||
|
const row = await prisma.checkIn.create({
|
||||||
|
data: {
|
||||||
|
habitId: habit.id,
|
||||||
|
userId,
|
||||||
|
date: day,
|
||||||
|
seq,
|
||||||
|
note: String(note || '').slice(0, 200),
|
||||||
|
source: day === today ? 'user' : 'backfill',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ok: true, date: day, seq, item: row }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'undo_habit_check', {
|
||||||
|
title: '撤销打卡',
|
||||||
|
description: '撤销指定日期所在周期内最近一次打卡记录。',
|
||||||
|
inputSchema: {
|
||||||
|
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||||
|
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||||
|
},
|
||||||
|
annotations: { destructiveHint: true },
|
||||||
|
}, async ({ habitId, date }) => {
|
||||||
|
const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||||
|
if (!habit) throw new Error('打卡项不存在')
|
||||||
|
|
||||||
|
const day = normDate(date)
|
||||||
|
const { start, end } = periodRange(habit.period, day)
|
||||||
|
const last = await prisma.checkIn.findFirst({
|
||||||
|
where: { habitId: habit.id, date: { gte: start, lte: end } },
|
||||||
|
orderBy: [{ date: 'desc' }, { seq: 'desc' }],
|
||||||
|
})
|
||||||
|
if (!last) throw new Error('本周期还没有打卡记录')
|
||||||
|
await prisma.checkIn.delete({ where: { id: last.id } })
|
||||||
|
return { ok: true, date: last.date, seq: last.seq }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// MCP 工具: 检查清单 (清单 / 检查项)
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { prisma } from '../../db.js'
|
||||||
|
import { ensureBuiltin } from '../../routes/checklists.js'
|
||||||
|
import { tool, reqStr } from '../helpers.js'
|
||||||
|
|
||||||
|
const LIST_INCLUDE = { items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }
|
||||||
|
|
||||||
|
function withProgress(list) {
|
||||||
|
const items = list.items || []
|
||||||
|
const done = items.filter((i) => i.done).length
|
||||||
|
return {
|
||||||
|
...list,
|
||||||
|
items,
|
||||||
|
total: items.length,
|
||||||
|
done,
|
||||||
|
percent: items.length ? done / items.length : 0,
|
||||||
|
completed: items.length > 0 && done === items.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireList(listId, userId) {
|
||||||
|
const list = await prisma.checkList.findFirst({ where: { id: Number(listId), userId } })
|
||||||
|
if (!list) throw new Error('清单不存在')
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSeedItems(items) {
|
||||||
|
return (Array.isArray(items) ? items : [])
|
||||||
|
.map((i) => (typeof i === 'string' ? { title: i } : i))
|
||||||
|
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
|
||||||
|
.filter((i) => i.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerChecklistTools(server, ctx) {
|
||||||
|
const userId = ctx.userId
|
||||||
|
|
||||||
|
tool(server, 'list_checklists', {
|
||||||
|
title: '查询清单',
|
||||||
|
description: '列出当前用户的检查清单 (出差/回老家等), 含完成进度。',
|
||||||
|
inputSchema: {
|
||||||
|
archived: z.boolean().optional().describe('true 查询已归档清单, 默认 false'),
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async ({ archived = false }) => {
|
||||||
|
const rows = await prisma.checkList.findMany({
|
||||||
|
where: { userId, archived: !!archived },
|
||||||
|
include: LIST_INCLUDE,
|
||||||
|
orderBy: [{ updatedAt: 'desc' }],
|
||||||
|
})
|
||||||
|
return { items: rows.map(withProgress) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'get_checklist', {
|
||||||
|
title: '查询清单详情',
|
||||||
|
description: '读取单个清单及其全部检查项、完成进度。',
|
||||||
|
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async ({ listId }) => {
|
||||||
|
const row = await prisma.checkList.findFirst({
|
||||||
|
where: { id: Number(listId), userId },
|
||||||
|
include: LIST_INCLUDE,
|
||||||
|
})
|
||||||
|
if (!row) throw new Error('清单不存在')
|
||||||
|
return { item: withProgress(row) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'create_checklist', {
|
||||||
|
title: '新建清单',
|
||||||
|
description: '新建检查清单, 可直接带检查项, 也可从系统模板/自建模板复制 (传 templateId)。',
|
||||||
|
inputSchema: {
|
||||||
|
title: z.string().describe('清单名称'),
|
||||||
|
scene: z.string().optional().describe('场景, 如 出差 / 回老家'),
|
||||||
|
note: z.string().optional().describe('备注'),
|
||||||
|
items: z.array(z.union([
|
||||||
|
z.string(),
|
||||||
|
z.object({ title: z.string(), note: z.string().optional() }),
|
||||||
|
])).optional().describe('初始检查项'),
|
||||||
|
templateId: z.coerce.number().int().positive().optional().describe('模板 ID, 在没有 items 时使用'),
|
||||||
|
},
|
||||||
|
}, async ({ title, scene = '', note = '', items = [], templateId }) => {
|
||||||
|
const cleanTitle = reqStr(title, '清单名称')
|
||||||
|
let seed = normalizeSeedItems(items)
|
||||||
|
|
||||||
|
if (!seed.length && templateId) {
|
||||||
|
await ensureBuiltin()
|
||||||
|
const tpl = await prisma.checkListTemplate.findFirst({
|
||||||
|
where: { id: Number(templateId), OR: [{ builtin: true }, { userId }] },
|
||||||
|
})
|
||||||
|
if (tpl && Array.isArray(tpl.items)) seed = normalizeSeedItems(tpl.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await prisma.checkList.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title: cleanTitle,
|
||||||
|
scene: String(scene || ''),
|
||||||
|
note: String(note || ''),
|
||||||
|
items: { create: seed.map((i, idx) => ({ title: i.title, note: i.note, sortOrder: idx })) },
|
||||||
|
},
|
||||||
|
include: LIST_INCLUDE,
|
||||||
|
})
|
||||||
|
return { item: withProgress(row) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'update_checklist', {
|
||||||
|
title: '修改清单',
|
||||||
|
description: '修改清单名称、场景、备注或归档状态。',
|
||||||
|
inputSchema: {
|
||||||
|
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||||
|
title: z.string().optional().describe('新名称'),
|
||||||
|
scene: z.string().optional().describe('新场景'),
|
||||||
|
note: z.string().optional().describe('新备注'),
|
||||||
|
archived: z.boolean().optional().describe('是否归档'),
|
||||||
|
},
|
||||||
|
}, async ({ listId, title, scene, note, archived }) => {
|
||||||
|
await requireList(listId, userId)
|
||||||
|
const data = {}
|
||||||
|
if (title !== undefined) data.title = reqStr(title, '清单名称')
|
||||||
|
if (scene !== undefined) data.scene = String(scene)
|
||||||
|
if (note !== undefined) data.note = String(note)
|
||||||
|
if (archived !== undefined) data.archived = !!archived
|
||||||
|
const row = await prisma.checkList.update({
|
||||||
|
where: { id: Number(listId) }, data, include: LIST_INCLUDE,
|
||||||
|
})
|
||||||
|
return { item: withProgress(row) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'delete_checklist', {
|
||||||
|
title: '删除清单',
|
||||||
|
description: '永久删除清单及其全部检查项。',
|
||||||
|
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||||
|
annotations: { destructiveHint: true },
|
||||||
|
}, async ({ listId }) => {
|
||||||
|
await requireList(listId, userId)
|
||||||
|
await prisma.checkListItem.deleteMany({ where: { listId: Number(listId) } })
|
||||||
|
await prisma.checkList.delete({ where: { id: Number(listId) } })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'duplicate_checklist', {
|
||||||
|
title: '复制清单',
|
||||||
|
description: '复制一份清单, 勾选状态重置。',
|
||||||
|
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||||
|
}, async ({ listId }) => {
|
||||||
|
const src = await prisma.checkList.findFirst({
|
||||||
|
where: { id: Number(listId), userId }, include: LIST_INCLUDE,
|
||||||
|
})
|
||||||
|
if (!src) throw new Error('清单不存在')
|
||||||
|
const row = await prisma.checkList.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title: src.title + ' 副本',
|
||||||
|
scene: src.scene,
|
||||||
|
note: src.note,
|
||||||
|
items: {
|
||||||
|
create: src.items.map((i, idx) => ({
|
||||||
|
title: i.title, note: i.note, done: false, sortOrder: idx,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: LIST_INCLUDE,
|
||||||
|
})
|
||||||
|
return { item: withProgress(row) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'add_checklist_item', {
|
||||||
|
title: '添加检查项',
|
||||||
|
description: '在清单末尾添加一个检查项。',
|
||||||
|
inputSchema: {
|
||||||
|
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||||
|
title: z.string().describe('检查项内容, 最多 40 字'),
|
||||||
|
note: z.string().optional().describe('备注'),
|
||||||
|
},
|
||||||
|
}, async ({ listId, title, note = '' }) => {
|
||||||
|
await requireList(listId, userId)
|
||||||
|
const cleanTitle = reqStr(title, '检查项', 40)
|
||||||
|
const max = await prisma.checkListItem.findFirst({
|
||||||
|
where: { listId: Number(listId) }, orderBy: { sortOrder: 'desc' },
|
||||||
|
})
|
||||||
|
const row = await prisma.checkListItem.create({
|
||||||
|
data: {
|
||||||
|
listId: Number(listId),
|
||||||
|
title: cleanTitle,
|
||||||
|
note: String(note || ''),
|
||||||
|
sortOrder: (max?.sortOrder ?? -1) + 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { item: row }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'update_checklist_item', {
|
||||||
|
title: '修改检查项',
|
||||||
|
description: '修改检查项内容、备注、完成状态或排序值。',
|
||||||
|
inputSchema: {
|
||||||
|
itemId: z.coerce.number().int().positive().describe('检查项 ID'),
|
||||||
|
title: z.string().optional().describe('新内容, 最多 40 字'),
|
||||||
|
note: z.string().optional().describe('新备注'),
|
||||||
|
done: z.boolean().optional().describe('是否完成'),
|
||||||
|
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
||||||
|
},
|
||||||
|
}, async ({ itemId, title, note, done, sortOrder }) => {
|
||||||
|
const exist = await prisma.checkListItem.findFirst({
|
||||||
|
where: { id: Number(itemId), list: { userId } },
|
||||||
|
})
|
||||||
|
if (!exist) throw new Error('检查项不存在')
|
||||||
|
const data = {}
|
||||||
|
if (title !== undefined) data.title = reqStr(title, '检查项', 40)
|
||||||
|
if (note !== undefined) data.note = String(note)
|
||||||
|
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
|
||||||
|
if (done !== undefined) {
|
||||||
|
data.done = !!done
|
||||||
|
data.doneAt = done ? new Date() : null
|
||||||
|
}
|
||||||
|
const row = await prisma.checkListItem.update({ where: { id: Number(itemId) }, data })
|
||||||
|
return { item: row }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'delete_checklist_item', {
|
||||||
|
title: '删除检查项',
|
||||||
|
description: '永久删除一个检查项。',
|
||||||
|
inputSchema: { itemId: z.coerce.number().int().positive().describe('检查项 ID') },
|
||||||
|
annotations: { destructiveHint: true },
|
||||||
|
}, async ({ itemId }) => {
|
||||||
|
const exist = await prisma.checkListItem.findFirst({
|
||||||
|
where: { id: Number(itemId), list: { userId } },
|
||||||
|
})
|
||||||
|
if (!exist) throw new Error('检查项不存在')
|
||||||
|
await prisma.checkListItem.delete({ where: { id: Number(itemId) } })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'reorder_checklist_items', {
|
||||||
|
title: '检查项排序',
|
||||||
|
description: '按传入的检查项 ID 数组重排清单内检查项, 数组顺序即新顺序。',
|
||||||
|
inputSchema: {
|
||||||
|
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||||
|
ids: z.array(z.coerce.number().int().positive()).describe('检查项 ID 的新顺序'),
|
||||||
|
},
|
||||||
|
}, async ({ listId, ids }) => {
|
||||||
|
await requireList(listId, userId)
|
||||||
|
const uniq = [...new Set(ids.map(Number))]
|
||||||
|
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
||||||
|
const rows = await prisma.checkListItem.findMany({
|
||||||
|
where: { listId: Number(listId), id: { in: uniq } }, select: { id: true },
|
||||||
|
})
|
||||||
|
if (rows.length !== uniq.length) throw new Error('包含不属于该清单的检查项')
|
||||||
|
await prisma.$transaction(
|
||||||
|
ids.map((itemId, idx) => prisma.checkListItem.update({
|
||||||
|
where: { id: Number(itemId) }, data: { sortOrder: idx },
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
return { ok: true, orderedIds: ids.map(Number) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'check_all_checklist_items', {
|
||||||
|
title: '清单一键勾选',
|
||||||
|
description: '把清单内全部检查项设为完成或未完成。',
|
||||||
|
inputSchema: {
|
||||||
|
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||||
|
done: z.boolean().describe('true 全部完成, false 全部取消'),
|
||||||
|
},
|
||||||
|
}, async ({ listId, done }) => {
|
||||||
|
await requireList(listId, userId)
|
||||||
|
await prisma.checkListItem.updateMany({
|
||||||
|
where: { listId: Number(listId) },
|
||||||
|
data: { done: !!done, doneAt: done ? new Date() : null },
|
||||||
|
})
|
||||||
|
const row = await prisma.checkList.findFirst({
|
||||||
|
where: { id: Number(listId) }, include: LIST_INCLUDE,
|
||||||
|
})
|
||||||
|
return { item: withProgress(row) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'list_checklist_templates', {
|
||||||
|
title: '查询清单模板',
|
||||||
|
description: '列出系统预置和当前用户自建的清单模板, 可配合 create_checklist 使用。',
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async () => {
|
||||||
|
await ensureBuiltin()
|
||||||
|
const rows = await prisma.checkListTemplate.findMany({
|
||||||
|
where: { OR: [{ builtin: true }, { userId }] },
|
||||||
|
orderBy: [{ builtin: 'desc' }, { name: 'asc' }],
|
||||||
|
})
|
||||||
|
return { items: rows }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,804 @@
|
|||||||
|
// MCP 工具: 任务模块 (项目 / 看板列 / 任务)
|
||||||
|
// 所有操作都以 token 绑定的用户身份执行, 复用任务域现有的权限与状态联动规则.
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { prisma } from '../../db.js'
|
||||||
|
import {
|
||||||
|
ensurePersonalProject, createDefaultColumns, normTaskKinds,
|
||||||
|
AUTO_ARCHIVE_OPTIONS, MAX_COLUMNS, DEFAULT_TASK_KINDS,
|
||||||
|
} from '../../routes/projects.js'
|
||||||
|
import { loadProjectForUser, loadTaskForUser, loadColumns, loadMemberIds } from '../../task-domain/access.js'
|
||||||
|
import { resolveTaskWrite } from '../../task-domain/resolveStatus.js'
|
||||||
|
import { copyTask } from '../../task-domain/copyTask.js'
|
||||||
|
import {
|
||||||
|
genUuid, withUuid, withTaskNumber, normProjectCode, allocProjectCode,
|
||||||
|
} from '../../task-domain/identity.js'
|
||||||
|
import { tool, reqStr, parseMarkDate } from '../helpers.js'
|
||||||
|
|
||||||
|
const MAX_NAME = 50
|
||||||
|
const MAX_COLUMN_NAME = 20
|
||||||
|
const STATUSES = ['todo', 'doing', 'done']
|
||||||
|
const RETENTIONS = ['never', 'today', '3d', 'week', 'month', 'forever']
|
||||||
|
|
||||||
|
const TASK_INCLUDE = {
|
||||||
|
column: true,
|
||||||
|
assignee: { select: { id: true, name: true } },
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
members: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
tags: true,
|
||||||
|
children: {
|
||||||
|
where: { archived: false },
|
||||||
|
include: {
|
||||||
|
column: true,
|
||||||
|
assignee: { select: { id: true, name: true } },
|
||||||
|
members: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeTask(t) {
|
||||||
|
if (!t) return t
|
||||||
|
const out = {
|
||||||
|
...t,
|
||||||
|
members: (t.members || []).map((m) => ({ userId: m.userId, name: m.user?.name })),
|
||||||
|
tags: (t.tags || []).map((tag) => tag.name),
|
||||||
|
}
|
||||||
|
if (Array.isArray(t.children)) out.children = t.children.map(shapeTask)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeProject(p) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
uuid: p.uuid,
|
||||||
|
code: p.code,
|
||||||
|
name: p.name,
|
||||||
|
ownerId: p.ownerId,
|
||||||
|
isPersonal: p.isPersonal,
|
||||||
|
archived: p.archived,
|
||||||
|
notes: Array.isArray(p.notes) ? p.notes : [],
|
||||||
|
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
||||||
|
autoArchive: p.autoArchive,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
memberCount: p._count?.members,
|
||||||
|
taskCount: p._count?.tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireProject(projectId, userId) {
|
||||||
|
const project = await loadProjectForUser(projectId, userId)
|
||||||
|
if (!project) throw new Error('项目不存在或无权限')
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotes(notes) {
|
||||||
|
if (!Array.isArray(notes)) throw new Error('项目信息块格式非法')
|
||||||
|
return notes.slice(0, 50).map((n) => ({
|
||||||
|
title: String(n?.title ?? '').slice(0, 50),
|
||||||
|
content: String(n?.content ?? '').slice(0, 20000),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 项目 ----------
|
||||||
|
|
||||||
|
function registerProjectTools(server, ctx) {
|
||||||
|
const userId = ctx.userId
|
||||||
|
|
||||||
|
tool(server, 'get_current_user', {
|
||||||
|
title: '查询当前用户',
|
||||||
|
description: '返回当前 MCP Token 绑定的用户信息, 用于确认操作身份。',
|
||||||
|
inputSchema: {},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async () => ({ user: ctx.user || { id: userId } }))
|
||||||
|
|
||||||
|
tool(server, 'list_projects', {
|
||||||
|
title: '查询项目',
|
||||||
|
description: '列出当前用户参与的全部项目 (含个人默认项目), 返回项目编号、名称、成员数、未归档任务数。',
|
||||||
|
inputSchema: {
|
||||||
|
includeArchived: z.boolean().optional().describe('是否包含已归档项目, 默认 false'),
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async ({ includeArchived = false }) => {
|
||||||
|
await ensurePersonalProject(userId)
|
||||||
|
const rows = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
members: { some: { userId } },
|
||||||
|
...(includeArchived ? {} : { archived: false }),
|
||||||
|
},
|
||||||
|
include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } },
|
||||||
|
orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
|
||||||
|
})
|
||||||
|
return { items: rows.map(shapeProject) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'get_project', {
|
||||||
|
title: '查询项目详情',
|
||||||
|
description: '读取单个项目的完整信息: 基本信息、成员、看板列、信息块。',
|
||||||
|
inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') },
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async ({ projectId }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
const [members, columns] = await Promise.all([
|
||||||
|
prisma.projectMember.findMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}),
|
||||||
|
loadColumns(project.id),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
...shapeProject(project),
|
||||||
|
members: members.map((m) => ({
|
||||||
|
userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission,
|
||||||
|
})),
|
||||||
|
columns,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'create_project', {
|
||||||
|
title: '新建项目',
|
||||||
|
description: '新建一个项目并自动创建默认看板列 (Plan/Doing/Action/Done), 创建者自动成为成员。',
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().describe('项目名称, 最多 50 字'),
|
||||||
|
code: z.string().optional().describe('项目短编号 2~8 位字母数字, 不填则按名称自动分配'),
|
||||||
|
},
|
||||||
|
}, async ({ name, code }) => {
|
||||||
|
const cleanName = reqStr(name, '项目名称', MAX_NAME)
|
||||||
|
let finalCode
|
||||||
|
if (code !== undefined && String(code).trim() !== '') {
|
||||||
|
finalCode = normProjectCode(code)
|
||||||
|
if (!finalCode) throw new Error('项目编号须为 2~8 位字母数字')
|
||||||
|
const dup = await prisma.project.findUnique({ where: { code: finalCode } })
|
||||||
|
if (dup) throw new Error(`项目编号 ${finalCode} 已被占用`)
|
||||||
|
} else {
|
||||||
|
finalCode = await allocProjectCode(cleanName)
|
||||||
|
}
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
name: cleanName,
|
||||||
|
ownerId: userId,
|
||||||
|
isPersonal: false,
|
||||||
|
uuid: genUuid(),
|
||||||
|
code: finalCode,
|
||||||
|
members: { create: { uuid: genUuid(), userId } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await createDefaultColumns(project.id)
|
||||||
|
return { item: shapeProject(project) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'update_project', {
|
||||||
|
title: '修改项目',
|
||||||
|
description: '修改项目名称、短编号、归档状态、信息块、任务类型标签、自动归档档位。',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||||
|
name: z.string().optional().describe('新项目名称, 最多 50 字'),
|
||||||
|
code: z.string().optional().describe('新项目短编号 2~8 位字母数字'),
|
||||||
|
archived: z.boolean().optional().describe('是否归档'),
|
||||||
|
notes: z.array(z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
})).optional().describe('项目信息块, 整体替换'),
|
||||||
|
taskKinds: z.array(z.string()).optional().describe('任务类型标签, 整体替换, 至少保留一个'),
|
||||||
|
autoArchive: z.enum(AUTO_ARCHIVE_OPTIONS).optional().describe('自动归档: 3d/1w/1m/never'),
|
||||||
|
},
|
||||||
|
}, async ({ projectId, name, code, archived, notes, taskKinds, autoArchive }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
const data = {}
|
||||||
|
if (name !== undefined) data.name = reqStr(name, '项目名称', MAX_NAME)
|
||||||
|
if (code !== undefined) {
|
||||||
|
const c = normProjectCode(code)
|
||||||
|
if (!c) throw new Error('项目编号须为 2~8 位字母数字')
|
||||||
|
if (c !== project.code) {
|
||||||
|
const dup = await prisma.project.findUnique({ where: { code: c } })
|
||||||
|
if (dup) throw new Error(`项目编号 ${c} 已被占用`)
|
||||||
|
data.code = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notes !== undefined) data.notes = normalizeNotes(notes)
|
||||||
|
if (taskKinds !== undefined) {
|
||||||
|
const kinds = normTaskKinds(taskKinds)
|
||||||
|
if (!kinds?.length) throw new Error('至少保留一个任务类型')
|
||||||
|
data.taskKinds = kinds
|
||||||
|
}
|
||||||
|
if (autoArchive !== undefined) data.autoArchive = autoArchive
|
||||||
|
if (archived !== undefined) data.archived = !!archived
|
||||||
|
const row = await prisma.project.update({ where: { id: project.id }, data })
|
||||||
|
return { item: shapeProject(row) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'delete_project', {
|
||||||
|
title: '删除项目',
|
||||||
|
description: '删除项目及其全部看板列和任务。个人默认项目不可删除。',
|
||||||
|
inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') },
|
||||||
|
annotations: { destructiveHint: true },
|
||||||
|
}, async ({ projectId }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
if (project.isPersonal) throw new Error('个人默认项目不可删除')
|
||||||
|
await prisma.project.delete({ where: { id: project.id } })
|
||||||
|
return { ok: true, deletedProjectId: project.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'add_project_member', {
|
||||||
|
title: '添加项目成员',
|
||||||
|
description: '按用户 ID 或邮箱把用户加入项目。个人默认项目不可添加成员。',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||||
|
userId: z.coerce.number().int().positive().optional().describe('目标用户 ID'),
|
||||||
|
email: z.string().optional().describe('目标用户邮箱'),
|
||||||
|
},
|
||||||
|
}, async ({ projectId, userId: targetId, email }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
if (project.isPersonal) throw new Error('个人默认项目不可添加成员')
|
||||||
|
if (!targetId && !email) throw new Error('userId 与 email 至少提供一个')
|
||||||
|
|
||||||
|
const target = targetId
|
||||||
|
? await prisma.user.findUnique({ where: { id: Number(targetId) } })
|
||||||
|
: await prisma.user.findFirst({ where: { email: String(email).trim() } })
|
||||||
|
if (!target) throw new Error('用户不存在')
|
||||||
|
|
||||||
|
const exist = await prisma.projectMember.findUnique({
|
||||||
|
where: { projectId_userId: { projectId: project.id, userId: target.id } },
|
||||||
|
})
|
||||||
|
if (exist) throw new Error('该用户已是项目成员')
|
||||||
|
|
||||||
|
await prisma.projectMember.create({
|
||||||
|
data: { uuid: genUuid(), projectId: project.id, userId: target.id },
|
||||||
|
})
|
||||||
|
return { ok: true, member: { userId: target.id, name: target.name, email: target.email } }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'remove_project_member', {
|
||||||
|
title: '移除项目成员',
|
||||||
|
description: '把成员移出项目; 成员本人也可用此工具退出项目。个人默认项目不可移除成员。',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||||
|
userId: z.coerce.number().int().positive().describe('要移除的用户 ID'),
|
||||||
|
},
|
||||||
|
annotations: { destructiveHint: true },
|
||||||
|
}, async ({ projectId, userId: targetId }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
if (project.isPersonal) throw new Error('个人默认项目不可移除成员')
|
||||||
|
const target = Number(targetId)
|
||||||
|
if (target !== userId && project.ownerId !== userId) {
|
||||||
|
throw new Error('只能移除自己,或由创建人移除他人')
|
||||||
|
}
|
||||||
|
await prisma.projectMember.deleteMany({ where: { projectId: project.id, userId: target } })
|
||||||
|
return { ok: true, removedUserId: target }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 看板列 ----------
|
||||||
|
|
||||||
|
function registerColumnTools(server, ctx) {
|
||||||
|
const userId = ctx.userId
|
||||||
|
|
||||||
|
tool(server, 'list_columns', {
|
||||||
|
title: '查询看板列',
|
||||||
|
description: '列出项目全部看板列 (按显示顺序), 含绑定状态与完成保留选项。',
|
||||||
|
inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') },
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async ({ projectId }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
return { items: await loadColumns(project.id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'create_column', {
|
||||||
|
title: '新建看板列',
|
||||||
|
description: `在项目末尾新建看板列, 最多 ${MAX_COLUMNS} 列。`,
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||||
|
name: z.string().describe('列名, 最多 20 字'),
|
||||||
|
boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态 todo/doing/done, null 表示不联动'),
|
||||||
|
retention: z.enum(RETENTIONS).optional().describe('完成保留选项, 默认 forever'),
|
||||||
|
},
|
||||||
|
}, async ({ projectId, name, boundStatus = null, retention = 'forever' }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
const cleanName = reqStr(name, '列名', MAX_COLUMN_NAME)
|
||||||
|
const cols = await loadColumns(project.id)
|
||||||
|
if (cols.length >= MAX_COLUMNS) throw new Error(`最多 ${MAX_COLUMNS} 列`)
|
||||||
|
if (cols.some((c) => c.name === cleanName)) throw new Error('列名已存在')
|
||||||
|
if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法')
|
||||||
|
if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法')
|
||||||
|
|
||||||
|
const row = await prisma.boardColumn.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
uuid: genUuid(),
|
||||||
|
name: cleanName,
|
||||||
|
sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0,
|
||||||
|
boundStatus,
|
||||||
|
retention,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { item: row }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'update_column', {
|
||||||
|
title: '修改看板列',
|
||||||
|
description: '修改列名、绑定状态或完成保留选项。',
|
||||||
|
inputSchema: {
|
||||||
|
columnId: z.coerce.number().int().positive().describe('看板列 ID'),
|
||||||
|
name: z.string().optional().describe('新列名, 最多 20 字'),
|
||||||
|
boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态, null 表示取消绑定'),
|
||||||
|
retention: z.enum(RETENTIONS).optional().describe('完成保留选项'),
|
||||||
|
},
|
||||||
|
}, async ({ columnId, name, boundStatus, retention }) => {
|
||||||
|
const col = await prisma.boardColumn.findUnique({ where: { id: Number(columnId) } })
|
||||||
|
if (!col) throw new Error('列不存在')
|
||||||
|
const project = await requireProject(col.projectId, userId)
|
||||||
|
|
||||||
|
const data = {}
|
||||||
|
if (name !== undefined) {
|
||||||
|
const t = reqStr(name, '列名', MAX_COLUMN_NAME)
|
||||||
|
const dup = await prisma.boardColumn.findFirst({
|
||||||
|
where: { projectId: project.id, name: t, NOT: { id: col.id } },
|
||||||
|
})
|
||||||
|
if (dup) throw new Error('列名已存在')
|
||||||
|
data.name = t
|
||||||
|
}
|
||||||
|
if (boundStatus !== undefined) {
|
||||||
|
if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法')
|
||||||
|
data.boundStatus = boundStatus
|
||||||
|
}
|
||||||
|
if (retention !== undefined) {
|
||||||
|
if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法')
|
||||||
|
data.retention = retention
|
||||||
|
}
|
||||||
|
const row = await prisma.boardColumn.update({ where: { id: col.id }, data })
|
||||||
|
return { item: row }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'delete_column', {
|
||||||
|
title: '删除看板列',
|
||||||
|
description: '删除看板列并把列内任务迁移到目标列 (不传则迁移到相邻列)。至少保留一列。',
|
||||||
|
inputSchema: {
|
||||||
|
columnId: z.coerce.number().int().positive().describe('看板列 ID'),
|
||||||
|
moveToColumnId: z.coerce.number().int().positive().optional().describe('任务迁移目标列 ID'),
|
||||||
|
},
|
||||||
|
annotations: { destructiveHint: true },
|
||||||
|
}, async ({ columnId, moveToColumnId }) => {
|
||||||
|
const col = await prisma.boardColumn.findUnique({ where: { id: Number(columnId) } })
|
||||||
|
if (!col) throw new Error('列不存在')
|
||||||
|
const project = await requireProject(col.projectId, userId)
|
||||||
|
|
||||||
|
const cols = await loadColumns(project.id)
|
||||||
|
if (cols.length <= 1) throw new Error('至少保留一列')
|
||||||
|
|
||||||
|
let targetId = Number(moveToColumnId) || 0
|
||||||
|
if (!targetId) {
|
||||||
|
const idx = cols.findIndex((c) => c.id === col.id)
|
||||||
|
targetId = (cols[idx + 1] || cols[idx - 1]).id
|
||||||
|
}
|
||||||
|
const target = cols.find((c) => c.id === targetId)
|
||||||
|
if (!target || target.id === col.id) throw new Error('迁移目标列非法')
|
||||||
|
|
||||||
|
const tasks = await prisma.task.findMany({ where: { columnId: col.id } })
|
||||||
|
const newStatus = target.boundStatus || null
|
||||||
|
await prisma.task.updateMany({
|
||||||
|
where: { columnId: col.id },
|
||||||
|
data: { columnId: target.id, ...(newStatus ? { status: newStatus } : {}) },
|
||||||
|
})
|
||||||
|
await prisma.boardColumn.delete({ where: { id: col.id } })
|
||||||
|
return { ok: true, moved: tasks.length, toColumnId: target.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'reorder_columns', {
|
||||||
|
title: '看板列排序',
|
||||||
|
description: '按传入的列 ID 数组重排项目全部看板列, 数组顺序即新顺序。',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||||
|
ids: z.array(z.coerce.number().int().positive()).describe('全部看板列 ID 的新顺序'),
|
||||||
|
},
|
||||||
|
}, async ({ projectId, ids }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
const cols = await loadColumns(project.id)
|
||||||
|
const uniq = [...new Set(ids.map(Number))]
|
||||||
|
if (uniq.length !== cols.length || uniq.length !== ids.length) {
|
||||||
|
throw new Error('必须传入项目全部看板列, 且不能重复')
|
||||||
|
}
|
||||||
|
const colSet = new Set(cols.map((c) => c.id))
|
||||||
|
if (uniq.some((eid) => !colSet.has(eid))) throw new Error('包含不属于该项目的看板列')
|
||||||
|
await prisma.$transaction(
|
||||||
|
ids.map((eid, idx) => prisma.boardColumn.update({ where: { id: Number(eid) }, data: { sortOrder: idx } })),
|
||||||
|
)
|
||||||
|
return { items: await loadColumns(project.id) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 任务 ----------
|
||||||
|
|
||||||
|
function registerTaskTools(server, ctx) {
|
||||||
|
const userId = ctx.userId
|
||||||
|
|
||||||
|
tool(server, 'list_tasks', {
|
||||||
|
title: '查询任务',
|
||||||
|
description: '查询项目任务列表 (默认只看未归档顶层任务, 含子任务), 支持按列、状态、关键词过滤。',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||||
|
columnId: z.coerce.number().int().positive().optional().describe('只看某一列'),
|
||||||
|
status: z.enum(STATUSES).optional().describe('只看某一状态'),
|
||||||
|
keyword: z.string().optional().describe('标题关键词'),
|
||||||
|
includeArchived: z.boolean().optional().describe('是否包含已归档任务, 默认 false'),
|
||||||
|
limit: z.coerce.number().int().positive().max(500).optional().describe('最多返回条数, 默认 200'),
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async ({ projectId, columnId, status, keyword, includeArchived = false, limit = 200 }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
const columns = await loadColumns(project.id)
|
||||||
|
const where = { projectId: project.id, parentId: null }
|
||||||
|
if (!includeArchived) where.archived = false
|
||||||
|
if (columnId) where.columnId = Number(columnId)
|
||||||
|
if (status) where.status = status
|
||||||
|
if (keyword && String(keyword).trim()) where.title = { contains: String(keyword).trim() }
|
||||||
|
|
||||||
|
const rows = await prisma.task.findMany({
|
||||||
|
where,
|
||||||
|
include: TASK_INCLUDE,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
const order = new Map(columns.map((c, i) => [c.id, i]))
|
||||||
|
rows.sort((a, b) => (
|
||||||
|
(order.get(a.columnId) ?? 9999) - (order.get(b.columnId) ?? 9999)
|
||||||
|
|| a.sortOrder - b.sortOrder
|
||||||
|
|| a.id - b.id
|
||||||
|
))
|
||||||
|
return { columns, items: rows.map(shapeTask), total: rows.length }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'get_task', {
|
||||||
|
title: '查询任务详情',
|
||||||
|
description: '读取单个任务的完整信息, 含看板列、负责人、创建者、成员、标签和子任务。',
|
||||||
|
inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') },
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
}, async ({ taskId }) => {
|
||||||
|
const found = await loadTaskForUser(taskId, userId)
|
||||||
|
if (!found) throw new Error('任务不存在或无权限')
|
||||||
|
return { item: shapeTask(found.task) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'create_task', {
|
||||||
|
title: '新建任务',
|
||||||
|
description: '在项目中新建任务或子任务, 可指定列、状态、负责人、成员、标签、类型和时间标记。',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||||
|
title: z.string().describe('任务标题, 最多 30 字'),
|
||||||
|
description: z.string().optional().describe('任务描述'),
|
||||||
|
columnId: z.coerce.number().int().positive().optional().describe('看板列 ID, 默认第一列'),
|
||||||
|
status: z.enum(STATUSES).optional().describe('任务状态, 默认跟随看板列绑定'),
|
||||||
|
parentId: z.coerce.number().int().positive().optional().describe('父任务 ID (仅一级子任务)'),
|
||||||
|
assigneeId: z.coerce.number().int().positive().nullable().optional().describe('负责人用户 ID; 不传默认创建者, null 表示不指派'),
|
||||||
|
memberIds: z.array(z.coerce.number().int().positive()).optional().describe('任务成员用户 ID 列表'),
|
||||||
|
tags: z.array(z.string()).optional().describe('标签列表'),
|
||||||
|
kind: z.string().optional().describe('任务类型标签, 最多 20 字'),
|
||||||
|
markDate: z.string().nullable().optional().describe('时间标记 YYYY-MM-DD'),
|
||||||
|
extras: z.array(z.object({
|
||||||
|
label: z.string().optional(),
|
||||||
|
value: z.string().optional(),
|
||||||
|
url: z.string().optional(),
|
||||||
|
})).optional().describe('扩展信息卡片'),
|
||||||
|
sortOrder: z.coerce.number().int().optional().describe('排序值, 不传则追加到列末尾'),
|
||||||
|
},
|
||||||
|
}, async (args) => {
|
||||||
|
const project = await requireProject(args.projectId, userId)
|
||||||
|
const title = reqStr(args.title, '任务标题', 30)
|
||||||
|
const columns = await loadColumns(project.id)
|
||||||
|
if (!columns.length) throw new Error('项目没有可用的看板列')
|
||||||
|
|
||||||
|
let pid = null
|
||||||
|
if (args.parentId) {
|
||||||
|
const parent = await prisma.task.findUnique({ where: { id: Number(args.parentId) } })
|
||||||
|
if (!parent) throw new Error('父任务不存在')
|
||||||
|
if (parent.projectId !== project.id) throw new Error('子任务必须与父任务同项目')
|
||||||
|
if (parent.parentId) throw new Error('仅支持一级子任务')
|
||||||
|
pid = parent.id
|
||||||
|
}
|
||||||
|
|
||||||
|
let columnId = args.columnId ? Number(args.columnId) : columns[0].id
|
||||||
|
let targetCol = columns.find((c) => c.id === columnId)
|
||||||
|
if (!targetCol) throw new Error('看板列不存在')
|
||||||
|
let status = args.status || targetCol.boundStatus || 'todo'
|
||||||
|
if (args.status !== undefined) {
|
||||||
|
const r = resolveTaskWrite(
|
||||||
|
{ status: targetCol.boundStatus || 'todo', columnId, parentId: pid, column: targetCol },
|
||||||
|
{ status: args.status },
|
||||||
|
columns,
|
||||||
|
)
|
||||||
|
status = r.status
|
||||||
|
columnId = r.columnId
|
||||||
|
targetCol = columns.find((c) => c.id === columnId) || targetCol
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = await loadMemberIds(project.id)
|
||||||
|
const memberSet = new Set(memberIds)
|
||||||
|
let finalAssignee = null
|
||||||
|
if (args.assigneeId === undefined) finalAssignee = memberSet.has(userId) ? userId : null
|
||||||
|
else if (args.assigneeId !== null && args.assigneeId !== '') {
|
||||||
|
const aid = Number(args.assigneeId)
|
||||||
|
if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员')
|
||||||
|
finalAssignee = aid
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortOrder = args.sortOrder
|
||||||
|
if (sortOrder === undefined) {
|
||||||
|
const agg = await prisma.task.aggregate({
|
||||||
|
where: { projectId: project.id, columnId, parentId: pid },
|
||||||
|
_max: { sortOrder: true },
|
||||||
|
})
|
||||||
|
sortOrder = (agg._max.sortOrder ?? -1) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskData = {
|
||||||
|
uuid: genUuid(),
|
||||||
|
projectId: project.id,
|
||||||
|
columnId,
|
||||||
|
parentId: pid,
|
||||||
|
title,
|
||||||
|
description: String(args.description || ''),
|
||||||
|
kind: args.kind ? String(args.kind).trim().slice(0, 20) : null,
|
||||||
|
markDate: parseMarkDate(args.markDate),
|
||||||
|
status,
|
||||||
|
doneAt: status === 'done' ? new Date() : null,
|
||||||
|
assigneeId: finalAssignee,
|
||||||
|
creatorId: userId,
|
||||||
|
extras: (Array.isArray(args.extras) ? args.extras : []).map((e) => ({
|
||||||
|
label: String(e?.label ?? '').slice(0, 40),
|
||||||
|
value: String(e?.value ?? ''),
|
||||||
|
url: e?.url ? String(e.url) : '',
|
||||||
|
})),
|
||||||
|
sortOrder,
|
||||||
|
members: {
|
||||||
|
create: withUuid((Array.isArray(args.memberIds) ? args.memberIds : [])
|
||||||
|
.map(Number).filter((uid) => memberSet.has(uid))
|
||||||
|
.map((uid) => ({ userId: uid }))),
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
create: withUuid((Array.isArray(args.tags) ? args.tags : [])
|
||||||
|
.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
|
||||||
|
.map((name) => ({ name: name.slice(0, 20) }))),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const { value: row } = await withTaskNumber(project.id, (number) =>
|
||||||
|
prisma.task.create({ data: { ...taskData, number }, include: TASK_INCLUDE }),
|
||||||
|
)
|
||||||
|
return { item: shapeTask(row) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'update_task', {
|
||||||
|
title: '修改任务',
|
||||||
|
description: '修改任务标题、描述、列、状态、负责人、成员、标签、类型、时间标记、父任务或排序值。',
|
||||||
|
inputSchema: {
|
||||||
|
taskId: z.coerce.number().int().positive().describe('任务 ID'),
|
||||||
|
title: z.string().optional().describe('新标题, 最多 30 字'),
|
||||||
|
description: z.string().optional().describe('新描述'),
|
||||||
|
columnId: z.coerce.number().int().positive().optional().describe('移动到看板列'),
|
||||||
|
status: z.enum(STATUSES).optional().describe('任务状态'),
|
||||||
|
assigneeId: z.coerce.number().int().positive().nullable().optional().describe('负责人用户 ID, null 取消指派'),
|
||||||
|
memberIds: z.array(z.coerce.number().int().positive()).optional().describe('任务成员, 整体替换'),
|
||||||
|
tags: z.array(z.string()).optional().describe('标签, 整体替换'),
|
||||||
|
kind: z.string().nullable().optional().describe('任务类型标签, null 清空'),
|
||||||
|
markDate: z.string().nullable().optional().describe('时间标记 YYYY-MM-DD, null 清空'),
|
||||||
|
extras: z.array(z.object({
|
||||||
|
label: z.string().optional(),
|
||||||
|
value: z.string().optional(),
|
||||||
|
url: z.string().optional(),
|
||||||
|
})).optional().describe('扩展信息, 整体替换'),
|
||||||
|
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
||||||
|
parentId: z.coerce.number().int().positive().nullable().optional().describe('父任务 ID, null 取消父子关系'),
|
||||||
|
archived: z.boolean().optional().describe('归档/恢复任务'),
|
||||||
|
},
|
||||||
|
}, async (args) => {
|
||||||
|
const found = await loadTaskForUser(args.taskId, userId)
|
||||||
|
if (!found) throw new Error('任务不存在或无权限')
|
||||||
|
const { task, project } = found
|
||||||
|
const columns = await loadColumns(project.id)
|
||||||
|
const data = {}
|
||||||
|
|
||||||
|
if (args.title !== undefined) data.title = reqStr(args.title, '任务标题', 30)
|
||||||
|
if (args.description !== undefined) data.description = String(args.description)
|
||||||
|
if (args.kind !== undefined) {
|
||||||
|
const k = String(args.kind || '').trim().slice(0, 20)
|
||||||
|
data.kind = k || null
|
||||||
|
}
|
||||||
|
if (args.markDate !== undefined) data.markDate = parseMarkDate(args.markDate)
|
||||||
|
if (args.extras !== undefined) {
|
||||||
|
data.extras = args.extras.map((e) => ({
|
||||||
|
label: String(e?.label ?? '').slice(0, 40),
|
||||||
|
value: String(e?.value ?? ''),
|
||||||
|
url: e?.url ? String(e.url) : '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (args.sortOrder !== undefined) data.sortOrder = Number(args.sortOrder) || 0
|
||||||
|
if (args.archived !== undefined) {
|
||||||
|
data.archived = !!args.archived
|
||||||
|
data.archivedAt = args.archived ? new Date() : null
|
||||||
|
data.archiveSource = args.archived ? 'manual' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = await loadMemberIds(project.id)
|
||||||
|
const memberSet = new Set(memberIds)
|
||||||
|
if (args.assigneeId !== undefined) {
|
||||||
|
if (args.assigneeId === null || args.assigneeId === '') data.assigneeId = null
|
||||||
|
else {
|
||||||
|
const aid = Number(args.assigneeId)
|
||||||
|
if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员')
|
||||||
|
data.assigneeId = aid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = {}
|
||||||
|
if (args.columnId !== undefined) patch.columnId = Number(args.columnId)
|
||||||
|
if (args.status !== undefined) patch.status = args.status
|
||||||
|
if (patch.columnId !== undefined && !columns.some((c) => c.id === patch.columnId)) {
|
||||||
|
throw new Error('看板列不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalStatus = task.status
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
const r = resolveTaskWrite(task, patch, columns)
|
||||||
|
data.status = r.status
|
||||||
|
data.columnId = r.columnId
|
||||||
|
finalStatus = r.status
|
||||||
|
}
|
||||||
|
if (finalStatus === 'done' && task.status !== 'done') data.doneAt = new Date()
|
||||||
|
else if (finalStatus !== 'done' && data.status !== undefined) data.doneAt = null
|
||||||
|
|
||||||
|
if (Array.isArray(args.memberIds)) {
|
||||||
|
const ids = args.memberIds.map(Number).filter((uid) => memberSet.has(uid))
|
||||||
|
await prisma.taskMember.deleteMany({ where: { taskId: task.id } })
|
||||||
|
if (ids.length) {
|
||||||
|
await prisma.taskMember.createMany({
|
||||||
|
data: withUuid(ids.map((uid) => ({ taskId: task.id, userId: uid }))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(args.tags)) {
|
||||||
|
await prisma.taskTag.deleteMany({ where: { taskId: task.id } })
|
||||||
|
const names = args.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
|
||||||
|
if (names.length) {
|
||||||
|
await prisma.taskTag.createMany({
|
||||||
|
data: withUuid(names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) }))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.parentId !== undefined) {
|
||||||
|
if (args.parentId === null) data.parentId = null
|
||||||
|
else {
|
||||||
|
const pid = Number(args.parentId)
|
||||||
|
if (pid === task.id) throw new Error('不能成为自己的子任务')
|
||||||
|
const parent = await prisma.task.findUnique({ where: { id: pid } })
|
||||||
|
if (!parent) throw new Error('目标任务不存在')
|
||||||
|
if (parent.projectId !== task.projectId) throw new Error('子任务必须与父任务同项目')
|
||||||
|
if (parent.parentId) throw new Error('仅支持一级子任务')
|
||||||
|
const childCount = await prisma.task.count({ where: { parentId: task.id } })
|
||||||
|
if (childCount > 0) throw new Error('该任务已有子任务,不能成为子任务')
|
||||||
|
data.parentId = pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = await prisma.task.update({ where: { id: task.id }, data, include: TASK_INCLUDE })
|
||||||
|
|
||||||
|
// 归档/恢复连同子任务, 与 archive_task / unarchive_task 口径一致
|
||||||
|
if (args.archived !== undefined) {
|
||||||
|
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
|
||||||
|
if (children.length) {
|
||||||
|
await prisma.task.updateMany({
|
||||||
|
where: { id: { in: children.map((c) => c.id) } },
|
||||||
|
data: {
|
||||||
|
archived: !!args.archived,
|
||||||
|
archivedAt: args.archived ? new Date() : null,
|
||||||
|
archiveSource: args.archived ? 'manual' : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 子任务状态变化后重取, 保证返回值与库内一致
|
||||||
|
row = await prisma.task.findUnique({ where: { id: task.id }, include: TASK_INCLUDE })
|
||||||
|
}
|
||||||
|
return { item: shapeTask(row) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'delete_task', {
|
||||||
|
title: '删除任务',
|
||||||
|
description: '永久删除任务; 其子任务会一并级联删除。',
|
||||||
|
inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') },
|
||||||
|
annotations: { destructiveHint: true },
|
||||||
|
}, async ({ taskId }) => {
|
||||||
|
const found = await loadTaskForUser(taskId, userId)
|
||||||
|
if (!found) throw new Error('任务不存在或无权限')
|
||||||
|
const childCount = await prisma.task.count({ where: { parentId: found.task.id } })
|
||||||
|
await prisma.task.delete({ where: { id: found.task.id } })
|
||||||
|
return { ok: true, deletedSubtasks: childCount }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'archive_task', {
|
||||||
|
title: '归档任务',
|
||||||
|
description: '归档任务并连带全部子任务; 已归档时重复调用无副作用。',
|
||||||
|
inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') },
|
||||||
|
}, async ({ taskId }) => {
|
||||||
|
const found = await loadTaskForUser(taskId, userId)
|
||||||
|
if (!found) throw new Error('任务不存在或无权限')
|
||||||
|
const children = await prisma.task.findMany({ where: { parentId: found.task.id }, select: { id: true } })
|
||||||
|
const r = await prisma.task.updateMany({
|
||||||
|
where: { id: { in: [found.task.id, ...children.map((c) => c.id)] } },
|
||||||
|
data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' },
|
||||||
|
})
|
||||||
|
return { ok: true, count: r.count }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'unarchive_task', {
|
||||||
|
title: '恢复任务',
|
||||||
|
description: '把归档任务恢复到原列, 状态不变; 连带恢复子任务。',
|
||||||
|
inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') },
|
||||||
|
}, async ({ taskId }) => {
|
||||||
|
const found = await loadTaskForUser(taskId, userId)
|
||||||
|
if (!found) throw new Error('任务不存在或无权限')
|
||||||
|
const children = await prisma.task.findMany({ where: { parentId: found.task.id }, select: { id: true } })
|
||||||
|
const r = await prisma.task.updateMany({
|
||||||
|
where: { id: { in: [found.task.id, ...children.map((c) => c.id)] } },
|
||||||
|
data: { archived: false, archivedAt: null, archiveSource: null },
|
||||||
|
})
|
||||||
|
return { ok: true, count: r.count }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'reorder_tasks', {
|
||||||
|
title: '任务排序',
|
||||||
|
description: '按传入的任务 ID 数组重写排序 (数组顺序即新顺序)。传 columnId 时只重排该列内的任务。',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||||
|
ids: z.array(z.coerce.number().int().positive()).describe('任务 ID 的新顺序'),
|
||||||
|
columnId: z.coerce.number().int().positive().optional().describe('仅重排该看板列内的任务'),
|
||||||
|
},
|
||||||
|
}, async ({ projectId, ids, columnId }) => {
|
||||||
|
const project = await requireProject(projectId, userId)
|
||||||
|
const uniq = [...new Set(ids.map(Number))]
|
||||||
|
if (!uniq.length) throw new Error('ids 不能为空')
|
||||||
|
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
||||||
|
|
||||||
|
const where = { projectId: project.id, id: { in: uniq } }
|
||||||
|
if (columnId) where.columnId = Number(columnId)
|
||||||
|
else where.parentId = null
|
||||||
|
const rows = await prisma.task.findMany({ where, select: { id: true } })
|
||||||
|
if (rows.length !== uniq.length) throw new Error('包含无效的任务或不属于指定列')
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
ids.map((tid, idx) => prisma.task.update({ where: { id: Number(tid) }, data: { sortOrder: idx } })),
|
||||||
|
)
|
||||||
|
return { ok: true, orderedIds: ids.map(Number) }
|
||||||
|
})
|
||||||
|
|
||||||
|
tool(server, 'copy_task', {
|
||||||
|
title: '拷贝任务',
|
||||||
|
description: '把任务及子任务拷贝到另一个项目; 非目标项目成员会被自动丢弃。',
|
||||||
|
inputSchema: {
|
||||||
|
taskId: z.coerce.number().int().positive().describe('源任务 ID'),
|
||||||
|
projectId: z.coerce.number().int().positive().describe('目标项目 ID'),
|
||||||
|
},
|
||||||
|
}, async ({ taskId, projectId }) => {
|
||||||
|
const found = await loadTaskForUser(taskId, userId)
|
||||||
|
if (!found) throw new Error('任务不存在或无权限')
|
||||||
|
const targetProject = await requireProject(projectId, userId)
|
||||||
|
const targetColumns = await loadColumns(targetProject.id)
|
||||||
|
if (!targetColumns.length) throw new Error('目标项目没有可用的看板列')
|
||||||
|
const targetMemberIds = await loadMemberIds(targetProject.id)
|
||||||
|
const sourceColumns = await loadColumns(found.project.id)
|
||||||
|
const source = { ...found.task, __columns: sourceColumns }
|
||||||
|
|
||||||
|
const result = await copyTask({
|
||||||
|
source, targetProject, targetColumns, operatorId: userId, targetMemberIds,
|
||||||
|
})
|
||||||
|
const fresh = await prisma.task.findUnique({ where: { id: result.task.id }, include: TASK_INCLUDE })
|
||||||
|
return { item: shapeTask(fresh), dropped: result.dropped }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerTaskToolsGroup(server, ctx) {
|
||||||
|
registerProjectTools(server, ctx)
|
||||||
|
registerColumnTools(server, ctx)
|
||||||
|
registerTaskTools(server, ctx)
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// apizero 代理路由 (前端不直接调 apizero, 一律走后端, key 不外泄)
|
|
||||||
//
|
|
||||||
// GET /api/apizero/oil/forecast - 油价 + 预测 (实时)
|
|
||||||
// GET /api/apizero/oil/price-all - 32 省油价
|
|
||||||
// GET /api/apizero/oil/price?province= - 单省油价
|
|
||||||
// GET /api/apizero/oil/schedule?year= - 某年调价日历 (按年缓存)
|
|
||||||
//
|
|
||||||
// 错误: apizero 错误统一包成 502 (上游错误) 或 400 (参数错误)
|
|
||||||
import { Router } from 'express'
|
|
||||||
import { authRequired } from '../middleware.js'
|
|
||||||
import { ApizeroError, oil, history } from '../apizero/index.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
router.use(authRequired)
|
|
||||||
|
|
||||||
function handleApizeroError(e, res, next) {
|
|
||||||
if (e instanceof ApizeroError) {
|
|
||||||
// 参数错 -> 400, 上游错 -> 502, QPS 限流 / 额度用完 -> 503
|
|
||||||
if (e.httpStatus === 400) return res.status(400).json({ error: e.message, apizero_code: e.code })
|
|
||||||
if (e.code === 4029 || e.code === 4030) return res.status(503).json({ error: e.message, apizero_code: e.code })
|
|
||||||
return res.status(502).json({ error: e.message, apizero_code: e.code })
|
|
||||||
}
|
|
||||||
next(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实时: 油价 + 国际原油 + 下次调价预测
|
|
||||||
router.get('/apizero/oil/forecast', async (req, res, next) => {
|
|
||||||
try { res.json(await oil.getForecast()) }
|
|
||||||
catch (e) { handleApizeroError(e, res, next) }
|
|
||||||
})
|
|
||||||
|
|
||||||
// 32 省油价
|
|
||||||
router.get('/apizero/oil/price-all', async (req, res, next) => {
|
|
||||||
try { res.json(await oil.getPriceAll()) }
|
|
||||||
catch (e) { handleApizeroError(e, res, next) }
|
|
||||||
})
|
|
||||||
|
|
||||||
// 单省油价
|
|
||||||
router.get('/apizero/oil/price', async (req, res, next) => {
|
|
||||||
try { res.json(await oil.getPrice(req.query.province)) }
|
|
||||||
catch (e) { handleApizeroError(e, res, next) }
|
|
||||||
})
|
|
||||||
|
|
||||||
// 调价日历 (按年缓存)
|
|
||||||
router.get('/apizero/oil/schedule', async (req, res, next) => {
|
|
||||||
try { res.json(await oil.getSchedule(req.query.year)) }
|
|
||||||
catch (e) { handleApizeroError(e, res, next) }
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
|
|
||||||
// 浙江省油价历史 (DB)
|
|
||||||
// GET /api/apizero/oil/history?fuel=92&days=180
|
|
||||||
// - fuel: 92 / 95 / 0 (留空 = 3 个油品都返回)
|
|
||||||
// - days: 默认 180 (半年)
|
|
||||||
router.get('/apizero/oil/history', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const fuel = req.query.fuel ? String(req.query.fuel) : undefined
|
|
||||||
const days = Math.max(1, Math.min(Number(req.query.days) || 180, 730))
|
|
||||||
const items = await history.query({ fuel, days })
|
|
||||||
res.json({ items, fuel: fuel || 'all', days })
|
|
||||||
} catch (e) { next(e) }
|
|
||||||
})
|
|
||||||
@@ -122,11 +122,15 @@ router.get('/habits', async (req, res, next) => {
|
|||||||
const userId = req.user.sub
|
const userId = req.user.sub
|
||||||
const today = toDateStr()
|
const today = toDateStr()
|
||||||
const date = normDate(req.query.date)
|
const date = normDate(req.query.date)
|
||||||
const habits = await loadActiveHabits(userId)
|
const habits = (await loadActiveHabits(userId))
|
||||||
|
// 回看历史日期时, 当天还不存在的打卡项不应出现 (与 /calendar 口径一致)
|
||||||
|
.filter((h) => toDateStr(h.createdAt) <= date)
|
||||||
const ranges = habits.map((h) => periodRange(h.period, date))
|
const ranges = habits.map((h) => periodRange(h.period, date))
|
||||||
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
|
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
|
||||||
const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today))
|
const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today))
|
||||||
const doneCount = items.filter((i) => i.completed).length
|
// summary 是"当天进度": 按当日份额 (countOnDate >= expectedPerDay) 统计,
|
||||||
|
// 不能用 completed (周期完成度), 否则历史日期会把同周期后续日期的打卡算进来。
|
||||||
|
const doneCount = items.filter((i) => i.dayDone).length
|
||||||
res.json({ today, date, items, summary: { total: items.length, done: doneCount } })
|
res.json({ today, date, items, summary: { total: items.length, done: doneCount } })
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
@@ -291,7 +295,14 @@ router.get('/calendar', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user.sub
|
const userId = req.user.sub
|
||||||
const today = toDateStr()
|
const today = toDateStr()
|
||||||
const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7)
|
// 兼容旧版客户端: addMonths 曾把 "YYYY-MM" 算成 "YYYY-MM-01", 请求月份带了日号,
|
||||||
|
// 旧逻辑判为非法后静默回退到本月, 导致切到上月时看不到任何记录。这里截回月份。
|
||||||
|
const rawMonth = String(req.query.month || '')
|
||||||
|
const month = MONTH_RE.test(rawMonth)
|
||||||
|
? rawMonth
|
||||||
|
: DATE_RE.test(rawMonth)
|
||||||
|
? rawMonth.slice(0, 7)
|
||||||
|
: today.slice(0, 7)
|
||||||
const start = month + '-01'
|
const start = month + '-01'
|
||||||
const end = addDays(monthStartNext(month), -1)
|
const end = addDays(monthStartNext(month), -1)
|
||||||
const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] })
|
const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] })
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const BUILTIN_TEMPLATES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async function ensureBuiltin() {
|
export async function ensureBuiltin() {
|
||||||
const count = await prisma.checkListTemplate.count({ where: { builtin: true } })
|
const count = await prisma.checkListTemplate.count({ where: { builtin: true } })
|
||||||
if (count >= BUILTIN_TEMPLATES.length) return
|
if (count >= BUILTIN_TEMPLATES.length) return
|
||||||
for (const t of BUILTIN_TEMPLATES) {
|
for (const t of BUILTIN_TEMPLATES) {
|
||||||
|
|||||||
+2
-31
@@ -6,36 +6,7 @@ const router = Router()
|
|||||||
|
|
||||||
router.use(authRequired)
|
router.use(authRequired)
|
||||||
|
|
||||||
// 指标列表: 元数据 + 明细点, 过滤 hidden (数据与脚本保留, 仅不展示)
|
// 能繁母猪存栏 (独立抓取域, 不属于宏观「经济数据」外接模块)
|
||||||
router.get('/indicators', async (_req, res, next) => {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.indicator.findMany({
|
|
||||||
where: { hidden: false },
|
|
||||||
include: { points: { orderBy: { period: 'asc' } } },
|
|
||||||
orderBy: [{ groupName: 'asc' }, { name: 'asc' }],
|
|
||||||
})
|
|
||||||
// 组装前端友好结构: points 保持从老到新
|
|
||||||
const indicators = rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
name: r.name,
|
|
||||||
groupName: r.groupName,
|
|
||||||
title: r.title,
|
|
||||||
unit: r.unit,
|
|
||||||
source: r.source,
|
|
||||||
frequency: r.frequency,
|
|
||||||
points: r.points.map((p) => ({ period: p.period, value: p.value })),
|
|
||||||
}))
|
|
||||||
res.json({ indicators })
|
|
||||||
} catch (e) { next(e) }
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get('/strategies', async (_req, res, next) => {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.strategy.findMany({ orderBy: { name: 'asc' } })
|
|
||||||
res.json({ strategies: rows })
|
|
||||||
} catch (e) { next(e) }
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get('/sow', async (_req, res, next) => {
|
router.get('/sow', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const rows = await prisma.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
|
const rows = await prisma.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
|
||||||
@@ -43,4 +14,4 @@ router.get('/sow', async (_req, res, next) => {
|
|||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// AI 接入 (MCP): 个人授权 token 管理 (登录用户本人)
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
import { authRequired } from '../middleware.js'
|
||||||
|
import { generateMcpToken, hashMcpToken } from '../mcp/auth.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(authRequired)
|
||||||
|
router.use((_req, res, next) => {
|
||||||
|
res.set('Cache-Control', 'no-store')
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
const NAME_MAX = 32
|
||||||
|
|
||||||
|
function publicToken(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
prefix: row.prefix,
|
||||||
|
lastUsedAt: row.lastUsedAt,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanName(v) {
|
||||||
|
const name = String(v || '').trim()
|
||||||
|
if (!name) return { error: 'Token 名称不能为空' }
|
||||||
|
if (name.length > NAME_MAX) return { error: `Token 名称不能超过 ${NAME_MAX} 字` }
|
||||||
|
return { name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/mcp-tokens 当前用户的全部 token (不含明文)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.mcpToken.findMany({
|
||||||
|
where: { userId: req.user.sub },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
res.json({ items: rows.map(publicToken) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/mcp-tokens { name } 新建 token, 明文仅在此响应中返回一次
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const { name, error } = cleanName(req.body?.name)
|
||||||
|
if (error) return res.status(400).json({ error })
|
||||||
|
|
||||||
|
const dup = await prisma.mcpToken.findFirst({ where: { userId, name } })
|
||||||
|
if (dup) return res.status(409).json({ error: `Token 名称「${name}」已存在` })
|
||||||
|
|
||||||
|
const raw = generateMcpToken()
|
||||||
|
const row = await prisma.mcpToken.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
prefix: raw.slice(0, 12),
|
||||||
|
tokenHash: hashMcpToken(raw),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res.json({ item: publicToken(row), token: raw })
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'P2002') return res.status(409).json({ error: 'Token 名称已存在' })
|
||||||
|
next(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/mcp-tokens/:id { name } 重命名
|
||||||
|
router.patch('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const { name, error } = cleanName(req.body?.name)
|
||||||
|
if (error) return res.status(400).json({ error })
|
||||||
|
|
||||||
|
const exist = await prisma.mcpToken.findFirst({ where: { id, userId } })
|
||||||
|
if (!exist) return res.status(404).json({ error: 'Token 不存在' })
|
||||||
|
|
||||||
|
const dup = await prisma.mcpToken.findFirst({ where: { userId, name, NOT: { id } } })
|
||||||
|
if (dup) return res.status(409).json({ error: `Token 名称「${name}」已存在` })
|
||||||
|
|
||||||
|
const row = await prisma.mcpToken.update({ where: { id }, data: { name } })
|
||||||
|
res.json({ item: publicToken(row) })
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'P2002') return res.status(409).json({ error: 'Token 名称已存在' })
|
||||||
|
next(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/mcp-tokens/:id 删除后立即失效
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const exist = await prisma.mcpToken.findFirst({ where: { id, userId } })
|
||||||
|
if (!exist) return res.status(404).json({ error: 'Token 不存在' })
|
||||||
|
await prisma.mcpToken.delete({ where: { id } })
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -7,6 +7,7 @@ import jwt from 'jsonwebtoken'
|
|||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
import { JWT_SECRET, authRequired } from '../middleware.js'
|
import { JWT_SECRET, authRequired } from '../middleware.js'
|
||||||
import { genUuid, normProjectCode, allocProjectCode, withTaskNumber } from '../task-domain/identity.js'
|
import { genUuid, normProjectCode, allocProjectCode, withTaskNumber } from '../task-domain/identity.js'
|
||||||
|
import { resolveTaskUpload } from '../task-domain/resolveStatus.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -96,6 +97,17 @@ async function applyPut(op, ctx) {
|
|||||||
if (d.taskKinds !== undefined) {
|
if (d.taskKinds !== undefined) {
|
||||||
data.taskKinds = Array.isArray(d.taskKinds) ? d.taskKinds.map((k) => String(k).slice(0, 20)).filter(Boolean) : null
|
data.taskKinds = Array.isArray(d.taskKinds) ? d.taskKinds.map((k) => String(k).slice(0, 20)).filter(Boolean) : null
|
||||||
}
|
}
|
||||||
|
// 项目图标: data URL (客户端已裁剪为 96x96 JPEG); 校验口径同 REST 写接口
|
||||||
|
if (d.icon !== undefined) {
|
||||||
|
if (d.icon === null || d.icon === '') {
|
||||||
|
data.icon = null
|
||||||
|
} else {
|
||||||
|
const s = String(d.icon)
|
||||||
|
if (!s.startsWith('data:image/')) throw new Error('图标格式非法')
|
||||||
|
if (s.length > 300_000) throw new Error('图标过大')
|
||||||
|
data.icon = s
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Object.keys(data).length) await prisma.project.update({ where: { uuid }, data })
|
if (Object.keys(data).length) await prisma.project.update({ where: { uuid }, data })
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -183,18 +195,43 @@ async function applyPut(op, ctx) {
|
|||||||
if (d.extras !== undefined) data.extras = cleanExtras(d.extras)
|
if (d.extras !== undefined) data.extras = cleanExtras(d.extras)
|
||||||
if (d.sortOrder !== undefined) data.sortOrder = Number(d.sortOrder) || 0
|
if (d.sortOrder !== undefined) data.sortOrder = Number(d.sortOrder) || 0
|
||||||
if (d.assigneeId !== undefined) data.assigneeId = d.assigneeId ? Number(d.assigneeId) : null
|
if (d.assigneeId !== undefined) data.assigneeId = d.assigneeId ? Number(d.assigneeId) : null
|
||||||
if (d.doneAt !== undefined) data.doneAt = isoTime(d.doneAt)
|
|
||||||
// 归档状态 (T-ARC): 客户端本地归档/恢复走这里
|
// 归档状态 (T-ARC): 客户端本地归档/恢复走这里
|
||||||
if (d.archived !== undefined) data.archived = !!d.archived
|
if (d.archived !== undefined) data.archived = !!d.archived
|
||||||
if (d.archivedAt !== undefined) data.archivedAt = isoTime(d.archivedAt)
|
if (d.archivedAt !== undefined) data.archivedAt = isoTime(d.archivedAt)
|
||||||
if (d.archiveSource !== undefined) {
|
if (d.archiveSource !== undefined) {
|
||||||
data.archiveSource = ['manual', 'auto'].includes(d.archiveSource) ? d.archiveSource : null
|
data.archiveSource = ['manual', 'auto'].includes(d.archiveSource) ? d.archiveSource : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 列 / 状态: 复用 REST 写接口的同一裁决口径 (server/task-domain/resolveStatus.js)
|
||||||
|
// 客户端上行的是本地整行 (含 status 与 columnId), 变更检测与裁决
|
||||||
|
// 统一封装在 resolveTaskUpload 里 (那边有详细注释与用例)
|
||||||
|
let nextColumnId
|
||||||
if (d.columnId !== undefined) {
|
if (d.columnId !== undefined) {
|
||||||
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } })
|
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } })
|
||||||
if (!col || col.projectId !== existing.projectId) throw new Error('看板列不存在')
|
if (!col || col.projectId !== existing.projectId) throw new Error('看板列不存在')
|
||||||
data.columnId = col.id
|
nextColumnId = col.id
|
||||||
}
|
}
|
||||||
|
if (d.status !== undefined && !TASK_STATUSES.includes(d.status)) {
|
||||||
|
throw new Error('任务状态取值非法')
|
||||||
|
}
|
||||||
|
const resolved = resolveTaskUpload(
|
||||||
|
existing,
|
||||||
|
{ columnId: nextColumnId, status: d.status },
|
||||||
|
await ctx.loadColumns(existing.projectId),
|
||||||
|
)
|
||||||
|
if (resolved) {
|
||||||
|
data.status = resolved.status
|
||||||
|
data.columnId = resolved.columnId
|
||||||
|
// doneAt 与最终状态保持一致 (口径同 REST 写接口): 进入 done 记时, 离开 done 清空
|
||||||
|
if (resolved.status === 'done') {
|
||||||
|
if (existing.status !== 'done') data.doneAt = isoTime(d.doneAt) || new Date()
|
||||||
|
} else {
|
||||||
|
data.doneAt = null
|
||||||
|
}
|
||||||
|
} else if (d.doneAt !== undefined) {
|
||||||
|
data.doneAt = isoTime(d.doneAt)
|
||||||
|
}
|
||||||
|
|
||||||
if (d.parentId !== undefined) {
|
if (d.parentId !== undefined) {
|
||||||
if (!d.parentId) {
|
if (!d.parentId) {
|
||||||
data.parentId = null
|
data.parentId = null
|
||||||
@@ -355,7 +392,19 @@ router.post('/upload', authRequired, async (req, res, next) => {
|
|||||||
memberCache.set(projectId, ok)
|
memberCache.set(projectId, ok)
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
const ctx = { userId, assertMember }
|
// 项目列缓存: 列/状态裁决需要全部列; 同批内按项目缓存 (ops 已按表序排列,
|
||||||
|
// BoardColumn 先于 Task 落地, 故 Task 阶段读到的列是本次批次的最终结果)
|
||||||
|
const columnCache = new Map()
|
||||||
|
const loadColumns = async (projectId) => {
|
||||||
|
if (columnCache.has(projectId)) return columnCache.get(projectId)
|
||||||
|
const rows = await prisma.boardColumn.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
columnCache.set(projectId, rows)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
const ctx = { userId, assertMember, loadColumns }
|
||||||
|
|
||||||
// 依赖顺序: 父表先落地 (Project → BoardColumn → ProjectMember → Task → TaskMember/TaskTag); 删除反向
|
// 依赖顺序: 父表先落地 (Project → BoardColumn → ProjectMember → Task → TaskMember/TaskTag); 删除反向
|
||||||
const rank = (o) => TABLE_RANK[o?.table] ?? 99
|
const rank = (o) => TABLE_RANK[o?.table] ?? 99
|
||||||
|
|||||||
@@ -83,3 +83,32 @@ export function resolveTaskWrite(task, patch, columns) {
|
|||||||
|
|
||||||
return { status, columnId }
|
return { status, columnId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 离线同步上传路径专用: 客户端上行的是**本地整行**, 字段总是存在, 因此不能用
|
||||||
|
* "字段是否存在"判断变更 —— 必须先与服务端现值比较, 得出"这次真正改了什么",
|
||||||
|
* 再交给 resolveTaskWrite 裁决。
|
||||||
|
*
|
||||||
|
* 不这样做会踩的坑 (T-BD-16): 任务从 Plan 拖到未绑定状态的 Action 列, 客户端
|
||||||
|
* 整行里 status 仍是 todo; 若把 status 当作"本次变更"提交, 场景 B 会把任务从
|
||||||
|
* Action 归位回第一个绑定 todo 的 Plan —— 用户拖到 Action 又被弹回去。
|
||||||
|
*
|
||||||
|
* @param {object} existing 服务端现有任务 (含 columnId/status/parentId)
|
||||||
|
* @param {object} incoming 本次上行值 { columnId?: number, status?: string }
|
||||||
|
* 外键需调用方先由 uuid 解析为内部 int id
|
||||||
|
* @param {object[]} columns 项目全部列
|
||||||
|
* @returns {{ status: string, columnId: number } | null} null = 列/状态无变化
|
||||||
|
*/
|
||||||
|
export function resolveTaskUpload(existing, incoming, columns) {
|
||||||
|
const nextColumnId = incoming?.columnId !== undefined ? incoming.columnId : existing.columnId
|
||||||
|
const patch = {}
|
||||||
|
if (nextColumnId !== existing.columnId) patch.columnId = nextColumnId
|
||||||
|
if (incoming?.status !== undefined && incoming.status !== existing.status) patch.status = incoming.status
|
||||||
|
if (!Object.keys(patch).length) return null
|
||||||
|
|
||||||
|
// task.column 必须与 columnId 一起指向目标列, 否则场景 B 的
|
||||||
|
// "当前列已匹配该状态" 会拿旧列判断, 同样导致误归位
|
||||||
|
const targetCol = (columns || []).find((c) => c.id === nextColumnId) || null
|
||||||
|
const r = resolveTaskWrite({ ...existing, columnId: nextColumnId, column: targetCol }, patch, columns)
|
||||||
|
return { status: r.status, columnId: r.columnId }
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob: asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://iboard.honor3.com http://bh.vps.honor3.com:3001 https://bh.vps.honor3.com:3001; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob: asset: http://asset.localhost https://*.honor3.com http://*.honor3.com; connect-src 'self' ipc: http://ipc.localhost https://iboard.honor3.com http://bh.vps.honor3.com:3001 https://bh.vps.honor3.com:3001 https://ifinance.honor3.com http://ifinance.honor3.com https://*.honor3.com https://*.honor3.com:* http://*.honor3.com http://*.honor3.com:*; frame-src 'self' https://ifinance.honor3.com http://ifinance.honor3.com https://*.honor3.com https://*.honor3.com:* http://*.honor3.com http://*.honor3.com:*; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
+5
-1
@@ -9,6 +9,7 @@ import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
|||||||
import TaskPage from './pages/task/TaskPage.jsx'
|
import TaskPage from './pages/task/TaskPage.jsx'
|
||||||
import { useAuth } from './auth/AuthContext.jsx'
|
import { useAuth } from './auth/AuthContext.jsx'
|
||||||
import { PowerSyncProvider } from './lib/powersync/react.jsx'
|
import { PowerSyncProvider } from './lib/powersync/react.jsx'
|
||||||
|
import useKeepInputVisible from './hooks/useKeepInputVisible.js'
|
||||||
|
|
||||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||||
const HOME_PATH = '/checkin'
|
const HOME_PATH = '/checkin'
|
||||||
@@ -31,6 +32,9 @@ function RequireAuth({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// 全局: 输入框聚焦时滚进可视区 (登录页与内页都覆盖)
|
||||||
|
useKeepInputVisible()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerSyncProvider>
|
<PowerSyncProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -43,7 +47,7 @@ export default function App() {
|
|||||||
<Route path="/task" element={<TaskPage />} />
|
<Route path="/task" element={<TaskPage />} />
|
||||||
<Route path="/task/list" element={<TaskPage />} />
|
<Route path="/task/list" element={<TaskPage />} />
|
||||||
<Route path="/task/:pid" element={<TaskPage />} />
|
<Route path="/task/:pid" element={<TaskPage />} />
|
||||||
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
|
{/* 宏观: 新闻 / 经济文章 + 外接 ifinance 分类 (动态 tab); 报警已迁至监控 (V0.2.2) */}
|
||||||
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
||||||
<Route path="/macro/*" element={<MacroPage />} />
|
<Route path="/macro/*" element={<MacroPage />} />
|
||||||
<Route path="/monitor" element={<MonitorPage />} />
|
<Route path="/monitor" element={<MonitorPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// AI 接入 (MCP): 个人授权 token 管理
|
||||||
|
import { api, getApiBase } from './client.js'
|
||||||
|
|
||||||
|
export const mcpTokenApi = {
|
||||||
|
list: () => api('/mcp-tokens'),
|
||||||
|
create: (name) => api('/mcp-tokens', { method: 'POST', body: { name } }),
|
||||||
|
rename: (id, name) => api(`/mcp-tokens/${id}`, { method: 'PATCH', body: { name } }),
|
||||||
|
remove: (id) => api(`/mcp-tokens/${id}`, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MCP 端点完整地址 (随 API 基址变化) */
|
||||||
|
export function getMcpEndpoint() {
|
||||||
|
const base = getApiBase()
|
||||||
|
if (base) return base.replace(/\/+$/, '') + '/api/mcp'
|
||||||
|
if (typeof window !== 'undefined') return window.location.origin + '/api/mcp'
|
||||||
|
return '/api/mcp'
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// 前端油价 API: 一律走后端 /api/apizero/oil/*, 不直接调 apizero (key 不外泄)
|
|
||||||
import { api, getApiBase } from './client.js'
|
|
||||||
|
|
||||||
// 实时: 油价 + 国际原油 + 下次调价预测
|
|
||||||
export async function getOilForecast() {
|
|
||||||
return api('/apizero/oil/forecast')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 32 省油价 (可暂未在 UI 用, 留作 API 暴露)
|
|
||||||
export async function getOilPriceAll() {
|
|
||||||
return api('/apizero/oil/price-all')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单省油价
|
|
||||||
export async function getOilPrice(province) {
|
|
||||||
return api('/apizero/oil/price?province=' + encodeURIComponent(province))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 某年调价日历 (后端按年缓存, 同年份不会重复调 apizero)
|
|
||||||
export async function getOilSchedule(year) {
|
|
||||||
return api('/apizero/oil/schedule?year=' + encodeURIComponent(year))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 浙江省油价历史 (走 DB, 由 cron 每天 04:00 抓取并落库)
|
|
||||||
// fuel: '92' | '95' | '0' | undefined (留空 = 3 个油品)
|
|
||||||
// days: 默认 180 (半年)
|
|
||||||
export async function getOilHistory({ fuel, days = 180 } = {}) {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (fuel) qs.set('fuel', fuel)
|
|
||||||
qs.set('days', String(days))
|
|
||||||
return api('/apizero/oil/history?' + qs.toString())
|
|
||||||
}
|
|
||||||
+42
-9
@@ -35,6 +35,13 @@ const DEFAULT_COLUMNS = [
|
|||||||
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
|
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
|
||||||
const MAX_NAME = 20
|
const MAX_NAME = 20
|
||||||
|
|
||||||
|
// 即时同步: 订阅用的本地表集合 (见 docs/V0.2/离线编辑模块/即时同步方案.md)
|
||||||
|
// TASK_TABLES 影响"某项目的任务列表/看板"的内容
|
||||||
|
// PROJECT_TABLES 影响"项目列表"的内容
|
||||||
|
// 只用于变更通知, 不参与查询; 表名必须与 PowerSync 客户端 schema 一致。
|
||||||
|
export const TASK_TABLES = ['Task', 'BoardColumn', 'TaskMember', 'TaskTag']
|
||||||
|
export const PROJECT_TABLES = ['Project', 'ProjectMember']
|
||||||
|
|
||||||
function normTaskKinds(v) {
|
function normTaskKinds(v) {
|
||||||
if (!Array.isArray(v)) return null
|
if (!Array.isArray(v)) return null
|
||||||
const out = []
|
const out = []
|
||||||
@@ -135,6 +142,8 @@ function projectSummary(p, memberCount, taskCount) {
|
|||||||
updatedAt: p.updatedAt,
|
updatedAt: p.updatedAt,
|
||||||
taskKinds: normTaskKinds(parseJson(p.taskKinds, null)) || DEFAULT_TASK_KINDS,
|
taskKinds: normTaskKinds(parseJson(p.taskKinds, null)) || DEFAULT_TASK_KINDS,
|
||||||
icon: p.icon || null,
|
icon: p.icon || null,
|
||||||
|
// 自动归档档位 (T-ARC-02): 必须回传给前端, 否则该设置在界面上永远显示默认的「永不」
|
||||||
|
autoArchive: p.autoArchive || 'never',
|
||||||
memberCount,
|
memberCount,
|
||||||
taskCount,
|
taskCount,
|
||||||
}
|
}
|
||||||
@@ -268,6 +277,11 @@ export const projectApi = {
|
|||||||
content: String(n?.content ?? '').slice(0, 20000),
|
content: String(n?.content ?? '').slice(0, 20000),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
if (body?.autoArchive !== undefined) {
|
||||||
|
// 自动归档档位 (T-ARC-02): 与服务端 AUTO_ARCHIVE_OPTIONS 同口径
|
||||||
|
if (!['3d', '1w', '1m', 'never'].includes(body.autoArchive)) throw new Error('自动归档档位非法')
|
||||||
|
data.autoArchive = body.autoArchive
|
||||||
|
}
|
||||||
const sets = Object.keys(data).map((k) => `"${k}" = ?`)
|
const sets = Object.keys(data).map((k) => `"${k}" = ?`)
|
||||||
if (sets.length) {
|
if (sets.length) {
|
||||||
await db().execute(
|
await db().execute(
|
||||||
@@ -536,10 +550,17 @@ export const taskApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 列 / 状态联动裁决 (与 server/task-domain/resolveStatus.js 同口径)
|
// 列 / 状态联动裁决 (与 server/task-domain/resolveStatus.js 同口径)
|
||||||
|
// 本地库主键是 uuid 字符串: 若调用方把 id 过了一道 Number() 会变成 NaN / "NaN",
|
||||||
|
// 这里直接拦下并给出可定位的错误, 而不是让后续比对静默失败
|
||||||
let status = task.status
|
let status = task.status
|
||||||
let columnId = String(task.columnId)
|
let columnId = String(task.columnId)
|
||||||
const patch = {}
|
const patch = {}
|
||||||
if (body?.columnId !== undefined) patch.columnId = String(body.columnId)
|
if (body?.columnId !== undefined) {
|
||||||
|
const cid = body.columnId === null ? '' : String(body.columnId)
|
||||||
|
if (!cid) throw new Error('看板列不能为空')
|
||||||
|
if (cid === 'NaN' || cid === 'undefined') throw new Error('看板列无效(编号已损坏)')
|
||||||
|
patch.columnId = cid
|
||||||
|
}
|
||||||
if (body?.status !== undefined) {
|
if (body?.status !== undefined) {
|
||||||
if (!['todo', 'doing', 'done'].includes(body.status)) throw new Error('任务状态取值非法')
|
if (!['todo', 'doing', 'done'].includes(body.status)) throw new Error('任务状态取值非法')
|
||||||
patch.status = body.status
|
patch.status = body.status
|
||||||
@@ -694,14 +715,16 @@ export const taskApi = {
|
|||||||
archive: async (id) => {
|
archive: async (id) => {
|
||||||
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
|
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
|
||||||
if (!task) throw new Error('任务不存在或无权限')
|
if (!task) throw new Error('任务不存在或无权限')
|
||||||
const now = now()
|
// 不能写 const now = now(): 同名 const 会遮蔽模块级 now, 右侧求值落入 TDZ,
|
||||||
|
// 压缩后报 "Cannot access 'a' before initialization"
|
||||||
|
const ts = now()
|
||||||
await db().writeTransaction(async (tx) => {
|
await db().writeTransaction(async (tx) => {
|
||||||
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
|
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
|
||||||
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
|
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
|
||||||
const ph = ids.map(() => '?').join(',')
|
const ph = ids.map(() => '?').join(',')
|
||||||
await tx.execute(
|
await tx.execute(
|
||||||
`UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`,
|
`UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`,
|
||||||
[now, now, ...ids],
|
[ts, ts, ...ids],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
return { ok: true, count: 1 }
|
return { ok: true, count: 1 }
|
||||||
@@ -711,14 +734,14 @@ export const taskApi = {
|
|||||||
unarchive: async (id) => {
|
unarchive: async (id) => {
|
||||||
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
|
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
|
||||||
if (!task) throw new Error('任务不存在或无权限')
|
if (!task) throw new Error('任务不存在或无权限')
|
||||||
const now = now()
|
const ts = now()
|
||||||
await db().writeTransaction(async (tx) => {
|
await db().writeTransaction(async (tx) => {
|
||||||
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
|
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
|
||||||
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
|
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
|
||||||
const ph = ids.map(() => '?').join(',')
|
const ph = ids.map(() => '?').join(',')
|
||||||
await tx.execute(
|
await tx.execute(
|
||||||
`UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`,
|
`UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`,
|
||||||
[now, ...ids],
|
[ts, ...ids],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
return { ok: true, count: 1 }
|
return { ok: true, count: 1 }
|
||||||
@@ -747,13 +770,23 @@ export const taskApi = {
|
|||||||
where.push('columnId = ?')
|
where.push('columnId = ?')
|
||||||
args.push(String(p.columnId))
|
args.push(String(p.columnId))
|
||||||
}
|
}
|
||||||
|
// 日期边界: 传 'YYYY-MM-DD' 时按**本地时区**的那一天换算成 UTC 瞬时再比较。
|
||||||
|
// (原来直接拼 'T00:00:00Z', 本地选的日期会偏移 8 小时, 导致当天凌晨的归档被漏掉)
|
||||||
|
const dayToIso = (v, endOfDay) => {
|
||||||
|
const s = String(v)
|
||||||
|
if (s.includes('T')) return s // 已带时间的 ISO, 原样使用
|
||||||
|
const d = new Date(`${s}T00:00:00`) // 本地 00:00
|
||||||
|
if (Number.isNaN(d.getTime())) return null
|
||||||
|
if (endOfDay) d.setHours(23, 59, 59, 999)
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
if (p.from) {
|
if (p.from) {
|
||||||
where.push('archivedAt >= ?')
|
const iso = dayToIso(p.from, false)
|
||||||
args.push(String(p.from) + 'T00:00:00+08:00'.replace('+08:00', 'Z'))
|
if (iso) { where.push('archivedAt >= ?'); args.push(iso) }
|
||||||
}
|
}
|
||||||
if (p.to) {
|
if (p.to) {
|
||||||
where.push('archivedAt <= ?')
|
const iso = dayToIso(p.to, true)
|
||||||
args.push(String(p.to) + 'T23:59:59.999Z')
|
if (iso) { where.push('archivedAt <= ?'); args.push(iso) }
|
||||||
}
|
}
|
||||||
if (p.cursor) {
|
if (p.cursor) {
|
||||||
const [ts, cid] = String(p.cursor).split('|')
|
const [ts, cid] = String(p.cursor).split('|')
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Tabs } from 'antd-mobile'
|
import { Tabs } from 'antd-mobile'
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
|
import 'swiper/css'
|
||||||
|
|
||||||
// 顶部分类 + 下方仅渲染当前激活 tab 的内容
|
// 顶部分类 + 下方 tab 内容
|
||||||
// - 顶部: antd-mobile Tabs (点击切换)
|
// - 顶部: antd-mobile Tabs (点击切换)
|
||||||
// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
|
// - 下方: 同时挂载所有 tab, 保留各 tab 内部状态
|
||||||
// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头,
|
// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头,
|
||||||
// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失
|
// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失
|
||||||
// props:
|
// props:
|
||||||
// tabs: [{ key, label, render }]
|
// tabs: [{ key, label, render }]
|
||||||
// active: 当前激活 key
|
// active: 当前激活 key
|
||||||
// onChange(key): 切换回调
|
// onChange(key): 切换回调
|
||||||
export default function SwipeTabs({ tabs, active, onChange }) {
|
// swipeable: 是否开启整屏左右滑动 (默认 false, 打卡/监控保持纯点击切换)
|
||||||
|
// - true: 每个 tab 一屏, Swiper 提供首尾橡皮筋 + 松手吸附 + 方向锁
|
||||||
|
// - false: 旧的 [hidden] 方案, 行为与改造前完全一致
|
||||||
|
const SWIPE_MS = 260
|
||||||
|
|
||||||
|
export default function SwipeTabs({ tabs, active, onChange, swipeable = false }) {
|
||||||
const wrapRef = useRef(null)
|
const wrapRef = useRef(null)
|
||||||
|
const swiperRef = useRef(null)
|
||||||
const [more, setMore] = useState(false)
|
const [more, setMore] = useState(false)
|
||||||
const [headerH, setHeaderH] = useState(45)
|
const [headerH, setHeaderH] = useState(45)
|
||||||
|
|
||||||
@@ -59,6 +67,29 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
|||||||
list.scrollTo({ left: Math.max(0, target), behavior: 'smooth' })
|
list.scrollTo({ left: Math.max(0, target), behavior: 'smooth' })
|
||||||
}, [active, tabs.length])
|
}, [active, tabs.length])
|
||||||
|
|
||||||
|
// 外部原因改变 active (点击 tab / 前进后退 / 分类动态加载) 时, 让 Swiper 跟到对应整屏。
|
||||||
|
// 手指滑动自身触发的 active 更新不会重复动画: 此时 swiper.activeIndex 已等于目标索引。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!swipeable) return
|
||||||
|
const sw = swiperRef.current
|
||||||
|
if (!sw) return
|
||||||
|
const idx = tabs.findIndex((t) => t.key === active)
|
||||||
|
if (idx < 0 || sw.activeIndex === idx) return
|
||||||
|
sw.slideTo(idx, SWIPE_MS)
|
||||||
|
}, [swipeable, active, tabs])
|
||||||
|
|
||||||
|
const activeIndex = useMemo(() => {
|
||||||
|
const idx = tabs.findIndex((t) => t.key === active)
|
||||||
|
return idx < 0 ? 0 : idx
|
||||||
|
}, [tabs, active])
|
||||||
|
|
||||||
|
// 所有 pane 的 React 节点按 tabs 缓存: 切页 (active 变化) 时不重新渲染各 tab 内容,
|
||||||
|
// 既保留内部状态, 也避免横滑过程中多个列表一起重渲染造成掉帧。
|
||||||
|
const panes = useMemo(
|
||||||
|
() => (swipeable ? tabs.map((t) => ({ key: t.key, node: t.render() })) : []),
|
||||||
|
[swipeable, tabs],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="swipe-tabs-wrap" ref={wrapRef}>
|
<div className="swipe-tabs-wrap" ref={wrapRef}>
|
||||||
<Tabs activeKey={active} onChange={onChange}>
|
<Tabs activeKey={active} onChange={onChange}>
|
||||||
@@ -78,13 +109,42 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
|||||||
<span className="tab-more-arrow">›</span>
|
<span className="tab-more-arrow">›</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="tab-content">
|
{swipeable ? (
|
||||||
{tabs.map((t) => (
|
// 整屏横滑: 首尾橡皮筋 (resistance) + 松手吸附, 竖向滚动仍交给浏览器
|
||||||
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
|
<Swiper
|
||||||
{t.render()}
|
className="swipe-tabs-swiper"
|
||||||
</div>
|
initialSlide={activeIndex}
|
||||||
))}
|
slidesPerView={1}
|
||||||
</div>
|
spaceBetween={0}
|
||||||
|
speed={SWIPE_MS}
|
||||||
|
threshold={6}
|
||||||
|
resistance
|
||||||
|
resistanceRatio={0.65}
|
||||||
|
followFinger
|
||||||
|
longSwipesRatio={0.35}
|
||||||
|
longSwipesMs={250}
|
||||||
|
shortSwipes
|
||||||
|
touchAngle={45}
|
||||||
|
roundLengths
|
||||||
|
onSwiper={(sw) => { swiperRef.current = sw }}
|
||||||
|
onSlideChange={(sw) => {
|
||||||
|
const t = tabs[sw.activeIndex]
|
||||||
|
if (t && t.key !== active) onChange(t.key)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{panes.map((p) => (
|
||||||
|
<SwiperSlide key={p.key}>{p.node}</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
) : (
|
||||||
|
<div className="tab-content">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
|
||||||
|
{t.render()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
// 趋势徽标: 上升 / 下降 / 持平 / 震荡
|
|
||||||
// props:
|
|
||||||
// trend: 'up' | 'down' | 'flat' | 'volatile'
|
|
||||||
// showText: 是否显示文字 (默认 true)
|
|
||||||
const META = {
|
|
||||||
up: { icon: '\u2191', text: '上升' },
|
|
||||||
down: { icon: '\u2193', text: '下降' },
|
|
||||||
flat: { icon: '=', text: '持平' },
|
|
||||||
volatile: { icon: '\u223F', text: '震荡' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据最近 N 个数据点计算趋势:
|
|
||||||
// - 数据不足 2 个 → flat
|
|
||||||
// - 取最近 6 个点的变化, 整体涨幅 < 1% → flat
|
|
||||||
// - 单调递增 → up, 单调递减 → down
|
|
||||||
// - 其余 (有升有降) → volatile
|
|
||||||
// 返回: { trend, change } (change 为百分比变化, 保留 1 位小数)
|
|
||||||
export function calcTrend(rows) {
|
|
||||||
if (!rows || rows.length < 2) return { trend: 'flat', change: 0 }
|
|
||||||
const N = Math.min(6, rows.length)
|
|
||||||
const recent = rows.slice(-N).map((r) => r.value).filter((v) => typeof v === 'number')
|
|
||||||
if (recent.length < 2) return { trend: 'flat', change: 0 }
|
|
||||||
const first = recent[0]
|
|
||||||
const last = recent[recent.length - 1]
|
|
||||||
const change = first === 0 ? 0 : ((last - first) / Math.abs(first)) * 100
|
|
||||||
if (Math.abs(change) < 1) return { trend: 'flat', change }
|
|
||||||
let up = 0, down = 0
|
|
||||||
for (let i = 1; i < recent.length; i++) {
|
|
||||||
if (recent[i] > recent[i - 1]) up++
|
|
||||||
else if (recent[i] < recent[i - 1]) down++
|
|
||||||
}
|
|
||||||
if (up === recent.length - 1) return { trend: 'up', change }
|
|
||||||
if (down === recent.length - 1) return { trend: 'down', change }
|
|
||||||
return { trend: 'volatile', change }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取数据序列最后一个有效点: { month, value } 或 null
|
|
||||||
export function lastPoint(data) {
|
|
||||||
const rows = (data && data.rows) || []
|
|
||||||
for (let i = rows.length - 1; i >= 0; i--) {
|
|
||||||
if (rows[i] && typeof rows[i].value === 'number') return rows[i]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TrendBadge({ trend, showText = true }) {
|
|
||||||
const m = META[trend] || META.flat
|
|
||||||
return (
|
|
||||||
<span className={'trend-badge trend-' + (trend || 'flat')}>
|
|
||||||
<span className="trend-icon">{m.icon}</span>
|
|
||||||
{showText && <span>{m.text}</span>}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 打卡日历的日期标记: 环形进度 (F-CL-06)
|
||||||
|
//
|
||||||
|
// 为什么是环而不是圆点:
|
||||||
|
// 旧实现是 4px 小圆点, 只按状态上色 —— 3/5 和 5/5 都是同一个绿点,
|
||||||
|
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色表状态:
|
||||||
|
// 0 < actual < expected -> 弧长按比例 (部分完成)
|
||||||
|
// actual >= expected -> 整圈 (全部完成)
|
||||||
|
// actual = 0 且 expected > 0 -> 只有底环 (未开始)
|
||||||
|
// expected = 0 由父组件判断, 不渲染本组件 (当天没有应打卡项)
|
||||||
|
//
|
||||||
|
// 颜色分级 (4 档, 由 CSS 按 `.mv-day` 作用域上色, 各视图可各自调色板):
|
||||||
|
// 小半 (ratio < 0.5) -> 红
|
||||||
|
// 大半 (0.5 <= r < 1) -> 橘
|
||||||
|
// 完成 (r = 1) -> 蓝
|
||||||
|
// 超额 -> 绿
|
||||||
|
// 注意: 服务端统计时把 actual 封顶在 expected (`Math.min(cnt, exp)`),
|
||||||
|
// 所以 ratio 永远 <= 1, **超额只能靠 status === 'over' 判断**, 不能看比例。
|
||||||
|
const DEFAULT_SIZE = 30
|
||||||
|
const DEFAULT_STROKE = 2.5
|
||||||
|
|
||||||
|
/** 4 档分级: 小半 / 大半 / 完成 / 超额 */
|
||||||
|
function tierOf(status, p) {
|
||||||
|
if (status === 'over') return 'over'
|
||||||
|
if (status === 'full') return 'full'
|
||||||
|
if (p <= 0) return 'empty'
|
||||||
|
if (p >= 1) return 'full'
|
||||||
|
return p < 0.5 ? 'low' : 'high'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DayRing({
|
||||||
|
status = 'partial',
|
||||||
|
ratio = 0,
|
||||||
|
size = DEFAULT_SIZE,
|
||||||
|
stroke = DEFAULT_STROKE,
|
||||||
|
}) {
|
||||||
|
const p = Math.max(0, Math.min(1, Number(ratio) || 0))
|
||||||
|
const tier = tierOf(status, p)
|
||||||
|
const r = (size - stroke) / 2
|
||||||
|
const c = 2 * Math.PI * r
|
||||||
|
const half = size / 2
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="day-ring"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="day-ring-bg"
|
||||||
|
cx={half} cy={half} r={r}
|
||||||
|
fill="none" strokeWidth={stroke}
|
||||||
|
/>
|
||||||
|
{p > 0 && (
|
||||||
|
<circle
|
||||||
|
className={'day-ring-bar ' + tier}
|
||||||
|
cx={half} cy={half} r={r}
|
||||||
|
fill="none" strokeWidth={stroke}
|
||||||
|
strokeDasharray={c}
|
||||||
|
strokeDashoffset={c * (1 - p)}
|
||||||
|
transform={`rotate(-90 ${half} ${half})`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import StatusDot from './StatusDot.jsx'
|
import StatusDot from './StatusDot.jsx'
|
||||||
import Avatar from './Avatar.jsx'
|
import Avatar from './Avatar.jsx'
|
||||||
import { kindColor } from '../../pages/task/kind.js'
|
import { kindColor } from '../../pages/task/kind.js'
|
||||||
|
import { taskShortId } from '../../pages/task/date.js'
|
||||||
|
|
||||||
// 看板卡片 (T-UI-02): 轻量化
|
// 看板卡片 (T-UI-02): 轻量化
|
||||||
// - 标题 + 状态点 (T-ST-09)
|
// - 标题 + 状态点 (T-ST-09)
|
||||||
@@ -9,11 +10,13 @@ import { kindColor } from '../../pages/task/kind.js'
|
|||||||
const MAX_SUB = 3
|
const MAX_SUB = 3
|
||||||
|
|
||||||
export default function TaskCard({
|
export default function TaskCard({
|
||||||
task, codePrefix, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false,
|
task, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false,
|
||||||
}) {
|
}) {
|
||||||
const children = task.children || []
|
const children = task.children || []
|
||||||
const shown = children.slice(0, MAX_SUB)
|
const shown = children.slice(0, MAX_SUB)
|
||||||
const rest = children.length - shown.length
|
const rest = children.length - shown.length
|
||||||
|
// 编号只显示 `#序号`; 完整编号 (项目编号-序号) 放详情页 (见 TaskDetail)
|
||||||
|
const shortId = taskShortId(task)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -47,9 +50,7 @@ export default function TaskCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bmeta">
|
<div className="bmeta">
|
||||||
{codePrefix && task.number != null && (
|
{shortId && <span className="num-tag">{shortId}</span>}
|
||||||
<span className="num-tag">{codePrefix}-{task.number}</span>
|
|
||||||
)}
|
|
||||||
{task.markDate && (
|
{task.markDate && (
|
||||||
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import StatusDot from './StatusDot.jsx'
|
import StatusDot from './StatusDot.jsx'
|
||||||
import Avatar from './Avatar.jsx'
|
import Avatar from './Avatar.jsx'
|
||||||
import { kindColor } from '../../pages/task/kind.js'
|
import { kindColor } from '../../pages/task/kind.js'
|
||||||
|
import { taskShortId } from '../../pages/task/date.js'
|
||||||
|
|
||||||
const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中' : '未开始')
|
const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中' : '未开始')
|
||||||
|
|
||||||
@@ -10,9 +11,11 @@ const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中
|
|||||||
// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动,
|
// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动,
|
||||||
// 避免长按行后抬手时合成的 click 落在控件上误触状态切换
|
// 避免长按行后抬手时合成的 click 落在控件上误触状态切换
|
||||||
export default function TaskRow({
|
export default function TaskRow({
|
||||||
task, codePrefix, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragging = false, dragOver, indent = false, children,
|
task, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragging = false, dragOver, indent = false, children,
|
||||||
}) {
|
}) {
|
||||||
const done = task.status === 'done'
|
const done = task.status === 'done'
|
||||||
|
// 编号只显示 `#序号`; 完整编号 (项目编号-序号) 放详情页 (见 TaskDetail)
|
||||||
|
const shortId = indent ? null : taskShortId(task)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -52,9 +55,7 @@ export default function TaskRow({
|
|||||||
<div className="task-title">{task.title}</div>
|
<div className="task-title">{task.title}</div>
|
||||||
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
|
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
|
||||||
<div className="task-meta">
|
<div className="task-meta">
|
||||||
{codePrefix && task.number != null && (
|
{shortId && <span className="num-tag">{shortId}</span>}
|
||||||
<span className="num-tag">{codePrefix}-{task.number}</span>
|
|
||||||
)}
|
|
||||||
{task.markDate && (
|
{task.markDate && (
|
||||||
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { datasetId, sameId } from '../../pages/task/ids.js'
|
||||||
|
|
||||||
// 看板卡片列拖动 (重点设计 §7.1)
|
// 看板卡片列拖动 (重点设计 §7.1)
|
||||||
// 方案要点:
|
// 方案要点:
|
||||||
@@ -60,10 +61,11 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
|
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
|
||||||
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
|
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
|
||||||
|
|
||||||
|
// 命中列 id: 本地库列 id 是 uuid 字符串, 不能 Number() (会变 NaN -> 落不下去)
|
||||||
const hitColumn = (x, y) => {
|
const hitColumn = (x, y) => {
|
||||||
const el = document.elementFromPoint(x, y)
|
const el = document.elementFromPoint(x, y)
|
||||||
const colEl = el?.closest?.('[data-column-id]')
|
const colEl = el?.closest?.('[data-column-id]')
|
||||||
return colEl ? Number(colEl.dataset.columnId) : null
|
return colEl ? datasetId(colEl.dataset.columnId) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 边缘自动滚动 (±36px 触发, 每帧 10px)
|
// 边缘自动滚动 (±36px 触发, 每帧 10px)
|
||||||
@@ -105,7 +107,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
if (s.active) {
|
if (s.active) {
|
||||||
lastEnd.current = Date.now()
|
lastEnd.current = Date.now()
|
||||||
const col = hitColumn(ev.clientX, ev.clientY)
|
const col = hitColumn(ev.clientX, ev.clientY)
|
||||||
if (col && s.task && col !== s.task.columnId) onDropRef.current?.(s.task, col)
|
if (col && s.task && !sameId(col, s.task.columnId)) onDropRef.current?.(s.task, col)
|
||||||
}
|
}
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { datasetId, sameId } from '../../pages/task/ids.js'
|
||||||
|
|
||||||
// 列表视图任务行拖动: 组间换列 + 组内排序
|
// 列表视图任务行拖动: 组间换列 + 组内排序
|
||||||
// 触发: 行长按 400ms, 或按住行首 ⠿ 把手 (touch-action:none + touchstart
|
// 触发: 行长按 400ms, 或按住行首 ⠿ 把手 (touch-action:none + touchstart
|
||||||
@@ -78,15 +79,15 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
|
|||||||
moveGhost(ev.clientX, ev.clientY)
|
moveGhost(ev.clientX, ev.clientY)
|
||||||
|
|
||||||
// 组内落点: 命中本组 ([data-column-id]=当前列) 的某条顶层行
|
// 组内落点: 命中本组 ([data-column-id]=当前列) 的某条顶层行
|
||||||
|
// 本地库的列/任务 id 均为 uuid 字符串 -> 一律按字符串比对, 不要 Number()
|
||||||
const hitEl = document.elementFromPoint(ev.clientX, ev.clientY)
|
const hitEl = document.elementFromPoint(ev.clientX, ev.clientY)
|
||||||
const rowEl = hitEl?.closest?.('[data-task-id]')
|
const rowEl = hitEl?.closest?.('[data-task-id]')
|
||||||
const colEl = hitEl?.closest?.('[data-column-id]')
|
const colEl = hitEl?.closest?.('[data-column-id]')
|
||||||
const colId = colEl ? Number(colEl.dataset.columnId) : null
|
const colId = colEl ? datasetId(colEl.dataset.columnId) : null
|
||||||
const curCol = orderedRef.current[s.toIdx]?.id
|
|
||||||
|
|
||||||
if (rowEl && colId === s.task.columnId) {
|
if (rowEl && sameId(colId, s.task.columnId)) {
|
||||||
const id = Number(rowEl.dataset.taskId)
|
const id = datasetId(rowEl.dataset.taskId)
|
||||||
if (id !== s.task.id) {
|
if (id && !sameId(id, s.task.id)) {
|
||||||
const r = rowEl.getBoundingClientRect()
|
const r = rowEl.getBoundingClientRect()
|
||||||
const hit = { id, after: ev.clientY > r.top + r.height / 2 }
|
const hit = { id, after: ev.clientY > r.top + r.height / 2 }
|
||||||
if (!s.over || s.over.id !== hit.id || s.over.after !== hit.after) {
|
if (!s.over || s.over.id !== hit.id || s.over.after !== hit.after) {
|
||||||
@@ -100,7 +101,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
|
|||||||
|
|
||||||
// 换列: 手指划到哪个分组就是目标列, 划出所有分组时保持上一目标
|
// 换列: 手指划到哪个分组就是目标列, 划出所有分组时保持上一目标
|
||||||
if (colId != null) {
|
if (colId != null) {
|
||||||
const to = orderedRef.current.findIndex((c) => c.id === colId)
|
const to = orderedRef.current.findIndex((c) => sameId(c.id, colId))
|
||||||
if (to >= 0 && to !== s.toIdx) {
|
if (to >= 0 && to !== s.toIdx) {
|
||||||
s.toIdx = to
|
s.toIdx = to
|
||||||
setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to })
|
setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to })
|
||||||
@@ -116,7 +117,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
|
|||||||
onReorderRef.current?.(s.task, s.over.id, s.over.after)
|
onReorderRef.current?.(s.task, s.over.id, s.over.after)
|
||||||
} else {
|
} else {
|
||||||
const target = orderedRef.current[s.toIdx]
|
const target = orderedRef.current[s.toIdx]
|
||||||
if (target && target.id !== s.task.columnId) onDropRef.current?.(s.task, target.id)
|
if (target && !sameId(target.id, s.task.columnId)) onDropRef.current?.(s.task, target.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleanup()
|
cleanup()
|
||||||
@@ -152,7 +153,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startDrag = (ev, task, instant) => {
|
const startDrag = (ev, task, instant) => {
|
||||||
const fromIdx = orderedRef.current.findIndex((c) => c.id === task.columnId)
|
const fromIdx = orderedRef.current.findIndex((c) => sameId(c.id, task.columnId))
|
||||||
if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动
|
if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动
|
||||||
st.current = {
|
st.current = {
|
||||||
...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx,
|
...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import scrollIntoView from 'scroll-into-view-if-needed'
|
||||||
|
|
||||||
|
// 全局兜底: 输入框获得焦点后保证它滚进可视区。
|
||||||
|
//
|
||||||
|
// 原生层 (scripts/ci/android-softinput.sh) 已经把软键盘高度从 WebView 里扣掉,
|
||||||
|
// 弹层 (position: fixed; bottom: 0) 会自动上移; 这里只负责把被聚焦的控件
|
||||||
|
// 滚到可视区中间, 覆盖页面滚动、Popup 内部滚动等所有场景。
|
||||||
|
//
|
||||||
|
// 用 scroll-into-view-if-needed (成熟库) 而不是手写滚动计算:
|
||||||
|
// 它会自动处理嵌套滚动容器, 并在目标已可见时不做多余滚动。
|
||||||
|
const FIELD_SELECTOR = 'input, textarea, [contenteditable="true"]'
|
||||||
|
|
||||||
|
function isField(el) {
|
||||||
|
return !!el && typeof el.matches === 'function' && el.matches(FIELD_SELECTOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useKeepInputVisible() {
|
||||||
|
useEffect(() => {
|
||||||
|
let timer = 0
|
||||||
|
|
||||||
|
const reveal = (el, behavior) => {
|
||||||
|
if (!isField(el)) return
|
||||||
|
scrollIntoView(el, {
|
||||||
|
behavior,
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest',
|
||||||
|
scrollMode: 'if-needed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFocusIn = (e) => {
|
||||||
|
const el = e.target
|
||||||
|
if (!isField(el)) return
|
||||||
|
// 先滚一次 (部分机型聚焦后立刻可用), 等键盘动画结束后再校正一次
|
||||||
|
reveal(el, 'smooth')
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => reveal(el, 'smooth'), 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘弹出/收起会改变可视区高度 (原生层已让 WebView 变矮)
|
||||||
|
const onViewportResize = () => {
|
||||||
|
const el = document.activeElement
|
||||||
|
if (!isField(el)) return
|
||||||
|
reveal(el, 'auto')
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('focusin', onFocusIn)
|
||||||
|
window.addEventListener('resize', onViewportResize)
|
||||||
|
window.visualViewport?.addEventListener('resize', onViewportResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
document.removeEventListener('focusin', onFocusIn)
|
||||||
|
window.removeEventListener('resize', onViewportResize)
|
||||||
|
window.visualViewport?.removeEventListener('resize', onViewportResize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Outlet, useLocation } from 'react-router-dom'
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
import BottomNav from '../components/BottomNav.jsx'
|
import BottomNav from '../components/BottomNav.jsx'
|
||||||
import { isOffline, subscribeOffline } from '../api/client.js'
|
import { isOffline, subscribeOffline } from '../api/client.js'
|
||||||
|
|
||||||
|
// 宏观页内部子 tab 的列表路径 (整屏横滑只在这些路径之间切换)
|
||||||
|
// /macro/news · /macro/articles · /macro/finance/:catId
|
||||||
|
// 详情页 (多一段) 不算, 进出详情仍要播放整页入场动画
|
||||||
|
function isMacroListPath(p) {
|
||||||
|
return p === '/macro/news'
|
||||||
|
|| p === '/macro/articles'
|
||||||
|
|| /^\/macro\/finance\/[^/]+\/?$/.test(p)
|
||||||
|
}
|
||||||
|
|
||||||
// 移动端应用外壳
|
// 移动端应用外壳
|
||||||
// - 顶部安全区: 用 --safe-top 撑开 (由 main.jsx 的 calibrateSafeTop 实测校准)
|
// - 顶部安全区: 用 --safe-top 撑开 (由 main.jsx 的 calibrateSafeTop 实测校准)
|
||||||
// - 中间 <main>: flex:1 可滚动主区
|
// - 中间 <main>: flex:1 可滚动主区
|
||||||
@@ -18,7 +27,15 @@ export default function MobileShell() {
|
|||||||
// pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树,
|
// pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树,
|
||||||
// 避免破坏 MacroPage 等页内子路由的挂载状态与滚动位置)
|
// 避免破坏 MacroPage 等页内子路由的挂载状态与滚动位置)
|
||||||
const [animating, setAnimating] = useState(false)
|
const [animating, setAnimating] = useState(false)
|
||||||
|
const prevPathRef = useRef(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const prev = prevPathRef.current
|
||||||
|
prevPathRef.current = location.pathname
|
||||||
|
// 宏观内部子 tab 切换已由整屏横滑提供动效, 不要再叠一次整页淡入 + 上移
|
||||||
|
if (prev !== null && isMacroListPath(prev) && isMacroListPath(location.pathname)) {
|
||||||
|
setAnimating(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setAnimating(true)
|
setAnimating(true)
|
||||||
const t = setTimeout(() => setAnimating(false), 320)
|
const t = setTimeout(() => setAnimating(false), 320)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
// ifinance 外接模块 (宿主侧)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// ifinance 是静态托管模块包, 唯一索引是 info.json。宿主约定:
|
||||||
|
// - 根地址固定为 https://ifinance.honor3.com (宿主常量, 不写进 info.json)
|
||||||
|
// - 没有统一入口页, 每个 html 都是独立入口 (在 WebView/iframe 中打开)
|
||||||
|
// - categories[] 对应 App 的 tab, items[] 是条目; 未知字段忽略
|
||||||
|
//
|
||||||
|
// 缓存策略 (schema 2, 见 ifinance/readme.md「缓存协议」):
|
||||||
|
// - info.json: 远端优先, 失败回退同源内置快照 → localStorage 上次成功快照;
|
||||||
|
// 启动时拉一次, 之后每天最多自动检查一次, 「我」页可手动触发
|
||||||
|
// - 条目文件: 按 files[].hash 内容寻址, 只缓存真正打开过的页面 (按需加载)
|
||||||
|
// - 页面 HTML 由 Service Worker 从 /__ifinance__/... 返回:
|
||||||
|
// * 命中本地缓存 → 直接离线返回
|
||||||
|
// * 未命中 → 只拉当前这一个文件, 校验 sha256 后写入缓存
|
||||||
|
// 用同源 URL + SW 而不是 blob: URL, 因为 blob: 文档会继承宿主 CSP
|
||||||
|
// (script-src 'self'), 内嵌看板的行内脚本会被拦截。
|
||||||
|
// - 开发环境 (vite dev) 走 /ifinance-api 代理以规避跨域; 生产环境直连。
|
||||||
|
// 注意: SW 内直接访问远端 (ifinance 已开放 CORS, 无需代理)。
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// 根地址: 生产直连; 开发用 vite 代理前缀 (见 vite.config.js)
|
||||||
|
export const FINANCE_BASE = import.meta.env.DEV
|
||||||
|
? '/ifinance-api'
|
||||||
|
: 'https://ifinance.honor3.com'
|
||||||
|
|
||||||
|
// 同源兜底快照 (构建时由 ifinance/info.json 拷贝到 public/, 仅在远端不可用时使用)
|
||||||
|
const FALLBACK_INFO_URL = '/ifinance-info.json'
|
||||||
|
|
||||||
|
const INFO_CACHE_KEY = 'ifinance_info_v1'
|
||||||
|
const FILE_CACHE_NAME = 'ifinance-files-v1'
|
||||||
|
|
||||||
|
// 页面缓存入口: 形如 /__ifinance__/public/econ/x.html?h=<hash>&t=text/html
|
||||||
|
// 由 public/ifinance-sw.js 拦截并返回内容
|
||||||
|
const PAGE_CACHE_PREFIX = '/__ifinance__/'
|
||||||
|
const SW_URL = (import.meta.env.BASE_URL || '/') + 'ifinance-sw.js'
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
// 模块级缓存: 一次会话内多处 (宏观页 / 详情页 / 「我」页) 共享
|
||||||
|
let memoInfo = null
|
||||||
|
let memoCheckedAt = 0 // 上次成功检查远端 info.json 的时间戳
|
||||||
|
let inflight = null
|
||||||
|
const listeners = new Set()
|
||||||
|
|
||||||
|
// 拼接 info.json 中给出的相对路径 (如 public/econ/x.html) 为可加载的绝对 URL
|
||||||
|
export function financeUrl(relPath) {
|
||||||
|
const p = String(relPath || '').replace(/^\/+/, '')
|
||||||
|
return FINANCE_BASE + '/' + p
|
||||||
|
}
|
||||||
|
|
||||||
|
// 条目 HTML 远端地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307)
|
||||||
|
export function financePageUrl(relPath) {
|
||||||
|
return financeUrl(String(relPath || '').replace(/\.html?$/i, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocalState() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(INFO_CACHE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
const info = parsed?.info
|
||||||
|
if (info && Array.isArray(info.categories)) {
|
||||||
|
return { info, checkedAt: Number(parsed.checkedAt) || 0 }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLocalState(info, checkedAt) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), checkedAt, info }))
|
||||||
|
} catch { /* 配额满等忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInfoFrom(url) {
|
||||||
|
const res = await fetch(url, { cache: 'no-cache' })
|
||||||
|
if (!res.ok) throw new Error('ifinance 索引加载失败 (' + res.status + ')')
|
||||||
|
const info = await res.json()
|
||||||
|
if (!info || !Array.isArray(info.categories)) throw new Error('ifinance 索引格式无效')
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用新索引替换模块状态, 通知所有订阅者, 并清理旧版本文件缓存
|
||||||
|
function applyInfo(info, checkedAt) {
|
||||||
|
memoInfo = info
|
||||||
|
memoCheckedAt = checkedAt
|
||||||
|
writeLocalState(info, checkedAt)
|
||||||
|
pruneFileCache(info)
|
||||||
|
for (const fn of listeners) {
|
||||||
|
try { fn({ info, checkedAt }) } catch { /* 单个订阅者异常不影响其他人 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅索引变化 (宏观 tab / 详情页 / 「我」页跨组件同步)
|
||||||
|
export function subscribeIfinanceInfo(fn) {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => { listeners.delete(fn) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理不在当前 files[] 里的旧缓存 (按 hash 内容寻址, 旧文件已无引用)
|
||||||
|
async function pruneFileCache(info) {
|
||||||
|
if (typeof caches === 'undefined') return
|
||||||
|
const files = Array.isArray(info?.files) ? info.files : []
|
||||||
|
if (!files.length) return
|
||||||
|
const keep = new Set(files.map((f) => String(f?.hash || '').toLowerCase()).filter(Boolean))
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(FILE_CACHE_NAME)
|
||||||
|
const reqs = await cache.keys()
|
||||||
|
await Promise.all(reqs.map((req) => {
|
||||||
|
const key = req.url.split('/').pop() || ''
|
||||||
|
return key && !keep.has(key.toLowerCase()) ? cache.delete(req) : null
|
||||||
|
}))
|
||||||
|
} catch { /* 缓存不可用/被清理时忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取 (并缓存) info.json: 内存 → 远端 → localStorage 快照 → 同源内置快照
|
||||||
|
export async function fetchIfinanceInfo({ force = false } = {}) {
|
||||||
|
if (!force && memoInfo) return memoInfo
|
||||||
|
if (inflight) return inflight
|
||||||
|
inflight = (async () => {
|
||||||
|
try {
|
||||||
|
const info = await fetchInfoFrom(financeUrl('info.json'))
|
||||||
|
applyInfo(info, Date.now())
|
||||||
|
return info
|
||||||
|
} catch (remoteErr) {
|
||||||
|
const cached = readLocalState()
|
||||||
|
if (cached?.info) {
|
||||||
|
// 远端不可用: 继续用上次成功快照 (保留 checkedAt, 下次还会重试)
|
||||||
|
memoInfo = cached.info
|
||||||
|
memoCheckedAt = cached.checkedAt
|
||||||
|
return cached.info
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const info = await fetchInfoFrom(FALLBACK_INFO_URL)
|
||||||
|
applyInfo(info, 0)
|
||||||
|
return info
|
||||||
|
} catch {
|
||||||
|
throw remoteErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
try {
|
||||||
|
return await inflight
|
||||||
|
} finally {
|
||||||
|
inflight = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动/每日刷新: 强制拉远端 info.json。失败直接抛出, 让调用方提示;
|
||||||
|
// 成功后返回是否发生变化 (buildHash 不变 = 内容没变)。
|
||||||
|
export async function refreshIfinanceInfo() {
|
||||||
|
const previous = memoInfo || readLocalState()?.info || null
|
||||||
|
let info
|
||||||
|
try {
|
||||||
|
info = await fetchInfoFrom(financeUrl('info.json'))
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('ifinance 索引更新失败: ' + (e?.message || '网络不可用'))
|
||||||
|
}
|
||||||
|
applyInfo(info, Date.now())
|
||||||
|
return {
|
||||||
|
info,
|
||||||
|
previous,
|
||||||
|
changed: !previous || previous.buildHash !== info.buildHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// React Hook: 获取分类索引。优先内存 → localStorage 快照 → 远端
|
||||||
|
// 订阅模块级更新: 手动刷新/每日刷新后, 已挂载的页面会自动重渲染。
|
||||||
|
export function useIfinanceInfo() {
|
||||||
|
const [state, setState] = useState(() => {
|
||||||
|
const cached = readLocalState()
|
||||||
|
return {
|
||||||
|
info: memoInfo || cached?.info || null,
|
||||||
|
loading: !(memoInfo || cached?.info),
|
||||||
|
error: '',
|
||||||
|
checkedAt: memoCheckedAt || cached?.checkedAt || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
const unsubscribe = subscribeIfinanceInfo(({ info, checkedAt }) => {
|
||||||
|
if (alive) setState({ info, loading: false, error: '', checkedAt })
|
||||||
|
})
|
||||||
|
fetchIfinanceInfo()
|
||||||
|
.then((info) => {
|
||||||
|
if (!alive) return
|
||||||
|
setState((s) => ({ ...s, info, loading: false, error: '' }))
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!alive) return
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
loading: false,
|
||||||
|
error: s.info ? '' : (e?.message || '外接模块加载失败'),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
return () => { alive = false; unsubscribe() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Service Worker: 把按需缓存的 ifinance 文件以同源 URL 提供给 iframe
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
let swPromise = null
|
||||||
|
|
||||||
|
// 注册/等待 SW 就绪。返回 true 表示 iframe 可以走 /__ifinance__/ 缓存入口。
|
||||||
|
// 任何环境不支持 (或被策略禁用) 都返回 false, 上层回退到远端直连。
|
||||||
|
export function initIfinanceCache() {
|
||||||
|
if (swPromise) return swPromise
|
||||||
|
if (
|
||||||
|
typeof navigator === 'undefined' ||
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!('serviceWorker' in navigator) ||
|
||||||
|
typeof caches === 'undefined'
|
||||||
|
) {
|
||||||
|
swPromise = Promise.resolve(false)
|
||||||
|
return swPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
swPromise = (async () => {
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register(SW_URL, { scope: '/', updateViaCache: 'none' })
|
||||||
|
await Promise.race([
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('service worker timeout')), 4000)),
|
||||||
|
])
|
||||||
|
if (navigator.serviceWorker.controller) return true
|
||||||
|
// 首次安装: 等待 activate 里的 clients.claim() 接管当前页面
|
||||||
|
const claimed = await new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve(false), 1500)
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(true)
|
||||||
|
}, { once: true })
|
||||||
|
})
|
||||||
|
return claimed || !!navigator.serviceWorker.controller
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ifinance] 缓存不可用, 回退远端直连:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return swPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileType(relPath) {
|
||||||
|
return /\.json$/i.test(relPath) ? 'application/json' : 'text/html'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存入口 URL (给 iframe 用)。hash 优先取 files[] 清单 (唯一事实来源),
|
||||||
|
// 其次退回 items[] 自带的 hash: 索引更新后 hash 变化 → 自动读取新文件,
|
||||||
|
// 不会命中旧缓存。
|
||||||
|
export function financeItemSrc(item) {
|
||||||
|
const rel = String(item?.html || '').replace(/^\/+/, '')
|
||||||
|
if (!rel) return ''
|
||||||
|
const file = (memoInfo?.files || []).find((f) => f.path === rel)
|
||||||
|
const hash = file?.hash || item?.hash
|
||||||
|
if (!hash) return financePageUrl(rel)
|
||||||
|
const params = new URLSearchParams({ h: String(hash), t: file?.type || fileType(rel) })
|
||||||
|
return PAGE_CACHE_PREFIX + rel + '?' + params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 每日自动刷新: 启动后检查一次, 之后每 30 分钟看一次是否已过 24h
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
let autoRefreshStarted = false
|
||||||
|
|
||||||
|
export function startIfinanceAutoRefresh() {
|
||||||
|
if (autoRefreshStarted || typeof window === 'undefined') return
|
||||||
|
autoRefreshStarted = true
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) return
|
||||||
|
const cached = readLocalState()
|
||||||
|
const last = memoCheckedAt || cached?.checkedAt || 0
|
||||||
|
if (!memoInfo && !cached?.info) {
|
||||||
|
// 还没索引: 先拉一次 (不阻塞启动)
|
||||||
|
fetchIfinanceInfo().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Date.now() - last >= DAY_MS) {
|
||||||
|
refreshIfinanceInfo().catch((e) => console.warn('[ifinance] 自动更新失败:', e?.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(check, 4000)
|
||||||
|
setInterval(check, 30 * 60 * 1000)
|
||||||
|
window.addEventListener('online', check)
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) check()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ export function getPowersyncDb() {
|
|||||||
database: {
|
database: {
|
||||||
// 默认 IDBBatchAtomicVFS (IndexedDB), 兼容性最广; WebView2 (Chromium) 后续可切 OPFS 加速
|
// 默认 IDBBatchAtomicVFS (IndexedDB), 兼容性最广; WebView2 (Chromium) 后续可切 OPFS 加速
|
||||||
dbFilename: 'iboard-powersync.db',
|
dbFilename: 'iboard-powersync.db',
|
||||||
|
// 关闭多标签支持 —— 否则桌面端 (WebView2/Chromium) 会启用 **共享 IndexedDB worker**:
|
||||||
|
// enableMultiTabs 默认为 true (桌面且有 SharedWorker 时), 该 SharedWorker 会跨页面
|
||||||
|
// reload 存活; OTA 升级后 reload 的新页面连上去时本地库读取会永久挂起
|
||||||
|
// (无异常、纯 pending) —— 表现为任务页一直「加载中」, 其它不碰本地库的页面正常。
|
||||||
|
// Android WebView 不支持 SharedWorker, 因此只有 Windows 外壳命中。
|
||||||
|
// 本应用是单窗口外壳 (dock.html 不访问本地库), 不需要多标签同步。
|
||||||
|
enableMultiTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 本地库变更订阅 (任务模块即时同步, 见 docs/V0.2/离线编辑模块/即时同步方案.md)
|
||||||
|
//
|
||||||
|
// 作用: 当指定表在本地 SQLite 里发生变化时回调 —— 变化可能来自
|
||||||
|
// 1) 本端写入 (乐观更新之后)
|
||||||
|
// 2) PowerSync 下行推送 (其它端 / 服务端改动)
|
||||||
|
// 两者最终都落到本地库, 所以订阅本地库即可覆盖跨端场景, 且离线时同样有效。
|
||||||
|
//
|
||||||
|
// 为什么不用手写"信号 SQL"求和: SDK 的 watch 已支持按表名过滤 (tables)
|
||||||
|
// + 节流 (throttleMs), 由 SDK 判断是否真的变化, 比自己做 count/max 汇总可靠。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// useDbSignal(['Task', 'BoardColumn'], () => load())
|
||||||
|
// useDbSignal(TASK_TABLES, onChange, { throttleMs: 300, enabled: someFlag })
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { getPowersyncDb } from './db.js'
|
||||||
|
|
||||||
|
// 无意义的占位查询: 只借 watch 的"变更通知"能力, 不关心结果
|
||||||
|
const SIGNAL_SQL = 'SELECT 1 AS s'
|
||||||
|
|
||||||
|
export default function useDbSignal(tables, onChange, options = {}) {
|
||||||
|
const { throttleMs = 250, enabled = true } = options
|
||||||
|
|
||||||
|
// 回调走 ref, 避免调用方每次渲染都重新订阅
|
||||||
|
const cbRef = useRef(onChange)
|
||||||
|
cbRef.current = onChange
|
||||||
|
|
||||||
|
// 表名数组每帧都是新引用, 用内容做依赖
|
||||||
|
const tablesKey = Array.isArray(tables) ? tables.join(',') : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 回滚开关: localStorage['iboard_realtime'] = '0' 时完全不订阅, 退回"手动刷新"行为,
|
||||||
|
// 用于线上出问题时的快速止损 (改完需重启应用生效)
|
||||||
|
let realtimeOff = false
|
||||||
|
try { realtimeOff = localStorage.getItem('iboard_realtime') === '0' } catch { /* ignore */ }
|
||||||
|
if (!enabled || !tablesKey || realtimeOff) return undefined
|
||||||
|
|
||||||
|
const db = getPowersyncDb()
|
||||||
|
const ac = new AbortController()
|
||||||
|
let disposed = false
|
||||||
|
// watch 订阅成功后会立刻给出一次当前结果, 而调用方挂载时已经主动 load 过,
|
||||||
|
// 这里跳过首次通知, 避免重复请求
|
||||||
|
let first = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.watch(
|
||||||
|
SIGNAL_SQL,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
onResult: () => {
|
||||||
|
if (disposed) return
|
||||||
|
if (first) { first = false; return }
|
||||||
|
cbRef.current?.()
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
if (!disposed) console.warn('[db-signal] watch 出错:', e)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ tables: tablesKey.split(','), throttleMs, signal: ac.signal },
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
// 订阅失败不应影响页面主流程 (退化为"手动刷新")
|
||||||
|
console.warn('[db-signal] 订阅失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
ac.abort()
|
||||||
|
}
|
||||||
|
}, [tablesKey, throttleMs, enabled])
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { BrowserRouter } from 'react-router-dom'
|
|||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import { AuthProvider } from './auth/AuthContext.jsx'
|
import { AuthProvider } from './auth/AuthContext.jsx'
|
||||||
import { initOta } from './api/ota.js'
|
import { initOta } from './api/ota.js'
|
||||||
|
import { startIfinanceAutoRefresh } from './lib/ifinance.js'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
|
|
||||||
// OTA 启动: 仅 Tauri 内生效
|
// OTA 启动: 仅 Tauri 内生效
|
||||||
@@ -14,6 +15,9 @@ import './styles/globals.css'
|
|||||||
// - 异步执行, 不阻塞 React 挂载
|
// - 异步执行, 不阻塞 React 挂载
|
||||||
initOta()
|
initOta()
|
||||||
|
|
||||||
|
// ifinance 索引每日自动更新 (启动 4s 后检查一次, 之后每 30 分钟看是否已过 24h)
|
||||||
|
startIfinanceAutoRefresh()
|
||||||
|
|
||||||
// ==================== 顶部状态栏安全区校准 ====================
|
// ==================== 顶部状态栏安全区校准 ====================
|
||||||
// 背景: Android WebView (Tauri) 下 env(safe-area-inset-top) 常返回 0,
|
// 背景: Android WebView (Tauri) 下 env(safe-area-inset-top) 常返回 0,
|
||||||
// 而 antd-mobile 的 <SafeArea> 只是个依赖该变量的空 div, 于是内容会顶到状态栏下.
|
// 而 antd-mobile 的 <SafeArea> 只是个依赖该变量的空 div, 于是内容会顶到状态栏下.
|
||||||
@@ -50,6 +54,9 @@ function calibrateSafeTop() {
|
|||||||
// 只在"纵向且高度差合理"时采用, 避免横屏误判
|
// 只在"纵向且高度差合理"时采用, 避免横屏误判
|
||||||
const isPortrait = viewW <= viewH
|
const isPortrait = viewW <= viewH
|
||||||
const diff = screenH - viewH
|
const diff = screenH - viewH
|
||||||
|
// 软键盘弹出会让 visualViewport 高度骤减 (diff 远大于状态栏高度)。
|
||||||
|
// 此时必须保留上一次算出的值, 否则会被当成"没有状态栏"而改成 24px, 整页跳动。
|
||||||
|
if (isPortrait && diff >= 120 && root.style.getPropertyValue('--safe-top')) return
|
||||||
const measured = isPortrait && diff > 0 && diff < 120 ? diff : 0
|
const measured = isPortrait && diff > 0 && diff < 120 ? diff : 0
|
||||||
|
|
||||||
// 取 env() 与实测值的较大者; 两者都失效时给 24px 保底
|
// 取 env() 与实测值的较大者; 两者都失效时给 24px 保底
|
||||||
|
|||||||
+51
-53
@@ -1,43 +1,32 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
||||||
import { Badge } from 'antd-mobile'
|
|
||||||
import PageHeader from '../components/PageHeader.jsx'
|
import PageHeader from '../components/PageHeader.jsx'
|
||||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||||
import DataView from './macro/DataView.jsx'
|
|
||||||
import StrategyView from './macro/StrategyView.jsx'
|
|
||||||
import ArticlesView from './macro/ArticlesView.jsx'
|
import ArticlesView from './macro/ArticlesView.jsx'
|
||||||
import ArticleDetail from './macro/ArticleDetail.jsx'
|
import ArticleDetail from './macro/ArticleDetail.jsx'
|
||||||
import OilPage from './macro/OilPage.jsx'
|
|
||||||
import AlertsView from './macro/AlertsView.jsx'
|
|
||||||
import NewsView from './macro/NewsView.jsx'
|
import NewsView from './macro/NewsView.jsx'
|
||||||
import NewsDetail from './news/NewsDetail.jsx'
|
import NewsDetail from './news/NewsDetail.jsx'
|
||||||
|
import FinanceCategoryView from './macro/FinanceCategoryView.jsx'
|
||||||
|
import FinanceItemView from './macro/FinanceItemView.jsx'
|
||||||
|
import { useIfinanceInfo } from '../lib/ifinance.js'
|
||||||
|
|
||||||
// 宏观: 顶栏 6 tab, 默认 新闻
|
// 宏观: 本地固定 2 个 tab (新闻 / 经济文章) + 外接 ifinance 分类动态挂载为 tab
|
||||||
// /macro → 显示 news tab (activeFromPath 决定)
|
// /macro/news → 新闻 (默认)
|
||||||
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来, 置最左)
|
// /macro/articles → 经济文章
|
||||||
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来, 置第二并作为默认)
|
// /macro/finance/:catId → 外接分类 (经济数据 / 策略研究 / …)
|
||||||
// /macro/oil → 油价
|
// 详情: /macro/news/:id · /macro/articles/:slug · /macro/finance/:catId/:itemId
|
||||||
// /macro/data → 经济数据
|
// 说明: 外接分类不写死, 由 info.json 的 categories[] 决定; ifinance 新增分类后
|
||||||
// /macro/strategy → 策略研究
|
// 本页自动多出新 tab, 宿主无需改代码。报警已迁到「监控」。
|
||||||
// /macro/articles → 经济文章
|
const BASE_TABS = [
|
||||||
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
|
|
||||||
// 说明: tab 数量已超出屏宽, SwipeTabs 会在右侧显示渐变 + 箭头提示可继续滑动
|
|
||||||
const TABS = [
|
|
||||||
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
|
|
||||||
{ key: 'news', label: '新闻', path: '/macro/news' },
|
{ key: 'news', label: '新闻', path: '/macro/news' },
|
||||||
{ key: 'oil', label: '油价', path: '/macro/oil' },
|
|
||||||
{ key: 'data', label: '经济数据', path: '/macro/data' },
|
|
||||||
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
|
|
||||||
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function activeFromPath(pathname) {
|
function activeFromPath(pathname) {
|
||||||
if (pathname.startsWith('/macro/alerts')) return 'alerts'
|
|
||||||
if (pathname.startsWith('/macro/news')) return 'news'
|
if (pathname.startsWith('/macro/news')) return 'news'
|
||||||
if (pathname.startsWith('/macro/oil')) return 'oil'
|
|
||||||
if (pathname.startsWith('/macro/data')) return 'data'
|
|
||||||
if (pathname.startsWith('/macro/strategy')) return 'strategy'
|
|
||||||
if (pathname.startsWith('/macro/articles')) return 'articles'
|
if (pathname.startsWith('/macro/articles')) return 'articles'
|
||||||
|
const m = pathname.match(/^\/macro\/finance\/([^/]+)/)
|
||||||
|
if (m) return 'finance:' + m[1]
|
||||||
// 父路由 /macro 走默认 tab (新闻)
|
// 父路由 /macro 走默认 tab (新闻)
|
||||||
return 'news'
|
return 'news'
|
||||||
}
|
}
|
||||||
@@ -45,47 +34,56 @@ function activeFromPath(pathname) {
|
|||||||
export default function MacroPage() {
|
export default function MacroPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { info } = useIfinanceInfo()
|
||||||
const active = activeFromPath(location.pathname)
|
const active = activeFromPath(location.pathname)
|
||||||
// 报警角标: 由 AlertsView 回传总数
|
const categories = info?.categories
|
||||||
const [alertCount, setAlertCount] = useState(0)
|
|
||||||
|
|
||||||
const tabs = TABS.map((t) => ({
|
// tabs 只在分类索引变化时重建: 横滑切页时 SwipeTabs 可复用已缓存的 pane 节点,
|
||||||
key: t.key,
|
// 不会因为父组件重渲染而把所有列表重新渲染一遍
|
||||||
label: t.key === 'alerts'
|
// (必须放在详情页提前 return 之前: hooks 不能有条件跳过)
|
||||||
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span>
|
const tabs = useMemo(() => [
|
||||||
: t.label,
|
...BASE_TABS.map((t) => ({
|
||||||
render: () => {
|
key: t.key,
|
||||||
if (t.key === 'oil') return <OilPage />
|
label: t.label,
|
||||||
if (t.key === 'data') return <DataView />
|
render: () => (t.key === 'news' ? <NewsView /> : <ArticlesView />),
|
||||||
if (t.key === 'strategy') return <StrategyView />
|
})),
|
||||||
if (t.key === 'articles') return <ArticlesView />
|
...(categories || []).map((c) => ({
|
||||||
if (t.key === 'alerts') return <AlertsView onCount={setAlertCount} />
|
key: 'finance:' + c.id,
|
||||||
if (t.key === 'news') return <NewsView />
|
label: c.name,
|
||||||
return null
|
render: () => <FinanceCategoryView category={c} />,
|
||||||
},
|
})),
|
||||||
}))
|
], [categories])
|
||||||
|
|
||||||
function onChange(key) {
|
// 详情页单独走 React Router 渲染 (需要 Route 提供 params), 不参与滑屏
|
||||||
const next = TABS.find((t) => t.key === key)
|
const p = location.pathname
|
||||||
if (next) navigate(next.path)
|
const isNewsDetail = /^\/macro\/news\/[^/]+$/.test(p)
|
||||||
}
|
const isArticleDetail = /^\/macro\/articles\/[^/]+$/.test(p)
|
||||||
|
const isFinanceDetail = /^\/macro\/finance\/[^/]+\/[^/]+$/.test(p)
|
||||||
// 详情页单独走 React Router 渲染, 不参与滑屏
|
if (isNewsDetail || isArticleDetail || isFinanceDetail) {
|
||||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy|news)\/[^/]+$/)) {
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="data/:name" element={<DataView />} />
|
|
||||||
<Route path="strategy/:name" element={<StrategyView />} />
|
|
||||||
<Route path="articles/:slug" element={<ArticleDetail />} />
|
|
||||||
<Route path="news/:id" element={<NewsDetail />} />
|
<Route path="news/:id" element={<NewsDetail />} />
|
||||||
|
<Route path="articles/:slug" element={<ArticleDetail />} />
|
||||||
|
<Route path="finance/:catId/:itemId" element={<FinanceItemView />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 子 tab 切换用 replace: 横滑多次不会在历史里堆一串记录,
|
||||||
|
// 返回键语义保持为「离开宏观」; 详情页仍按默认 push
|
||||||
|
function onChange(key) {
|
||||||
|
if (key.startsWith('finance:')) {
|
||||||
|
navigate('/macro/finance/' + key.slice('finance:'.length), { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = BASE_TABS.find((t) => t.key === key)
|
||||||
|
if (next) navigate(next.path, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-with-navbar">
|
<div className="page-with-navbar">
|
||||||
<PageHeader title="宏观" />
|
<PageHeader title="宏观" />
|
||||||
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
|
<SwipeTabs tabs={tabs} active={active} onChange={onChange} swipeable />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+222
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile'
|
import { Avatar, Button, Dialog, Input, List, ProgressBar, Switch, Toast } from 'antd-mobile'
|
||||||
import { useAuth } from '../auth/AuthContext.jsx'
|
import { useAuth } from '../auth/AuthContext.jsx'
|
||||||
import {
|
import {
|
||||||
applyOtaUpdate,
|
applyOtaUpdate,
|
||||||
@@ -13,6 +13,31 @@ import {
|
|||||||
rollbackOta,
|
rollbackOta,
|
||||||
setOtaDevMode,
|
setOtaDevMode,
|
||||||
} from '../api/ota.js'
|
} from '../api/ota.js'
|
||||||
|
import { refreshIfinanceInfo, useIfinanceInfo } from '../lib/ifinance.js'
|
||||||
|
import { getMcpEndpoint, mcpTokenApi } from '../api/mcp.js'
|
||||||
|
|
||||||
|
// ifinance 构建时间: 优先按 info.json 里的 ISO 字符串原样展示 (北京时间),
|
||||||
|
// 避免设备时区不同导致显示偏移。
|
||||||
|
function fmtIfinanceTime(value) {
|
||||||
|
if (!value) return '—'
|
||||||
|
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/)
|
||||||
|
if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`
|
||||||
|
const d = new Date(value)
|
||||||
|
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCheckTime(ts) {
|
||||||
|
if (!ts) return '—'
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
|
||||||
@@ -54,6 +79,21 @@ export default function MePage() {
|
|||||||
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
|
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
|
||||||
const [devMode, setDevMode] = useState(() => getOtaDevMode())
|
const [devMode, setDevMode] = useState(() => getOtaDevMode())
|
||||||
|
|
||||||
|
// AI 接入 (MCP) 个人 token
|
||||||
|
const [mcpTokens, setMcpTokens] = useState([])
|
||||||
|
const [mcpName, setMcpName] = useState('')
|
||||||
|
const [mcpCreating, setMcpCreating] = useState(false)
|
||||||
|
const [mcpError, setMcpError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
mcpTokenApi
|
||||||
|
.list()
|
||||||
|
.then((r) => { if (!cancelled) setMcpTokens(r.items || []) })
|
||||||
|
.catch((e) => { if (!cancelled) setMcpError(e?.message || 'Token 列表加载失败') })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
|
// 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -120,6 +160,118 @@ export default function MePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ifinance 外部数据索引: 版本/构建时间 + 手动更新
|
||||||
|
const {
|
||||||
|
info: ifinanceInfo,
|
||||||
|
checkedAt: ifinanceCheckedAt,
|
||||||
|
error: ifinanceError,
|
||||||
|
} = useIfinanceInfo()
|
||||||
|
const [ifinanceBusy, setIfinanceBusy] = useState(false)
|
||||||
|
|
||||||
|
async function onRefreshIfinance() {
|
||||||
|
if (ifinanceBusy) return
|
||||||
|
setIfinanceBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await refreshIfinanceInfo()
|
||||||
|
Toast.show({
|
||||||
|
icon: 'success',
|
||||||
|
content: r.changed
|
||||||
|
? 'ifinance 已更新到 v' + (r.info?.version || '')
|
||||||
|
: 'ifinance 已是最新 (v' + (r.info?.version || '—') + ')',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ icon: 'fail', content: e?.message || 'ifinance 更新失败' })
|
||||||
|
} finally {
|
||||||
|
setIfinanceBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- AI 接入 (MCP) ----------
|
||||||
|
|
||||||
|
async function copyText(text, label) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
Toast.show({ icon: 'success', content: label + '已复制' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* 降级到 execCommand */ }
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
const ok = document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
Toast.show(ok
|
||||||
|
? { icon: 'success', content: label + '已复制' }
|
||||||
|
: { content: '复制失败,请手动选择文本' })
|
||||||
|
} catch {
|
||||||
|
Toast.show({ content: '复制失败,请手动选择文本' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewToken(raw, item) {
|
||||||
|
Dialog.alert({
|
||||||
|
title: `Token「${item.name}」已生成`,
|
||||||
|
content: (
|
||||||
|
<div className="mcp-token-dialog">
|
||||||
|
<div className="mcp-token-hint">
|
||||||
|
仅显示这一次,请立即保存。任何拿到该 Token 的人都能以你的身份查询、新增、修改、删除和排序全部数据。
|
||||||
|
</div>
|
||||||
|
<div className="mcp-token-value">{raw}</div>
|
||||||
|
<div className="mcp-token-actions">
|
||||||
|
<Button size="mini" color="primary" onClick={() => copyText(raw, 'Token')}>
|
||||||
|
复制 Token
|
||||||
|
</Button>
|
||||||
|
<Button size="mini" fill="outline" onClick={() => copyText(getMcpEndpoint(), 'MCP 地址')}>
|
||||||
|
复制 MCP 地址
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
confirmText: '我已保存',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateMcpToken() {
|
||||||
|
const name = mcpName.trim()
|
||||||
|
if (!name) {
|
||||||
|
Toast.show({ content: '请先填写 Token 名称,例如 R1' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMcpCreating(true)
|
||||||
|
setMcpError('')
|
||||||
|
try {
|
||||||
|
const r = await mcpTokenApi.create(name)
|
||||||
|
setMcpTokens((prev) => [...prev, r.item])
|
||||||
|
setMcpName('')
|
||||||
|
showNewToken(r.token, r.item)
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ icon: 'fail', content: e?.message || '创建失败' })
|
||||||
|
} finally {
|
||||||
|
setMcpCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteMcpToken(item) {
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
content: `删除 Token「${item.name}」?使用该 Token 的 AI 会立即失去访问权限。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await mcpTokenApi.remove(item.id)
|
||||||
|
setMcpTokens((prev) => prev.filter((t) => t.id !== item.id))
|
||||||
|
Toast.show({ icon: 'success', content: '已删除' })
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ icon: 'fail', content: e?.message || '删除失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
const confirmed = window.confirm('确定退出登录?')
|
const confirmed = window.confirm('确定退出登录?')
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -147,6 +299,75 @@ export default function MePage() {
|
|||||||
<div className="me-email">{user?.email}</div>
|
<div className="me-email">{user?.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ifinance 外部数据索引: 点击版本行手动更新 info.json (每日也会自动更新一次) */}
|
||||||
|
<List className="me-list" mode="card" header="外部数据 (ifinance)">
|
||||||
|
<List.Item
|
||||||
|
clickable
|
||||||
|
onClick={onRefreshIfinance}
|
||||||
|
extra={ifinanceBusy ? '更新中…' : 'v' + (ifinanceInfo?.version || '—')}
|
||||||
|
description={ifinanceBusy ? '正在拉取 info.json…' : '点击检查并更新索引'}
|
||||||
|
>
|
||||||
|
ifinance 版本
|
||||||
|
</List.Item>
|
||||||
|
<List.Item extra={fmtIfinanceTime(ifinanceInfo?.generatedAt)}>构建时间</List.Item>
|
||||||
|
{ifinanceInfo?.buildId && (
|
||||||
|
<List.Item extra={ifinanceInfo.buildId}>构建号</List.Item>
|
||||||
|
)}
|
||||||
|
<List.Item extra={fmtCheckTime(ifinanceCheckedAt)}>上次检查</List.Item>
|
||||||
|
{ifinanceError && (
|
||||||
|
<List.Item>
|
||||||
|
<span className="me-ota-error">{ifinanceError}</span>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{/* AI 接入 (MCP): 个人 token, 拥有当前用户的全部业务权限 */}
|
||||||
|
<List className="me-list" mode="card" header="AI 接入 (MCP)">
|
||||||
|
<List.Item
|
||||||
|
description={getMcpEndpoint()}
|
||||||
|
extra={<span className="mcp-link" onClick={() => copyText(getMcpEndpoint(), 'MCP 地址')}>复制</span>}
|
||||||
|
>
|
||||||
|
MCP 服务地址
|
||||||
|
</List.Item>
|
||||||
|
<List.Item description="使用 Authorization: Bearer <Token> 调用,拥有你的查询、新增、修改、删除、排序等全部权限">
|
||||||
|
授权说明
|
||||||
|
</List.Item>
|
||||||
|
{mcpTokens.map((t) => (
|
||||||
|
<List.Item
|
||||||
|
key={t.id}
|
||||||
|
extra={<span className="mcp-link danger" onClick={() => onDeleteMcpToken(t)}>删除</span>}
|
||||||
|
description={
|
||||||
|
`创建 ${fmtCheckTime(t.createdAt)} · 最近使用 ${t.lastUsedAt ? fmtCheckTime(t.lastUsedAt) : '从未'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="mcp-token-name">{t.name}</span>
|
||||||
|
<span className="mcp-token-prefix">{t.prefix}…</span>
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
{!mcpTokens.length && !mcpError && (
|
||||||
|
<List.Item description="还没有 Token,在下方创建一个给 AI 使用">暂无 Token</List.Item>
|
||||||
|
)}
|
||||||
|
<List.Item>
|
||||||
|
<div className="mcp-create">
|
||||||
|
<Input
|
||||||
|
placeholder="Token 名称,例如 R1"
|
||||||
|
value={mcpName}
|
||||||
|
onChange={setMcpName}
|
||||||
|
onEnterPress={onCreateMcpToken}
|
||||||
|
maxLength={32}
|
||||||
|
/>
|
||||||
|
<Button size="small" color="primary" loading={mcpCreating} onClick={onCreateMcpToken}>
|
||||||
|
新建 Token
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
{mcpError && (
|
||||||
|
<List.Item>
|
||||||
|
<span className="me-ota-error">{mcpError}</span>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
{/* OTA 升级区 */}
|
{/* OTA 升级区 */}
|
||||||
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
||||||
<List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
|
<List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user