26 Commits
Author SHA1 Message Date
jif2.zhang a5ae211496 Merge branch 'master' into android-1.0
Android Build & Publish / build-android (push) Successful in 11m49s
2026-11-10 09:24:23 +08:00
jif2.zhang e05ed6b491 fix(任务): 归档以整棵树为单位并移除隐藏中间态
Docker Build and Push / build-image (push) Successful in 1m42s
- 自动归档改为 doneAt + autoArchive 到期,只判定顶层任务,到期整树归档
- 手动归档/恢复提升到根父任务,子任务完全跟随父任务
- 新增 normalizeArchiveTree 修复历史父子归档不一致
- 移除按列保留窗口的显示过滤(前后端实现删除,T-FL-01~15 废止)
- 列 retention 字段仅兼容旧数据保留,不再参与判定
- 已归档查询返回归档子任务,删除提示可正确显示连带条数
2026-11-10 09:20:40 +08:00
jif2.zhang af2a1deb1a 合并 master 到 android-1.0: 软键盘遮挡修复, 触发 APK 构建
Android Build & Publish / build-android (push) Successful in 28m46s
2026-11-09 16:25:49 +08:00
jif2.zhang 7b52220aad fix(键盘): 统一修复 Android 软键盘遮挡输入框
Docker Build and Push / build-image (push) Successful in 3m52s
根因: Tauri targetSdk 37 强制 edge-to-edge, MainActivity 不处理 IME Insets,
系统不再因键盘缩小窗口, 清单 adjustResize 失效, 键盘直接盖住 WebView。

- 新增 scripts/ci/android-softinput.sh: init 后注入 windowSoftInputMode=adjustResize,
  并重写 MainActivity.kt 监听 IME Insets, 键盘可见时把高度加到内容容器底部 (API 30+),
  让 WebView 真正变矮, 所有 bottom 弹层自动上移
- CI (android-build.yml) 与本地 build-apk.sh 在 init 之后、build 之前调用该脚本 (幂等)
- 新增全局 useKeepInputVisible: 输入框聚焦后用 scroll-into-view-if-needed 滚入可视区,
  覆盖页面滚动与 Popup 内部滚动
- 弹层补 max-height + 内部滚动, 防止键盘弹出后顶部被顶出屏幕
- calibrateSafeTop 在键盘弹出时保留旧值, 避免误判导致整页跳动
2026-11-09 16:25:11 +08:00
jif2.zhang 3730e55e93 feat(打卡): 新建打卡项去掉「每年」周期选项
Docker Build and Push / build-image (push) Failing after 1m23s
新建时只保留 每天/每周/每月; 历史遗留的 year 项在编辑时仍显示「每年」, 避免当前周期无选中项
2026-11-09 15:23:02 +08:00
jif2.zhang 902c6a0ebe feat(宏观): 子 tab 整屏左右滑动并与顶部同步
Docker Build and Push / build-image (push) Successful in 4m3s
SwipeTabs 新增 swipeable 开关, Macro 开启, 打卡/监控保持原行为; 使用 swiper 14 实现整屏分页 + 首尾橡皮筋 + 松手吸附 + 方向锁; 滑动/点击/路由双向同步, 子 tab 切换用 replace 不堆历史; 每个 slide 独立竖向滚动, pane 节点缓存避免横滑时重渲染列表; 宏观内部切 tab 不再重复播放整页入场动画
2026-11-09 15:12:11 +08:00
jif2.zhang ec6c9c7677 feat(AI接入): 内置 MCP 服务与个人 Token 管理
Docker Build and Push / build-image (push) Successful in 4m34s
- 新增 /api/mcp (Streamable HTTP) 与 41 个工具: 项目/看板列/任务/清单/打卡; - 新增 McpToken 模型与迁移, DB 仅存 SHA-256 哈希, token 绑定用户; - 新增 /api/mcp-tokens 增删改查, 支持独立命名 (如 R1); - 「我的」页新增 AI 接入 (MCP) 区块: 地址复制/新建/一次性展示/删除; - 补充 docs/MCP.md 与样式
2026-11-09 10:41:08 +08:00
jif2.zhang fa172d8974 feat(宏观): ifinance 版本展示、手动/每日更新与按需文件缓存
Docker Build and Push / build-image (push) Successful in 3m58s
- 「我」页展示 ifinance 版本/构建时间/构建号/上次检查, 点击手动更新 info.json
- 启动及每 30 分钟检查, 超过 24h 自动刷新索引
- 新增 Service Worker: 按 files[].hash 内容寻址, 只缓存打开过的页面
- 未命中时按需下载单个文件并校验 sha256, 索引升级后清理旧缓存
- 缓存页面通过 CSP sandbox 隔离, 支持看板行内脚本且无法访问宿主数据
2026-11-03 15:33:40 +08:00
jif2.zhang 5622e4b3d4 fix(打卡): 修复日历切上月看不到记录; 当日进度改按当天份额统计
Docker Build and Push / build-image (push) Successful in 1m40s
- date.js: addMonths 对 "YYYY-MM" 月粒度保持月粒度, 不再生成 "YYYY-MM-01"。
  旧逻辑使日历切上月时请求 month=2026-10-01, 服务端判为非法后静默回退本月,
  表现为上个月一片空白。
- MergedView: 「回到今天」同时把月份切回当前月; 日标题「已完成 x/y」改为
  按 items 的 dayDone 现算, 避免把同周期后续日期的打卡计入历史日期,
  并让打卡/撤销的乐观更新立即反映到标题。
- server /calendar: 兼容旧的 month=YYYY-MM-DD 请求并截回月份, 不再静默回退。
- server /habits: 回看历史日期时过滤当天尚未创建的打卡项, 与 /calendar 口径一致;
  summary.done 改用 dayDone (当天份额) 而非 completed (周期完成度)。

验证: Vite dev + 生产数据 (admin) 2026-10 显示 10 个环 / 完成率 32%,
10-22 的标题由错误的 3/14 修正为 1/14; 「回到今天」回到 2026-11 (14%)。
2026-11-02 13:54:59 +08:00
Cheney 308ace8559 ci(android): Rust 缓存 key 升到 v2, 重建含 cargo-ndk/settings.toml 的完整缓存
Android Build & Publish / build-android (push) Successful in 16m7s
2026-11-02 10:28:13 +08:00
Cheney 6d560583ae ci(android): 修复 Rust 缓存命中后 cargo 报 no default toolchain
Android Build & Publish / build-android (push) Successful in 16m27s
2026-11-02 10:11:11 +08:00
jif2.zhang f2c7dae4a8 Merge branch 'dev-0.2' into android-1.0
Android Build & Publish / build-android (push) Failing after 6m23s
2026-10-30 17:17:52 +08:00
jif2.zhang afbb9bc603 feat(宏观): ifinance 分类动态挂载为宏观 tab, 报警迁入监控, 下线油价/本地经济数据/策略研究
Docker Build and Push / build-image (push) Successful in 2m37s
- 宏观只保留本地 新闻/经济文章, 外接 ifinance categories[] 动态成为 tab (纯前端直连, CORS/_headers, https)
- 报警从宏观迁到监控第 4 个 tab (保留 Alert 模块, 供未来新增报警源)
- 彻底删除油价/经济数据/策略研究: 前端组件+api、后端 apizero/data 路由、cron、fetchers、种子数据
- 新增迁移 DROP Indicator/IndicatorPoint/Strategy/OilPriceHistory; 重新生成 Prisma Client
- CSP 放开 *.honor3.com (含端口通配), 避免新增子域反复重装外壳
2026-10-30 17:17:10 +08:00
jif2.zhang 82c4ae8a84 fix(项目设置): 修复「隐藏的任务自动归档」提示成功但不生效
Docker Build and Push / build-image (push) Successful in 1m34s
现象: 项目设置 -> 归档设置 -> 隐藏的任务自动归档, 选完档位提示「已保存」,
重新打开仍是「永不」, 且不会产生任何归档。

根因 (读写两条路都断了, 缺一不可):
1. 读: projectSummary() 没有回传 autoArchive, 而 ProjectManage 读的是
   project.autoArchive -> 永远是 undefined -> 界面恒显示默认值「永不」;
2. 写: projectApi.update() 的字段白名单里没有 autoArchive 分支
   (只有 name/code/taskKinds/icon/archived/notes), 传进来直接被忽略,
   data 为空 -> 一条 SQL 都不执行 -> 但函数正常返回, 调用方照常弹「已保存」。

服务端一直是对的: powersync 上传分支处理了 autoArchive, REST 也有校验,
所以不是同步丢字段。

修复:
- projectSummary 增加 autoArchive: p.autoArchive || 'never';
- projectApi.update 增加 autoArchive 分支, 校验取值与 AUTO_ARCHIVE_OPTIONS 同口径。

验证 (真实 Windows 外壳, 临时暴露 API 直调被修改的函数):
- update({autoArchive:'3d'}) 返回 3d; 重新 list/detail 仍为 3d (已落库);
- 进项目设置, 该行显示「一周后 ›」(设成 1w 后), 不再恒为「永不」;
- 还原为 never 后界面同步回「永不」。
- 顺带确认「同样隐藏已完成」的列级设置 retention 读写都正常:
  Done 列 retention=3d, 服务端 26 条已完成中 9 条超 3 天,
  App 实际只返回 14 条 -> 过滤本身是生效的, 此前不生效的只有自动归档档位。
- 控制台无异常; 验证用的临时钩子已移除。
2026-10-28 18:32:40 +08:00
jif2.zhang c5fc328f2b feat(任务/打卡): 已归档页面按原型重做; 打卡日历完成度改 4 档配色
Docker Build and Push / build-image (push) Successful in 1m27s
一、已归档任务管理: 按评审通过的原型落地 (docs/V0.2/任务模块/已归档任务管理-原型.html)

- 去掉「任务类型」过滤: 类型是弱维度, 归档场景几乎不按它检索, 反而占掉一整行;
  类型标签仍在行内展示。
- 关键词输入框前加常驻标签「标题」, 框内占位改为「输入关键词」。
- 归档时间新增快捷项: 全部 / 本周 / 本月 / 本季度 / 自定义;
  选中后下方灰字回显实际区间 (本周=周一起算, 区间截至今天, 不跨未来);
  「自定义」才展开两个日期选择器。
- 补上「筛选」按钮 (带生效条件数徽标) 与「重置」: 条件先编辑, 点按钮统一提交。
  原实现改任一条件就即时重查, 连点会连发请求, 输关键词时列表会闪。
