11 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
55 changed files with 4906 additions and 573 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:
+1
View File
@@ -5,6 +5,7 @@
- 宏观:新闻 / 经济文章(本地),以及外接 ifinance 模块的分类(经济数据 / 策略研究 / 看板,运行时动态挂载为 tab)
- 数据与策略由外接 ifinance 模块承担(静态包,见 `../ifinance/readme.md`)
- 报警并入「监控」;油价模块已下线
- AI 接入:内置 MCP 服务,个人 Token 授权后可查询 / 新增 / 修改 / 删除 / 排序项目、任务、清单和打卡(见 [`docs/MCP.md`](docs/MCP.md))
## 宏观
+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,19 +337,20 @@
### 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()` 回到原位 |
@@ -367,9 +358,10 @@
| 场景 | 处理 |
| --- | --- |
| 归档父任务时新增/恢复子任务 | 子任务随父判定:父 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 列(上限待确认) |
+5
View File
@@ -142,3 +142,8 @@ export type TaskMember = Prisma.TaskMemberModel
*
*/
export type TaskTag = Prisma.TaskTagModel
/**
* Model McpToken
*
*/
export type McpToken = Prisma.McpTokenModel
+5
View File
@@ -166,3 +166,8 @@ export type TaskMember = Prisma.TaskMemberModel
*
*/
export type TaskTag = Prisma.TaskTagModel
/**
* Model McpToken
*
*/
export type McpToken = Prisma.McpTokenModel
File diff suppressed because one or more lines are too long
+92 -2
View File
@@ -408,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]
@@ -424,7 +425,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
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"
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: {
@@ -2278,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: {
@@ -2715,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'
@@ -2979,6 +3068,7 @@ export type GlobalOmitConfig = {
task?: Prisma.TaskOmit
taskMember?: Prisma.TaskMemberOmit
taskTag?: Prisma.TaskTagOmit
mcpToken?: Prisma.McpTokenOmit
}
/* Types for Logging */
@@ -75,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]
@@ -492,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'
+1
View File
@@ -33,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'
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
*/
+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 -1
View File
@@ -33,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",
@@ -53,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,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;
+21
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 {
@@ -460,3 +463,21 @@ model TaskTag {
@@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])
}
+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>'
}
+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] 完成"
+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"
+5
View File
@@ -16,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
@@ -38,6 +40,9 @@ 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)
+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)
}
+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) {
+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
+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
}
-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
}
+4
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>
+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'
}
+16 -15
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()
@@ -380,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)])
@@ -452,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) => {
@@ -711,16 +705,20 @@ 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 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})`,
@@ -730,14 +728,17 @@ export const taskApi = {
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 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})`,
+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>
)
}
+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)
+210 -31
View File
@@ -5,11 +5,17 @@
// - 没有统一入口页, 每个 html 都是独立入口 (在 WebView/iframe 中打开)
// - categories[] 对应 App 的 tab, items[] 是条目; 未知字段忽略
//
// 本模块采用「纯前端直连」方式: 直接 fetch 远端 info.json, 条目 HTML 直接
// 用 iframe 加载远端地址, 不做端上文件缓存 (始终最新)。
// 开发环境 (vite dev) 走 /ifinance-api 代理以规避跨域; 生产环境直连。
// 若远端 info.json 拉取失败, 依次回退: 同源内置快照 (public/ifinance-info.json)
// → localStorage 上次成功快照, 保证分类 tab 仍可渲染。
// 缓存策略 (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)
@@ -21,10 +27,20 @@ export const FINANCE_BASE = import.meta.env.DEV
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) {
@@ -32,24 +48,27 @@ export function financeUrl(relPath) {
return FINANCE_BASE + '/' + p
}
// 条目 HTML 地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307 重定向)
// 条目 HTML 远端地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307)
export function financePageUrl(relPath) {
return financeUrl(String(relPath || '').replace(/\.html?$/i, ''))
}
function readLocalInfo() {
function readLocalState() {
try {
const raw = localStorage.getItem(INFO_CACHE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed && parsed.info && Array.isArray(parsed.info.categories)) return parsed.info
const info = parsed?.info
if (info && Array.isArray(info.categories)) {
return { info, checkedAt: Number(parsed.checkedAt) || 0 }
}
} catch { /* ignore */ }
return null
}
function writeLocalInfo(info) {
function writeLocalState(info, checkedAt) {
try {
localStorage.setItem(INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), info }))
localStorage.setItem(INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), checkedAt, info }))
} catch { /* 配额满等忽略 */ }
}
@@ -61,24 +80,64 @@ async function fetchInfoFrom(url) {
return info
}
// 拉取 (并缓存) info.json: 远端优先, 失败回退同源内置快照
export async function fetchIfinanceInfo() {
// 用新索引替换模块状态, 通知所有订阅者, 并清理旧版本文件缓存
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 () => {
let info
try {
info = await fetchInfoFrom(financeUrl('info.json'))
const info = await fetchInfoFrom(financeUrl('info.json'))
applyInfo(info, Date.now())
return info
} catch (remoteErr) {
// 兜底: 同源内置快照 (ifinance 未开 CORS / 临时不可达时仍可渲染分类)
const cached = readLocalState()
if (cached?.info) {
// 远端不可用: 继续用上次成功快照 (保留 checkedAt, 下次还会重试)
memoInfo = cached.info
memoCheckedAt = cached.checkedAt
return cached.info
}
try {
info = await fetchInfoFrom(FALLBACK_INFO_URL)
const info = await fetchInfoFrom(FALLBACK_INFO_URL)
applyInfo(info, 0)
return info
} catch {
throw remoteErr
}
}
memoInfo = info
writeLocalInfo(info)
return info
})()
try {
return await inflight
@@ -87,30 +146,150 @@ export async function fetchIfinanceInfo() {
}
}
// 手动/每日刷新: 强制拉远端 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(() => {
if (memoInfo) return { info: memoInfo, loading: false, error: '' }
const cached = readLocalInfo()
return { info: cached, loading: true, error: '' }
const cached = readLocalState()
return {
info: memoInfo || cached?.info || null,
loading: !(memoInfo || cached?.info),
error: '',
checkedAt: memoCheckedAt || cached?.checkedAt || 0,
}
})
useEffect(() => {
let alive = true
setState((s) => ({ ...s, loading: true }))
const unsubscribe = subscribeIfinanceInfo(({ info, checkedAt }) => {
if (alive) setState({ info, loading: false, error: '', checkedAt })
})
fetchIfinanceInfo()
.then((info) => { if (alive) setState({ info, loading: false, error: '' }) })
.then((info) => {
if (!alive) return
setState((s) => ({ ...s, info, loading: false, error: '' }))
})
.catch((e) => {
if (!alive) return
const cached = readLocalInfo()
setState({
info: cached,
setState((s) => ({
...s,
loading: false,
error: cached ? '' : (e?.message || '外接模块加载失败'),
})
error: s.info ? '' : (e?.message || '外接模块加载失败'),
}))
})
return () => { alive = false }
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
@@ -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 保底
+23 -17
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
import PageHeader from '../components/PageHeader.jsx'
import SwipeTabs from '../components/SwipeTabs.jsx'
@@ -35,7 +36,23 @@ export default function MacroPage() {
const navigate = useNavigate()
const { info } = useIfinanceInfo()
const active = activeFromPath(location.pathname)
const categories = info?.categories || []
const categories = info?.categories
// 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])
// 详情页单独走 React Router 渲染 (需要 Route 提供 params), 不参与滑屏
const p = location.pathname
@@ -52,32 +69,21 @@ export default function MacroPage() {
)
}
const tabs = [
...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} />,
})),
]
// 子 tab 切换用 replace: 横滑多次不会在历史里堆一串记录,
// 返回键语义保持为「离开宏观」; 详情页仍按默认 push
function onChange(key) {
if (key.startsWith('finance:')) {
navigate('/macro/finance/' + key.slice('finance:'.length))
navigate('/macro/finance/' + key.slice('finance:'.length), { replace: true })
return
}
const next = BASE_TABS.find((t) => t.key === key)
if (next) navigate(next.path)
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>
+19 -4
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">
@@ -433,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] }))}
/>
+12 -3
View File
@@ -25,13 +25,22 @@ export function addDays(s, n) {
return fromUTC(d)
}
/**
* 月份加减. 同时接受月粒度 "YYYY-MM" 与日粒度 "YYYY-MM-DD":
* - 月粒度必须原样返回 "YYYY-MM", 不能变成 "YYYY-MM-01" —— 否则
* `/checkin/calendar?month=2026-10-01` 会被服务端 MONTH_RE 判为非法并回退到当月,
* 日历切到上个月时就会"看不到任何记录"。
* - 日粒度保留原日号, 超出目标月天数时收敛到月末。
*/
export function addMonths(s, n) {
const [y, m] = String(s).split('-').map(Number)
const str = String(s)
const [y, m, d] = str.split('-').map(Number)
const day = d || 1
const first = new Date(Date.UTC(y, (m || 1) - 1 + n, 1))
const day = Number(String(s).slice(8, 10)) || 1
const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
first.setUTCDate(Math.min(day, lastDay))
return fromUTC(first)
const out = fromUTC(first)
return str.length === 7 ? out.slice(0, 7) : out
}
/** 周一为一周第一天 */
+33 -4
View File
@@ -1,18 +1,45 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { NavBar, DotLoading } from 'antd-mobile'
import { useIfinanceInfo, financePageUrl } from '../../lib/ifinance.js'
import {
useIfinanceInfo,
financeItemSrc,
financePageUrl,
initIfinanceCache,
} from '../../lib/ifinance.js'
// 外接条目详情: 用 iframe 加载 ifinance 生成的独立 HTML。
// - 页面本身无导航/头部 (符合 ifinance 规范), 由宿主提供 NavBar 返回
// - 直接指向远端地址 (纯前端直连, 不做端上缓存)
// - 优先走 Service Worker 缓存入口 (/__ifinance__/...), 只按需下载当前页面
// SW 响应带 `Content-Security-Policy: sandbox allow-scripts`, 页面处于不透明源,
// 行内脚本可以运行 (看板图表需要), 但无法访问宿主数据
// - SW 不可用时回退到远端地址 (功能不受影响, 只是没有本地缓存)
export default function FinanceItemView() {
const { catId, itemId } = useParams()
const navigate = useNavigate()
const { info, loading, error } = useIfinanceInfo()
const [src, setSrc] = useState('')
const cat = (info?.categories || []).find((c) => c.id === catId)
const item = (cat?.items || []).find((i) => i.id === itemId)
const title = item?.title || '详情'
const html = item?.html || ''
// 条目/索引变化时重新决定 iframe 地址
useEffect(() => {
let alive = true
setSrc('')
if (!html) return () => { alive = false }
initIfinanceCache().then((cacheReady) => {
if (!alive) return
setSrc(cacheReady ? financeItemSrc(item) : financePageUrl(html))
})
return () => { alive = false }
// item 对象每次渲染都会重建, 用 html/hash 作为依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html, item?.hash, info?.buildId])
return (
<div className="finance-item-page">
@@ -20,11 +47,13 @@ export default function FinanceItemView() {
{loading && !item && <div className="loading"><DotLoading /> 加载中...</div>}
{!loading && error && <div className="error-banner">{error}</div>}
{!loading && !error && !item && <div className="empty">内容不存在</div>}
{item?.html && (
{html && !src && !error && <div className="loading"><DotLoading /> 加载中...</div>}
{html && src && (
<iframe
key={src}
className="finance-frame"
title={title}
src={financePageUrl(item.html)}
src={src}
loading="lazy"
/>
)}
+2 -28
View File
@@ -1,14 +1,13 @@
import { useState } from 'react'
import { Dialog, Toast } from 'antd-mobile'
import { columnApi } from '../../api/task.js'
import { RETENTION_OPTIONS, RETENTION_LABEL, STATUS_OPTIONS, STATUS_LABEL } from './date.js'
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
const MAX_COLUMNS = 10
// 列设置 (T-COL-*, T-BD-02/09/10, T-FL-01/02/15)
// 列设置 (T-COL-*, T-BD-02/09/10)
// 1) 列管理: 改名 / 新增 / 删除 / 排序
// 2) 状态绑定: 每列独立选择 不绑定 / 未开始 / 执行中 / 完成
// 3) 保留选项: 每列独立 6 档
export default function ColumnSettings({ projectId, columns, onClose, onSaved }) {
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
const [list, setList] = useState(ordered)
@@ -28,7 +27,6 @@ export default function ColumnSettings({ projectId, columns, onClose, onSaved })
const body = {}
if (orig.name !== c.name) body.name = c.name
if (orig.boundStatus !== c.boundStatus) body.boundStatus = c.boundStatus
if (orig.retention !== c.retention) body.retention = c.retention
if (Object.keys(body).length) await columnApi.update(c.id, body)
}
Toast.show({ content: '已保存' })
@@ -129,30 +127,6 @@ export default function ColumnSettings({ projectId, columns, onClose, onSaved })
<span className="cs-count">当前 {list.length} / {MAX_COLUMNS} 列</span>
</div>
<div className="section-h" style={{ margin: '6px 14px 8px' }}>已完成任务保留</div>
{list.map((col) => (
<div className="set-row" key={col.id}>
<span className="sname" style={{ width: 82 }}>{col.name}</span>
<div className="sctrl">
<select
className="drop"
value={col.retention || 'forever'}
onChange={(e) => patchLocal(col.id, { retention: e.target.value })}
>
{RETENTION_OPTIONS.map((o) => (
<option key={o} value={o}>{RETENTION_LABEL[o]}</option>
))}
</select>
</div>
</div>
))}
<div className="set-hint">
保留选项:永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示。<br />
仅影响已完成任务;未完成任务始终展示。列表与看板按同一规则过滤。
</div>
<div style={{ padding: '0 14px 20px' }}>
<div className="btn-primary" onClick={save}>{pending ? '保存中…' : '保存'}</div>
<div className="btn-ghost" onClick={onClose}>取消</div>
+1 -1
View File
@@ -249,7 +249,7 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
<div className="section-h" style={{ margin: '14px 14px 8px' }}>归档设置</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setArchivePick(true)}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>隐藏的任务自动归档</span>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>完成后自动归档</span>
<span style={{ fontSize: 12.5, color: '#999' }}>
{AUTO_ARCHIVE_LABEL[project.autoArchive] || '永不'} ›
</span>
+13 -25
View File
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
import { projectApi, taskApi, TASK_TABLES } from '../../api/task.js'
import useDbSignal from '../../lib/powersync/useDbSignal.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { filterVisible, msToNextBoundary } from './date.js'
import { sameId } from './ids.js'
import { getViewMode, setViewMode } from './prefs.js'
import { parseTimePrefix, splitTitle } from './quickAdd.js'
@@ -25,7 +24,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const [openTask, setOpenTask] = useState(null)
const [showManage, setShowManage] = useState(false)
const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
const [archivedOpen, setArchivedOpen] = useState(false) // 已归档任务查询
@@ -70,23 +68,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
setViewMode(projectId, next)
}
// 保留窗口到点重算 (T-EX-18)
useEffect(() => {
const options = (columns || []).map((c) => c.retention)
const near = options
.map((o) => msToNextBoundary(o))
.filter((v) => v != null)
.sort((a, b) => a - b)[0]
const t = setTimeout(() => setTick((v) => v + 1), near ? Math.min(near, 60_000) : 60_000)
return () => clearTimeout(t)
}, [columns, tick])
const visibleTasks = useMemo(
() => filterVisible(tasks || [], columns),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tasks, columns, tick],
)
// ---- 乐观更新 ----
const patchTask = (id, patch) => setTasks((prev) => (prev || []).map((t) => {
if (t.id === id) return { ...t, ...patch }
@@ -214,14 +195,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
handler.close?.()
if (item.key === 'edit') setOpenTask(task)
else if (item.key === 'archive') {
const subs = task.children?.length || 0
// 子任务不能单独归档: 提升到根父任务, 整棵树一起处理
const root = task.parentId
? (tasks || []).find((t) => sameId(t.id, task.parentId)) || task
: task
const subs = root.children?.length || 0
const extra = root.id !== task.id ? '(子任务随父任务一起归档)' : ''
const ok = await Dialog.confirm({
content: subs ? `归档「${task.title}」?将连带归档 ${subs} 条子任务。` : `归档「${task.title}」?`,
content: subs
? `归档「${root.title}」?将连带归档 ${subs} 条子任务。`
: `归档「${root.title}」?${extra}`,
confirmText: '归档',
})
if (!ok) return
try {
await taskApi.archive(task.id)
await taskApi.archive(root.id)
Toast.show({ content: '已归档' })
load()
} catch (e) {
@@ -308,7 +296,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
{mode === 'list' ? (
<ListView
tasks={visibleTasks}
tasks={tasks}
columns={columns}
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
@@ -319,7 +307,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
/>
) : (
<BoardView
tasks={visibleTasks}
tasks={tasks}
columns={columns}
onMoveColumn={moveToColumn}
onOpen={(t) => setOpenTask(t)}
+1 -59
View File
@@ -1,18 +1,6 @@
// 任务模块前端: 自然日边界 + 保留选项可见性
// 与后端 server/task-domain/date.js 保持同一口径, 改动需同步
// 任务模块前端: 日期工具 + 任务编号/状态
export const TZ = 'Asia/Shanghai'
export const RETENTION_LABEL = {
never: '永不显示',
today: '当天',
'3d': '3 天',
week: '本周',
month: '本月',
forever: '永久显示',
}
export const RETENTION_OPTIONS = ['never', 'today', '3d', 'week', 'month', 'forever']
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
export const STATUS_OPTIONS = ['todo', 'doing', 'done']
@@ -66,49 +54,3 @@ export function startOfMonth(s) {
return String(s).slice(0, 8) + '01'
}
/** 保留选项 -> 窗口起点 ("YYYY-MM-DD") 或 特殊标记 */
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' }
}
}
/** 单条任务在某保留选项下是否可见 */
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
return toDateStr(task.doneAt) >= win.date
}
/** 按任务所属列的保留选项过滤任务 */
export function filterVisible(tasks, columns, now = new Date()) {
const map = new Map((columns || []).map((c) => [c.id, c.retention || 'forever']))
return (tasks || []).filter((t) => isVisibleByRetention(t, map.get(t.columnId) || 'forever', now))
}
/** 距离下一个自然日/周/月边界的毫秒数, 用于定时重算 (T-EX-18) */
export function msToNextBoundary(option, now = new Date()) {
if (!['today', '3d', 'week', 'month'].includes(option)) return null
const today = toDateStr(now)
let next
if (option === 'today' || option === '3d') next = addDays(today, 1)
else if (option === 'week') next = addDays(startOfWeek(today), 7)
else {
const [y, m] = today.split('-').map(Number)
const nm = new Date(Date.UTC(y, m, 1))
next = nm.toISOString().slice(0, 10)
}
// 本地零点转北京时间零点: 用 Date 构造 "next 00:00 +08:00"
const target = new Date(next + 'T00:00:00+08:00').getTime()
return Math.max(1000, target - now.getTime())
}
+60
View File
@@ -222,6 +222,24 @@ input, textarea, select { font: inherit; }
}
.tab-pane[hidden] { display: none; }
/* 宏观整屏横滑 (SwipeTabs swipeable): 每个 tab 一屏, 每个 slide 自己竖向滚动。
.tab-content 仍保留给打卡/监控 (纯点击切换, 不进 Swiper)。 */
.swipe-tabs-wrap .swipe-tabs-swiper {
flex: 1 1 0%;
min-height: 0;
width: 100%;
overflow: hidden;
}
.swipe-tabs-swiper .swiper-wrapper { height: 100%; }
.swipe-tabs-swiper .swiper-slide {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
touch-action: pan-y; /* 竖向滚动交给浏览器, 横向手势交给 Swiper */
}
/* ================== 通用组件辅助类 ================== */
@@ -323,6 +341,33 @@ body.me-safe-top .app-safe-top { height: 0; }
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
.me-logout { color: var(--color-danger); }
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
/* AI 接入 (MCP) */
.mcp-link { color: var(--color-primary, #1677ff); cursor: pointer; font-size: 13px; }
.mcp-link.danger { color: var(--color-danger); }
.mcp-token-name { font-weight: 600; }
.mcp-token-prefix {
margin-left: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
color: var(--color-muted);
}
.mcp-create { display: flex; align-items: center; gap: 10px; width: 100%; }
.mcp-create .adm-input { flex: 1; }
.mcp-create .adm-button { flex: none; }
.mcp-token-dialog { display: flex; flex-direction: column; gap: 10px; }
.mcp-token-hint { font-size: 12px; line-height: 1.6; color: var(--color-muted); }
.mcp-token-value {
padding: 10px 12px;
border-radius: 8px;
background: var(--color-bg);
border: 1px solid var(--color-border, #eee);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
word-break: break-all;
user-select: all;
}
.mcp-token-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* 资讯模块: 报警 + 新闻 (V0.2 起从「首页」并入「宏观」Tab) */
.tab-label { display: inline-flex; align-items: center; gap: 4px; }
.home-section { padding: 12px 12px 16px; }
@@ -661,6 +706,21 @@ body.me-safe-top .app-safe-top { height: 0; }
flex: 1; border: 1px solid var(--color-border); border-radius: 6px; padding: 6px 8px;
}
/* ----- 软键盘避让 (Android) -----
原生层把键盘高度从 WebView 里扣掉后, 可视高度会变小;
弹层内容超过剩余高度时改为内部滚动, 避免顶部/输入框被顶出屏幕。 */
.adm-popup-body {
max-height: 100vh;
max-height: 100dvh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.adm-dialog-body {
max-height: calc(100vh - 48px);
max-height: calc(100dvh - 48px);
overflow-y: auto;
}
/* ----- 表单弹窗 (打卡项 / 清单) ----- */
.form-popup { padding: 16px 16px 8px; }
.form-title { font-size: 16px; font-weight: 700; color: var(--color-text); margin-bottom: 12px; }