Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2a1deb1a | ||
|
|
7b52220aad | ||
|
|
3730e55e93 | ||
|
|
902c6a0ebe | ||
|
|
ec6c9c7677 | ||
|
|
fa172d8974 | ||
|
|
308ace8559 | ||
|
|
6d560583ae | ||
|
|
f2c7dae4a8 | ||
|
|
4ded8e30ed | ||
|
|
be70868d0b | ||
|
|
49583599b3 | ||
|
|
8cfa3802d5 |
@@ -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: |
|
||||
if [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
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:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- 宏观:新闻 / 经济文章(本地),以及外接 ifinance 模块的分类(经济数据 / 策略研究 / 看板,运行时动态挂载为 tab)
|
||||
- 数据与策略由外接 ifinance 模块承担(静态包,见 `../ifinance/readme.md`)
|
||||
- 报警并入「监控」;油价模块已下线
|
||||
- AI 接入:内置 MCP 服务,个人 Token 授权后可查询 / 新增 / 修改 / 删除 / 排序项目、任务、清单和打卡(见 [`docs/MCP.md`](docs/MCP.md))
|
||||
|
||||
## 宏观
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# AI 接入 (MCP)
|
||||
|
||||
iBoard 内置一个 MCP (Model Context Protocol) 服务,AI 客户端可以通过个人授权 Token 直接查询、新增、修改、删除和排序当前用户的数据。
|
||||
|
||||
## 1. 创建 Token
|
||||
|
||||
1. 登录 iBoard,进入「我的」。
|
||||
2. 找到「AI 接入 (MCP)」。
|
||||
3. 输入 Token 名称(例如 `R1`、`家里电脑`),点击「新建 Token」。
|
||||
4. 复制弹窗中的 Token。**明文只显示一次**,关闭后无法再次查看。
|
||||
|
||||
每个 Token 独立命名,并与创建它的用户绑定。使用该 Token 的 AI 拥有该用户的全部业务权限;删除 Token 后立即失效。
|
||||
|
||||
## 2. 连接方式
|
||||
|
||||
- 端点:`https://<你的 iBoard 域名>/api/mcp`(页面上的「MCP 服务地址」可直接复制)
|
||||
- 协议:Streamable HTTP(无状态)
|
||||
- 鉴权头:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <Token>
|
||||
```
|
||||
|
||||
通用 MCP 客户端配置示例(以支持 HTTP transport 的客户端为例):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"iboard": {
|
||||
"type": "http",
|
||||
"url": "https://<你的 iBoard 域名>/api/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ibm_xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 可用能力
|
||||
|
||||
- 项目:查询 / 新建 / 修改 / 删除;成员添加与移除
|
||||
- 看板列:查询 / 新建 / 修改 / 删除;列排序
|
||||
- 任务:查询 / 新建 / 修改 / 删除 / 归档 / 恢复 / 排序 / 跨项目拷贝
|
||||
- 检查清单:查询 / 新建 / 修改 / 删除 / 复制;检查项增删改、排序、一键勾选;模板查询
|
||||
- 打卡:查询 / 新建 / 修改 / 删除 / 排序;打卡与撤销打卡
|
||||
|
||||
工具名与参数以 MCP `tools/list` 返回为准,所有描述均为中文,便于 AI 选择。
|
||||
|
||||
## 4. 安全建议
|
||||
|
||||
- Token 等同于账号密码,请勿提交到 Git 或发送到公开群聊。
|
||||
- 给不同设备 / 不同 AI 客户端分别创建具名 Token,便于单独吊销。
|
||||
- 不再使用时在「我的 → AI 接入 (MCP)」中删除。
|
||||
@@ -142,3 +142,8 @@ export type TaskMember = Prisma.TaskMemberModel
|
||||
*
|
||||
*/
|
||||
export type TaskTag = Prisma.TaskTagModel
|
||||
/**
|
||||
* Model McpToken
|
||||
*
|
||||
*/
|
||||
export type McpToken = Prisma.McpTokenModel
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
Generated
+39
-1
@@ -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
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// ifinance 文件缓存 Service Worker
|
||||
// ------------------------------------------------------------
|
||||
// 宿主把 iframe 指向 /__ifinance__/<path>?h=<sha256>&t=<mime>, 由本 SW:
|
||||
// 1. 按 hash 从 Cache Storage 取本地缓存 (内容寻址, 索引更新后自然失效)
|
||||
// 2. 未命中时只拉当前这一个文件 (按需加载, 不预下载整个模块包)
|
||||
// 3. 用 sha256 校验后再写入缓存; 校验失败仍然返回内容, 但不落盘
|
||||
//
|
||||
// 为什么不用 blob: URL 直接渲染: blob: 文档会继承宿主页面的 CSP
|
||||
// (script-src 'self'), 内嵌看板的 <script> 会被拦截, 图表无法渲染。
|
||||
// SW 返回的是普通 http(s) 子文档, 不受宿主 CSP 的脚本限制。
|
||||
//
|
||||
// 文件缓存里以 /__ifinance_file__/<hash> 为 key, 供宿主侧清理旧版本。
|
||||
const PAGE_PREFIX = '/__ifinance__/'
|
||||
const FILE_CACHE_NAME = 'ifinance-files-v1'
|
||||
const FILE_KEY_PREFIX = '/__ifinance_file__/'
|
||||
const REMOTE_BASE = 'https://ifinance.honor3.com'
|
||||
const NETWORK_TIMEOUT = 20000
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// 新版本 SW 立刻接管, 避免用户必须再打开一次页面
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (url.origin !== self.location.origin) return
|
||||
if (!url.pathname.startsWith(PAGE_PREFIX)) return
|
||||
if (event.request.method !== 'GET') return
|
||||
event.respondWith(serve(url))
|
||||
})
|
||||
|
||||
async function serve(url) {
|
||||
const rel = decodeURIComponent(url.pathname.slice(PAGE_PREFIX.length))
|
||||
const hash = (url.searchParams.get('h') || '').toLowerCase()
|
||||
const type = url.searchParams.get('t') || guessType(rel)
|
||||
const keyUrl = self.location.origin + FILE_KEY_PREFIX + (hash || encodeURIComponent(rel))
|
||||
|
||||
// 缓存不可用时 (隐私模式/配额/被策略禁用) 仍然正常联网加载
|
||||
let cache = null
|
||||
if (typeof caches !== 'undefined') {
|
||||
try { cache = await caches.open(FILE_CACHE_NAME) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (cache && hash) {
|
||||
try {
|
||||
const hit = await cache.match(keyUrl)
|
||||
if (hit) return respond(await hit.arrayBuffer(), type, 'hit')
|
||||
} catch { /* 读取失败按未命中处理 */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await fetchRemote(rel)
|
||||
if (cache && hash) {
|
||||
try {
|
||||
const digest = await sha256Hex(payload)
|
||||
if (digest === hash) {
|
||||
await cache.put(keyUrl, new Response(payload, {
|
||||
headers: { 'Content-Type': withCharset(type), 'Cache-Control': 'no-store' },
|
||||
}))
|
||||
} else {
|
||||
console.warn('[ifinance-sw] hash 不匹配, 不写入缓存:', rel, digest, '!=', hash)
|
||||
}
|
||||
} catch (e) {
|
||||
// crypto.subtle 不可用等: 只跳过落盘, 不影响本次展示
|
||||
console.warn('[ifinance-sw] 校验失败, 跳过缓存:', rel, e)
|
||||
}
|
||||
}
|
||||
return respond(payload, type, 'network')
|
||||
} catch (e) {
|
||||
console.warn('[ifinance-sw] 加载失败:', rel, e)
|
||||
return respondText(errorPage(rel, e), 'text/html; charset=utf-8', 502)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(rel) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT)
|
||||
try {
|
||||
const res = await fetch(REMOTE_BASE + '/' + rel, {
|
||||
cache: 'no-cache',
|
||||
mode: 'cors',
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||
return await res.arrayBuffer()
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function respond(buf, type, source) {
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': withCharset(type),
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Ifinance-Cache': source,
|
||||
// 同源内容用 CSP sandbox 隔离成不透明源: 行内脚本照常运行 (看板需要),
|
||||
// 但读不到宿主 localStorage/cookie, 也不能访问 parent。
|
||||
'Content-Security-Policy': 'sandbox allow-scripts',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function respondText(text, type, status = 200) {
|
||||
return new Response(text, {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': withCharset(type),
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Security-Policy': 'sandbox allow-scripts',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function withCharset(type) {
|
||||
const t = String(type || 'text/html')
|
||||
return /charset=/i.test(t) ? t : t + '; charset=utf-8'
|
||||
}
|
||||
|
||||
function guessType(rel) {
|
||||
return /\.json$/i.test(rel) ? 'application/json' : 'text/html'
|
||||
}
|
||||
|
||||
async function sha256Hex(buf) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', buf)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function errorPage(rel, err) {
|
||||
const msg = String(err?.message || err || '加载失败')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
return '<!doctype html><html><head><meta charset="utf-8">' +
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
||||
'<style>body{margin:0;padding:24px;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;' +
|
||||
'color:#666;background:#f5f5f5}h3{margin:0 0 8px;color:#333;font-size:15px}code{font-size:12px;color:#999;word-break:break-all}</style>' +
|
||||
'</head><body><h3>内容加载失败</h3><div>请检查网络后重试。</div><p><code>' +
|
||||
msg + '<br>' + rel.replace(/&/g, '&').replace(/</g, '<') +
|
||||
'</code></p></body></html>'
|
||||
}
|
||||
@@ -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] 完成"
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// MCP 个人授权 token 的生成与校验
|
||||
// - 明文只在创建时返回一次, 数据库仅存 SHA-256 哈希
|
||||
// - token 与用户绑定, 使用该 token 调用 MCP 时拥有该用户的全部业务权限
|
||||
import crypto from 'node:crypto'
|
||||
import { prisma } from '../db.js'
|
||||
|
||||
const TOKEN_PREFIX = 'ibm_'
|
||||
const BEARER_RE = /^Bearer\s+(.+)$/i
|
||||
|
||||
/** 生成新的 MCP token 明文 (256 bit 随机, base64url) */
|
||||
export function generateMcpToken() {
|
||||
return TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
/** token 明文 -> 数据库存储的哈希 */
|
||||
export function hashMcpToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
/** 从 Authorization: Bearer xxx 中取出明文 token */
|
||||
export function extractBearer(req) {
|
||||
const h = req.headers?.authorization || ''
|
||||
const m = String(h).match(BEARER_RE)
|
||||
return m ? m[1].trim() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 MCP 请求的 token
|
||||
* @returns {Promise<null | { tokenId: number, userId: number, user: { id: number, name: string, email: string } }>}
|
||||
*/
|
||||
export async function authenticateMcpToken(req) {
|
||||
const raw = extractBearer(req)
|
||||
if (!raw) return null
|
||||
|
||||
const row = await prisma.mcpToken.findUnique({
|
||||
where: { tokenHash: hashMcpToken(raw) },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
})
|
||||
if (!row || !row.user) return null
|
||||
|
||||
// 最近使用时间: 每分钟最多写一次, 避免每次工具调用都产生写操作
|
||||
const last = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0
|
||||
if (Date.now() - last > 60_000) {
|
||||
prisma.mcpToken
|
||||
.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } })
|
||||
.catch((e) => console.warn('[mcp] 更新 lastUsedAt 失败:', e.message))
|
||||
}
|
||||
|
||||
return { tokenId: row.id, userId: row.userId, user: row.user }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// MCP 工具注册与结果序列化辅助
|
||||
|
||||
/** 把任意可 JSON 化数据包装为 MCP 文本结果 (BigInt 转 Number) */
|
||||
export function jsonResult(data) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(data, (_key, value) => (typeof value === 'bigint' ? Number(value) : value), 2),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具执行失败: 统一返回 isError, 由 AI 自行读取错误信息 */
|
||||
export function errorResult(message) {
|
||||
return {
|
||||
content: [{ type: 'text', text: '错误: ' + (message || '未知错误') }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册工具并统一处理异常
|
||||
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
||||
* @param {string} name
|
||||
* @param {object} config
|
||||
* @param {(args: object) => Promise<unknown>} handler
|
||||
*/
|
||||
export function tool(server, name, config, handler) {
|
||||
server.registerTool(name, config, async (args) => {
|
||||
try {
|
||||
return jsonResult(await handler(args || {}))
|
||||
} catch (e) {
|
||||
// 业务校验错误直接回传; Prisma / 连接错误不向 AI 泄露 SQL 细节
|
||||
if (String(e?.name || '').startsWith('Prisma')) {
|
||||
return errorResult(`数据库操作失败${e?.code ? ' (' + e.code + ')' : ''}`)
|
||||
}
|
||||
return errorResult(e?.message || String(e))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 非空字符串 + 最大长度 */
|
||||
export function reqStr(v, label, max) {
|
||||
const s = String(v ?? '').trim()
|
||||
if (!s) throw new Error(`${label}不能为空`)
|
||||
if (max && s.length > max) throw new Error(`${label}不能超过 ${max} 字`)
|
||||
return s
|
||||
}
|
||||
|
||||
/** 时间标记: YYYY-MM-DD 或 ISO; null/'' 清空 */
|
||||
export function parseMarkDate(v) {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) throw new Error('时间标记格式非法')
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// MCP 服务 (Streamable HTTP, 无状态)
|
||||
// 入口: POST/GET/DELETE /api/mcp
|
||||
// 鉴权: Authorization: Bearer <个人 MCP Token>, 与用户绑定, 拥有该用户全部业务权限.
|
||||
import { Router } from 'express'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
import { authenticateMcpToken } from './auth.js'
|
||||
import { registerTaskToolsGroup } from './tools/tasks.js'
|
||||
import { registerChecklistTools } from './tools/checklists.js'
|
||||
import { registerCheckinTools } from './tools/checkin.js'
|
||||
|
||||
const SERVER_INFO = { name: 'iboard', version: '0.2.1' }
|
||||
|
||||
export function buildMcpServer(ctx) {
|
||||
const server = new McpServer(SERVER_INFO, {
|
||||
instructions: [
|
||||
'本服务连接 iBoard 个人工作台, 当前 token 已绑定到具体用户。',
|
||||
'可查询、新建、修改、删除、排序项目 / 看板列 / 任务, 以及检查清单和打卡。',
|
||||
'所有写操作立即生效, 删除类操作不可恢复, 调用前请确认用户意图。',
|
||||
'任务状态: todo / doing / done; 看板列可通过 boundStatus 与状态联动。',
|
||||
].join('\n'),
|
||||
})
|
||||
registerTaskToolsGroup(server, ctx)
|
||||
registerChecklistTools(server, ctx)
|
||||
registerCheckinTools(server, ctx)
|
||||
return server
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.all('/', async (req, res) => {
|
||||
let auth
|
||||
try {
|
||||
auth = await authenticateMcpToken(req)
|
||||
} catch (e) {
|
||||
console.error('[mcp] token 校验失败:', e)
|
||||
return res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
id: req.body?.id ?? null,
|
||||
error: { code: -32603, message: 'MCP 鉴权失败' },
|
||||
})
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="iboard-mcp"')
|
||||
return res.status(401).json({
|
||||
jsonrpc: '2.0',
|
||||
id: req.body?.id ?? null,
|
||||
error: {
|
||||
code: -32001,
|
||||
message: '未授权: 请在「我的 → AI 接入 (MCP)」创建 Token, 并使用 Authorization: Bearer <Token>',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const server = buildMcpServer({ userId: auth.userId, user: auth.user })
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
})
|
||||
|
||||
res.on('close', () => {
|
||||
transport.close().catch(() => {})
|
||||
server.close().catch(() => {})
|
||||
})
|
||||
|
||||
try {
|
||||
await server.connect(transport)
|
||||
await transport.handleRequest(req, res, req.body)
|
||||
} catch (e) {
|
||||
console.error('[mcp] 请求处理失败:', e)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
id: req.body?.id ?? null,
|
||||
error: { code: -32603, message: e?.message || 'MCP 处理失败' },
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,262 @@
|
||||
// MCP 工具: 打卡 (打卡项 / 打卡记录)
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../db.js'
|
||||
import { toDateStr, addDays, periodRange, PERIODS } from '../../checkin/period.js'
|
||||
import { tool, reqStr } from '../helpers.js'
|
||||
|
||||
const BACKFILL_DAYS = 7
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
function normDate(v) {
|
||||
const s = String(v || '')
|
||||
return DATE_RE.test(s) ? s : toDateStr()
|
||||
}
|
||||
|
||||
function groupCounts(records) {
|
||||
const map = new Map()
|
||||
for (const r of records) {
|
||||
if (!map.has(r.habitId)) map.set(r.habitId, new Map())
|
||||
const byDate = map.get(r.habitId)
|
||||
byDate.set(r.date, (byDate.get(r.date) || 0) + 1)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function sumRange(byDate, start, end) {
|
||||
let n = 0
|
||||
let cursor = start
|
||||
while (cursor <= end) {
|
||||
n += byDate.get(cursor) || 0
|
||||
cursor = addDays(cursor, 1)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export function registerCheckinTools(server, ctx) {
|
||||
const userId = ctx.userId
|
||||
|
||||
tool(server, 'list_habits', {
|
||||
title: '查询打卡项',
|
||||
description: '列出打卡项及其指定日期所在周期的完成进度; 默认只返回未归档项。',
|
||||
inputSchema: {
|
||||
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||
includeArchived: z.boolean().optional().describe('是否包含已归档打卡项, 默认 false'),
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ date, includeArchived = false }) => {
|
||||
const today = toDateStr()
|
||||
const day = normDate(date)
|
||||
const habits = await prisma.habit.findMany({
|
||||
where: { userId, ...(includeArchived ? {} : { archived: false }) },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
if (!habits.length) return { date: day, today, items: [] }
|
||||
|
||||
const ranges = new Map(habits.map((h) => [h.id, periodRange(h.period, day)]))
|
||||
let min = null
|
||||
let max = null
|
||||
for (const r of ranges.values()) {
|
||||
if (!min || r.start < min) min = r.start
|
||||
if (!max || r.end > max) max = r.end
|
||||
}
|
||||
const records = await prisma.checkIn.findMany({
|
||||
where: {
|
||||
userId,
|
||||
habitId: { in: habits.map((h) => h.id) },
|
||||
date: { gte: min, lte: max },
|
||||
},
|
||||
select: { habitId: true, date: true },
|
||||
})
|
||||
const grouped = groupCounts(records)
|
||||
|
||||
const items = habits.map((h) => {
|
||||
const range = ranges.get(h.id)
|
||||
const byDate = grouped.get(h.id) || new Map()
|
||||
const periodDone = sumRange(byDate, range.start, range.end)
|
||||
const target = Math.max(1, h.target)
|
||||
return {
|
||||
...h,
|
||||
target,
|
||||
periodStart: range.start,
|
||||
periodEnd: range.end,
|
||||
periodDone,
|
||||
completed: periodDone >= target,
|
||||
countOnDate: byDate.get(day) || 0,
|
||||
}
|
||||
})
|
||||
return { date: day, today, items }
|
||||
})
|
||||
|
||||
tool(server, 'create_habit', {
|
||||
title: '新建打卡项',
|
||||
description: '新建一个周期性打卡目标, 例如每天喝 8 杯水、每周运动 3 次。',
|
||||
inputSchema: {
|
||||
name: z.string().describe('打卡项名称, 最多 20 字'),
|
||||
period: z.enum(PERIODS).optional().describe('周期: day/week/month/year, 默认 day'),
|
||||
target: z.coerce.number().int().min(1).optional().describe('每周期目标次数, 默认 1'),
|
||||
color: z.string().optional().describe('颜色, 默认 #1677ff'),
|
||||
icon: z.string().optional().describe('图标 (emoji 或图标名)'),
|
||||
note: z.string().optional().describe('备注'),
|
||||
},
|
||||
}, async ({ name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' }) => {
|
||||
const cleanName = reqStr(name, '名称', 20)
|
||||
if (!PERIODS.includes(period)) throw new Error('周期类型不合法')
|
||||
const t = Math.floor(Number(target))
|
||||
if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数')
|
||||
|
||||
const last = await prisma.habit.findFirst({
|
||||
where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true },
|
||||
})
|
||||
const row = await prisma.habit.create({
|
||||
data: {
|
||||
userId,
|
||||
name: cleanName,
|
||||
period,
|
||||
target: t,
|
||||
color: String(color),
|
||||
icon: String(icon),
|
||||
note: String(note || ''),
|
||||
sortOrder: (last?.sortOrder ?? -1) + 1,
|
||||
},
|
||||
})
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'update_habit', {
|
||||
title: '修改打卡项',
|
||||
description: '修改打卡项名称、周期、目标次数、颜色、图标、备注、归档状态或排序值。',
|
||||
inputSchema: {
|
||||
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||
name: z.string().optional().describe('新名称, 最多 20 字'),
|
||||
period: z.enum(PERIODS).optional().describe('新周期'),
|
||||
target: z.coerce.number().int().min(1).optional().describe('新目标次数'),
|
||||
color: z.string().optional().describe('新颜色'),
|
||||
icon: z.string().optional().describe('新图标'),
|
||||
note: z.string().optional().describe('新备注'),
|
||||
archived: z.boolean().optional().describe('是否归档'),
|
||||
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
||||
},
|
||||
}, async ({ habitId, name, period, target, color, icon, note, archived, sortOrder }) => {
|
||||
const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||
if (!exist) throw new Error('打卡项不存在')
|
||||
const data = {}
|
||||
if (name !== undefined) data.name = reqStr(name, '名称', 20)
|
||||
if (period !== undefined) {
|
||||
if (!PERIODS.includes(period)) throw new Error('周期类型不合法')
|
||||
data.period = period
|
||||
}
|
||||
if (target !== undefined) {
|
||||
const t = Math.floor(Number(target))
|
||||
if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数')
|
||||
data.target = t
|
||||
}
|
||||
if (color !== undefined) data.color = String(color)
|
||||
if (icon !== undefined) data.icon = String(icon)
|
||||
if (note !== undefined) data.note = String(note)
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
|
||||
const row = await prisma.habit.update({ where: { id: Number(habitId) }, data })
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'delete_habit', {
|
||||
title: '删除打卡项',
|
||||
description: '默认归档并保留历史记录; hard=true 时永久删除打卡项及其全部打卡记录。',
|
||||
inputSchema: {
|
||||
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||
hard: z.boolean().optional().describe('true 永久删除, 默认 false 仅归档'),
|
||||
},
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ habitId, hard = false }) => {
|
||||
const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||
if (!exist) throw new Error('打卡项不存在')
|
||||
if (hard) {
|
||||
await prisma.checkIn.deleteMany({ where: { habitId: Number(habitId) } })
|
||||
await prisma.habit.delete({ where: { id: Number(habitId) } })
|
||||
return { ok: true, removed: true }
|
||||
}
|
||||
const row = await prisma.habit.update({
|
||||
where: { id: Number(habitId) }, data: { archived: true },
|
||||
})
|
||||
return { ok: true, removed: false, item: row }
|
||||
})
|
||||
|
||||
tool(server, 'reorder_habits', {
|
||||
title: '打卡项排序',
|
||||
description: '按传入的打卡项 ID 数组重排, 数组顺序即新顺序。',
|
||||
inputSchema: {
|
||||
ids: z.array(z.coerce.number().int().positive()).describe('打卡项 ID 的新顺序'),
|
||||
},
|
||||
}, async ({ ids }) => {
|
||||
const uniq = [...new Set(ids.map(Number))]
|
||||
if (!uniq.length) throw new Error('ids 不能为空')
|
||||
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
||||
const rows = await prisma.habit.findMany({
|
||||
where: { userId, id: { in: uniq } }, select: { id: true },
|
||||
})
|
||||
if (rows.length !== uniq.length) throw new Error('包含无效的打卡项')
|
||||
await prisma.$transaction(
|
||||
ids.map((hid, idx) => prisma.habit.update({
|
||||
where: { id: Number(hid) }, data: { sortOrder: idx },
|
||||
})),
|
||||
)
|
||||
return { ok: true, orderedIds: ids.map(Number) }
|
||||
})
|
||||
|
||||
tool(server, 'check_habit', {
|
||||
title: '打卡',
|
||||
description: '为打卡项记录一次完成; 可为最近 7 天补打, 不能打未来日期。',
|
||||
inputSchema: {
|
||||
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||
note: z.string().optional().describe('打卡备注, 最多 200 字'),
|
||||
},
|
||||
}, async ({ habitId, date, note }) => {
|
||||
const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||
if (!habit) throw new Error('打卡项不存在')
|
||||
|
||||
const today = toDateStr()
|
||||
const day = normDate(date)
|
||||
if (day > today) throw new Error('不能为未来日期打卡')
|
||||
if (day < addDays(today, -BACKFILL_DAYS)) throw new Error(`最多只能补打最近 ${BACKFILL_DAYS} 天`)
|
||||
|
||||
const dayRecords = await prisma.checkIn.findMany({
|
||||
where: { habitId: habit.id, date: day }, select: { seq: true },
|
||||
})
|
||||
const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1
|
||||
const row = await prisma.checkIn.create({
|
||||
data: {
|
||||
habitId: habit.id,
|
||||
userId,
|
||||
date: day,
|
||||
seq,
|
||||
note: String(note || '').slice(0, 200),
|
||||
source: day === today ? 'user' : 'backfill',
|
||||
},
|
||||
})
|
||||
return { ok: true, date: day, seq, item: row }
|
||||
})
|
||||
|
||||
tool(server, 'undo_habit_check', {
|
||||
title: '撤销打卡',
|
||||
description: '撤销指定日期所在周期内最近一次打卡记录。',
|
||||
inputSchema: {
|
||||
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||
},
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ habitId, date }) => {
|
||||
const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||
if (!habit) throw new Error('打卡项不存在')
|
||||
|
||||
const day = normDate(date)
|
||||
const { start, end } = periodRange(habit.period, day)
|
||||
const last = await prisma.checkIn.findFirst({
|
||||
where: { habitId: habit.id, date: { gte: start, lte: end } },
|
||||
orderBy: [{ date: 'desc' }, { seq: 'desc' }],
|
||||
})
|
||||
if (!last) throw new Error('本周期还没有打卡记录')
|
||||
await prisma.checkIn.delete({ where: { id: last.id } })
|
||||
return { ok: true, date: last.date, seq: last.seq }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// MCP 工具: 检查清单 (清单 / 检查项)
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../db.js'
|
||||
import { ensureBuiltin } from '../../routes/checklists.js'
|
||||
import { tool, reqStr } from '../helpers.js'
|
||||
|
||||
const LIST_INCLUDE = { items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }
|
||||
|
||||
function withProgress(list) {
|
||||
const items = list.items || []
|
||||
const done = items.filter((i) => i.done).length
|
||||
return {
|
||||
...list,
|
||||
items,
|
||||
total: items.length,
|
||||
done,
|
||||
percent: items.length ? done / items.length : 0,
|
||||
completed: items.length > 0 && done === items.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function requireList(listId, userId) {
|
||||
const list = await prisma.checkList.findFirst({ where: { id: Number(listId), userId } })
|
||||
if (!list) throw new Error('清单不存在')
|
||||
return list
|
||||
}
|
||||
|
||||
function normalizeSeedItems(items) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.map((i) => (typeof i === 'string' ? { title: i } : i))
|
||||
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
|
||||
.filter((i) => i.title)
|
||||
}
|
||||
|
||||
export function registerChecklistTools(server, ctx) {
|
||||
const userId = ctx.userId
|
||||
|
||||
tool(server, 'list_checklists', {
|
||||
title: '查询清单',
|
||||
description: '列出当前用户的检查清单 (出差/回老家等), 含完成进度。',
|
||||
inputSchema: {
|
||||
archived: z.boolean().optional().describe('true 查询已归档清单, 默认 false'),
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ archived = false }) => {
|
||||
const rows = await prisma.checkList.findMany({
|
||||
where: { userId, archived: !!archived },
|
||||
include: LIST_INCLUDE,
|
||||
orderBy: [{ updatedAt: 'desc' }],
|
||||
})
|
||||
return { items: rows.map(withProgress) }
|
||||
})
|
||||
|
||||
tool(server, 'get_checklist', {
|
||||
title: '查询清单详情',
|
||||
description: '读取单个清单及其全部检查项、完成进度。',
|
||||
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ listId }) => {
|
||||
const row = await prisma.checkList.findFirst({
|
||||
where: { id: Number(listId), userId },
|
||||
include: LIST_INCLUDE,
|
||||
})
|
||||
if (!row) throw new Error('清单不存在')
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'create_checklist', {
|
||||
title: '新建清单',
|
||||
description: '新建检查清单, 可直接带检查项, 也可从系统模板/自建模板复制 (传 templateId)。',
|
||||
inputSchema: {
|
||||
title: z.string().describe('清单名称'),
|
||||
scene: z.string().optional().describe('场景, 如 出差 / 回老家'),
|
||||
note: z.string().optional().describe('备注'),
|
||||
items: z.array(z.union([
|
||||
z.string(),
|
||||
z.object({ title: z.string(), note: z.string().optional() }),
|
||||
])).optional().describe('初始检查项'),
|
||||
templateId: z.coerce.number().int().positive().optional().describe('模板 ID, 在没有 items 时使用'),
|
||||
},
|
||||
}, async ({ title, scene = '', note = '', items = [], templateId }) => {
|
||||
const cleanTitle = reqStr(title, '清单名称')
|
||||
let seed = normalizeSeedItems(items)
|
||||
|
||||
if (!seed.length && templateId) {
|
||||
await ensureBuiltin()
|
||||
const tpl = await prisma.checkListTemplate.findFirst({
|
||||
where: { id: Number(templateId), OR: [{ builtin: true }, { userId }] },
|
||||
})
|
||||
if (tpl && Array.isArray(tpl.items)) seed = normalizeSeedItems(tpl.items)
|
||||
}
|
||||
|
||||
const row = await prisma.checkList.create({
|
||||
data: {
|
||||
userId,
|
||||
title: cleanTitle,
|
||||
scene: String(scene || ''),
|
||||
note: String(note || ''),
|
||||
items: { create: seed.map((i, idx) => ({ title: i.title, note: i.note, sortOrder: idx })) },
|
||||
},
|
||||
include: LIST_INCLUDE,
|
||||
})
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'update_checklist', {
|
||||
title: '修改清单',
|
||||
description: '修改清单名称、场景、备注或归档状态。',
|
||||
inputSchema: {
|
||||
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||
title: z.string().optional().describe('新名称'),
|
||||
scene: z.string().optional().describe('新场景'),
|
||||
note: z.string().optional().describe('新备注'),
|
||||
archived: z.boolean().optional().describe('是否归档'),
|
||||
},
|
||||
}, async ({ listId, title, scene, note, archived }) => {
|
||||
await requireList(listId, userId)
|
||||
const data = {}
|
||||
if (title !== undefined) data.title = reqStr(title, '清单名称')
|
||||
if (scene !== undefined) data.scene = String(scene)
|
||||
if (note !== undefined) data.note = String(note)
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
const row = await prisma.checkList.update({
|
||||
where: { id: Number(listId) }, data, include: LIST_INCLUDE,
|
||||
})
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'delete_checklist', {
|
||||
title: '删除清单',
|
||||
description: '永久删除清单及其全部检查项。',
|
||||
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ listId }) => {
|
||||
await requireList(listId, userId)
|
||||
await prisma.checkListItem.deleteMany({ where: { listId: Number(listId) } })
|
||||
await prisma.checkList.delete({ where: { id: Number(listId) } })
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
tool(server, 'duplicate_checklist', {
|
||||
title: '复制清单',
|
||||
description: '复制一份清单, 勾选状态重置。',
|
||||
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||
}, async ({ listId }) => {
|
||||
const src = await prisma.checkList.findFirst({
|
||||
where: { id: Number(listId), userId }, include: LIST_INCLUDE,
|
||||
})
|
||||
if (!src) throw new Error('清单不存在')
|
||||
const row = await prisma.checkList.create({
|
||||
data: {
|
||||
userId,
|
||||
title: src.title + ' 副本',
|
||||
scene: src.scene,
|
||||
note: src.note,
|
||||
items: {
|
||||
create: src.items.map((i, idx) => ({
|
||||
title: i.title, note: i.note, done: false, sortOrder: idx,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: LIST_INCLUDE,
|
||||
})
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'add_checklist_item', {
|
||||
title: '添加检查项',
|
||||
description: '在清单末尾添加一个检查项。',
|
||||
inputSchema: {
|
||||
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||
title: z.string().describe('检查项内容, 最多 40 字'),
|
||||
note: z.string().optional().describe('备注'),
|
||||
},
|
||||
}, async ({ listId, title, note = '' }) => {
|
||||
await requireList(listId, userId)
|
||||
const cleanTitle = reqStr(title, '检查项', 40)
|
||||
const max = await prisma.checkListItem.findFirst({
|
||||
where: { listId: Number(listId) }, orderBy: { sortOrder: 'desc' },
|
||||
})
|
||||
const row = await prisma.checkListItem.create({
|
||||
data: {
|
||||
listId: Number(listId),
|
||||
title: cleanTitle,
|
||||
note: String(note || ''),
|
||||
sortOrder: (max?.sortOrder ?? -1) + 1,
|
||||
},
|
||||
})
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'update_checklist_item', {
|
||||
title: '修改检查项',
|
||||
description: '修改检查项内容、备注、完成状态或排序值。',
|
||||
inputSchema: {
|
||||
itemId: z.coerce.number().int().positive().describe('检查项 ID'),
|
||||
title: z.string().optional().describe('新内容, 最多 40 字'),
|
||||
note: z.string().optional().describe('新备注'),
|
||||
done: z.boolean().optional().describe('是否完成'),
|
||||
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
||||
},
|
||||
}, async ({ itemId, title, note, done, sortOrder }) => {
|
||||
const exist = await prisma.checkListItem.findFirst({
|
||||
where: { id: Number(itemId), list: { userId } },
|
||||
})
|
||||
if (!exist) throw new Error('检查项不存在')
|
||||
const data = {}
|
||||
if (title !== undefined) data.title = reqStr(title, '检查项', 40)
|
||||
if (note !== undefined) data.note = String(note)
|
||||
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
|
||||
if (done !== undefined) {
|
||||
data.done = !!done
|
||||
data.doneAt = done ? new Date() : null
|
||||
}
|
||||
const row = await prisma.checkListItem.update({ where: { id: Number(itemId) }, data })
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'delete_checklist_item', {
|
||||
title: '删除检查项',
|
||||
description: '永久删除一个检查项。',
|
||||
inputSchema: { itemId: z.coerce.number().int().positive().describe('检查项 ID') },
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ itemId }) => {
|
||||
const exist = await prisma.checkListItem.findFirst({
|
||||
where: { id: Number(itemId), list: { userId } },
|
||||
})
|
||||
if (!exist) throw new Error('检查项不存在')
|
||||
await prisma.checkListItem.delete({ where: { id: Number(itemId) } })
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
tool(server, 'reorder_checklist_items', {
|
||||
title: '检查项排序',
|
||||
description: '按传入的检查项 ID 数组重排清单内检查项, 数组顺序即新顺序。',
|
||||
inputSchema: {
|
||||
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||
ids: z.array(z.coerce.number().int().positive()).describe('检查项 ID 的新顺序'),
|
||||
},
|
||||
}, async ({ listId, ids }) => {
|
||||
await requireList(listId, userId)
|
||||
const uniq = [...new Set(ids.map(Number))]
|
||||
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
||||
const rows = await prisma.checkListItem.findMany({
|
||||
where: { listId: Number(listId), id: { in: uniq } }, select: { id: true },
|
||||
})
|
||||
if (rows.length !== uniq.length) throw new Error('包含不属于该清单的检查项')
|
||||
await prisma.$transaction(
|
||||
ids.map((itemId, idx) => prisma.checkListItem.update({
|
||||
where: { id: Number(itemId) }, data: { sortOrder: idx },
|
||||
})),
|
||||
)
|
||||
return { ok: true, orderedIds: ids.map(Number) }
|
||||
})
|
||||
|
||||
tool(server, 'check_all_checklist_items', {
|
||||
title: '清单一键勾选',
|
||||
description: '把清单内全部检查项设为完成或未完成。',
|
||||
inputSchema: {
|
||||
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||
done: z.boolean().describe('true 全部完成, false 全部取消'),
|
||||
},
|
||||
}, async ({ listId, done }) => {
|
||||
await requireList(listId, userId)
|
||||
await prisma.checkListItem.updateMany({
|
||||
where: { listId: Number(listId) },
|
||||
data: { done: !!done, doneAt: done ? new Date() : null },
|
||||
})
|
||||
const row = await prisma.checkList.findFirst({
|
||||
where: { id: Number(listId) }, include: LIST_INCLUDE,
|
||||
})
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'list_checklist_templates', {
|
||||
title: '查询清单模板',
|
||||
description: '列出系统预置和当前用户自建的清单模板, 可配合 create_checklist 使用。',
|
||||
inputSchema: {},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async () => {
|
||||
await ensureBuiltin()
|
||||
const rows = await prisma.checkListTemplate.findMany({
|
||||
where: { OR: [{ builtin: true }, { userId }] },
|
||||
orderBy: [{ builtin: 'desc' }, { name: 'asc' }],
|
||||
})
|
||||
return { items: rows }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
// MCP 工具: 任务模块 (项目 / 看板列 / 任务)
|
||||
// 所有操作都以 token 绑定的用户身份执行, 复用任务域现有的权限与状态联动规则.
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../db.js'
|
||||
import {
|
||||
ensurePersonalProject, createDefaultColumns, normTaskKinds,
|
||||
AUTO_ARCHIVE_OPTIONS, MAX_COLUMNS, DEFAULT_TASK_KINDS,
|
||||
} from '../../routes/projects.js'
|
||||
import { loadProjectForUser, loadTaskForUser, loadColumns, loadMemberIds } from '../../task-domain/access.js'
|
||||
import { resolveTaskWrite } from '../../task-domain/resolveStatus.js'
|
||||
import { copyTask } from '../../task-domain/copyTask.js'
|
||||
import {
|
||||
genUuid, withUuid, withTaskNumber, normProjectCode, allocProjectCode,
|
||||
} from '../../task-domain/identity.js'
|
||||
import { tool, reqStr, parseMarkDate } from '../helpers.js'
|
||||
|
||||
const MAX_NAME = 50
|
||||
const MAX_COLUMN_NAME = 20
|
||||
const STATUSES = ['todo', 'doing', 'done']
|
||||
const RETENTIONS = ['never', 'today', '3d', 'week', 'month', 'forever']
|
||||
|
||||
const TASK_INCLUDE = {
|
||||
column: true,
|
||||
assignee: { select: { id: true, name: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
members: { include: { user: { select: { id: true, name: true } } } },
|
||||
tags: true,
|
||||
children: {
|
||||
where: { archived: false },
|
||||
include: {
|
||||
column: true,
|
||||
assignee: { select: { id: true, name: true } },
|
||||
members: { include: { user: { select: { id: true, name: true } } } },
|
||||
tags: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
},
|
||||
}
|
||||
|
||||
function shapeTask(t) {
|
||||
if (!t) return t
|
||||
const out = {
|
||||
...t,
|
||||
members: (t.members || []).map((m) => ({ userId: m.userId, name: m.user?.name })),
|
||||
tags: (t.tags || []).map((tag) => tag.name),
|
||||
}
|
||||
if (Array.isArray(t.children)) out.children = t.children.map(shapeTask)
|
||||
return out
|
||||
}
|
||||
|
||||
function shapeProject(p) {
|
||||
return {
|
||||
id: p.id,
|
||||
uuid: p.uuid,
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
ownerId: p.ownerId,
|
||||
isPersonal: p.isPersonal,
|
||||
archived: p.archived,
|
||||
notes: Array.isArray(p.notes) ? p.notes : [],
|
||||
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
||||
autoArchive: p.autoArchive,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
memberCount: p._count?.members,
|
||||
taskCount: p._count?.tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async function requireProject(projectId, userId) {
|
||||
const project = await loadProjectForUser(projectId, userId)
|
||||
if (!project) throw new Error('项目不存在或无权限')
|
||||
return project
|
||||
}
|
||||
|
||||
function normalizeNotes(notes) {
|
||||
if (!Array.isArray(notes)) throw new Error('项目信息块格式非法')
|
||||
return notes.slice(0, 50).map((n) => ({
|
||||
title: String(n?.title ?? '').slice(0, 50),
|
||||
content: String(n?.content ?? '').slice(0, 20000),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- 项目 ----------
|
||||
|
||||
function registerProjectTools(server, ctx) {
|
||||
const userId = ctx.userId
|
||||
|
||||
tool(server, 'get_current_user', {
|
||||
title: '查询当前用户',
|
||||
description: '返回当前 MCP Token 绑定的用户信息, 用于确认操作身份。',
|
||||
inputSchema: {},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async () => ({ user: ctx.user || { id: userId } }))
|
||||
|
||||
tool(server, 'list_projects', {
|
||||
title: '查询项目',
|
||||
description: '列出当前用户参与的全部项目 (含个人默认项目), 返回项目编号、名称、成员数、未归档任务数。',
|
||||
inputSchema: {
|
||||
includeArchived: z.boolean().optional().describe('是否包含已归档项目, 默认 false'),
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ includeArchived = false }) => {
|
||||
await ensurePersonalProject(userId)
|
||||
const rows = await prisma.project.findMany({
|
||||
where: {
|
||||
members: { some: { userId } },
|
||||
...(includeArchived ? {} : { archived: false }),
|
||||
},
|
||||
include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } },
|
||||
orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
return { items: rows.map(shapeProject) }
|
||||
})
|
||||
|
||||
tool(server, 'get_project', {
|
||||
title: '查询项目详情',
|
||||
description: '读取单个项目的完整信息: 基本信息、成员、看板列、信息块。',
|
||||
inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') },
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ projectId }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
const [members, columns] = await Promise.all([
|
||||
prisma.projectMember.findMany({
|
||||
where: { projectId: project.id },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
loadColumns(project.id),
|
||||
])
|
||||
return {
|
||||
item: {
|
||||
...shapeProject(project),
|
||||
members: members.map((m) => ({
|
||||
userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission,
|
||||
})),
|
||||
columns,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
tool(server, 'create_project', {
|
||||
title: '新建项目',
|
||||
description: '新建一个项目并自动创建默认看板列 (Plan/Doing/Action/Done), 创建者自动成为成员。',
|
||||
inputSchema: {
|
||||
name: z.string().describe('项目名称, 最多 50 字'),
|
||||
code: z.string().optional().describe('项目短编号 2~8 位字母数字, 不填则按名称自动分配'),
|
||||
},
|
||||
}, async ({ name, code }) => {
|
||||
const cleanName = reqStr(name, '项目名称', MAX_NAME)
|
||||
let finalCode
|
||||
if (code !== undefined && String(code).trim() !== '') {
|
||||
finalCode = normProjectCode(code)
|
||||
if (!finalCode) throw new Error('项目编号须为 2~8 位字母数字')
|
||||
const dup = await prisma.project.findUnique({ where: { code: finalCode } })
|
||||
if (dup) throw new Error(`项目编号 ${finalCode} 已被占用`)
|
||||
} else {
|
||||
finalCode = await allocProjectCode(cleanName)
|
||||
}
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: cleanName,
|
||||
ownerId: userId,
|
||||
isPersonal: false,
|
||||
uuid: genUuid(),
|
||||
code: finalCode,
|
||||
members: { create: { uuid: genUuid(), userId } },
|
||||
},
|
||||
})
|
||||
await createDefaultColumns(project.id)
|
||||
return { item: shapeProject(project) }
|
||||
})
|
||||
|
||||
tool(server, 'update_project', {
|
||||
title: '修改项目',
|
||||
description: '修改项目名称、短编号、归档状态、信息块、任务类型标签、自动归档档位。',
|
||||
inputSchema: {
|
||||
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||
name: z.string().optional().describe('新项目名称, 最多 50 字'),
|
||||
code: z.string().optional().describe('新项目短编号 2~8 位字母数字'),
|
||||
archived: z.boolean().optional().describe('是否归档'),
|
||||
notes: z.array(z.object({
|
||||
title: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})).optional().describe('项目信息块, 整体替换'),
|
||||
taskKinds: z.array(z.string()).optional().describe('任务类型标签, 整体替换, 至少保留一个'),
|
||||
autoArchive: z.enum(AUTO_ARCHIVE_OPTIONS).optional().describe('自动归档: 3d/1w/1m/never'),
|
||||
},
|
||||
}, async ({ projectId, name, code, archived, notes, taskKinds, autoArchive }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
const data = {}
|
||||
if (name !== undefined) data.name = reqStr(name, '项目名称', MAX_NAME)
|
||||
if (code !== undefined) {
|
||||
const c = normProjectCode(code)
|
||||
if (!c) throw new Error('项目编号须为 2~8 位字母数字')
|
||||
if (c !== project.code) {
|
||||
const dup = await prisma.project.findUnique({ where: { code: c } })
|
||||
if (dup) throw new Error(`项目编号 ${c} 已被占用`)
|
||||
data.code = c
|
||||
}
|
||||
}
|
||||
if (notes !== undefined) data.notes = normalizeNotes(notes)
|
||||
if (taskKinds !== undefined) {
|
||||
const kinds = normTaskKinds(taskKinds)
|
||||
if (!kinds?.length) throw new Error('至少保留一个任务类型')
|
||||
data.taskKinds = kinds
|
||||
}
|
||||
if (autoArchive !== undefined) data.autoArchive = autoArchive
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
const row = await prisma.project.update({ where: { id: project.id }, data })
|
||||
return { item: shapeProject(row) }
|
||||
})
|
||||
|
||||
tool(server, 'delete_project', {
|
||||
title: '删除项目',
|
||||
description: '删除项目及其全部看板列和任务。个人默认项目不可删除。',
|
||||
inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') },
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ projectId }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
if (project.isPersonal) throw new Error('个人默认项目不可删除')
|
||||
await prisma.project.delete({ where: { id: project.id } })
|
||||
return { ok: true, deletedProjectId: project.id }
|
||||
})
|
||||
|
||||
tool(server, 'add_project_member', {
|
||||
title: '添加项目成员',
|
||||
description: '按用户 ID 或邮箱把用户加入项目。个人默认项目不可添加成员。',
|
||||
inputSchema: {
|
||||
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||
userId: z.coerce.number().int().positive().optional().describe('目标用户 ID'),
|
||||
email: z.string().optional().describe('目标用户邮箱'),
|
||||
},
|
||||
}, async ({ projectId, userId: targetId, email }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
if (project.isPersonal) throw new Error('个人默认项目不可添加成员')
|
||||
if (!targetId && !email) throw new Error('userId 与 email 至少提供一个')
|
||||
|
||||
const target = targetId
|
||||
? await prisma.user.findUnique({ where: { id: Number(targetId) } })
|
||||
: await prisma.user.findFirst({ where: { email: String(email).trim() } })
|
||||
if (!target) throw new Error('用户不存在')
|
||||
|
||||
const exist = await prisma.projectMember.findUnique({
|
||||
where: { projectId_userId: { projectId: project.id, userId: target.id } },
|
||||
})
|
||||
if (exist) throw new Error('该用户已是项目成员')
|
||||
|
||||
await prisma.projectMember.create({
|
||||
data: { uuid: genUuid(), projectId: project.id, userId: target.id },
|
||||
})
|
||||
return { ok: true, member: { userId: target.id, name: target.name, email: target.email } }
|
||||
})
|
||||
|
||||
tool(server, 'remove_project_member', {
|
||||
title: '移除项目成员',
|
||||
description: '把成员移出项目; 成员本人也可用此工具退出项目。个人默认项目不可移除成员。',
|
||||
inputSchema: {
|
||||
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||
userId: z.coerce.number().int().positive().describe('要移除的用户 ID'),
|
||||
},
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ projectId, userId: targetId }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
if (project.isPersonal) throw new Error('个人默认项目不可移除成员')
|
||||
const target = Number(targetId)
|
||||
if (target !== userId && project.ownerId !== userId) {
|
||||
throw new Error('只能移除自己,或由创建人移除他人')
|
||||
}
|
||||
await prisma.projectMember.deleteMany({ where: { projectId: project.id, userId: target } })
|
||||
return { ok: true, removedUserId: target }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 看板列 ----------
|
||||
|
||||
function registerColumnTools(server, ctx) {
|
||||
const userId = ctx.userId
|
||||
|
||||
tool(server, 'list_columns', {
|
||||
title: '查询看板列',
|
||||
description: '列出项目全部看板列 (按显示顺序), 含绑定状态与完成保留选项。',
|
||||
inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') },
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ projectId }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
return { items: await loadColumns(project.id) }
|
||||
})
|
||||
|
||||
tool(server, 'create_column', {
|
||||
title: '新建看板列',
|
||||
description: `在项目末尾新建看板列, 最多 ${MAX_COLUMNS} 列。`,
|
||||
inputSchema: {
|
||||
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||
name: z.string().describe('列名, 最多 20 字'),
|
||||
boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态 todo/doing/done, null 表示不联动'),
|
||||
retention: z.enum(RETENTIONS).optional().describe('完成保留选项, 默认 forever'),
|
||||
},
|
||||
}, async ({ projectId, name, boundStatus = null, retention = 'forever' }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
const cleanName = reqStr(name, '列名', MAX_COLUMN_NAME)
|
||||
const cols = await loadColumns(project.id)
|
||||
if (cols.length >= MAX_COLUMNS) throw new Error(`最多 ${MAX_COLUMNS} 列`)
|
||||
if (cols.some((c) => c.name === cleanName)) throw new Error('列名已存在')
|
||||
if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法')
|
||||
if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法')
|
||||
|
||||
const row = await prisma.boardColumn.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
uuid: genUuid(),
|
||||
name: cleanName,
|
||||
sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0,
|
||||
boundStatus,
|
||||
retention,
|
||||
},
|
||||
})
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'update_column', {
|
||||
title: '修改看板列',
|
||||
description: '修改列名、绑定状态或完成保留选项。',
|
||||
inputSchema: {
|
||||
columnId: z.coerce.number().int().positive().describe('看板列 ID'),
|
||||
name: z.string().optional().describe('新列名, 最多 20 字'),
|
||||
boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态, null 表示取消绑定'),
|
||||
retention: z.enum(RETENTIONS).optional().describe('完成保留选项'),
|
||||
},
|
||||
}, async ({ columnId, name, boundStatus, retention }) => {
|
||||
const col = await prisma.boardColumn.findUnique({ where: { id: Number(columnId) } })
|
||||
if (!col) throw new Error('列不存在')
|
||||
const project = await requireProject(col.projectId, userId)
|
||||
|
||||
const data = {}
|
||||
if (name !== undefined) {
|
||||
const t = reqStr(name, '列名', MAX_COLUMN_NAME)
|
||||
const dup = await prisma.boardColumn.findFirst({
|
||||
where: { projectId: project.id, name: t, NOT: { id: col.id } },
|
||||
})
|
||||
if (dup) throw new Error('列名已存在')
|
||||
data.name = t
|
||||
}
|
||||
if (boundStatus !== undefined) {
|
||||
if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法')
|
||||
data.boundStatus = boundStatus
|
||||
}
|
||||
if (retention !== undefined) {
|
||||
if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法')
|
||||
data.retention = retention
|
||||
}
|
||||
const row = await prisma.boardColumn.update({ where: { id: col.id }, data })
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'delete_column', {
|
||||
title: '删除看板列',
|
||||
description: '删除看板列并把列内任务迁移到目标列 (不传则迁移到相邻列)。至少保留一列。',
|
||||
inputSchema: {
|
||||
columnId: z.coerce.number().int().positive().describe('看板列 ID'),
|
||||
moveToColumnId: z.coerce.number().int().positive().optional().describe('任务迁移目标列 ID'),
|
||||
},
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ columnId, moveToColumnId }) => {
|
||||
const col = await prisma.boardColumn.findUnique({ where: { id: Number(columnId) } })
|
||||
if (!col) throw new Error('列不存在')
|
||||
const project = await requireProject(col.projectId, userId)
|
||||
|
||||
const cols = await loadColumns(project.id)
|
||||
if (cols.length <= 1) throw new Error('至少保留一列')
|
||||
|
||||
let targetId = Number(moveToColumnId) || 0
|
||||
if (!targetId) {
|
||||
const idx = cols.findIndex((c) => c.id === col.id)
|
||||
targetId = (cols[idx + 1] || cols[idx - 1]).id
|
||||
}
|
||||
const target = cols.find((c) => c.id === targetId)
|
||||
if (!target || target.id === col.id) throw new Error('迁移目标列非法')
|
||||
|
||||
const tasks = await prisma.task.findMany({ where: { columnId: col.id } })
|
||||
const newStatus = target.boundStatus || null
|
||||
await prisma.task.updateMany({
|
||||
where: { columnId: col.id },
|
||||
data: { columnId: target.id, ...(newStatus ? { status: newStatus } : {}) },
|
||||
})
|
||||
await prisma.boardColumn.delete({ where: { id: col.id } })
|
||||
return { ok: true, moved: tasks.length, toColumnId: target.id }
|
||||
})
|
||||
|
||||
tool(server, 'reorder_columns', {
|
||||
title: '看板列排序',
|
||||
description: '按传入的列 ID 数组重排项目全部看板列, 数组顺序即新顺序。',
|
||||
inputSchema: {
|
||||
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||
ids: z.array(z.coerce.number().int().positive()).describe('全部看板列 ID 的新顺序'),
|
||||
},
|
||||
}, async ({ projectId, ids }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
const cols = await loadColumns(project.id)
|
||||
const uniq = [...new Set(ids.map(Number))]
|
||||
if (uniq.length !== cols.length || uniq.length !== ids.length) {
|
||||
throw new Error('必须传入项目全部看板列, 且不能重复')
|
||||
}
|
||||
const colSet = new Set(cols.map((c) => c.id))
|
||||
if (uniq.some((eid) => !colSet.has(eid))) throw new Error('包含不属于该项目的看板列')
|
||||
await prisma.$transaction(
|
||||
ids.map((eid, idx) => prisma.boardColumn.update({ where: { id: Number(eid) }, data: { sortOrder: idx } })),
|
||||
)
|
||||
return { items: await loadColumns(project.id) }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 任务 ----------
|
||||
|
||||
function registerTaskTools(server, ctx) {
|
||||
const userId = ctx.userId
|
||||
|
||||
tool(server, 'list_tasks', {
|
||||
title: '查询任务',
|
||||
description: '查询项目任务列表 (默认只看未归档顶层任务, 含子任务), 支持按列、状态、关键词过滤。',
|
||||
inputSchema: {
|
||||
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||
columnId: z.coerce.number().int().positive().optional().describe('只看某一列'),
|
||||
status: z.enum(STATUSES).optional().describe('只看某一状态'),
|
||||
keyword: z.string().optional().describe('标题关键词'),
|
||||
includeArchived: z.boolean().optional().describe('是否包含已归档任务, 默认 false'),
|
||||
limit: z.coerce.number().int().positive().max(500).optional().describe('最多返回条数, 默认 200'),
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ projectId, columnId, status, keyword, includeArchived = false, limit = 200 }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
const columns = await loadColumns(project.id)
|
||||
const where = { projectId: project.id, parentId: null }
|
||||
if (!includeArchived) where.archived = false
|
||||
if (columnId) where.columnId = Number(columnId)
|
||||
if (status) where.status = status
|
||||
if (keyword && String(keyword).trim()) where.title = { contains: String(keyword).trim() }
|
||||
|
||||
const rows = await prisma.task.findMany({
|
||||
where,
|
||||
include: TASK_INCLUDE,
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
take: limit,
|
||||
})
|
||||
const order = new Map(columns.map((c, i) => [c.id, i]))
|
||||
rows.sort((a, b) => (
|
||||
(order.get(a.columnId) ?? 9999) - (order.get(b.columnId) ?? 9999)
|
||||
|| a.sortOrder - b.sortOrder
|
||||
|| a.id - b.id
|
||||
))
|
||||
return { columns, items: rows.map(shapeTask), total: rows.length }
|
||||
})
|
||||
|
||||
tool(server, 'get_task', {
|
||||
title: '查询任务详情',
|
||||
description: '读取单个任务的完整信息, 含看板列、负责人、创建者、成员、标签和子任务。',
|
||||
inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') },
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ taskId }) => {
|
||||
const found = await loadTaskForUser(taskId, userId)
|
||||
if (!found) throw new Error('任务不存在或无权限')
|
||||
return { item: shapeTask(found.task) }
|
||||
})
|
||||
|
||||
tool(server, 'create_task', {
|
||||
title: '新建任务',
|
||||
description: '在项目中新建任务或子任务, 可指定列、状态、负责人、成员、标签、类型和时间标记。',
|
||||
inputSchema: {
|
||||
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||
title: z.string().describe('任务标题, 最多 30 字'),
|
||||
description: z.string().optional().describe('任务描述'),
|
||||
columnId: z.coerce.number().int().positive().optional().describe('看板列 ID, 默认第一列'),
|
||||
status: z.enum(STATUSES).optional().describe('任务状态, 默认跟随看板列绑定'),
|
||||
parentId: z.coerce.number().int().positive().optional().describe('父任务 ID (仅一级子任务)'),
|
||||
assigneeId: z.coerce.number().int().positive().nullable().optional().describe('负责人用户 ID; 不传默认创建者, null 表示不指派'),
|
||||
memberIds: z.array(z.coerce.number().int().positive()).optional().describe('任务成员用户 ID 列表'),
|
||||
tags: z.array(z.string()).optional().describe('标签列表'),
|
||||
kind: z.string().optional().describe('任务类型标签, 最多 20 字'),
|
||||
markDate: z.string().nullable().optional().describe('时间标记 YYYY-MM-DD'),
|
||||
extras: z.array(z.object({
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
})).optional().describe('扩展信息卡片'),
|
||||
sortOrder: z.coerce.number().int().optional().describe('排序值, 不传则追加到列末尾'),
|
||||
},
|
||||
}, async (args) => {
|
||||
const project = await requireProject(args.projectId, userId)
|
||||
const title = reqStr(args.title, '任务标题', 30)
|
||||
const columns = await loadColumns(project.id)
|
||||
if (!columns.length) throw new Error('项目没有可用的看板列')
|
||||
|
||||
let pid = null
|
||||
if (args.parentId) {
|
||||
const parent = await prisma.task.findUnique({ where: { id: Number(args.parentId) } })
|
||||
if (!parent) throw new Error('父任务不存在')
|
||||
if (parent.projectId !== project.id) throw new Error('子任务必须与父任务同项目')
|
||||
if (parent.parentId) throw new Error('仅支持一级子任务')
|
||||
pid = parent.id
|
||||
}
|
||||
|
||||
let columnId = args.columnId ? Number(args.columnId) : columns[0].id
|
||||
let targetCol = columns.find((c) => c.id === columnId)
|
||||
if (!targetCol) throw new Error('看板列不存在')
|
||||
let status = args.status || targetCol.boundStatus || 'todo'
|
||||
if (args.status !== undefined) {
|
||||
const r = resolveTaskWrite(
|
||||
{ status: targetCol.boundStatus || 'todo', columnId, parentId: pid, column: targetCol },
|
||||
{ status: args.status },
|
||||
columns,
|
||||
)
|
||||
status = r.status
|
||||
columnId = r.columnId
|
||||
targetCol = columns.find((c) => c.id === columnId) || targetCol
|
||||
}
|
||||
|
||||
const memberIds = await loadMemberIds(project.id)
|
||||
const memberSet = new Set(memberIds)
|
||||
let finalAssignee = null
|
||||
if (args.assigneeId === undefined) finalAssignee = memberSet.has(userId) ? userId : null
|
||||
else if (args.assigneeId !== null && args.assigneeId !== '') {
|
||||
const aid = Number(args.assigneeId)
|
||||
if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员')
|
||||
finalAssignee = aid
|
||||
}
|
||||
|
||||
let sortOrder = args.sortOrder
|
||||
if (sortOrder === undefined) {
|
||||
const agg = await prisma.task.aggregate({
|
||||
where: { projectId: project.id, columnId, parentId: pid },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
sortOrder = (agg._max.sortOrder ?? -1) + 1
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
uuid: genUuid(),
|
||||
projectId: project.id,
|
||||
columnId,
|
||||
parentId: pid,
|
||||
title,
|
||||
description: String(args.description || ''),
|
||||
kind: args.kind ? String(args.kind).trim().slice(0, 20) : null,
|
||||
markDate: parseMarkDate(args.markDate),
|
||||
status,
|
||||
doneAt: status === 'done' ? new Date() : null,
|
||||
assigneeId: finalAssignee,
|
||||
creatorId: userId,
|
||||
extras: (Array.isArray(args.extras) ? args.extras : []).map((e) => ({
|
||||
label: String(e?.label ?? '').slice(0, 40),
|
||||
value: String(e?.value ?? ''),
|
||||
url: e?.url ? String(e.url) : '',
|
||||
})),
|
||||
sortOrder,
|
||||
members: {
|
||||
create: withUuid((Array.isArray(args.memberIds) ? args.memberIds : [])
|
||||
.map(Number).filter((uid) => memberSet.has(uid))
|
||||
.map((uid) => ({ userId: uid }))),
|
||||
},
|
||||
tags: {
|
||||
create: withUuid((Array.isArray(args.tags) ? args.tags : [])
|
||||
.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
|
||||
.map((name) => ({ name: name.slice(0, 20) }))),
|
||||
},
|
||||
}
|
||||
const { value: row } = await withTaskNumber(project.id, (number) =>
|
||||
prisma.task.create({ data: { ...taskData, number }, include: TASK_INCLUDE }),
|
||||
)
|
||||
return { item: shapeTask(row) }
|
||||
})
|
||||
|
||||
tool(server, 'update_task', {
|
||||
title: '修改任务',
|
||||
description: '修改任务标题、描述、列、状态、负责人、成员、标签、类型、时间标记、父任务或排序值。',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().int().positive().describe('任务 ID'),
|
||||
title: z.string().optional().describe('新标题, 最多 30 字'),
|
||||
description: z.string().optional().describe('新描述'),
|
||||
columnId: z.coerce.number().int().positive().optional().describe('移动到看板列'),
|
||||
status: z.enum(STATUSES).optional().describe('任务状态'),
|
||||
assigneeId: z.coerce.number().int().positive().nullable().optional().describe('负责人用户 ID, null 取消指派'),
|
||||
memberIds: z.array(z.coerce.number().int().positive()).optional().describe('任务成员, 整体替换'),
|
||||
tags: z.array(z.string()).optional().describe('标签, 整体替换'),
|
||||
kind: z.string().nullable().optional().describe('任务类型标签, null 清空'),
|
||||
markDate: z.string().nullable().optional().describe('时间标记 YYYY-MM-DD, null 清空'),
|
||||
extras: z.array(z.object({
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
})).optional().describe('扩展信息, 整体替换'),
|
||||
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
||||
parentId: z.coerce.number().int().positive().nullable().optional().describe('父任务 ID, null 取消父子关系'),
|
||||
archived: z.boolean().optional().describe('归档/恢复任务'),
|
||||
},
|
||||
}, async (args) => {
|
||||
const found = await loadTaskForUser(args.taskId, userId)
|
||||
if (!found) throw new Error('任务不存在或无权限')
|
||||
const { task, project } = found
|
||||
const columns = await loadColumns(project.id)
|
||||
const data = {}
|
||||
|
||||
if (args.title !== undefined) data.title = reqStr(args.title, '任务标题', 30)
|
||||
if (args.description !== undefined) data.description = String(args.description)
|
||||
if (args.kind !== undefined) {
|
||||
const k = String(args.kind || '').trim().slice(0, 20)
|
||||
data.kind = k || null
|
||||
}
|
||||
if (args.markDate !== undefined) data.markDate = parseMarkDate(args.markDate)
|
||||
if (args.extras !== undefined) {
|
||||
data.extras = args.extras.map((e) => ({
|
||||
label: String(e?.label ?? '').slice(0, 40),
|
||||
value: String(e?.value ?? ''),
|
||||
url: e?.url ? String(e.url) : '',
|
||||
}))
|
||||
}
|
||||
if (args.sortOrder !== undefined) data.sortOrder = Number(args.sortOrder) || 0
|
||||
if (args.archived !== undefined) {
|
||||
data.archived = !!args.archived
|
||||
data.archivedAt = args.archived ? new Date() : null
|
||||
data.archiveSource = args.archived ? 'manual' : null
|
||||
}
|
||||
|
||||
const memberIds = await loadMemberIds(project.id)
|
||||
const memberSet = new Set(memberIds)
|
||||
if (args.assigneeId !== undefined) {
|
||||
if (args.assigneeId === null || args.assigneeId === '') data.assigneeId = null
|
||||
else {
|
||||
const aid = Number(args.assigneeId)
|
||||
if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员')
|
||||
data.assigneeId = aid
|
||||
}
|
||||
}
|
||||
|
||||
const patch = {}
|
||||
if (args.columnId !== undefined) patch.columnId = Number(args.columnId)
|
||||
if (args.status !== undefined) patch.status = args.status
|
||||
if (patch.columnId !== undefined && !columns.some((c) => c.id === patch.columnId)) {
|
||||
throw new Error('看板列不存在')
|
||||
}
|
||||
|
||||
let finalStatus = task.status
|
||||
if (Object.keys(patch).length) {
|
||||
const r = resolveTaskWrite(task, patch, columns)
|
||||
data.status = r.status
|
||||
data.columnId = r.columnId
|
||||
finalStatus = r.status
|
||||
}
|
||||
if (finalStatus === 'done' && task.status !== 'done') data.doneAt = new Date()
|
||||
else if (finalStatus !== 'done' && data.status !== undefined) data.doneAt = null
|
||||
|
||||
if (Array.isArray(args.memberIds)) {
|
||||
const ids = args.memberIds.map(Number).filter((uid) => memberSet.has(uid))
|
||||
await prisma.taskMember.deleteMany({ where: { taskId: task.id } })
|
||||
if (ids.length) {
|
||||
await prisma.taskMember.createMany({
|
||||
data: withUuid(ids.map((uid) => ({ taskId: task.id, userId: uid }))),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (Array.isArray(args.tags)) {
|
||||
await prisma.taskTag.deleteMany({ where: { taskId: task.id } })
|
||||
const names = args.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
|
||||
if (names.length) {
|
||||
await prisma.taskTag.createMany({
|
||||
data: withUuid(names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) }))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (args.parentId !== undefined) {
|
||||
if (args.parentId === null) data.parentId = null
|
||||
else {
|
||||
const pid = Number(args.parentId)
|
||||
if (pid === task.id) throw new Error('不能成为自己的子任务')
|
||||
const parent = await prisma.task.findUnique({ where: { id: pid } })
|
||||
if (!parent) throw new Error('目标任务不存在')
|
||||
if (parent.projectId !== task.projectId) throw new Error('子任务必须与父任务同项目')
|
||||
if (parent.parentId) throw new Error('仅支持一级子任务')
|
||||
const childCount = await prisma.task.count({ where: { parentId: task.id } })
|
||||
if (childCount > 0) throw new Error('该任务已有子任务,不能成为子任务')
|
||||
data.parentId = pid
|
||||
}
|
||||
}
|
||||
|
||||
let row = await prisma.task.update({ where: { id: task.id }, data, include: TASK_INCLUDE })
|
||||
|
||||
// 归档/恢复连同子任务, 与 archive_task / unarchive_task 口径一致
|
||||
if (args.archived !== undefined) {
|
||||
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
|
||||
if (children.length) {
|
||||
await prisma.task.updateMany({
|
||||
where: { id: { in: children.map((c) => c.id) } },
|
||||
data: {
|
||||
archived: !!args.archived,
|
||||
archivedAt: args.archived ? new Date() : null,
|
||||
archiveSource: args.archived ? 'manual' : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
// 子任务状态变化后重取, 保证返回值与库内一致
|
||||
row = await prisma.task.findUnique({ where: { id: task.id }, include: TASK_INCLUDE })
|
||||
}
|
||||
return { item: shapeTask(row) }
|
||||
})
|
||||
|
||||
tool(server, 'delete_task', {
|
||||
title: '删除任务',
|
||||
description: '永久删除任务; 其子任务会一并级联删除。',
|
||||
inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') },
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ taskId }) => {
|
||||
const found = await loadTaskForUser(taskId, userId)
|
||||
if (!found) throw new Error('任务不存在或无权限')
|
||||
const childCount = await prisma.task.count({ where: { parentId: found.task.id } })
|
||||
await prisma.task.delete({ where: { id: found.task.id } })
|
||||
return { ok: true, deletedSubtasks: childCount }
|
||||
})
|
||||
|
||||
tool(server, 'archive_task', {
|
||||
title: '归档任务',
|
||||
description: '归档任务并连带全部子任务; 已归档时重复调用无副作用。',
|
||||
inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') },
|
||||
}, async ({ taskId }) => {
|
||||
const found = await loadTaskForUser(taskId, userId)
|
||||
if (!found) throw new Error('任务不存在或无权限')
|
||||
const children = await prisma.task.findMany({ where: { parentId: found.task.id }, select: { id: true } })
|
||||
const r = await prisma.task.updateMany({
|
||||
where: { id: { in: [found.task.id, ...children.map((c) => c.id)] } },
|
||||
data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' },
|
||||
})
|
||||
return { ok: true, count: r.count }
|
||||
})
|
||||
|
||||
tool(server, 'unarchive_task', {
|
||||
title: '恢复任务',
|
||||
description: '把归档任务恢复到原列, 状态不变; 连带恢复子任务。',
|
||||
inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') },
|
||||
}, async ({ taskId }) => {
|
||||
const found = await loadTaskForUser(taskId, userId)
|
||||
if (!found) throw new Error('任务不存在或无权限')
|
||||
const children = await prisma.task.findMany({ where: { parentId: found.task.id }, select: { id: true } })
|
||||
const r = await prisma.task.updateMany({
|
||||
where: { id: { in: [found.task.id, ...children.map((c) => c.id)] } },
|
||||
data: { archived: false, archivedAt: null, archiveSource: null },
|
||||
})
|
||||
return { ok: true, count: r.count }
|
||||
})
|
||||
|
||||
tool(server, 'reorder_tasks', {
|
||||
title: '任务排序',
|
||||
description: '按传入的任务 ID 数组重写排序 (数组顺序即新顺序)。传 columnId 时只重排该列内的任务。',
|
||||
inputSchema: {
|
||||
projectId: z.coerce.number().int().positive().describe('项目 ID'),
|
||||
ids: z.array(z.coerce.number().int().positive()).describe('任务 ID 的新顺序'),
|
||||
columnId: z.coerce.number().int().positive().optional().describe('仅重排该看板列内的任务'),
|
||||
},
|
||||
}, async ({ projectId, ids, columnId }) => {
|
||||
const project = await requireProject(projectId, userId)
|
||||
const uniq = [...new Set(ids.map(Number))]
|
||||
if (!uniq.length) throw new Error('ids 不能为空')
|
||||
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
||||
|
||||
const where = { projectId: project.id, id: { in: uniq } }
|
||||
if (columnId) where.columnId = Number(columnId)
|
||||
else where.parentId = null
|
||||
const rows = await prisma.task.findMany({ where, select: { id: true } })
|
||||
if (rows.length !== uniq.length) throw new Error('包含无效的任务或不属于指定列')
|
||||
|
||||
await prisma.$transaction(
|
||||
ids.map((tid, idx) => prisma.task.update({ where: { id: Number(tid) }, data: { sortOrder: idx } })),
|
||||
)
|
||||
return { ok: true, orderedIds: ids.map(Number) }
|
||||
})
|
||||
|
||||
tool(server, 'copy_task', {
|
||||
title: '拷贝任务',
|
||||
description: '把任务及子任务拷贝到另一个项目; 非目标项目成员会被自动丢弃。',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().int().positive().describe('源任务 ID'),
|
||||
projectId: z.coerce.number().int().positive().describe('目标项目 ID'),
|
||||
},
|
||||
}, async ({ taskId, projectId }) => {
|
||||
const found = await loadTaskForUser(taskId, userId)
|
||||
if (!found) throw new Error('任务不存在或无权限')
|
||||
const targetProject = await requireProject(projectId, userId)
|
||||
const targetColumns = await loadColumns(targetProject.id)
|
||||
if (!targetColumns.length) throw new Error('目标项目没有可用的看板列')
|
||||
const targetMemberIds = await loadMemberIds(targetProject.id)
|
||||
const sourceColumns = await loadColumns(found.project.id)
|
||||
const source = { ...found.task, __columns: sourceColumns }
|
||||
|
||||
const result = await copyTask({
|
||||
source, targetProject, targetColumns, operatorId: userId, targetMemberIds,
|
||||
})
|
||||
const fresh = await prisma.task.findUnique({ where: { id: result.task.id }, include: TASK_INCLUDE })
|
||||
return { item: shapeTask(fresh), dropped: result.dropped }
|
||||
})
|
||||
}
|
||||
|
||||
export function registerTaskToolsGroup(server, ctx) {
|
||||
registerProjectTools(server, ctx)
|
||||
registerColumnTools(server, ctx)
|
||||
registerTaskTools(server, ctx)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// AI 接入 (MCP): 个人授权 token 管理
|
||||
import { api, getApiBase } from './client.js'
|
||||
|
||||
export const mcpTokenApi = {
|
||||
list: () => api('/mcp-tokens'),
|
||||
create: (name) => api('/mcp-tokens', { method: 'POST', body: { name } }),
|
||||
rename: (id, name) => api(`/mcp-tokens/${id}`, { method: 'PATCH', body: { name } }),
|
||||
remove: (id) => api(`/mcp-tokens/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
/** MCP 端点完整地址 (随 API 基址变化) */
|
||||
export function getMcpEndpoint() {
|
||||
const base = getApiBase()
|
||||
if (base) return base.replace(/\/+$/, '') + '/api/mcp'
|
||||
if (typeof window !== 'undefined') return window.location.origin + '/api/mcp'
|
||||
return '/api/mcp'
|
||||
}
|
||||
@@ -1,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,6 +109,34 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
||||
<span className="tab-more-arrow">›</span>
|
||||
</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}>
|
||||
@@ -85,6 +144,7 @@ export default function SwipeTabs({ tabs, active, onChange }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from 'react'
|
||||
import scrollIntoView from 'scroll-into-view-if-needed'
|
||||
|
||||
// 全局兜底: 输入框获得焦点后保证它滚进可视区。
|
||||
//
|
||||
// 原生层 (scripts/ci/android-softinput.sh) 已经把软键盘高度从 WebView 里扣掉,
|
||||
// 弹层 (position: fixed; bottom: 0) 会自动上移; 这里只负责把被聚焦的控件
|
||||
// 滚到可视区中间, 覆盖页面滚动、Popup 内部滚动等所有场景。
|
||||
//
|
||||
// 用 scroll-into-view-if-needed (成熟库) 而不是手写滚动计算:
|
||||
// 它会自动处理嵌套滚动容器, 并在目标已可见时不做多余滚动。
|
||||
const FIELD_SELECTOR = 'input, textarea, [contenteditable="true"]'
|
||||
|
||||
function isField(el) {
|
||||
return !!el && typeof el.matches === 'function' && el.matches(FIELD_SELECTOR)
|
||||
}
|
||||
|
||||
export default function useKeepInputVisible() {
|
||||
useEffect(() => {
|
||||
let timer = 0
|
||||
|
||||
const reveal = (el, behavior) => {
|
||||
if (!isField(el)) return
|
||||
scrollIntoView(el, {
|
||||
behavior,
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
scrollMode: 'if-needed',
|
||||
})
|
||||
}
|
||||
|
||||
const onFocusIn = (e) => {
|
||||
const el = e.target
|
||||
if (!isField(el)) return
|
||||
// 先滚一次 (部分机型聚焦后立刻可用), 等键盘动画结束后再校正一次
|
||||
reveal(el, 'smooth')
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => reveal(el, 'smooth'), 260)
|
||||
}
|
||||
|
||||
// 键盘弹出/收起会改变可视区高度 (原生层已让 WebView 变矮)
|
||||
const onViewportResize = () => {
|
||||
const el = document.activeElement
|
||||
if (!isField(el)) return
|
||||
reveal(el, 'auto')
|
||||
}
|
||||
|
||||
document.addEventListener('focusin', onFocusIn)
|
||||
window.addEventListener('resize', onViewportResize)
|
||||
window.visualViewport?.addEventListener('resize', onViewportResize)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
document.removeEventListener('focusin', onFocusIn)
|
||||
window.removeEventListener('resize', onViewportResize)
|
||||
window.visualViewport?.removeEventListener('resize', onViewportResize)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import 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
@@ -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,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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: '' }
|
||||
@@ -255,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">
|
||||
{/* 月历 */}
|
||||
@@ -440,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] }))}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user