- 列表上方显示已应用条件标签 (可单独 ✕ 撤销) + 命中条数。
- 列表行重排: 标题独占一行, 元信息收成一行 (#编号 · 看板列 · 归档于日期),
  「恢复 / 删除」由右侧竖排改到底部右侧横排, 降低行高。
- 空状态区分「这个项目还没有归档任务」(提示去 ⋯ 菜单归档) 与
  「没有符合条件的归档任务」(建议放宽条件)。
- 修正日期边界: archivedList 的 from/to 原来直接拼 'T00:00:00Z',
  本地选的日期会偏移 8 小时, 当天凌晨的归档被漏掉; 改为按本地时区换算成 UTC 瞬时。

二、打卡日历完成度配色: 由 3 档改 4 档

    小半 (ratio < 0.5)  -> 红 #ff3141
    大半 (0.5 <= r < 1) -> 橘 #ff8f1f
    完成 (r = 1)        -> 蓝 #1677ff
    超额                -> 绿 #00b578

  注意服务端统计时把 actual 封顶在 expected (`Math.min(cnt, exp)`),
  所以 ratio 永远 <= 1,**超额只能靠 status === 'over' 判断**,不能看比例;
  DayRing 内新增 tierOf() 统一分级,图例同步改为四档。

验证 (真实 Windows 外壳 + CDP):
- 打卡页: 图例显示「小半/大半/完成/超额」, 计算样式分别为
  rgb(255,49,65) / rgb(255,143,31) / rgb(22,119,255) / rgb(0,181,120);
  当月实际渲染出 low=5 / high=1 / over=1。
- 已归档页: 筛选行标签为 标题/看板列/归档时间; 时间选项五项齐全;
  按钮为 重置/筛选; 点「本周」回显 2026-10-26 ~ 2026-10-28, 点「筛选」后
  徽标显示 1、条件标签可撤销、命中条数正确。
- 归档 → 已归档列表可见 → 恢复 全链路走通 (含上次修好的 TDZ 问题),
  服务端确认 archived 由 t 变回 f。
- 控制台无异常。
2026-10-28 10:19:17 +08:00
jif2.zhang 77e057c580 docs(任务): 已归档任务管理界面重设计原型 (待评审, 未改业务代码)
Docker Build and Push / build-image (push) Successful in 1m29s
按评审意见重做「已归档任务」全屏弹层的界面, 产出可交互原型:
docs/V0.2/任务模块/已归档任务管理-原型.html

四点改动:
1. 去掉「类型」过滤 —— 类型是弱维度, 归档场景几乎不按类型检索, 反而占掉一整行;
2. 关键词输入框前加常驻标签「标题」, 框内占位改为「输入关键词」;
3. 归档时间新增快捷预设: 全部 / 本周 / 本月 / 本季度 (+ 自定义才展开日期区间),
   选完在下方灰字回显实际区间, 避免"以为选的是整周";
4. 补上「筛选」按钮 —— 原版改任何条件都立即查询且没有确认动作; 改为条件填完一次性提交,
   按钮带生效条件数徽标, 另有「重置」。

附带改进:
- 列表上方显示已应用条件标签(可单独 × 撤销) + 命中条数;
- 列表行重排: 标题独占一行, 元信息收成一行(#编号 · 看板列 · 归档于日期),
  「恢复/删除」从右侧竖排改到底部右侧横排, 降低行高;
- 空状态区分「还没归档过」与「筛选无结果」, 分别给出下一步提示;
- 类型标签仍在行内展示, 只是不再作为筛选项。

原型为单文件 HTML (无外部依赖), 可交互: 点预设/筛选/恢复/删除均有反馈。
已用无头浏览器验证: 无 JS 错误, 本周预设算出 2026-10-26~2026-10-28, 筛选命中条数正确。

业务代码未改动, 等待评审确认后再落地。
2026-10-28 09:51:00 +08:00
jif2.zhang 75cabe8339 fix(任务): 修复归档/恢复归档报 "Cannot access 'a' before initialization"
根因: api/task.js 的 archive / unarchive 里写了 `const now = now()`。
同一函数作用域内的 `const now` 会遮蔽模块级的 now 函数, 而初始化表达式的右侧
在求值时该 const 正处于 TDZ, 于是抛 ReferenceError。
源码里报错信息是 "Cannot access 'now' before initialization",
经 vite 压缩后变量被重命名为单字母, 用户端看到的就是 "Cannt access 'a' before i"。

影响: 点「归档」确认后必然失败 (弹错误框); 恢复归档同理。删除/编辑不受影响。

修复: 局部变量改名为 ts, 并补注释避免后人再写回同名。

验证 (真实 Windows 外壳 + CDP 走 UI):
- 点任务行 ⋯ → 归档 → 确认: 无异常, 行从列表消失, 任务数减少;
- 服务端确认已落库: archived=t / archivedAt / archiveSource=manual;
- 测试数据已还原 (archived=f)。

顺带全仓搜索 `const X = X()` 自遮蔽写法, 仅这两处, 无其它同类隐患。
2026-10-28 09:45:43 +08:00
jif2.zhang 58879024a2 docs+feat(即时同步): 实测端到端 1~2s 达标; 补回滚开关; 订正两次错误排查结论
Docker Build and Push / build-image (push) Successful in 1m26s
一、结论订正 (第二次, 前两次均被测量方式误导)

初次把 ~14s 延迟归因于 nginx 未关 proxy_buffering —— 实测被推翻:
加了 proxy_buffering off / proxy_read_timeout 1h 并热重载, nginx -T 确认生效, 延迟毫无变化。
(该配置已回滚: advanced_config 清空、生效 conf 还原、nginx 已重载。保留它反而会让 OTA 大包
 下载失去缓冲保护, 属净负收益。)

随后把 ~15-30s 延迟归因于客户端 db.watch/渲染滞后 —— 同样被推翻:
错误在于测量方法, 用 CDP 反复读 document.body.innerText (长列表页开销大) 去轮询,
且把 ssh + docker exec 的 ~6s 开销算进了延迟。

改为**页面内自计时**后 (50ms 轮询本地库 + MutationObserver 观察 DOM, 各自记录 performance.now),
一次干净测量的结果:
- psql 命令总耗时 (ssh + docker exec + UPDATE): 2138ms
- 数据出现在客户端本地库: 2251ms  (≈ 命令返回后 113ms)
- 界面更新: 2580ms
- 同步数据到达 -> 渲染完成: 329ms

即端到端约 1~2s, 其中 UI 渲染只占 ~0.33s, useDbSignal 的表级 watch 工作正常,
无需改用 useQuery。服务端侧的 powersync 日志也一致 (checkpoint_complete 与改库同一秒,
stream_ms 达 42 分钟、close_reason: client closing stream, 长连接稳定)。

二、补回滚开关

文档验收标准里的"回滚开关"此前并未实现。useDbSignal 现在读取
localStorage['iboard_realtime'] === '0' 时完全不订阅, 退回手动刷新,
用于线上出问题时的快速止损 (重启应用生效)。

三、文档

§7.0 改为"端到端约 1~2s (达标)", 写入可复现的页面内自计时测量方法,
并留档两次被推翻的结论及各自错因, 避免后人重复踩坑。§6.2 验收标准同步更新。
2026-10-27 22:20:53 +08:00
jif2.zhang 96f4e09bd5 docs(即时同步): 订正延迟排查结论 —— 不是 nginx 缓冲, 瓶颈在客户端应用/渲染段
Docker Build and Push / build-image (push) Successful in 1m28s
上一版文档把 ~14s 延迟归因于 nginx 未关闭 proxy_buffering, 实测推翻了该判断:
- 已为 iboard.honor3.com 配置 proxy_buffering off / proxy_read_timeout 1h 并热重载,
  nginx -T 确认生效, 但端到端延迟无任何改善;
- powersync 服务日志 (docker logs -t): 改库与服务端 checkpoint_complete 几乎同一秒
  (13:46:53.738), 另一次约 6s —— 服务端推送很快;
- 同步流是长连接 (stream_ms 达 42 分钟, close_reason: client closing stream), 非频繁重连;
- 临时把 db 挂到 window.__psdb 轮询: 数据确实已写入客户端本地库, 但 DOM 更新明显滞后。

结论修正为: 瓶颈在客户端「同步数据应用 → db.watch 通知 → React 渲染」这一段,
延迟 15~30s 且波动 (1s/6s/17s/27s), 更像排队或重试退避。文档补充了下一步排查方向。

nginx 那组配置未回滚: 对长连接流式接口属合理配置, 且已验证不影响其它站点。
2026-10-27 21:54:20 +08:00
jif2.zhang fd4079c9c3 feat(任务): 即时同步 —— 订阅本地库变更, 跨端改动无需切页自动刷新
Docker Build and Push / build-image (push) Failing after 1m48s
现象: 多端同时打开同一项目时, 一端改完, 另一端必须切到别的页面再切回来才更新。

根因: 不是同步没到, 而是 UI 从未订阅本地库变化 ——
PowerSync 的下行推送 (PG 逻辑复制 → sync service → 客户端本地 SQLite) 本身正常,
但任务模块的读全是命令式 db().getAll() (api/task.js), 读完不再看;
lib/powersync/react.jsx 虽然导出了 useQuery/useStatus, 全项目一次都没用过;
TaskPage/ProjectView 只在 useEffect 里 load() 一次, 连 useRefreshOnVisible 都没接
(只有打卡与宏观页面在用)。「切面板能刷新」纯粹是因为组件重新挂载又 load 了一次。

实现 (docs/V0.2/离线编辑模块/即时同步方案.md, 方案 C):
- 新增 lib/powersync/useDbSignal.js: 用 db.watch() 的表级过滤 (tables) + 节流
  (throttleMs) + AbortSignal, 订阅本地库变更; 跳过订阅时的首次通知, 避免与
  挂载时的 load() 重复。
  (初版设计是手写 count/sum/max(updatedAt) 的「信号 SQL」, 实施时发现 SDK 原生
   支持 tables 过滤, 更可靠, 已改为表过滤并在文档中记录变更。)
- api/task.js 新增 TASK_TABLES / PROJECT_TABLES 常量 (表名须与客户端 schema 一致)。
- ProjectView: 订阅任务域 (Task/BoardColumn/TaskMember/TaskTag) → 重算;
  TaskPage: 订阅项目域 (Project/ProjectMember) → 重算。
- 不打断操作: BoardView/ListView 上报 onDragActiveChange, 拖拽期间收到变更只记
  pending, 松手后补一次刷新, 避免卡片在手指下跳位。
- 回前台兜底: 两页都接 useRefreshOnVisible (WebView 后台可能节流订阅回调)。

验证 (真实 Windows 外壳 + CDP, 服务端直接改库模拟另一端):
- 服务端改任务标题后, 页面在**无任何操作**的情况下自动更新 (三次复测均成功);
- 改回原标题后同样自动还原; 测试数据已恢复。

已知瓶颈 (不在 UI 层, 另见文档 §7.0):
- 实测端到端延迟约 13~14s, 未达 ≤1s 目标;
- CDP Network 观察客户端: 无 WebSocket, 仅 /sync/stream 长连接;
- NPM (baishe nginx-app-1, proxy_host/5.conf) 未关闭 proxy_buffering,
  流式响应被 nginx 缓冲后按批下发, 与观测到的固定批次延迟吻合;
- 修复属运维配置 (proxy_buffering off 等), 需单独评估后执行。
2026-10-27 20:57:05 +08:00
jif2.zhang 86335dfaa4 fix(离线库): 关闭 PowerSync 多标签共享 worker —— 修复 Windows 升级后任务页永久「加载中」
Docker Build and Push / build-image (push) Successful in 1m30s
现象: Windows 外壳 OTA 升级后, 任务页一直「加载中」; 其它不访问本地库的页面正常。
Android 正常。仅「原地升级/reload 之后」出现, 全新启动进程则正常。

根因: PowerSync Web 的 enableMultiTabs 默认在桌面端开启 (有 SharedWorker 时),
开启后会额外用 **共享 IndexedDB worker** 承载本地库。该 SharedWorker 跨页面 reload
存活, OTA applyUpdate 后 location.reload() 的新页面连上去时, 本地库读取永久挂起
—— 无异常、纯 pending, 所以 TaskPage 的 projects 一直是 null, 停在「加载中…」。
Android WebView 不支持 SharedWorker, 走独立 worker (随页面销毁), 因此不受影响。

实测 (真实 Windows 外壳 + WebView2 CDP):
- 修复前: 目标页 /me 正常; Page.reload 后落到 /task 显示「加载中…」, 再导航仍卡住;
  shared_worker 目标仍在 (worker-*.js 前后同 URL)。
- 修复后: reload 后 /task 正常渲染 25 个任务; shared_worker 目标消失;
  控制台仅剩 PowerSync「未启用多标签」提示。

修复: database.enableMultiTabs = false。本应用是单窗口外壳 (dock.html 不访问本地库),
不需要多标签同步; 该选项关闭后本地库改用随页面销毁的独立 worker, reload 干净。

注: 该问题与 OTA 包内容无关 (同一个包全新启动正常), 属 reload 路径的运行时缺陷。
2026-10-27 19:56:25 +08:00
jif2.zhang a23b10959c fix(同步): 上传路径补上 Task.status 与 Project.icon —— 此前静默丢弃
Docker Build and Push / build-image (push) Successful in 1m35s
现象: 任务点完成后被自动移入 Done 分组, 但详情页状态仍是「未开始」。

根因: /api/powersync/upload 的 **更新分支** 漏了 status 字段 ——
Task 更新分支逐个处理了 title/description/kind/markDate/extras/sortOrder/
assigneeId/doneAt/archived*/columnId/parentId, 唯独没有 d.status
(离线新建分支是处理了的, 所以只有"改已有任务"这条路丢状态)。
客户端 api/task.js 本地写入时 status 与 columnId 是一起算、一起写的
(改状态会按列绑定归位到 Done 列), connector 上行回读整行, 所以 payload 里
status 一定存在 —— 是服务端 prisma.task.update 时没把它放进 data。

结果: 列变更落地、状态被丢弃; 下行同步以服务端为权威, 又把本地 status 覆盖
回去, 于是"看板在 Done、详情未开始"。

数据实证 (生产 PG): 9 条顶层任务处于 Done 列 (boundStatus=done) 但 status
非 done (8 条 todo + 1 条 doing), 且全部 doneAt 有值; 反向 (status=done 却
不在 done 列) 0 条 —— 单向故障, 排除下行覆盖/并发冲突。这 9 条的 columnId 与
doneAt 在同一个 update 里都写成功了, 唯独 status 没有, 只可能是它压根没进 data。

修复 (稳妥方案, 复用唯一口径):
- resolveStatus.js 新增 resolveTaskUpload(): 客户端上行的是**本地整行**, 字段
  总是存在, 不能用"字段是否存在"判断变更, 必须先与服务端现值比较, 再交给
  resolveTaskWrite 裁决。不这样做会踩 T-BD-16: 任务从 Plan 拖到未绑定状态的
  Action 列时, 整行里 status 仍是 todo, 若当作"本次变更"提交, 场景 B 会把任务
  从 Action 归位回 Plan (拖过去又被弹回来)。
- powersync.js Task 更新分支改为调用 resolveTaskUpload, 并把 task.column 与
  columnId 一起指向目标列 (否则场景 B 会拿旧列判断, 同样误归位);
  doneAt 与最终状态保持一致 (进入 done 记时 / 离开 done 清空, 口径同 REST)。
- Project 更新分支补 icon (客户端本地写了 icon, 服务端同样丢弃), 校验口径与
  REST 写接口一致: 必须 data:image/ 前缀, 长度 <= 300_000。
- 上传处理器新增按项目缓存的 loadColumns (ops 已按表序排列, BoardColumn 先于
  Task 落地, 故 Task 阶段读到的列是本批次最终结果)。

用例 (10 项, 含关键回归与旧思路对照):
仅换列到未绑定列保持列与状态 / 换列到绑定列状态跟随 / 仅改状态归位 /
无变化返回 null / 脏数据自愈 / 子任务豁免 / 无匹配列不移动 /
对照证明旧思路会把 Action 里的任务弹回 Plan。

存量脏数据 (9 条) 需另行订正为 done, 不在本提交内。
2026-10-27 17:28:38 +08:00
jif2.zhang 4ded8e30ed ci(android): 重跑构建 —— runner 磁盘不足已清理 (No space left on device)
Android Build & Publish / build-android (push) Successful in 19m51s
2026-10-26 21:56:34 +08:00
jif2.zhang be70868d0b chore(deps): 同步 package-lock —— tauri CLI 2.12.4 平台包对齐 (修复 npm ci 校验失败)
Android Build & Publish / build-android (push) Failing after 10m36s
2026-10-26 21:39:13 +08:00
jif2.zhang 49583599b3 Merge branch 'dev-0.2' into android-1.0 2026-10-26 21:34:39 +08:00
jif2.zhang 8cfa3802d5 合并 master 到 android-1.0: 引入米家环境监控模块与二维码登录修复, 触发 APK 构建
Android Build & Publish / build-android (push) Successful in 33m2s
2026-10-23 22:24:30 +08:00
117 changed files with 7357 additions and 10551 deletions
+24 -10
View File
@@ -60,9 +60,13 @@ jobs:
with:
path: |
~/.cargo
~/.rustup/toolchains
~/.rustup
# 工具链升级时 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
uses: https://gitea.com/actions/cache@v4
@@ -72,16 +76,22 @@ jobs:
key: tauri-target-android-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: tauri-target-android-
# ---------- 安装工具链 (仅缓存未命中时) ----------
- name: Install Rust + Android targets
if: steps.cache-rust.outputs.cache-hit != 'true'
# ---------- 安装/修复工具链 (缓存命中时也要执行) ----------
# 缓存命中时不能只是跳过安装: 缓存里可能缺少 ~/.rustup/settings.toml,
# rustup 代理会报 "no default is configured", 导致 cargo / tauri 全部失败。
# 因此这一步无条件运行, 但都是幂等操作 (已安装时不会重新下载)。
- name: Setup Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
if [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
. "$HOME/.cargo/env"
# 幂等: stable 已在 ~/.rustup/toolchains 时只补写 settings.toml
rustup default stable
# 只构建 aarch64 (现代 Android 手机); 需要模拟器/x86 设备时再加回其他 target
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
if: steps.cache-android.outputs.cache-hit != 'true'
@@ -117,12 +127,16 @@ jobs:
- name: Init Android project
run: |
# tauri android init 需要 cargo (运行 cargo metadata)。
# Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH,
# 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。
# Setup Rust toolchain 已把 ~/.cargo/bin 写入 GITHUB_PATH, 这里再 source 一次兜底。
. "$HOME/.cargo/env"
cargo --version
cd src-tauri
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
run: bash scripts/ci/android-signing.sh
env:
+10 -13
View File
@@ -2,25 +2,22 @@
跨平台个人看板 (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 条。存入数据库。
### 报警模块
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
- 新闻:每日夜间 3 点通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条,存入数据库;列表按时间倒序无限滚动。
- 经济文章:由 `content/articles/*.md` 承载的长文。
- 经济数据 / 策略研究 / …:交给外接 ifinance 模块,宿主只读取 `info.json` 并用 iframe 打开条目页(纯前端直连,见 `ifinance/readme.md` §9)。iBoard 本地不再保存指标 / 策略数据。
## 监控
监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址:<https://komari.honor3.com/>
监控分为主机监控 / 服务监控 / 环境监控 / 报警四个 tab。主机监控对接 komari:<https://komari.honor3.com/>;报警 tab 展示跨源数据差异与定时任务异常,按时间倒序。
---
+54
View File
@@ -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)」中删除。
@@ -23,7 +23,7 @@
| --- | --- | --- |
| 状态枚举 | `todo` / `doing` / `done` | 未开始 / 执行中 / 完成 |
| 列 | `BoardColumn` 实体,按 `sortOrder` 升序 | 默认 `Plan/Doing/Action/Done` |
| 保留选项 | `never` / `today` / `3d` / `week` / `month` / `forever` | 永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示 |
| 自动归档档位 | `3d` / `1w` / `1m` / `never` | 完成后 3 天 / 一周 / 一月 / 永不 |
| 视图 | `list` / `board` | 设备端记忆 |
| 设备端键 | `iboard_task_prefs` | `{ lastProjectId, viewMode: { [projectId]: 'list'\|'board' } }` |
@@ -39,7 +39,7 @@
| POST | `/api/projects/:id/members` | 加成员(个人项目 400) |
| DELETE | `/api/projects/:id/members/:uid` | 移成员 / 自己退出 |
| GET/POST | `/api/projects/:id/columns` | 列列表 / 新增列 |
| PUT | `/api/columns/:id` | 改名 / 改绑定 / 改保留选项 |
| PUT | `/api/columns/:id` | 改名 / 改绑定 |
| DELETE | `/api/columns/:id` | 删列(需 `moveToColumnId`,最后列 400) |
| PUT | `/api/projects/:id/columns/order` | 列排序 |
| GET | `/api/projects/:id/tasks` | 任务列表(含子任务与列信息) |
@@ -60,7 +60,7 @@
| T-VW-03 | `prefs.js` → `viewMode[projectId]` | 按项目 id 分键存储,A/B 项目互不影响 |
| T-VW-04 | `ProjectView` 挂载时读 `prefs.getViewMode(projectId)` | 有值用之;无值走 T-VW-05 默认 |
| T-VW-05 | 默认值 `'list'` | 新项目首次进入为列表 |
| T-VW-06 | `localStorage.iboard_task_prefs` | **不落库、不同步**;换设备不继承(与 T-FL-15 的项目级配置区分) |
| T-VW-06 | `localStorage.iboard_task_prefs` | **不落库、不同步**;换设备不继承 |
| T-VW-07 | `prefs.getLastProjectId()` / `setLastProjectId()` | 进入 `/task` 时读取;无记录或记录的项目已不可见 ⇒ 回落个人默认项目 |
| T-VW-08 | `TaskPage` 首屏上部 `RecentProjects` 条 | 取最近访问的 3 个项目(按设备端访问时间倒序),末尾「更多项目」入口 → `/task/list`(完整项目列表 `ProjectList`,从「更多」进入) |
| T-VW-09 | `TaskPage` 首屏下部直接内嵌 `ProjectView` | 当前项目 = 上次访问项目(无记录 ⇒ 个人默认项目);点首屏项目卡片切换当前项目,下部整体切换,不跳转页面 |
@@ -109,32 +109,22 @@
| T-BD-09 | 初始化默认值 | `Plan=todo`、`Doing=doing`、`Done=done`、`Action=null` |
| T-BD-10 | 项目级配置 | 任一成员可改;不同项目独立 |
| T-BD-11 | 配置变更不回溯 | 保存配置时**不触发**任何任务更新语句 |
| T-BD-12 | 保存后返回最新列列表 | 前端整体替换 + 重算可见集合,无需刷新 |
| T-BD-12 | 保存后返回最新列列表 | 前端整体替换,无需刷新 |
| T-BD-13 | `resolveStatus()` 首行判 `parentId` | 子任务直接跳过全部联动 |
---
## 4. 已完成任务显示控制(T-FL)
## 4. 已完成任务显示控制(T-FL,已移除)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-FL-01 | `BoardColumn.retention` | 每列独立字段 |
| T-FL-02 | `ColumnSettings` 下拉 6 项 | 固定枚举,无自由输入 |
| T-FL-03 | `retention='forever'` | 判定恒为可见 |
| T-FL-04 | `retention='never'` | 已完成任务恒不可见 |
| T-FL-05 | `retention='today'` | `doneAt >= 今天 00:00` |
| T-FL-06 | `retention='3d'` | `doneAt >= 今天-2天 00:00` |
| T-FL-07 | `retention='week'` | `doneAt >= 本周一 00:00` |
| T-FL-08 | `retention='month'` | `doneAt >= 本月1日 00:00` |
| T-FL-09 | `task-domain/retention.js` + 前端定时重算 | 超出窗口自动隐藏,无需操作 |
| T-FL-10 | 判定字段 `Task.doneAt` | 与当前列无关;取消完成时 `doneAt=null` |
| T-FL-11 | 前端可见性统一走同一函数 | 列表与看板共用 `isTaskVisible(task, column)`;列表不分组但归属不变 |
| T-FL-12 | 判定前置 `status !== 'done'` | 未完成任务恒可见 |
| T-FL-13 | 纯展示层过滤 | 不修改任何字段;接口返回全量,前端过滤(或服务端按需过滤但不改数据) |
| T-FL-14 | 列计数取过滤后集合长度 | 列头计数与卡片数同源,杜绝不一致 |
| T-FL-15 | 存在 `BoardColumn` 上 | 项目级共享,非个人偏好(对比 T-VW-06 设备端) |
> **实现细节**:保留判定在前后端各有一份实现(服务端用于分页/统计,前端用于即时过滤),但**必须共用同一口径常量**,跨度边界由 `date.js` 单测覆盖。
> **2026-11-10 变更**:取消「隐藏」这一中间状态。任务只有两种状态:
> **显示**(在主列表/看板中)或**归档**(在已归档列表中)。
>
> - 不再按看板列的保留窗口隐藏已完成任务;
> - `BoardColumn.retention` 字段仅为兼容旧数据保留,**不再参与任何显示或归档判定**;
> - 已完成任务在手动归档或项目自动归档到期前,始终留在主列表/看板;
> - 原 T-FL-01 ~ 15 全部废止,不再有前后端保留窗口过滤实现。
>
> 归档规则见 §15。子任务完全跟随父任务:要么一起显示,要么一起归档。
---
@@ -314,21 +304,21 @@
| T-EX-15 | 拖到自身或后代 | 拒绝 + Toast |
| T-EX-16 | 删除有任务的列 | 二次确认 + 迁移到指定列(默认相邻)+ 重新裁决 |
| T-EX-17 | 删除最后一列 | 400「至少保留一列」 |
| T-EX-18 | 跨周期当场消失 | 定时重算 + 退场动画 + 重排 |
| T-EX-18 | 跨周期不再隐藏(原保留窗口需求已移除) | 已完成任务保持显示,直到手动归档或自动归档到期 |
| T-EX-19 | 达到列数上限 | 拒绝新增 + 提示 |
---
## 15. 任务归档(T-ARC)
> 归档与 T-FL 显示控制的分层:**隐藏 = 展示过滤(可逆、不落库)**;**归档 = 移出主工作集(落库、可恢复)**。
> 自动归档的倒计时起点是任务**被隐藏的时刻**(由 `doneAt + 列保留窗口` 推导,不新增 hiddenAt 字段)。
> 任务只有「显示」与「归档」两种状态,不再存在「隐藏」中间态。
> 自动归档倒计时起点是任务的**完成时刻** `doneAt`,到期后整棵任务树一起归档。
### 15.1 数据
| 落点 | 说明 |
| --- | --- |
| `Project.autoArchive VARCHAR(8) NOT NULL DEFAULT 'never'` | 自动归档档位:`3d` / `1w` / `1m` / `never`;项目级共享(同 T-FL-12 口径) |
| `Project.autoArchive VARCHAR(8) NOT NULL DEFAULT 'never'` | 自动归档档位:`3d` / `1w` / `1m` / `never`;项目级共享 |
| `Task.archived BOOLEAN NOT NULL DEFAULT false` | 归档标记;归档**不迁移表**,主查询统一追加 `archived = false` |
| `Task.archivedAt DATETIME(3) NULL` | 归档时刻(查询过滤与排序键) |
| `Task.archiveSource VARCHAR(8) NULL` | `'manual'` / `'auto'`,溯源用 |
@@ -339,7 +329,7 @@
| 接口 | 说明 |
| --- | --- |
| `POST /api/tasks/:id/archive` | 手动归档:父任务连带全部后代置 `archived/archivedAt/archiveSource='manual'`;幂等(已归档直接返回当前态) |
| `POST /api/tasks/:id/archive` | 手动归档:以整棵树为单位置 `archived/archivedAt/archiveSource='manual'`;传子任务 id 也提升到根父任务;幂等 |
| `POST /api/tasks/:id/unarchive` | 恢复:父任务连带后代清除归档三字段;原列因 `ON DELETE RESTRICT` 必存在,状态保持不变 |
| `GET /api/projects/:id/tasks/archived?keyword&kind&columnId&from&to&cursor&limit` | 归档查询:`keyword` 标题 LIKE、`kind`、`columnId`、`from/to`(归档日期,含边界);`limit` 默认 20、≤100;游标 = `archivedAt+id` 复合降序,返回 `{ items, nextCursor }`,`nextCursor` 为空即到底 |
| `PUT /api/projects/:id` | 增接受 `autoArchive`,枚举校验(非法 400) |
@@ -347,29 +337,31 @@
### 15.3 自动归档执行
- **归档到期判定**:`due = 隐藏时刻 + autoArchive 档位`;隐藏时刻 = `doneAt + 列保留窗口`(复用 `retentionStart` 口径,含 `today/3d/week/month`;`forever` 列任务永不隐藏、不进入自动归档)。
- **归档到期判定**:`due = doneAt + autoArchive 档位`(`3d` = 72 小时、`1w` = 7 天、`1m` = 上海日历月 +1;`never` = 不自动归档)。
- **判定范围**:只看**顶层任务**(`parentId IS NULL`)且 `status = 'done'` / `doneAt` 非空 / 未归档。子任务不单独判定。
- **整树归档**:顶层任务到期后,该任务与其全部子任务一起置 `archived/archivedAt/archiveSource='auto'`,不会出现父任务归档而子任务仍显示的情况。
- **执行时机(双保险)**:
1. **读时惰性**:加载项目任务时对该项目先执行一轮到期归档(保证用户看到的数据与判定一致,刚到期的任务当场消失);
2. **每小时 cron 兜底**:扫描全部 `autoArchive != 'never'` 项目的到期 done 任务批量归档(`archiveSource='auto'`)。
- **幂等**:`archived = false` 且 `status = 'done'` 且 `doneAt` 非空才进入判定;重复执行无副作用。
- **批量写**:cron 用 `updateMany`(按到期集合),读时惰性用单项目范围 `updateMany`。
1. **读时惰性**:加载项目任务时对该项目先执行一轮到期归档;
2. **每小时 cron 兜底**:扫描全部项目的到期顶层任务批量归档,并修复历史父子归档状态不一致。
- **幂等**:重复执行无副作用;批量写使用 `updateMany`。
### 15.4 前端
| 落点 | 说明 |
| --- | --- |
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 |
| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 |
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(关键词/类型/列/归档时段)+ 分页「加载更多」+ 行内「恢复 / 删除」 |
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);子任务上点归档会提升到根父任务,整棵树一起处理 |
| 项目管理 | 「归档设置」分区:完成后自动归档四档(3 天后/一周后/一月后/永不),保存即生效 |
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(**关键词 / 看板列 / 归档时段**;时段含「本周 / 本月 / 本季度」快捷项与自定义区间;条件编辑后点「筛选」统一提交,另有「重置」;**不提供类型过滤**)+ 已应用条件标签(可单独撤销)与命中条数 + 分页「加载更多」+ 行内「恢复 / 删除」 |
| 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 |
### 15.5 边界
| 场景 | 处理 |
| --- | --- |
| 归档父任务时新增/恢复子任务 | 子任务随父判定:父 archived 则子不单独出现在主列表(查询按父排除) |
| 子任务单独归档/恢复 | 不允许单独变化:接口与前端都会提升到根父任务,整棵树一起归档或恢复 |
| 历史数据父子不一致 | 服务端归档扫描时执行 `normalizeArchiveTree`,子任务归档态强制跟随父任务 |
| 自动归档期间用户正在编辑该任务 | 详情弹层保存正常(归档不锁写);保存后任务仍在归档集 |
| 恢复后任务再次到期隐藏 | 重新进入自动归档倒计时(`doneAt` 未变则可能立即再次到期,属预期) |
| 恢复后任务再次到期 | 重新进入自动归档倒计时(`doneAt` 未变则可能立即再次到期,属预期) |
| 「永不」档位 | 不做任何自动归档;手动归档不受影响 |
| 拷贝任务 | 拷贝件不继承归档态(`copyTask` 不复制 archived 三字段) |
@@ -382,7 +374,7 @@
| 视图体系 | T-VW-01 ~ 10 | 10 | §1 |
| 看板列管理 | T-COL-01 ~ 10 | 10 | §2 |
| 列-状态绑定 | T-BD-01 ~ 16 | 16 | §3 |
| 已完成显示控制 | T-FL-01 ~ 15 | 15 | §4 |
| 已完成显示控制(已移除) | T-FL-01 ~ 15 | 15(废止) | §4 |
| 任务字段体系 | T-TK-01 ~ 13 | 13 | §5 |
| 负责人 & 成员 | T-AS-01 ~ 07 | 7 | §6 |
| 子任务体系 | T-ST-01 ~ 15 | 15 | §7 |
@@ -396,4 +388,4 @@
| 任务归档 | T-ARC-01 ~ 08 | 8 | §15 |
| **合计** | — | **157** | — |
> 需求文档共 **157 条**需求点,本文逐条给出设计落点,覆盖率 **157/157**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11`、`T-ARC-*` 为评审后新增,编号不连续,非笔误。)
> 需求文档共 **157 条**需求点(其中 `T-FL-01~15` 已于 2026-11-10 废止,不再有隐藏中间态),本文逐条给出设计落点。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11`、`T-ARC-*` 为评审后新增,编号不连续,非笔误。)
@@ -20,7 +20,7 @@
| --- | --- | --- |
| `Project` | 项目(隔离与权限单位) | 项目域 |
| `ProjectMember` | 项目成员(权限关系) | 项目域 |
| `BoardColumn` | 看板列(名称 / 顺序 / 状态绑定 / 保留选项) | 看板域 |
| `BoardColumn` | 看板列(名称 / 顺序 / 状态绑定;`retention` 字段已废弃) | 看板域 |
| `Task` | 任务与子任务(自关联,一层) | 任务域 |
| `TaskMember` | 任务成员(多人标记) | 任务域 |
| `TaskTag` | 任务标签 | 任务域 |
@@ -29,7 +29,7 @@
1. **列是实体,不是枚举。** 看板列允许增删改与排序(T-COL),若仍用 `plan/doing/action/done` 枚举,则"改名"无从表达、"排序"只能堆在 JSON 里、"删除列"会留下悬空字符串。因此独立建 `BoardColumn` 表,`Task` 用 `columnId` 外键引用。
2. **状态与列双落库,互不推导。** `Task.status`(三值)与 `Task.columnId` 是两个正交维度;未绑定的列(默认 `Action`)必须允许二者不一致(T-BD-08、T-BD-16),任何"由状态反推列"或"由列反推状态"的写法都会丢失信息。联动只在写接口中按项目配置即时裁决。
3. **配置不下沉到任务。** 绑定与保留选项只存在 `BoardColumn` 上,任务不冗余任何配置副本——改配置立即对所有任务生效,且不需要回填。
3. **配置不下沉到任务。** 列-状态绑定只存在 `BoardColumn` 上,自动归档档位只存在 `Project` 上,任务不冗余任何配置副本——改配置立即对所有任务生效,且不需要回填。
4. **按项目隔离。** 所有业务表都可通过 `projectId` 归集,权限校验统一查 `ProjectMember`。
5. **软删除优先。** 项目用 `archived`;任务删除为级联物理删除(T-ST-11),但删除前有二次确认。
6. **迁移只增不改。** 新增需求一律新迁移文件,不修改已执行的迁移。
@@ -131,7 +131,7 @@
### 3.3 `BoardColumn` — 看板列
**本模块新增的核心实体**,承载三件事:显示顺序、状态绑定、已完成保留选项。
**本模块新增的核心实体**,承载两件事:显示顺序、状态绑定。`retention` 字段仅为兼容旧数据保留,已不参与业务判定。
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
@@ -140,7 +140,7 @@
| `name` | `VARCHAR(20)` | 否 | — | 列名,项目内唯一(T-COL-07) |
| `sortOrder` | `INT` | 否 | `0` | 显示顺序;**决定自动归位取"第一个匹配列"**(T-BD-15) |
| `boundStatus` | `VARCHAR(8)` | **是** | `NULL` | `NULL`=不绑定;或 `todo`/`doing`/`done`(T-BD-02) |
| `retention` | `VARCHAR(8)` | 否 | `'forever'` | `never`/`today`/`3d`/`week`/`month`/`forever`(T-FL-02) |
| `retention` | `VARCHAR(8)` | 否 | `'forever'` | **已废弃**:仅为兼容旧数据保留,不再参与显示/归档判定 |
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
| `updatedAt` | `DATETIME(3)` | 否 | — | — |
@@ -153,14 +153,14 @@
**默认数据**(新建项目时由应用层在事务内写入,T-COL-01、T-BD-09):
| sortOrder | name | boundStatus | retention |
| sortOrder | name | boundStatus | retention(废弃) |
| --- | --- | --- | --- |
| 0 | `Plan` | `todo` | `forever` |
| 1 | `Doing` | `doing` | `forever` |
| 2 | `Action` | `NULL` | `forever` |
| 3 | `Done` | `done` | `forever` |
> **为什么保留选项默认为 `forever` 而不是 `never`**:默认应"看得见",避免用户误以为数据丢失;隐藏是主动行为。
> **保留字段说明**:`retention` 默认仍为 `forever` 仅为兼容旧库;2026-11-10 起不再按列隐藏已完成任务,归档由 `Project.autoArchive` 控制。
**约束与校验**
@@ -170,7 +170,7 @@
| 至少保留 1 列(T-EX-17) | 应用层:删除前 count > 1 |
| 删列必须指定迁移目标(T-EX-16) | 应用层:接口必须带 `moveToColumnId` |
| `boundStatus` 取值合法 | 应用层白名单校验 |
| `retention` 取值合法 | 应用层白名单校验 |
| `retention` 字段 | 已废弃,不再校验/使用 |
### 3.4 `Task` — 任务 / 子任务
@@ -185,7 +185,7 @@
| `kind` | `VARCHAR(20)` | **是** | `NULL` | 任务类型标签,取值见 `Project.taskKinds` |
| `markDate` | `DATETIME(3)` | **是** | `NULL` | 时间标记,完整日期存储、展示 `MM-dd`(T-TK-15;迁移 20261025120000) |
| `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) |
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) |
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**自动归档判定依据** |
| `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) |
| `creatorId` | `INT` | 否 | — | 创建人;拷贝时为操作者(T-CP-04) |
| `extras` | `JSON` | 否 | `[]` | 扩展信息卡片 `[{label,value,url?}]`(T-TK-04/05) |
@@ -208,7 +208,7 @@
| `idx_task_project_column` | `(projectId, columnId)` | — | 看板按列查询 |
| `idx_task_project_status` | `(projectId, status)` | — | 列表/统计 |
| `idx_task_parent` | `(parentId)` | — | 取子任务 |
| `idx_task_done_at` | `(projectId, doneAt)` | — | 保留窗口计算 |
| `idx_task_done_at` | `(projectId, doneAt)` | — | 自动归档到期计算 |
| `idx_task_project_archived` | `(projectId, archived, archivedAt)` | — | 已归档查询(T-ARC-04) |
| `idx_task_project_status_arch` | `(projectId, status, archived)` | — | 主列表/看板排除归档(T-ARC-03) |
@@ -292,7 +292,7 @@ model BoardColumn {
name String @db.VarChar(20)
sortOrder Int @default(0)
boundStatus String? @db.VarChar(8) // null | todo | doing | done
retention String @default("forever") @db.VarChar(8)
retention String @default("forever") @db.VarChar(8) // 已废弃: 兼容旧数据
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -384,7 +384,7 @@ model User {
| 项目详情 + 列 | `SELECT * FROM BoardColumn WHERE projectId=? ORDER BY sortOrder` |
| 看板任务(含子任务) | 顶层:`WHERE projectId=? AND parentId IS NULL`;子任务:`WHERE parentId IN (...)` 一次取回后在内存分组(避免 N+1) |
| 权限校验 | `SELECT 1 FROM ProjectMember WHERE projectId=? AND userId=?` |
| 保留窗口过滤 | 服务端可选:`status <> 'done' OR (doneAt >= ?)`;前端另有一份同口径实现 |
| 自动归档到期 | 顶层任务:`parentId IS NULL AND archived=0 AND status='done' AND doneAt <= now - autoArchive`;到期后整棵树一起归档 |
| 自动归位选列 | 内存中对已加载的 `columns`(按 sortOrder)`find(c => c.boundStatus === status)` |
| 已归档查询(T-ARC-04) | `WHERE projectId=? AND archived=1 [AND kind=?] [AND columnId=?] [AND archivedAt BETWEEN ? AND ?] [AND title LIKE ?] ORDER BY archivedAt DESC, id DESC LIMIT ?+1`;多取 1 条判定 `nextCursor` |
@@ -257,13 +257,19 @@
<div class="doc-head">
<h1>iBoard V0.2 界面设计 · 任务模块</h1>
<p>轻量任务 / 项目管理 —— 列表与看板双视图 · 列-状态绑定 · 按列保留选项 · 一级子任务</p>
<p>轻量任务 / 项目管理 —— 列表与看板双视图 · 列-状态绑定 · 自动归档 · 一级子任务</p>
<div class="meta">
<span>版本 V0.2</span><span>视口 420 × 820</span><span>移动端优先</span>
<span>风格沿用《界面设计.html》</span><span>需求编号 T-*</span>
</div>
</div>
<div class="desc" style="border-left:3px solid #faad14;padding-left:10px;margin:14px 0">
<b>2026-11-10 更新:</b>取消「隐藏」中间态,任务只有「显示」与「归档」两种状态。
本页中涉及「按列保留选项 / 当场消失」的演示仅作历史参考,不再作为实现依据;
自动归档见需求与设计文档 §3.15。
</div>
<h2>1 设计规范 <span class="sub">沿用主文档 token,仅新增任务模块语义色</span></h2>
<p class="desc">任务模块不引入新主色,全部复用 V0.2 既有色板;只在「任务状态」「列绑定」两处新增语义映射。</p>
<div class="tokens">
@@ -27,7 +27,7 @@
| **看板视图** | 多人分工下的阶段流转 | 列(阶段)× 卡片(任务),长按拖拽换列 |
| **项目** | 数据与权限的隔离单位 | 项目即"工作区",成员可见即可读写 |
| **列-状态绑定** | 让"拖动列"与"改状态"两个动作不必重复做 | 列是**空间**,状态是**属性**,绑定是二者之间的**可选桥** |
| **列保留选项** | 已完成任务不淹没在列表里 | 每列自己的"保质期" |
| **自动归档档位** | 已完成任务不淹没在列表里 | 项目级"完成后多久归档" |
**设计的关键取舍**:把「看板列」与「任务状态」设计成**两个独立维度**,再用**项目级、逐列的绑定配置**把它们关联起来。这样既支持"拖动即改状态"的一体化体验,也支持"这一列只是分类、不进状态机"的轻量用法——这正是需求中 `Action` 默认不绑定状态的意图。
@@ -56,7 +56,8 @@
│ └─► task-domain/ ← 【领域逻辑唯一来源】 │
│ resolveStatus() 列/状态联动裁决 │
│ resolveColumn() 自动归位 │
│ retentionWindow() 保留窗口 │
│ archiveDue() 自动归档到期判定 │
│ normalizeArchiveTree() 父子归档一致性 │
│ copyTask() 跨项目拷贝 │
└──────────────────────────────┬─────────────────────────────────────────────────────────┘
▼
@@ -70,13 +71,13 @@
| 页面 | `src/pages/task/*.jsx` | 视图编排、本地状态、乐观更新与回滚 |
| 组件 | `src/components/task/*.jsx` | 纯展示 + 局部交互(卡片、列、子任务行、状态点、扩展卡片) |
| 设备端偏好 | `src/pages/task/prefs.js` | `viewMode` / `lastProjectId`(**仅设备端**,T-VW-06、T-VW-07) |
| 日期工具 | `src/pages/task/date.js` | 自然日边界计算,**与后端 `task-domain/date.js` 同口径,改一处必须同步另一处** |
| 日期工具 | `src/pages/task/date.js` | 日期边界、任务编号与状态常量 |
| API 封装 | `src/api/task.js` | 统一走既有 `api()`(自带 token / 错误解析) |
| 路由 | `server/routes/tasks.js`、`projects.js`、`columns.js` | 参数校验、鉴权、组装展示态 |
| 领域逻辑 | `server/task-domain/*.js` | **列-状态裁决、自动归位、保留窗口、拷贝**(唯一口径) |
| 领域逻辑 | `server/task-domain/*.js` | **列-状态裁决、自动归位、自动归档、拷贝**(唯一口径) |
| 持久化 | Prisma Client | 表操作 |
> **与打卡模块的区别**:打卡的周期口径只有 `period.js` 一个文件;任务模块的领域规则(三条)彼此有依赖(归位依赖列序、保留依赖完成时间与列配置),统一收敛到 `task-domain/` 目录,**前后端不得各自实现一遍裁决逻辑**——前端只做乐观更新,最终以服务端返回为准。
> **与打卡模块的区别**:打卡的周期口径只有 `period.js` 一个文件;任务模块的领域规则(三条)彼此有依赖(归位依赖列序、归档依赖完成时间与项目档位),统一收敛到 `task-domain/` 目录,**前后端不得各自实现一遍裁决逻辑**——前端只做乐观更新,最终以服务端返回为准。
### 1.4 目录结构(本次新增/修改)
@@ -86,9 +87,8 @@ prisma/
migrations/20261024xxxxxx_add_task_module/ # 新建表 + 外键 + 索引
server/
task-domain/
date.js # 自然日边界(今天/本周/本月)
resolveStatus.js # 列→状态、状态→列、自动归位
retention.js # 列保留窗口 → 可见性
archive.js # 自动归档到期判定 + 父子整树一致性
copyTask.js # 跨项目深拷贝
routes/
projects.js # /api/projects/*
@@ -105,7 +105,7 @@ src/
ProjectView.jsx # 列表/看板容器
ListView.jsx # 列表视图
BoardView.jsx # 看板视图
ColumnSettings.jsx # 列管理 + 绑定 + 保留选项(由项目管理进入)
ColumnSettings.jsx # 列管理 + 绑定(由项目管理进入)
TaskDetail.jsx # 任务详情(含扩展卡片 / 子任务;底部安全距离)
ProjectMembers.jsx # 成员管理(由项目管理进入)
ProjectManage.jsx # 项目管理: 成员 / 列设置 / 项目信息 三区
@@ -161,7 +161,7 @@ docs/V0.2/任务模块/ # 需求 / 框架 /
1. 未绑定的列(如默认 `Action`)要求二者**完全正交**,任何推导都会丢失信息;
2. 同一状态可被多列绑定(T-BD-03),状态无法反推唯一列;
3. 保留选项依赖"完成时间"而非列,推导会引入隐式状态。
3. 自动归档依赖"完成时间"与项目档位,不依赖列,避免推导引入隐式状态。
### 2.2 三种联动场景与判定函数
@@ -235,7 +235,7 @@ if task.parentId != null:
| 列 A 由「绑定 done」改为「不绑定」 | 已在 A 中且状态为 done 的任务**保持不动**(列与状态都保留),见 T-EX-03 |
| 列 A 绑定状态由 doing 改为 done | 已在 A 中的任务状态**不回写**;后续拖入 A 的任务才按 done 处理(T-BD-11) |
| 删除列 A | 二次确认 → 列内任务迁移到指定列(默认相邻列)→ 迁移后**按目标列重新裁决状态**;A 的绑定解除(T-COL-08、T-EX-16) |
| 新增列 | 取默认值 `retention=forever`、`boundStatus=null`,**不继承相邻列**(B-18) |
| 新增列 | 取默认值 `boundStatus=null`,**不继承相邻列**(B-18);`retention` 字段已废弃 |
> 为什么删除列要"重新裁决"而改绑定不裁决:删除是**数据搬家**(任务必须有个列),搬家后必须与目标列的语义自洽;而改绑定只是改了"未来的规则",不该动已有数据。
@@ -286,40 +286,33 @@ if task.parentId != null:
> 兜底:无法拖拽时(如长按被系统手势占用),卡片「⋯」菜单提供「移动到列 / 移动到子任务」的等价操作(T-UI-07)。
### 3.2 保留选项的可见性计算与"当场消失"
### 3.2 自动归档与父子整树一致性
**窗口计算**(后端 `task-domain/retention.js`,自然日口径):
> 2026-11-10 起取消「隐藏」中间态:任务只有**显示**与**归档**两种状态,不再有按列保留窗口的可见性过滤。
**到期计算**(后端 `task-domain/archive.js`):
```
function retentionStart(option, now):
today = startOfDay(now) # Asia/Shanghai 00:00
switch option:
never => 返回"不可见"
forever => 返回"无下界"(全可见)
today => today
3d => today - 2 天 # 含今天共 3 天
week => 本周一 00:00
month => 本月 1 日 00:00
due = doneAt + autoArchive 档位
autoArchive = 3d → doneAt + 72 小时
autoArchive = 1w → doneAt + 7 天
autoArchive = 1m → doneAt + 1 个上海日历月
autoArchive = never → 不自动归档
可见性判定:task.status == done
? (doneAt != null && doneAt >= retentionStart(该任务所属列的 option))
: true # 未完成任务始终可见(T-FL-12)
判定范围:parentId IS NULL / status = done / doneAt 非空 / archived = false
到期后:该顶层任务 + 全部子任务一起置 archived=true
```
**判定依据**:`Task.doneAt`(完成时刻),**与任务当前所在列无关**(T-FL-10);归属哪一列决定用哪一列的选项(T-FL-11)。
**父子一致性**:子任务不参与独立判定。手动归档/恢复、自动归档都以整棵树为单位;服务端归档扫描时执行 `normalizeArchiveTree()`,修复历史数据中父子归档态不一致的情况(子任务强制跟随父任务)。
**前端"当场消失"**(T-EX-18):
**执行时机**:
```
每分钟(以及跨日/跨周/跨月边界整点)执行一次:
重新计算当前可见集合
对"由可见变不可见"的任务卡片:
加 .is-leaving(透明 + 高度收缩,约 240ms)
动画结束移除 DOM
其余卡片通过布局动画平滑重排(不做全局刷新)
1. 读时惰性:GET /api/projects/:id/tasks 前先跑一轮该项目归档
2. 每小时 cron:扫描全部项目,先修复父子一致性,再执行到期自动归档
```
> 也允许退化为"刷新时计算"——需求明确接受该实现方式(§5 决策 23)。跨周期边界用**定时器 + 页面可见性变化(`visibilitychange`)**双保险。
> 前端不再需要保留窗口定时器或可见性过滤;`taskApi.list` 只返回 `archived = 0` 的顶层任务及其 `archived = 0` 的子任务。
### 3.3 跨项目拷贝
@@ -405,12 +398,12 @@ POST /api/tasks/:id/copy { projectId: 目标项目 }
| --- | --- | --- |
| 1 | **列与状态双维度带来的认知成本** | 默认配置下二者一一对应(除 Action),用户"不懂也不会错";只有主动改配置时才需要理解绑定 |
| 2 | **自动归位可能让用户"找不到"任务** | 任务从当前列跳走。对策:归位后该卡片带一次高亮动画;提供 Toast「已移至 <列名>」 |
| 3 | **保留选项与"未绑定完成的已完任务"** | 状态为 done 但所有列都不绑定 done 时,任务停在不绑定列,其可见性按该列选项判定(T-FL-11)——语义自洽但需要文案说明 |
| 3 | **已完成任务与"未绑定完成的已完任务"** | 状态为 done 但所有列都不绑定 done 时,任务停在不绑定列并保持显示,直到归档(不再有隐藏中间态) |
| 4 | **列可增删导致看板横向过宽** | 上限暂定 10 列(T-COL-06,**待确认**);超出时看板横向滚动 + 列头悬浮 |
| 5 | **子任务状态与父任务不一致的展示** | 卡片内嵌子任务带**小状态标记**(T-ST-09),避免"父已完成但子未做"的信息丢失 |
| 6 | **跨项目拷贝丢弃人员** | 返回被丢弃的名单并在前端提示,避免静默丢数据(T-EX-08) |
| 7 | **无成员项目的数据残留** | 本期不处理(T-PJ-08),需后台维护;在数据库设计文档中标注为"孤儿项目" |
| 8 | **前后端日期口径不一致** | `date.js` 双份实现是既有项目的做法;本期继续沿用并在两处加注释互指,写单测覆盖边界(周日 23:59 / 周一 00:00 / 月末) |
| 8 | **归档时间口径不一致** | 自动归档统一在 `server/task-domain/archive.js` 计算(`doneAt + autoArchive`),前端不重复实现;边界写单测覆盖(72h / 7d / 上海日历月) |
---
@@ -418,11 +411,11 @@ POST /api/tasks/:id/copy { projectId: 目标项目 }
| 类别 | 要点 |
| --- | --- |
| **领域规则单测** | §2.3 判定矩阵 10 条全覆盖;`retentionStart()` 六档边界(含周日/月末跨点) |
| **领域规则单测** | §2.3 判定矩阵 10 条全覆盖;`autoArchiveDue()` 四档边界(含上海日历月跨月) |
| **接口测试** | 非项目成员访问 403;个人默认项目的两个保护接口返回 400;拷贝后源任务不变 |
| **交互验证** | 拖拽换列 / 降为子任务 / 拒绝场景 Toast;保留档位切换任务当场消失并重排 |
| **交互验证** | 拖拽换列 / 降为子任务 / 拒绝场景 Toast;归档/恢复以整棵树为单位,子任务不单独变化 |
| **兼容性** | Android WebView + 移动浏览器(T-NF-02);375 / 390 / 430 三档视口 |
| **两视图一致** | 列表与看板可见条数一致(T-FL-11、T-FL-14);任一处修改另一处即时可见(T-UI-03) |
| **两视图一致** | 列表与看板数据同源、条数一致;任一处修改另一处即时可见(T-UI-03) |
---
@@ -10,7 +10,7 @@
### 1.1 一句话描述
在 iBoard 现有 APP 内新增一个**轻量级的项目与任务管理模块**:界面交互参考 Todoist,以**项目**为隔离单位组织任务,提供**列表 / 看板**双视图,并允许**每个项目独立配置看板列与任务状态的对应关系、以及每列已完成任务的保留选项**,用最小认知成本覆盖「个人琐事记录」与「多人协作推进」两类场景。
在 iBoard 现有 APP 内新增一个**轻量级的项目与任务管理模块**:界面交互参考 Todoist,以**项目**为隔离单位组织任务,提供**列表 / 看板**双视图,并允许**每个项目独立配置看板列与任务状态的对应关系、以及已完成任务的自动归档档位**,用最小认知成本覆盖「个人琐事记录」与「多人协作推进」两类场景。
**「轻量」的含义**:不做需求管理、工时、甘特图、工作流引擎、审批流;字段克制、结构最多两层(任务 + 一级子任务)、权限不做细分角色。目标是用最少的配置让用户"打开就能记、记完就能推进"。
@@ -31,7 +31,7 @@
| **任务状态(Status)** | 任务的处理阶段,取值为 `未开始` / `执行中` / `完成`。**与看板列是两个独立概念**,二者的对应关系由项目配置决定 |
| **列-状态绑定(Column Status Binding)** | 项目级配置:**每一列独立**选择"是否绑定任务状态"以及"绑定到哪个状态"。绑定后列与状态双向联动;不绑定时二者完全独立 |
| **已完成但未绑定状态** | 任务状态为「完成」、但其所在看板列未绑定到「完成」的情形——此时列保持不变 |
| **看板列保留选项(Column Retention)** | 项目级配置:**每一列独立**设置已完成任务的保留时长,取值为「永不显示」/「当天」/「3 天」/「本周」/「本月」/「永久显示」六档,用于控制该列中已完成任务的可见性 |
| **自动归档档位(Auto Archive)** | 项目级配置:任务**完成后**经过 `3 天 / 一周 / 一月 / 永不` 即自动归档;归档后从主列表/看板移入已归档列表。子任务完全跟随父任务一起显示或一起归档 |
| **视图记忆** | 设备端按项目独立保存"上次选择的视图(列表 / 看板)",进入项目自动恢复 |
| **上下文** | 用户当前所处的视图位置(某个项目 / 某个父任务的子任务列表 / 某个列表分区),用于解释"本次修改"相对哪些任务生效 |
@@ -46,7 +46,7 @@
- **默认**:`Plan` 绑定「未开始」、`Doing` 绑定「执行中」、`Done` 绑定「完成」、`Action` **不绑定**。
- 配置是**项目级**的,项目内任意成员均可修改,不同项目互不影响。
- **子任务不受列-状态绑定约束**,其状态可与父任务不一致。
3. **已完成任务显示控制**:**按看板列独立配置保留选项**——`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示` 六选一;超出保留选项的已完成任务在该列中自动隐藏。列表视图按其所属列的配置执行同样的显示控制。
3. **已完成任务归档**:任务只有「显示」与「归档」两种状态,不再有「隐藏」中间态。已完成任务默认继续显示;项目可配置「完成后 3 天 / 一周 / 一月 / 永不」自动归档,到期后任务整棵树移入已归档列表。子任务与父任务完全同步。
4. **任务字段体系**:基础字段(标题必填,负责人默认本人)+ 内置可折叠**扩展信息卡片**(默认收起、全非必填);负责人(单人)与任务成员(多人)均为信息标记。
5. **负责人 & 成员展示规则**:负责人大字号突出、成员小字号弱化并置于下方;二者只能从**当前项目成员**中选择;不改变项目统一读写权限。
6. **子任务体系**:仅一级、必须同项目、父子完成状态互不联动;列表视图支持缩进与折叠展开;**看板卡片内以简洁形式嵌套展示子任务并可直接勾选**,完整详情在卡片弹窗内编辑;支持**长按拖拽把任务降为另一个任务的一级子任务**。
@@ -73,8 +73,8 @@
**场景 D · 跨项目复用**
把「APP 改版」里的某任务跨项目拷贝到「APP 二期」→ 生成一条**全新任务**,字段、扩展信息、子任务、负责人、成员、状态、看板列、标签全部复制,创建人记为执行拷贝的用户;此后两任务互不影响。
**场景 E · 按列控制已完成任务显示**
某项目的 `Done` 列已完成任务过多影响阅读 → 把该列保留选项设为「当天」→ 更早的已完成任务自动隐藏;同时把 `Action` 列设为「永不显示」,该列不再展示已完成任务;`Plan` 列保持「永久显示」不受影响。列表视图同样按各任务所属列的配置隐藏它们,与看板一致。切到另一个项目时,该项目各列配置保持自己的设置(互不干扰)。
**场景 E · 完成后自动归档**
某项目的已完成任务长期堆积影响阅读 → 在项目管理中把「完成后自动归档」设为「一周后」→ 任务完成满一周后自动移入已归档列表;子任务随父任务一起归档。若设为「永不」则不会自动归档,只能手动归档。切到另一个项目时,该项目保持自己的归档档位(互不干扰)。
**场景 F · 拖拽调整层级**
列表视图中长按任务卡片,向上拖动到另一个任务上并停留 → 该任务成为目标任务的**一级子任务**(缩进显示)。若目标任务本身已是子任务,或被拖任务已带子任务,则拒绝并给出提示(仅支持一级)。降为子任务后,它原有的看板列与状态保持不变。
@@ -86,7 +86,7 @@
底部导航顺序为「打卡 → **任务** → 宏观 → 监控 → 我」→ 点击「任务」进入首屏:**上部**是最近使用的 3 个项目卡片 + 「更多项目」入口(点卡片即切换当前项目,长按卡片可调整顺序);**下部**直接是当前项目(上次访问的,记录在设备端;无记录则回到个人默认项目)的任务管理页,无需先进项目列表。点「更多项目」才进入完整的项目列表页。
**场景 I · 项目管理**
在项目页头部点「项目管理」→ 弹出统一管理页,内含三个分区:**成员**(查看 / 添加 / 移除)、**列设置**(列管理 / 状态绑定 / 保留选项)、**项目信息**(改名 / 归档 / 删除 / 退出,个人默认项目不出现删除与退出)。
在项目页头部点「项目管理」→ 弹出统一管理页,内含三个分区:**成员**(查看 / 添加 / 移除)、**列设置**(列管理 / 状态绑定)、**项目信息**(改名 / 归档 / 删除 / 退出,个人默认项目不出现删除与退出)。
### 1.5 不在本期范围(明确排除)
@@ -114,7 +114,7 @@
| **看板列(column)** | 项目级可配置的列实体,默认 `Plan` / `Doing` / `Action` / `Done` 四列;可增删改、可排序,数量不固定 |
| **列-状态绑定(columnBindings)** | 项目级配置:每列独立声明「是否绑定」及「绑定到哪个状态」;默认 plan→todo、doing→doing、done→done、action→不绑定 |
| **已完成但未绑定状态** | 状态为 `done` 但所在列未绑定到 `done`;此时拖拽/勾选只改状态不改列(见 T-EX-04) |
| **看板列保留选项(columnRetention)** | 项目级配置:每列独立设置已完成任务的保留时长,`never` 永不显示 / `today` 当天 / `3d` 3 天 / `week` 本周 / `month` 本月 / `forever` 永久显示 |
| **自动归档档位(autoArchive)** | 项目级配置:任务完成后经过 `3d` / `1w` / `1m` / `never` 自动归档;归档后从主列表/看板移入已归档列表 |
| **视图记忆(viewMode)** | `list` / `board`,**按项目独立记忆、保存在设备端** |
| **扩展信息卡片** | 任务的折叠信息区,存任意 `[{label, value, url?}]` 条目,默认收起、全部非必填 |
| **一级子任务** | `parentId` 非空的任务;其父任务必须为顶层任务,且同项目 |
@@ -132,7 +132,7 @@
| `VW` | 视图体系 | 第 1 条 |
| `COL` | 看板列管理 | 第 2 条 |
| `BD` | 看板与列-状态绑定 | 第 2 条 |
| `FL` | 已完成任务显示控制(按列保留选项) | 第 3 条 |
| `FL` | 已完成任务显示控制(已移除,任务只有显示/归档两态) | 第 3 条 |
| `TK` | 任务字段体系(含扩展信息卡片) | 第 4 条 |
| `AS` | 负责人与成员 | 第 5 条 |
| `ST` | 子任务体系 | 第 6 条 |
@@ -165,7 +165,7 @@
### 3.2 看板列管理(T-COL)
> 看板列是**项目级可配置实体**:默认 4 列,允许增删改与排序。列与「状态绑定」「保留选项」是三条正交的配置。
> 看板列是**项目级可配置实体**:默认 4 列,允许增删改与排序。列与「状态绑定」是两条正交的配置。
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
@@ -201,27 +201,16 @@
| T-BD-12 | 绑定配置变更后,看板与列表**即时一致**,无需手动刷新 | 保存配置后,看板列徽标与完成态立即反映新规则 | P1 |
| T-BD-13 | **子任务不受看板列-状态绑定影响**:子任务状态不因所在列而改写,也不触发「状态 → 移入列」 | 父任务在绑定「完成」的列,其子任务状态仍可为「执行中」且不被改写;修改子任务状态不使其离开当前列 | P0 |
### 3.4 已完成任务显示控制(T-FL)
### 3.4 已完成任务显示控制(T-FL,已移除)
> 本节的「保留选项」**以看板列为单位**配置,取代原先的「项目整体 5 档过滤」。
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-FL-01 | **每一列独立**配置已完成任务的保留选项,配置项属于项目级 | 各列(含新增列)都有独立配置入口,修改一列不影响其他列 | P0 |
| T-FL-02 | 保留选项为**固定 6 档**:`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示` | 下拉仅此六项,不提供自由输入天数 | P0 |
| T-FL-03 | 「永久显示」:该列已完成任务**始终展示**,不受时间限制 | 设为永久显示后,历史任意时间完成的任务均可见 | P0 |
| T-FL-04 | 「永不显示」:该列已完成任务**始终不展示** | 设为永不显示后,该列无已完成任务卡片 | P0 |
| T-FL-05 | 「当天」:仅展示**完成于今天(自然日)**的已完成任务 | 今天完成的可见;昨天完成的次日即不显示 | P0 |
| T-FL-06 | 「3 天」:展示**完成于最近 3 个自然日(含今天)**的已完成任务 | 今天、昨天、前天完成可见,更早不显示 | P0 |
| T-FL-07 | 「本周」:展示**完成于本周(周一 00:00 起,至今天)**的已完成任务 | 周一起算;下周一进来看,上周完成的任务不再显示 | P0 |
| T-FL-08 | 「本月」:展示**完成于本月(当月 1 日起,至今天)**的已完成任务 | 当月 1 日起算;进入次月后,上月完成的任务不再显示 | P0 |
| T-FL-09 | 超出所选档位范围的已完成任务**自动隐藏**(无需手动操作) | 某列设为「当天」,次日进入该列,昨日完成的任务自动消失 | P0 |
| T-FL-10 | 保留判定以「该任务的**完成时间**」为准,与任务当前所在列无关 | 任务完成后再移动列,可见性仍按其完成时间与新列档位判定 | P0 |
| T-FL-11 | **列表视图与看板视图行为一致**:列表只是不按看板列分组展示,任务归属哪一列仍是哪一列,显示控制同样按**其所属列**的配置执行 | 看板中某列已隐藏的已完成任务,在列表视图中同样不出现;两视图可见条数一致 | P0 |
| T-FL-12 | 该配置仅影响**已完成任务**;未完成任务始终展示,不受保留选项影响 | 未完成任务在任何保留选项配置下均可见 | P0 |
| T-FL-13 | 显示控制为**展示层过滤**,不改变任务数据与状态 | 修改保留选项后,任务数据、状态、计数口径不发生变化 | P0 |
| T-FL-14 | 过滤后每列的计数与展示条数一致 | 列头显示 N 条时,该列实际可见卡片同为 N 条 | P1 |
| T-FL-15 | 保留选项配置在**项目内所有成员间共享**(属项目配置,非个人偏好) | 成员 A 修改后,成员 B 进入即按新配置显示 | P0 |
> **2026-11-10 变更**:取消「隐藏」中间态,任务只有**显示**与**归档**两种状态。
>
> - 不再按看板列的保留窗口隐藏已完成任务;原 T-FL-01 ~ 15 全部废止;
> - `BoardColumn.retention` 字段仅为兼容旧数据保留,不再参与显示或归档判定;
> - 已完成任务在手动归档或自动归档到期前始终显示;
> - 子任务完全跟随父任务:要么一起显示,要么一起归档。
>
> 自动归档规则见 §3.15。
### 3.5 任务字段体系(T-TK)
@@ -299,7 +288,7 @@
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-PM-01 | 权限**以项目为隔离单位**:用户只能看到自己是成员的项目及其任务 | 非成员访问某项目接口返回 403 / 404,列表不出现该项目 | P0 |
| T-PM-02 | 项目内**所有成员权限均等**,具备**全读写**能力 | 任意成员均可新增 / 编辑 / 删除任务、管理成员、修改列-状态绑定与保留选项配置 | P0 |
| T-PM-02 | 项目内**所有成员权限均等**,具备**全读写**能力 | 任意成员均可新增 / 编辑 / 删除任务、管理成员、修改列-状态绑定与自动归档配置 | P0 |
| T-PM-03 | 预留**权限分级入口**,本期**不实现**细分角色 | 数据结构或接口层保留扩展位;界面无角色分配功能 | P1 |
| T-PM-04 | 项目内**所有成员都可以归档项目** | 任意成员执行归档均成功 | P0 |
| T-PM-05 | 复用现有项目框架的登录态与鉴权,未登录访问被拒 | 无有效登录态访问返回 401 | P0 |
@@ -340,9 +329,9 @@
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-DT-01 | 新增 Prisma 模型:`Project`(含 `isPersonal`、列-状态绑定配置、列保留选项配置、`archived` 等)、`ProjectMember` | 字段见附录 A | P0 |
| T-DT-01 | 新增 Prisma 模型:`Project`(含 `isPersonal`、列-状态绑定配置、自动归档档位、`archived` 等)、`ProjectMember` | 字段见附录 A | P0 |
| T-DT-02 | 列-状态绑定配置**按列存储**,每列独立保存「是否绑定 + 绑定状态」,支持多列指向同一状态 | 任意数量列的配置可独立读写;两列绑定同一状态可正确落库 | P0 |
| T-DT-03 | 列保留选项配置**按列存储**,取值覆盖六档:`never` / `today` / `3d` / `week` / `month` / `forever` | 三档取值均可落库并正确读回 | P0 |
| T-DT-03 | 自动归档档位**按项目存储**,取值 `3d` / `1w` / `1m` / `never` | 四档取值均可落库并正确读回 | P0 |
| T-DT-04 | 新增 Prisma 模型:`Task`(含 `parentId` 自关联、`assigneeId`、`status`、看板列、扩展信息卡片、`sortOrder`) | 字段见附录 A | P0 |
| T-DT-05 | 新增 Prisma 模型:`TaskMember`(任务成员关联)、扩展信息卡片结构、标签结构 | 字段见附录 A | P0 |
| T-DT-06 | 视图记忆按**设备端**存储,不要求落库 | 换设备登录不要求视图一致 | P0 |
@@ -373,7 +362,7 @@
| T-EX-07 | 删除父任务 | 级联删除其子任务,确认弹窗提示「将一并删除 N 条子任务」 | P0 |
| T-EX-08 | 跨项目拷贝时负责人 / 成员不属于目标项目 | 仅保留属于目标项目成员者,其余丢弃并在结果中提示 | P1 |
| T-EX-09 | 个人默认项目尝试添加成员 / 尝试删除 | 接口返回明确错误,前端不提供入口 | P0 |
| T-EX-10 | 某列设为「当天」等档位且该列无符合条件的已完成任务 | 展示该列空态,不报错 | P0 |
| T-EX-10 | 已归档列表无符合条件的任务 | 展示空态,不报错 | P0 |
| T-EX-11 | 项目成员清空后的项目 | 静默保留,前端不报错、不特殊提示 | P0 |
| T-EX-12 | 子任务所属项目与父任务不一致(绕过前端直接调用) | 后端拒绝并返回错误 | P0 |
| T-EX-13 | 拖拽成为子任务时,子任务的**看板列与状态**如何取值 | 保持自身原值不变,仅改变父子归属 | P1 |
@@ -382,21 +371,20 @@
| T-EX-16 | 删除仍有任务的列 | 二次确认后,该列任务迁移至**相邻列**(或由用户指定目标列),不产生无归属任务 | P0 |
| T-EX-17 | 删除唯一剩余的列 | **拒绝操作**并提示「至少保留一列」 | P0 |
| T-EX-19 | 新增列时已达列数上限 | 拒绝新增并提示已达上限,不产生空列 | P1 |
| T-EX-18 | 已完成任务跨周期(周一零点 / 月初零点)后应隐藏 | **当场消失**并带移除动画,其余任务平滑重排,不留空白 | P1 |
| T-EX-18 | 已完成任务跨周期不再隐藏(原保留窗口需求已移除) | 已完成任务保持显示,直到手动归档或自动归档到期 | P1 |
### 3.15 任务归档(T-ARC)
> 与「已完成显示控制(T-FL)」的关系:T-FL 决定任务**看不看得见**(可逆的展示过滤),
> 归档决定任务**在不在主工作集**(数据层面的收纳)。隐藏是自动归档的前置条件,归档移出主列表,
> 可通过「已归档查询」恢复。
> 任务只有「显示」与「归档」两种状态,不再存在「隐藏」中间态。
> 自动归档倒计时起点是任务的**完成时刻** `doneAt`;到期后整棵任务树一起归档,可通过「已归档查询」恢复。
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-ARC-01 | **手动归档**:任务 `⋯` 菜单增加「归档」;任意未归档任务(不限状态)均可归档;父任务归档时**连带全部子任务**一并归档;操作需二次确认(提示连带条数) | 归档后任务从列表/看板消失;子任务同步消失;确认弹窗条数正确 | P0 |
| T-ARC-02 | **自动归档**:项目级配置「**隐藏的任务**多久归档」,可选 `3 天后 / 一周后 / 一月后 / 永不`(默认永不);已隐藏(T-FL 保留窗口过期)的 done 任务到期后由系统自动归档 | 配置保存后生效;隐藏任务到期被自动归档;「永不」时永不自动归档;未隐藏的任务不受影响 | P1 |
| T-ARC-01 | **手动归档**:任务 `⋯` 菜单增加「归档」;任意未归档任务(不限状态)均可归档;父任务归档时**连带全部子任务**一并归档;在子任务上归档会提升到根父任务;操作需二次确认(提示连带条数) | 归档后任务从列表/看板消失;子任务同步消失;确认弹窗条数正确 | P0 |
| T-ARC-02 | **自动归档**:项目级配置「**完成后**多久归档」,可选 `3 天后 / 一周后 / 一月后 / 永不`(默认永不);顶层任务完成满该时长后,任务及其全部子任务由系统自动归档 | 配置保存后生效;到期任务整树自动归档;「永不」时永不自动归档;未完成的任务不受影响 | P1 |
| T-ARC-03 | **归档排除**:已归档任务从任务列表、看板、列计数、项目任务数统计中排除 | 归档后各视图与计数立即减少;恢复后回到原位 | P0 |
| T-ARC-04 | **已归档查询**:独立入口查看本项目已归档任务;支持关键词(标题)、任务类型、看板列、归档时间范围过滤;**游标分页**支撑大量数据(千级) | 过滤条件生效;分页可连续加载到底;千级数据下查询流畅 | P0 |
| T-ARC-05 | **恢复**:已归档任务可取消归档,回到原看板列(列被删除的场景因外键 `ON DELETE RESTRICT` 不存在),状态保持不变;父任务恢复连带子任务 | 恢复后任务与子任务重新出现在列表/看板原列 | P0 |
| T-ARC-05 | **恢复**:已归档任务可取消归档,回到原看板列(列被删除的场景因外键 `ON DELETE RESTRICT` 不存在),状态保持不变;父任务恢复连带子任务,在子任务上恢复会提升到根父任务 | 恢复后任务与子任务重新出现在列表/看板原列 | P0 |
| T-ARC-06 | **彻底删除**:已归档任务可物理删除(含子任务),二次确认 | 删除后不再出现在已归档查询 | P1 |
| T-ARC-07 | **配置入口**:项目管理新增「归档设置」分区,展示与修改自动归档档位 | 修改后即时保存并对全项目生效 | P1 |
| T-ARC-08 | **权限口径**:项目成员均可归档/恢复/删除归档任务(权限均等口径同 T-PM-04) | 与其他写操作权限一致,无额外限制 | P1 |
@@ -405,8 +393,8 @@
## 4. 优先级汇总(本期交付范围)
- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-FL-01~13、15;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~13;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17;T-ARC-01、03、04、05。
- **P1(应该)**:T-VW-05、10;T-COL-08;T-BD-11、12;T-FL-14;T-TK-12、13;T-AS-06;T-ST-10、15;T-PJ-09;T-PM-03;T-CP-07、09;T-UI-06;T-DT-09;T-NF-05;T-EX-06、08、13、14、18、19;T-ARC-02、06、07、08。
- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~13;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17;T-ARC-01、03、04、05。
- **P1(应该)**:T-VW-05、10;T-COL-08;T-BD-11、12;T-TK-12、13;T-AS-06;T-ST-10、15;T-PJ-09;T-PM-03;T-CP-07、09;T-UI-06;T-DT-09;T-NF-05;T-EX-06、08、13、14、18、19;T-ARC-02、06、07、08。
---
@@ -417,34 +405,34 @@
| # | 问题 | 决策 |
| --- | --- | --- |
| 1 | 列-状态绑定是单列还是多列? | **每列独立配置**,允许**多列绑定同一状态**;默认 plan→未开始、doing→执行中、done→完成、action→不绑定(T-BD-02、03、09) |
| 2 | 保留档位的时间边界按自然日还是滚动小时? | **自然日**(Asia/Shanghai,含今天 00:00 起);本周按周一起算、本月按 1 日起算 |
| 3 | 视图记忆 / 显示配置存在设备端还是服务端? | 视图记忆**设备端**、不跨设备;保留选项**项目级共享**(T-VW-06、T-FL-12、T-DT-06) |
| 2 | 自动归档档位的时间边界? | `3d` / `1w` 按完成时刻起算的绝对时长(72 小时 / 7 天);`1m` 按上海日历月 +1 |
| 3 | 视图记忆 / 自动归档配置存在设备端还是服务端? | 视图记忆**设备端**、不跨设备;自动归档档位**项目级共享**(T-VW-06、T-DT-06) |
| 4 | 删除父任务时子任务如何处理? | **级联删除**,弹窗提示条数(T-EX-07) |
| 5 | 已完成任务在看板中的呈现? | 由**该列的保留选项**控制显示与隐藏(T-FL-01~10) |
| 5 | 已完成任务在看板中的呈现? | **保持显示**,直到手动归档或自动归档到期(不再有隐藏中间态) |
| 6 | 看板拖拽的移动端交互? | **长按拖拽** + 「移动到」兜底;**允许拖拽降为子任务**(T-UI-07、T-ST-12) |
| 7 | 项目成员清空后是否需要本模块的管理入口? | **本期不承担**,交后台;前端静默(T-PJ-08) |
| 8 | 扩展信息组的具体字段清单? | **不固定字段**,改为**任意扩展信息卡片**(T-TK-04、05) |
| 9 | 已完成显示策略的粒度? | 改为**按看板列独立的保留选项**(T-FL-01、02) |
| 9 | 已完成显示策略的粒度? | **已移除按列保留选项**;任务只有显示/归档两种状态,子任务跟随父任务 |
| 10 | 是否单独要求 WebView / 登录态 / 返回键? | **不单列**,沿用现有项目框架(T-NF-04、T-PM-05) |
| 11 | 同一状态被多列绑定,状态变化时进哪一列? | **按当前列顺序取第一个匹配**(T-BD-06、15、B-3) |
| 12 | **从绑定列拖出**时状态是否回退? | **不设「拖出即回退」规则**。只看**目标列**:目标列绑定了状态就按目标列改,未绑定则**保留原状态**(T-BD-07、B-4) |
| 13 | 列表视图如何处理「按列」的显示控制? | 列表只是**不按列分组**,任务归属仍是原看板列,行为与看板**完全一致**(T-FL-11、B-6) |
| 13 | 列表视图与看板的显示是否一致? | 一致:两视图都不做隐藏过滤,任务只会在归档后从主工作集移除(T-ARC) |
| 14 | 看板卡片如何展示子任务? | **在卡片内嵌套简洁展示**,可直接勾选;完整详情在卡片弹窗(T-ST-06、07) |
| 15 | 子任务是否受列-状态绑定影响? | **不受**。子任务状态可与父任务不一致,拖入列不改写其状态(T-BD-13、T-ST-08、B-7) |
| 16 | 父任务能否整体挂到别的任务下? | **不能**。含子任务的父任务不可被拖拽为子任务(T-ST-14) |
| 17 | 保留选项的取值范围? | 固定 **6 档**:`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示`,不做自由输入(T-FL-02、B-5) |
| 17 | 自动归档档位的取值范围? | 固定 **4 档**:`3 天后` / `一周后` / `一月后` / `永不`(T-ARC-02) |
| 18 | 任务状态与所在列绑定不一致时怎么处理? | **触发自动归位**:查找绑定该状态的列并移入,按列序取第一个;**无匹配列则不移动**(T-BD-14~16、B-4b) |
| 19 | 卡片内嵌套的子任务是否显示自身状态? | **显示小状态标记**,以区分与父任务不一致的状态(T-ST-09) |
| 20 | 任务模块入口放在哪里? | 作为**顶级 Tab**,位置在**「打卡」之后**(T-PM-06) |
| 21 | 5 个底部 Tab 是否影响既有布局与默认首页? | **暂无障碍**;默认首页**仍为打卡**(T-PM-06) |
| 22 | 任务状态为「完成」但所有列都未绑定「完成」时,列表视图按哪一列过滤? | **保留在当前列**,按该列保留选项过滤。**不存在「已完成统一用 Done 列」的强制规则**,那只是默认配置下的自然效果,用户可完全自定义(T-FL-11) |
| 23 | 「本周 / 本月」跨周跨月时是否要求实时隐藏? | **当场消失并带移除动画,其余任务平滑重排**;以刷新方式实现也可接受(T-EX-18) |
| 22 | 任务状态为「完成」但所有列都未绑定「完成」时如何显示? | **保持当前列并继续显示**,直到归档。**不存在「已完成统一用 Done 列」的强制规则** |
| 23 | 已完成任务跨周跨月是否要求实时隐藏? | **不再隐藏**;由自动归档档位在到期后整树移出主列表(T-ARC-02、T-EX-18) |
| 24 | 「上次访问的项目」记录在哪?无记录时如何? | 记录在**设备端**;**无记录一律回个人默认项目**(T-VW-07、T-PM-07) |
| 25 | 看板列是否固定 4 列? | **非固定**:默认 4 列,支持增删改与排序,数量可变,至少保留 1 列(T-COL-01~07) |
| 26 | 是否允许删除最后一个列? | **不允许**。删除唯一剩余列时拒绝并提示,项目至少保留 1 列(T-COL-06、T-EX-17) |
| 27 | 新增列的默认设置? | 按**默认值**:保留选项 `永久显示`、不绑定状态,**不继承**相邻列设置(T-COL-02、B-18) |
| 27 | 新增列的默认设置? | 按**默认值**:不绑定状态,**不继承**相邻列设置;`retention` 字段已废弃,不再写入业务含义(T-COL-02、B-18) |
| 28 | 看板列数量上限? | 暂定**上限 10 列**(`待确认`,见下) |
| 29 | 任务首屏结构? | **上部** = 最近使用的 3 个项目卡片 + 「更多项目」入口;**下部** = 当前选中项目的任务管理视图,默认上次访问的项目(T-VW-08/09) |
| 30 | 首屏「任务卡片可以排序」的指代? | 指**最近项目卡片**可长按拖动排序,结果存设备端;任务卡片的列内排序维持既有口径(T-VW-10) |
@@ -490,7 +478,7 @@ model BoardColumn {
name String // 列名, 项目内唯一
sortOrder Int @default(0) // 显示顺序, 决定自动归位时的"第一个匹配列"
boundStatus String? // null = 不绑定; todo | doing | done
retention String @default("forever") // never | today | 3d | week | month | forever
retention String @default("forever") // 已废弃: 仅为兼容旧数据保留, 不参与显示/归档判定
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -573,7 +561,7 @@ model TaskTag {
> 说明:
> 1. `status` 与 `columnId` **两个字段都落库**(而非互相推导),因为未绑定的列需要二者彼此独立;联动仅在项目配置了绑定的列/状态之间发生,由服务端统一裁决。
> 2. 保留选项的判定依赖「在该列的完成时间」,建议在任务上记录完成时间或按列迁移时间计算,具体口径在设计阶段确定。
> 2. 自动归档判定依赖任务的**完成时间** `Task.doneAt`:顶层任务完成后按项目 `autoArchive` 档位到期,整棵树一起归档。
> 3. 视图记忆存设备端(localStorage),不落库。
---
@@ -587,9 +575,9 @@ model TaskTag {
| B-3 | **多列绑定同一状态时的目标列** | 移入列时按**当前列顺序**取**第一个**绑定该状态的列(列顺序可被用户调整) |
| B-4 | **列决定状态** | 拖动任务时只看**目标列**:目标列绑定了状态 ⇒ 状态 = 所绑定状态(含从任意列拖入);目标列未绑定 ⇒ 状态保持原值。**不存在「从某列拖出即回退」的规则** |
| B-4b | **状态与列不一致时的自动归位** | 状态改变后,若当前所在列未绑定该状态 ⇒ 按列序取第一个绑定该状态的列并移入;**无匹配列则不移动**,状态照常改变 |
| B-5 | **列保留选项** | `columnRetention` 为「列 → 选项」映射,选项为固定 6 档:`never`(永不显示)/ `today`(当天)/ `3d`(3 天)/ `week`(本周)/ `month`(本月)/ `forever`(永久显示) |
| B-5b | **各档位口径** | 均按 Asia/Shanghai 自然日:`today` = 今天;`3d` = 含今天在内的最近 3 天;`week` = 本周一 00:00 起;`month` = 本月 1 日 00:00 起;判定依据为任务的**完成时间** |
| B-6 | **显示控制作用域** | 列表视图与看板视图**同样**按任务**当前所属列**的保留选项执行过滤;列表只是不按列分组,归属不变;未完成任务不受影响 |
| B-5 | **自动归档档位** | `Project.autoArchive` 为项目级配置,固定 4 档:`3d`(完成后 3 天)/ `1w`(一周后)/ `1m`(一月后)/ `never`(永不) |
| B-5b | **档位口径** | `3d` = 完成时刻 + 72 小时;`1w` = 完成时刻 + 7 天;`1m` = 完成时刻 + 1 个上海日历月;判定依据为顶层任务的**完成时间** `doneAt` |
| B-6 | **归档作用域** | 只有顶层任务参与自动归档判定;到期后该任务与全部子任务一起归档,列表与看板同步移出,未完成任务不参与 |
| B-7 | **子任务与绑定无关** | 子任务状态**不受**看板列-状态绑定影响:拖入列不改写子任务状态,修改子任务状态也不触发「移入列」 |
| B-8 | **子任务约束** | `parentId` 非空时:父任务 `parentId` 必须为空(禁止二级);`projectId` 必须与父任务一致;禁止指向自身或后代 |
| B-9 | **拖拽降为子任务** | 长按拖动任务 A 到任务 B 上释放 ⇒ A.parentId = B.id;要求 B 为顶层任务且 A 本身无子任务,否则拒绝 |
@@ -598,8 +586,8 @@ model TaskTag {
| B-12 | **成员清空** | 移除最后一名成员时不删除 `Project`,仅删除 `ProjectMember` 记录;前端不特判 |
| B-13 | **跨项目拷贝** | 新建任务(新 id)+ 复制全部字段、扩展卡片与子任务;`creatorId` = 操作者;负责人 / 成员按 T-EX-08 过滤;状态与列按 T-EX-14 沿用 |
| B-14 | **权限校验** | 所有项目 / 任务接口均先校验「请求者为该项目成员」,非成员返回 403 |
| B-15 | **偏好存储** | 视图记忆与「上次访问的项目」存设备端;列定义、列-状态绑定、列保留选项属项目配置,存服务端、项目内共享 |
| B-16 | **看板列实体** | 列为 `BoardColumn`(name / sortOrder / boundStatus / retention);默认 4 列由新建项目时初始化;至少保留 1 列;列名项目内唯一 |
| B-15 | **偏好存储** | 视图记忆与「上次访问的项目」存设备端;列定义、列-状态绑定、自动归档档位属项目配置,存服务端、项目内共享 |
| B-16 | **看板列实体** | 列为 `BoardColumn`(name / sortOrder / boundStatus);默认 4 列由新建项目时初始化;至少保留 1 列;列名项目内唯一;`retention` 字段已废弃 |
| B-17 | **删除列的迁移** | 删除列前需二次确认;列内任务迁移至指定列(默认相邻列),迁移后任务状态按目标列绑定重新裁决;**唯一剩余列不可删除** |
| B-18 | **新增列默认值** | 新增列取默认值:`retention = forever`、`boundStatus = null`,**不继承**相邻列配置;`sortOrder` 追加到末尾 |
| B-18 | **新增列默认值** | 新增列取默认值:`boundStatus = null`,**不继承**相邻列配置;`sortOrder` 追加到末尾;不再写入 `retention` 业务含义 |
| B-19 | **列数上限** | 至少 1 列、至多 10 列(上限待确认) |
@@ -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 约半天到一天即可上线验证。
+5 -20
View File
@@ -27,21 +27,6 @@ export type User = Prisma.UserModel
*
*/
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
*
@@ -77,11 +62,6 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
*
*/
export type ScraperCache = Prisma.ScraperCacheModel
/**
* Model OilPriceHistory
*
*/
export type OilPriceHistory = Prisma.OilPriceHistoryModel
/**
* Model MihomeAccount
*
@@ -162,3 +142,8 @@ export type TaskMember = Prisma.TaskMemberModel
*
*/
export type TaskTag = Prisma.TaskTagModel
/**
* Model McpToken
*
*/
export type McpToken = Prisma.McpTokenModel
+5 -20
View File
@@ -51,21 +51,6 @@ export type User = Prisma.UserModel
*
*/
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
*
@@ -101,11 +86,6 @@ export type Econ_SowInventory = Prisma.Econ_SowInventoryModel
*
*/
export type ScraperCache = Prisma.ScraperCacheModel
/**
* Model OilPriceHistory
*
*/
export type OilPriceHistory = Prisma.OilPriceHistoryModel
/**
* Model MihomeAccount
*
@@ -186,3 +166,8 @@ export type TaskMember = Prisma.TaskMemberModel
*
*/
export type TaskTag = Prisma.TaskTagModel
/**
* Model McpToken
*
*/
export type McpToken = Prisma.McpTokenModel
+144 -144
View File
@@ -99,97 +99,6 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
_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> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
@@ -228,6 +137,33 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
_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> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
@@ -328,6 +264,11 @@ export type DateTimeNullableFilter<$PrismaModel = never> = {
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> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
@@ -358,6 +299,14 @@ export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
_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> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
@@ -385,6 +334,57 @@ export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
_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> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
@@ -479,59 +479,6 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
_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> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
@@ -574,6 +521,22 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
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> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[] | Prisma.ListBigIntFieldRefInput<$PrismaModel>
@@ -636,6 +599,11 @@ export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
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> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
@@ -677,6 +645,14 @@ export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
_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> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
@@ -693,4 +669,28 @@ export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
_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
+128 -395
View File
@@ -386,9 +386,6 @@ type FieldRefInputType<Model, FieldType> = Model extends never ? never : FieldRe
export const ModelName = {
User: 'User',
Article: 'Article',
Strategy: 'Strategy',
Indicator: 'Indicator',
IndicatorPoint: 'IndicatorPoint',
News: 'News',
Alert: 'Alert',
ServerNode: 'ServerNode',
@@ -396,7 +393,6 @@ export const ModelName = {
ServiceCheck: 'ServiceCheck',
Econ_SowInventory: 'Econ_SowInventory',
ScraperCache: 'ScraperCache',
OilPriceHistory: 'OilPriceHistory',
MihomeAccount: 'MihomeAccount',
MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice',
@@ -412,7 +408,8 @@ export const ModelName = {
BoardColumn: 'BoardColumn',
Task: 'Task',
TaskMember: 'TaskMember',
TaskTag: 'TaskTag'
TaskTag: 'TaskTag',
McpToken: 'McpToken'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -428,7 +425,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "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
}
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: {
payload: Prisma.$NewsPayload<ExtArgs>
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: {
payload: Prisma.$MihomeAccountPayload<ExtArgs>
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: {
@@ -2642,46 +2417,6 @@ export const 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 = {
id: 'id',
title: 'title',
@@ -2801,19 +2536,6 @@ export const ScraperCacheScalarFieldEnum = {
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof ScraperCacheScalarFieldEnum]
export const OilPriceHistoryScalarFieldEnum = {
id: 'id',
date: 'date',
province: 'province',
fuel: 'fuel',
price: 'price',
source: 'source',
createdAt: 'createdAt'
} as const
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
export const MihomeAccountScalarFieldEnum = {
id: 'id',
key: 'key',
@@ -3068,6 +2790,20 @@ export const 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 = {
asc: 'asc',
desc: 'desc'
@@ -3076,13 +2812,6 @@ export const 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 = {
DbNull: DbNull,
JsonNull: JsonNull
@@ -3091,6 +2820,13 @@ export const 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 = {
default: 'default',
insensitive: 'insensitive'
@@ -3099,6 +2835,14 @@ export const 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 = {
DbNull: DbNull,
JsonNull: JsonNull,
@@ -3108,14 +2852,6 @@ export const 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
@@ -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'
*/
@@ -3212,6 +2927,27 @@ export type BigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel,
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
*/
@@ -3309,9 +3045,6 @@ export type PrismaClientOptions = ({
export type GlobalOmitConfig = {
user?: Prisma.UserOmit
article?: Prisma.ArticleOmit
strategy?: Prisma.StrategyOmit
indicator?: Prisma.IndicatorOmit
indicatorPoint?: Prisma.IndicatorPointOmit
news?: Prisma.NewsOmit
alert?: Prisma.AlertOmit
serverNode?: Prisma.ServerNodeOmit
@@ -3319,7 +3052,6 @@ export type GlobalOmitConfig = {
serviceCheck?: Prisma.ServiceCheckOmit
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
scraperCache?: Prisma.ScraperCacheOmit
oilPriceHistory?: Prisma.OilPriceHistoryOmit
mihomeAccount?: Prisma.MihomeAccountOmit
mihomeHome?: Prisma.MihomeHomeOmit
mihomeDevice?: Prisma.MihomeDeviceOmit
@@ -3336,6 +3068,7 @@ export type GlobalOmitConfig = {
task?: Prisma.TaskOmit
taskMember?: Prisma.TaskMemberOmit
taskTag?: Prisma.TaskTagOmit
mcpToken?: Prisma.McpTokenOmit
}
/* Types for Logging */
@@ -53,9 +53,6 @@ export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Article: 'Article',
Strategy: 'Strategy',
Indicator: 'Indicator',
IndicatorPoint: 'IndicatorPoint',
News: 'News',
Alert: 'Alert',
ServerNode: 'ServerNode',
@@ -63,7 +60,6 @@ export const ModelName = {
ServiceCheck: 'ServiceCheck',
Econ_SowInventory: 'Econ_SowInventory',
ScraperCache: 'ScraperCache',
OilPriceHistory: 'OilPriceHistory',
MihomeAccount: 'MihomeAccount',
MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice',
@@ -79,7 +75,8 @@ export const ModelName = {
BoardColumn: 'BoardColumn',
Task: 'Task',
TaskMember: 'TaskMember',
TaskTag: 'TaskTag'
TaskTag: 'TaskTag',
McpToken: 'McpToken'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -123,46 +120,6 @@ export const 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 = {
id: 'id',
title: 'title',
@@ -282,19 +239,6 @@ export const ScraperCacheScalarFieldEnum = {
export type ScraperCacheScalarFieldEnum = (typeof ScraperCacheScalarFieldEnum)[keyof typeof ScraperCacheScalarFieldEnum]
export const OilPriceHistoryScalarFieldEnum = {
id: 'id',
date: 'date',
province: 'province',
fuel: 'fuel',
price: 'price',
source: 'source',
createdAt: 'createdAt'
} as const
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
export const MihomeAccountScalarFieldEnum = {
id: 'id',
key: 'key',
@@ -549,6 +493,20 @@ export const 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 = {
asc: 'asc',
desc: 'desc'
@@ -557,13 +515,6 @@ export const 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 = {
DbNull: DbNull,
JsonNull: JsonNull
@@ -572,6 +523,13 @@ export const 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 = {
default: 'default',
insensitive: 'insensitive'
@@ -580,6 +538,14 @@ export const 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 = {
DbNull: DbNull,
JsonNull: JsonNull,
@@ -588,11 +554,3 @@ export const 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]
+1 -4
View File
@@ -10,9 +10,6 @@
*/
export type * from './models/User.ts'
export type * from './models/Article.ts'
export type * from './models/Strategy.ts'
export type * from './models/Indicator.ts'
export type * from './models/IndicatorPoint.ts'
export type * from './models/News.ts'
export type * from './models/Alert.ts'
export type * from './models/ServerNode.ts'
@@ -20,7 +17,6 @@ export type * from './models/ServerNodeHistory.ts'
export type * from './models/ServiceCheck.ts'
export type * from './models/Econ_SowInventory.ts'
export type * from './models/ScraperCache.ts'
export type * from './models/OilPriceHistory.ts'
export type * from './models/MihomeAccount.ts'
export type * from './models/MihomeHome.ts'
export type * from './models/MihomeDevice.ts'
@@ -37,4 +33,5 @@ export type * from './models/BoardColumn.ts'
export type * from './models/Task.ts'
export type * from './models/TaskMember.ts'
export type * from './models/TaskTag.ts'
export type * from './models/McpToken.ts'
export type * from './commonInputTypes.ts'
+8
View File
@@ -480,6 +480,14 @@ export type AlertSumOrderByAggregateInput = {
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<{
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -737,6 +737,10 @@ export type NullableDateTimeFieldUpdateOperationsInput = {
set?: Date | string | null
}
export type BoolFieldUpdateOperationsInput = {
set?: boolean
}
export type MihomeDeviceCreateNestedOneWithoutSamplesInput = {
create?: Prisma.XOR<Prisma.MihomeDeviceCreateWithoutSamplesInput, Prisma.MihomeDeviceUncheckedCreateWithoutSamplesInput>
connectOrCreate?: Prisma.MihomeDeviceCreateOrConnectWithoutSamplesInput
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+180
View File
@@ -233,6 +233,7 @@ export type UserWhereInput = {
assignedTasks?: Prisma.TaskListRelationFilter
createdTasks?: Prisma.TaskListRelationFilter
taskMembers?: Prisma.TaskMemberListRelationFilter
mcpTokens?: Prisma.McpTokenListRelationFilter
}
export type UserOrderByWithRelationInput = {
@@ -251,6 +252,7 @@ export type UserOrderByWithRelationInput = {
assignedTasks?: Prisma.TaskOrderByRelationAggregateInput
createdTasks?: Prisma.TaskOrderByRelationAggregateInput
taskMembers?: Prisma.TaskMemberOrderByRelationAggregateInput
mcpTokens?: Prisma.McpTokenOrderByRelationAggregateInput
}
export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -272,6 +274,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
assignedTasks?: Prisma.TaskListRelationFilter
createdTasks?: Prisma.TaskListRelationFilter
taskMembers?: Prisma.TaskMemberListRelationFilter
mcpTokens?: Prisma.McpTokenListRelationFilter
}, "id" | "email">
export type UserOrderByWithAggregationInput = {
@@ -315,6 +318,7 @@ export type UserCreateInput = {
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateInput = {
@@ -333,6 +337,7 @@ export type UserUncheckedCreateInput = {
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserUpdateInput = {
@@ -350,6 +355,7 @@ export type UserUpdateInput = {
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -368,6 +374,7 @@ export type UserUncheckedUpdateInput = {
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateManyInput = {
@@ -587,6 +594,20 @@ export type UserUpdateOneRequiredWithoutTaskMembersNestedInput = {
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 = {
name: string
email: string
@@ -601,6 +622,7 @@ export type UserCreateWithoutHabitsInput = {
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutHabitsInput = {
@@ -618,6 +640,7 @@ export type UserUncheckedCreateWithoutHabitsInput = {
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutHabitsInput = {
@@ -650,6 +673,7 @@ export type UserUpdateWithoutHabitsInput = {
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutHabitsInput = {
@@ -667,6 +691,7 @@ export type UserUncheckedUpdateWithoutHabitsInput = {
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutCheckInsInput = {
@@ -683,6 +708,7 @@ export type UserCreateWithoutCheckInsInput = {
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutCheckInsInput = {
@@ -700,6 +726,7 @@ export type UserUncheckedCreateWithoutCheckInsInput = {
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutCheckInsInput = {
@@ -732,6 +759,7 @@ export type UserUpdateWithoutCheckInsInput = {
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutCheckInsInput = {
@@ -749,6 +777,7 @@ export type UserUncheckedUpdateWithoutCheckInsInput = {
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutCheckListsInput = {
@@ -765,6 +794,7 @@ export type UserCreateWithoutCheckListsInput = {
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutCheckListsInput = {
@@ -782,6 +812,7 @@ export type UserUncheckedCreateWithoutCheckListsInput = {
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutCheckListsInput = {
@@ -814,6 +845,7 @@ export type UserUpdateWithoutCheckListsInput = {
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutCheckListsInput = {
@@ -831,6 +863,7 @@ export type UserUncheckedUpdateWithoutCheckListsInput = {
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutTemplatesInput = {
@@ -847,6 +880,7 @@ export type UserCreateWithoutTemplatesInput = {
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutTemplatesInput = {
@@ -864,6 +898,7 @@ export type UserUncheckedCreateWithoutTemplatesInput = {
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutTemplatesInput = {
@@ -896,6 +931,7 @@ export type UserUpdateWithoutTemplatesInput = {
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutTemplatesInput = {
@@ -913,6 +949,7 @@ export type UserUncheckedUpdateWithoutTemplatesInput = {
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutOwnedProjectsInput = {
@@ -929,6 +966,7 @@ export type UserCreateWithoutOwnedProjectsInput = {
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutOwnedProjectsInput = {
@@ -946,6 +984,7 @@ export type UserUncheckedCreateWithoutOwnedProjectsInput = {
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutOwnedProjectsInput = {
@@ -978,6 +1017,7 @@ export type UserUpdateWithoutOwnedProjectsInput = {
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutOwnedProjectsInput = {
@@ -995,6 +1035,7 @@ export type UserUncheckedUpdateWithoutOwnedProjectsInput = {
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutProjectMembersInput = {
@@ -1011,6 +1052,7 @@ export type UserCreateWithoutProjectMembersInput = {
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutProjectMembersInput = {
@@ -1028,6 +1070,7 @@ export type UserUncheckedCreateWithoutProjectMembersInput = {
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutProjectMembersInput = {
@@ -1060,6 +1103,7 @@ export type UserUpdateWithoutProjectMembersInput = {
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutProjectMembersInput = {
@@ -1077,6 +1121,7 @@ export type UserUncheckedUpdateWithoutProjectMembersInput = {
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutAssignedTasksInput = {
@@ -1093,6 +1138,7 @@ export type UserCreateWithoutAssignedTasksInput = {
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutAssignedTasksInput = {
@@ -1110,6 +1156,7 @@ export type UserUncheckedCreateWithoutAssignedTasksInput = {
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutAssignedTasksInput = {
@@ -1131,6 +1178,7 @@ export type UserCreateWithoutCreatedTasksInput = {
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
taskMembers?: Prisma.TaskMemberCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutCreatedTasksInput = {
@@ -1148,6 +1196,7 @@ export type UserUncheckedCreateWithoutCreatedTasksInput = {
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
taskMembers?: Prisma.TaskMemberUncheckedCreateNestedManyWithoutUserInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutCreatedTasksInput = {
@@ -1180,6 +1229,7 @@ export type UserUpdateWithoutAssignedTasksInput = {
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutAssignedTasksInput = {
@@ -1197,6 +1247,7 @@ export type UserUncheckedUpdateWithoutAssignedTasksInput = {
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
createdTasks?: Prisma.TaskUncheckedUpdateManyWithoutCreatorNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserUpsertWithoutCreatedTasksInput = {
@@ -1224,6 +1275,7 @@ export type UserUpdateWithoutCreatedTasksInput = {
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
taskMembers?: Prisma.TaskMemberUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutCreatedTasksInput = {
@@ -1241,6 +1293,7 @@ export type UserUncheckedUpdateWithoutCreatedTasksInput = {
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
taskMembers?: Prisma.TaskMemberUncheckedUpdateManyWithoutUserNestedInput
mcpTokens?: Prisma.McpTokenUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutTaskMembersInput = {
@@ -1257,6 +1310,7 @@ export type UserCreateWithoutTaskMembersInput = {
projectMembers?: Prisma.ProjectMemberCreateNestedManyWithoutUserInput
assignedTasks?: Prisma.TaskCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskCreateNestedManyWithoutCreatorInput
mcpTokens?: Prisma.McpTokenCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutTaskMembersInput = {
@@ -1274,6 +1328,7 @@ export type UserUncheckedCreateWithoutTaskMembersInput = {
projectMembers?: Prisma.ProjectMemberUncheckedCreateNestedManyWithoutUserInput
assignedTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutAssigneeInput
createdTasks?: Prisma.TaskUncheckedCreateNestedManyWithoutCreatorInput
mcpTokens?: Prisma.McpTokenUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutTaskMembersInput = {
@@ -1306,6 +1361,7 @@ export type UserUpdateWithoutTaskMembersInput = {
projectMembers?: Prisma.ProjectMemberUpdateManyWithoutUserNestedInput
assignedTasks?: Prisma.TaskUpdateManyWithoutAssigneeNestedInput
createdTasks?: Prisma.TaskUpdateManyWithoutCreatorNestedInput
mcpTokens?: Prisma.McpTokenUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutTaskMembersInput = {
@@ -1323,6 +1379,93 @@ export type UserUncheckedUpdateWithoutTaskMembersInput = {
projectMembers?: Prisma.ProjectMemberUncheckedUpdateManyWithoutUserNestedInput
assignedTasks?: Prisma.TaskUncheckedUpdateManyWithoutAssigneeNestedInput
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
createdTasks: number
taskMembers: number
mcpTokens: number
}
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
createdTasks?: boolean | UserCountOutputTypeCountCreatedTasksArgs
taskMembers?: boolean | UserCountOutputTypeCountTaskMembersArgs
mcpTokens?: boolean | UserCountOutputTypeCountMcpTokensArgs
}
/**
@@ -1427,6 +1572,13 @@ export type UserCountOutputTypeCountTaskMembersArgs<ExtArgs extends runtime.Type
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<{
id?: boolean
@@ -1444,6 +1596,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
assignedTasks?: boolean | Prisma.User$assignedTasksArgs<ExtArgs>
createdTasks?: boolean | Prisma.User$createdTasksArgs<ExtArgs>
taskMembers?: boolean | Prisma.User$taskMembersArgs<ExtArgs>
mcpTokens?: boolean | Prisma.User$mcpTokensArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -1485,6 +1638,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
assignedTasks?: boolean | Prisma.User$assignedTasksArgs<ExtArgs>
createdTasks?: boolean | Prisma.User$createdTasksArgs<ExtArgs>
taskMembers?: boolean | Prisma.User$taskMembersArgs<ExtArgs>
mcpTokens?: boolean | Prisma.User$mcpTokensArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
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>[]
createdTasks: Prisma.$TaskPayload<ExtArgs>[]
taskMembers: Prisma.$TaskMemberPayload<ExtArgs>[]
mcpTokens: Prisma.$McpTokenPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
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>
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>
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.
* @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[]
}
/**
* 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
*/
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1f2937" />
<title>iBoard - 经济数据与策略</title>
<title>iBoard - 个人看板</title>
</head>
<body>
<div id="root"></div>
+39 -1
View File
@@ -9,6 +9,7 @@
"version": "0.2.1",
"dependencies": {
"@journeyapps/wa-sqlite": "^2.0.7",
"@modelcontextprotocol/sdk": "^1.30.0",
"@powersync/react": "^2.1.0",
"@powersync/web": "^2.5.0",
"@prisma/adapter-pg": "^7.9.1",
@@ -29,8 +30,11 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.8.1",
"scroll-into-view-if-needed": "^3.1.0",
"swiper": "^14.4.0",
"tauri-plugin-hotswap-api": "^0.0.4",
"ws": "^8.21.2"
"ws": "^8.21.2",
"zod": "^4.6.5"
},
"devDependencies": {
"@tauri-apps/api": "^2.12.4",
@@ -3636,6 +3640,12 @@
"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": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
@@ -6391,6 +6401,15 @@
"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": {
"version": "1.0.0",
"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"
}
},
"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": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/tauri-plugin-hotswap-api/-/tauri-plugin-hotswap-api-0.0.4.tgz",
+5 -3
View File
@@ -18,8 +18,6 @@
"db:migrate": "prisma migrate deploy",
"db:seed": "node scripts/seed.cjs",
"registry:sync": "node scripts/sync-registry.mjs",
"data:refresh": "node --no-warnings scripts/refresh-indicators.cjs",
"data:discover": "node --no-warnings scripts/discover-mcp-tools.cjs",
"icons": "node scripts/generate-icons.mjs",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
@@ -35,6 +33,7 @@
},
"dependencies": {
"@journeyapps/wa-sqlite": "^2.0.7",
"@modelcontextprotocol/sdk": "^1.30.0",
"@powersync/react": "^2.1.0",
"@powersync/web": "^2.5.0",
"@prisma/adapter-pg": "^7.9.1",
@@ -55,8 +54,11 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.8.1",
"scroll-into-view-if-needed": "^3.1.0",
"swiper": "^14.4.0",
"tauri-plugin-hotswap-api": "^0.0.4",
"ws": "^8.21.2"
"ws": "^8.21.2",
"zod": "^4.6.5"
},
"devDependencies": {
"@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
View File
@@ -32,6 +32,9 @@ model User {
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
taskMembers TaskMember[]
// AI 接入 (MCP): 每人可创建多个具名授权 token
mcpTokens McpToken[]
}
model Article {
@@ -44,41 +47,6 @@ model Article {
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 拉取, 按影响大小排序, 累加多日供首页无限滚动
model News {
@@ -96,7 +64,7 @@ model News {
@@index([publishedAt])
}
// 报警: refresh 写库时比对同 (indicatorName, period) 的跨源数据, 差异超阈值记录
// 报警: 定时任务失败等异常写入 (展示于「监控」报警 tab)
model Alert {
id Int @id @default(autoincrement())
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 存凭证, 本表只存状态/错误供前端展示)
@@ -513,4 +462,22 @@ model TaskTag {
name String @db.VarChar(20)
@@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])
}
+347
View File
@@ -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"
}
]
}
+147
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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, '&amp;').replace(/</g, '&lt;') +
'</code></p></body></html>'
}
-72
View File
@@ -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)
+113
View File
@@ -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] 完成"
-17
View File
@@ -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)});
-32
View File
@@ -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)
})
-4
View File
@@ -27,9 +27,6 @@ const TABLES = [
['TaskMember', 'taskMember'],
['TaskTag', 'taskTag'],
['Article', 'article'],
['Strategy', 'strategy'],
['Indicator', 'indicator'],
['IndicatorPoint', 'indicatorPoint'],
['News', 'news'],
['Alert', 'alert'],
['ServerNode', 'serverNode'],
@@ -37,7 +34,6 @@ const TABLES = [
['ServiceCheck', 'serviceCheck'],
['Econ_SowInventory', 'econ_SowInventory'],
['ScraperCache', 'scraperCache'],
['OilPriceHistory', 'oilPriceHistory'],
['MihomeAccount', 'mihomeAccount'],
['MihomeHome', 'mihomeHome'],
['MihomeDevice', 'mihomeDevice'],
-79
View File
@@ -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();
-303
View File
@@ -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
}
-83
View File
@@ -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
}
-168
View File
@@ -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);
});
}
-150
View File
@@ -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);
});
}
-113
View File
@@ -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
}
-119
View File
@@ -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)
})
-84
View File
@@ -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) })
-185
View File
@@ -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)
})
-338
View File
@@ -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();
-335
View File
@@ -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();
-1
View File
File diff suppressed because one or more lines are too long
+86 -132
View File
@@ -1,133 +1,87 @@
/**
* iBoard 基础数据初始化脚本
* 用法: npm run db:seed
* 幂等: 主键/唯一键冲突跳过, Strategy/Indicator 按 name 存在则更新
*/
require('dotenv').config();
const crypto = require('crypto');
const mysql = require('mysql2/promise');
function hashPassword(plain) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(plain, salt, 64).toString('hex');
return 'scrypt$' + salt + '$' + hash;
}
function parseUrl(url) {
const u = new URL(url);
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
};
}
async function insertIgnore(conn, sql, params) {
try {
const [r] = await conn.execute(sql, params);
return r.affectedRows;
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') return 0;
throw e;
}
}
async function upsertByName(conn, table, payload, keyField) {
const fields = Object.keys(payload);
const placeholders = fields.map(() => '?').join(', ');
const updateClause = fields
.filter((f) => f !== keyField)
.map((f) => '`' + f + '` = VALUES(`' + f + '`)')
.join(', ');
const sql = 'INSERT INTO `' + table + '` (' + fields.map((f) => '`' + f + '`').join(', ') + ') VALUES (' + placeholders + ') ON DUPLICATE KEY UPDATE ' + updateClause;
const params = fields.map((f) => payload[f]);
const [r] = await conn.execute(sql, params);
return r.affectedRows;
}
async function main() {
const cfg = parseUrl(process.env.DATABASE_URL);
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
const conn = await mysql.createConnection(cfg);
console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database);
const adminPwd = hashPassword('admin123');
const n1 = await insertIgnore(
conn,
'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))',
['Admin', 'admin@iboard.local', adminPwd],
);
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
const strategies = [
{ name: 'us-debt-gdp', description: '美国政府债务/GDP分析',
data: { source: 'strategy.js', unit: '万亿美元', rows: [
{ year: '2019', debt: 22.7, gdp: 21.4, debtToGdp: 106.1, interestToGdp: 1.8 },
{ year: '2020', debt: 26.9, gdp: 20.9, debtToGdp: 128.7, interestToGdp: 1.6 },
{ year: '2021', debt: 28.4, gdp: 23.0, debtToGdp: 123.5, interestToGdp: 1.5 },
{ year: '2022', debt: 30.9, gdp: 25.4, debtToGdp: 121.6, interestToGdp: 1.6 },
{ year: '2023', debt: 33.1, gdp: 26.9, debtToGdp: 123.0, interestToGdp: 1.9 },
{ year: '2024', debt: 35.3, gdp: 28.5, debtToGdp: 123.9, interestToGdp: 2.1 },
]},
},
{ name: 'china-debt-gdp', description: '中国政府债务/GDP分析',
data: { source: 'strategy.js', unit: '万亿美元', rows: [
{ year: '2019', debt: 8.8, gdp: 14.3, debtToGdp: 61.5, interestToGdp: 1.9 },
{ year: '2020', debt: 10.3, gdp: 14.7, debtToGdp: 70.1, interestToGdp: 1.8 },
{ year: '2021', debt: 11.7, gdp: 16.1, debtToGdp: 72.7, interestToGdp: 1.7 },
{ year: '2022', debt: 13.1, gdp: 17.9, debtToGdp: 73.2, interestToGdp: 1.6 },
{ year: '2023', debt: 14.5, gdp: 19.4, debtToGdp: 74.7, interestToGdp: 1.6 },
{ year: '2024', debt: 15.9, gdp: 21.0, debtToGdp: 75.7, interestToGdp: 1.5 },
]},
},
];
let n2 = 0;
for (const s of strategies) {
n2 += await upsertByName(conn, 'Strategy',
{ name: s.name, description: s.description, data: JSON.stringify(s.data), updatedAt: new Date() },
'name');
}
console.log('[Strategy] upsert 策略: ' + n2 + ' 行 (1=新增, 2=更新)');
// Indicator 表: 改由 scripts/refresh-indicators.cjs 管理 (2026-08 重构后数据存 IndicatorPoint 明细表)
// 本脚本不再写入 Indicator, 如需初始化指标请运行 npm run data:refresh
const article = {
title: '欢迎使用 iBoard',
slug: 'welcome-to-iboard',
content: 'iBoard 用于收集和整理经济数据,以策略为主体重新组织。\n\n本文为示例条目,正式文章仍由 content/articles/*.md 文件承载。',
date: '2026-08-03',
};
const n4 = await insertIgnore(
conn,
'INSERT INTO `Article` (title, slug, content, date, createdAt, updatedAt) VALUES (?, ?, ?, ?, NOW(3), NOW(3))',
[article.title, article.slug, article.content, article.date],
);
console.log('[Article] 新增示例文章: ' + n4 + ' 行');
const sowRows = [
{ month: '2024-01', inventory: 4067 },
{ month: '2024-02', inventory: 4042 },
{ month: '2024-03', inventory: 3992 },
{ month: '2024-04', inventory: 3986 },
{ month: '2024-05', inventory: 3996 },
{ month: '2024-06', inventory: 4038 },
];
let n5 = 0;
for (const r of sowRows) {
n5 += await insertIgnore(
conn,
'INSERT INTO `Econ_SowInventory` (month, inventory, createdAt, updatedAt) VALUES (?, ?, NOW(3), NOW(3))',
[r.month, r.inventory],
);
}
console.log('[Econ_SowInventory] 新增: ' + n5 + ' 行');
await conn.end();
console.log('\nSeed 完成.');
}
/**
* iBoard 基础数据初始化脚本
* 用法: npm run db:seed
* 幂等: 主键/唯一键冲突跳过
*/
require('dotenv').config();
const crypto = require('crypto');
const mysql = require('mysql2/promise');
function hashPassword(plain) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(plain, salt, 64).toString('hex');
return 'scrypt$' + salt + '$' + hash;
}
function parseUrl(url) {
const u = new URL(url);
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
};
}
async function insertIgnore(conn, sql, params) {
try {
const [r] = await conn.execute(sql, params);
return r.affectedRows;
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') return 0;
throw e;
}
}
async function main() {
const cfg = parseUrl(process.env.DATABASE_URL);
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
const conn = await mysql.createConnection(cfg);
console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database);
const adminPwd = hashPassword('admin123');
const n1 = await insertIgnore(
conn,
'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))',
['Admin', 'admin@iboard.local', adminPwd],
);
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
const 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); });
-22
View File
@@ -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)});
-31
View File
@@ -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()
}
-22
View File
@@ -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); });
-15
View File
@@ -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); });
-22
View File
@@ -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();
}
+3
View File
@@ -31,6 +31,9 @@ if [ ! -d "gen/android" ]; then
fi
cd "$WIN_PROJECT"
# 0.5 软键盘避让: 注入 MainActivity 的 IME Insets 处理 (幂等, 每次都要跑)
bash scripts/ci/android-softinput.sh
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
export VITE_API_BASE="${VITE_API_BASE:-https://iboard.honor3.com}"
echo "VITE_API_BASE=$VITE_API_BASE"
-46
View File
@@ -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 }
}
-46
View File
@@ -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'))
}
-77
View File
@@ -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
}
-6
View File
@@ -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'
-87
View File
@@ -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),
}))
}
-41
View File
@@ -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
View File
@@ -1,110 +1,52 @@
import cron from 'node-cron'
import { prisma } from './db.js'
import mysql from 'mysql2/promise'
import { checkAfterRefresh } from './alert-engine.js'
import { ApizeroError, oil, history } from './apizero/index.js'
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
async function importNewsFetcher() {
const mod = await import('../scripts/fetchers/news.fetcher.js')
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
}
async function alertError(taskName, message) {
try {
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
await insertCronAlert(taskName, message)
} catch (e) {
console.error('[cron] 报警写入失败(忽略):', e.message)
}
}
function parseUrl(url) {
const u = new URL(url)
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
}
async function openConn() {
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
}
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
function scheduleNews() {
cron.schedule('0 6 * * *', async () => {
console.log('[cron] 6:00 触发')
try {
const { fetchNewsList, persistNews } = await importNewsFetcher()
const list = await fetchNewsList()
const r = await persistNews(list)
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
const conn = await openConn()
try {
const a = await checkAfterRefresh(conn)
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
} finally { await conn.end() }
} catch (e) {
console.error('[cron] 任务失败:', e.message)
await alertError('新闻拉取+报警检测', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
}
// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
// - 0 命中: 不调 apizero (省配额)
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
function scheduleOilHistory() {
cron.schedule('10 6 * * *', async () => {
console.log('[cron] 6:10 油价历史触发')
try {
const year = new Date().getFullYear()
const sched = await oil.getSchedule(year)
const list = sched.schedule || []
// 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
const today = new Date(todayStr + 'T00:00:00+08:00')
const yesterdayMs = today.getTime() - 86400000
const pendingPast = list.filter((it) => {
if (it.status !== '待定') return false
const t = new Date(it.date + 'T00:00:00').getTime()
return Number.isFinite(t) && t <= yesterdayMs
})
console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
if (pendingPast.length === 0) {
console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
return
}
const r = await history.fetchAndStore()
console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
} catch (e) {
if (e instanceof ApizeroError) {
console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
} else {
console.error('[cron] 油价历史任务失败:', e.message)
}
await alertError('浙江油价历史抓取', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
}
// 每小时兜底: 任务自动归档扫描 (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()
import cron from 'node-cron'
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
async function importNewsFetcher() {
const mod = await import('../scripts/fetchers/news.fetcher.js')
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
}
async function alertError(taskName, message) {
try {
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
await insertCronAlert(taskName, message)
} catch (e) {
console.error('[cron] 报警写入失败(忽略):', e.message)
}
}
// 每天 6:00 (Asia/Shanghai) 拉取新闻, 出错生成报警
function scheduleNews() {
cron.schedule('0 6 * * *', async () => {
console.log('[cron] 6:00 触发')
try {
const { fetchNewsList, persistNews } = await importNewsFetcher()
const list = await fetchNewsList()
const r = await persistNews(list)
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
} catch (e) {
console.error('[cron] 任务失败:', e.message)
await alertError('新闻拉取', e.message)
}
}, { timezone: 'Asia/Shanghai' })
console.log('[cron] 已注册: 每天 06:00 拉取新闻')
}
// 每小时兜底: 任务自动归档扫描 (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()
scheduleAutoArchive()
+5 -2
View File
@@ -9,7 +9,6 @@ import newsRouter from './routes/news.js'
import alertsRouter from './routes/alerts.js'
import articlesRouter from './routes/articles.js'
import otaRouter from './routes/ota.js'
import apizeroRouter from './routes/apizero.js'
import monitorsRouter from './routes/monitors.js'
import mihomeRouter from './routes/mihome.js'
import checkinRouter from './routes/checkin.js'
@@ -17,6 +16,8 @@ import checklistsRouter from './routes/checklists.js'
import projectsRouter, { columnRouter } from './routes/projects.js'
import tasksRouter from './routes/tasks.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'
const PORT = Number(process.env.PORT) || 3001
@@ -39,11 +40,13 @@ app.use('/api/auth', authRouter)
app.use('/api', otaRouter)
// PowerSync: token 签发 + 离线上行队列 (自带 authRequired, 同样必须挂在带 authRequired 的业务 router 之前)
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', newsRouter)
app.use('/api', alertsRouter)
app.use('/api', articlesRouter)
app.use('/api', apizeroRouter)
app.use('/api', monitorsRouter)
app.use('/api/mihome', mihomeRouter)
// V0.2 打卡 / 日历 / 清单
+50
View File
@@ -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 }
}
+56
View File
@@ -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
}
+82
View File
@@ -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
+262
View File
@@ -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 }
})
}
+288
View File
@@ -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 }
})
}
+795
View File
@@ -0,0 +1,795 @@
// 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 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 表示不联动'),
},
}, async ({ projectId, name, boundStatus = null }) => {
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('绑定状态取值非法')
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,
},
})
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 表示取消绑定'),
},
}, async ({ columnId, name, boundStatus }) => {
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
}
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)
}
-63
View File
@@ -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) }
})
+14 -3
View File
@@ -122,11 +122,15 @@ router.get('/habits', async (req, res, next) => {
const userId = req.user.sub
const today = toDateStr()
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 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 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 } })
} catch (e) { next(e) }
})
@@ -291,7 +295,14 @@ router.get('/calendar', async (req, res, next) => {
try {
const userId = req.user.sub
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 end = addDays(monthStartNext(month), -1)
const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] })
+1 -1
View File
@@ -44,7 +44,7 @@ const BUILTIN_TEMPLATES = [
},
]
async function ensureBuiltin() {
export async function ensureBuiltin() {
const count = await prisma.checkListTemplate.count({ where: { builtin: true } })
if (count >= BUILTIN_TEMPLATES.length) return
for (const t of BUILTIN_TEMPLATES) {
+2 -31
View File
@@ -6,36 +6,7 @@ const router = Router()
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) => {
try {
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) }
})
export default router
export default router
+104
View File
@@ -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
+52 -3
View File
@@ -7,6 +7,7 @@ import jwt from 'jsonwebtoken'
import { prisma } from '../db.js'
import { JWT_SECRET, authRequired } from '../middleware.js'
import { genUuid, normProjectCode, allocProjectCode, withTaskNumber } from '../task-domain/identity.js'
import { resolveTaskUpload } from '../task-domain/resolveStatus.js'
const router = Router()
@@ -96,6 +97,17 @@ async function applyPut(op, ctx) {
if (d.taskKinds !== undefined) {
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 })
return {}
}
@@ -183,18 +195,43 @@ async function applyPut(op, ctx) {
if (d.extras !== undefined) data.extras = cleanExtras(d.extras)
if (d.sortOrder !== undefined) data.sortOrder = Number(d.sortOrder) || 0
if (d.assigneeId !== undefined) data.assigneeId = d.assigneeId ? Number(d.assigneeId) : null
if (d.doneAt !== undefined) data.doneAt = isoTime(d.doneAt)
// 归档状态 (T-ARC): 客户端本地归档/恢复走这里
if (d.archived !== undefined) data.archived = !!d.archived
if (d.archivedAt !== undefined) data.archivedAt = isoTime(d.archivedAt)
if (d.archiveSource !== undefined) {
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) {
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } })
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) {
data.parentId = null
@@ -355,7 +392,19 @@ router.post('/upload', authRequired, async (req, res, next) => {
memberCache.set(projectId, 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); 删除反向
const rank = (o) => TABLE_RANK[o?.table] ?? 99
+21 -11
View File
@@ -6,7 +6,6 @@ import {
loadTaskForUser, loadProjectForUser, loadColumns, loadMemberIds,
} from '../task-domain/access.js'
import { resolveTaskWrite } from '../task-domain/resolveStatus.js'
import { filterVisible } from '../task-domain/retention.js'
import { copyTask } from '../task-domain/copyTask.js'
import { runAutoArchiveForProject } from '../task-domain/archive.js'
import { genUuid, withUuid, withTaskNumber } from '../task-domain/identity.js'
@@ -65,24 +64,31 @@ router.get('/projects/:id/tasks', async (req, res, next) => {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
})
const shaped = rows.map(shape)
// 保留选项过滤 (T-FL-*): 已完成任务按所属列配置隐藏
const visible = filterVisible(shaped, columns)
res.json({ items: visible, columns, total: shaped.length })
res.json({ items: shaped, columns, total: shaped.length })
} catch (e) { next(e) }
})
// ---------- 归档 (T-ARC) ----------
/**
* 归档/恢复以整棵树为单位: 子任务不单独变化。
* 即使调用方传的是子任务 id, 也提升到根父任务处理。
*/
async function loadTreeIds(task) {
const rootId = task.parentId ? task.parentId : task.id
const children = await prisma.task.findMany({ where: { parentId: rootId }, select: { id: true } })
return [rootId, ...children.map((c) => c.id)]
}
// POST /api/tasks/:id/archive 手动归档: 连带全部子任务, 幂等
router.post('/tasks/:id/archive', async (req, res, next) => {
try {
const userId = req.user.sub
const found = await loadTaskForUser(req.params.id, userId)
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
const { task } = found
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
const ids = await loadTreeIds(found.task)
const r = await prisma.task.updateMany({
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
where: { id: { in: ids } },
data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' },
})
res.json({ ok: true, count: r.count })
@@ -95,10 +101,9 @@ router.post('/tasks/:id/unarchive', async (req, res, next) => {
const userId = req.user.sub
const found = await loadTaskForUser(req.params.id, userId)
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
const { task } = found
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
const ids = await loadTreeIds(found.task)
const r = await prisma.task.updateMany({
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
where: { id: { in: ids } },
data: { archived: false, archivedAt: null, archiveSource: null },
})
res.json({ ok: true, count: r.count })
@@ -135,7 +140,12 @@ router.get('/projects/:id/tasks/archived', async (req, res, next) => {
const rows = await prisma.task.findMany({
where,
include: TASK_INCLUDE,
// 已归档任务整树归档: 子任务也是 archived=true, 这里要一并取出,
// 否则「彻底删除」弹窗无法提示连带条数
include: {
...TASK_INCLUDE,
children: { ...TASK_INCLUDE.children, where: { archived: true } },
},
orderBy: [{ archivedAt: 'desc' }, { id: 'desc' }],
take: limit + 1,
})
+70 -65
View File
@@ -1,105 +1,110 @@
// 任务归档领域逻辑 (T-ARC)
// 自动归档判定: 倒计时起点 = 任务"被隐藏的时刻"(与 date.js isVisibleByRetention
// 精确同口径推导): 完成日 + 保留窗口后的上海零点; 加上项目 autoArchive 档位即到期时刻。
// 自动归档: 顶层任务完成后, 经过项目 autoArchive 档位即到期; 到期时把该任务
// 及其全部子任务作为一个整体归档。子任务不单独判定, 完全跟随父任务。
// 执行双保险: 项目任务加载时惰性执行 (runAutoArchiveForProject) + 每小时 cron 兜底。
import { prisma } from '../db.js'
import { toDateStr, addDays, startOfWeek, startOfMonth } from './date.js'
export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never']
const DAY_MS = 24 * 3600 * 1000
const SHANGHAI_OFFSET_MS = 8 * 3600 * 1000
/** 上海日历日 "YYYY-MM-DD" 的零点时刻 (UTC 表示) */
function shanghaiMidnight(dayStr) {
const [y, m, d] = dayStr.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d) - SHANGHAI_OFFSET_MS)
/** 按上海日历月加 n 个月 (保留时刻), 用于 1m 档 */
function addMonthsShanghai(date, n) {
const shifted = new Date(date.getTime() + SHANGHAI_OFFSET_MS)
shifted.setUTCMonth(shifted.getUTCMonth() + n)
return new Date(shifted.getTime() - SHANGHAI_OFFSET_MS)
}
/** 上海日历日 + n 个月后的零点 (月末溢出由 Date 归一化) */
function addMonths(dayStr, n) {
const [y, m, d] = dayStr.split('-').map(Number)
return new Date(Date.UTC(y, m - 1 + n, d) - SHANGHAI_OFFSET_MS)
}
/**
* 任务被隐藏的时刻: doneAt 所在完成日之后, 保留窗口结束的上海零点。
* 与 date.js isVisibleByRetention 的可见判定精确同口径:
* never → doneAt 即刻隐藏; today → 完成日次日零点; 3d → 完成日+3 零点;
* week → 完成周次下一周一零点; month → 次月 1 日零点; forever → 永不隐藏 (null)
*/
export function hiddenAtOf(doneAt, option) {
if (!doneAt) return null
const day = toDateStr(doneAt)
switch (option) {
case 'never': return new Date(doneAt)
case 'today': return shanghaiMidnight(addDays(day, 1))
case '3d': return shanghaiMidnight(addDays(day, 3))
case 'week': return shanghaiMidnight(addDays(startOfWeek(day), 7))
case 'month': return shanghaiMidnight(String(addDays(day, 32)).slice(0, 8) + '01')
default: return null // forever: 永不隐藏
}
}
/** 自动归档到期时刻: 隐藏时刻 + 档位; 'never' 或无法隐藏 → null */
export function autoArchiveDue(autoArchive, doneAt, columnRetention) {
if (!autoArchive || autoArchive === 'never') return null
const hidden = hiddenAtOf(doneAt, columnRetention)
if (!hidden) return null
/** 自动归档到期时刻: 完成时刻 + 档位; 'never' 或未完成 -> null */
export function autoArchiveDue(autoArchive, doneAt) {
if (!autoArchive || autoArchive === 'never' || !doneAt) return null
const at = doneAt instanceof Date ? doneAt : new Date(doneAt)
if (Number.isNaN(at.getTime())) return null
switch (autoArchive) {
case '3d': return new Date(hidden.getTime() + 3 * DAY_MS)
case '1w': return new Date(hidden.getTime() + 7 * DAY_MS)
case '1m': {
const d = new Date(hidden)
d.setUTCMonth(d.getUTCMonth() + 1)
return d
}
case '3d': return new Date(at.getTime() + 3 * DAY_MS)
case '1w': return new Date(at.getTime() + 7 * DAY_MS)
case '1m': return addMonthsShanghai(at, 1)
default: return null
}
}
/**
* 修复历史数据里父子归档状态不一致: 子任务完全跟随父任务。
* 只支持一级子任务; 返回被修正的条数。
*/
export async function normalizeArchiveTree(projectId) {
const tasks = await prisma.task.findMany({
where: { projectId },
select: { id: true, parentId: true, archived: true, archivedAt: true, archiveSource: true },
})
const byId = new Map(tasks.map((t) => [t.id, t]))
const fixes = []
for (const t of tasks) {
if (!t.parentId) continue
const parent = byId.get(t.parentId)
if (!parent || parent.archived === t.archived) continue
fixes.push({
id: t.id,
archived: parent.archived,
archivedAt: parent.archived ? (parent.archivedAt || new Date()) : null,
archiveSource: parent.archived ? (parent.archiveSource || 'manual') : null,
})
}
if (!fixes.length) return 0
await prisma.$transaction(fixes.map((f) => prisma.task.update({
where: { id: f.id },
data: {
archived: f.archived,
archivedAt: f.archivedAt,
archiveSource: f.archiveSource,
},
})))
return fixes.length
}
/**
* 对单个项目执行一轮到期自动归档 (T-ARC-02)。
* 只处理 status=done / doneAt 非空 / 未归档 的任务; 幂等。
* 只看顶层任务: 完成 + 档位到期 -> 该任务与全部子任务一起归档。幂等。
* @returns {number} 本次归档条数
*/
export async function runAutoArchiveForProject(projectId) {
// 先修复历史父子不一致, 保证要么都显示、要么都归档
await normalizeArchiveTree(projectId)
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { autoArchive: true },
})
if (!project || !AUTO_ARCHIVE_OPTIONS.includes(project.autoArchive) || project.autoArchive === 'never') return 0
const columns = await prisma.boardColumn.findMany({
where: { projectId },
select: { id: true, retention: true },
})
const retentionById = new Map(columns.map((c) => [c.id, c.retention || 'forever']))
const tasks = await prisma.task.findMany({
where: { projectId, archived: false, status: 'done', doneAt: { not: null } },
select: { id: true, doneAt: true, columnId: true },
const roots = await prisma.task.findMany({
where: { projectId, parentId: null, archived: false, status: 'done', doneAt: { not: null } },
select: { id: true, doneAt: true },
})
const now = new Date()
const dueIds = []
for (const t of tasks) {
const due = autoArchiveDue(project.autoArchive, t.doneAt, retentionById.get(t.columnId))
if (due && due <= now) dueIds.push(t.id)
const dueRootIds = []
for (const t of roots) {
const due = autoArchiveDue(project.autoArchive, t.doneAt)
if (due && due <= now) dueRootIds.push(t.id)
}
if (!dueIds.length) return 0
if (!dueRootIds.length) return 0
// 整棵树一起归档: 子任务不单独判定, 避免父任务归档而子任务仍留在看板
const children = await prisma.task.findMany({
where: { parentId: { in: dueRootIds } },
select: { id: true },
})
const ids = [...dueRootIds, ...children.map((c) => c.id)]
const r = await prisma.task.updateMany({
where: { id: { in: dueIds } },
where: { id: { in: ids } },
data: { archived: true, archivedAt: now, archiveSource: 'auto' },
})
return r.count
}
/** 每小时 cron 兜底: 扫描所有启用了自动归档的项目 */
/** 每小时 cron 兜底: 修复所有项目的父子归档一致性, 并执行到期自动归档 */
export async function runAutoArchiveSweep() {
const projects = await prisma.project.findMany({
where: { autoArchive: { not: 'never' } },
select: { id: true },
})
const projects = await prisma.project.findMany({ select: { id: true } })
let total = 0
for (const p of projects) {
try {
-93
View File
@@ -1,93 +0,0 @@
// 任务模块: 自然日边界工具 (Asia/Shanghai)
// 与前端 src/pages/task/date.js 保持同一口径, 改动需同步
//
// 保留选项口径 (T-FL-02):
// never 永不显示
// today 当天 -> 今天 00:00
// 3d 3 天 -> 今天-2 天 00:00 (含今天共 3 个自然日)
// week 本周 -> 本周一 00:00
// month 本月 -> 本月 1 日 00:00
// forever 永久显示 -> 无下界
export const TZ = 'Asia/Shanghai'
export const RETENTION = ['never', 'today', '3d', 'week', 'month', 'forever']
export const RETENTION_LABEL = {
never: '永不显示',
today: '当天',
'3d': '3 天',
week: '本周',
month: '本月',
forever: '永久显示',
}
export const STATUSES = ['todo', 'doing', 'done']
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
// 当前时刻在 Asia/Shanghai 的 "YYYY-MM-DD" 与星期几
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
})
/** 任意时刻 -> 本地日历日 "YYYY-MM-DD" */
export function toDateStr(d = new Date()) {
return FMT.format(d instanceof Date ? d : new Date(d))
}
/** "YYYY-MM-DD" -> UTC 零点 Date (仅做日期运算) */
function toUTC(s) {
const [y, m, d] = String(s).split('-').map(Number)
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
}
function fromUTC(dt) {
return dt.toISOString().slice(0, 10)
}
export function addDays(s, n) {
const d = toUTC(s)
d.setUTCDate(d.getUTCDate() + n)
return fromUTC(d)
}
/** 周一为一周第一天 */
export function startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7
return addDays(s, -dow)
}
export function startOfMonth(s) {
return String(s).slice(0, 8) + '01'
}
/**
* 保留选项 -> 起始时间(不含时区偏移的 Date, 以 UTC 表示当日零点)
* 返回 null 表示"全可见"(forever); 返回 'never' 表示"全隐藏"
*/
export function retentionStart(option, now = new Date()) {
const today = toDateStr(now)
switch (option) {
case 'never': return { mode: 'never' }
case 'forever': return { mode: 'all' }
case 'today': return { mode: 'since', date: today }
case '3d': return { mode: 'since', date: addDays(today, -2) }
case 'week': return { mode: 'since', date: startOfWeek(today) }
case 'month': return { mode: 'since', date: startOfMonth(today) }
default: return { mode: 'all' }
}
}
/**
* 判断一条已完成任务在给定保留选项下是否可见。
* 依据任务的完成时刻 doneAt (T-FL-10); 未完成任务恒可见 (T-FL-12)。
*/
export function isVisibleByRetention(task, option, now = new Date()) {
if (task.status !== 'done') return true
const win = retentionStart(option, now)
if (win.mode === 'never') return false
if (win.mode === 'all') return true
if (!task.doneAt) return false
// doneAt 是绝对时刻, 转成本地日历日再与窗口起点比较
const doneDay = toDateStr(task.doneAt)
return doneDay >= win.date
}
+29
View File
@@ -83,3 +83,32 @@ export function resolveTaskWrite(task, patch, columns) {
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 }
}
-31
View File
@@ -1,31 +0,0 @@
// 任务模块: 保留选项的可见性过滤 (T-FL-*)
// 前端 src/pages/task/date.js 有一份同口径实现, 用于即时过滤; 后端用于统计/兜底
import { isVisibleByRetention } from './date.js'
/**
* 按"任务所属列"的保留选项过滤任务列表 (T-FL-11)
* @param {object[]} tasks 任务(需含 status / doneAt / columnId)
* @param {object[]} columns 列(需含 id / retention)
*/
export function filterVisible(tasks, columns, now = new Date()) {
const retentionById = new Map((columns || []).map((c) => [c.id, c.retention || 'forever']))
return (tasks || []).filter((t) => {
const opt = retentionById.get(t.columnId) || 'forever'
return isVisibleByRetention(t, opt, now)
})
}
/** 单条判定, 供详情接口复用 */
export function isTaskVisible(task, columns, now = new Date()) {
const col = (columns || []).find((c) => c.id === task.columnId)
return isVisibleByRetention(task, col?.retention || 'forever', now)
}
/** 列内计数: 过滤后条数 (T-FL-14 计数与展示同源) */
export function countByColumn(tasks, columns, now = new Date()) {
const visible = filterVisible(tasks, columns, now)
const out = {}
for (const c of columns || []) out[c.id] = 0
for (const t of visible) out[t.columnId] = (out[t.columnId] || 0) + 1
return out
}
+1 -1
View File
@@ -32,7 +32,7 @@
}
],
"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": {
+5 -1
View File
@@ -9,6 +9,7 @@ import CheckinPage from './pages/checkin/CheckinPage.jsx'
import TaskPage from './pages/task/TaskPage.jsx'
import { useAuth } from './auth/AuthContext.jsx'
import { PowerSyncProvider } from './lib/powersync/react.jsx'
import useKeepInputVisible from './hooks/useKeepInputVisible.js'
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
const HOME_PATH = '/checkin'
@@ -31,6 +32,9 @@ function RequireAuth({ children }) {
}
export default function App() {
// 全局: 输入框聚焦时滚进可视区 (登录页与内页都覆盖)
useKeepInputVisible()
return (
<PowerSyncProvider>
<Routes>
@@ -43,7 +47,7 @@ export default function App() {
<Route path="/task" element={<TaskPage />} />
<Route path="/task/list" 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={<MacroPage />} />
<Route path="/monitor" element={<MonitorPage />} />
+17
View File
@@ -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'
}
-32
View File
@@ -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())
}
+50 -23
View File
@@ -5,7 +5,6 @@
// 见 docs/V0.2/离线编辑模块/0.2.1实施方案.md
import { getPowersyncDb } from '../lib/powersync/db.js'
import { api, getCurrentUserId } from './client.js'
import { filterVisible } from '../pages/task/date.js'
const db = () => getPowersyncDb()
@@ -35,6 +34,13 @@ const DEFAULT_COLUMNS = [
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
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) {
if (!Array.isArray(v)) return null
const out = []
@@ -135,6 +141,8 @@ function projectSummary(p, memberCount, taskCount) {
updatedAt: p.updatedAt,
taskKinds: normTaskKinds(parseJson(p.taskKinds, null)) || DEFAULT_TASK_KINDS,
icon: p.icon || null,
// 自动归档档位 (T-ARC-02): 必须回传给前端, 否则该设置在界面上永远显示默认的「永不」
autoArchive: p.autoArchive || 'never',
memberCount,
taskCount,
}
@@ -268,6 +276,11 @@ export const projectApi = {
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}" = ?`)
if (sets.length) {
await db().execute(
@@ -366,10 +379,6 @@ export const columnApi = {
if (body.boundStatus !== null && !['todo', 'doing', 'done'].includes(body.boundStatus)) throw new Error('绑定状态取值非法')
data.boundStatus = body.boundStatus
}
if (body?.retention !== undefined) {
if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(body.retention)) throw new Error('保留选项取值非法')
data.retention = body.retention
}
const sets = Object.keys(data).map((k) => `"${k}" = ?`)
if (sets.length) {
await db().execute(`UPDATE BoardColumn SET ${sets.join(', ')}, updatedAt = ? WHERE id = ?`, [...Object.values(data), now(), String(id)])
@@ -438,17 +447,16 @@ export const taskApi = {
)
const shape = await shapeTasks(rows, columns)
const shaped = rows.map(shape)
// 子任务挂载 (与服务端 TASK_INCLUDE.children 同构)
// 子任务挂载 (与服务端 TASK_INCLUDE.children 同构); 子任务完全跟随父任务
for (const t of shaped) {
const childRows = await db().getAll(
'SELECT * FROM Task WHERE parentId = ? ORDER BY sortOrder, id',
'SELECT * FROM Task WHERE parentId = ? AND archived = 0 ORDER BY sortOrder, id',
[String(t.id)],
)
const childShape = await shapeTasks(childRows, columns)
t.children = childRows.map(childShape)
}
const visible = filterVisible(shaped, columns)
return { items: visible, columns, total: shaped.length }
return { items: shaped, columns, total: shaped.length }
},
create: async (projectId, body) => {
@@ -697,35 +705,44 @@ export const taskApi = {
},
// ---------- 归档 (T-ARC, 本地实现: 写本地库 -> 上行) ----------
/** 手动归档: 连带全部子任务, 幂等 (T-ARC-01) */
/** 手动归档: 以整棵树为单位 (子任务跟随父任务), 幂等 (T-ARC-01) */
archive: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限')
const now = now()
// 子任务被单独归档时, 提升到根父任务, 避免父子状态不一致
const root = task.parentId
? (await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(task.parentId)])) || task
: task
// 不能写 const now = now(): 同名 const 会遮蔽模块级 now, 右侧求值落入 TDZ,
// 压缩后报 "Cannot access 'a' before initialization"
const ts = now()
await db().writeTransaction(async (tx) => {
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 kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(root.id)])
const ids = [String(root.id), ...kids.map((k) => String(k.id))]
const ph = ids.map(() => '?').join(',')
await tx.execute(
`UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`,
[now, now, ...ids],
[ts, ts, ...ids],
)
})
return { ok: true, count: 1 }
},
/** 恢复归档: 回原列, 状态不变, 连带子任务 (T-ARC-03) */
/** 恢复归档: 回原列, 状态不变, 以整棵树为单位 (T-ARC-03) */
unarchive: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限')
const now = now()
const root = task.parentId
? (await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(task.parentId)])) || task
: task
const ts = now()
await db().writeTransaction(async (tx) => {
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 kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(root.id)])
const ids = [String(root.id), ...kids.map((k) => String(k.id))]
const ph = ids.map(() => '?').join(',')
await tx.execute(
`UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`,
[now, ...ids],
[ts, ...ids],
)
})
return { ok: true, count: 1 }
@@ -754,13 +771,23 @@ export const taskApi = {
where.push('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) {
where.push('archivedAt >= ?')
args.push(String(p.from) + 'T00:00:00+08:00'.replace('+08:00', 'Z'))
const iso = dayToIso(p.from, false)
if (iso) { where.push('archivedAt >= ?'); args.push(iso) }
}
if (p.to) {
where.push('archivedAt <= ?')
args.push(String(p.to) + 'T23:59:59.999Z')
const iso = dayToIso(p.to, true)
if (iso) { where.push('archivedAt <= ?'); args.push(iso) }
}
if (p.cursor) {
const [ts, cid] = String(p.cursor).split('|')
+71 -11
View File
@@ -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 { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
// 顶部分类 + 下方仅渲染当前激活 tab 的内容
// 顶部分类 + 下方 tab 内容
// - 顶部: antd-mobile Tabs (点击切换)
// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
// - 下方: 同时挂载所有 tab, 保留各 tab 内部状态
// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头,
// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失
// props:
// tabs: [{ key, label, render }]
// active: 当前激活 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 swiperRef = useRef(null)
const [more, setMore] = useState(false)
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' })
}, [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 (
<div className="swipe-tabs-wrap" ref={wrapRef}>
<Tabs activeKey={active} onChange={onChange}>
@@ -78,13 +109,42 @@ export default function SwipeTabs({ tabs, active, onChange }) {
<span className="tab-more-arrow">›</span>
</div>
)}
<div className="tab-content">
{tabs.map((t) => (
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
{t.render()}
</div>
))}
</div>
{swipeable ? (
// 整屏横滑: 首尾橡皮筋 (resistance) + 松手吸附, 竖向滚动仍交给浏览器
<Swiper
className="swipe-tabs-swiper"
initialSlide={activeIndex}
slidesPerView={1}
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>
)
}
-54
View File
@@ -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>
)
}
+22 -6
View File
@@ -2,16 +2,31 @@
//
// 为什么是环而不是圆点:
// 旧实现是 4px 小圆点, 只按状态上色 —— 3/5 和 5/5 都是同一个绿点,
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色仍表状态:
// 0 < actual < expected -> 弧长按比例 (部分完成 / 半填充)
// actual >= expected -> 整圈 (全部完成 / 实心)
// actual = 0 且 expected > 0 -> 只有底环 (未开始 / 空心)
// 看不出"当天做到几成"。改成环形后 **弧长 = 当天完成比例**, 颜色表状态:
// 0 < actual < expected -> 弧长按比例 (部分完成)
// actual >= expected -> 整圈 (全部完成)
// actual = 0 且 expected > 0 -> 只有底环 (未开始)
// expected = 0 由父组件判断, 不渲染本组件 (当天没有应打卡项)
//
// 颜色不写在这里: 由 CSS 按 `.mv-day` 作用域上色 (各视图调色板可能不同)。
// 颜色分级 (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,
@@ -19,6 +34,7 @@ export default function DayRing({
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
@@ -37,7 +53,7 @@ export default function DayRing({
/>
{p > 0 && (
<circle
className={'day-ring-bar ' + status}
className={'day-ring-bar ' + tier}
cx={half} cy={half} r={r}
fill="none" strokeWidth={stroke}
strokeDasharray={c}
+59
View File
@@ -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)
}
}, [])
}
+18 -1
View File
@@ -1,8 +1,17 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import BottomNav from '../components/BottomNav.jsx'
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 实测校准)
// - 中间 <main>: flex:1 可滚动主区
@@ -18,7 +27,15 @@ export default function MobileShell() {
// pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树,
// 避免破坏 MacroPage 等页内子路由的挂载状态与滚动位置)
const [animating, setAnimating] = useState(false)
const prevPathRef = useRef(null)
useEffect(() => {
const prev = prevPathRef.current
prevPathRef.current = location.pathname
// 宏观内部子 tab 切换已由整屏横滑提供动效, 不要再叠一次整页淡入 + 上移
if (prev !== null && isMacroListPath(prev) && isMacroListPath(location.pathname)) {
setAnimating(false)
return
}
setAnimating(true)
const t = setTimeout(() => setAnimating(false), 320)
return () => clearTimeout(t)
+295
View File
@@ -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()
})
}
+7
View File
@@ -13,6 +13,13 @@ export function getPowersyncDb() {
database: {
// 默认 IDBBatchAtomicVFS (IndexedDB), 兼容性最广; WebView2 (Chromium) 后续可切 OPFS 加速
dbFilename: 'iboard-powersync.db',
// 关闭多标签支持 —— 否则桌面端 (WebView2/Chromium) 会启用 **共享 IndexedDB worker**:
// enableMultiTabs 默认为 true (桌面且有 SharedWorker 时), 该 SharedWorker 会跨页面
// reload 存活; OTA 升级后 reload 的新页面连上去时本地库读取会永久挂起
// (无异常、纯 pending) —— 表现为任务页一直「加载中」, 其它不碰本地库的页面正常。
// Android WebView 不支持 SharedWorker, 因此只有 Windows 外壳命中。
// 本应用是单窗口外壳 (dock.html 不访问本地库), 不需要多标签同步。
enableMultiTabs: false,
},
})
}
+70
View File
@@ -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
View File
@@ -7,6 +7,7 @@ import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { AuthProvider } from './auth/AuthContext.jsx'
import { initOta } from './api/ota.js'
import { startIfinanceAutoRefresh } from './lib/ifinance.js'
import './styles/globals.css'
// OTA 启动: 仅 Tauri 内生效
@@ -14,6 +15,9 @@ import './styles/globals.css'
// - 异步执行, 不阻塞 React 挂载
initOta()
// ifinance 索引每日自动更新 (启动 4s 后检查一次, 之后每 30 分钟看是否已过 24h)
startIfinanceAutoRefresh()
// ==================== 顶部状态栏安全区校准 ====================
// 背景: Android WebView (Tauri) 下 env(safe-area-inset-top) 常返回 0,
// 而 antd-mobile 的 <SafeArea> 只是个依赖该变量的空 div, 于是内容会顶到状态栏下.
@@ -50,6 +54,9 @@ function calibrateSafeTop() {
// 只在"纵向且高度差合理"时采用, 避免横屏误判
const isPortrait = viewW <= 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
// 取 env() 与实测值的较大者; 两者都失效时给 24px 保底
+51 -53
View File
@@ -1,43 +1,32 @@
import { useState } from 'react'
import { useMemo } from 'react'
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
import { Badge } from 'antd-mobile'
import PageHeader from '../components/PageHeader.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 ArticleDetail from './macro/ArticleDetail.jsx'
import OilPage from './macro/OilPage.jsx'
import AlertsView from './macro/AlertsView.jsx'
import NewsView from './macro/NewsView.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, 默认 新闻
// /macro → 显示 news tab (activeFromPath 决定)
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来, 置最左)
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来, 置第二并作为默认)
// /macro/oil → 油价
// /macro/data → 经济数据
// /macro/strategy → 策略研究
// /macro/articles → 经济文章
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
// 说明: tab 数量已超出屏宽, SwipeTabs 会在右侧显示渐变 + 箭头提示可继续滑动
const TABS = [
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
// 宏观: 本地固定 2 个 tab (新闻 / 经济文章) + 外接 ifinance 分类动态挂载为 tab
// /macro/news → 新闻 (默认)
// /macro/articles → 经济文章
// /macro/finance/:catId → 外接分类 (经济数据 / 策略研究 / …)
// 详情: /macro/news/:id · /macro/articles/:slug · /macro/finance/:catId/:itemId
// 说明: 外接分类不写死, 由 info.json 的 categories[] 决定; ifinance 新增分类后
// 本页自动多出新 tab, 宿主无需改代码。报警已迁到「监控」。
const BASE_TABS = [
{ 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' },
]
function activeFromPath(pathname) {
if (pathname.startsWith('/macro/alerts')) return 'alerts'
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'
const m = pathname.match(/^\/macro\/finance\/([^/]+)/)
if (m) return 'finance:' + m[1]
// 父路由 /macro 走默认 tab (新闻)
return 'news'
}
@@ -45,47 +34,56 @@ function activeFromPath(pathname) {
export default function MacroPage() {
const location = useLocation()
const navigate = useNavigate()
const { info } = useIfinanceInfo()
const active = activeFromPath(location.pathname)
// 报警角标: 由 AlertsView 回传总数
const [alertCount, setAlertCount] = useState(0)
const categories = info?.categories
const tabs = TABS.map((t) => ({
key: t.key,
label: t.key === 'alerts'
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span>
: t.label,
render: () => {
if (t.key === 'oil') return <OilPage />
if (t.key === 'data') return <DataView />
if (t.key === 'strategy') return <StrategyView />
if (t.key === 'articles') return <ArticlesView />
if (t.key === 'alerts') return <AlertsView onCount={setAlertCount} />
if (t.key === 'news') return <NewsView />
return null
},
}))
// tabs 只在分类索引变化时重建: 横滑切页时 SwipeTabs 可复用已缓存的 pane 节点,
// 不会因为父组件重渲染而把所有列表重新渲染一遍
// (必须放在详情页提前 return 之前: hooks 不能有条件跳过)
const tabs = useMemo(() => [
...BASE_TABS.map((t) => ({
key: t.key,
label: t.label,
render: () => (t.key === 'news' ? <NewsView /> : <ArticlesView />),
})),
...(categories || []).map((c) => ({
key: 'finance:' + c.id,
label: c.name,
render: () => <FinanceCategoryView category={c} />,
})),
], [categories])
function onChange(key) {
const next = TABS.find((t) => t.key === key)
if (next) navigate(next.path)
}
// 详情页单独走 React Router 渲染, 不参与滑屏
if (location.pathname.match(/^\/macro\/(articles|data|strategy|news)\/[^/]+$/)) {
// 详情页单独走 React Router 渲染 (需要 Route 提供 params), 不参与滑屏
const p = location.pathname
const isNewsDetail = /^\/macro\/news\/[^/]+$/.test(p)
const isArticleDetail = /^\/macro\/articles\/[^/]+$/.test(p)
const isFinanceDetail = /^\/macro\/finance\/[^/]+\/[^/]+$/.test(p)
if (isNewsDetail || isArticleDetail || isFinanceDetail) {
return (
<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="articles/:slug" element={<ArticleDetail />} />
<Route path="finance/:catId/:itemId" element={<FinanceItemView />} />
</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 (
<div className="page-with-navbar">
<PageHeader title="宏观" />
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
<SwipeTabs tabs={tabs} active={active} onChange={onChange} swipeable />
</div>
)
}
+222 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
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 {
applyOtaUpdate,
@@ -13,6 +13,31 @@ import {
rollbackOta,
setOtaDevMode,
} 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
@@ -54,6 +79,21 @@ export default function MePage() {
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
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 也无所谓)
useEffect(() => {
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() {
const confirmed = window.confirm('确定退出登录?')
if (!confirmed) return
@@ -147,6 +299,75 @@ export default function MePage() {
<div className="me-email">{user?.email}</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 升级区 */}
<List className="me-list" mode="card" header="前端升级 (OTA)">
<List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
+16 -2
View File
@@ -1,15 +1,18 @@
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Empty, DotLoading } from 'antd-mobile'
import { Badge, Card, Empty, DotLoading } from 'antd-mobile'
import { api } from '../api/client.js'
import PageHeader from '../components/PageHeader.jsx'
import SwipeTabs from '../components/SwipeTabs.jsx'
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
import EnvView from './monitor/EnvView.jsx'
import AlertsView from './monitor/AlertsView.jsx'
// 监控页: 两个横向分类
// 监控页: 四个横向分类
// - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序
// - 服务监控: 暂留空占位
// - 环境监控: 米家温湿度
// - 报警: V0.2.2 从「宏观」迁入, 数据差异/任务异常按时间倒序
function fmtTime(s) {
if (!s) return ''
const d = new Date(s)
@@ -40,10 +43,21 @@ function pct(used, total) {
export default function MonitorPage() {
const [active, setActive] = useState('servers')
// 报警角标: 由 AlertsView 回传总数
const [alertCount, setAlertCount] = useState(0)
const tabs = [
{ key: 'servers', label: '主机监控', render: () => <ServerView /> },
{ key: 'services', label: '服务监控', render: () => <ServiceView /> },
{ key: 'env', label: '环境监控', render: () => <EnvView /> },
{
key: 'alerts',
label: (
<span className="tab-label">
报警{alertCount > 0 && <Badge content={alertCount} />}
</span>
),
render: () => <AlertsView onCount={setAlertCount} />,
},
]
return (
<div className="page-with-navbar">
+22 -6
View File
@@ -21,12 +21,15 @@ import {
const GROUP_ORDER = ['day', 'week', 'month']
const GROUP_LABEL = { day: '每天', week: '每周', month: '每月' }
// 新建时只提供 每天 / 每周 / 每月 (「每年」已下线)
const PERIOD_OPTIONS = [
{ label: '每天', value: 'day' },
{ label: '每周', value: 'week' },
{ label: '每月', value: 'month' },
{ label: '每年', value: 'year' },
]
// 历史遗留的 year 项仍可编辑: 仅在编辑该周期时补回「每年」选项,
// 避免打开编辑弹窗时当前周期没有任何选中项
const YEAR_OPTION = { label: '每年', value: 'year' }
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e']
const EMPTY_FORM = { id: null, name: '', period: 'day', target: 1, color: COLORS[0], note: '' }
@@ -130,7 +133,14 @@ export default function MergedView({ date, onDateChange }) {
}
}
const summary = detail?.summary || { total: 0, done: 0 }
// 当日进度 = 当天已打卡项 / 当天打卡项. 用 dayDone 而非 completed:
// completed 是"周期目标已达成" (每周/每月项可能在本周期其他天完成), 放到
// 历史日期的标题上会把当天之后、同周期内的打卡也算进来。按 items 现算还能
// 让乐观更新 (打卡/撤销) 立即反映到标题, 不必等 /habits 重新拉取。
const summary = useMemo(
() => ({ total: items.length, done: items.filter((i) => i.dayDone).length }),
[items],
)
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
// ---------- 打卡 / 撤销 (乐观更新) ----------
@@ -248,6 +258,11 @@ export default function MergedView({ date, onDateChange }) {
}
// ---------- 渲染 ----------
// 只有编辑历史 year 项时才显示「每年」选项; 新建一律不给该周期
const periodOptions = form.period === 'year'
? [...PERIOD_OPTIONS, YEAR_OPTION]
: PERIOD_OPTIONS
return (
<div className="mv-view">
{/* 月历 */}
@@ -256,7 +271,7 @@ export default function MergedView({ date, onDateChange }) {
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
<b>{fmtMonth(month)}</b>
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
<span className="today" onClick={() => onDateChange(today)}>回到今天</span>
<span className="today" onClick={() => { setMonth(today.slice(0, 7)); onDateChange(today) }}>回到今天</span>
</div>
<div className="mv-grid mv-wd">
@@ -292,9 +307,10 @@ export default function MergedView({ date, onDateChange }) {
{cal && (
<div className="mv-rate">
<span>本月完成率 <b>{Math.round((cal.rate || 0) * 100)}%</b></span>
{/* 图例: 弧长=完成比例, 颜色=状态 */}
{/* 图例: 弧长=完成比例, 颜色=4 档 (小半/大半/完成/超额) */}
<span className="mv-legend">
<i className="partial" />部分
<i className="low" />小半
<i className="high" />大半
<i className="full" />完成
<i className="over" />超额
</span>
@@ -432,7 +448,7 @@ export default function MergedView({ date, onDateChange }) {
<div className="form-field">
<label>周期</label>
<Selector
options={PERIOD_OPTIONS}
options={periodOptions}
value={[form.period]}
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))}
/>

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