0.2.1 onlyoffice修复
This commit is contained in:
+3
-1
@@ -23,6 +23,8 @@ temp/
|
|||||||
.venv/
|
.venv/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
# Convex 本机开发临时目录
|
||||||
|
**/.convex-tmp/
|
||||||
# 大模型文件,防止超出 LFS 配额
|
# 大模型文件,防止超出 LFS 配额
|
||||||
services/mineru/models/
|
services/mineru/models/
|
||||||
*.safetensors
|
*.safetensors
|
||||||
@@ -42,4 +44,4 @@ cankao/
|
|||||||
pw-tests/**
|
pw-tests/**
|
||||||
pw-tests/
|
pw-tests/
|
||||||
wolai-frontend/public/documents/
|
wolai-frontend/public/documents/
|
||||||
wolai-frontend/public/documents/**
|
wolai-frontend/public/documents/**
|
||||||
|
|||||||
Submodule
+1
Submodule Coder-Codex-Gemini added at 732de60740
@@ -0,0 +1,358 @@
|
|||||||
|
可以,按你选的 1A/2B/3B,我建议的落地方案是:
|
||||||
|
|
||||||
|
- Convex:自托管 Docker Compose(官方)
|
||||||
|
- 对象存储:MinIO(S3 兼容)
|
||||||
|
- 鉴权:固定“开发用户”(仅开发态),所有权限校验先按这个用户走
|
||||||
|
- 后端:不再以 FastAPI/Celery 为主链路,长任务改为 Convex action + scheduler;如后续需要 OCR/RAG 重计算,再让 Convex
|
||||||
|
action 去调用现有 services/* HTTP 服务
|
||||||
|
|
||||||
|
下面是我建议的“可执行迁移流程设计”(按阶段推进,每一阶段都能跑通):
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
## 一、部署方案(Windows 本机)
|
||||||
|
|
||||||
|
### A. 运行组件
|
||||||
|
|
||||||
|
1. convex-backend(端口:3210 + 3211 actions)
|
||||||
|
2. convex-dashboard(端口:6791)
|
||||||
|
3. minio(端口:9000 S3 API,9001 Console)
|
||||||
|
4. minio-init(启动时自动建 bucket)
|
||||||
|
|
||||||
|
### B. 目录规划(建议)
|
||||||
|
|
||||||
|
- infra/convex/
|
||||||
|
- docker-compose.yml(Convex backend + dashboard + MinIO)
|
||||||
|
- .env(只放本机,不提交;或提交 .env.example)
|
||||||
|
- README.md(写清启动端口与初始化)
|
||||||
|
|
||||||
|
### C. 关键环境变量(Convex backend 使用 MinIO 作为 S3 存储)
|
||||||
|
|
||||||
|
Convex 自托管支持把导出/导入/模块/用户文件/搜索索引都放到 S3,并允许通过 S3_ENDPOINT_URL 连接 MinIO。你将配置这些
|
||||||
|
bucket(示例名):
|
||||||
|
|
||||||
|
- convex-snapshot-exports
|
||||||
|
- convex-snapshot-imports
|
||||||
|
- convex-modules
|
||||||
|
- convex-user-files
|
||||||
|
- convex-search-indexes
|
||||||
|
|
||||||
|
并在 backend 容器里设置(示例):
|
||||||
|
|
||||||
|
- AWS_REGION=us-east-1
|
||||||
|
- AWS_ACCESS_KEY_ID=<minio key>
|
||||||
|
- AWS_SECRET_ACCESS_KEY=<minio secret>
|
||||||
|
- S3_ENDPOINT_URL=http://minio:9000
|
||||||
|
- S3_STORAGE_EXPORTS_BUCKET=...(以及其余 4 个)
|
||||||
|
|
||||||
|
> 这样后续你在 Convex 里用文件能力(上传/存储/取 URL)会自然落到 MinIO,不需要再引入 Supabase Storage 或自写一套 presigned > 上传逻辑。
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
## 二、迁移总体策略(符合“先跑通但都换掉”)
|
||||||
|
|
||||||
|
你的项目目前最大的问题不是“后端”,而是数据访问散落在 Next Route Handlers + Supabase。迁移应以 “把 Supabase 数据面替换成
|
||||||
|
Convex” 为主线,同时把鉴权先简化为固定用户。
|
||||||
|
|
||||||
|
我建议采用“门面不变、内核替换”的方式:
|
||||||
|
|
||||||
|
- 保留现有 wolai-frontend/src/app/api/**/route.ts 路由不变(前端 UI 不用立刻大改)
|
||||||
|
- 逐个把这些 route handler 内部从 supabase.* 改为 convex query/mutation/action
|
||||||
|
- 用一个总开关 USE_CONVEX=1 控制,方便随时切回 Supabase 对照(你没有用户数据,回退成本也接近 0)
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
## 三、阶段化详细流程(每阶段验收点明确)
|
||||||
|
|
||||||
|
### 阶段 0:盘点与冻结范围(半天内)
|
||||||
|
|
||||||
|
目标:明确“哪些模块先迁、哪些先不动”,避免一次改爆。
|
||||||
|
|
||||||
|
- 以 CODE_INDEX.md 为基准,确认实际运行入口是 wolai-frontend/(不是根目录 src/)。
|
||||||
|
- 先只迁:documents / workspaces / workspace_members / mindmap / background_tasks(足够跑主流程)
|
||||||
|
- 暂不迁或延后:luckysheet_ws(你现在也没实际测试/用户)
|
||||||
|
|
||||||
|
验收:列出一张迁移模块清单(我可以直接在仓库里生成 infra/convex/MIGRATION.md,你确认后再动代码)。
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
### 阶段 1:Convex + MinIO 跑起来(不改业务代码)
|
||||||
|
|
||||||
|
目标:本机基础设施 ready。
|
||||||
|
|
||||||
|
- 在 infra/convex/ 放置 compose,docker compose up -d
|
||||||
|
- 生成 admin key:docker compose exec backend ./generate_admin_key.sh
|
||||||
|
- 在 wolai-frontend/.env.local 配:
|
||||||
|
- CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210
|
||||||
|
- CONVEX_SELF_HOSTED_ADMIN_KEY=...
|
||||||
|
- USE_CONVEX=1
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Dashboard 能打开:http://localhost:6791
|
||||||
|
- MinIO Console 能打开:http://localhost:9001
|
||||||
|
- Convex CLI 能连上自托管(下一阶段会做)
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
### 阶段 2:在 wolai-frontend/ 初始化 Convex 项目(最小 demo)
|
||||||
|
|
||||||
|
目标:让前端工程具备 convex/ 目录与生成类型的能力。
|
||||||
|
|
||||||
|
- 安装 convex 依赖
|
||||||
|
- 初始化 wolai-frontend/convex/(schema + demo function)
|
||||||
|
- 跑一次 npx convex dev(自托管模式,指向 CONVEX_SELF_HOSTED_URL)
|
||||||
|
|
||||||
|
验收:写一个 ping query,Next.js 页面/route 能调用到并返回结果。
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
### 阶段 3:引入“固定开发用户”与权限骨架(替代 Supabase Auth/RLS)
|
||||||
|
|
||||||
|
目标:所有数据访问都通过同一套“开发用户上下文”注入,避免到处散落假逻辑。
|
||||||
|
建议实现方式:
|
||||||
|
|
||||||
|
- wolai-frontend/src/lib/auth/devUser.ts:
|
||||||
|
- getDevUser() 固定返回 { userId, email, name }(从 .env.local 读取,默认常量)
|
||||||
|
- Convex functions 不接受“任意 userId 参数”,而是由 route handler 统一注入(现在固定,未来替换为真实 auth)
|
||||||
|
|
||||||
|
权限策略(先最小化):
|
||||||
|
|
||||||
|
- 所有写操作:要求 workspace_members 存在(你可以先自动把 dev 用户加入默认 workspace)
|
||||||
|
- 所有读操作:同上
|
||||||
|
- 未来接入真实 auth 时,只需要把 getDevUser() 替换为 getAuthedUser(),Convex 侧的权限函数不变
|
||||||
|
|
||||||
|
验收:不依赖 Supabase token,也能跑通“创建默认 workspace + 创建文档”。
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
### 阶段 4:迁移核心数据模型与最小 CRUD(documents/workspaces)
|
||||||
|
|
||||||
|
目标:主流程跑通(侧边栏、打开文档、保存)。
|
||||||
|
|
||||||
|
- 在 Convex schema 中创建对应集合(建议保留你现有 UUID 作为业务 id 字段,并建立唯一索引,避免 URL/引用大改)
|
||||||
|
- 实现 queries/mutations:
|
||||||
|
- workspaces.getOrCreateDefaultForUser
|
||||||
|
- documents.listByWorkspace
|
||||||
|
- documents.getById
|
||||||
|
- documents.create
|
||||||
|
- documents.updateContent(含 raw_text/index_status 等你现在写回字段)
|
||||||
|
|
||||||
|
然后改 wolai-frontend/src/app/api/documents/**/route.ts 内部实现:
|
||||||
|
|
||||||
|
- 先只改:create/content/save/title/options/list 等最常用路径
|
||||||
|
- 暂时保留 Supabase 版本分支(USE_CONVEX 开关)
|
||||||
|
|
||||||
|
验收:你打开应用后能创建/读取/保存文档(数据落在 Convex),且不再访问 Supabase 表。
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
### 阶段 5:迁移 mindmap 与任务(替换 Celery/trigger 的思路)
|
||||||
|
|
||||||
|
目标:把“异步/任务/进度”统一到 Convex。
|
||||||
|
|
||||||
|
- background_tasks:做成 Convex 表
|
||||||
|
- startOcr:mutation 创建任务记录,然后 scheduler.runAfter() 触发 action
|
||||||
|
- ocrPipeline:action 模拟 OCR(对齐你现在 wolai-backend/app/workers/tasks.py 的行为:更新进度、回写文档内容、任务
|
||||||
|
completed)
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 前端调用“OCR/任务”相关 API 能返回 taskId
|
||||||
|
- 任务状态能轮询/订阅(先轮询即可)
|
||||||
|
- 不再需要 wolai-backend 的 Celery 才能完成基本链路
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
### 阶段 6:文件存储(MinIO + Convex file storage)
|
||||||
|
|
||||||
|
目标:后续做媒体/附件时不会再回到 Supabase Storage。
|
||||||
|
|
||||||
|
- 保持 Convex backend 已配置 S3(MinIO)
|
||||||
|
- 在 Convex 里实现:
|
||||||
|
- files.generateUploadUrl
|
||||||
|
- files.saveMeta(把文件与 document/workspace 关联)
|
||||||
|
- files.getUrl(用于展示/下载)
|
||||||
|
|
||||||
|
验收:能上传一个文件到 Convex file storage(底层落 MinIO),并在页面拿到可访问 URL。
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
到目前为止,你已经跑通了“文档/附件 + Convex 自部署”这条主链路;接下来要做的事,本质上是:把
|
||||||
|
USE_CONVEX=1 时仍在走 Supabase 的那些模块逐步“替换或下线”,直到项目真的只依赖 Convex。
|
||||||
|
|
||||||
|
- 先确认“还剩哪些 Supabase 依赖”
|
||||||
|
- wolai-frontend/src/ 下仍有大量 Supabase 路由/工具在用:mindmap、references(RPC)、onlyoffice、
|
||||||
|
luckysheet、ai-agent、sidebar、search(recent) 等(目前大概率是“Supabase 分支/兜底分支”)。
|
||||||
|
- services/ingest_service/ 仍通过 Supabase REST 做任务/索引/清理(属于后续 RAG/索引链路)。
|
||||||
|
- 根目录 src/ 也还有 Supabase 用法(更像历史/备用 Next 工程,若不参与桌面构建可先不动)。
|
||||||
|
- 按你“尽量一体化绑死 Convex”的优先级,建议下一步这样排
|
||||||
|
1. 鉴权/权限从“固定用户”升级为可扩展的真实方案:否则后面所有“按用户/工作区隔离”都只能靠约定。
|
||||||
|
Convex Auth 是官方路线之一,但对 Next.js server 侧支持仍在演进中,需要你接受一定不稳定/适配成
|
||||||
|
本。citeturn0search1
|
||||||
|
2. 把仍依赖 Supabase Storage 的功能全部切到 Convex Files:你已经验证 Dashboard Files 可见;下一步
|
||||||
|
是把 luckysheet/onlyoffice 等涉及上传/签名 URL 的地方也迁掉(或临时 501 下线),保证
|
||||||
|
USE_CONVEX=1 时不会再触发 Supabase Storage。citeturn0search5
|
||||||
|
3. 把“搜索/索引/推荐”从 Postgres/RPC 思路迁到 Convex:文档搜索走 Convex 全文检索;RAG/embedding 走 Vector Search(注意向量检索需要在 action 里跑)。citeturn0search2turn0search4
|
||||||
|
4. 把 services/ingest_service 的“队列/任务状态机”迁出 Supabase:要么先停用该服务;要么改成 Convex
|
||||||
|
内部的任务表 + actions + scheduler(这样系统更一体化)。
|
||||||
|
5. 做运维闭环(自部署必需):明确 Docker 卷备份/恢复策略(Convex 也在推进更易用的数据备份/恢复能
|
||||||
|
力,但你仍应以卷级备份为底线)。citeturn0search3
|
||||||
|
- 关于“Convex 组件”与你项目的适配结论
|
||||||
|
- Convex “Components”适合把通用能力(比如协作编辑、鉴权、工作流)做成可插拔模块,并且支持隔离/复
|
||||||
|
用;你的项目属于“练手快速迭代”,很适合用组件化方式逐块替换 Supabase 逻辑。citeturn1search0
|
||||||
|
- Files 这一块:Convex 的 Files 更像“一个大池子/大桶”,不强调文件夹;你要的“按用户/工作区区分、路
|
||||||
|
径/目录视图”,仍建议用业务表字段(workspace_id、user_id、path)来实现管理与展示,这是最贴合你“一 体化 + 不引入外部对象存储”的路线。citeturn0search5
|
||||||
|
checklist:
|
||||||
|
- M1(已完成):documents/workspaces/media/search 全链路 Convex 化 + 冒烟回归
|
||||||
|
- M2:Mindmap 数据与接口迁移(/api/mindmap/**、/api/mindmap-trash/empty),落到 Convex(优先复用
|
||||||
|
documents.mindmap_data)
|
||||||
|
- M3:References(页面引用/反链)迁移(/api/references/record、/api/references/backlinks),补齐目前的 占位实现
|
||||||
|
- M4:AI Agent(/api/ai-agent/run、/api/ai-agent/client-tool-result)去 Supabase 化(鉴权/读写文档/工
|
||||||
|
具回调)
|
||||||
|
- M5:Online Table / Luckysheet(/api/tables/**、/api/luckysheet/**)迁移或在 Convex 模式下先禁用(给
|
||||||
|
出明确 UI 提示)
|
||||||
|
- M6:OnlyOffice(/api/onlyoffice/*)迁移或在 Convex 模式下先禁用(同上)
|
||||||
|
- M7:服务侧(services/ingest_service 等)去 Supabase 化:任务表/状态机迁到 Convex jobs/actions(或先
|
||||||
|
停用该链路)
|
||||||
|
- M8:鉴权从“固定用户”升级为可扩展方案(仍保持 Convex 一体化)
|
||||||
|
- M9:运维闭环:Convex 数据/Files 卷备份恢复、日志与健康检查、启动脚本收敛
|
||||||
|
|
||||||
|
我刚做完的(你现在的代码状态)
|
||||||
|
|
||||||
|
- 补齐 documents 的最后缺口:wolai-frontend/src/app/api/documents/embed/route.ts:1 已支持 Convex。
|
||||||
|
- 抽了一个路由侧通用 helper:wolai-frontend/src/lib/convex/route.ts:1(统一拿 auth+client)。
|
||||||
|
- “最近访问”已落库到 Convex:wolai-frontend/convex/schema.ts:1 新增 user_recent_pages;wolai-frontend/ convex/recents.ts:1 + 接入 wolai-frontend/src/app/api/search/recent/route.ts:1、wolai-frontend/src/
|
||||||
|
app/api/search/documents/route.ts:1。
|
||||||
|
- 回归脚本已扩展并跑通:pw-tests/scripts/e2e_convex_smoke.py:1;最新产物 pw-tests/artifacts/convex-
|
||||||
|
smoke-20260116-194415.png:1、pw-tests/artifacts/convex-smoke-console-20260116-194415.log:1。
|
||||||
|
|
||||||
|
接下来我建议从 M2(Mindmap)开始做:它目前是 Convex 模式下仍“纯 Supabase 路由”的最大功能块。你希望优先
|
||||||
|
做 Mindmap,还是先做 AI Agent/OnlyOffice/表格?
|
||||||
|
|
||||||
|
———
|
||||||
|
|
||||||
|
## 补充规格:编辑器“移动/嵌入到...”按 wolai 机制复刻(同页嵌入先禁止)
|
||||||
|
|
||||||
|
### 0. 结论(本次明确的产品语义)
|
||||||
|
|
||||||
|
- “嵌入到...” = **块引用(同步编辑)**:目标页面插入“嵌入引用块”,引用源块;源块仍留在原位置。
|
||||||
|
- “移动到...” = **移动块本体**:源块(含子树)从当前页面移到目标页面;块 ID 保持不变。
|
||||||
|
- **同页嵌入先禁止**:当目标页面 = 源块所在页面时,禁止“嵌入到...”(避免递归/复杂边界)。
|
||||||
|
|
||||||
|
> 说明:当前代码里(CustomSideMenu/sidebar)做的是“把块变成子页面 + 插入 pageReference”,这不等价于 wolai 的块引用/块移动,需要整体重做。
|
||||||
|
|
||||||
|
### 1. 术语
|
||||||
|
|
||||||
|
- 源块(sourceBlock):用户在编辑器里选中的那个块(BlockNote block),有稳定 `blockId`。
|
||||||
|
- 源页面(sourceDoc):包含源块的页面(document)。
|
||||||
|
- 目标页面(targetDoc):用户在弹窗里选择的页面。
|
||||||
|
- 块引用(blockReference):一种特殊块,指向 `targetBlockId`,显示/编辑代理到源块。
|
||||||
|
|
||||||
|
### 2. 行为规格(可直接转成 pw-tests 验收点)
|
||||||
|
|
||||||
|
#### 2.1 “移动到...”(Move)
|
||||||
|
|
||||||
|
- 触发:块左侧拖拽菜单 → “移动/嵌入到...” → 选“移动到” → 选目标页面。
|
||||||
|
- 结果:
|
||||||
|
- 源页面:源块(含 children 子树)消失。
|
||||||
|
- 目标页面:插入源块子树(MVP 先插在末尾)。
|
||||||
|
- 不变量:
|
||||||
|
- 源块 `blockId` 不变(未来引用/链接仍指向同一块)。
|
||||||
|
- 子树结构保持不变(children 仍挂在源块下)。
|
||||||
|
- 限制:
|
||||||
|
- 选择目标页面为当前页面:视为 no-op(提示“已在当前页面”或直接关闭)。
|
||||||
|
- 无权限/不存在:失败提示。
|
||||||
|
|
||||||
|
#### 2.2 “嵌入到...”(Embed)
|
||||||
|
|
||||||
|
- 触发:块左侧拖拽菜单 → “移动/嵌入到...” → 切换“嵌入到” → 选目标页面。
|
||||||
|
- 结果:
|
||||||
|
- 源页面:源块保持原位。
|
||||||
|
- 目标页面:新增一个 `blockReference`(display=embed),`targetBlockId = 源块.blockId`(MVP 先插在末尾)。
|
||||||
|
- 同步编辑:
|
||||||
|
- 在目标页面的嵌入引用中编辑内容,实际修改的是源块(刷新源页面可见变化)。
|
||||||
|
- 删除语义:
|
||||||
|
- 删除目标页面里的引用块,只移除“引用”,不删除源块本体。
|
||||||
|
- 跳转语义(先对齐 wolai 思路,后续可微调):
|
||||||
|
- 嵌入引用块本体不强制“点击跳转”;但在块菜单提供“跳转到原块”动作。
|
||||||
|
- 限制:
|
||||||
|
- **同页嵌入禁止**:`targetDocId === sourceDocId` 时直接禁止(UI 禁用 + API 双重校验)。
|
||||||
|
- 无权限/不存在:失败提示。
|
||||||
|
|
||||||
|
### 3. 数据结构设计(MVP,兼容你当前“整页 content JSON”)
|
||||||
|
|
||||||
|
#### 3.1 新增块类型:blockReference(区分于 pageReference)
|
||||||
|
|
||||||
|
- `type: "blockReference"`
|
||||||
|
- `props`(建议):
|
||||||
|
- `targetBlockId: string`(必填)
|
||||||
|
- `display: "inline" | "embed"`(MVP 用 embed)
|
||||||
|
- `alias?: string`(行内引用别名,后续再做)
|
||||||
|
|
||||||
|
#### 3.2 块索引(block_index)——用于从 blockId 反查所在页面
|
||||||
|
|
||||||
|
因为块仍存放在 `documents.content` 内(整页 JSON),为了实现:
|
||||||
|
- “跳转到原块”
|
||||||
|
- “嵌入引用渲染/编辑时找到源块”
|
||||||
|
|
||||||
|
需要一个索引集合(Convex 表):
|
||||||
|
- `block_index { blockId, documentId, workspaceId, updatedAt }`
|
||||||
|
|
||||||
|
维护方式(MVP):
|
||||||
|
- 每次保存页面 content 时(documents.save / documents.updateContent),解析 blocks,批量 upsert 索引。
|
||||||
|
- Move 操作需要同时更新源/目标页的索引(或依赖后续 save 再修正,但建议 move 立刻修正)。
|
||||||
|
|
||||||
|
#### 3.3 引用边(可选,但很有用)
|
||||||
|
|
||||||
|
- `reference_edges { sourceDocumentId, targetBlockId, createdAt }`
|
||||||
|
- 用途:反链面板(Backlinks)、统计、权限校验辅助。
|
||||||
|
|
||||||
|
### 4. API 设计(建议新增 blocks 维度接口,避免滥用 documents/embed)
|
||||||
|
|
||||||
|
> 目标:把“块移动/块引用”从“创建子页面 + pageReference”的错误语义中解耦出来。
|
||||||
|
|
||||||
|
#### 4.1 `POST /api/blocks/move`
|
||||||
|
|
||||||
|
- 入参:`{ sourceDocumentId, blockId, targetDocumentId, position?: "end" }`
|
||||||
|
- 行为:从 sourceDoc content 移除 block 子树,追加到 targetDoc content;更新索引。
|
||||||
|
|
||||||
|
#### 4.2 `POST /api/blocks/embed`
|
||||||
|
|
||||||
|
- 入参:`{ sourceDocumentId, blockId, targetDocumentId, position?: "end" }`
|
||||||
|
- 行为:校验非同页;在 targetDoc content 追加 `blockReference(targetBlockId=blockId, display="embed")`。
|
||||||
|
|
||||||
|
#### 4.3 `GET /api/blocks/get?blockId=...`
|
||||||
|
|
||||||
|
- 返回:`{ documentId, block, path? }`
|
||||||
|
- 用途:渲染引用块、跳转到原块、hover 预览(后续)。
|
||||||
|
|
||||||
|
#### 4.4 `POST /api/blocks/patch`(嵌入引用的同步编辑)
|
||||||
|
|
||||||
|
- 入参:`{ blockId, patch }`(MVP 可先做 `replaceBlock`:提交完整 block JSON)
|
||||||
|
- 行为:定位源块所在文档,修改该块内容并保存;广播刷新(后续可用 Convex 订阅优化)。
|
||||||
|
|
||||||
|
### 5. UI / 交互设计(与现有 MoveEmbedPickerDialog 的对接)
|
||||||
|
|
||||||
|
- 仍复用现有 `MoveEmbedPickerDialog` 做“选页面”能力。
|
||||||
|
- 在“嵌入到”模式下:
|
||||||
|
- Picker 直接排除当前页面(`excludeIds=[currentDocumentId]`),并在选中时二次校验。
|
||||||
|
- 行为完成后的反馈:
|
||||||
|
- Move:toast “已移动到 XXX”
|
||||||
|
- Embed:toast “已在目标页面末尾插入引用块”
|
||||||
|
|
||||||
|
### 6. pw-tests 验收用例(最小集合,锁定行为不跑偏)
|
||||||
|
|
||||||
|
- `move_basic`:A 页面块 → Move 到 B;断言 A 不存在、B 存在,且块 `blockId` 未变化。
|
||||||
|
- `embed_basic`:A 页面块 → Embed 到 B;断言 A 仍存在、B 出现 `blockReference(targetBlockId=...)`。
|
||||||
|
- `embed_edit_sync`:在 B 的嵌入引用中编辑 → 刷新 A → 断言源块同步变化。
|
||||||
|
- `embed_same_page_forbidden`:Embed 目标选择当前页 → UI 提示/不可选,且服务端拒绝。
|
||||||
|
- `embed_delete_only_reference`:删除 B 的引用块 → A 源块仍存在。
|
||||||
|
|
||||||
|
### 7. 迁移实施顺序(避免一次性大爆炸)
|
||||||
|
|
||||||
|
1) 先落地 `blockReference` 块类型与只读渲染(不做同步编辑)。
|
||||||
|
2) 上 `block_index` 并在保存 content 时维护;补齐 `GET /api/blocks/get`。
|
||||||
|
3) 实现 `POST /api/blocks/embed` + UI 接入(替换旧 documents/embed 的错误语义)。
|
||||||
|
4) 实现 `POST /api/blocks/move`(跨文档移动块子树)。
|
||||||
|
5) 最后做 `POST /api/blocks/patch`,实现嵌入引用的同步编辑与限制(删除语义/跳转)。
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Convex 自托管(Windows 本机)
|
||||||
|
|
||||||
|
本目录用于在本机通过 Docker Compose 启动 Convex backend + dashboard,并使用 backend 自身的持久化卷存储(包含数据库与文件存储)。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
1. 确保 `infra/convex/.env` 存在(已在本仓库中生成,且被 `.gitignore` 忽略)。
|
||||||
|
2. 在本目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生成 Dashboard / CLI admin key
|
||||||
|
|
||||||
|
在本目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env exec backend ./generate_admin_key.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 访问地址(默认)
|
||||||
|
|
||||||
|
- Dashboard:`http://localhost:6791`
|
||||||
|
- Backend:`http://127.0.0.1:3210`
|
||||||
|
- HTTP Actions:`http://127.0.0.1:3211`
|
||||||
|
- 文件存储:由 Convex 管理(Dashboard 的 Files 页面可查看)
|
||||||
|
|
||||||
|
## 快速验证(可选)
|
||||||
|
|
||||||
|
### 1) 验证 Convex functions 可用
|
||||||
|
|
||||||
|
在 `wolai-frontend/` 执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx convex dev --once --tail-logs disable --env-file .env.local --run ping:ping
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) 验证固定开发用户(阶段 3)
|
||||||
|
|
||||||
|
启动前端后访问:`http://localhost:3000/api/dev/whoami`
|
||||||
|
|
||||||
|
### 3) 验证异步任务骨架(阶段 5)
|
||||||
|
|
||||||
|
启动前端后:
|
||||||
|
- `POST http://localhost:3000/api/dev/jobs/demo`(body 可选:`{ "ms": 800 }`)创建 demo job
|
||||||
|
- `GET http://localhost:3000/api/dev/jobs/demo?id=<jobId>` 查询状态(queued/running/succeeded/failed)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
# 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。
|
||||||
|
image: ghcr.io/get-convex/convex-backend:latest
|
||||||
|
stop_grace_period: 10s
|
||||||
|
stop_signal: SIGINT
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3210}:3210"
|
||||||
|
- "${SITE_PROXY_PORT:-3211}:3211"
|
||||||
|
volumes:
|
||||||
|
- convex_data_v1:/convex/data
|
||||||
|
environment:
|
||||||
|
- ACTIONS_USER_TIMEOUT_SECS
|
||||||
|
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://127.0.0.1:${PORT:-3210}}
|
||||||
|
- CONVEX_RELEASE_VERSION_DEV
|
||||||
|
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://127.0.0.1:${SITE_PROXY_PORT:-3211}}
|
||||||
|
- DATABASE_URL
|
||||||
|
- DISABLE_BEACON
|
||||||
|
- DOCUMENT_RETENTION_DELAY=${DOCUMENT_RETENTION_DELAY:-172800} # 降低默认保留到 2 天
|
||||||
|
- DO_NOT_REQUIRE_SSL
|
||||||
|
- HTTP_SERVER_TIMEOUT_SECONDS
|
||||||
|
- INSTANCE_NAME
|
||||||
|
- INSTANCE_SECRET
|
||||||
|
- MYSQL_URL
|
||||||
|
- POSTGRES_URL
|
||||||
|
- REDACT_LOGS_TO_CLIENT
|
||||||
|
- RUST_BACKTRACE
|
||||||
|
- RUST_LOG=${RUST_LOG:-info}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
|
||||||
|
interval: 5s
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
image: ghcr.io/get-convex/convex-dashboard:latest
|
||||||
|
stop_grace_period: 10s
|
||||||
|
stop_signal: SIGINT
|
||||||
|
ports:
|
||||||
|
- "${DASHBOARD_PORT:-6791}:6791"
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://127.0.0.1:${PORT:-3210}}
|
||||||
|
- NEXT_PUBLIC_LOAD_MONACO_INTERNALLY
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
convex_data_v1:
|
||||||
+120
-1
@@ -12,7 +12,7 @@
|
|||||||
* - SKIP_CELERY:设为 "1" or "true" 可跳过 Celery。
|
* - SKIP_CELERY:设为 "1" or "true" 可跳过 Celery。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { spawn } = require("child_process");
|
const { spawn, execSync } = require("child_process");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const net = require("net");
|
const net = require("net");
|
||||||
const { URL } = require("url");
|
const { URL } = require("url");
|
||||||
@@ -29,6 +29,7 @@ const skipCelery =
|
|||||||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
|
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
|
||||||
const celeryCmdFromEnv = process.env.CELERY_CMD;
|
const celeryCmdFromEnv = process.env.CELERY_CMD;
|
||||||
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
|
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
|
||||||
|
const frontendPortFromEnv = Number(process.env.FRONTEND_PORT || 3000);
|
||||||
|
|
||||||
const tasks = [
|
const tasks = [
|
||||||
{
|
{
|
||||||
@@ -48,6 +49,95 @@ const tasks = [
|
|||||||
const children = [];
|
const children = [];
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
async function isPortFree(host, port, timeoutMs = 400) {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const socket = net.createConnection({ host, port });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(true);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
socket.once("connect", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.end();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFreePort(startPort) {
|
||||||
|
let port = Number.isFinite(startPort) ? Math.floor(startPort) : 3000;
|
||||||
|
port = Math.max(1, Math.min(65535, port));
|
||||||
|
|
||||||
|
// 最多尝试 20 个端口,避免无限循环。
|
||||||
|
for (let i = 0; i < 20; i += 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const free = await isPortFree("127.0.0.1", port);
|
||||||
|
if (free) return port;
|
||||||
|
port += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return startPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getListeningPidsByPort(port) {
|
||||||
|
try {
|
||||||
|
// 说明:netstat 输出示例:
|
||||||
|
// TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345
|
||||||
|
const out = execSync("netstat -ano -p tcp", { encoding: "utf8" });
|
||||||
|
const pids = new Set();
|
||||||
|
for (const line of out.split(/\r?\n/)) {
|
||||||
|
if (!line.includes(`:${port}`)) continue;
|
||||||
|
if (!/LISTENING/i.test(line)) continue;
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
const pid = Number(parts[parts.length - 1]);
|
||||||
|
if (Number.isFinite(pid) && pid > 0) {
|
||||||
|
pids.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...pids];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePortFree(port, nameForLog) {
|
||||||
|
const free = await isPortFree("127.0.0.1", port);
|
||||||
|
if (free) return true;
|
||||||
|
|
||||||
|
const pids = getListeningPidsByPort(port);
|
||||||
|
if (pids.length === 0) {
|
||||||
|
logPrefix(nameForLog, `检测到端口 ${port} 被占用,但无法定位 PID。`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logPrefix(nameForLog, `检测到端口 ${port} 被占用,准备重启(结束旧进程):${pids.join(", ")}`);
|
||||||
|
for (const pid of pids) {
|
||||||
|
try {
|
||||||
|
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待端口释放
|
||||||
|
for (let i = 0; i < 20; i += 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const ok = await isPortFree("127.0.0.1", port, 250);
|
||||||
|
if (ok) return true;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
logPrefix(nameForLog, `端口 ${port} 仍未释放,可能有其他程序占用。`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function loadEnvFile(filePath) {
|
function loadEnvFile(filePath) {
|
||||||
if (!fs.existsSync(filePath)) return {};
|
if (!fs.existsSync(filePath)) return {};
|
||||||
const content = fs.readFileSync(filePath, "utf8");
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
@@ -160,6 +250,35 @@ async function checkRedisReachable(urlString, timeoutMs = 2000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。
|
||||||
|
// 这里在启动前做一次“安全清理”,避免重启后仍卡住。
|
||||||
|
const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock");
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(nextDevLockPath)) {
|
||||||
|
fs.rmSync(nextDevLockPath, { force: true });
|
||||||
|
logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
|
||||||
|
// 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。
|
||||||
|
const desiredFrontendPort = frontendPortFromEnv;
|
||||||
|
const frontendPortOk = await ensurePortFree(desiredFrontendPort, "frontend");
|
||||||
|
if (!frontendPortOk) {
|
||||||
|
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const frontendPort = desiredFrontendPort;
|
||||||
|
const frontendUrl = `http://localhost:${frontendPort}`;
|
||||||
|
|
||||||
|
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
|
||||||
|
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
|
||||||
|
tasks[0].command = process.env.FRONTEND_CMD || `pnpm dev -p ${frontendPort}`;
|
||||||
|
logPrefix("frontend", `前端目录:${frontendDir}`);
|
||||||
|
logPrefix("frontend", `前端地址:${frontendUrl}`);
|
||||||
|
|
||||||
if (!skipCelery) {
|
if (!skipCelery) {
|
||||||
const celeryTask = {
|
const celeryTask = {
|
||||||
name: "celery",
|
name: "celery",
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
supabase_url: str
|
# 说明:项目迁移到 Convex 的过程中,后端可能暂时不依赖 Supabase。
|
||||||
supabase_service_role_key: str
|
# 因此这里给出空字符串默认值,避免本地未配置 .env 时无法启动开发服务器。
|
||||||
|
supabase_url: str = ""
|
||||||
|
supabase_service_role_key: str = ""
|
||||||
redis_url: str = "redis://localhost:6379/0"
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
frontend_url: str = "http://localhost:3000"
|
frontend_url: str = "http://localhost:3000"
|
||||||
openai_api_key: str = ""
|
openai_api_key: str = ""
|
||||||
lightrag_db_url: str
|
lightrag_db_url: str = ""
|
||||||
lightrag_collection: str = "wolai-docs"
|
lightrag_collection: str = "wolai-docs"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated `api` utility.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as _utils_time from "../_utils/time.js";
|
||||||
|
import type * as documents from "../documents.js";
|
||||||
|
import type * as jobs from "../jobs.js";
|
||||||
|
import type * as mediaAssets from "../mediaAssets.js";
|
||||||
|
import type * as mindmaps from "../mindmaps.js";
|
||||||
|
import type * as ping from "../ping.js";
|
||||||
|
import type * as recents from "../recents.js";
|
||||||
|
import type * as references from "../references.js";
|
||||||
|
import type * as workspaces from "../workspaces.js";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiFromModules,
|
||||||
|
FilterApi,
|
||||||
|
FunctionReference,
|
||||||
|
} from "convex/server";
|
||||||
|
|
||||||
|
declare const fullApi: ApiFromModules<{
|
||||||
|
"_utils/time": typeof _utils_time;
|
||||||
|
documents: typeof documents;
|
||||||
|
jobs: typeof jobs;
|
||||||
|
mediaAssets: typeof mediaAssets;
|
||||||
|
mindmaps: typeof mindmaps;
|
||||||
|
ping: typeof ping;
|
||||||
|
recents: typeof recents;
|
||||||
|
references: typeof references;
|
||||||
|
workspaces: typeof workspaces;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's public API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const api: FilterApi<
|
||||||
|
typeof fullApi,
|
||||||
|
FunctionReference<any, "public">
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's internal API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = internal.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const internal: FilterApi<
|
||||||
|
typeof fullApi,
|
||||||
|
FunctionReference<any, "internal">
|
||||||
|
>;
|
||||||
|
|
||||||
|
export declare const components: {};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated `api` utility.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { anyApi, componentsGeneric } from "convex/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const api = anyApi;
|
||||||
|
export const internal = anyApi;
|
||||||
|
export const components = componentsGeneric();
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated data model types.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DataModelFromSchemaDefinition,
|
||||||
|
DocumentByName,
|
||||||
|
TableNamesInDataModel,
|
||||||
|
SystemTableNames,
|
||||||
|
} from "convex/server";
|
||||||
|
import type { GenericId } from "convex/values";
|
||||||
|
import schema from "../schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The names of all of your Convex tables.
|
||||||
|
*/
|
||||||
|
export type TableNames = TableNamesInDataModel<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of a document stored in Convex.
|
||||||
|
*
|
||||||
|
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||||
|
*/
|
||||||
|
export type Doc<TableName extends TableNames> = DocumentByName<
|
||||||
|
DataModel,
|
||||||
|
TableName
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An identifier for a document in Convex.
|
||||||
|
*
|
||||||
|
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||||
|
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||||
|
*
|
||||||
|
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
|
||||||
|
*
|
||||||
|
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||||
|
* strings when type checking.
|
||||||
|
*
|
||||||
|
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||||
|
*/
|
||||||
|
export type Id<TableName extends TableNames | SystemTableNames> =
|
||||||
|
GenericId<TableName>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type describing your Convex data model.
|
||||||
|
*
|
||||||
|
* This type includes information about what tables you have, the type of
|
||||||
|
* documents stored in those tables, and the indexes defined on them.
|
||||||
|
*
|
||||||
|
* This type is used to parameterize methods like `queryGeneric` and
|
||||||
|
* `mutationGeneric` to make them type-safe.
|
||||||
|
*/
|
||||||
|
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionBuilder,
|
||||||
|
HttpActionBuilder,
|
||||||
|
MutationBuilder,
|
||||||
|
QueryBuilder,
|
||||||
|
GenericActionCtx,
|
||||||
|
GenericMutationCtx,
|
||||||
|
GenericQueryCtx,
|
||||||
|
GenericDatabaseReader,
|
||||||
|
GenericDatabaseWriter,
|
||||||
|
} from "convex/server";
|
||||||
|
import type { DataModel } from "./dataModel.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const query: QueryBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||||
|
* code and code with side-effects, like calling third-party services.
|
||||||
|
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||||
|
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||||
|
*
|
||||||
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const action: ActionBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an HTTP action.
|
||||||
|
*
|
||||||
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
|
*/
|
||||||
|
export declare const httpAction: HttpActionBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex query functions.
|
||||||
|
*
|
||||||
|
* The query context is passed as the first argument to any Convex query
|
||||||
|
* function run on the server.
|
||||||
|
*
|
||||||
|
* This differs from the {@link MutationCtx} because all of the services are
|
||||||
|
* read-only.
|
||||||
|
*/
|
||||||
|
export type QueryCtx = GenericQueryCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex mutation functions.
|
||||||
|
*
|
||||||
|
* The mutation context is passed as the first argument to any Convex mutation
|
||||||
|
* function run on the server.
|
||||||
|
*/
|
||||||
|
export type MutationCtx = GenericMutationCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex action functions.
|
||||||
|
*
|
||||||
|
* The action context is passed as the first argument to any Convex action
|
||||||
|
* function run on the server.
|
||||||
|
*/
|
||||||
|
export type ActionCtx = GenericActionCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to read from the database within Convex query functions.
|
||||||
|
*
|
||||||
|
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
||||||
|
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
||||||
|
* building a query.
|
||||||
|
*/
|
||||||
|
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to read from and write to the database within Convex mutation
|
||||||
|
* functions.
|
||||||
|
*
|
||||||
|
* Convex guarantees that all writes within a single mutation are
|
||||||
|
* executed atomically, so you never have to worry about partial writes leaving
|
||||||
|
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
||||||
|
* for the guarantees Convex provides your functions.
|
||||||
|
*/
|
||||||
|
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
actionGeneric,
|
||||||
|
httpActionGeneric,
|
||||||
|
queryGeneric,
|
||||||
|
mutationGeneric,
|
||||||
|
internalActionGeneric,
|
||||||
|
internalMutationGeneric,
|
||||||
|
internalQueryGeneric,
|
||||||
|
} from "convex/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const query = queryGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalQuery = internalQueryGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const mutation = mutationGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalMutation = internalMutationGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||||
|
* code and code with side-effects, like calling third-party services.
|
||||||
|
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||||
|
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||||
|
*
|
||||||
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const action = actionGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalAction = internalActionGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an HTTP action.
|
||||||
|
*
|
||||||
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
|
*/
|
||||||
|
export const httpAction = httpActionGeneric;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { nowIso } from "./_utils/time";
|
||||||
|
|
||||||
|
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
|
||||||
|
|
||||||
|
export const getMeta = query({
|
||||||
|
args: { userId: v.string(), id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) return null;
|
||||||
|
if (doc.user_id !== args.userId) return null;
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
user_id: doc.user_id,
|
||||||
|
workspace_id: doc.workspace_id,
|
||||||
|
access_scope: doc.access_scope,
|
||||||
|
title: doc.title ?? null,
|
||||||
|
parent_id: doc.parent_id ?? null,
|
||||||
|
created_at: doc.created_at,
|
||||||
|
updated_at: doc.updated_at ?? null,
|
||||||
|
wide_layout: doc.wide_layout ?? null,
|
||||||
|
use_small_text: doc.use_small_text ?? null,
|
||||||
|
show_heading_numbers: doc.show_heading_numbers ?? null,
|
||||||
|
show_toc: doc.show_toc ?? null,
|
||||||
|
show_structure: doc.show_structure ?? null,
|
||||||
|
protect_editing: doc.protect_editing ?? null,
|
||||||
|
show_word_count: doc.show_word_count ?? null,
|
||||||
|
word_count: doc.word_count ?? null,
|
||||||
|
character_count: doc.character_count ?? null,
|
||||||
|
block_count: doc.block_count ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getContent = query({
|
||||||
|
args: { userId: v.string(), id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) return null;
|
||||||
|
if (doc.user_id !== args.userId) return null;
|
||||||
|
return { content: doc.content ?? null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listByWorkspace = query({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const docs = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。
|
||||||
|
return docs
|
||||||
|
.filter((d) => d.user_id === args.userId)
|
||||||
|
.filter((d) => d.deleted_at == null)
|
||||||
|
.map((d) => ({
|
||||||
|
access_scope: d.access_scope,
|
||||||
|
id: d.id,
|
||||||
|
workspace_id: d.workspace_id,
|
||||||
|
title: d.title ?? "无标题",
|
||||||
|
parent_id: d.parent_id ?? null,
|
||||||
|
sort_order: d.sort_order ?? null,
|
||||||
|
is_starred: d.is_starred ?? null,
|
||||||
|
is_template: d.is_template ?? false,
|
||||||
|
created_at: d.created_at,
|
||||||
|
updated_at: d.updated_at ?? null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listTrashedByWorkspace = query({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const docs = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return docs
|
||||||
|
.filter((d) => d.user_id === args.userId)
|
||||||
|
.filter((d) => d.deleted_at != null)
|
||||||
|
.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""))
|
||||||
|
.slice(0, 100)
|
||||||
|
.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title ?? null,
|
||||||
|
parent_id: d.parent_id ?? null,
|
||||||
|
deleted_at: d.deleted_at!,
|
||||||
|
access_scope: d.access_scope,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
id: v.string(),
|
||||||
|
workspaceId: v.string(),
|
||||||
|
parentId: v.union(v.string(), v.null()),
|
||||||
|
title: v.optional(v.union(v.string(), v.null())),
|
||||||
|
accessScope,
|
||||||
|
content: v.optional(v.any()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const siblings = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace_parent", (q) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const sortOrder = siblings.filter((d) => d.deleted_at == null).length;
|
||||||
|
const ts = nowIso();
|
||||||
|
|
||||||
|
const title = (args.title ?? "无标题") || "无标题";
|
||||||
|
const content = typeof args.content === "undefined" ? [] : args.content;
|
||||||
|
|
||||||
|
await ctx.db.insert("documents", {
|
||||||
|
id: args.id,
|
||||||
|
user_id: args.userId,
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
parent_id: args.parentId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
access_scope: args.accessScope,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
|
||||||
|
wide_layout: false,
|
||||||
|
use_small_text: false,
|
||||||
|
show_heading_numbers: true,
|
||||||
|
show_toc: false,
|
||||||
|
show_structure: false,
|
||||||
|
protect_editing: false,
|
||||||
|
show_word_count: true,
|
||||||
|
word_count: 0,
|
||||||
|
character_count: 0,
|
||||||
|
block_count: 0,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: args.id,
|
||||||
|
title,
|
||||||
|
parent_id: args.parentId,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
is_starred: false,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
access_scope: args.accessScope,
|
||||||
|
is_template: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateContent = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string(), content: v.any() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
|
||||||
|
return { ok: true, updated_at: ts };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateTitle = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string(), title: v.union(v.string(), v.null()) },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(doc._id, { title: args.title, updated_at: ts });
|
||||||
|
return { ok: true, updated_at: ts };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const move = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
id: v.string(),
|
||||||
|
parentId: v.union(v.string(), v.null()),
|
||||||
|
sortOrder: v.number(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(doc._id, {
|
||||||
|
parent_id: args.parentId,
|
||||||
|
sort_order: args.sortOrder,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
return { ok: true, updated_at: ts };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const softDelete = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const restore = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(doc._id, {
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
parent_id: null,
|
||||||
|
access_scope: "private",
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const purge = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||||
|
await ctx.db.delete(doc._id);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emptyTrashByWorkspace = mutation({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// 说明:阶段 4/5 先用“membership 存在即可”的规则,避免引入复杂权限模型。
|
||||||
|
const membership = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error("无权操作该工作空间");
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const toDelete = docs.filter((d) => d.user_id === args.userId && d.deleted_at != null);
|
||||||
|
for (const d of toDelete) {
|
||||||
|
await ctx.db.delete(d._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, deletedCount: toDelete.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateOptions = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
id: v.string(),
|
||||||
|
options: v.object({
|
||||||
|
wideLayout: v.optional(v.boolean()),
|
||||||
|
smallText: v.optional(v.boolean()),
|
||||||
|
showHeadingNumbers: v.optional(v.boolean()),
|
||||||
|
showToc: v.optional(v.boolean()),
|
||||||
|
showStructure: v.optional(v.boolean()),
|
||||||
|
protectEditing: v.optional(v.boolean()),
|
||||||
|
showWordCount: v.optional(v.boolean()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout;
|
||||||
|
if (typeof args.options.smallText === "boolean") patch.use_small_text = args.options.smallText;
|
||||||
|
if (typeof args.options.showHeadingNumbers === "boolean")
|
||||||
|
patch.show_heading_numbers = args.options.showHeadingNumbers;
|
||||||
|
if (typeof args.options.showToc === "boolean") patch.show_toc = args.options.showToc;
|
||||||
|
if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure;
|
||||||
|
if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing;
|
||||||
|
if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount;
|
||||||
|
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
throw new Error("缺少可更新的选项");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(doc._id, { ...patch, updated_at: ts });
|
||||||
|
return { ok: true, updated_at: ts };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateStats = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
id: v.string(),
|
||||||
|
wordCount: v.number(),
|
||||||
|
characterCount: v.number(),
|
||||||
|
blockCount: v.number(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(doc._id, {
|
||||||
|
word_count: args.wordCount,
|
||||||
|
character_count: args.characterCount,
|
||||||
|
block_count: args.blockCount,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
return { ok: true, updated_at: ts };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const duplicate = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
sourceId: v.string(),
|
||||||
|
newId: v.string(),
|
||||||
|
title: v.optional(v.union(v.string(), v.null())),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const source = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.sourceId))
|
||||||
|
.first();
|
||||||
|
if (!source) throw new Error("页面不存在或无权限访问");
|
||||||
|
if (source.user_id !== args.userId) throw new Error("页面不存在或无权限访问");
|
||||||
|
|
||||||
|
const siblings = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace_parent", (q) =>
|
||||||
|
q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const sortOrder = siblings.filter((d) => d.deleted_at == null).length;
|
||||||
|
const ts = nowIso();
|
||||||
|
const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题";
|
||||||
|
const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`;
|
||||||
|
|
||||||
|
await ctx.db.insert("documents", {
|
||||||
|
id: args.newId,
|
||||||
|
user_id: args.userId,
|
||||||
|
workspace_id: source.workspace_id,
|
||||||
|
parent_id: source.parent_id,
|
||||||
|
title,
|
||||||
|
content: source.content ?? [],
|
||||||
|
access_scope: source.access_scope,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
|
||||||
|
wide_layout: source.wide_layout ?? false,
|
||||||
|
use_small_text: source.use_small_text ?? false,
|
||||||
|
show_heading_numbers: source.show_heading_numbers ?? true,
|
||||||
|
show_toc: source.show_toc ?? false,
|
||||||
|
show_structure: source.show_structure ?? false,
|
||||||
|
protect_editing: source.protect_editing ?? false,
|
||||||
|
show_word_count: source.show_word_count ?? true,
|
||||||
|
word_count: source.word_count ?? 0,
|
||||||
|
character_count: source.character_count ?? 0,
|
||||||
|
block_count: source.block_count ?? 0,
|
||||||
|
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: args.newId,
|
||||||
|
title,
|
||||||
|
parent_id: source.parent_id ?? null,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
workspace_id: source.workspace_id,
|
||||||
|
access_scope: source.access_scope,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listAllForCopy = query({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const docs = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return docs
|
||||||
|
.filter((d) => d.user_id === args.userId)
|
||||||
|
.filter((d) => d.deleted_at == null)
|
||||||
|
.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title ?? null,
|
||||||
|
parent_id: d.parent_id ?? null,
|
||||||
|
workspace_id: d.workspace_id,
|
||||||
|
access_scope: d.access_scope,
|
||||||
|
sort_order: d.sort_order ?? null,
|
||||||
|
created_at: d.created_at ?? null,
|
||||||
|
content: d.content ?? null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { nowIso } from "./_utils/time";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { userId: v.string(), id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const job = await ctx.db
|
||||||
|
.query("jobs")
|
||||||
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!job) return null;
|
||||||
|
if (job.user_id !== args.userId) return null;
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
type: job.type,
|
||||||
|
status: job.status,
|
||||||
|
payload: job.payload,
|
||||||
|
result: job.result,
|
||||||
|
error: job.error,
|
||||||
|
created_at: job.created_at,
|
||||||
|
updated_at: job.updated_at,
|
||||||
|
started_at: job.started_at,
|
||||||
|
finished_at: job.finished_at,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enqueueDemo = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string(), ms: v.optional(v.number()) },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const ts = nowIso();
|
||||||
|
const payload = { ms: args.ms ?? 800 };
|
||||||
|
await ctx.db.insert("jobs", {
|
||||||
|
id: args.id,
|
||||||
|
user_id: args.userId,
|
||||||
|
type: "demo.sleep",
|
||||||
|
status: "queued",
|
||||||
|
payload,
|
||||||
|
result: null,
|
||||||
|
error: null,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
started_at: null,
|
||||||
|
finished_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 说明:阶段 5 骨架——用 scheduler 触发内部 mutation,再由内部 action 执行耗时逻辑。
|
||||||
|
await ctx.scheduler.runAfter(0, internal.jobs.start, { id: args.id });
|
||||||
|
return { ok: true, id: args.id };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const start = internalMutation({
|
||||||
|
args: { id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const job = await ctx.db
|
||||||
|
.query("jobs")
|
||||||
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!job) return;
|
||||||
|
if (job.status !== "queued") return;
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(job._id, { status: "running", started_at: ts, updated_at: ts });
|
||||||
|
await ctx.scheduler.runAfter(0, internal.jobs.run, { id: args.id });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const run = internalAction({
|
||||||
|
args: { id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const job = await ctx.runQuery(internal.jobs._getInternal, { id: args.id });
|
||||||
|
if (!job) return;
|
||||||
|
if (job.status !== "running") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (job.type === "demo.sleep") {
|
||||||
|
const ms = typeof job.payload?.ms === "number" ? job.payload.ms : 800;
|
||||||
|
await new Promise((r) => setTimeout(r, ms));
|
||||||
|
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||||
|
id: args.id,
|
||||||
|
result: { ok: true, sleptMs: ms },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`未知任务类型:${job.type}`);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await ctx.runMutation(internal.jobs.finishFailure, { id: args.id, error: message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const _getInternal = internalQuery({
|
||||||
|
args: { id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const job = await ctx.db
|
||||||
|
.query("jobs")
|
||||||
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!job) return null;
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
type: job.type,
|
||||||
|
status: job.status,
|
||||||
|
payload: job.payload,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const finishSuccess = internalMutation({
|
||||||
|
args: { id: v.string(), result: v.any() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const job = await ctx.db
|
||||||
|
.query("jobs")
|
||||||
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!job) return;
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(job._id, {
|
||||||
|
status: "succeeded",
|
||||||
|
result: args.result,
|
||||||
|
error: null,
|
||||||
|
finished_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const finishFailure = internalMutation({
|
||||||
|
args: { id: v.string(), error: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const job = await ctx.db
|
||||||
|
.query("jobs")
|
||||||
|
.withIndex("by_job_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!job) return;
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(job._id, {
|
||||||
|
status: "failed",
|
||||||
|
result: null,
|
||||||
|
error: args.error,
|
||||||
|
finished_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { nowIso } from "./_utils/time";
|
||||||
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
|
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
|
||||||
|
const membership = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||||
|
.first();
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error("无权访问该工作空间");
|
||||||
|
}
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getById = query({
|
||||||
|
args: { userId: v.string(), id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const row = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!row) return null;
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generateUploadUrl = mutation({
|
||||||
|
args: { userId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// 说明:简单兜底,要求用户至少有一个工作空间 membership。
|
||||||
|
const membership = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
|
||||||
|
.first();
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error("尚未初始化工作空间,无法上传");
|
||||||
|
}
|
||||||
|
return await ctx.storage.generateUploadUrl();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listByWorkspace = query({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
workspaceId: v.string(),
|
||||||
|
assetType: v.optional(v.string()),
|
||||||
|
includeDeleted: v.optional(v.boolean()),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||||
|
|
||||||
|
const includeDeleted = Boolean(args.includeDeleted);
|
||||||
|
const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 12)));
|
||||||
|
|
||||||
|
let rows = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.order("desc")
|
||||||
|
.take(limit * 5);
|
||||||
|
|
||||||
|
if (!includeDeleted) {
|
||||||
|
rows = rows.filter((r) => !r.deleted_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.assetType) {
|
||||||
|
rows = rows.filter((r) => r.asset_type === args.assetType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:Convex 的 take 以索引排序为准,这里再截一刀保证输出稳定。
|
||||||
|
return rows.slice(0, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listByDocument = query({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
documentId: v.string(),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const limit = Math.max(1, Math.min(500, Math.floor(args.limit ?? 200)));
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_document", (q) => q.eq("document_id", args.documentId))
|
||||||
|
.order("desc")
|
||||||
|
.take(limit * 2);
|
||||||
|
|
||||||
|
const filtered = rows.filter((r) => !r.deleted_at);
|
||||||
|
const ws = filtered[0]?.workspace_id ?? rows[0]?.workspace_id ?? null;
|
||||||
|
if (ws) await assertWorkspaceMember(ctx, args.userId, ws);
|
||||||
|
return filtered.slice(0, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listDeletedByWorkspace = query({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
workspaceId: v.string(),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||||
|
const limit = Math.max(1, Math.min(2000, Math.floor(args.limit ?? 2000)));
|
||||||
|
|
||||||
|
// 说明:Convex 目前不支持“deleted_at is not null”这种索引条件,先全取再过滤(对练手项目足够)。
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.order("desc")
|
||||||
|
.take(limit * 3);
|
||||||
|
|
||||||
|
return rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at).slice(0, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listByIds = query({
|
||||||
|
args: { userId: v.string(), ids: v.array(v.string()) },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const ids = Array.from(new Set(args.ids.filter(Boolean))).slice(0, 200);
|
||||||
|
const out: any[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const row = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_asset_id", (q) => q.eq("id", id))
|
||||||
|
.first();
|
||||||
|
if (!row) continue;
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
asset: v.object({
|
||||||
|
id: v.string(),
|
||||||
|
workspace_id: v.string(),
|
||||||
|
document_id: v.string(),
|
||||||
|
asset_type: v.string(),
|
||||||
|
file_url: v.union(v.string(), v.null()),
|
||||||
|
thumbnail_url: v.union(v.string(), v.null()),
|
||||||
|
storage_id: v.optional(v.union(v.id("_storage"), v.null())),
|
||||||
|
bucket: v.union(v.string(), v.null()),
|
||||||
|
storage_path: v.union(v.string(), v.null()),
|
||||||
|
file_name: v.union(v.string(), v.null()),
|
||||||
|
file_size: v.union(v.number(), v.null()),
|
||||||
|
mime_type: v.union(v.string(), v.null()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id);
|
||||||
|
const ts = nowIso();
|
||||||
|
|
||||||
|
await ctx.db.insert("media_assets", {
|
||||||
|
...args.asset,
|
||||||
|
storage_id: args.asset.storage_id ?? null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
purged_at: null,
|
||||||
|
created_by: args.userId,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return args.asset;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createWithStorage = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
storageId: v.id("_storage"),
|
||||||
|
asset: v.object({
|
||||||
|
id: v.string(),
|
||||||
|
workspace_id: v.string(),
|
||||||
|
document_id: v.string(),
|
||||||
|
asset_type: v.string(),
|
||||||
|
file_name: v.union(v.string(), v.null()),
|
||||||
|
file_size: v.union(v.number(), v.null()),
|
||||||
|
mime_type: v.union(v.string(), v.null()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id);
|
||||||
|
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q) => q.eq("id", args.asset.document_id))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!doc || doc.workspace_id !== args.asset.workspace_id) {
|
||||||
|
throw new Error("目标页面不存在或不属于该工作空间");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await ctx.storage.getUrl(args.storageId);
|
||||||
|
if (!url) {
|
||||||
|
throw new Error("文件不存在或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
const row = {
|
||||||
|
id: args.asset.id,
|
||||||
|
workspace_id: args.asset.workspace_id,
|
||||||
|
document_id: args.asset.document_id,
|
||||||
|
asset_type: args.asset.asset_type,
|
||||||
|
file_url: url,
|
||||||
|
thumbnail_url: url,
|
||||||
|
storage_id: args.storageId,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: args.asset.file_name,
|
||||||
|
file_size: args.asset.file_size,
|
||||||
|
mime_type: args.asset.mime_type,
|
||||||
|
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
purged_at: null,
|
||||||
|
created_by: args.userId,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
};
|
||||||
|
|
||||||
|
await ctx.db.insert("media_assets", row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const patchById = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
id: v.string(),
|
||||||
|
patch: v.any(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const row = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!row) throw new Error("资源不存在");
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||||
|
|
||||||
|
const next = { ...(args.patch as Record<string, unknown>), updated_at: nowIso() };
|
||||||
|
await ctx.db.patch(row._id, next);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const refreshUrl = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const row = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!row) throw new Error("资源不存在");
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||||
|
|
||||||
|
const storageId = (row.storage_id as any) ?? null;
|
||||||
|
if (!storageId) {
|
||||||
|
// 外链资源:直接回传现有 URL
|
||||||
|
return { signedUrl: row.file_url };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await ctx.storage.getUrl(storageId);
|
||||||
|
if (!url) {
|
||||||
|
throw new Error("文件不存在或已被删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(row._id, { file_url: url, thumbnail_url: url, updated_at: nowIso() });
|
||||||
|
return { signedUrl: url };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const replaceStorageFromUpload = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string(), storageId: v.id("_storage") },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const row = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!row) throw new Error("资源不存在");
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||||
|
|
||||||
|
const url = await ctx.storage.getUrl(args.storageId);
|
||||||
|
if (!url) {
|
||||||
|
throw new Error("文件不存在或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(row._id, {
|
||||||
|
storage_id: args.storageId,
|
||||||
|
file_url: url,
|
||||||
|
thumbnail_url: url,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
updated_at: nowIso(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, fileUrl: url };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const purgeById = mutation({
|
||||||
|
args: { userId: v.string(), id: v.string(), expiredDeletedAt: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const row = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
|
||||||
|
.first();
|
||||||
|
if (!row) throw new Error("未找到附件");
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||||
|
|
||||||
|
if (row.purged_at) {
|
||||||
|
return { ok: true, alreadyPurged: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageId = (row.storage_id as any) ?? null;
|
||||||
|
if (storageId) {
|
||||||
|
const refs = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_storage_id", (q) => q.eq("storage_id", storageId))
|
||||||
|
.collect();
|
||||||
|
const otherAlive = refs.some((r) => r.id !== row.id && !r.purged_at);
|
||||||
|
if (!otherAlive) {
|
||||||
|
await ctx.storage.delete(storageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(row._id, {
|
||||||
|
deleted_at: args.expiredDeletedAt,
|
||||||
|
deleted_by: args.userId,
|
||||||
|
purged_at: ts,
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
storage_id: null,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emptyTrashByWorkspace = mutation({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string(), expiredDeletedAt: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||||
|
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.order("desc")
|
||||||
|
.take(5000);
|
||||||
|
|
||||||
|
const targets = rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
return { ok: true, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:按 storage_id 分组,只有当该 storage_id 没有任何“未清理”的引用时才删除底层文件。
|
||||||
|
const byStorage = new Map<string, string[]>();
|
||||||
|
for (const r of targets) {
|
||||||
|
const sid = (r.storage_id as any) ?? null;
|
||||||
|
if (!sid) continue;
|
||||||
|
const list = byStorage.get(sid) ?? [];
|
||||||
|
list.push(r.id);
|
||||||
|
byStorage.set(sid, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sid] of byStorage.entries()) {
|
||||||
|
const refs = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_storage_id", (q) => q.eq("storage_id", sid as any))
|
||||||
|
.collect();
|
||||||
|
const alive = refs.some((r) => !r.purged_at && !r.deleted_at);
|
||||||
|
if (!alive) {
|
||||||
|
try {
|
||||||
|
await ctx.storage.delete(sid as any);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
for (const r of targets) {
|
||||||
|
await ctx.db.patch(r._id, {
|
||||||
|
deleted_at: args.expiredDeletedAt,
|
||||||
|
deleted_by: args.userId,
|
||||||
|
purged_at: ts,
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
storage_id: null,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, updated: targets.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { nowIso } from "./_utils/time";
|
||||||
|
|
||||||
|
const defaultMindmapData = {
|
||||||
|
data: { text: "中心主题" },
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeMindmapId(docId: string, mindmapId: string): string {
|
||||||
|
const raw = String(mindmapId ?? "").trim();
|
||||||
|
if (raw) return raw;
|
||||||
|
// 兜底:不允许空 mindmapId(否则无法索引)
|
||||||
|
return `legacy-${docId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireOwnedDocument(ctx: any, userId: string, docId: string) {
|
||||||
|
const doc = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_document_id", (q: any) => q.eq("id", docId))
|
||||||
|
.first();
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error("页面不存在");
|
||||||
|
}
|
||||||
|
if (doc.user_id !== userId) {
|
||||||
|
throw new Error("无权限");
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||||
|
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||||
|
|
||||||
|
const row = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// 兼容旧逻辑:不存在或已删除时仍返回默认导图,避免前端卡死。
|
||||||
|
if (!row || row.deleted_at != null) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: defaultMindmapData,
|
||||||
|
meta: {
|
||||||
|
workspace_id: doc.workspace_id,
|
||||||
|
document_id: args.docId,
|
||||||
|
mindmap_id: mindmapId,
|
||||||
|
exists: false,
|
||||||
|
deleted_at: row?.deleted_at ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: row.data ?? defaultMindmapData,
|
||||||
|
meta: {
|
||||||
|
workspace_id: row.workspace_id,
|
||||||
|
document_id: row.document_id,
|
||||||
|
mindmap_id: row.mindmap_id,
|
||||||
|
exists: true,
|
||||||
|
deleted_at: row.deleted_at,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const put = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
docId: v.string(),
|
||||||
|
mindmapId: v.string(),
|
||||||
|
data: v.any(),
|
||||||
|
createOnly: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const doc = await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||||
|
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
const payload = args.data ?? defaultMindmapData;
|
||||||
|
|
||||||
|
if (existing && existing.deleted_at == null && args.createOnly) {
|
||||||
|
return { ok: true, created: false, skipped: true, updated_at: existing.updated_at ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
data: payload,
|
||||||
|
updated_at: ts,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
});
|
||||||
|
return { ok: true, created: false, skipped: false, updated_at: ts };
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert("mindmaps", {
|
||||||
|
id: `${args.docId}:${mindmapId}`,
|
||||||
|
user_id: args.userId,
|
||||||
|
workspace_id: doc.workspace_id,
|
||||||
|
document_id: args.docId,
|
||||||
|
mindmap_id: mindmapId,
|
||||||
|
data: payload,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, created: true, skipped: false, updated_at: ts };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const softDelete = mutation({
|
||||||
|
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||||
|
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// 兼容:不存在也视为成功
|
||||||
|
return { ok: true, moved: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts });
|
||||||
|
return { ok: true, moved: 1, deleted_at: ts };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const restore = mutation({
|
||||||
|
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||||
|
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error("未找到可操作的记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.patch(existing._id, { deleted_at: null, deleted_by: null, updated_at: ts });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const purge = mutation({
|
||||||
|
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||||
|
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error("未找到可操作的记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(existing._id);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listByWorkspace = query({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string(), includeDeleted: v.optional(v.boolean()) },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。
|
||||||
|
const membership = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
|
||||||
|
.first();
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error("无权操作该工作空间");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const includeDeleted = Boolean(args.includeDeleted);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter((r) => r.user_id === args.userId)
|
||||||
|
.filter((r) => (includeDeleted ? true : r.deleted_at == null))
|
||||||
|
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
workspace_id: r.workspace_id,
|
||||||
|
document_id: r.document_id,
|
||||||
|
mindmap_id: r.mindmap_id,
|
||||||
|
data: r.data ?? defaultMindmapData,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
deleted_at: r.deleted_at,
|
||||||
|
deleted_by: r.deleted_by,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const copyByDocument = mutation({
|
||||||
|
args: { userId: v.string(), sourceDocId: v.string(), targetDocId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const sourceDoc = await requireOwnedDocument(ctx, args.userId, args.sourceDocId);
|
||||||
|
const targetDoc = await requireOwnedDocument(ctx, args.userId, args.targetDocId);
|
||||||
|
|
||||||
|
// 说明:同一工作空间复制最常见;若跨工作空间复制,这里仍允许,但 mindmaps 会落到目标页面的 workspace_id。
|
||||||
|
const sourceRows = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", sourceDoc.workspace_id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const sourceMindmaps = sourceRows
|
||||||
|
.filter((r) => r.user_id === args.userId)
|
||||||
|
.filter((r) => r.document_id === args.sourceDocId)
|
||||||
|
.filter((r) => r.deleted_at == null);
|
||||||
|
|
||||||
|
let copied = 0;
|
||||||
|
const ts = nowIso();
|
||||||
|
|
||||||
|
for (const r of sourceMindmaps) {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.targetDocId).eq("mindmap_id", r.mindmap_id))
|
||||||
|
.first();
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
await ctx.db.insert("mindmaps", {
|
||||||
|
id: `${args.targetDocId}:${r.mindmap_id}`,
|
||||||
|
user_id: args.userId,
|
||||||
|
workspace_id: targetDoc.workspace_id,
|
||||||
|
document_id: args.targetDocId,
|
||||||
|
mindmap_id: r.mindmap_id,
|
||||||
|
data: r.data ?? defaultMindmapData,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
});
|
||||||
|
copied += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, copied };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emptyTrashByWorkspace = mutation({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const membership = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
|
||||||
|
.first();
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error("无权操作该工作空间");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("mindmaps")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const toDelete = rows.filter((r) => r.user_id === args.userId && r.deleted_at != null);
|
||||||
|
for (const r of toDelete) {
|
||||||
|
await ctx.db.delete(r._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, deletedCount: toDelete.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { query } from "./_generated/server";
|
||||||
|
|
||||||
|
export const ping = query({
|
||||||
|
args: {},
|
||||||
|
handler: async () => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "pong",
|
||||||
|
now: Date.now(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
export const listByWorkspace = query({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string(), limit: v.optional(v.number()) },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("user_recent_pages")
|
||||||
|
.withIndex("by_user_workspace", (q) => q.eq("user_id", args.userId).eq("workspace_id", args.workspaceId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const sorted = rows.sort((a, b) => (b.last_accessed_at ?? "").localeCompare(a.last_accessed_at ?? ""));
|
||||||
|
const limit = Math.max(0, Math.min(args.limit ?? 10, 50));
|
||||||
|
return sorted.slice(0, limit).map((r) => ({
|
||||||
|
user_id: r.user_id,
|
||||||
|
workspace_id: r.workspace_id,
|
||||||
|
document_id: r.document_id,
|
||||||
|
last_accessed_at: r.last_accessed_at,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upsert = mutation({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string(), documentId: v.string(), lastAccessedAt: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("user_recent_pages")
|
||||||
|
.withIndex("by_user_document", (q) => q.eq("user_id", args.userId).eq("document_id", args.documentId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
last_accessed_at: args.lastAccessedAt,
|
||||||
|
});
|
||||||
|
return { ok: true, updated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert("user_recent_pages", {
|
||||||
|
user_id: args.userId,
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
document_id: args.documentId,
|
||||||
|
last_accessed_at: args.lastAccessedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, updated: false };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { nowIso } from "./_utils/time";
|
||||||
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
|
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
|
||||||
|
const membership = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||||
|
.first();
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error("无权访问该工作空间");
|
||||||
|
}
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMode = v.union(v.literal("inline"), v.literal("embed"));
|
||||||
|
|
||||||
|
export const record = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
workspaceId: v.string(),
|
||||||
|
sourcePageId: v.string(),
|
||||||
|
targetPageId: v.string(),
|
||||||
|
sourceBlockId: v.union(v.string(), v.null()),
|
||||||
|
alias: v.union(v.string(), v.null()),
|
||||||
|
displayMode,
|
||||||
|
isPreviewable: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("page_references")
|
||||||
|
.withIndex("by_unique", (q) =>
|
||||||
|
q
|
||||||
|
.eq("workspace_id", args.workspaceId)
|
||||||
|
.eq("source_page_id", args.sourcePageId)
|
||||||
|
.eq("source_block_id", args.sourceBlockId)
|
||||||
|
.eq("target_page_id", args.targetPageId)
|
||||||
|
.eq("display_mode", args.displayMode),
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
alias: args.alias ?? null,
|
||||||
|
is_previewable: Boolean(args.isPreviewable),
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: existing.id,
|
||||||
|
workspace_id: existing.workspace_id,
|
||||||
|
source_page_id: existing.source_page_id,
|
||||||
|
source_block_id: existing.source_block_id,
|
||||||
|
target_page_id: existing.target_page_id,
|
||||||
|
alias: args.alias ?? null,
|
||||||
|
display_mode: existing.display_mode,
|
||||||
|
is_previewable: Boolean(args.isPreviewable),
|
||||||
|
created_at: existing.created_at,
|
||||||
|
updated_at: ts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:id 使用可读的组合键,便于调试;并不要求前端依赖该规则。
|
||||||
|
const id = `ref:${args.workspaceId}:${args.sourcePageId}:${args.sourceBlockId ?? "page"}:${args.targetPageId}:${args.displayMode}:${ts}`;
|
||||||
|
|
||||||
|
await ctx.db.insert("page_references", {
|
||||||
|
id,
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
source_page_id: args.sourcePageId,
|
||||||
|
source_block_id: args.sourceBlockId ?? null,
|
||||||
|
target_page_id: args.targetPageId,
|
||||||
|
alias: args.alias ?? null,
|
||||||
|
display_mode: args.displayMode,
|
||||||
|
is_previewable: Boolean(args.isPreviewable),
|
||||||
|
created_by: args.userId,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
workspace_id: args.workspaceId,
|
||||||
|
source_page_id: args.sourcePageId,
|
||||||
|
source_block_id: args.sourceBlockId ?? null,
|
||||||
|
target_page_id: args.targetPageId,
|
||||||
|
alias: args.alias ?? null,
|
||||||
|
display_mode: args.displayMode,
|
||||||
|
is_previewable: Boolean(args.isPreviewable),
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listBacklinks = query({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
workspaceId: v.string(),
|
||||||
|
pageId: v.string(),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
offset: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||||
|
|
||||||
|
const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 50)));
|
||||||
|
const offset = Math.max(0, Math.floor(args.offset ?? 0));
|
||||||
|
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("page_references")
|
||||||
|
.withIndex("by_workspace_target", (q) => q.eq("workspace_id", args.workspaceId).eq("target_page_id", args.pageId))
|
||||||
|
.order("desc")
|
||||||
|
.take(limit + offset + 200);
|
||||||
|
|
||||||
|
const sliced = rows.slice(offset, offset + limit);
|
||||||
|
const sourceIds = Array.from(new Set(sliced.map((r) => String((r as any).source_page_id ?? "")).filter(Boolean)));
|
||||||
|
|
||||||
|
const sourceTitleById = new Map<string, string | null>();
|
||||||
|
for (const sid of sourceIds) {
|
||||||
|
const doc = await ctx.db.query("documents").withIndex("by_document_id", (q) => q.eq("id", sid)).first();
|
||||||
|
sourceTitleById.set(sid, doc ? (doc.title ?? null) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sliced.map((row) => {
|
||||||
|
const r = row as any;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
source_page_id: r.source_page_id,
|
||||||
|
source_block_id: r.source_block_id ?? null,
|
||||||
|
alias: r.alias ?? null,
|
||||||
|
display_mode: r.display_mode,
|
||||||
|
is_previewable: Boolean(r.is_previewable),
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
source_title: sourceTitleById.get(String(r.source_page_id ?? "")) ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { defineSchema, defineTable } from "convex/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
// 说明:
|
||||||
|
// - 这里先按“兼容现有 Next API 返回结构”的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。
|
||||||
|
// - Convex 自带的 _id 仍然存在,但我们暂时不把它暴露给上层业务,便于逐步迁移与回退。
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
workspaces: defineTable({
|
||||||
|
id: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
type: v.union(v.literal("personal"), v.literal("team")),
|
||||||
|
icon_url: v.union(v.string(), v.null()),
|
||||||
|
created_by: v.string(),
|
||||||
|
created_at: v.string(),
|
||||||
|
})
|
||||||
|
.index("by_workspace_id", ["id"])
|
||||||
|
.index("by_created_by", ["created_by"]),
|
||||||
|
|
||||||
|
workspace_members: defineTable({
|
||||||
|
workspace_id: v.string(),
|
||||||
|
user_id: v.string(),
|
||||||
|
role: v.string(),
|
||||||
|
is_default: v.boolean(),
|
||||||
|
created_at: v.string(),
|
||||||
|
})
|
||||||
|
.index("by_user_id", ["user_id"])
|
||||||
|
.index("by_workspace_user", ["workspace_id", "user_id"])
|
||||||
|
.index("by_workspace_id", ["workspace_id"]),
|
||||||
|
|
||||||
|
documents: defineTable({
|
||||||
|
id: v.string(),
|
||||||
|
user_id: v.string(),
|
||||||
|
workspace_id: v.string(),
|
||||||
|
|
||||||
|
title: v.union(v.string(), v.null()),
|
||||||
|
parent_id: v.union(v.string(), v.null()),
|
||||||
|
sort_order: v.union(v.number(), v.null()),
|
||||||
|
is_starred: v.union(v.boolean(), v.null()),
|
||||||
|
is_template: v.boolean(),
|
||||||
|
access_scope: v.union(v.literal("private"), v.literal("shared"), v.literal("public")),
|
||||||
|
|
||||||
|
// 页面选项(对应 Supabase documents 上的 UI 配置列)
|
||||||
|
wide_layout: v.union(v.boolean(), v.null()),
|
||||||
|
use_small_text: v.union(v.boolean(), v.null()),
|
||||||
|
show_heading_numbers: v.union(v.boolean(), v.null()),
|
||||||
|
show_toc: v.union(v.boolean(), v.null()),
|
||||||
|
show_structure: v.union(v.boolean(), v.null()),
|
||||||
|
protect_editing: v.union(v.boolean(), v.null()),
|
||||||
|
show_word_count: v.union(v.boolean(), v.null()),
|
||||||
|
|
||||||
|
// 统计信息(由客户端编辑器计算后回写)
|
||||||
|
word_count: v.union(v.number(), v.null()),
|
||||||
|
character_count: v.union(v.number(), v.null()),
|
||||||
|
block_count: v.union(v.number(), v.null()),
|
||||||
|
|
||||||
|
// 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。
|
||||||
|
content: v.any(),
|
||||||
|
|
||||||
|
// 说明:后续可用于搜索/索引(目前先留空,不强制写入)。
|
||||||
|
raw_text: v.optional(v.union(v.string(), v.null())),
|
||||||
|
|
||||||
|
// 时间戳统一使用 ISO 字符串,便于直接复用前端现有排序逻辑。
|
||||||
|
created_at: v.string(),
|
||||||
|
updated_at: v.union(v.string(), v.null()),
|
||||||
|
|
||||||
|
// 软删除(阶段 4 先不实现垃圾桶逻辑,但字段先留好,便于后续迁移)。
|
||||||
|
deleted_at: v.union(v.string(), v.null()),
|
||||||
|
deleted_by: v.union(v.string(), v.null()),
|
||||||
|
|
||||||
|
// 兼容旧逻辑:Supabase documents.mindmap_data。
|
||||||
|
mindmap_data: v.optional(v.any()),
|
||||||
|
})
|
||||||
|
.index("by_document_id", ["id"])
|
||||||
|
.index("by_user", ["user_id"])
|
||||||
|
.index("by_workspace", ["workspace_id"])
|
||||||
|
.index("by_workspace_parent", ["workspace_id", "parent_id"]),
|
||||||
|
|
||||||
|
// 最近访问(替代 Supabase user_recent_pages)
|
||||||
|
user_recent_pages: defineTable({
|
||||||
|
user_id: v.string(),
|
||||||
|
workspace_id: v.string(),
|
||||||
|
document_id: v.string(),
|
||||||
|
last_accessed_at: v.string(),
|
||||||
|
})
|
||||||
|
.index("by_user_workspace", ["user_id", "workspace_id"])
|
||||||
|
.index("by_user_document", ["user_id", "document_id"]),
|
||||||
|
|
||||||
|
// 阶段 5:异步任务/队列表(最小骨架,后续可扩展为通用作业系统)
|
||||||
|
jobs: defineTable({
|
||||||
|
id: v.string(),
|
||||||
|
user_id: v.string(),
|
||||||
|
type: v.string(),
|
||||||
|
status: v.union(v.literal("queued"), v.literal("running"), v.literal("succeeded"), v.literal("failed")),
|
||||||
|
payload: v.any(),
|
||||||
|
result: v.union(v.any(), v.null()),
|
||||||
|
error: v.union(v.string(), v.null()),
|
||||||
|
created_at: v.string(),
|
||||||
|
updated_at: v.string(),
|
||||||
|
started_at: v.union(v.string(), v.null()),
|
||||||
|
finished_at: v.union(v.string(), v.null()),
|
||||||
|
})
|
||||||
|
.index("by_job_id", ["id"])
|
||||||
|
.index("by_user", ["user_id"])
|
||||||
|
.index("by_status", ["status"]),
|
||||||
|
|
||||||
|
// 阶段 6:媒体/附件(替代 Supabase Storage + media_assets 表)
|
||||||
|
media_assets: defineTable({
|
||||||
|
id: v.string(),
|
||||||
|
workspace_id: v.string(),
|
||||||
|
document_id: v.string(),
|
||||||
|
|
||||||
|
asset_type: v.string(),
|
||||||
|
file_url: v.union(v.string(), v.null()),
|
||||||
|
thumbnail_url: v.union(v.string(), v.null()),
|
||||||
|
|
||||||
|
// Convex Files(storage)定位信息
|
||||||
|
// 说明:早期阶段 6(MinIO 直存)写入的记录可能没有该字段;允许缺失以便平滑迁移。
|
||||||
|
storage_id: v.optional(v.union(v.id("_storage"), v.null())),
|
||||||
|
|
||||||
|
// S3/MinIO 定位信息
|
||||||
|
bucket: v.union(v.string(), v.null()),
|
||||||
|
storage_path: v.union(v.string(), v.null()),
|
||||||
|
|
||||||
|
file_name: v.union(v.string(), v.null()),
|
||||||
|
file_size: v.union(v.number(), v.null()),
|
||||||
|
mime_type: v.union(v.string(), v.null()),
|
||||||
|
|
||||||
|
// OCR 相关(后续再接入)
|
||||||
|
ocr_text: v.union(v.string(), v.null()),
|
||||||
|
ocr_status: v.union(v.string(), v.null()),
|
||||||
|
ocr_payload: v.optional(v.any()),
|
||||||
|
ocr_strategy: v.optional(v.union(v.string(), v.null())),
|
||||||
|
|
||||||
|
// 回收站/清理
|
||||||
|
deleted_at: v.union(v.string(), v.null()),
|
||||||
|
deleted_by: v.union(v.string(), v.null()),
|
||||||
|
purged_at: v.union(v.string(), v.null()),
|
||||||
|
|
||||||
|
created_by: v.string(),
|
||||||
|
created_at: v.string(),
|
||||||
|
updated_at: v.string(),
|
||||||
|
})
|
||||||
|
.index("by_asset_id", ["id"])
|
||||||
|
.index("by_storage_id", ["storage_id"])
|
||||||
|
.index("by_workspace", ["workspace_id"])
|
||||||
|
.index("by_document", ["document_id"])
|
||||||
|
.index("by_workspace_deleted", ["workspace_id", "deleted_at"]),
|
||||||
|
|
||||||
|
// M2:思维导图(替代本地文件 public/documents/<docId>/mindmap*.json + Supabase documents.mindmap_data)
|
||||||
|
// 说明:
|
||||||
|
// - 业务侧依然以 (document_id, mindmap_id) 作为“定位键”,避免跨页面冲突。
|
||||||
|
// - id 是一个便于排查的全局唯一串(例如 `${docId}:${mindmapId}`),但不要求前端依赖它。
|
||||||
|
mindmaps: defineTable({
|
||||||
|
id: v.string(),
|
||||||
|
user_id: v.string(),
|
||||||
|
workspace_id: v.string(),
|
||||||
|
document_id: v.string(),
|
||||||
|
mindmap_id: v.string(),
|
||||||
|
data: v.any(),
|
||||||
|
created_at: v.string(),
|
||||||
|
updated_at: v.string(),
|
||||||
|
deleted_at: v.union(v.string(), v.null()),
|
||||||
|
deleted_by: v.union(v.string(), v.null()),
|
||||||
|
})
|
||||||
|
.index("by_mindmap_id", ["id"])
|
||||||
|
.index("by_doc_mindmap", ["document_id", "mindmap_id"])
|
||||||
|
.index("by_workspace", ["workspace_id"])
|
||||||
|
.index("by_workspace_deleted", ["workspace_id", "deleted_at"])
|
||||||
|
.index("by_user", ["user_id"]),
|
||||||
|
|
||||||
|
// M3:页面引用/反链(替代 Supabase RPC:record_page_ref、list_backlinks)
|
||||||
|
page_references: defineTable({
|
||||||
|
id: v.string(),
|
||||||
|
workspace_id: v.string(),
|
||||||
|
source_page_id: v.string(),
|
||||||
|
source_block_id: v.union(v.string(), v.null()),
|
||||||
|
target_page_id: v.string(),
|
||||||
|
alias: v.union(v.string(), v.null()),
|
||||||
|
display_mode: v.union(v.literal("inline"), v.literal("embed")),
|
||||||
|
is_previewable: v.boolean(),
|
||||||
|
created_by: v.string(),
|
||||||
|
created_at: v.string(),
|
||||||
|
updated_at: v.string(),
|
||||||
|
})
|
||||||
|
.index("by_reference_id", ["id"])
|
||||||
|
.index("by_workspace_target", ["workspace_id", "target_page_id"])
|
||||||
|
.index("by_workspace_source", ["workspace_id", "source_page_id"])
|
||||||
|
.index("by_unique", ["workspace_id", "source_page_id", "source_block_id", "target_page_id", "display_mode"]),
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { nowIso } from "./_utils/time";
|
||||||
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
|
type WorkspaceSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "personal" | "team";
|
||||||
|
iconUrl: string | null;
|
||||||
|
memberCount: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findWorkspaceById(ctx: QueryCtx | MutationCtx, workspaceId: string) {
|
||||||
|
return await ctx.db
|
||||||
|
.query("workspaces")
|
||||||
|
.withIndex("by_workspace_id", (q) => q.eq("id", workspaceId))
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countMembers(ctx: QueryCtx | MutationCtx, workspaceId: string): Promise<number> {
|
||||||
|
const members = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_workspace_id", (q) => q.eq("workspace_id", workspaceId))
|
||||||
|
.collect();
|
||||||
|
return members.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensureDefaultWorkspace = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
fallbackName: v.optional(v.string()),
|
||||||
|
workspaceIdIfCreate: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const memberships = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if (memberships.length === 0) {
|
||||||
|
const workspaceName = (args.fallbackName ?? "").trim()
|
||||||
|
? `${args.fallbackName!.trim()} 的空间`
|
||||||
|
: "我的空间";
|
||||||
|
|
||||||
|
const ts = nowIso();
|
||||||
|
await ctx.db.insert("workspaces", {
|
||||||
|
id: args.workspaceIdIfCreate,
|
||||||
|
name: workspaceName,
|
||||||
|
type: "personal",
|
||||||
|
icon_url: null,
|
||||||
|
created_by: args.userId,
|
||||||
|
created_at: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert("workspace_members", {
|
||||||
|
workspace_id: args.workspaceIdIfCreate,
|
||||||
|
user_id: args.userId,
|
||||||
|
role: "owner",
|
||||||
|
is_default: true,
|
||||||
|
created_at: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary: WorkspaceSummary = {
|
||||||
|
id: args.workspaceIdIfCreate,
|
||||||
|
name: workspaceName,
|
||||||
|
type: "personal",
|
||||||
|
iconUrl: null,
|
||||||
|
memberCount: 1,
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaces: [summary],
|
||||||
|
activeWorkspaceId: args.workspaceIdIfCreate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有 membership 就认为已有 workspace;再兜底一次补齐 workspace 记录。
|
||||||
|
const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id)));
|
||||||
|
const summaries: WorkspaceSummary[] = [];
|
||||||
|
for (const wid of workspaceIds) {
|
||||||
|
const ws = await findWorkspaceById(ctx, wid);
|
||||||
|
if (!ws) continue;
|
||||||
|
const memberCount = await countMembers(ctx, wid);
|
||||||
|
const isDefault = memberships.some((m) => m.workspace_id === wid && m.is_default);
|
||||||
|
summaries.push({
|
||||||
|
id: ws.id,
|
||||||
|
name: ws.name,
|
||||||
|
type: ws.type,
|
||||||
|
iconUrl: ws.icon_url,
|
||||||
|
memberCount: memberCount || 1,
|
||||||
|
isDefault,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:保持与原 fetchWorkspaceSummaries 一致:默认 workspace 优先,否则取第一个。
|
||||||
|
const defaultWs = summaries.find((w) => w.isDefault);
|
||||||
|
const activeWorkspaceId = defaultWs?.id ?? summaries[0]?.id ?? "";
|
||||||
|
return { workspaces: summaries, activeWorkspaceId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchWorkspaceSummaries = query({
|
||||||
|
args: { userId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// 说明:为了复用 ensureDefaultWorkspace 的返回结构,这里直接走同样的聚合逻辑。
|
||||||
|
const memberships = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id)));
|
||||||
|
const summaries: WorkspaceSummary[] = [];
|
||||||
|
for (const wid of workspaceIds) {
|
||||||
|
const ws = await findWorkspaceById(ctx, wid);
|
||||||
|
if (!ws) continue;
|
||||||
|
const memberCount = await countMembers(ctx, wid);
|
||||||
|
const isDefault = memberships.some((m) => m.workspace_id === wid && m.is_default);
|
||||||
|
summaries.push({
|
||||||
|
id: ws.id,
|
||||||
|
name: ws.name,
|
||||||
|
type: ws.type,
|
||||||
|
iconUrl: ws.icon_url,
|
||||||
|
memberCount: memberCount || 1,
|
||||||
|
isDefault,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultWs = summaries.find((w) => w.isDefault);
|
||||||
|
const activeWorkspaceId = defaultWs?.id ?? summaries[0]?.id ?? "";
|
||||||
|
return { workspaces: summaries, activeWorkspaceId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const switchDefaultWorkspace = mutation({
|
||||||
|
args: { userId: v.string(), workspaceId: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const target = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_workspace_user", (q) =>
|
||||||
|
q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId),
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("无权切换至该工作空间");
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await ctx.db
|
||||||
|
.query("workspace_members")
|
||||||
|
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 说明:Convex 暂无批量 update,这里逐条 patch。
|
||||||
|
for (const m of memberships) {
|
||||||
|
if (m.is_default) {
|
||||||
|
await ctx.db.patch(m._id, { is_default: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(target._id, { is_default: true });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "node scripts/dev-server.js",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
@@ -39,6 +39,8 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"convex": "^1.31.4",
|
||||||
|
"jszip": "3.10.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
|
|||||||
Generated
+36
@@ -95,6 +95,12 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
convex:
|
||||||
|
specifier: ^1.31.4
|
||||||
|
version: 1.31.4(react@19.2.0)
|
||||||
|
jszip:
|
||||||
|
specifier: 3.10.1
|
||||||
|
version: 3.10.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.554.0
|
specifier: ^0.554.0
|
||||||
version: 0.554.0(react@19.2.0)
|
version: 0.554.0(react@19.2.0)
|
||||||
@@ -2722,6 +2728,22 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
convex@1.31.4:
|
||||||
|
resolution: {integrity: sha512-iDm283Gb/CFRb30cvhH6Z9qlYof6dhtin415FarKUKB3K7gumO0rn8snY0CTvUrThV3UnCtttbuL/1oY7LscyA==}
|
||||||
|
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@auth0/auth0-react': ^2.0.1
|
||||||
|
'@clerk/clerk-react': ^4.12.8 || ^5.0.0
|
||||||
|
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@auth0/auth0-react':
|
||||||
|
optional: true
|
||||||
|
'@clerk/clerk-react':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
cookie@0.5.0:
|
cookie@0.5.0:
|
||||||
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -4275,6 +4297,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
prettier@3.8.0:
|
||||||
|
resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
printable-characters@1.0.42:
|
printable-characters@1.0.42:
|
||||||
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
|
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
|
||||||
|
|
||||||
@@ -7668,6 +7695,13 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
convex@1.31.4(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.27.0
|
||||||
|
prettier: 3.8.0
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
|
||||||
cookie@0.5.0: {}
|
cookie@0.5.0: {}
|
||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
@@ -9727,6 +9761,8 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
|
prettier@3.8.0: {}
|
||||||
|
|
||||||
printable-characters@1.0.42: {}
|
printable-characters@1.0.42: {}
|
||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
process-nextick-args@2.0.1: {}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"supabaseInternalUrl": "http://127.0.0.1:18000",
|
"supabaseInternalUrl": "http://127.0.0.1:18000",
|
||||||
"supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y",
|
"supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y",
|
||||||
"backendUrl": "https://frp-dry.com:44399",
|
"backendUrl": "https://frp-dry.com:44399",
|
||||||
"onlyofficeBaseUrlWeb": "https://frp-dry.com:16630/onlyoffice-server",
|
"onlyofficeBaseUrlWeb": "/onlyoffice-server",
|
||||||
"onlyofficeBaseUrlDesktop": "http://localhost:8081",
|
"onlyofficeBaseUrlDesktop": "http://localhost:8081",
|
||||||
"onlyofficeStorageHostOverrideWeb": "",
|
"onlyofficeStorageHostOverrideWeb": "",
|
||||||
"onlyofficeStorageHostOverrideDesktop": "",
|
"onlyofficeStorageHostOverrideDesktop": "",
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 自定义 Next dev server:
|
||||||
|
* - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。
|
||||||
|
* - Next App Router 的 Route Handler 无法处理 Upgrade,因此必须在 Node http server 层做透传。
|
||||||
|
*
|
||||||
|
* 用法(保持与 next dev 类似):
|
||||||
|
* - pnpm dev -p 3000
|
||||||
|
* - node scripts/dev-server.js -p 3000
|
||||||
|
*
|
||||||
|
* 依赖环境变量:
|
||||||
|
* - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require("http");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
const next = require("next");
|
||||||
|
const { parse: parseUrl } = require("url");
|
||||||
|
|
||||||
|
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
|
||||||
|
|
||||||
|
function readArgValue(flag) {
|
||||||
|
const idx = process.argv.findIndex((x) => x === flag);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const v = process.argv[idx + 1];
|
||||||
|
if (!v || v.startsWith("-")) return null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePort() {
|
||||||
|
const fromArg = readArgValue("-p") || readArgValue("--port");
|
||||||
|
const raw = fromArg || process.env.PORT || "3000";
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? Math.max(1, Math.min(65535, Math.floor(n))) : 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHostname() {
|
||||||
|
return readArgValue("-H") || readArgValue("--hostname") || process.env.HOSTNAME || "0.0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnlyOfficePath(urlString) {
|
||||||
|
try {
|
||||||
|
const u = new URL(urlString, "http://localhost");
|
||||||
|
return u.pathname === ONLYOFFICE_PREFIX || u.pathname.startsWith(`${ONLYOFFICE_PREFIX}/`);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpstreamRequestHead(req, targetUrl) {
|
||||||
|
const incoming = new URL(req.url || "/", "http://localhost");
|
||||||
|
const rawPath = incoming.pathname || "/";
|
||||||
|
const stripped = rawPath === ONLYOFFICE_PREFIX ? "/" : rawPath.slice(ONLYOFFICE_PREFIX.length) || "/";
|
||||||
|
|
||||||
|
const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || "";
|
||||||
|
const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || "");
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`);
|
||||||
|
|
||||||
|
const headers = req.headers || {};
|
||||||
|
for (const [k, v] of Object.entries(headers)) {
|
||||||
|
if (!v) continue;
|
||||||
|
const key = String(k);
|
||||||
|
if (key.toLowerCase() === "host") continue;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
lines.push(`${key}: ${v.join(", ")}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`${key}: ${String(v)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
|
||||||
|
lines.push(`Host: ${targetUrl.host}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("");
|
||||||
|
return lines.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyOnlyOfficeUpgrade(req, socket, head) {
|
||||||
|
const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/");
|
||||||
|
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
|
||||||
|
|
||||||
|
const upstream = net.connect({ host: target.hostname, port }, () => {
|
||||||
|
try {
|
||||||
|
const reqHead = buildUpstreamRequestHead(req, target);
|
||||||
|
upstream.write(reqHead);
|
||||||
|
if (head && head.length > 0) upstream.write(head);
|
||||||
|
socket.pipe(upstream);
|
||||||
|
upstream.pipe(socket);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
upstream.destroy();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onError = (err) => {
|
||||||
|
try {
|
||||||
|
const msg = err && err.message ? String(err.message) : String(err || "");
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[dev-server][onlyoffice-ws] proxy error", msg);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
upstream.destroy();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
upstream.on("error", onError);
|
||||||
|
socket.on("error", onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const port = resolvePort();
|
||||||
|
const hostname = resolveHostname();
|
||||||
|
const dev = true;
|
||||||
|
|
||||||
|
const app = next({ dev, dir: path.join(__dirname, "..") });
|
||||||
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
|
await app.prepare();
|
||||||
|
// 说明:Next dev 的 HMR 依赖 WebSocket(/_next/webpack-hmr),需要交给 Next 自己处理 upgrade。
|
||||||
|
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null;
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
try {
|
||||||
|
res.setHeader("x-mnote-dev-server", "1");
|
||||||
|
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const parsed = parseUrl(req.url || "/", true);
|
||||||
|
handle(req, res, parsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("upgrade", (req, socket, head) => {
|
||||||
|
if (isOnlyOfficePath(req.url || "/")) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[dev-server][onlyoffice-ws] upgrade", req.url);
|
||||||
|
} catch {}
|
||||||
|
proxyOnlyOfficeUpgrade(req, socket, head);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (handleUpgrade) {
|
||||||
|
handleUpgrade(req, socket, head);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, hostname, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err instanceof Error ? err.stack : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -2,6 +2,10 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||||
import { DocumentShell } from "@/components/editor/document-shell";
|
import { DocumentShell } from "@/components/editor/document-shell";
|
||||||
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
|
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
interface DocumentPageProps {
|
interface DocumentPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -14,6 +18,49 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
|||||||
const resolvedSearch = (await searchParams) ?? {};
|
const resolvedSearch = (await searchParams) ?? {};
|
||||||
const openTableIdRaw = resolvedSearch?.openTableId;
|
const openTableIdRaw = resolvedSearch?.openTableId;
|
||||||
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
|
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id });
|
||||||
|
if (!doc) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialOptions: PageOptionsState = {
|
||||||
|
wideLayout: doc.wide_layout ?? false,
|
||||||
|
smallText: doc.use_small_text ?? false,
|
||||||
|
showHeadingNumbers: doc.show_heading_numbers ?? true,
|
||||||
|
showToc: doc.show_toc ?? false,
|
||||||
|
showStructure: doc.show_structure ?? false,
|
||||||
|
protectEditing: doc.protect_editing ?? false,
|
||||||
|
showWordCount: doc.show_word_count ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialStats: DocumentStats = {
|
||||||
|
wordCount: doc.word_count ?? 0,
|
||||||
|
characterCount: doc.character_count ?? 0,
|
||||||
|
blockCount: doc.block_count ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<DocumentShell
|
||||||
|
documentId={doc.id}
|
||||||
|
workspaceId={doc.workspace_id}
|
||||||
|
title={doc.title ?? "无标题"}
|
||||||
|
updatedAt={doc.updated_at}
|
||||||
|
initialContent={null}
|
||||||
|
initialOptions={initialOptions}
|
||||||
|
initialStats={initialStats}
|
||||||
|
openTableId={openTableId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseServerClient();
|
const supabase = await createSupabaseServerClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -10,9 +10,189 @@ import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspace
|
|||||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
import { SearchPalette } from "@/components/search/search-palette";
|
import { SearchPalette } from "@/components/search/search-palette";
|
||||||
import { detectLocalMindmapDocs, detectLocalTrashedMindmapAssets } from "@/lib/server/mindmap-files";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
|
||||||
|
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
|
||||||
|
const root = (() => {
|
||||||
|
if (!input || typeof input !== "object") return input;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const record = input as any;
|
||||||
|
return record && typeof record === "object" && "root" in record ? record.root : input;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const push = (value: unknown) => {
|
||||||
|
if (typeof value !== "string") return;
|
||||||
|
if (!value.startsWith("asset:")) return;
|
||||||
|
const id = value.slice("asset:".length).trim();
|
||||||
|
if (!id) return;
|
||||||
|
if (seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
ids.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const get = (obj: unknown, key: string): unknown => {
|
||||||
|
if (!obj || typeof obj !== "object") return undefined;
|
||||||
|
return (obj as Record<string, unknown>)[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
const data = get(node, "data");
|
||||||
|
const image = get(node, "image");
|
||||||
|
push(get(data, "image"));
|
||||||
|
push(image);
|
||||||
|
push(get(image, "url"));
|
||||||
|
push(get(get(data, "image"), "url"));
|
||||||
|
const children = get(node, "children");
|
||||||
|
if (Array.isArray(children)) children.forEach(walk);
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeId(): string {
|
||||||
|
return typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||||
|
workspaceIdIfCreate: makeId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaces = ensured.workspaces;
|
||||||
|
const activeWorkspaceId = ensured.activeWorkspaceId;
|
||||||
|
|
||||||
|
let documents: DocumentRecord[] = [];
|
||||||
|
let sidebarInitialData: SidebarInitialData | null = null;
|
||||||
|
|
||||||
|
if (activeWorkspaceId) {
|
||||||
|
const [docRows, trashedDocs] = await Promise.all([
|
||||||
|
client.query(api.documents.listByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: activeWorkspaceId,
|
||||||
|
}),
|
||||||
|
client.query(api.documents.listTrashedByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: activeWorkspaceId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
documents = docRows as unknown as DocumentRecord[];
|
||||||
|
|
||||||
|
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: activeWorkspaceId,
|
||||||
|
includeDeleted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
|
||||||
|
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
|
||||||
|
|
||||||
|
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
|
||||||
|
|
||||||
|
const mindmapAssetChildren: Record<string, string[]> = {};
|
||||||
|
activeMindmaps.forEach((r) => {
|
||||||
|
const ids = extractMindmapImageAssetIdsFromData(r.data);
|
||||||
|
if (ids.length > 0) mindmapAssetChildren[r.mindmap_id] = ids;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
|
||||||
|
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||||
|
return {
|
||||||
|
id: r.mindmap_id,
|
||||||
|
workspace_id: r.workspace_id ?? activeWorkspaceId,
|
||||||
|
document_id: r.document_id,
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||||
|
file_size: null,
|
||||||
|
mime_type: "application/json",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: r.created_at ?? "",
|
||||||
|
updated_at: r.updated_at ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => {
|
||||||
|
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||||
|
return {
|
||||||
|
id: r.mindmap_id,
|
||||||
|
workspace_id: r.workspace_id ?? activeWorkspaceId,
|
||||||
|
document_id: r.document_id,
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||||
|
file_size: null,
|
||||||
|
mime_type: "application/json",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
deleted_at: r.deleted_at ?? null,
|
||||||
|
deleted_by: r.deleted_by ?? null,
|
||||||
|
purged_at: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: r.created_at ?? "",
|
||||||
|
updated_at: r.updated_at ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
sidebarInitialData = {
|
||||||
|
activeWorkspaceId,
|
||||||
|
workspaces,
|
||||||
|
documents: docRows as unknown as DocumentRecord[],
|
||||||
|
trashedDocuments: trashedDocs as unknown as SidebarInitialData["trashedDocuments"],
|
||||||
|
trashedMediaAssets: [],
|
||||||
|
trashedMindmapAssets,
|
||||||
|
mediaAssets: [],
|
||||||
|
mindmapDocs,
|
||||||
|
mindmapAssets,
|
||||||
|
mindmapAssetChildren,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-white">
|
||||||
|
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4">
|
||||||
|
<MobileSidebarTrigger />
|
||||||
|
<Breadcrumb documents={documents} />
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||||
|
<BottomToolbar />
|
||||||
|
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseServerClient();
|
const supabase = await createSupabaseServerClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
import {
|
import {
|
||||||
buildClientToolKey,
|
buildClientToolKey,
|
||||||
resolveClientToolCall,
|
resolveClientToolCall,
|
||||||
@@ -26,11 +28,26 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "缺少 requestId/callId" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 requestId/callId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const userId = (() => {
|
||||||
const {
|
if (isConvexEnabled()) {
|
||||||
data: { session },
|
const auth = requireAuthContext();
|
||||||
} = await supabase.auth.getSession();
|
return auth.userId;
|
||||||
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const resolvedUserId = async () => {
|
||||||
|
if (userId) return userId;
|
||||||
|
const supabase = await createSupabaseRouteClient();
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
return session.user.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalUserId = await resolvedUserId();
|
||||||
|
if (!finalUserId) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
|
||||||
const result: ClientToolResult = payload.ok
|
const result: ClientToolResult = payload.ok
|
||||||
? { ok: true, result: "result" in payload ? payload.result : null }
|
? { ok: true, result: "result" in payload ? payload.result : null }
|
||||||
@@ -39,7 +56,7 @@ export async function POST(request: Request) {
|
|||||||
const key = buildClientToolKey(requestId, callId);
|
const key = buildClientToolKey(requestId, callId);
|
||||||
const resolved = resolveClientToolCall({
|
const resolved = resolveClientToolCall({
|
||||||
key,
|
key,
|
||||||
userId: session.user.id,
|
userId: finalUserId,
|
||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
@@ -48,4 +65,3 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/
|
|||||||
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
|
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
|
||||||
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
|
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
|
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
|
||||||
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
|
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
|
||||||
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
|
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
|
||||||
@@ -14,6 +15,8 @@ import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent
|
|||||||
import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools";
|
import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools";
|
||||||
import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools";
|
import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools";
|
||||||
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
|
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -70,12 +73,21 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "缺少 messages" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 messages" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1:先要求登录(避免在生产环境暴露推理能力);后续可做更细的权限控制
|
// v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session)
|
||||||
const supabase = await createSupabaseRouteClient();
|
const convexOn = isConvexEnabled();
|
||||||
const {
|
const { userId, supabase, convexClient } = await (async () => {
|
||||||
data: { session },
|
if (convexOn) {
|
||||||
} = await supabase.auth.getSession();
|
const { auth, client } = getAuthedConvexClient();
|
||||||
if (!session) {
|
return { userId: auth.userId, supabase: null as any, convexClient: client };
|
||||||
|
}
|
||||||
|
const supabase = await createSupabaseRouteClient();
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
if (!session) return { userId: "", supabase: null as any, convexClient: null as any };
|
||||||
|
return { userId: session.user.id, supabase, convexClient: null as any };
|
||||||
|
})();
|
||||||
|
if (!userId) {
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +179,25 @@ export async function POST(request: Request) {
|
|||||||
allowedToolIds.delete("doc_replace_range");
|
allowedToolIds.delete("doc_replace_range");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 说明:Convex 迁移阶段(M4)先确保“不会再触发 Supabase 依赖”。
|
||||||
|
// 未迁移的能力(OnlyOffice 等)在 Convex 模式下直接禁用对应工具。
|
||||||
|
if (convexOn) {
|
||||||
|
for (const id of [...allowedToolIds]) {
|
||||||
|
if (
|
||||||
|
id === "search_web" ||
|
||||||
|
id === "image_read" ||
|
||||||
|
id === "slash_run" ||
|
||||||
|
id.startsWith("rag_") ||
|
||||||
|
id.startsWith("mindmap_") ||
|
||||||
|
id.startsWith("doc_") ||
|
||||||
|
id.startsWith("docs_")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
allowedToolIds.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const systemContextText = (() => {
|
const systemContextText = (() => {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (documentId) lines.push(`documentId=${documentId}`);
|
if (documentId) lines.push(`documentId=${documentId}`);
|
||||||
@@ -181,12 +212,67 @@ export async function POST(request: Request) {
|
|||||||
return lines.join("\n").trim();
|
return lines.join("\n").trim();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const normalizeBlocksForTools = (content: unknown): unknown[] => {
|
||||||
|
if (Array.isArray(content)) return content;
|
||||||
|
if (content && typeof content === "object" && "blocks" in (content as any)) {
|
||||||
|
const blocks = (content as any).blocks;
|
||||||
|
if (Array.isArray(blocks)) return blocks;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractPlainTextFromBlocks = (blocks: unknown[], maxChars: number) => {
|
||||||
|
const pieces: string[] = [];
|
||||||
|
const walk = (list: unknown[]) => {
|
||||||
|
for (const b of list) {
|
||||||
|
if (!b || typeof b !== "object") continue;
|
||||||
|
const content = (b as any).content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const n of content) {
|
||||||
|
const t = n && typeof n === "object" ? String((n as any).text ?? "") : "";
|
||||||
|
if (t) pieces.push(t);
|
||||||
|
if (pieces.join("").length >= maxChars) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const children = (b as any).children;
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
walk(children);
|
||||||
|
if (pieces.join("").length >= maxChars) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(blocks);
|
||||||
|
const raw = pieces.join("").replace(/\s+/g, " ").trim();
|
||||||
|
return raw.length > maxChars ? `${raw.slice(0, maxChars)}…` : raw;
|
||||||
|
};
|
||||||
|
|
||||||
const mindmapTools = hasMindmapContext
|
const mindmapTools = hasMindmapContext
|
||||||
? createMindmapServerTools({
|
? createMindmapServerTools({
|
||||||
supabase: supabase as unknown as MindmapSupabaseClient,
|
supabase: supabase as unknown as MindmapSupabaseClient,
|
||||||
ctx: { documentId, mindmapId, userId: session.user.id, selectedUids, attachments },
|
ctx: { documentId, mindmapId, userId, selectedUids, attachments },
|
||||||
cfg: { ...cfg, model: modelOverride ?? cfg.model },
|
cfg: { ...cfg, model: modelOverride ?? cfg.model },
|
||||||
allowedToolIds,
|
allowedToolIds,
|
||||||
|
...(convexOn
|
||||||
|
? {
|
||||||
|
loadMindmap: async () => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
const [mm, meta] = await Promise.all([
|
||||||
|
convexClient.query(api.mindmaps.get, { userId, docId: documentId, mindmapId }),
|
||||||
|
convexClient.query(api.documents.getMeta, { userId, id: documentId }),
|
||||||
|
]);
|
||||||
|
const title = meta?.title ?? null;
|
||||||
|
const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null;
|
||||||
|
return {
|
||||||
|
doc: { id: documentId, title, workspace_id: workspaceId },
|
||||||
|
base: (mm as any)?.data ?? { data: { text: "中心主题" }, children: [] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
saveMindmap: async ({ data }) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
await convexClient.mutation(api.mindmaps.put, { userId, docId: documentId, mindmapId, data });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -198,14 +284,30 @@ export async function POST(request: Request) {
|
|||||||
allowedToolIds.has("doc_replace_range"))
|
allowedToolIds.has("doc_replace_range"))
|
||||||
? createDocServerTools({
|
? createDocServerTools({
|
||||||
supabase: supabase as unknown as DocSupabaseClient,
|
supabase: supabase as unknown as DocSupabaseClient,
|
||||||
ctx: { documentId, userId: session.user.id, baseBlocks: documentBlocks },
|
ctx: { documentId, userId, baseBlocks: documentBlocks },
|
||||||
allowedToolIds,
|
allowedToolIds,
|
||||||
|
...(convexOn
|
||||||
|
? {
|
||||||
|
loadBlocks: async () => {
|
||||||
|
const base = normalizeBlocksForTools(documentBlocks);
|
||||||
|
if (base.length > 0) return { blocks: base, source: "client" };
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
const res = await convexClient.query(api.documents.getContent, { userId, id: documentId });
|
||||||
|
const blocks = normalizeBlocksForTools(res?.content ?? null);
|
||||||
|
return { blocks, source: "convex" };
|
||||||
|
},
|
||||||
|
saveBlocks: async (blocks: unknown[]) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
await convexClient.mutation(api.documents.updateContent, { userId, id: documentId, content: blocks });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const ragTools = allowedToolIds.has("rag_lightrag_query")
|
const ragTools = allowedToolIds.has("rag_lightrag_query")
|
||||||
? createRagServerTools({
|
? createRagServerTools({
|
||||||
ctx: { userId: session.user.id },
|
ctx: { userId },
|
||||||
allowedToolIds,
|
allowedToolIds,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -214,24 +316,88 @@ export async function POST(request: Request) {
|
|||||||
allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read")
|
allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read")
|
||||||
? createDocsServerTools({
|
? createDocsServerTools({
|
||||||
supabase: supabase as unknown as DocsSupabaseClient,
|
supabase: supabase as unknown as DocsSupabaseClient,
|
||||||
ctx: { userId: session.user.id },
|
ctx: { userId },
|
||||||
allowedToolIds,
|
allowedToolIds,
|
||||||
|
...(convexOn
|
||||||
|
? {
|
||||||
|
searchDocs: async ({ query, limit, workspaceId, includeDeleted }) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
const wsIds = workspaceId
|
||||||
|
? [workspaceId]
|
||||||
|
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId }))?.workspaces ?? []).map((w: any) =>
|
||||||
|
String(w?.id ?? ""),
|
||||||
|
);
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const results: any[] = [];
|
||||||
|
for (const wid of wsIds.filter(Boolean)) {
|
||||||
|
const docs = await convexClient.query(api.documents.listByWorkspace, { userId, workspaceId: wid });
|
||||||
|
const extra = includeDeleted
|
||||||
|
? await convexClient.query(api.documents.listTrashedByWorkspace, { userId, workspaceId: wid }).catch(() => [])
|
||||||
|
: [];
|
||||||
|
const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])];
|
||||||
|
for (const d of all) {
|
||||||
|
const title = String((d as any)?.title ?? "");
|
||||||
|
if (!title.toLowerCase().includes(q)) continue;
|
||||||
|
results.push({
|
||||||
|
id: String((d as any)?.id ?? ""),
|
||||||
|
title,
|
||||||
|
workspaceId: String((d as any)?.workspace_id ?? wid),
|
||||||
|
parentId: (d as any)?.parent_id ? String((d as any).parent_id) : null,
|
||||||
|
updatedAt: (d as any)?.updated_at ?? null,
|
||||||
|
snippet: title.slice(0, 120),
|
||||||
|
});
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
}
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
}
|
||||||
|
return results.slice(0, limit);
|
||||||
|
},
|
||||||
|
readDoc: async ({ documentId: rid, maxChars, includeContent }) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
const meta = await convexClient.query(api.documents.getMeta, { userId, id: rid });
|
||||||
|
if (!meta) throw new Error("页面不存在");
|
||||||
|
const contentRes = await convexClient.query(api.documents.getContent, { userId, id: rid });
|
||||||
|
const blocks = normalizeBlocksForTools(contentRes?.content ?? null);
|
||||||
|
const rawText = extractPlainTextFromBlocks(blocks, maxChars);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
documentId: rid,
|
||||||
|
title: String(meta.title ?? ""),
|
||||||
|
workspaceId: String((meta as any).workspace_id ?? ""),
|
||||||
|
parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null,
|
||||||
|
updatedAt: (meta as any).updated_at ?? null,
|
||||||
|
rawTextLength: rawText.length,
|
||||||
|
rawText,
|
||||||
|
...(includeContent ? { content: contentRes?.content ?? null } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const mediaTools = allowedToolIds.has("image_read")
|
const mediaTools = allowedToolIds.has("image_read")
|
||||||
? createMediaServerTools({
|
? createMediaServerTools({
|
||||||
supabase: supabase as unknown as MediaSupabaseClient,
|
supabase: supabase as unknown as MediaSupabaseClient,
|
||||||
ctx: { userId: session.user.id, attachments },
|
ctx: { userId, attachments },
|
||||||
allowedToolIds,
|
allowedToolIds,
|
||||||
|
...(convexOn
|
||||||
|
? {
|
||||||
|
loadById: async (id: string) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
return await convexClient.query(api.mediaAssets.getById, { userId, id });
|
||||||
|
},
|
||||||
|
loadByFileUrl: async (_fileUrl: string) => null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const onlyofficeTools =
|
const onlyofficeTools =
|
||||||
allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap")
|
!convexOn && (allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap"))
|
||||||
? createOnlyOfficeServerTools({
|
? createOnlyOfficeServerTools({
|
||||||
supabase: supabase as unknown as OnlyOfficeSupabaseClient,
|
supabase: supabase as unknown as OnlyOfficeSupabaseClient,
|
||||||
ctx: { userId: session.user.id, documentId: documentId || undefined, attachments },
|
ctx: { userId, documentId: documentId || undefined, attachments },
|
||||||
allowedToolIds,
|
allowedToolIds,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -239,8 +405,56 @@ export async function POST(request: Request) {
|
|||||||
const slashTools = allowedToolIds.has("slash_run")
|
const slashTools = allowedToolIds.has("slash_run")
|
||||||
? createSlashServerTools({
|
? createSlashServerTools({
|
||||||
supabase: supabase as unknown as SlashSupabaseClient,
|
supabase: supabase as unknown as SlashSupabaseClient,
|
||||||
ctx: { userId: session.user.id, currentDocumentId: documentId || undefined },
|
ctx: { userId, currentDocumentId: documentId || undefined },
|
||||||
allowedToolIds,
|
allowedToolIds,
|
||||||
|
...(convexOn
|
||||||
|
? {
|
||||||
|
loadWorkspaceIds: async (uid: string) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId: uid });
|
||||||
|
return (res?.workspaces ?? []).map((w: any) => String(w?.id ?? "")).filter(Boolean);
|
||||||
|
},
|
||||||
|
inferWorkspaceIdFromDoc: async (docId: string) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
const meta = await convexClient.query(api.documents.getMeta, { userId, id: docId });
|
||||||
|
return meta ? String((meta as any).workspace_id ?? "") || null : null;
|
||||||
|
},
|
||||||
|
createDoc: async ({ workspaceId, parentId, title }) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `doc_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
const created = await convexClient.mutation(api.documents.create, {
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
title,
|
||||||
|
accessScope: "private",
|
||||||
|
content: [],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: String((created as any).id ?? id),
|
||||||
|
title: String((created as any).title ?? title),
|
||||||
|
workspaceId: String((created as any).workspace_id ?? workspaceId),
|
||||||
|
parentId: (created as any).parent_id ? String((created as any).parent_id) : parentId,
|
||||||
|
createdAt: (created as any).created_at ?? null,
|
||||||
|
updatedAt: (created as any).updated_at ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
renameDoc: async ({ documentId: did, title }) => {
|
||||||
|
if (!convexClient) throw new Error("Convex 未初始化");
|
||||||
|
await convexClient.mutation(api.documents.updateTitle, { userId, id: did, title });
|
||||||
|
const meta = await convexClient.query(api.documents.getMeta, { userId, id: did });
|
||||||
|
if (!meta) throw new Error("页面不存在");
|
||||||
|
return {
|
||||||
|
id: String((meta as any).id ?? did),
|
||||||
|
title: String((meta as any).title ?? title),
|
||||||
|
workspaceId: String((meta as any).workspace_id ?? ""),
|
||||||
|
parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null,
|
||||||
|
updatedAt: (meta as any).updated_at ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -323,11 +537,11 @@ export async function POST(request: Request) {
|
|||||||
if (isOnlyOfficeClientTool(toolId)) {
|
if (isOnlyOfficeClientTool(toolId)) {
|
||||||
const callId = lastToolCall?.tool === toolId ? lastToolCall.id : `call_${Date.now()}`;
|
const callId = lastToolCall?.tool === toolId ? lastToolCall.id : `call_${Date.now()}`;
|
||||||
const key = buildClientToolKey(requestId, callId);
|
const key = buildClientToolKey(requestId, callId);
|
||||||
const wait = registerClientToolCall({
|
const wait = registerClientToolCall({
|
||||||
key,
|
key,
|
||||||
userId: session.user.id,
|
userId,
|
||||||
timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
|
timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result
|
// 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result
|
||||||
send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs });
|
send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs });
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||||
|
|
||||||
|
type EmbedBlockPayload = {
|
||||||
|
sourceDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
targetDocumentId: string;
|
||||||
|
position?: "end";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { sourceDocumentId, blockId, targetDocumentId }: EmbedBlockPayload = await request.json();
|
||||||
|
|
||||||
|
if (!sourceDocumentId || !blockId || !targetDocumentId) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceDocumentId === targetDocumentId) {
|
||||||
|
return NextResponse.json({ error: "禁止嵌入到当前页面" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||||
|
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
|
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
||||||
|
const hit = findBlockInTree(sourceBlocks, blockId);
|
||||||
|
if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId });
|
||||||
|
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||||
|
const referenceBlock = {
|
||||||
|
id: randomUUID(),
|
||||||
|
type: "blockReference",
|
||||||
|
props: {
|
||||||
|
sourceDocumentId,
|
||||||
|
targetBlockId: blockId,
|
||||||
|
display: "embed",
|
||||||
|
},
|
||||||
|
content: [],
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextBlocks = [...targetBlocks, referenceBlock];
|
||||||
|
const payload = withBlocksWrittenBack(target.content, nextBlocks);
|
||||||
|
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: payload });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createSupabaseRouteClient();
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: sourceDoc } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,content,user_id")
|
||||||
|
.eq("id", sourceDocumentId)
|
||||||
|
.eq("user_id", session.user.id)
|
||||||
|
.single();
|
||||||
|
if (!sourceDoc) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const sourceBlocks = getBlocksFromDocumentContent(sourceDoc.content);
|
||||||
|
if (!findBlockInTree(sourceBlocks, blockId)) {
|
||||||
|
return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: targetDoc } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,content,user_id")
|
||||||
|
.eq("id", targetDocumentId)
|
||||||
|
.eq("user_id", session.user.id)
|
||||||
|
.single();
|
||||||
|
if (!targetDoc) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const targetBlocks = getBlocksFromDocumentContent(targetDoc.content);
|
||||||
|
const referenceBlock = {
|
||||||
|
id: randomUUID(),
|
||||||
|
type: "blockReference",
|
||||||
|
props: {
|
||||||
|
sourceDocumentId,
|
||||||
|
targetBlockId: blockId,
|
||||||
|
display: "embed",
|
||||||
|
},
|
||||||
|
content: [],
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextBlocks = [...targetBlocks, referenceBlock];
|
||||||
|
const payload = withBlocksWrittenBack(targetDoc.content, nextBlocks);
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.update({ content: payload })
|
||||||
|
.eq("id", targetDocumentId)
|
||||||
|
.eq("user_id", session.user.id);
|
||||||
|
|
||||||
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const sourceDocumentId = url.searchParams.get("sourceDocumentId") || "";
|
||||||
|
const blockId = url.searchParams.get("blockId") || "";
|
||||||
|
|
||||||
|
if (!sourceDocumentId || !blockId) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||||
|
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||||
|
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||||
|
const hit = findBlockInTree(blocks, blockId);
|
||||||
|
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||||
|
return NextResponse.json({ ok: true, block: hit.block });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createSupabaseRouteClient();
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
|
||||||
|
const { data: doc } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,content,user_id")
|
||||||
|
.eq("id", sourceDocumentId)
|
||||||
|
.eq("user_id", session.user.id)
|
||||||
|
.single();
|
||||||
|
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||||
|
const hit = findBlockInTree(blocks, blockId);
|
||||||
|
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, block: hit.block });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||||
|
|
||||||
|
type MoveBlockPayload = {
|
||||||
|
sourceDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
targetDocumentId: string;
|
||||||
|
position?: "end";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { sourceDocumentId, blockId, targetDocumentId }: MoveBlockPayload = await request.json();
|
||||||
|
|
||||||
|
if (!sourceDocumentId || !blockId || !targetDocumentId) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceDocumentId === targetDocumentId) {
|
||||||
|
// 说明:同页移动先不做“定位插入”,视为 no-op。
|
||||||
|
return NextResponse.json({ ok: true, noop: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||||
|
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
|
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
||||||
|
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
|
||||||
|
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId });
|
||||||
|
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||||
|
|
||||||
|
const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks);
|
||||||
|
const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]);
|
||||||
|
|
||||||
|
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: nextSourcePayload });
|
||||||
|
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: nextTargetPayload });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createSupabaseRouteClient();
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: sourceDoc } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,content,user_id")
|
||||||
|
.eq("id", sourceDocumentId)
|
||||||
|
.eq("user_id", session.user.id)
|
||||||
|
.single();
|
||||||
|
if (!sourceDoc) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const sourceBlocks = getBlocksFromDocumentContent(sourceDoc.content);
|
||||||
|
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
|
||||||
|
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const { data: targetDoc } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,content,user_id")
|
||||||
|
.eq("id", targetDocumentId)
|
||||||
|
.eq("user_id", session.user.id)
|
||||||
|
.single();
|
||||||
|
if (!targetDoc) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const targetBlocks = getBlocksFromDocumentContent(targetDoc.content);
|
||||||
|
const nextSourcePayload = withBlocksWrittenBack(sourceDoc.content, removedRes.nextBlocks);
|
||||||
|
const nextTargetPayload = withBlocksWrittenBack(targetDoc.content, [...targetBlocks, removedRes.removed]);
|
||||||
|
|
||||||
|
const { error: srcErr } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.update({ content: nextSourcePayload })
|
||||||
|
.eq("id", sourceDocumentId)
|
||||||
|
.eq("user_id", session.user.id);
|
||||||
|
if (srcErr) return NextResponse.json({ error: srcErr.message }, { status: 500 });
|
||||||
|
|
||||||
|
const { error: tgtErr } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.update({ content: nextTargetPayload })
|
||||||
|
.eq("id", targetDocumentId)
|
||||||
|
.eq("user_id", session.user.id);
|
||||||
|
if (tgtErr) return NextResponse.json({ error: tgtErr.message }, { status: 500 });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||||
|
|
||||||
|
type PatchPayload = {
|
||||||
|
sourceDocumentId: string;
|
||||||
|
blockId: string;
|
||||||
|
nextBlock: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { sourceDocumentId, blockId, nextBlock }: PatchPayload = await request.json();
|
||||||
|
|
||||||
|
if (!sourceDocumentId || !blockId || !nextBlock) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||||
|
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||||
|
const replaced = replaceBlockInTree(blocks, blockId, nextBlock as any);
|
||||||
|
if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
|
||||||
|
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: payload });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createSupabaseRouteClient();
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
|
||||||
|
const { data: doc } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.select("id,content,user_id")
|
||||||
|
.eq("id", sourceDocumentId)
|
||||||
|
.eq("user_id", session.user.id)
|
||||||
|
.single();
|
||||||
|
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||||
|
const replaced = replaceBlockInTree(blocks, blockId, nextBlock as any);
|
||||||
|
if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||||
|
|
||||||
|
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.update({ content: payload })
|
||||||
|
.eq("id", sourceDocumentId)
|
||||||
|
.eq("user_id", session.user.id);
|
||||||
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const { ms }: { ms?: number } = await request.json().catch(() => ({}));
|
||||||
|
const id = randomUUID();
|
||||||
|
|
||||||
|
const result = await client.mutation(api.jobs.enqueueDemo, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id,
|
||||||
|
ms,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const id = url.searchParams.get("id") ?? "";
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await client.query(api.jobs.get, { userId: auth.userId, id });
|
||||||
|
if (!job) {
|
||||||
|
return NextResponse.json({ error: "任务不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(job);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
return NextResponse.json({ ok: true, auth }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof HttpError ? err.status : 500;
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json({ ok: false, error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,35 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const documentId = url.searchParams.get("documentId") ?? "";
|
||||||
|
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const result = await client.query(api.documents.getContent, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ content: result.content ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -37,4 +63,3 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ content: document.content ?? null });
|
return NextResponse.json({ content: document.content ?? null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import type { Json } from "@/types/supabase";
|
import type { Json } from "@/types/supabase";
|
||||||
@@ -158,6 +162,142 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const payload = (await request.json()) as CopyTreePayload;
|
||||||
|
if (!payload?.items?.length) {
|
||||||
|
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedItems = payload.items.filter((it) => it?.documentId);
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetParentId = payload.targetParentId ?? null;
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
|
||||||
|
if (targetParentId) {
|
||||||
|
const targetDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: targetParentId });
|
||||||
|
if (!targetDoc) {
|
||||||
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
workspaceId = targetDoc.workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
||||||
|
const firstMeta = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceIds[0] });
|
||||||
|
if (!firstMeta) {
|
||||||
|
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
workspaceId = firstMeta.workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wid = workspaceId;
|
||||||
|
|
||||||
|
const allDocs = await client.query(api.documents.listAllForCopy, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: wid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceById = new Map<string, DocRow>();
|
||||||
|
(allDocs as unknown as DocRow[]).forEach((d) => sourceById.set(d.id, d));
|
||||||
|
|
||||||
|
const missing = sourceIds.find((id) => !sourceById.has(id));
|
||||||
|
if (missing) {
|
||||||
|
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenByParent = new Map<string | null, DocRow[]>();
|
||||||
|
(allDocs as unknown as DocRow[]).forEach((doc) => {
|
||||||
|
const list = childrenByParent.get(doc.parent_id) ?? [];
|
||||||
|
list.push(doc);
|
||||||
|
childrenByParent.set(doc.parent_id, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
||||||
|
const seedTitleSet = (parent: string | null) => {
|
||||||
|
if (existingTitleSetByParent.has(parent)) return;
|
||||||
|
const titles = new Set<string>();
|
||||||
|
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
||||||
|
existingTitleSetByParent.set(parent, titles);
|
||||||
|
};
|
||||||
|
seedTitleSet(targetParentId);
|
||||||
|
|
||||||
|
const newIdByOldId = new Map<string, string>();
|
||||||
|
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
|
||||||
|
|
||||||
|
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
|
||||||
|
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
|
||||||
|
const newId =
|
||||||
|
typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
newIdByOldId.set(node.id, newId);
|
||||||
|
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
|
||||||
|
if (!recursive) return;
|
||||||
|
const children = getChildrenSorted(childrenByParent, node.id);
|
||||||
|
children.forEach((child) => visit(child, newId, newId));
|
||||||
|
};
|
||||||
|
visit(root, newParent, newParent);
|
||||||
|
};
|
||||||
|
|
||||||
|
normalizedItems.forEach((item) => {
|
||||||
|
const doc = sourceById.get(item.documentId) ?? null;
|
||||||
|
if (doc) {
|
||||||
|
enqueueTree(doc, targetParentId, Boolean(item.recursive));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (copyQueue.length === 0) {
|
||||||
|
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
||||||
|
|
||||||
|
for (const item of copyQueue) {
|
||||||
|
const newId = newIdByOldId.get(item.old.id)!;
|
||||||
|
const parentId = item.newParentId;
|
||||||
|
|
||||||
|
if (!existingTitleSetByParent.has(parentId)) {
|
||||||
|
seedTitleSet(parentId);
|
||||||
|
}
|
||||||
|
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
||||||
|
existingTitleSetByParent.set(parentId, titleSet);
|
||||||
|
|
||||||
|
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||||
|
titleSet.add(newTitle);
|
||||||
|
|
||||||
|
await client.mutation(api.documents.create, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: newId,
|
||||||
|
workspaceId: wid,
|
||||||
|
parentId,
|
||||||
|
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
|
||||||
|
title: newTitle,
|
||||||
|
content: item.old.content ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureDocumentScaffold(newId, newTitle);
|
||||||
|
await client.mutation(api.mindmaps.copyByDocument, {
|
||||||
|
userId: auth.userId,
|
||||||
|
sourceDocId: item.old.id,
|
||||||
|
targetDocId: newId,
|
||||||
|
});
|
||||||
|
insertedDocs.push({ oldId: item.old.id, newId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
|
function makeId(): string {
|
||||||
|
return typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
type CreateChildPayload = {
|
type CreateChildPayload = {
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
@@ -11,6 +21,58 @@ type CreateChildPayload = {
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
||||||
|
|
||||||
|
if (typeof parentId === "undefined") {
|
||||||
|
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspaceId: string;
|
||||||
|
let accessScope: "private" | "shared" | "public" = "private";
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
const parentDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: parentId });
|
||||||
|
if (!parentDoc) {
|
||||||
|
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
workspaceId = parentDoc.workspace_id;
|
||||||
|
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||||
|
} else {
|
||||||
|
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||||
|
workspaceIdIfCreate: makeId(),
|
||||||
|
});
|
||||||
|
workspaceId = ensured.activeWorkspaceId;
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTitle = title && title.trim().length > 0 ? title.trim() : "未命名页面";
|
||||||
|
const contentPayload = Array.isArray(blocks) ? blocks : [];
|
||||||
|
const pageId = makeId();
|
||||||
|
|
||||||
|
const created = await client.mutation(api.documents.create, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: pageId,
|
||||||
|
workspaceId,
|
||||||
|
parentId: parentId ?? null,
|
||||||
|
title: resolvedTitle,
|
||||||
|
accessScope,
|
||||||
|
content: contentPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
pageId: created.id,
|
||||||
|
title: created.title ?? resolvedTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/docume
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
|
||||||
const documentsBaseDir = getDocumentsBaseDir();
|
const documentsBaseDir = getDocumentsBaseDir();
|
||||||
|
|
||||||
@@ -25,6 +29,9 @@ async function ensureDocumentScaffold(id: string, title: string | null) {
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
return await handleCreateRequestConvex(request);
|
||||||
|
}
|
||||||
return await handleCreateRequest(request);
|
return await handleCreateRequest(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("创建页面失败", error);
|
console.error("创建页面失败", error);
|
||||||
@@ -33,6 +40,83 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateRequestConvex(request: Request) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const { parentId }: { parentId?: string | null } = await request.json();
|
||||||
|
|
||||||
|
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||||
|
workspaceIdIfCreate: randomUUID(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
let parentContent: Json | null = null;
|
||||||
|
let accessScope: "private" | "shared" | "public" = "private";
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
const parentDoc = await client.query(api.documents.getMeta, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: parentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parentDoc) {
|
||||||
|
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceId = parentDoc.workspace_id;
|
||||||
|
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||||
|
const parentContentRes = await client.query(api.documents.getContent, { userId: auth.userId, id: parentId });
|
||||||
|
parentContent = (parentContentRes?.content as Json | null) ?? null;
|
||||||
|
} else {
|
||||||
|
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const data = await client.mutation(api.documents.create, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
parentId: parentId ?? null,
|
||||||
|
title: "无标题",
|
||||||
|
accessScope,
|
||||||
|
content: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为文件树创建本地目录和 index.md
|
||||||
|
if (data?.id) {
|
||||||
|
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId && data) {
|
||||||
|
const existingBlocks = extractBlocksFromContent(parentContent);
|
||||||
|
const pageReferenceBlock = {
|
||||||
|
id: randomUUID(),
|
||||||
|
type: "pageReference",
|
||||||
|
props: {
|
||||||
|
pageId: data.id,
|
||||||
|
title: data.title ?? "无标题",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
||||||
|
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
||||||
|
|
||||||
|
await client.mutation(api.documents.updateContent, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: parentId,
|
||||||
|
content: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateRequest(request: Request) {
|
async function handleCreateRequest(request: Request) {
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { documentId } = await request.json();
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.softDelete, { userId: auth.userId, id: documentId });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||||
@@ -43,6 +47,50 @@ async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const { documentId }: DuplicatePayload = await request.json();
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTitle =
|
||||||
|
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
||||||
|
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||||
|
|
||||||
|
const newId = typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
const duplicated = await client.mutation(api.documents.duplicate, {
|
||||||
|
userId: auth.userId,
|
||||||
|
sourceId: documentId,
|
||||||
|
newId,
|
||||||
|
title: duplicatedTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||||
|
await client.mutation(api.mindmaps.copyByDocument, {
|
||||||
|
userId: auth.userId,
|
||||||
|
sourceDocId: documentId,
|
||||||
|
targetDocId: duplicated.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: duplicated.id,
|
||||||
|
title: duplicated.title ?? duplicatedTitle,
|
||||||
|
parent_id: duplicated.parent_id ?? null,
|
||||||
|
sort_order: duplicated.sort_order ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { NextResponse } from "next/server";
|
|||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
import type { Json } from "@/types/supabase";
|
import type { Json } from "@/types/supabase";
|
||||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
interface EmbedPayload {
|
interface EmbedPayload {
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
@@ -10,6 +13,49 @@ interface EmbedPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
|
||||||
|
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||||
|
|
||||||
|
if (!sourceId || !targetId) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetContent = await client.query(api.documents.getContent, { userId: auth.userId, id: targetId });
|
||||||
|
if (!targetContent) {
|
||||||
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||||
|
const nextBlocks: Json[] = [
|
||||||
|
...currentBlocks,
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
type: "pageReference",
|
||||||
|
props: {
|
||||||
|
pageId: sourceDoc.id,
|
||||||
|
title: sourceDoc.title ?? "无标题",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||||
|
|
||||||
|
await client.mutation(api.documents.updateContent, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: targetId,
|
||||||
|
content: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -8,6 +12,17 @@ interface EmptyTrashPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.emptyTrashByWorkspace, { userId: auth.userId, workspaceId });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
interface MovePayload {
|
interface MovePayload {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -8,6 +12,20 @@ interface MovePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||||
|
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.move, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: documentId,
|
||||||
|
parentId,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
import type { PageOptionsState } from "@/types/page-options";
|
import type { PageOptionsState } from "@/types/page-options";
|
||||||
import type { Database } from "@/types/supabase";
|
import type { Database } from "@/types/supabase";
|
||||||
|
|
||||||
@@ -19,6 +23,31 @@ const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Table
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { documentId, options }: OptionsPayload = await request.json();
|
||||||
|
if (!documentId || !options) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.updateOptions, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: documentId,
|
||||||
|
options: {
|
||||||
|
wideLayout: options.wideLayout,
|
||||||
|
smallText: options.smallText,
|
||||||
|
showHeadingNumbers: options.showHeadingNumbers,
|
||||||
|
showToc: options.showToc,
|
||||||
|
showStructure: options.showStructure,
|
||||||
|
protectEditing: options.protectEditing,
|
||||||
|
showWordCount: options.showWordCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { documentId } = await request.json();
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.purge, { userId: auth.userId, id: documentId });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { documentId } = await request.json();
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.restore, { userId: auth.userId, id: documentId });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
|
||||||
interface SavePayload {
|
interface SavePayload {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -7,6 +11,18 @@ interface SavePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { documentId, content }: SavePayload = await request.json();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.updateContent, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: documentId,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
import type { DocumentStats } from "@/types/page-options";
|
import type { DocumentStats } from "@/types/page-options";
|
||||||
|
|
||||||
interface StatsPayload {
|
interface StatsPayload {
|
||||||
@@ -8,6 +12,25 @@ interface StatsPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { documentId, stats }: StatsPayload = await request.json();
|
||||||
|
if (!documentId || !stats) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.updateStats, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: documentId,
|
||||||
|
wordCount: stats.wordCount,
|
||||||
|
characterCount: stats.characterCount,
|
||||||
|
blockCount: stats.blockCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
|
||||||
interface RenamePayload {
|
interface RenamePayload {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -7,6 +11,18 @@ interface RenamePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const { documentId, title }: RenamePayload = await request.json();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.documents.updateTitle, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: documentId,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,10 +1,46 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = requireAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const workspaceId = searchParams.get("workspaceId");
|
||||||
|
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
|
||||||
|
const assetType = searchParams.get("assetType") ?? undefined;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const items = await client.query(api.mediaAssets.listByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId,
|
||||||
|
assetType,
|
||||||
|
includeDeleted: false,
|
||||||
|
limit: Number.isNaN(limit) ? 12 : limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
@@ -46,6 +82,75 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = requireAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await request.json()) as {
|
||||||
|
workspaceId: string;
|
||||||
|
documentId: string;
|
||||||
|
fileUrl: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
assetType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
|
||||||
|
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetId =
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const asset: MediaAsset = {
|
||||||
|
id: assetId,
|
||||||
|
workspace_id: payload.workspaceId,
|
||||||
|
document_id: payload.documentId,
|
||||||
|
file_url: payload.fileUrl,
|
||||||
|
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
|
||||||
|
asset_type: payload.assetType ?? "image",
|
||||||
|
file_name: payload.fileName ?? null,
|
||||||
|
file_size: payload.fileSize ?? null,
|
||||||
|
mime_type: payload.mimeType ?? null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.mediaAssets.create, {
|
||||||
|
userId: auth.userId,
|
||||||
|
asset: {
|
||||||
|
id: asset.id,
|
||||||
|
workspace_id: asset.workspace_id,
|
||||||
|
document_id: asset.document_id,
|
||||||
|
asset_type: asset.asset_type,
|
||||||
|
file_url: asset.file_url,
|
||||||
|
thumbnail_url: asset.thumbnail_url,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: asset.file_name,
|
||||||
|
file_size: asset.file_size,
|
||||||
|
mime_type: asset.mime_type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ asset });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
import { extname } from "path";
|
|
||||||
import { makeUniqueFileName } from "@/lib/file-tree/naming";
|
import { makeUniqueFileName } from "@/lib/file-tree/naming";
|
||||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -65,6 +68,151 @@ function sanitizeSubPath(input: string | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = requireAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await request.json()) as BatchPayload;
|
||||||
|
if (!payload?.action || !payload.assetIds?.length) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const assets = (await client.query(api.mediaAssets.listByIds, {
|
||||||
|
userId: auth.userId,
|
||||||
|
ids: payload.assetIds,
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
if (!assets?.length) {
|
||||||
|
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (payload.action) {
|
||||||
|
case "delete": {
|
||||||
|
for (const a of assets) {
|
||||||
|
await client.mutation(api.mediaAssets.patchById, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: String(a.id),
|
||||||
|
patch: { deleted_at: nowIso(), deleted_by: auth.userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
case "restore": {
|
||||||
|
for (const a of assets) {
|
||||||
|
await client.mutation(api.mediaAssets.patchById, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: String(a.id),
|
||||||
|
patch: { deleted_at: null, deleted_by: null, purged_at: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
case "rename": {
|
||||||
|
if (payload.assetIds.length !== 1 || !payload.newName) {
|
||||||
|
return NextResponse.json({ error: "重命名需要单个文件与新名称" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const asset = assets[0];
|
||||||
|
const currentName = String(asset.file_name ?? "");
|
||||||
|
const ext = currentName.includes(".") ? `.${currentName.split(".").pop()}` : "";
|
||||||
|
const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`;
|
||||||
|
const safeName = newFileName.replace(/[\\/]/g, "_");
|
||||||
|
|
||||||
|
await client.mutation(api.mediaAssets.patchById, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: String(asset.id),
|
||||||
|
patch: { file_name: safeName },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
case "copy":
|
||||||
|
case "move": {
|
||||||
|
if (!payload.targetDocumentId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDoc = await client.query(api.documents.getMeta, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: payload.targetDocumentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetDoc) {
|
||||||
|
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = (await client.query(api.mediaAssets.listByDocument, {
|
||||||
|
userId: auth.userId,
|
||||||
|
documentId: payload.targetDocumentId,
|
||||||
|
limit: 500,
|
||||||
|
})) as any[];
|
||||||
|
const existingNames = new Set<string>(
|
||||||
|
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
|
||||||
|
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
|
||||||
|
if (!storageId) continue;
|
||||||
|
|
||||||
|
if (payload.action === "copy") {
|
||||||
|
const newId =
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
||||||
|
userId: auth.userId,
|
||||||
|
storageId: storageId as any,
|
||||||
|
asset: {
|
||||||
|
id: newId,
|
||||||
|
workspace_id: String(targetDoc.workspace_id),
|
||||||
|
document_id: String(payload.targetDocumentId),
|
||||||
|
asset_type: String(asset.asset_type ?? "file"),
|
||||||
|
file_name: fileName,
|
||||||
|
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
|
||||||
|
mime_type: (asset.mime_type ?? null) as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push(created);
|
||||||
|
} else {
|
||||||
|
await client.mutation(api.mediaAssets.patchById, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: String(asset.id),
|
||||||
|
patch: {
|
||||||
|
workspace_id: String(targetDoc.workspace_id),
|
||||||
|
document_id: String(payload.targetDocumentId),
|
||||||
|
file_name: fileName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ items: results });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "操作失败";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = requireAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId,
|
||||||
|
expiredDeletedAt: makeExpiredDeletedAt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, ...(res as any) });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -85,4 +115,3 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, updated: assetIds.length });
|
return NextResponse.json({ success: true, updated: assetIds.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
// 说明:OCR 链路目前仍依赖 Supabase JWT/表结构,迁移阶段先显式禁用,避免 UI/接口误用。
|
||||||
|
return NextResponse.json({ error: "Convex 模式暂不支持 OCR" }, { status: 501 });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = requireAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { assetId }: PurgePayload = await request.json().catch(() => ({}));
|
||||||
|
if (!assetId) {
|
||||||
|
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const res = await client.mutation(api.mediaAssets.purgeById, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: assetId,
|
||||||
|
expiredDeletedAt: makeExpiredDeletedAt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, ...(res as any) });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -86,4 +116,3 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
|||||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
import supabaseAdmin from "@/lib/supabase/admin";
|
import supabaseAdmin from "@/lib/supabase/admin";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -61,6 +65,47 @@ const resolveAssetObjectLocation = async (params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = requireAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const assetId = searchParams.get("assetId");
|
||||||
|
|
||||||
|
if (!assetId) {
|
||||||
|
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
|
||||||
|
if (!asset) {
|
||||||
|
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshed = await client.mutation(api.mediaAssets.refreshUrl, { userId: auth.userId, id: assetId });
|
||||||
|
const signedUrl = (refreshed as { signedUrl?: string | null } | null)?.signedUrl ?? null;
|
||||||
|
if (!signedUrl) {
|
||||||
|
return NextResponse.json({ error: "生成签名链接失败" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
signedUrl,
|
||||||
|
asset: {
|
||||||
|
id: asset.id,
|
||||||
|
file_name: asset.file_name,
|
||||||
|
mime_type: asset.mime_type,
|
||||||
|
file_size: asset.file_size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
|||||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
import supabaseAdmin from "@/lib/supabase/admin";
|
import supabaseAdmin from "@/lib/supabase/admin";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -92,6 +94,26 @@ const tryResolveExistingObjectPath = async (params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||||
|
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||||
|
try {
|
||||||
|
requireAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const fileUrl = searchParams.get("fileUrl");
|
||||||
|
if (!fileUrl) {
|
||||||
|
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ signedUrl: fileUrl });
|
||||||
|
}
|
||||||
|
|
||||||
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost,
|
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost,
|
||||||
// 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。
|
// 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。
|
||||||
// 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin,
|
// 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type { MediaAsset } from "@/types/media";
|
|||||||
import { extname } from "path";
|
import { extname } from "path";
|
||||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -17,6 +21,86 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = requireAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get("file");
|
||||||
|
const workspaceId = String(formData.get("workspaceId") ?? "");
|
||||||
|
const documentId = String(formData.get("documentId") ?? "");
|
||||||
|
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
|
||||||
|
|
||||||
|
if (!(file instanceof File) || !workspaceId || !documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const assetId =
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
const assetType = resolveAssetType(file.type || "");
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
// 1) 获取 Convex 的上传 URL(短时有效)
|
||||||
|
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
|
||||||
|
if (!uploadUrl || typeof uploadUrl !== "string") {
|
||||||
|
return NextResponse.json({ error: "获取上传地址失败" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 上传文件到 Convex Files
|
||||||
|
const uploadRes = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||||
|
body: buffer,
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
const text = await uploadRes.text().catch(() => "");
|
||||||
|
return NextResponse.json({ error: `上传到 Convex 失败:${uploadRes.status} ${text}` }, { status: 500 });
|
||||||
|
}
|
||||||
|
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
|
||||||
|
const storageId = uploadJson?.storageId ?? "";
|
||||||
|
if (!storageId) {
|
||||||
|
return NextResponse.json({ error: "上传到 Convex 失败:缺少 storageId" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 写入 Convex 的 media_assets 元数据(并记录 storageId)
|
||||||
|
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
||||||
|
userId: auth.userId,
|
||||||
|
storageId: storageId as any,
|
||||||
|
asset: {
|
||||||
|
id: assetId,
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
document_id: documentId,
|
||||||
|
asset_type: assetType,
|
||||||
|
file_name: file.name || null,
|
||||||
|
file_size: file.size,
|
||||||
|
mime_type: file.type || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const asset = created as unknown as MediaAsset;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
asset,
|
||||||
|
mindmapUrl: `asset:${asset.id}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "上传失败" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -29,6 +32,24 @@ async function purgeTrashFolder(folder: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
|
|||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps";
|
import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps";
|
||||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
const defaultMindmapData = {
|
const defaultMindmapData = {
|
||||||
data: { text: "中心主题" },
|
data: { text: "中心主题" },
|
||||||
@@ -19,6 +22,53 @@ export async function POST(
|
|||||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||||
) {
|
) {
|
||||||
const { docId, mindmapId } = await params;
|
const { docId, mindmapId } = await params;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
|
||||||
|
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
|
||||||
|
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
|
||||||
|
if (!ops.length) {
|
||||||
|
return NextResponse.json({ error: "缺少 ops" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (ops.length > 80) {
|
||||||
|
return NextResponse.json({ error: "ops 过多(最多 80)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = await client.query(api.mindmaps.get, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId,
|
||||||
|
mindmapId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseData = current?.data ?? defaultMindmapData;
|
||||||
|
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
|
||||||
|
|
||||||
|
await client.mutation(api.mindmaps.put, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId,
|
||||||
|
mindmapId,
|
||||||
|
data: nextData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
applied,
|
||||||
|
errors,
|
||||||
|
data: nextData,
|
||||||
|
meta: {
|
||||||
|
documentId: docId,
|
||||||
|
mindmapId,
|
||||||
|
actor: payload?.actor ?? null,
|
||||||
|
reason: payload?.reason ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -70,4 +120,3 @@ export async function POST(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
const defaultMindmapData = {
|
const defaultMindmapData = {
|
||||||
data: { text: "中心主题" },
|
data: { text: "中心主题" },
|
||||||
@@ -112,6 +115,17 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||||
) {
|
) {
|
||||||
const { docId, mindmapId } = await params;
|
const { docId, mindmapId } = await params;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const res = await client.query(api.mindmaps.get, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId,
|
||||||
|
mindmapId,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -155,6 +169,28 @@ export async function POST(
|
|||||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||||
) {
|
) {
|
||||||
const { docId, mindmapId } = await params;
|
const { docId, mindmapId } = await params;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
|
||||||
|
data?: unknown;
|
||||||
|
createOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.mutation(api.mindmaps.put, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId,
|
||||||
|
mindmapId,
|
||||||
|
data: data ?? defaultMindmapData,
|
||||||
|
...(typeof createOnly === "boolean" ? { createOnly } : {}),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -203,6 +239,21 @@ export async function DELETE(
|
|||||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||||
) {
|
) {
|
||||||
const { docId, mindmapId } = await params;
|
const { docId, mindmapId } = await params;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
try {
|
||||||
|
const result = await client.mutation(api.mindmaps.softDelete, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId,
|
||||||
|
mindmapId,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -263,6 +314,37 @@ export async function PATCH(
|
|||||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||||
) {
|
) {
|
||||||
const { docId, mindmapId } = await params;
|
const { docId, mindmapId } = await params;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
|
||||||
|
if (action !== "restore" && action !== "purge") {
|
||||||
|
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === "purge") {
|
||||||
|
const result = await client.mutation(api.mindmaps.purge, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId,
|
||||||
|
mindmapId,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.mutation(api.mindmaps.restore, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId,
|
||||||
|
mindmapId,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message ?? "操作失败";
|
||||||
|
const status = msg.includes("未找到") ? 404 : 400;
|
||||||
|
return NextResponse.json({ error: msg }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
const defaultMindmapData = {
|
const defaultMindmapData = {
|
||||||
data: { text: "中心主题" },
|
data: { text: "中心主题" },
|
||||||
@@ -40,6 +43,18 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ docId: string }> },
|
{ params }: { params: Promise<{ docId: string }> },
|
||||||
) {
|
) {
|
||||||
const { docId: id } = await params;
|
const { docId: id } = await params;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const mindmapId = `legacy-${id}`;
|
||||||
|
const res = await client.query(api.mindmaps.get, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId: id,
|
||||||
|
mindmapId,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -89,6 +104,20 @@ export async function POST(
|
|||||||
{ params }: { params: Promise<{ docId: string }> },
|
{ params }: { params: Promise<{ docId: string }> },
|
||||||
) {
|
) {
|
||||||
const { docId: id } = await params;
|
const { docId: id } = await params;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const mindmapId = `legacy-${id}`;
|
||||||
|
const { data } = await request.json();
|
||||||
|
const result = await client.mutation(api.mindmaps.put, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId: id,
|
||||||
|
mindmapId,
|
||||||
|
data: data ?? defaultMindmapData,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
@@ -130,6 +159,18 @@ export async function DELETE(
|
|||||||
{ params }: { params: Promise<{ docId: string }> },
|
{ params }: { params: Promise<{ docId: string }> },
|
||||||
) {
|
) {
|
||||||
const { docId: id } = await params;
|
const { docId: id } = await params;
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const mindmapId = `legacy-${id}`;
|
||||||
|
const result = await client.mutation(api.mindmaps.softDelete, {
|
||||||
|
userId: auth.userId,
|
||||||
|
docId: id,
|
||||||
|
mindmapId,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import supabaseAdmin from "@/lib/supabase/admin";
|
import supabaseAdmin from "@/lib/supabase/admin";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
|
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
|
||||||
|
const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim();
|
||||||
|
|
||||||
type OnlyOfficeCallbackBody = {
|
type OnlyOfficeCallbackBody = {
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -11,6 +15,19 @@ type OnlyOfficeCallbackBody = {
|
|||||||
key?: string;
|
key?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeSecret = (raw: string) => {
|
||||||
|
const trimmed = String(raw || "").trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
|
||||||
|
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
||||||
|
) {
|
||||||
|
return trimmed.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
|
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
|
||||||
try {
|
try {
|
||||||
const u = new URL(raw);
|
const u = new URL(raw);
|
||||||
@@ -33,6 +50,16 @@ export async function POST(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const assetId = searchParams.get("assetId") || "";
|
const assetId = searchParams.get("assetId") || "";
|
||||||
|
|
||||||
|
// 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。
|
||||||
|
// 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。
|
||||||
|
if (ONLYOFFICE_CALLBACK_SECRET) {
|
||||||
|
const got = normalizeSecret(searchParams.get("token") || "");
|
||||||
|
const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET);
|
||||||
|
if (!got || got !== expected) {
|
||||||
|
return NextResponse.json({ error: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
|
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return NextResponse.json({ error: 0 });
|
return NextResponse.json({ error: 0 });
|
||||||
@@ -53,6 +80,54 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 1 });
|
return NextResponse.json({ error: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
|
||||||
|
const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user";
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
|
||||||
|
if (!asset) {
|
||||||
|
return NextResponse.json({ error: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
|
||||||
|
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return NextResponse.json({ error: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||||
|
|
||||||
|
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId });
|
||||||
|
if (!uploadUrl || typeof uploadUrl !== "string") {
|
||||||
|
return NextResponse.json({ error: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadRes = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": asset.mime_type || "application/octet-stream" },
|
||||||
|
body: buf,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
return NextResponse.json({ error: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
|
||||||
|
const storageId = String(uploadJson?.storageId || "");
|
||||||
|
if (!storageId) {
|
||||||
|
return NextResponse.json({ error: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.mutation(api.mediaAssets.replaceStorageFromUpload, {
|
||||||
|
userId,
|
||||||
|
id: assetId,
|
||||||
|
storageId: storageId as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
const { data: asset, error: assetError } = await supabaseAdmin
|
const { data: asset, error: assetError } = await supabaseAdmin
|
||||||
.from("media_assets")
|
.from("media_assets")
|
||||||
.select("id,bucket,storage_path,mime_type")
|
.select("id,bucket,storage_path,mime_type")
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
|
|||||||
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
|
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
|
||||||
);
|
);
|
||||||
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
|
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
|
||||||
|
const convexOrigin = tryParseOriginUrl(process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL);
|
||||||
|
|
||||||
const isSupabasePath =
|
const isSupabasePath =
|
||||||
target.pathname.startsWith("/storage/v1/") ||
|
target.pathname.startsWith("/storage/v1/") ||
|
||||||
@@ -155,7 +156,17 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
|
|||||||
addAllowed(storageOverride.hostname, storageOverride.port);
|
addAllowed(storageOverride.hostname, storageOverride.port);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname)) {
|
if (convexOrigin?.hostname) {
|
||||||
|
addAllowed(convexOrigin.hostname, convexOrigin.port || "");
|
||||||
|
if (isLocalHostname(convexOrigin.hostname)) {
|
||||||
|
addAllowed("127.0.0.1", convexOrigin.port || "");
|
||||||
|
addAllowed("localhost", convexOrigin.port || "");
|
||||||
|
addAllowed("host.docker.internal", convexOrigin.port || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:默认禁止代理到内网/私有地址;但如果该 hostname 被显式配置为允许(例如 Docker bridge/host 回源),则放行。
|
||||||
|
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname) && !allowedHostnames.has(target.hostname)) {
|
||||||
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
|
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const workspaceId = searchParams.get("workspaceId");
|
||||||
|
const pageId = searchParams.get("pageId");
|
||||||
|
if (!workspaceId || !pageId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId 或 pageId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Number(searchParams.get("limit") ?? "50");
|
||||||
|
const offset = Number(searchParams.get("offset") ?? "0");
|
||||||
|
|
||||||
|
const backlinks = await client.query(api.references.listBacklinks, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId,
|
||||||
|
pageId,
|
||||||
|
limit: Number.isFinite(limit) ? limit : 50,
|
||||||
|
offset: Number.isFinite(offset) ? offset : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ backlinks });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
type DisplayMode = "inline" | "embed";
|
type DisplayMode = "inline" | "embed";
|
||||||
|
|
||||||
@@ -16,6 +19,30 @@ interface RecordReferencePayload {
|
|||||||
const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inline" || mode === "embed";
|
const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inline" || mode === "embed";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const body = (await request.json()) as RecordReferencePayload;
|
||||||
|
if (!body?.workspaceId || !body?.sourcePageId || !body?.targetPageId) {
|
||||||
|
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!isValidDisplayMode(String(body.displayMode ?? ""))) {
|
||||||
|
return NextResponse.json({ error: "非法的引用模式" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference = await client.mutation(api.references.record, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: body.workspaceId,
|
||||||
|
sourcePageId: body.sourcePageId,
|
||||||
|
targetPageId: body.targetPageId,
|
||||||
|
sourceBlockId: body.sourceBlockId ?? null,
|
||||||
|
alias: body.alias ?? null,
|
||||||
|
displayMode: body.displayMode,
|
||||||
|
isPreviewable: Boolean(body.isPreviewable ?? true),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ reference });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
import type {
|
import type {
|
||||||
DocumentSearchFilters,
|
DocumentSearchFilters,
|
||||||
DocumentSearchRequest,
|
DocumentSearchRequest,
|
||||||
@@ -144,6 +147,89 @@ const fetchOcrMatches = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const payload = (await request.json()) as DocumentSearchRequest;
|
||||||
|
const workspaceId = payload.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: DocumentSearchFilters = {
|
||||||
|
...DEFAULT_FILTERS,
|
||||||
|
...payload.filters,
|
||||||
|
};
|
||||||
|
|
||||||
|
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||||
|
const normalizedQuery = payload.query?.trim() ?? "";
|
||||||
|
const normalizedLower = normalizedQuery.toLowerCase();
|
||||||
|
|
||||||
|
const docs = await client.query(api.documents.listByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedByRecent = [...docs].sort((a, b) => {
|
||||||
|
const ta = a.updated_at ?? a.created_at ?? "";
|
||||||
|
const tb = b.updated_at ?? b.created_at ?? "";
|
||||||
|
return tb.localeCompare(ta);
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentRows = await client.query(api.recents.listByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
const docMap = new Map(docs.map((d) => [d.id, d]));
|
||||||
|
const recent: DocumentSearchResult[] = recentRows
|
||||||
|
.map((r) => docMap.get(r.document_id))
|
||||||
|
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title ?? "无标题",
|
||||||
|
snippet: "",
|
||||||
|
updatedAt: row.updated_at ?? null,
|
||||||
|
createdAt: row.created_at ?? null,
|
||||||
|
matchField: "recent",
|
||||||
|
hasOcr: false,
|
||||||
|
publicPath: `/documents/${row.id}`,
|
||||||
|
score: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
const response: DocumentSearchResponse = { results: [], recent };
|
||||||
|
return NextResponse.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const narrowed = sortedByRecent.filter((row) => {
|
||||||
|
if (filters.onlyCurrentPage && payload.documentId) {
|
||||||
|
if (row.id !== payload.documentId) return false;
|
||||||
|
}
|
||||||
|
const title = (row.title ?? "无标题").toLowerCase();
|
||||||
|
return title.includes(normalizedLower);
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title ?? "无标题",
|
||||||
|
snippet: buildSnippet(row.title ?? "", normalizedQuery),
|
||||||
|
updatedAt: row.updated_at ?? null,
|
||||||
|
createdAt: row.created_at ?? null,
|
||||||
|
matchField: "title",
|
||||||
|
hasOcr: false,
|
||||||
|
publicPath: `/documents/${row.id}`,
|
||||||
|
score: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response: DocumentSearchResponse = {
|
||||||
|
results,
|
||||||
|
recent,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
interface RecentPayload {
|
interface RecentPayload {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -7,6 +10,24 @@ interface RecentPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const { auth, client } = getAuthedConvexClient();
|
||||||
|
const { workspaceId, documentId }: RecentPayload = await request.json();
|
||||||
|
|
||||||
|
if (!workspaceId || !documentId) {
|
||||||
|
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.mutation(api.recents.upsert, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
lastAccessedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
|||||||
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
||||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import {
|
import {
|
||||||
detectLocalMindmapFiles,
|
detectLocalMindmapFiles,
|
||||||
detectLocalMindmapDocs,
|
detectLocalMindmapDocs,
|
||||||
@@ -13,7 +18,188 @@ import type { MediaAsset } from "@/types/media";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
|
||||||
|
const root = (() => {
|
||||||
|
if (!input || typeof input !== "object") return input;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const record = input as any;
|
||||||
|
// 兼容:某些导图结构为 { root: ... }
|
||||||
|
return record && typeof record === "object" && "root" in record ? record.root : input;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const push = (value: unknown) => {
|
||||||
|
if (typeof value !== "string") return;
|
||||||
|
if (!value.startsWith("asset:")) return;
|
||||||
|
const id = value.slice("asset:".length).trim();
|
||||||
|
if (!id) return;
|
||||||
|
if (seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
ids.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const get = (obj: unknown, key: string): unknown => {
|
||||||
|
if (!obj || typeof obj !== "object") return undefined;
|
||||||
|
return (obj as Record<string, unknown>)[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
|
||||||
|
const data = get(node, "data");
|
||||||
|
const image = get(node, "image");
|
||||||
|
|
||||||
|
// 常见:node.data.image = "asset:xxx"
|
||||||
|
push(get(data, "image"));
|
||||||
|
// 兼容:node.image = "asset:xxx"
|
||||||
|
push(image);
|
||||||
|
// 兼容:node.image.url = "asset:xxx"
|
||||||
|
push(get(image, "url"));
|
||||||
|
// 兼容:node.data.image.url = "asset:xxx"
|
||||||
|
push(get(get(data, "image"), "url"));
|
||||||
|
|
||||||
|
const children = get(node, "children");
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
children.forEach(walk);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const workspaceIdParam = url.searchParams.get("workspaceId");
|
||||||
|
|
||||||
|
const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||||
|
workspaceIdIfCreate: randomUUID(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaries = await client.query(api.workspaces.fetchWorkspaceSummaries, {
|
||||||
|
userId: auth.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces;
|
||||||
|
const activeWorkspaceId = summaries.activeWorkspaceId || bootstrap.activeWorkspaceId;
|
||||||
|
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
|
||||||
|
|
||||||
|
if (!targetWorkspaceId) {
|
||||||
|
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const documents = await client.query(api.documents.listByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: targetWorkspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trashedDocuments = await client.query(api.documents.listTrashedByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: targetWorkspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: targetWorkspaceId,
|
||||||
|
includeDeleted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
|
||||||
|
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
|
||||||
|
|
||||||
|
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
|
||||||
|
|
||||||
|
const mindmapAssetChildren: Record<string, string[]> = {};
|
||||||
|
activeMindmaps.forEach((r) => {
|
||||||
|
const ids = extractMindmapImageAssetIdsFromData(r.data);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
mindmapAssetChildren[r.mindmap_id] = ids;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
|
||||||
|
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||||
|
return {
|
||||||
|
id: r.mindmap_id,
|
||||||
|
workspace_id: r.workspace_id ?? targetWorkspaceId,
|
||||||
|
document_id: r.document_id,
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||||
|
file_size: null,
|
||||||
|
mime_type: "application/json",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: r.created_at ?? "",
|
||||||
|
updated_at: r.updated_at ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => {
|
||||||
|
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||||
|
return {
|
||||||
|
id: r.mindmap_id,
|
||||||
|
workspace_id: r.workspace_id ?? targetWorkspaceId,
|
||||||
|
document_id: r.document_id,
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||||
|
file_size: null,
|
||||||
|
mime_type: "application/json",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
deleted_at: r.deleted_at ?? null,
|
||||||
|
deleted_by: r.deleted_by ?? null,
|
||||||
|
purged_at: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: r.created_at ?? "",
|
||||||
|
updated_at: r.updated_at ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: SidebarInitialData = {
|
||||||
|
activeWorkspaceId: targetWorkspaceId,
|
||||||
|
workspaces,
|
||||||
|
documents,
|
||||||
|
trashedDocuments,
|
||||||
|
trashedMediaAssets: [],
|
||||||
|
trashedMindmapAssets,
|
||||||
|
mindmapDocs,
|
||||||
|
mindmapAssets,
|
||||||
|
mindmapAssetChildren,
|
||||||
|
tableAssets: [],
|
||||||
|
mediaAssets: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const payload = await request.json().catch(() => ({}));
|
||||||
|
const workspaceId = payload.workspaceId as string | undefined;
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
await client.mutation(api.workspaces.switchDefaultWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
|
|||||||
+17
-1
@@ -29,6 +29,19 @@ const stripHopByHopHeaders = (headers: Headers) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pickCacheControl = (pathParts: string[], contentType: string, method: string) => {
|
||||||
|
const m = String(method || "").toUpperCase();
|
||||||
|
if (m !== "GET" && m !== "HEAD") return "no-store";
|
||||||
|
if (contentType.includes("text/html")) return "no-store";
|
||||||
|
|
||||||
|
const p = `/${(pathParts ?? []).join("/")}`.toLowerCase();
|
||||||
|
// 说明:/cache 主要是 ONLYOFFICE 运行期二进制缓存,适合短缓存提升性能,但不宜过长。
|
||||||
|
if (p.includes("editor.bin") || p.endsWith(".bin")) {
|
||||||
|
return "public, max-age=3600, stale-while-revalidate=600";
|
||||||
|
}
|
||||||
|
return "public, max-age=300, stale-while-revalidate=300";
|
||||||
|
};
|
||||||
|
|
||||||
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
|
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
|
||||||
const incomingUrl = new URL(request.url);
|
const incomingUrl = new URL(request.url);
|
||||||
const target = new URL(
|
const target = new URL(
|
||||||
@@ -60,6 +73,10 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => {
|
|||||||
stripHopByHopHeaders(outHeaders);
|
stripHopByHopHeaders(outHeaders);
|
||||||
outHeaders.delete("content-encoding");
|
outHeaders.delete("content-encoding");
|
||||||
outHeaders.delete("content-length");
|
outHeaders.delete("content-length");
|
||||||
|
outHeaders.set("cache-control", pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method));
|
||||||
|
outHeaders.delete("pragma");
|
||||||
|
outHeaders.delete("expires");
|
||||||
|
outHeaders.delete("set-cookie");
|
||||||
|
|
||||||
return new NextResponse(upstream.body, {
|
return new NextResponse(upstream.body, {
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
@@ -83,4 +100,3 @@ export async function OPTIONS(request: NextRequest, ctx: RouteCtx) {
|
|||||||
const { path } = await ctx.params;
|
const { path } = await ctx.params;
|
||||||
return proxyCache(request, path ?? []);
|
return proxyCache(request, path ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SupabaseProvider } from "@/components/providers/supabase-provider";
|
|||||||
import { QueryProvider } from "@/components/providers/query-provider";
|
import { QueryProvider } from "@/components/providers/query-provider";
|
||||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -22,16 +23,19 @@ export default async function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const isDesktop = process.env.MNOTE_DESKTOP === "1";
|
const isDesktop = process.env.MNOTE_DESKTOP === "1";
|
||||||
|
const useConvex = isConvexEnabled();
|
||||||
|
|
||||||
let session = null;
|
let session = null;
|
||||||
try {
|
if (!useConvex) {
|
||||||
session = (await (await createSupabaseServerClient()).auth.getSession()).data.session;
|
try {
|
||||||
} catch {
|
session = (await (await createSupabaseServerClient()).auth.getSession()).data.session;
|
||||||
// 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。
|
} catch {
|
||||||
session = null;
|
// 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop };
|
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop, useConvex };
|
||||||
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
|
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +50,13 @@ export default async function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.variable} antialiased`}>
|
<body className={`${inter.variable} antialiased`}>
|
||||||
<SupabaseProvider session={session}>
|
{useConvex ? (
|
||||||
<QueryProvider>{children}</QueryProvider>
|
<QueryProvider>{children}</QueryProvider>
|
||||||
</SupabaseProvider>
|
) : (
|
||||||
|
<SupabaseProvider session={session}>
|
||||||
|
<QueryProvider>{children}</QueryProvider>
|
||||||
|
</SupabaseProvider>
|
||||||
|
)}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,39 @@ window.__MNOTE_DISABLE_ONLYOFFICE_SW__ = true;
|
|||||||
</script>
|
</script>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
|
const XHR_REWRITE_SNIPPET = `
|
||||||
|
<script>
|
||||||
|
// 说明:ONLYOFFICE 在被反向代理(/onlyoffice-server)时,运行期仍可能发起指向内部端口
|
||||||
|
// http://127.0.0.1:8081/cache/... 的绝对请求(来自 ONLYOFFICE 内部逻辑)。
|
||||||
|
// 这会导致浏览器从 origin(3000) 跨域请求 8081 并触发 CORS 拦截。
|
||||||
|
// 这里在 ONLYOFFICE 页面(含其 iframe)内统一重写 XHR:把内部 8081 的请求改写回同源 /onlyoffice-server/*。
|
||||||
|
window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var proxyPrefix = location.origin.replace(/\\/+$/, '') + '/onlyoffice-server';
|
||||||
|
var internal = {
|
||||||
|
'http://127.0.0.1:8081': true,
|
||||||
|
'http://localhost:8081': true
|
||||||
|
};
|
||||||
|
function rewrite(u) {
|
||||||
|
try {
|
||||||
|
var abs = new URL(u, location.origin);
|
||||||
|
var origin = abs.protocol + '//' + abs.host;
|
||||||
|
if (!internal[origin]) return u;
|
||||||
|
return proxyPrefix + abs.pathname + abs.search + abs.hash;
|
||||||
|
} catch (e) {
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var origOpen = XMLHttpRequest.prototype.open;
|
||||||
|
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
|
||||||
|
return origOpen.call(this, method, rewrite(url), async, user, password);
|
||||||
|
};
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
const stripHopByHopHeaders = (headers: Headers) => {
|
const stripHopByHopHeaders = (headers: Headers) => {
|
||||||
// 说明:Hop-by-hop headers 不应被代理转发/透传
|
// 说明:Hop-by-hop headers 不应被代理转发/透传
|
||||||
const hopByHop = [
|
const hopByHop = [
|
||||||
@@ -51,8 +84,63 @@ const stripHopByHopHeaders = (headers: Headers) => {
|
|||||||
|
|
||||||
const injectDisableServiceWorker = (html: string) => {
|
const injectDisableServiceWorker = (html: string) => {
|
||||||
// 说明:只注入一次,避免重复拼接
|
// 说明:只注入一次,避免重复拼接
|
||||||
if (html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__")) return html;
|
const hasSw = html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__");
|
||||||
return html.replace(/<head[^>]*>/i, (m) => `${m}\n${DISABLE_SERVICE_WORKER_SNIPPET}\n`);
|
const hasXhr = html.includes("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__");
|
||||||
|
if (hasSw && hasXhr) return html;
|
||||||
|
const injected = [
|
||||||
|
hasSw ? "" : DISABLE_SERVICE_WORKER_SNIPPET,
|
||||||
|
hasXhr ? "" : XHR_REWRITE_SNIPPET,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
return html.replace(/<head[^>]*>/i, (m) => `${m}\n${injected}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickCacheControl = (pathParts: string[], contentType: string, method: string) => {
|
||||||
|
const m = String(method || "").toUpperCase();
|
||||||
|
if (m !== "GET" && m !== "HEAD") return "no-store";
|
||||||
|
if (contentType.includes("text/html")) return "no-store";
|
||||||
|
|
||||||
|
const p = `/${(pathParts ?? []).join("/")}`;
|
||||||
|
const lower = p.toLowerCase();
|
||||||
|
|
||||||
|
// 说明:这些路径通常是运行期接口/动态响应,不应缓存。
|
||||||
|
if (
|
||||||
|
lower.includes("/docservice/") ||
|
||||||
|
lower.includes("/coauthoring/") ||
|
||||||
|
lower.includes("/converter/") ||
|
||||||
|
lower.includes("/healthcheck") ||
|
||||||
|
lower.includes("/metrics")
|
||||||
|
) {
|
||||||
|
return "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStaticByPath =
|
||||||
|
lower.includes("/web-apps/") ||
|
||||||
|
lower.includes("/sdkjs/") ||
|
||||||
|
lower.endsWith(".js") ||
|
||||||
|
lower.endsWith(".css") ||
|
||||||
|
lower.endsWith(".map") ||
|
||||||
|
lower.endsWith(".png") ||
|
||||||
|
lower.endsWith(".jpg") ||
|
||||||
|
lower.endsWith(".jpeg") ||
|
||||||
|
lower.endsWith(".gif") ||
|
||||||
|
lower.endsWith(".svg") ||
|
||||||
|
lower.endsWith(".ico") ||
|
||||||
|
lower.endsWith(".woff") ||
|
||||||
|
lower.endsWith(".woff2") ||
|
||||||
|
lower.endsWith(".ttf") ||
|
||||||
|
lower.endsWith(".otf") ||
|
||||||
|
lower.endsWith(".json") ||
|
||||||
|
lower.endsWith(".wasm") ||
|
||||||
|
lower.endsWith(".bin");
|
||||||
|
|
||||||
|
// 说明:ONLYOFFICE 静态资源体积大,且文件名通常稳定;这里尽量给浏览器缓存,提升二次打开速度。
|
||||||
|
if (isStaticByPath) {
|
||||||
|
return "public, max-age=604800, stale-while-revalidate=86400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "public, max-age=300, stale-while-revalidate=300";
|
||||||
};
|
};
|
||||||
|
|
||||||
const proxy = async (request: NextRequest, pathParts: string[]) => {
|
const proxy = async (request: NextRequest, pathParts: string[]) => {
|
||||||
@@ -63,6 +151,17 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
|
|||||||
const headers = new Headers(request.headers);
|
const headers = new Headers(request.headers);
|
||||||
// 说明:避免把外部 Host 传给上游
|
// 说明:避免把外部 Host 传给上游
|
||||||
headers.delete("host");
|
headers.delete("host");
|
||||||
|
// 说明:ONLYOFFICE 在被反向代理时,会根据 X-Forwarded-* 推导自身对外地址,
|
||||||
|
// 用于生成静态资源/缓存文件的 URL。若缺失这些信息,可能会返回指向内部端口
|
||||||
|
//(例如 http://127.0.0.1:8081/cache/...)的绝对 URL,导致浏览器跨域请求被 CORS 拦截。
|
||||||
|
headers.set("x-forwarded-host", incomingUrl.host);
|
||||||
|
headers.set("x-forwarded-proto", incomingUrl.protocol.replace(":", ""));
|
||||||
|
if (incomingUrl.port) {
|
||||||
|
headers.set("x-forwarded-port", incomingUrl.port);
|
||||||
|
} else {
|
||||||
|
headers.set("x-forwarded-port", incomingUrl.protocol === "https:" ? "443" : "80");
|
||||||
|
}
|
||||||
|
headers.set("x-forwarded-prefix", "/onlyoffice-server");
|
||||||
// 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding,
|
// 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding,
|
||||||
// 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。
|
// 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。
|
||||||
headers.set("accept-encoding", "identity");
|
headers.set("accept-encoding", "identity");
|
||||||
@@ -89,6 +188,10 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
|
|||||||
outHeaders.delete("content-length");
|
outHeaders.delete("content-length");
|
||||||
|
|
||||||
const contentType = upstream.headers.get("content-type") || "";
|
const contentType = upstream.headers.get("content-type") || "";
|
||||||
|
outHeaders.set("cache-control", pickCacheControl(pathParts, contentType, request.method));
|
||||||
|
outHeaders.delete("pragma");
|
||||||
|
outHeaders.delete("expires");
|
||||||
|
outHeaders.delete("set-cookie");
|
||||||
if (contentType.includes("text/html")) {
|
if (contentType.includes("text/html")) {
|
||||||
const html = await upstream.text();
|
const html = await upstream.text();
|
||||||
const injected = injectDisableServiceWorker(html);
|
const injected = injectDisableServiceWorker(html);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ declare global {
|
|||||||
__MNOTE_ONLYOFFICE_ERRLOG__?: Array<Record<string, unknown>>;
|
__MNOTE_ONLYOFFICE_ERRLOG__?: Array<Record<string, unknown>>;
|
||||||
__MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean;
|
__MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean;
|
||||||
__MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean;
|
__MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean;
|
||||||
|
__MNOTE_ONLYOFFICE_DEBUG__?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +133,97 @@ const base64UrlEncodeUtf8 = (input: string) => {
|
|||||||
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
|
||||||
|
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
|
||||||
|
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
|
||||||
|
// 这里在 ONLYOFFICE 页面内对 XHR 做一次 URL 重写:把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
|
||||||
|
// 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!baseUrl) return;
|
||||||
|
|
||||||
|
const normalizedBase = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||||||
|
const isProxyMode = normalizedBase === "/onlyoffice-server" || normalizedBase.endsWith("/onlyoffice-server");
|
||||||
|
if (!isProxyMode) return;
|
||||||
|
|
||||||
|
const proxyPrefix = (() => {
|
||||||
|
if (/^https?:\/\//i.test(normalizedBase)) return normalizedBase;
|
||||||
|
return `${window.location.origin.replace(/\/+$/, "")}${normalizedBase}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const internalOrigins = new Set<string>(["http://127.0.0.1:8081", "http://localhost:8081"]);
|
||||||
|
try {
|
||||||
|
if (onlyofficeBaseUrlDesktop) {
|
||||||
|
const u = new URL(onlyofficeBaseUrlDesktop);
|
||||||
|
internalOrigins.add(`${u.protocol}//${u.host}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchWindow = (win: Window) => {
|
||||||
|
try {
|
||||||
|
if ((win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__) return;
|
||||||
|
const rewriteUrl = (input: string) => {
|
||||||
|
try {
|
||||||
|
const u = new (win as any).URL(input, (win as any).location?.origin || window.location.origin);
|
||||||
|
const origin = `${u.protocol}//${u.host}`;
|
||||||
|
if (!internalOrigins.has(origin)) return input;
|
||||||
|
return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`;
|
||||||
|
} catch {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
|
||||||
|
if (typeof origOpen !== "function") return;
|
||||||
|
// eslint-disable-next-line no-extend-native
|
||||||
|
(win as any).XMLHttpRequest.prototype.open = function openPatched(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
async?: boolean,
|
||||||
|
user?: string | null,
|
||||||
|
password?: string | null,
|
||||||
|
) {
|
||||||
|
const nextUrl = typeof url === "string" ? rewriteUrl(url) : url;
|
||||||
|
// eslint-disable-next-line prefer-rest-params
|
||||||
|
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
|
||||||
|
};
|
||||||
|
(win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
patchWindow(window);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = Date.now();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
try {
|
||||||
|
const frames = Array.from(document.querySelectorAll("iframe"));
|
||||||
|
for (const f of frames) {
|
||||||
|
try {
|
||||||
|
const w = (f as HTMLIFrameElement).contentWindow;
|
||||||
|
if (!w) continue;
|
||||||
|
// 说明:同源时才能访问 location;跨域会抛异常,直接跳过。
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
w.location?.origin;
|
||||||
|
patchWindow(w);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (Date.now() - start > 120_000) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const docTypeFromExt = (ext: string) => {
|
const docTypeFromExt = (ext: string) => {
|
||||||
const word = ["doc", "docx", "odt", "rtf"];
|
const word = ["doc", "docx", "odt", "rtf"];
|
||||||
const slide = ["ppt", "pptx", "odp"];
|
const slide = ["ppt", "pptx", "odp"];
|
||||||
@@ -167,9 +259,53 @@ export default function OnlyOfficePage() {
|
|||||||
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
|
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
|
||||||
const mode = (params.get("mode") ?? "edit") as EditorMode;
|
const mode = (params.get("mode") ?? "edit") as EditorMode;
|
||||||
const assetId = params.get("assetId") ?? "";
|
const assetId = params.get("assetId") ?? "";
|
||||||
|
const channel = (params.get("channel") ?? "").trim().toLowerCase();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||||
const baseUrl = runtimeConfig.onlyofficeBaseUrlWeb || runtimeConfig.onlyofficeBaseUrl;
|
const baseUrlCandidates = useMemo(() => {
|
||||||
|
const uniq: string[] = [];
|
||||||
|
const push = (v?: string | null) => {
|
||||||
|
const s = String(v || "").trim().replace(/\/+$/, "");
|
||||||
|
if (!s) return;
|
||||||
|
if (!uniq.includes(s)) uniq.push(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 说明:网页端优先走同源 /onlyoffice-server(Next 代理到 ONLYOFFICE_INTERNAL_URL),
|
||||||
|
// 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。
|
||||||
|
// 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。
|
||||||
|
try {
|
||||||
|
push("/onlyoffice-server");
|
||||||
|
push(`${window.location.origin.replace(/\/+$/, "")}/onlyoffice-server`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明:默认优先使用运行期根据 isDesktop 归一化后的 onlyofficeBaseUrl;
|
||||||
|
// 如遇到端口转发/本机服务不可达,可自动回退到另一套配置。
|
||||||
|
if (channel === "web") {
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrl);
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||||
|
return uniq;
|
||||||
|
}
|
||||||
|
if (channel === "desktop") {
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrl);
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||||
|
return uniq;
|
||||||
|
}
|
||||||
|
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrl);
|
||||||
|
if (runtimeConfig.isDesktop) {
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||||
|
} else {
|
||||||
|
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||||
|
}
|
||||||
|
return uniq;
|
||||||
|
}, [channel, runtimeConfig]);
|
||||||
|
|
||||||
|
const [baseUrlIndex, setBaseUrlIndex] = useState(0);
|
||||||
|
const baseUrl = baseUrlCandidates[baseUrlIndex] || "";
|
||||||
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
|
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
|
||||||
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
||||||
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
|
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
|
||||||
@@ -293,15 +429,33 @@ export default function OnlyOfficePage() {
|
|||||||
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
|
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
|
||||||
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000,
|
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000,
|
||||||
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
|
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
|
||||||
let base = storageHostOverride
|
const isConvexStorageUrl = (() => {
|
||||||
? fileUrl
|
// 说明:Convex Files 的直链通常形如:
|
||||||
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
|
// - http://127.0.0.1:3210/api/storage/<id>
|
||||||
|
// - https://<convex-host>/api/storage/<id>
|
||||||
|
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
|
||||||
|
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
|
||||||
|
try {
|
||||||
|
const u = new URL(fileUrl);
|
||||||
|
return u.pathname.startsWith("/api/storage/");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
let base =
|
||||||
|
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
|
||||||
|
? fileUrl
|
||||||
|
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
|
||||||
try {
|
try {
|
||||||
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
|
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
|
||||||
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
|
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
|
||||||
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
|
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
|
||||||
const raw = new URL(base);
|
const raw = new URL(base);
|
||||||
const alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
||||||
|
|
||||||
|
const isLocalHost =
|
||||||
|
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
|
||||||
|
|
||||||
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
|
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
|
||||||
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
|
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
|
||||||
@@ -312,11 +466,23 @@ export default function OnlyOfficePage() {
|
|||||||
raw.host = po.host;
|
raw.host = po.host;
|
||||||
base = raw.toString();
|
base = raw.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关键兜底:OnlyOffice 的 document.url 由“文档服务器容器”去拉取。
|
||||||
|
// 如果这里是 localhost/127.0.0.1(对容器而言指向它自己),会导致“下载失败(-4)”。
|
||||||
|
// 因此在配置了 proxyOrigin 时,强制走 /api/onlyoffice/proxy 把回源留给 Next 服务端完成。
|
||||||
|
if (!alreadyProxy && proxyOrigin && isLocalHost) {
|
||||||
|
const proxyBase = proxyOrigin || window.location.origin;
|
||||||
|
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
||||||
|
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
||||||
|
base = proxy.toString();
|
||||||
|
alreadyProxy = true;
|
||||||
|
}
|
||||||
if (!alreadyProxy && raw.searchParams.has("token")) {
|
if (!alreadyProxy && raw.searchParams.has("token")) {
|
||||||
const proxyBase = proxyOrigin || window.location.origin;
|
const proxyBase = proxyOrigin || window.location.origin;
|
||||||
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
||||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
||||||
base = proxy.toString();
|
base = proxy.toString();
|
||||||
|
alreadyProxy = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const u = new URL(base);
|
const u = new URL(base);
|
||||||
@@ -337,6 +503,25 @@ export default function OnlyOfficePage() {
|
|||||||
}
|
}
|
||||||
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
|
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.__MNOTE_ONLYOFFICE_DEBUG__ = {
|
||||||
|
pageOrigin: window.location.origin,
|
||||||
|
baseUrl,
|
||||||
|
proxyOrigin,
|
||||||
|
callbackOrigin,
|
||||||
|
fileUrlInput: fileUrl,
|
||||||
|
resolvedFileUrl,
|
||||||
|
fileName,
|
||||||
|
fileType,
|
||||||
|
mode,
|
||||||
|
assetId,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [assetId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
|
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
|
||||||
@@ -346,6 +531,28 @@ export default function OnlyOfficePage() {
|
|||||||
setError("缺少 fileUrl 参数。");
|
setError("缺少 fileUrl 参数。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupOnlyOfficeInternalRequestRewrite(baseUrl, runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||||
|
|
||||||
|
// 说明:外网访问(例如 frp/隧道)时,OnlyOffice 文档服务器运行在本机 Docker 容器内,无法直接访问
|
||||||
|
// document.url 里的 127.0.0.1/localhost。此时必须把 document.url 指向一个“容器可访问”的 Next Origin
|
||||||
|
//(onlyofficeProxyOrigin / onlyofficeProxyOriginWeb),让 Next 服务端代为回源下载。
|
||||||
|
try {
|
||||||
|
const pageHost = window.location.hostname;
|
||||||
|
const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost";
|
||||||
|
const isPageRemote = !isPageLocal;
|
||||||
|
const u = new URL(fileUrl);
|
||||||
|
const isFileLocal = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
|
||||||
|
if (isPageRemote && isFileLocal && !proxyOrigin) {
|
||||||
|
setError(
|
||||||
|
"外网访问时检测到 fileUrl 为本机地址(127.0.0.1/localhost),但未配置 onlyofficeProxyOriginWeb。请在 public/mnote-env.json 配置 onlyofficeProxyOriginWeb/onlyofficeCallbackOriginWeb(例如 http://host.docker.internal:3000 或当前 Docker 可达的主机 IP)。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
|
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
|
||||||
loadScript(scriptUrl)
|
loadScript(scriptUrl)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -462,9 +669,15 @@ export default function OnlyOfficePage() {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
|
// 说明:优先“无感回退”到备选 baseUrl(常见于本机 8081 未启动/外网转发不可达)。
|
||||||
|
const hasNext = baseUrlIndex + 1 < baseUrlCandidates.length;
|
||||||
|
if (hasNext) {
|
||||||
|
setBaseUrlIndex((i) => i + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
});
|
});
|
||||||
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
|
}, [baseUrl, baseUrlCandidates.length, baseUrlIndex, fileName, fileType, fileUrl, mode, resolvedFileUrl, targetDocType]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,51 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
|
function makeId(): string {
|
||||||
|
return typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
|
if (isConvexEnabled()) {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
|
||||||
|
const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||||
|
workspaceIdIfCreate: makeId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = await client.query(api.documents.listByWorkspace, {
|
||||||
|
userId: auth.userId,
|
||||||
|
workspaceId: activeWorkspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstDoc = [...docs].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? ""))[0];
|
||||||
|
if (firstDoc?.id) {
|
||||||
|
redirect(`/documents/${firstDoc.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = makeId();
|
||||||
|
await client.mutation(api.documents.create, {
|
||||||
|
userId: auth.userId,
|
||||||
|
id: docId,
|
||||||
|
workspaceId: activeWorkspaceId,
|
||||||
|
parentId: null,
|
||||||
|
title: "新页面",
|
||||||
|
accessScope: "private",
|
||||||
|
content: [],
|
||||||
|
});
|
||||||
|
redirect(`/documents/${docId}`);
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createSupabaseServerClient();
|
const supabase = await createSupabaseServerClient();
|
||||||
const session = (await supabase.auth.getSession()).data.session;
|
const session = (await supabase.auth.getSession()).data.session;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSessionContext } from "@supabase/auth-helpers-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
@@ -18,56 +17,37 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentTaskPanel({ documentId }: Props) {
|
export function DocumentTaskPanel({ documentId }: Props) {
|
||||||
const { session } = useSessionContext();
|
|
||||||
const [task, setTask] = useState<TaskResponse | null>(null);
|
const [task, setTask] = useState<TaskResponse | null>(null);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const backendUrl = useMemo(() => getMnoteRuntimeConfig().backendUrl, []);
|
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||||
|
const backendUrl = runtime.backendUrl;
|
||||||
|
const useConvex = Boolean(runtime.useConvex);
|
||||||
|
|
||||||
const triggerTask = async () => {
|
const triggerTask = async () => {
|
||||||
if (!backendUrl || !session?.access_token) return;
|
// 说明:当前后端(FastAPI)仍使用 Supabase JWT 做鉴权;Convex 迁移阶段先不打通这一块。
|
||||||
|
if (useConvex) return;
|
||||||
|
if (!backendUrl) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, {
|
// TODO:如需恢复该能力,请在接入真实鉴权后,将 access_token 从 AuthContext 注入到这里。
|
||||||
method: "POST",
|
// 这里暂时保持 UI 可渲染,不发起请求。
|
||||||
headers: {
|
void documentId;
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
document_id: documentId,
|
|
||||||
file_url: "https://example.com/sample.pdf",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
setTask(data);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!backendUrl || !session?.access_token || !task?.task_id) {
|
if (useConvex) return;
|
||||||
|
if (!backendUrl || !task?.task_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setInterval(async () => {
|
const timer = setInterval(async () => {
|
||||||
const response = await fetch(`${backendUrl}/api/v1/tasks/${task.task_id}`, {
|
// 说明:同上,暂不轮询。
|
||||||
headers: {
|
void timer;
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as TaskResponse;
|
|
||||||
setTask(data);
|
|
||||||
if (data.status === "completed") {
|
|
||||||
clearInterval(timer);
|
|
||||||
}
|
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [backendUrl, session?.access_token, task?.task_id]);
|
}, [backendUrl, task?.task_id, useConvex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-4 bg-white shadow-sm">
|
<Card className="mt-4 bg-white shadow-sm">
|
||||||
@@ -77,9 +57,14 @@ export function DocumentTaskPanel({ documentId }: Props) {
|
|||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
状态:{task ? task.status : "未开始"} · 进度:{task ? `${task.progress}%` : "0%"}
|
状态:{task ? task.status : "未开始"} · 进度:{task ? `${task.progress}%` : "0%"}
|
||||||
</div>
|
</div>
|
||||||
|
{useConvex && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
提示:Convex 迁移阶段暂未接入后端鉴权(Supabase JWT),该按钮仅用于占位。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{task?.message && <div className="text-xs text-gray-500">提示:{task.message}</div>}
|
{task?.message && <div className="text-xs text-gray-500">提示:{task.message}</div>}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={triggerTask} disabled={pending} variant="outline">
|
<Button onClick={triggerTask} disabled={pending || useConvex} variant="outline">
|
||||||
{pending ? "触发中..." : "触发 OCR"}
|
{pending ? "触发中..." : "触发 OCR"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
|
import { buildDocumentTree } from "@/lib/documents";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||||
|
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||||
|
|
||||||
|
export type MoveEmbedMode = "move" | "embed";
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
||||||
|
titleOnly: true,
|
||||||
|
exact: false,
|
||||||
|
onlyCurrentPage: false,
|
||||||
|
includeOcr: false,
|
||||||
|
timeRange: "any",
|
||||||
|
timeField: "updated",
|
||||||
|
};
|
||||||
|
|
||||||
|
type PickerItem =
|
||||||
|
| { kind: "root"; id: null; title: string; subtitle?: string }
|
||||||
|
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
|
||||||
|
|
||||||
|
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
|
||||||
|
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
const message = payload?.error ?? "获取页面列表失败";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MoveEmbedPickerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
workspaceId: string | null;
|
||||||
|
defaultMode?: MoveEmbedMode;
|
||||||
|
modes?: MoveEmbedMode[];
|
||||||
|
allowRoot?: boolean;
|
||||||
|
excludeIds?: string[];
|
||||||
|
onPick: (mode: MoveEmbedMode, targetId: string | null) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoveEmbedPickerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
workspaceId,
|
||||||
|
defaultMode = "move",
|
||||||
|
modes = ["move", "embed"],
|
||||||
|
allowRoot = true,
|
||||||
|
excludeIds = [],
|
||||||
|
onPick,
|
||||||
|
}: MoveEmbedPickerDialogProps) {
|
||||||
|
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [highlighted, setHighlighted] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery("");
|
||||||
|
setHighlighted(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 说明:每次打开对话框时,强制同步到调用方传入的默认模式(移动/嵌入)。
|
||||||
|
setMode(defaultMode);
|
||||||
|
setQuery("");
|
||||||
|
setHighlighted(0);
|
||||||
|
}, [defaultMode, open]);
|
||||||
|
|
||||||
|
const payload = useMemo(() => {
|
||||||
|
if (!workspaceId) return null;
|
||||||
|
return {
|
||||||
|
workspaceId,
|
||||||
|
query,
|
||||||
|
filters: DEFAULT_FILTERS,
|
||||||
|
limit: 30,
|
||||||
|
};
|
||||||
|
}, [query, workspaceId]);
|
||||||
|
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const isEmptyQuery = trimmed.length === 0;
|
||||||
|
|
||||||
|
const sidebarQuery = useQuery({
|
||||||
|
queryKey: ["move-embed-picker-sidebar", workspaceId],
|
||||||
|
queryFn: () => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new Error("缺少 workspaceId");
|
||||||
|
}
|
||||||
|
return fetchSidebarData(workspaceId);
|
||||||
|
},
|
||||||
|
enabled: open && Boolean(workspaceId) && isEmptyQuery,
|
||||||
|
staleTime: 30_000,
|
||||||
|
gcTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useDocumentSearch(payload, open && !isEmptyQuery);
|
||||||
|
|
||||||
|
const items = useMemo<PickerItem[]>(() => {
|
||||||
|
const excluded = new Set(excludeIds);
|
||||||
|
|
||||||
|
const result: PickerItem[] = [];
|
||||||
|
|
||||||
|
if (allowRoot && mode === "move") {
|
||||||
|
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmptyQuery) {
|
||||||
|
const docs = sidebarQuery.data?.documents ?? [];
|
||||||
|
const tree = buildDocumentTree(docs);
|
||||||
|
|
||||||
|
const flattened: Array<{ id: string; title: string; depth: number }> = [];
|
||||||
|
const walk = (nodes: ReturnType<typeof buildDocumentTree>, depth: number) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
flattened.push({ id: node.id, title: node.title ?? "无标题", depth });
|
||||||
|
if (node.children?.length) {
|
||||||
|
walk(node.children, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(tree, 0);
|
||||||
|
|
||||||
|
for (const item of flattened) {
|
||||||
|
if (excluded.has(item.id)) continue;
|
||||||
|
result.push({
|
||||||
|
kind: "doc",
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
depth: item.depth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawList = data?.results?.length ? data?.results : data?.recent ?? [];
|
||||||
|
for (const r of rawList) {
|
||||||
|
if (!r || excluded.has(r.id)) continue;
|
||||||
|
result.push({
|
||||||
|
kind: "doc",
|
||||||
|
id: r.id,
|
||||||
|
title: r.title || "无标题",
|
||||||
|
subtitle: r.matchField === "recent" ? "最近打开" : undefined,
|
||||||
|
raw: r,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.documents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlighted(0);
|
||||||
|
}, [mode, query, open]);
|
||||||
|
|
||||||
|
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
||||||
|
|
||||||
|
const handlePick = useCallback(
|
||||||
|
async (targetId: string | null) => {
|
||||||
|
await onPick(mode, targetId);
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
[mode, onOpenChange, onPick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] w-full max-w-md overflow-hidden border-none bg-white p-0 shadow-xl">
|
||||||
|
<DialogTitle className="sr-only">选择目标页面</DialogTitle>
|
||||||
|
<div className="flex h-[520px] flex-col">
|
||||||
|
<div className="border-b border-[#eef2ff] p-4">
|
||||||
|
<Tabs value={mode} onValueChange={(v) => setMode(v as MoveEmbedMode)}>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{modes.includes("move") && (
|
||||||
|
<TabsTrigger value="move" className="flex-1">
|
||||||
|
移动到
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{modes.includes("embed") && (
|
||||||
|
<TabsTrigger value="embed" className="flex-1">
|
||||||
|
嵌入到
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value={mode} className="mt-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-10 rounded-xl border-[#e2e8f0] pl-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!open) return;
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlighted((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
const picked = items[highlighted];
|
||||||
|
if (!picked) return;
|
||||||
|
void handlePick(picked.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!workspaceId ? (
|
||||||
|
<div className="p-4 text-sm text-gray-500">缺少 workspaceId,无法加载页面列表。</div>
|
||||||
|
) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? (
|
||||||
|
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||||
|
) : (isEmptyQuery ? sidebarQuery.error : error) ? (
|
||||||
|
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-2">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<button
|
||||||
|
key={item.kind === "root" ? "root" : item.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
||||||
|
idx === highlighted && "bg-[#e3ecff]",
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setHighlighted(idx)}
|
||||||
|
onClick={() => void handlePick(item.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium text-gray-900"
|
||||||
|
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { MoveEmbedPickerDialog } from "@/components/documents/move-embed-picker-dialog";
|
||||||
|
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||||
|
|
||||||
|
export function MoveEmbedPickerHost() {
|
||||||
|
const open = useMoveEmbedPickerStore((s) => s.open);
|
||||||
|
const workspaceId = useMoveEmbedPickerStore((s) => s.workspaceId);
|
||||||
|
const defaultMode = useMoveEmbedPickerStore((s) => s.defaultMode);
|
||||||
|
const modes = useMoveEmbedPickerStore((s) => s.modes);
|
||||||
|
const allowRoot = useMoveEmbedPickerStore((s) => s.allowRoot);
|
||||||
|
const excludeIds = useMoveEmbedPickerStore((s) => s.excludeIds);
|
||||||
|
const onPick = useMoveEmbedPickerStore((s) => s.onPick);
|
||||||
|
const setWorkspaceId = useMoveEmbedPickerStore((s) => s.setWorkspaceId);
|
||||||
|
const close = useMoveEmbedPickerStore((s) => s.close);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (workspaceId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/sidebar");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const json = (await res.json().catch(() => null)) as { activeWorkspaceId?: string } | null;
|
||||||
|
const nextId = typeof json?.activeWorkspaceId === "string" ? json.activeWorkspaceId : null;
|
||||||
|
if (!cancelled) {
|
||||||
|
setWorkspaceId(nextId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, setWorkspaceId, workspaceId]);
|
||||||
|
|
||||||
|
const handlePick = useCallback(
|
||||||
|
async (mode: "move" | "embed", targetId: string | null) => {
|
||||||
|
if (onPick) {
|
||||||
|
await onPick(mode, targetId);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
[close, onPick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
defaultMode={defaultMode}
|
||||||
|
modes={modes}
|
||||||
|
allowRoot={allowRoot}
|
||||||
|
excludeIds={excludeIds}
|
||||||
|
onPick={handlePick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import type { MediaAsset } from "@/types/media";
|
|||||||
import { customBlockSchema, type CustomBlockSchema } from "./schema";
|
import { customBlockSchema, type CustomBlockSchema } from "./schema";
|
||||||
import { CustomSideMenu } from "./menus/CustomSideMenu";
|
import { CustomSideMenu } from "./menus/CustomSideMenu";
|
||||||
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
|
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
|
||||||
|
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
||||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||||
@@ -777,13 +778,13 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
|||||||
editable={!pageOptions.protectEditing}
|
editable={!pageOptions.protectEditing}
|
||||||
className={blocknoteClass}
|
className={blocknoteClass}
|
||||||
>
|
>
|
||||||
{!isFullScreenTableOpen && (
|
{!isFullScreenTableOpen && (
|
||||||
<SideMenuController
|
<SideMenuController
|
||||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||||
<CustomSideMenu {...props} currentDocumentId={documentId} />
|
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||||
</BlockNoteView>
|
</BlockNoteView>
|
||||||
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
|
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
|
||||||
@@ -793,6 +794,8 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
|||||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
|
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MoveEmbedPickerHost />
|
||||||
|
|
||||||
{/* 全屏表格编辑器 Modal */}
|
{/* 全屏表格编辑器 Modal */}
|
||||||
{fullScreenTableId && (
|
{fullScreenTableId && (
|
||||||
<FullScreenTableEditor
|
<FullScreenTableEditor
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createReactBlockSpec } from "@blocknote/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { extractBlockText } from "@/lib/blocks";
|
||||||
|
|
||||||
|
type RemoteBlock = {
|
||||||
|
id: string;
|
||||||
|
type?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
content?: unknown;
|
||||||
|
children?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTextBlock = (block: RemoteBlock) => block.type === "paragraph" || block.type === "heading";
|
||||||
|
|
||||||
|
export const blockReferenceBlock = createReactBlockSpec(
|
||||||
|
{
|
||||||
|
type: "blockReference",
|
||||||
|
propSchema: {
|
||||||
|
sourceDocumentId: { default: "" },
|
||||||
|
targetBlockId: { default: "" },
|
||||||
|
display: { default: "embed" },
|
||||||
|
},
|
||||||
|
content: "none",
|
||||||
|
},
|
||||||
|
() => ({
|
||||||
|
render: ({ block }) => <BlockReferenceContent block={block as any} />,
|
||||||
|
}),
|
||||||
|
)();
|
||||||
|
|
||||||
|
function BlockReferenceContent({ block }: { block: { props: { sourceDocumentId: string; targetBlockId: string } } }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const sourceDocumentId = block.props.sourceDocumentId;
|
||||||
|
const targetBlockId = block.props.targetBlockId;
|
||||||
|
|
||||||
|
const [remote, setRemote] = useState<RemoteBlock | null>(null);
|
||||||
|
const [textDraft, setTextDraft] = useState<string>("");
|
||||||
|
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
const canEdit = useMemo(() => Boolean(remote && isTextBlock(remote)), [remote]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourceDocumentId || !targetBlockId) {
|
||||||
|
setStatus("error");
|
||||||
|
setError("引用信息不完整");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
setRemote(null);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/blocks/get?sourceDocumentId=${encodeURIComponent(sourceDocumentId)}&blockId=${encodeURIComponent(targetBlockId)}`,
|
||||||
|
{ method: "GET", credentials: "include" },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(payload?.error ?? "获取引用块失败");
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
const next = (json?.block ?? null) as RemoteBlock | null;
|
||||||
|
if (!cancelled) {
|
||||||
|
setRemote(next);
|
||||||
|
if (next && isTextBlock(next)) {
|
||||||
|
setTextDraft(extractBlockText(next as any));
|
||||||
|
}
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(e instanceof Error ? e.message : "获取引用块失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [sourceDocumentId, targetBlockId]);
|
||||||
|
|
||||||
|
const openSource = useCallback(() => {
|
||||||
|
if (sourceDocumentId) {
|
||||||
|
router.push(`/documents/${sourceDocumentId}`);
|
||||||
|
}
|
||||||
|
}, [router, sourceDocumentId]);
|
||||||
|
|
||||||
|
const saveText = useCallback(async () => {
|
||||||
|
if (!remote || !canEdit) return;
|
||||||
|
const nextBlock: RemoteBlock = {
|
||||||
|
...remote,
|
||||||
|
id: remote.id,
|
||||||
|
content: [{ type: "text", text: textDraft }],
|
||||||
|
};
|
||||||
|
const res = await fetch("/api/blocks/patch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ sourceDocumentId, blockId: targetBlockId, nextBlock }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
const msg = payload?.error ?? "同步编辑失败";
|
||||||
|
if (typeof window !== "undefined") window.alert(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") window.alert("已同步编辑到原块");
|
||||||
|
}, [canEdit, remote, sourceDocumentId, targetBlockId, textDraft]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-2 rounded-md border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// 说明:避免点击内部按钮/输入框时误触发编辑器的拖拽/选择。
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span>嵌入引用</span>
|
||||||
|
<span className="ml-auto flex items-center gap-2">
|
||||||
|
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={openSource}>
|
||||||
|
打开原块
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "loading" ? (
|
||||||
|
<div className="text-sm text-gray-400">加载中...</div>
|
||||||
|
) : status === "error" ? (
|
||||||
|
<div className="text-sm text-red-600">{error}</div>
|
||||||
|
) : !remote ? (
|
||||||
|
<div className="text-sm text-gray-400">引用块不存在</div>
|
||||||
|
) : canEdit ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
className="w-full resize-y rounded-md border border-[#e2e8f0] bg-white p-2 text-sm text-gray-900 outline-none"
|
||||||
|
rows={3}
|
||||||
|
value={textDraft}
|
||||||
|
onChange={(e) => setTextDraft(e.target.value)}
|
||||||
|
placeholder="在这里编辑会同步到原块(MVP:仅支持段落/标题纯文本)"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" size="sm" className="h-8 px-3 text-xs" onClick={() => void saveText()}>
|
||||||
|
同步到原块
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-gray-400">MVP:仅支持段落/标题纯文本同步</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
<div className="mb-1 text-xs text-gray-400">当前块类型:{remote.type ?? "unknown"}</div>
|
||||||
|
<div className="text-sm text-gray-800">{extractBlockText(remote as any) || "(内容为空或暂不支持渲染)"}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createReactBlockSpec } from "@blocknote/react";
|
import { createReactBlockSpec } from "@blocknote/react";
|
||||||
import { RiFileTextFill } from "react-icons/ri";
|
import { RiFileTextFill } from "react-icons/ri";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||||
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
|
|
||||||
const normalizeTitle = (value?: string | null) => {
|
const normalizeTitle = (value?: string | null) => {
|
||||||
if (!value || !value.trim()) {
|
if (!value || !value.trim()) {
|
||||||
@@ -15,7 +16,7 @@ const normalizeTitle = (value?: string | null) => {
|
|||||||
|
|
||||||
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
|
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const supabaseBrowser = getSupabaseBrowserClient();
|
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
||||||
const fallbackTitle = normalizeTitle(title);
|
const fallbackTitle = normalizeTitle(title);
|
||||||
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
|
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
|
||||||
|
|
||||||
@@ -27,6 +28,10 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
|||||||
if (!pageId) {
|
if (!pageId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (useConvex) {
|
||||||
|
// 说明:Convex 迁移阶段先不做 title 的实时订阅/拉取,直接使用 block props 里的 title。
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const applyTitle = (nextTitle?: string | null) => {
|
const applyTitle = (nextTitle?: string | null) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -36,6 +41,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
|||||||
|
|
||||||
const fetchTitle = async () => {
|
const fetchTitle = async () => {
|
||||||
try {
|
try {
|
||||||
|
const supabaseBrowser = getSupabaseBrowserClient();
|
||||||
const { data } = await supabaseBrowser
|
const { data } = await supabaseBrowser
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.select("title")
|
.select("title")
|
||||||
@@ -51,6 +57,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
|||||||
|
|
||||||
void fetchTitle();
|
void fetchTitle();
|
||||||
|
|
||||||
|
const supabaseBrowser = getSupabaseBrowserClient();
|
||||||
const channel = supabaseBrowser
|
const channel = supabaseBrowser
|
||||||
.channel(`page-ref-${pageId}`)
|
.channel(`page-ref-${pageId}`)
|
||||||
.on(
|
.on(
|
||||||
@@ -67,7 +74,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
supabaseBrowser.removeChannel(channel);
|
supabaseBrowser.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId, useConvex]);
|
||||||
|
|
||||||
const navigate = () => {
|
const navigate = () => {
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import type { CustomBlockSchema } from "../schema";
|
import type { CustomBlockSchema } from "../schema";
|
||||||
import { deleteOnlineTable } from "@/lib/online-table";
|
import { deleteOnlineTable } from "@/lib/online-table";
|
||||||
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||||
|
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||||
|
|
||||||
type InlineNode = { text?: unknown };
|
type InlineNode = { text?: unknown };
|
||||||
type TableMenuBlock = Parameters<
|
type TableMenuBlock = Parameters<
|
||||||
@@ -32,6 +33,7 @@ type ConvertOption = {
|
|||||||
|
|
||||||
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
|
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
|
||||||
currentDocumentId: string;
|
currentDocumentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractText = (block: Block<CustomBlockSchema>) => {
|
const extractText = (block: Block<CustomBlockSchema>) => {
|
||||||
@@ -43,10 +45,11 @@ const extractText = (block: Block<CustomBlockSchema>) => {
|
|||||||
return "未命名页面";
|
return "未命名页面";
|
||||||
};
|
};
|
||||||
|
|
||||||
const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => {
|
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
|
||||||
const Components = useComponentsContext()!;
|
const Components = useComponentsContext()!;
|
||||||
const editor = useBlockNoteEditor<CustomBlockSchema>();
|
const editor = useBlockNoteEditor<CustomBlockSchema>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||||
|
|
||||||
const duplicateBlock = useCallback(() => {
|
const duplicateBlock = useCallback(() => {
|
||||||
const blockWithoutId: DraftBlock = { ...block };
|
const blockWithoutId: DraftBlock = { ...block };
|
||||||
@@ -156,39 +159,71 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}, [block, currentDocumentId, editor, router]);
|
}, [block, currentDocumentId, editor, router]);
|
||||||
|
|
||||||
|
const handleMoveEmbedPick = useCallback(
|
||||||
|
async (mode: "move" | "embed", targetDocumentId: string | null) => {
|
||||||
|
if (!targetDocumentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "embed" && targetDocumentId === currentDocumentId) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.alert("禁止嵌入到当前页面");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = mode === "embed" ? "/api/blocks/embed" : "/api/blocks/move";
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceDocumentId: currentDocumentId,
|
||||||
|
blockId: block.id,
|
||||||
|
targetDocumentId,
|
||||||
|
position: "end",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
const message =
|
||||||
|
payload?.error ?? (mode === "embed" ? "嵌入失败,请检查目标页面" : "移动失败,请检查目标页面");
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.alert(message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "move") {
|
||||||
|
// 说明:移动块本体:本地编辑器也要移除该块,避免等待刷新造成错觉。
|
||||||
|
try {
|
||||||
|
editor.removeBlocks([block.id]);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.alert("已在目标页面末尾插入嵌入引用块");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[block.id, currentDocumentId, editor, router],
|
||||||
|
);
|
||||||
|
|
||||||
const moveOrEmbedBlock = useCallback(async () => {
|
const moveOrEmbedBlock = useCallback(async () => {
|
||||||
if (typeof window === "undefined") {
|
// 说明:拖拽菜单点击后会立即卸载,必须使用全局 Host 承载弹窗。
|
||||||
return;
|
openPicker({
|
||||||
}
|
workspaceId,
|
||||||
const targetParent = window.prompt("输入目标页面 ID(将在该页面末尾插入新子页面)", currentDocumentId);
|
defaultMode: "move",
|
||||||
if (!targetParent) {
|
modes: ["move", "embed"],
|
||||||
return;
|
allowRoot: false,
|
||||||
}
|
excludeIds: [currentDocumentId],
|
||||||
const response = await fetch("/api/documents/create-child", {
|
onPick: handleMoveEmbedPick,
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
parentId: targetParent.trim(),
|
|
||||||
title: extractText(block),
|
|
||||||
blocks: [block],
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
return;
|
||||||
window.alert("移动失败,请确认页面 ID");
|
}, [currentDocumentId, handleMoveEmbedPick, openPicker, workspaceId]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { pageId, title } = await response.json();
|
|
||||||
editor.replaceBlocks(
|
|
||||||
[block.id],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
type: "pageReference",
|
|
||||||
props: { pageId, title },
|
|
||||||
} as PartialBlock<CustomBlockSchema>,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
router.refresh();
|
|
||||||
}, [block, currentDocumentId, editor, router]);
|
|
||||||
|
|
||||||
const convertOptions = useMemo<ConvertOption[]>(
|
const convertOptions = useMemo<ConvertOption[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -372,6 +407,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
|||||||
|
|
||||||
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
|
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
|
||||||
currentDocumentId: string;
|
currentDocumentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
||||||
@@ -381,6 +417,7 @@ export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
|||||||
<CustomDragHandleMenu
|
<CustomDragHandleMenu
|
||||||
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
|
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
|
||||||
currentDocumentId={props.currentDocumentId}
|
currentDocumentId={props.currentDocumentId}
|
||||||
|
workspaceId={props.workspaceId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { progressBlock } from "./blocks/ProgressBlock";
|
|||||||
import { mediaBlock } from "./blocks/MediaBlock";
|
import { mediaBlock } from "./blocks/MediaBlock";
|
||||||
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
|
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
|
||||||
import { mindmapBlock } from "./blocks/MindmapBlock";
|
import { mindmapBlock } from "./blocks/MindmapBlock";
|
||||||
|
import { blockReferenceBlock } from "./blocks/BlockReferenceBlock";
|
||||||
|
|
||||||
const headingSpec = createHeadingBlockSpec({
|
const headingSpec = createHeadingBlockSpec({
|
||||||
levels: [1, 2, 3, 4, 5],
|
levels: [1, 2, 3, 4, 5],
|
||||||
@@ -24,6 +25,7 @@ export const customBlockSchema = BlockNoteSchema.create({
|
|||||||
...defaultBlockSpecs,
|
...defaultBlockSpecs,
|
||||||
heading: headingSpec,
|
heading: headingSpec,
|
||||||
pageReference: pageReferenceBlock,
|
pageReference: pageReferenceBlock,
|
||||||
|
blockReference: blockReferenceBlock,
|
||||||
advancedTodo: advancedTodoBlock,
|
advancedTodo: advancedTodoBlock,
|
||||||
progressMeter: progressBlock,
|
progressMeter: progressBlock,
|
||||||
media: mediaBlock,
|
media: mediaBlock,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoad
|
|||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import { extractRowsForPreview } from "@/components/online-table/utils";
|
import { extractRowsForPreview } from "@/components/online-table/utils";
|
||||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||||
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
|
|
||||||
type LuckysheetSelection =
|
type LuckysheetSelection =
|
||||||
| {
|
| {
|
||||||
@@ -52,7 +53,15 @@ const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
|
|||||||
|
|
||||||
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
|
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
|
||||||
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
|
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
|
||||||
const supabaseBrowser = useMemo(() => getSupabaseBrowserClient(), []);
|
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
||||||
|
const supabaseBrowser = useMemo(() => {
|
||||||
|
if (useConvex) return null;
|
||||||
|
try {
|
||||||
|
return getSupabaseBrowserClient();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [useConvex]);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const isLuckysheetReady = useLuckysheetLoader();
|
const isLuckysheetReady = useLuckysheetLoader();
|
||||||
const [table, setTable] = useState<DocumentTable | null>(null);
|
const [table, setTable] = useState<DocumentTable | null>(null);
|
||||||
@@ -234,6 +243,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableId) return;
|
if (!tableId) return;
|
||||||
|
if (!supabaseBrowser) return;
|
||||||
const channel = supabaseBrowser
|
const channel = supabaseBrowser
|
||||||
.channel(`table-${tableId}-live`)
|
.channel(`table-${tableId}-live`)
|
||||||
.on(
|
.on(
|
||||||
@@ -261,7 +271,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
|||||||
return () => {
|
return () => {
|
||||||
supabaseBrowser.removeChannel(channel);
|
supabaseBrowser.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [tableId]);
|
}, [tableId, supabaseBrowser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
|
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import type { FileTreeRow } from "@/lib/file-tree/types";
|
|||||||
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
||||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||||
|
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
|
||||||
import {
|
import {
|
||||||
inferPasteTargetDocId,
|
inferPasteTargetDocId,
|
||||||
isTextInputTarget,
|
isTextInputTarget,
|
||||||
@@ -128,7 +129,15 @@ interface ContextMenuState {
|
|||||||
export function Sidebar({ initialData }: SidebarProps) {
|
export function Sidebar({ initialData }: SidebarProps) {
|
||||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
|
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
|
||||||
useSidebarStore();
|
useSidebarStore();
|
||||||
const supabaseBrowser = useMemo(() => getSupabaseBrowserClient(), []);
|
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
||||||
|
const supabaseBrowser = useMemo(() => {
|
||||||
|
if (useConvex) return null;
|
||||||
|
try {
|
||||||
|
return getSupabaseBrowserClient();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [useConvex]);
|
||||||
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
|
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
|
||||||
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
|
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
|
||||||
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
|
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
|
||||||
@@ -154,6 +163,9 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
||||||
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
||||||
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
||||||
|
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
|
||||||
|
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
|
||||||
|
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null);
|
||||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -242,6 +254,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
}, [sidebarQuery]);
|
}, [sidebarQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!supabaseBrowser) return;
|
||||||
const channel = supabaseBrowser
|
const channel = supabaseBrowser
|
||||||
.channel("documents-feed")
|
.channel("documents-feed")
|
||||||
.on(
|
.on(
|
||||||
@@ -255,9 +268,10 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
supabaseBrowser.removeChannel(channel);
|
supabaseBrowser.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [refreshTree]);
|
}, [refreshTree, supabaseBrowser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!supabaseBrowser) return;
|
||||||
const channel = supabaseBrowser
|
const channel = supabaseBrowser
|
||||||
.channel("media-assets-feed")
|
.channel("media-assets-feed")
|
||||||
.on(
|
.on(
|
||||||
@@ -276,7 +290,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
supabaseBrowser.removeChannel(channel);
|
supabaseBrowser.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [sidebarData.activeWorkspaceId, sidebarQuery]);
|
}, [sidebarData.activeWorkspaceId, sidebarQuery, supabaseBrowser]);
|
||||||
|
|
||||||
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
|
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
|
||||||
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
||||||
@@ -476,7 +490,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
[refreshTree],
|
[refreshTree],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => {
|
||||||
|
setMoveEmbedSource(node);
|
||||||
|
setMoveEmbedMode(nextMode);
|
||||||
|
setMoveEmbedOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
|
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
|
||||||
|
openMoveEmbedPicker(node, "embed");
|
||||||
|
return;
|
||||||
|
if (sidebarData.activeWorkspaceId) {
|
||||||
|
openMoveEmbedPicker(node, "embed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1465,6 +1491,12 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
|
|
||||||
const handleMovePrompt = useCallback(
|
const handleMovePrompt = useCallback(
|
||||||
async (node: DocumentNode) => {
|
async (node: DocumentNode) => {
|
||||||
|
openMoveEmbedPicker(node, "move");
|
||||||
|
return;
|
||||||
|
if (sidebarData.activeWorkspaceId) {
|
||||||
|
openMoveEmbedPicker(node, "move");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2034,6 +2066,44 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
onDelete={() => void handleDeleteFileTreeSelection()}
|
onDelete={() => void handleDeleteFileTreeSelection()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open={moveEmbedOpen}
|
||||||
|
onOpenChange={setMoveEmbedOpen}
|
||||||
|
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||||
|
defaultMode={moveEmbedMode}
|
||||||
|
excludeIds={moveEmbedSource?.id ? [moveEmbedSource.id] : []}
|
||||||
|
onPick={async (pickedMode, targetId) => {
|
||||||
|
const source = moveEmbedSource;
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pickedMode === "move") {
|
||||||
|
await handleMove(source.id, targetId, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined" && source.id === targetId) {
|
||||||
|
window.alert("不能嵌入到自身页面");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch("/api/documents/embed", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sourceId: source.id, targetId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.alert("嵌入失败,请检查目标页面");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.alert("已在目标页面末尾插入引用块");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{assetMenu && (
|
{assetMenu && (
|
||||||
<AssetContextMenu
|
<AssetContextMenu
|
||||||
asset={assetMenu.asset}
|
asset={assetMenu.asset}
|
||||||
|
|||||||
@@ -162,10 +162,28 @@ const saveDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolConte
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createDocServerTools = (args: {
|
export const createDocServerTools = (args: {
|
||||||
supabase: DocSupabaseClient;
|
supabase?: DocSupabaseClient;
|
||||||
ctx: DocToolContext;
|
ctx: DocToolContext;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
|
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
||||||
|
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
|
||||||
|
saveBlocks?: (blocks: unknown[]) => Promise<void>;
|
||||||
}) => {
|
}) => {
|
||||||
|
const loadBlocks = async () => {
|
||||||
|
if (args.loadBlocks) return await args.loadBlocks();
|
||||||
|
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks)");
|
||||||
|
return await loadDocumentBlocks(args.supabase, args.ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBlocks = async (blocks: unknown[]) => {
|
||||||
|
if (args.saveBlocks) {
|
||||||
|
await args.saveBlocks(blocks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks)");
|
||||||
|
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
||||||
|
};
|
||||||
|
|
||||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
if (!args.allowedToolIds.has(toolId)) {
|
if (!args.allowedToolIds.has(toolId)) {
|
||||||
throw new Error(`工具未被允许:${toolId}`);
|
throw new Error(`工具未被允许:${toolId}`);
|
||||||
@@ -174,7 +192,7 @@ export const createDocServerTools = (args: {
|
|||||||
if (toolId === "doc_get") {
|
if (toolId === "doc_get") {
|
||||||
const maxNodesRaw = Number(toolArgs.maxBlocks ?? 80);
|
const maxNodesRaw = Number(toolArgs.maxBlocks ?? 80);
|
||||||
const maxBlocks = Math.max(10, Math.min(240, Number.isFinite(maxNodesRaw) ? Math.floor(maxNodesRaw) : 80));
|
const maxBlocks = Math.max(10, Math.min(240, Number.isFinite(maxNodesRaw) ? Math.floor(maxNodesRaw) : 80));
|
||||||
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
const { blocks, source } = await loadBlocks();
|
||||||
const summary = walkSummaries(blocks, maxBlocks);
|
const summary = walkSummaries(blocks, maxBlocks);
|
||||||
return { ok: true, source, totalTopLevelBlocks: blocks.length, blocks: summary };
|
return { ok: true, source, totalTopLevelBlocks: blocks.length, blocks: summary };
|
||||||
}
|
}
|
||||||
@@ -184,7 +202,7 @@ export const createDocServerTools = (args: {
|
|||||||
if (!query) throw new Error("缺少 query");
|
if (!query) throw new Error("缺少 query");
|
||||||
const maxRaw = Number(toolArgs.maxResults ?? 8);
|
const maxRaw = Number(toolArgs.maxResults ?? 8);
|
||||||
const maxResults = Math.max(1, Math.min(30, Number.isFinite(maxRaw) ? Math.floor(maxRaw) : 8));
|
const maxResults = Math.max(1, Math.min(30, Number.isFinite(maxRaw) ? Math.floor(maxRaw) : 8));
|
||||||
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
const { blocks, source } = await loadBlocks();
|
||||||
const summary = walkSummaries(blocks, 400);
|
const summary = walkSummaries(blocks, 400);
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
const hits = summary.filter((x) => x.text.toLowerCase().includes(q)).slice(0, maxResults);
|
const hits = summary.filter((x) => x.text.toLowerCase().includes(q)).slice(0, maxResults);
|
||||||
@@ -207,7 +225,7 @@ export const createDocServerTools = (args: {
|
|||||||
|
|
||||||
const created = specs.map(buildBlockFromSpec);
|
const created = specs.map(buildBlockFromSpec);
|
||||||
|
|
||||||
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
const { blocks, source } = await loadBlocks();
|
||||||
const targetId = beforeBlockId || afterBlockId;
|
const targetId = beforeBlockId || afterBlockId;
|
||||||
const found = targetId ? findContainerById(blocks, targetId) : null;
|
const found = targetId ? findContainerById(blocks, targetId) : null;
|
||||||
if (targetId && !found) {
|
if (targetId && !found) {
|
||||||
@@ -221,7 +239,7 @@ export const createDocServerTools = (args: {
|
|||||||
found.container.splice(insertAt, 0, ...created);
|
found.container.splice(insertAt, 0, ...created);
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
await saveBlocks(blocks);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
source,
|
source,
|
||||||
@@ -238,7 +256,7 @@ export const createDocServerTools = (args: {
|
|||||||
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
const modeRaw = String(toolArgs.mode ?? "replace").trim();
|
||||||
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
|
||||||
|
|
||||||
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
|
const { blocks, source } = await loadBlocks();
|
||||||
const found = findContainerById(blocks, blockId);
|
const found = findContainerById(blocks, blockId);
|
||||||
if (!found) throw new Error(`未找到 blockId:${blockId}`);
|
if (!found) throw new Error(`未找到 blockId:${blockId}`);
|
||||||
const block = found.container[found.index];
|
const block = found.container[found.index];
|
||||||
@@ -247,7 +265,7 @@ export const createDocServerTools = (args: {
|
|||||||
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
|
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
|
||||||
found.container[found.index] = { ...block, content: createTextContent(nextText) };
|
found.container[found.index] = { ...block, content: createTextContent(nextText) };
|
||||||
|
|
||||||
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
|
await saveBlocks(blocks);
|
||||||
return { ok: true, source, blockId, mode, data: blocks };
|
return { ok: true, source, blockId, mode, data: blocks };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,42 @@ const loadWorkspaceIds = async (supabase: DocsSupabaseClient, userId: string) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createDocsServerTools = (args: {
|
export const createDocsServerTools = (args: {
|
||||||
supabase: DocsSupabaseClient;
|
supabase?: DocsSupabaseClient;
|
||||||
ctx: DocsToolContext;
|
ctx: DocsToolContext;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
|
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
||||||
|
searchDocs?: (args: {
|
||||||
|
userId: string;
|
||||||
|
query: string;
|
||||||
|
limit: number;
|
||||||
|
workspaceId: string | null;
|
||||||
|
includeDeleted: boolean;
|
||||||
|
}) => Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
workspaceId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
updatedAt: unknown;
|
||||||
|
snippet: string;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
readDoc?: (args: {
|
||||||
|
userId: string;
|
||||||
|
documentId: string;
|
||||||
|
maxChars: number;
|
||||||
|
includeContent: boolean;
|
||||||
|
}) => Promise<{
|
||||||
|
ok: true;
|
||||||
|
documentId: string;
|
||||||
|
title: string;
|
||||||
|
workspaceId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
updatedAt: unknown;
|
||||||
|
rawTextLength: number;
|
||||||
|
rawText: string;
|
||||||
|
content?: unknown;
|
||||||
|
}>;
|
||||||
}) => {
|
}) => {
|
||||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
if (!args.allowedToolIds.has(toolId)) {
|
if (!args.allowedToolIds.has(toolId)) {
|
||||||
@@ -62,6 +95,21 @@ export const createDocsServerTools = (args: {
|
|||||||
const workspaceId = String(toolArgs.workspaceId ?? "").trim() || null;
|
const workspaceId = String(toolArgs.workspaceId ?? "").trim() || null;
|
||||||
const includeDeleted = Boolean(toolArgs.includeDeleted ?? false);
|
const includeDeleted = Boolean(toolArgs.includeDeleted ?? false);
|
||||||
|
|
||||||
|
if (args.searchDocs) {
|
||||||
|
const results = await args.searchDocs({
|
||||||
|
userId: args.ctx.userId,
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
workspaceId,
|
||||||
|
includeDeleted,
|
||||||
|
});
|
||||||
|
return { ok: true, query, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.supabase) {
|
||||||
|
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 searchDocs/readDoc)");
|
||||||
|
}
|
||||||
|
|
||||||
const wsIds = await loadWorkspaceIds(args.supabase, args.ctx.userId);
|
const wsIds = await loadWorkspaceIds(args.supabase, args.ctx.userId);
|
||||||
const wsFilter = workspaceId ? [workspaceId] : wsIds;
|
const wsFilter = workspaceId ? [workspaceId] : wsIds;
|
||||||
if (wsFilter.length === 0) return { ok: true, query, results: [] };
|
if (wsFilter.length === 0) return { ok: true, query, results: [] };
|
||||||
@@ -108,6 +156,19 @@ export const createDocsServerTools = (args: {
|
|||||||
const maxChars = Math.max(200, Math.min(20_000, Number.isFinite(maxCharsRaw) ? Math.floor(maxCharsRaw) : 2500));
|
const maxChars = Math.max(200, Math.min(20_000, Number.isFinite(maxCharsRaw) ? Math.floor(maxCharsRaw) : 2500));
|
||||||
const includeContent = Boolean(toolArgs.includeContent ?? false);
|
const includeContent = Boolean(toolArgs.includeContent ?? false);
|
||||||
|
|
||||||
|
if (args.readDoc) {
|
||||||
|
return await args.readDoc({
|
||||||
|
userId: args.ctx.userId,
|
||||||
|
documentId,
|
||||||
|
maxChars,
|
||||||
|
includeContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.supabase) {
|
||||||
|
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 searchDocs/readDoc)");
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await args.supabase
|
const { data, error } = await args.supabase
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.select(includeContent ? "id,title,raw_text,content,workspace_id,parent_id,updated_at" : "id,title,raw_text,workspace_id,parent_id,updated_at")
|
.select(includeContent ? "id,title,raw_text,content,workspace_id,parent_id,updated_at" : "id,title,raw_text,workspace_id,parent_id,updated_at")
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ const resolveAttachment = (ctx: MediaToolContext, ref: string): ResolvedAttachme
|
|||||||
const pick = (obj: unknown, key: string) => (isRecord(obj) ? obj[key] : undefined);
|
const pick = (obj: unknown, key: string) => (isRecord(obj) ? obj[key] : undefined);
|
||||||
|
|
||||||
export const createMediaServerTools = (args: {
|
export const createMediaServerTools = (args: {
|
||||||
supabase: MediaSupabaseClient;
|
supabase?: MediaSupabaseClient;
|
||||||
ctx: MediaToolContext;
|
ctx: MediaToolContext;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
|
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
||||||
|
loadById?: (id: string) => Promise<unknown | null>;
|
||||||
|
loadByFileUrl?: (fileUrl: string) => Promise<unknown | null>;
|
||||||
}) => {
|
}) => {
|
||||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||||
@@ -48,27 +51,37 @@ export const createMediaServerTools = (args: {
|
|||||||
|
|
||||||
let row: unknown = null;
|
let row: unknown = null;
|
||||||
if (targetAssetId) {
|
if (targetAssetId) {
|
||||||
const { data, error } = await args.supabase
|
if (args.loadById) {
|
||||||
.from("media_assets")
|
row = await args.loadById(targetAssetId);
|
||||||
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
} else {
|
||||||
.eq("id", targetAssetId)
|
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl)");
|
||||||
.single();
|
const { data, error } = await args.supabase
|
||||||
if (error) {
|
.from("media_assets")
|
||||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||||
|
.eq("id", targetAssetId)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||||
|
}
|
||||||
|
row = data;
|
||||||
}
|
}
|
||||||
row = data;
|
|
||||||
} else if (targetUrl) {
|
} else if (targetUrl) {
|
||||||
const { data, error } = await args.supabase
|
if (args.loadByFileUrl) {
|
||||||
.from("media_assets")
|
row = await args.loadByFileUrl(targetUrl);
|
||||||
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
} else {
|
||||||
.eq("file_url", targetUrl)
|
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl)");
|
||||||
.order("updated_at", { ascending: false })
|
const { data, error } = await args.supabase
|
||||||
.limit(1)
|
.from("media_assets")
|
||||||
.maybeSingle();
|
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
|
||||||
if (error) {
|
.eq("file_url", targetUrl)
|
||||||
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
.order("updated_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) {
|
||||||
|
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
|
||||||
|
}
|
||||||
|
row = data;
|
||||||
}
|
}
|
||||||
row = data;
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("缺少 assetId / fileUrl / attachmentRef");
|
throw new Error("缺少 assetId / fileUrl / attachmentRef");
|
||||||
}
|
}
|
||||||
@@ -109,4 +122,3 @@ export const createMediaServerTools = (args: {
|
|||||||
|
|
||||||
return { run };
|
return { run };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -188,13 +188,25 @@ const sanitizeAddChildOps = (args: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createMindmapServerTools = (args: {
|
export const createMindmapServerTools = (args: {
|
||||||
supabase: SupabaseRouteClient;
|
supabase?: SupabaseRouteClient;
|
||||||
ctx: MindmapToolContext;
|
ctx: MindmapToolContext;
|
||||||
cfg: OpenAiCompatibleChatOptions;
|
cfg: OpenAiCompatibleChatOptions;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
|
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase/local 文件。
|
||||||
|
loadMindmap?: () => Promise<{
|
||||||
|
doc: { id: string; title: string | null; workspace_id: string | null };
|
||||||
|
base: MindmapTreeNode;
|
||||||
|
}>;
|
||||||
|
saveMindmap?: (args: {
|
||||||
|
doc: { id: string; title: string | null; workspace_id: string | null };
|
||||||
|
data: MindmapTreeNode;
|
||||||
|
}) => Promise<void>;
|
||||||
}) => {
|
}) => {
|
||||||
const loadDoc = async () => {
|
const loadDoc = async () => {
|
||||||
const { documentId, userId } = args.ctx;
|
const { documentId, userId } = args.ctx;
|
||||||
|
if (!args.supabase) {
|
||||||
|
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadMindmap/saveMindmap)");
|
||||||
|
}
|
||||||
const query = args.supabase
|
const query = args.supabase
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.select("id,title,workspace_id,mindmap_data")
|
.select("id,title,workspace_id,mindmap_data")
|
||||||
@@ -210,6 +222,11 @@ export const createMindmapServerTools = (args: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadMindmap = async () => {
|
const loadMindmap = async () => {
|
||||||
|
if (args.loadMindmap) {
|
||||||
|
const loaded = await args.loadMindmap();
|
||||||
|
ensureMindmapUids(loaded.base);
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
const doc = await loadDoc();
|
const doc = await loadDoc();
|
||||||
const local = await readMindmapLocal(args.ctx.documentId, args.ctx.mindmapId);
|
const local = await readMindmapLocal(args.ctx.documentId, args.ctx.mindmapId);
|
||||||
const base = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
|
const base = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
|
||||||
@@ -217,6 +234,14 @@ export const createMindmapServerTools = (args: {
|
|||||||
return { doc, base };
|
return { doc, base };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persistMindmap = async (doc: { id: string; title: string | null; workspace_id: string | null }, nextData: MindmapTreeNode) => {
|
||||||
|
if (args.saveMindmap) {
|
||||||
|
await args.saveMindmap({ doc, data: nextData });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
||||||
|
};
|
||||||
|
|
||||||
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
|
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
|
||||||
const maxNodes = Number(toolArgs.maxNodes ?? 120);
|
const maxNodes = Number(toolArgs.maxNodes ?? 120);
|
||||||
const { base } = await loadMindmap();
|
const { base } = await loadMindmap();
|
||||||
@@ -313,7 +338,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
|
|
||||||
const { doc, base } = await loadMindmap();
|
const { doc, base } = await loadMindmap();
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -334,7 +359,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||||
};
|
};
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -355,7 +380,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
|
||||||
};
|
};
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,7 +393,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
const { doc, base } = await loadMindmap();
|
const { doc, base } = await loadMindmap();
|
||||||
const op: MindmapOp = { op: "updateText", uid, text };
|
const op: MindmapOp = { op: "updateText", uid, text };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -384,7 +409,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
const { doc, base } = await loadMindmap();
|
const { doc, base } = await loadMindmap();
|
||||||
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -397,7 +422,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
const { doc, base } = await loadMindmap();
|
const { doc, base } = await loadMindmap();
|
||||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -410,7 +435,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
const { doc, base } = await loadMindmap();
|
const { doc, base } = await loadMindmap();
|
||||||
const op: MindmapOp = { op: "setRefs", uid, refs };
|
const op: MindmapOp = { op: "setRefs", uid, refs };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -421,7 +446,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
const { doc, base } = await loadMindmap();
|
const { doc, base } = await loadMindmap();
|
||||||
const op: MindmapOp = { op: "deleteNode", uid };
|
const op: MindmapOp = { op: "deleteNode", uid };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -493,7 +518,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
|
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
|
||||||
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
|
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -546,7 +571,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
|
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
|
||||||
};
|
};
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -579,7 +604,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
|
|
||||||
const { doc, base } = await loadMindmap();
|
const { doc, base } = await loadMindmap();
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
return { ok: true, applied, errors, data: nextData, meta: { reason } };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -600,7 +625,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
const { doc, base } = await loadMindmap();
|
const { doc, base } = await loadMindmap();
|
||||||
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
const op: MindmapOp = { op: "appendNote", uid, markdown };
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
applied,
|
applied,
|
||||||
@@ -701,7 +726,7 @@ export const createMindmapServerTools = (args: {
|
|||||||
|
|
||||||
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
|
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
|
||||||
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
|
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
|
||||||
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
|
await persistMindmap(doc, nextData);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -63,9 +63,27 @@ const parseSlash = (text: string): ParsedSlash => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createSlashServerTools = (args: {
|
export const createSlashServerTools = (args: {
|
||||||
supabase: SlashSupabaseClient;
|
supabase?: SlashSupabaseClient;
|
||||||
ctx: SlashToolContext;
|
ctx: SlashToolContext;
|
||||||
allowedToolIds: Set<string>;
|
allowedToolIds: Set<string>;
|
||||||
|
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
|
||||||
|
loadWorkspaceIds?: (userId: string) => Promise<string[]>;
|
||||||
|
inferWorkspaceIdFromDoc?: (documentId: string) => Promise<string | null>;
|
||||||
|
createDoc?: (args: { userId: string; workspaceId: string; parentId: string | null; title: string }) => Promise<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
workspaceId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
createdAt: unknown;
|
||||||
|
updatedAt: unknown;
|
||||||
|
}>;
|
||||||
|
renameDoc?: (args: { userId: string; documentId: string; title: string }) => Promise<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
workspaceId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
updatedAt: unknown;
|
||||||
|
}>;
|
||||||
}) => {
|
}) => {
|
||||||
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
|
||||||
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
|
||||||
@@ -106,11 +124,35 @@ export const createSlashServerTools = (args: {
|
|||||||
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
|
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
|
||||||
const workspaceId =
|
const workspaceId =
|
||||||
workspaceIdFromParams ||
|
workspaceIdFromParams ||
|
||||||
(args.ctx.currentDocumentId ? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId) : null) ||
|
(args.ctx.currentDocumentId
|
||||||
(await loadWorkspaceIds(args.supabase, args.ctx.userId))[0] ||
|
? args.inferWorkspaceIdFromDoc
|
||||||
|
? await args.inferWorkspaceIdFromDoc(args.ctx.currentDocumentId)
|
||||||
|
: args.supabase
|
||||||
|
? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId)
|
||||||
|
: null
|
||||||
|
: null) ||
|
||||||
|
((args.loadWorkspaceIds
|
||||||
|
? (await args.loadWorkspaceIds(args.ctx.userId))[0]
|
||||||
|
: args.supabase
|
||||||
|
? (await loadWorkspaceIds(args.supabase, args.ctx.userId))[0]
|
||||||
|
: null) ?? null) ||
|
||||||
null;
|
null;
|
||||||
if (!workspaceId) throw new Error("无法推断 workspaceId(请在 params.workspaceId 指定)");
|
if (!workspaceId) throw new Error("无法推断 workspaceId(请在 params.workspaceId 指定)");
|
||||||
|
|
||||||
|
if (args.createDoc) {
|
||||||
|
const doc = await args.createDoc({
|
||||||
|
userId: args.ctx.userId,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
return { ok: true, command: "new_doc", document: doc };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.supabase) {
|
||||||
|
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc)");
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
workspace_id: workspaceId,
|
workspace_id: workspaceId,
|
||||||
user_id: args.ctx.userId,
|
user_id: args.ctx.userId,
|
||||||
@@ -142,6 +184,15 @@ export const createSlashServerTools = (args: {
|
|||||||
const title = String(parsed.params.title ?? "").trim();
|
const title = String(parsed.params.title ?? "").trim();
|
||||||
if (!documentId || !title) throw new Error("缺少 documentId 或 title");
|
if (!documentId || !title) throw new Error("缺少 documentId 或 title");
|
||||||
|
|
||||||
|
if (args.renameDoc) {
|
||||||
|
const doc = await args.renameDoc({ userId: args.ctx.userId, documentId, title });
|
||||||
|
return { ok: true, command: "rename_doc", document: doc };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.supabase) {
|
||||||
|
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc)");
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await args.supabase
|
const { data, error } = await args.supabase
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.update({ title })
|
.update({ title })
|
||||||
@@ -172,4 +223,3 @@ export const createSlashServerTools = (args: {
|
|||||||
|
|
||||||
return { run };
|
return { run };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AuthContext } from "@/lib/auth/types";
|
||||||
|
import { getDevUser, isDevAuthEnabled } from "@/lib/auth/devUser";
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HttpError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthContext(): AuthContext {
|
||||||
|
if (isDevAuthEnabled()) return getDevUser();
|
||||||
|
// 说明:后续接入真实鉴权时,在这里替换为 Supabase/Convex Auth 的校验逻辑。
|
||||||
|
throw new Error("Auth is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuthContext(): AuthContext {
|
||||||
|
try {
|
||||||
|
return getAuthContext();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unauthorized";
|
||||||
|
throw new HttpError(401, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { AuthContext } from "@/lib/auth/types";
|
||||||
|
|
||||||
|
function _readEnv(key: string): string | undefined {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevUser(): AuthContext {
|
||||||
|
// 说明:第三阶段先用固定用户跑通迁移链路,后续接入真实鉴权时再替换这一层。
|
||||||
|
const userId = _readEnv("DEV_USER_ID") ?? "dev-user";
|
||||||
|
const email = _readEnv("DEV_USER_EMAIL") ?? "dev@mnote.local";
|
||||||
|
const name = _readEnv("DEV_USER_NAME") ?? "开发用户";
|
||||||
|
return { userId, email, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDevAuthEnabled(): boolean {
|
||||||
|
// 说明:目前只要启用了 USE_CONVEX,就默认启用固定用户鉴权(便于迁移与测试)。
|
||||||
|
return process.env.USE_CONVEX === "1";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type AuthContext = {
|
||||||
|
userId: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Json } from "@/types/supabase";
|
||||||
|
|
||||||
|
type BlockLike = {
|
||||||
|
id: string;
|
||||||
|
type?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
content?: unknown;
|
||||||
|
children?: BlockLike[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const asBlockArray = (value: unknown): BlockLike[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((b) => b && typeof b === "object" && typeof (b as { id?: unknown }).id === "string") as BlockLike[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findBlockInTree = (
|
||||||
|
blocks: BlockLike[],
|
||||||
|
blockId: string,
|
||||||
|
): { block: BlockLike; parent: BlockLike | null; index: number } | null => {
|
||||||
|
const stack: Array<{ list: BlockLike[]; parent: BlockLike | null }> = [{ list: blocks, parent: null }];
|
||||||
|
while (stack.length) {
|
||||||
|
const item = stack.pop()!;
|
||||||
|
const list = item.list;
|
||||||
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
|
const b = list[i]!;
|
||||||
|
if (b.id === blockId) {
|
||||||
|
return { block: b, parent: item.parent, index: i };
|
||||||
|
}
|
||||||
|
if (Array.isArray(b.children) && b.children.length > 0) {
|
||||||
|
stack.push({ list: b.children, parent: b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneBlock = (block: BlockLike): BlockLike => {
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
props: block.props ? { ...block.props } : undefined,
|
||||||
|
content: Array.isArray(block.content) ? [...block.content] : block.content,
|
||||||
|
children: Array.isArray(block.children) ? block.children.map(cloneBlock) : block.children,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeBlockSubtree = (
|
||||||
|
blocks: BlockLike[],
|
||||||
|
blockId: string,
|
||||||
|
): { removed: BlockLike | null; nextBlocks: BlockLike[] } => {
|
||||||
|
// 说明:这里不直接修改入参 blocks,返回新的 nextBlocks。
|
||||||
|
const nextTop = blocks.map(cloneBlock);
|
||||||
|
const hit = findBlockInTree(nextTop, blockId);
|
||||||
|
if (!hit) return { removed: null, nextBlocks: nextTop };
|
||||||
|
|
||||||
|
if (hit.parent) {
|
||||||
|
const parent = hit.parent;
|
||||||
|
const nextChildren = asBlockArray(parent.children).map(cloneBlock);
|
||||||
|
const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
|
||||||
|
parent.children = nextChildren;
|
||||||
|
return { removed, nextBlocks: nextTop };
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = nextTop.splice(hit.index, 1)[0] ?? null;
|
||||||
|
return { removed, nextBlocks: nextTop };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceBlockInTree = (
|
||||||
|
blocks: BlockLike[],
|
||||||
|
blockId: string,
|
||||||
|
nextBlock: BlockLike,
|
||||||
|
): { ok: boolean; nextBlocks: BlockLike[] } => {
|
||||||
|
const nextTop = blocks.map(cloneBlock);
|
||||||
|
const hit = findBlockInTree(nextTop, blockId);
|
||||||
|
if (!hit) return { ok: false, nextBlocks: nextTop };
|
||||||
|
|
||||||
|
const normalized = cloneBlock({ ...nextBlock, id: blockId });
|
||||||
|
if (hit.parent) {
|
||||||
|
const parent = hit.parent;
|
||||||
|
const nextChildren = asBlockArray(parent.children).map(cloneBlock);
|
||||||
|
nextChildren[hit.index] = normalized;
|
||||||
|
parent.children = nextChildren;
|
||||||
|
return { ok: true, nextBlocks: nextTop };
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTop[hit.index] = normalized;
|
||||||
|
return { ok: true, nextBlocks: nextTop };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractBlockText = (block: BlockLike): string => {
|
||||||
|
const inline = Array.isArray(block.content) ? (block.content as Array<{ text?: unknown }>) : [];
|
||||||
|
const text = inline.map((n) => (typeof n?.text === "string" ? n.text : "")).join("");
|
||||||
|
return text.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBlocksFromDocumentContent = (content: unknown): BlockLike[] => {
|
||||||
|
if (Array.isArray(content)) return asBlockArray(content);
|
||||||
|
if (content && typeof content === "object") {
|
||||||
|
const blocks = (content as { blocks?: unknown }).blocks;
|
||||||
|
return asBlockArray(blocks);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withBlocksWrittenBack = (content: unknown, blocks: BlockLike[]): Json => {
|
||||||
|
// 复用现有结构:数组或 {blocks: []}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return blocks as unknown as Json;
|
||||||
|
}
|
||||||
|
if (content && typeof content === "object") {
|
||||||
|
return { ...(content as Record<string, unknown>), blocks } as Json;
|
||||||
|
}
|
||||||
|
return { blocks } as Json;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { api, internal } from "../../../convex/_generated/api";
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function isConvexEnabled(): boolean {
|
||||||
|
return process.env.USE_CONVEX === "1";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import type { AuthContext } from "@/lib/auth/types";
|
||||||
|
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||||
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||||
|
|
||||||
|
export function getAuthedConvexClient(): { auth: AuthContext; client: ConvexHttpClient } {
|
||||||
|
const auth = requireAuthContext();
|
||||||
|
const client = getConvexHttpClient();
|
||||||
|
return { auth, client };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ConvexHttpClient } from "convex/browser";
|
||||||
|
|
||||||
|
let cached: ConvexHttpClient | null = null;
|
||||||
|
|
||||||
|
export function getConvexHttpClient(): ConvexHttpClient {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL 配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(url);
|
||||||
|
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY;
|
||||||
|
if (adminKey) {
|
||||||
|
client.setAdminAuth(adminKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = client;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
export type MnoteRuntimeConfig = {
|
export type MnoteRuntimeConfig = {
|
||||||
|
/**
|
||||||
|
* 是否启用 Convex(自部署)链路。
|
||||||
|
* 说明:该字段由服务端在运行期注入到 window.__MNOTE_RUNTIME_CONFIG__,用于客户端按需关闭 Supabase 相关能力。
|
||||||
|
*/
|
||||||
|
useConvex?: boolean;
|
||||||
supabaseUrl?: string;
|
supabaseUrl?: string;
|
||||||
/**
|
/**
|
||||||
* 服务端/本机回源用的 Supabase 地址(通常是 HTTP),用于避免 FRP/自签证书导致 Node 侧 TLS 校验失败。
|
* 服务端/本机回源用的 Supabase 地址(通常是 HTTP),用于避免 FRP/自签证书导致 Node 侧 TLS 校验失败。
|
||||||
@@ -39,10 +44,11 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const readFromEnv = (): MnoteRuntimeConfig => ({
|
const readFromEnv = (): MnoteRuntimeConfig => ({
|
||||||
|
useConvex: process.env.USE_CONVEX === "1",
|
||||||
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
|
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
|
||||||
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||||
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
|
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
|
||||||
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
|
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
|
||||||
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
|
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
|
||||||
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
|
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user