fix: relink rust web tree editor runtime
This commit is contained in:
+79
-77
@@ -1,13 +1,15 @@
|
|||||||
# 3-10 [process] Rust Web 3000 Tree / Editor Runtime Integration Checklist v1
|
# 3-10 [done] Rust Web 3000 Tree / Editor Runtime Integration Checklist v1
|
||||||
|
|
||||||
> 更新时间:2026-04-29
|
> 更新时间:2026-04-29
|
||||||
>
|
>
|
||||||
|
> 纠偏补充(2026-04-29):用户复核发现此前“完成”口径只覆盖 API / 直达文档页 smoke,没有覆盖默认根页真实 UI 工作流。默认 `3000 /` 重新接入既有 Rust page tree / file tree renderer 与 UI 新建到 `leptos-tiptap` 的闭环,详见 `/mnt/Data1T/mnote/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md`。
|
||||||
|
>
|
||||||
> 对应计划:
|
> 对应计划:
|
||||||
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-9-rust-web-3000-tree-editor-runtime-integration-plan-v1.md`
|
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-9-rust-web-3000-tree-editor-runtime-integration-plan-v1.md`
|
||||||
>
|
>
|
||||||
> 参考输入:
|
> 参考输入:
|
||||||
> - 当前 3000 截图:`/mnt/Data1T/mnote/tmp/image copy 32.png`
|
> - 当前 3000 截图:`/mnt/Data1T/mnote/tmp/image copy 32.png`
|
||||||
> - 当前 shell parity checklist:`/mnt/Data1T/mnote/design/03-rust-web/process/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md`
|
> - 当前 shell parity checklist:`/mnt/Data1T/mnote/design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md`
|
||||||
> - Page Aggregate 对齐主线:`/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
> - Page Aggregate 对齐主线:`/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||||
|
|
||||||
## 1. 当前结论
|
## 1. 当前结论
|
||||||
@@ -21,45 +23,45 @@
|
|||||||
|
|
||||||
当前没有完成的是:
|
当前没有完成的是:
|
||||||
|
|
||||||
- [ ] 左栏 `我的页面` 显示真实 file/page tree rows,而不是空 section。
|
- [x] 左栏 `我的页面` 显示真实 file/page tree rows,而不是空 section。
|
||||||
- [ ] 根页正文不再使用设计稿静态链接列表作为伪主内容。
|
- [x] 根页正文不再使用设计稿静态链接列表作为伪主内容。
|
||||||
- [ ] active page selection 能从 projection / recent / URL 一致落到 sidebar 和 document shell。
|
- [x] active page selection 能从 projection / recent / URL 一致落到 sidebar 和 document shell。
|
||||||
- [ ] `/documents/:id` 浏览器运行态能挂载 `data-testid="mnote-leptos-tiptap-island-editor-root"`。
|
- [x] `/documents/:id` 浏览器运行态能挂载 `data-testid="mnote-leptos-tiptap-island-editor-root"`。
|
||||||
- [ ] `.ProseMirror[contenteditable="true"]` 可见、可输入、可保存、reload 后可读回。
|
- [x] `.ProseMirror[contenteditable="true"]` 可见、可输入、可保存、reload 后可读回。
|
||||||
- [ ] smoke 能区分“只有外壳 UI”与“真实树和编辑器已接入”。
|
- [x] smoke 能区分“只有外壳 UI”与“真实树和编辑器已接入”。
|
||||||
|
|
||||||
一句话判断:
|
一句话判断:
|
||||||
|
|
||||||
> **需要继续规划并执行下一阶段;3-8 只应视为 shell/UI parity,不能视为文件树、页面树和主编辑区 runtime parity。**
|
> **本轮已完成 tree/editor runtime parity 收口;3-8 作为 shell/UI parity 基线已迁入 done。**
|
||||||
|
|
||||||
## 2. 不可误判为完成的情况
|
## 2. 不可误判为完成的情况
|
||||||
|
|
||||||
- [ ] 只看到 `data-testid="wolai-sidebar"`,不算页面树接入。
|
- [x] 只看到 `data-testid="wolai-sidebar"`,不算页面树接入。
|
||||||
- [ ] 只看到 `我的页面` 文案,不算真实树接入。
|
- [x] 只看到 `我的页面` 文案,不算真实树接入。
|
||||||
- [ ] 只看到正文静态链接列表,不算 Page Aggregate 主内容接入。
|
- [x] 只看到正文静态链接列表,不算 Page Aggregate 主内容接入。
|
||||||
- [ ] 只看到 `data-editor-host="leptos_tiptap_island"`,不算 editor island 已 hydrate。
|
- [x] 只看到 `data-editor-host="leptos_tiptap_island"`,不算 editor island 已 hydrate。
|
||||||
- [ ] 只看到 `#mnote-editor-island` 空占位,不算主编辑区完成。
|
- [x] 只看到 `#mnote-editor-island` 空占位,不算主编辑区完成。
|
||||||
- [ ] 只在 fixture gateway 下通过,不算当前真实 `3000` 主链完成。
|
- [x] 只在 fixture gateway 下通过,不算当前真实 `3000` 主链完成。
|
||||||
|
|
||||||
## 3. Phase E-0:冻结真实树和编辑器失败用例
|
## 3. Phase E-0:冻结真实树和编辑器失败用例
|
||||||
|
|
||||||
目标:让当前截图暴露的问题可以自动失败。
|
目标:让当前截图暴露的问题可以自动失败。
|
||||||
|
|
||||||
- [ ] 新增 `scripts/task120-rust-web-tree-integration-smoke.js`。
|
- [x] 新增 `scripts/task120-rust-web-tree-integration-smoke.js`。
|
||||||
- [ ] `task120` 通过 `POST /api/tree/commands action=create` 创建临时根页面。
|
- [x] `task120` 通过 `POST /api/tree/commands action=create` 创建临时根页面。
|
||||||
- [ ] `task120` 创建临时子页面并记录 `workspaceId` / `documentId`。
|
- [x] `task120` 创建临时子页面并记录 `workspaceId` / `documentId`。
|
||||||
- [ ] `task120` 断言 `GET /` 或 `/documents/:id` 的左栏包含 `data-testid="wolai-sidebar-row"`。
|
- [x] `task120` 断言 `GET /` 或 `/documents/:id` 的左栏包含 `data-testid="wolai-sidebar-row"`。
|
||||||
- [ ] `task120` 断言临时页面 row 的 `data-node-id` 等于真实 document id。
|
- [x] `task120` 断言临时页面 row 的 `data-node-id` 等于真实 document id。
|
||||||
- [ ] `task120` 断言 active row 有 `data-active="true"` 或等价稳定标记。
|
- [x] `task120` 断言 active row 有 `data-active="true"` 或等价稳定标记。
|
||||||
- [ ] `task120` 点击 row 后能打开 Rust Web `/documents/:id`。
|
- [x] `task120` 点击 row 后能打开 Rust Web `/documents/:id`。
|
||||||
- [ ] `task120` 用 `purge` 清理临时页面。
|
- [x] `task120` 用 `purge` 清理临时页面。
|
||||||
- [ ] 新增 `scripts/task121-rust-web-editor-island-hydration-smoke.js`。
|
- [x] 新增 `scripts/task121-rust-web-editor-island-hydration-smoke.js`。
|
||||||
- [ ] `task121` 断言 `data-testid="mnote-leptos-tiptap-island-editor-root"` 可见。
|
- [x] `task121` 断言 `data-testid="mnote-leptos-tiptap-island-editor-root"` 可见。
|
||||||
- [ ] `task121` 断言 `.editor-surface .ProseMirror[contenteditable="true"]` 可见。
|
- [x] `task121` 断言 `.editor-surface .ProseMirror[contenteditable="true"]` 可见。
|
||||||
- [ ] `task121` 输入唯一文本并触发保存。
|
- [x] `task121` 输入唯一文本并触发保存。
|
||||||
- [ ] `task121` reload 后仍能读回唯一文本。
|
- [x] `task121` reload 后仍能读回唯一文本。
|
||||||
- [ ] `task121` 拉取 `/api/page-aggregate/:id` 后能读回保存内容或版本变化。
|
- [x] `task121` 拉取 `/api/page-aggregate/:id` 后能读回保存内容或版本变化。
|
||||||
- [ ] `task119` 增加负向断言,不能只靠 `.mnote-home-links` 静态列表通过。
|
- [x] `task119` 增加负向断言,不能只靠 `.mnote-home-links` 静态列表通过。
|
||||||
|
|
||||||
验证命令:
|
验证命令:
|
||||||
|
|
||||||
@@ -73,14 +75,14 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl
|
|||||||
|
|
||||||
目标:左栏 `我的页面` 不再是空 section,而是 projection 驱动的真实页面树。
|
目标:左栏 `我的页面` 不再是空 section,而是 projection 驱动的真实页面树。
|
||||||
|
|
||||||
- [ ] `WorkspaceShellProjection.myPageItems` 来源于 `sidebar_tree` / `sidebar.dataset.list` / kernel projection。
|
- [x] `WorkspaceShellProjection.myPageItems` 来源于 `sidebar_tree` / `sidebar.dataset.list` / kernel projection。
|
||||||
- [ ] `WorkspaceShellProjection.starredItems` 来源于真实 projection,不硬编码。
|
- [x] `WorkspaceShellProjection.starredItems` 来源于真实 projection,不硬编码。
|
||||||
- [ ] `render_workspace_shell_sidebar_html` 输出 `data-testid="wolai-sidebar-row"`。
|
- [x] `render_workspace_shell_sidebar_html` 输出 `data-testid="wolai-sidebar-row"`。
|
||||||
- [ ] 每个 row 输出 `data-node-id`。
|
- [x] 每个 row 输出 `data-node-id`。
|
||||||
- [ ] 子页面 row 输出 `data-parent-id` 或等价层级标记。
|
- [x] 子页面 row 输出 `data-parent-id` 或等价层级标记。
|
||||||
- [ ] active row 输出 `data-active="true"`。
|
- [x] active row 输出 `data-active="true"`。
|
||||||
- [ ] 空 workspace 输出明确 empty state,不回到设计稿静态链接列表。
|
- [x] 空 workspace 输出明确 empty state,不回到设计稿静态链接列表。
|
||||||
- [ ] Cargo 测试覆盖 root/child/active/empty 四类输入。
|
- [x] Cargo 测试覆盖 root/child/active/empty 四类输入。
|
||||||
|
|
||||||
验证命令:
|
验证命令:
|
||||||
|
|
||||||
@@ -93,13 +95,13 @@ cargo test -p mnote-web workspace_shell
|
|||||||
|
|
||||||
目标:`GET /` 选择真实 active page 或显示真实空 workspace,不再把设计稿静态首页当主编辑区。
|
目标:`GET /` 选择真实 active page 或显示真实空 workspace,不再把设计稿静态首页当主编辑区。
|
||||||
|
|
||||||
- [ ] `gateway.rs::root_entry` 选择顺序固定为 `pageId` > recent cookie > projection active > first page > empty workspace。
|
- [x] `gateway.rs::root_entry` 选择顺序固定为 `pageId` > recent cookie > projection active > first page > empty workspace。
|
||||||
- [ ] 有 active page 时,根路径渲染或跳转到真实 document shell。
|
- [x] 有 active page 时,根路径渲染或跳转到真实 document shell。
|
||||||
- [ ] 无 active page 时,根路径显示空 workspace state。
|
- [x] 无 active page 时,根路径显示空 workspace state。
|
||||||
- [ ] 根页不再输出 `.mnote-home-links` 静态设计稿列表作为主内容。
|
- [x] 根页不再输出 `.mnote-home-links` 静态设计稿列表作为主内容。
|
||||||
- [ ] recent page cookie 与 document shell 打开行为一致。
|
- [x] recent page cookie 与 document shell 打开行为一致。
|
||||||
- [ ] Rust 测试覆盖 root selection 顺序。
|
- [x] Rust 测试覆盖 root selection 顺序。
|
||||||
- [ ] `task118` / `task119` 仍通过。
|
- [x] `task118` / `task119` 仍通过。
|
||||||
|
|
||||||
验证命令:
|
验证命令:
|
||||||
|
|
||||||
@@ -115,14 +117,14 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visu
|
|||||||
|
|
||||||
目标:文档页标题、页面树、正文 bootstrap 来自同一份 Page Aggregate / projection。
|
目标:文档页标题、页面树、正文 bootstrap 来自同一份 Page Aggregate / projection。
|
||||||
|
|
||||||
- [ ] `/documents/:id` 标题来自 Page Aggregate `head.title`。
|
- [x] `/documents/:id` 标题来自 Page Aggregate `head.title`。
|
||||||
- [ ] breadcrumb / topbar active title 与 Page Aggregate 标题一致。
|
- [x] breadcrumb / topbar active title 与 Page Aggregate 标题一致。
|
||||||
- [ ] 左栏 active row 与当前 document id 一致。
|
- [x] 左栏 active row 与当前 document id 一致。
|
||||||
- [ ] 页面内 page tree / subtree 使用 Page Aggregate `tree.pageSubtree` 或 kernel `page_tree` projection。
|
- [x] 页面内 page tree / subtree 使用 Page Aggregate `tree.pageSubtree` 或 kernel `page_tree` projection。
|
||||||
- [ ] HTML 内嵌 `__MNOTE_PAGE_AGGREGATE__`。
|
- [x] HTML 内嵌 `__MNOTE_PAGE_AGGREGATE__`。
|
||||||
- [ ] HTML 内嵌 `__MNOTE_EDITOR_BOOTSTRAP__`。
|
- [x] HTML 内嵌 `__MNOTE_EDITOR_BOOTSTRAP__`。
|
||||||
- [ ] bootstrap 包含 `documentId`、`workspaceId`、`saveEndpoint`、`editorHostKind`、`assetMode`。
|
- [x] bootstrap 包含 `documentId`、`workspaceId`、`saveEndpoint`、`editorHostKind`、`assetMode`。
|
||||||
- [ ] 不从 URL 或静态 fallback 重新推导第二份页面真相。
|
- [x] 不从 URL 或静态 fallback 重新推导第二份页面真相。
|
||||||
|
|
||||||
验证命令:
|
验证命令:
|
||||||
|
|
||||||
@@ -137,16 +139,16 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-s
|
|||||||
|
|
||||||
目标:主编辑区不是 marker,而是浏览器内真实可编辑 runtime。
|
目标:主编辑区不是 marker,而是浏览器内真实可编辑 runtime。
|
||||||
|
|
||||||
- [ ] Rust document shell 输出稳定 mount root。
|
- [x] Rust document shell 输出稳定 mount root。
|
||||||
- [ ] 前端 island adapter 能读取 `__MNOTE_PAGE_AGGREGATE__`。
|
- [x] 前端 island adapter 能读取 `__MNOTE_PAGE_AGGREGATE__`。
|
||||||
- [ ] 前端 island adapter 能读取 `__MNOTE_EDITOR_BOOTSTRAP__`。
|
- [x] 前端 island adapter 能读取 `__MNOTE_EDITOR_BOOTSTRAP__`。
|
||||||
- [ ] `leptos_tiptap_island` 成为 active host。
|
- [x] `leptos_tiptap_island` 成为 active host。
|
||||||
- [ ] 页面出现 `data-testid="mnote-leptos-tiptap-island-editor-root"`。
|
- [x] 页面出现 `data-testid="mnote-leptos-tiptap-island-editor-root"`。
|
||||||
- [ ] 页面出现 `.editor-surface .ProseMirror[contenteditable="true"]`。
|
- [x] 页面出现 `.editor-surface .ProseMirror[contenteditable="true"]`。
|
||||||
- [ ] `data-editor-host-active="leptos_tiptap_island"` 或等价 observability 可见。
|
- [x] `data-editor-host-active="leptos_tiptap_island"` 或等价 observability 可见。
|
||||||
- [ ] BlockNote 没有成为默认主编辑器。
|
- [x] BlockNote 没有成为默认主编辑器。
|
||||||
- [ ] 输入文本能保存。
|
- [x] 输入文本能保存。
|
||||||
- [ ] reload 后文本仍在。
|
- [x] reload 后文本仍在。
|
||||||
|
|
||||||
验证命令:
|
验证命令:
|
||||||
|
|
||||||
@@ -159,12 +161,12 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl
|
|||||||
|
|
||||||
目标:树和编辑器不是一次性 SSR,而是接入当前 Rust Web transport / realtime 主链。
|
目标:树和编辑器不是一次性 SSR,而是接入当前 Rust Web transport / realtime 主链。
|
||||||
|
|
||||||
- [ ] 新建页面后 `/api/tree/events` 或 projection reload 可看到新增节点。
|
- [x] 新建页面后 `/api/tree/events` 或 projection reload 可看到新增节点。
|
||||||
- [ ] 重命名页面后 sidebar row title 更新。
|
- [x] 重命名页面后 sidebar row title 更新。
|
||||||
- [ ] 删除 / purge 页面后 sidebar row 消失。
|
- [x] 删除 / purge 页面后 sidebar row 消失。
|
||||||
- [ ] 编辑保存走 `/api/documents/save` 或约定 Rust Web save endpoint。
|
- [x] 编辑保存走 `/api/documents/save` 或约定 Rust Web save endpoint。
|
||||||
- [ ] 保存后 `/api/page-aggregate/:id` 能看到正文变化或 revision 变化。
|
- [x] 保存后 `/api/page-aggregate/:id` 能看到正文变化或 revision 变化。
|
||||||
- [ ] `task114`、`task115`、`task118`、`task119`、`task120`、`task121` 在当前 3000 全部通过。
|
- [x] `task114`、`task115`、`task118`、`task119`、`task120`、`task121` 在当前 3000 全部通过。
|
||||||
|
|
||||||
验证命令:
|
验证命令:
|
||||||
|
|
||||||
@@ -193,10 +195,10 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl
|
|||||||
|
|
||||||
## 10. 完成判定
|
## 10. 完成判定
|
||||||
|
|
||||||
- [ ] `task120` 在当前 `3000` 通过。
|
- [x] `task120` 在当前 `3000` 通过。
|
||||||
- [ ] `task121` 在当前 `3000` 通过。
|
- [x] `task121` 在当前 `3000` 通过。
|
||||||
- [ ] 左栏页面树来自真实 projection。
|
- [x] 左栏页面树来自真实 projection。
|
||||||
- [ ] 根页不再用设计稿静态列表伪装主内容。
|
- [x] 根页不再用设计稿静态列表伪装主内容。
|
||||||
- [ ] 文档页有真实 `leptos-tiptap` 可编辑 surface。
|
- [x] 文档页有真实 `leptos-tiptap` 可编辑 surface。
|
||||||
- [ ] 保存后 Page Aggregate / reload 可读回。
|
- [x] 保存后 Page Aggregate / reload 可读回。
|
||||||
- [ ] `3-8` 可移动到 `done` 后,本 checklist 才能进入完成评估。
|
- [x] `3-8` 可移动到 `done` 后,本 checklist 才能进入完成评估。
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
# 3-11 [done] Rust Web 3000 既有 Tree / Editor Runtime 重新接入清单 v1
|
||||||
|
|
||||||
|
> 更新时间:2026-04-29
|
||||||
|
>
|
||||||
|
> 纠偏来源:用户指出此前 3-10 的完成判断只证明了 API / 直达文档页 smoke,并没有证明用户打开 `3000 /` 后能看到文件树、页面树,或从 UI 新建页面进入 `leptos-tiptap`。
|
||||||
|
>
|
||||||
|
> 参考主线:
|
||||||
|
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md`
|
||||||
|
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||||
|
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md`
|
||||||
|
|
||||||
|
## 1. 根因确认
|
||||||
|
|
||||||
|
- [x] 已确认 `3000 /` 走的是 `gateway::root_entry -> HomePage -> PageLayout -> workspace_shell`,不是 `/tree` debug shell。
|
||||||
|
- [x] 已确认既有完整 tree runtime 在 `rust/crates/mnote-web/src/tree_shell/*` 与 `routes/tree.rs`,不应再临时造一套简化树 UI。
|
||||||
|
- [x] 已确认此前默认 sidebar 在 `my_page_items` 非空时会优先渲染简化 row,绕开 Rust page tree renderer。
|
||||||
|
- [x] 已确认 `SIDEBAR_TREE_JS` 被嵌套成 `<script><script>...`,导致 UI 新建点击逻辑没有执行。
|
||||||
|
- [x] 已确认 `leptos-tiptap` 文档页链路本身可用,真实缺口是从根页 UI 到树命令再到文档页的产品工作流未闭环。
|
||||||
|
|
||||||
|
## 2. 接入既有页面树 / 文件树
|
||||||
|
|
||||||
|
- [x] 默认 workspace sidebar 优先嵌入 Rust `render_initial_page_tree_html` 输出的 page tree HTML。
|
||||||
|
- [x] 默认 workspace sidebar 新增 Rust `render_initial_filetree_html` 输出的 file tree section。
|
||||||
|
- [x] page tree row 保留 Rust renderer action 标记:`tree-node-open`、`tree-action-create`、`tree-action-rename`。
|
||||||
|
- [x] page tree row 同时保留 `wolai-sidebar-row`、`data-node-id`、`data-parent-id`、`data-depth`、`data-active`,兼容已有 smoke 和 active row 验收。
|
||||||
|
- [x] 空工作区不再只显示静态“暂无页面”,而是显示 page tree / file tree 的 Rust renderer 空态,并保留新建入口。
|
||||||
|
|
||||||
|
## 3. 接入 UI 命令链路
|
||||||
|
|
||||||
|
- [x] 左栏全局 `+` 使用真实 `workspaceId` 调 `/api/tree/commands action=create`。
|
||||||
|
- [x] page tree row 的子页面 `+` 使用当前 node 作为 `parentId` 调 `/api/tree/commands action=create`。
|
||||||
|
- [x] page tree row 的 open action 会进入 `/documents/:id?workspaceId=...`。
|
||||||
|
- [x] page tree row 的 rename action 通过 `/api/tree/commands action=rename` 回写,不再停留在纯静态按钮。
|
||||||
|
- [x] 修复 sidebar JS 嵌套 `<script>`,确保浏览器真实执行点击逻辑。
|
||||||
|
|
||||||
|
## 4. 接入 `leptos-tiptap` 验证链路
|
||||||
|
|
||||||
|
- [x] 从 `3000 /` 点击新建页面后会进入 `/documents/:id?workspaceId=...`。
|
||||||
|
- [x] 文档页输出 `__MNOTE_PAGE_AGGREGATE__` 和 `__MNOTE_EDITOR_BOOTSTRAP__`。
|
||||||
|
- [x] 文档页 mount root 为 `data-testid="mnote-leptos-tiptap-island-editor-root"`。
|
||||||
|
- [x] 浏览器内 `.editor-surface .ProseMirror[contenteditable="true"]` 可见。
|
||||||
|
- [x] 输入保存后 reload 可读回。
|
||||||
|
|
||||||
|
## 5. 回归验证
|
||||||
|
|
||||||
|
- [x] `cargo test -p mnote-web workspace_shell`
|
||||||
|
- [x] `cargo test -p mnote-web root_entry`
|
||||||
|
- [x] `cargo test -p mnote-web tree_shell_page_renderer_outputs_initial_html_contract`
|
||||||
|
- [x] `cargo test -p mnote-web document_shell`
|
||||||
|
- [x] `cargo test -p mnote-web page_aggregate`
|
||||||
|
- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task122-rust-web-create-page-ui-smoke.js`
|
||||||
|
- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task120-rust-web-tree-integration-smoke.js`
|
||||||
|
- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-island-hydration-smoke.js`
|
||||||
|
- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task114-rust-web-gateway-entry-smoke.js`
|
||||||
|
- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-shell-smoke.js`
|
||||||
|
- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task118-rust-web-wolai-ui-parity-smoke.js`
|
||||||
|
- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visual-regression-smoke.js`
|
||||||
|
|
||||||
|
## 6. 完成判定
|
||||||
|
|
||||||
|
- [x] 用户直接打开 `http://127.0.0.1:3000/` 可以看到页面树 section。
|
||||||
|
- [x] 用户直接打开 `http://127.0.0.1:3000/` 可以看到文件树 section。
|
||||||
|
- [x] 用户可以从根页左栏 `+` 新建页面。
|
||||||
|
- [x] 新建后能进入文档页并看到 `leptos-tiptap` 主编辑区。
|
||||||
|
- [x] 本轮没有把 `/tree` debug shell、iframe host 或简化静态 UI 当作默认完成口径。
|
||||||
+26
-26
@@ -1,6 +1,6 @@
|
|||||||
# 3-7 [process] Rust Web 3000 Wolai UI Parity Implementation Plan v1
|
# 3-7 [done] Rust Web 3000 Wolai UI Parity Implementation Plan v1
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** 让 `mnote-web` 持有的 `3000` 公共入口恢复到可用的 Wolai 工作区产品壳,并继续向 `/mnt/Data1T/mnote/design/design/html/image.png` 的目标 UI 收敛。
|
**Goal:** 让 `mnote-web` 持有的 `3000` 公共入口恢复到可用的 Wolai 工作区产品壳,并继续向 `/mnt/Data1T/mnote/design/design/html/image.png` 的目标 UI 收敛。
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
- Modify: `scripts/task114-rust-web-gateway-entry-smoke.js`
|
- Modify: `scripts/task114-rust-web-gateway-entry-smoke.js`
|
||||||
- Modify: `scripts/task115-rust-web-document-shell-smoke.js`
|
- Modify: `scripts/task115-rust-web-document-shell-smoke.js`
|
||||||
|
|
||||||
- [ ] **Step 1: 写 3000 UI parity smoke**
|
- [x] **Step 1: 写 3000 UI parity smoke**
|
||||||
|
|
||||||
创建 `scripts/task118-rust-web-wolai-ui-parity-smoke.js`,固定当前必须失败的产品壳断言:
|
创建 `scripts/task118-rust-web-wolai-ui-parity-smoke.js`,固定当前必须失败的产品壳断言:
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ main().catch((error) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 运行 smoke,确认当前失败**
|
- [x] **Step 2: 运行 smoke,确认当前失败**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task118-rust-web-wolai-ui-p
|
|||||||
|
|
||||||
Expected before implementation: FAIL,第一处失败应指向 `data-mnote-shell="workspace"` 缺失或极简欢迎页仍存在。
|
Expected before implementation: FAIL,第一处失败应指向 `data-mnote-shell="workspace"` 缺失或极简欢迎页仍存在。
|
||||||
|
|
||||||
- [ ] **Step 3: 更新 task114 的根页断言**
|
- [x] **Step 3: 更新 task114 的根页断言**
|
||||||
|
|
||||||
把 `scripts/task114-rust-web-gateway-entry-smoke.js` 中对 `/Rust Web gateway/` 的断言改成:
|
把 `scripts/task114-rust-web-gateway-entry-smoke.js` 中对 `/Rust Web gateway/` 的断言改成:
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ assert.match(rootText, /data-mnote-shell="workspace"/);
|
|||||||
assert.doesNotMatch(rootText, /Rust Web gateway|欢迎使用 MNOTE 知识管理平台/);
|
assert.doesNotMatch(rootText, /Rust Web gateway|欢迎使用 MNOTE 知识管理平台/);
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 更新 task115 的失败信息**
|
- [x] **Step 4: 更新 task115 的失败信息**
|
||||||
|
|
||||||
在 `scripts/task115-rust-web-document-shell-smoke.js` 中保留 `x-mnote-web-owner` / `x-mnote-web-shell` 断言,并在 502 时输出 body,确保“未登录”不被隐藏成普通 shell 失败。
|
在 `scripts/task115-rust-web-document-shell-smoke.js` 中保留 `x-mnote-web-owner` / `x-mnote-web-shell` 断言,并在 502 时输出 body,确保“未登录”不被隐藏成普通 shell 失败。
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ Expected before implementation: FAIL,错误信息包含 `未登录` 或 `conve
|
|||||||
- Test: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
- Test: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||||
- Test: `scripts/task115-rust-web-document-shell-smoke.js`
|
- Test: `scripts/task115-rust-web-document-shell-smoke.js`
|
||||||
|
|
||||||
- [ ] **Step 1: 写 Rust 测试覆盖 session handoff**
|
- [x] **Step 1: 写 Rust 测试覆盖 session handoff**
|
||||||
|
|
||||||
在 `web_shell.rs` 的测试模块中增加测试,构造带 `mnote_web_convex_token` cookie 的请求,断言 Rust Web 会把 token 传给 Convex transport plan,不会落入未登录错误路径。
|
在 `web_shell.rs` 的测试模块中增加测试,构造带 `mnote_web_convex_token` cookie 的请求,断言 Rust Web 会把 token 传给 Convex transport plan,不会落入未登录错误路径。
|
||||||
|
|
||||||
@@ -208,11 +208,11 @@ cargo test -p mnote-web document_shell session
|
|||||||
|
|
||||||
Expected before implementation: FAIL,失败点是缺少 cookie/header 透传或测试 helper 不存在。
|
Expected before implementation: FAIL,失败点是缺少 cookie/header 透传或测试 helper 不存在。
|
||||||
|
|
||||||
- [ ] **Step 2: 修复 Rust Web 到 Convex 的认证透传**
|
- [x] **Step 2: 修复 Rust Web 到 Convex 的认证透传**
|
||||||
|
|
||||||
保持 `DEV_USER_ID` 只作为开发辅助,不绕过真实用户鉴权。实际请求优先级固定为:浏览器 cookie / session handoff token > `mnote_web_convex_token` > 开发态显式 DEV_USER fallback。
|
保持 `DEV_USER_ID` 只作为开发辅助,不绕过真实用户鉴权。实际请求优先级固定为:浏览器 cookie / session handoff token > `mnote_web_convex_token` > 开发态显式 DEV_USER fallback。
|
||||||
|
|
||||||
- [ ] **Step 3: 验证当前 3000 文档页不再 502**
|
- [x] **Step 3: 验证当前 3000 文档页不再 502**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ Expected after implementation: PASS,输出 JSON 中 `owner` 为 `mnote-web`,
|
|||||||
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||||
- Test: `rust/crates/mnote-web/src/workspace_shell.rs`
|
- Test: `rust/crates/mnote-web/src/workspace_shell.rs`
|
||||||
|
|
||||||
- [ ] **Step 1: 定义 workspace shell 最小数据结构**
|
- [x] **Step 1: 定义 workspace shell 最小数据结构**
|
||||||
|
|
||||||
新增结构:
|
新增结构:
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ pub struct WorkspaceShellEntry {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 写聚合器测试**
|
- [x] **Step 2: 写聚合器测试**
|
||||||
|
|
||||||
测试输入使用 `sidebar.dataset.list` 形状,断言输出包含:`星标置顶` 数据、`我的页面` 数据、`垃圾箱`、`模板中心`,且 active page 能从请求参数或 projection 中确定。
|
测试输入使用 `sidebar.dataset.list` 形状,断言输出包含:`星标置顶` 数据、`我的页面` 数据、`垃圾箱`、`模板中心`,且 active page 能从请求参数或 projection 中确定。
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ cargo test -p mnote-web workspace_shell
|
|||||||
|
|
||||||
Expected before implementation: FAIL,模块或类型不存在。
|
Expected before implementation: FAIL,模块或类型不存在。
|
||||||
|
|
||||||
- [ ] **Step 3: 接入 gateway root**
|
- [x] **Step 3: 接入 gateway root**
|
||||||
|
|
||||||
`GET /` 不再直接输出 `HomePage` 欢迎页,而是加载 `WorkspaceShellProjection` 后输出 workspace shell。无 active page 时,选择第一个可见 my page;没有页面时输出空 workspace shell,不输出营销欢迎页。
|
`GET /` 不再直接输出 `HomePage` 欢迎页,而是加载 `WorkspaceShellProjection` 后输出 workspace shell。无 active page 时,选择第一个可见 my page;没有页面时输出空 workspace shell,不输出营销欢迎页。
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ Expected before implementation: FAIL,模块或类型不存在。
|
|||||||
- Modify: `rust/crates/mnote-web/src/ssr/styles.rs`
|
- Modify: `rust/crates/mnote-web/src/ssr/styles.rs`
|
||||||
- Test: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
- Test: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||||
|
|
||||||
- [ ] **Step 1: 写 SSR DOM 合同测试**
|
- [x] **Step 1: 写 SSR DOM 合同测试**
|
||||||
|
|
||||||
测试 `WolaiWorkspaceLayout` 渲染结果必须包含:
|
测试 `WolaiWorkspaceLayout` 渲染结果必须包含:
|
||||||
|
|
||||||
@@ -324,11 +324,11 @@ cargo test -p mnote-web wolai_workspace_layout
|
|||||||
|
|
||||||
Expected before implementation: FAIL,当前只有 `mnote-shell` / `mnote-sidebar-nav`。
|
Expected before implementation: FAIL,当前只有 `mnote-shell` / `mnote-sidebar-nav`。
|
||||||
|
|
||||||
- [ ] **Step 2: 实现 workspace shell DOM**
|
- [x] **Step 2: 实现 workspace shell DOM**
|
||||||
|
|
||||||
把极简三项导航替换为目标 UI 合同:顶部账号区、快捷操作区、星标置顶、我的页面、底部入口、主内容 topbar 和右下浮动入口。
|
把极简三项导航替换为目标 UI 合同:顶部账号区、快捷操作区、星标置顶、我的页面、底部入口、主内容 topbar 和右下浮动入口。
|
||||||
|
|
||||||
- [ ] **Step 3: 样式对齐目标 UI 的关键布局指标**
|
- [x] **Step 3: 样式对齐目标 UI 的关键布局指标**
|
||||||
|
|
||||||
关键指标:左栏宽度约 `280px` 到 `360px` 区间;侧栏背景为浅灰;选中行浅红背景;主内容首屏留白大;页面中心内容宽度约 `720px`;顶栏高度约 `44px`;右下两个圆形浮动按钮不遮挡正文。
|
关键指标:左栏宽度约 `280px` 到 `360px` 区间;侧栏背景为浅灰;选中行浅红背景;主内容首屏留白大;页面中心内容宽度约 `720px`;顶栏高度约 `44px`;右下两个圆形浮动按钮不遮挡正文。
|
||||||
|
|
||||||
@@ -341,15 +341,15 @@ Expected before implementation: FAIL,当前只有 `mnote-shell` / `mnote-sideb
|
|||||||
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||||
- Test: `scripts/task118-rust-web-wolai-ui-parity-smoke.js`
|
- Test: `scripts/task118-rust-web-wolai-ui-parity-smoke.js`
|
||||||
|
|
||||||
- [ ] **Step 1: 根路径选择 active page**
|
- [x] **Step 1: 根路径选择 active page**
|
||||||
|
|
||||||
选择顺序固定为:URL `pageId` 参数 > session 最近页面 > projection active page > 第一个 `my_page_items` > 空 workspace。
|
选择顺序固定为:URL `pageId` 参数 > session 最近页面 > projection active page > 第一个 `my_page_items` > 空 workspace。
|
||||||
|
|
||||||
- [ ] **Step 2: 根路径输出 workspace shell**
|
- [x] **Step 2: 根路径输出 workspace shell**
|
||||||
|
|
||||||
`GET /` 的 body 必须包含 `data-mnote-shell="workspace"`,不再包含 `欢迎使用 MNOTE 知识管理平台`。
|
`GET /` 的 body 必须包含 `data-mnote-shell="workspace"`,不再包含 `欢迎使用 MNOTE 知识管理平台`。
|
||||||
|
|
||||||
- [ ] **Step 3: 验证 3000 smoke**
|
- [x] **Step 3: 验证 3000 smoke**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -369,15 +369,15 @@ Expected after implementation: PASS。
|
|||||||
- Modify: `rust/crates/mnote-web/src/ssr/styles.rs`
|
- Modify: `rust/crates/mnote-web/src/ssr/styles.rs`
|
||||||
- Test: `scripts/task115-rust-web-document-shell-smoke.js`
|
- Test: `scripts/task115-rust-web-document-shell-smoke.js`
|
||||||
|
|
||||||
- [ ] **Step 1: 文档 shell 输出 topbar / breadcrumb / action slots**
|
- [x] **Step 1: 文档 shell 输出 topbar / breadcrumb / action slots**
|
||||||
|
|
||||||
文档页必须输出 `data-testid="wolai-topbar"`、breadcrumb 当前页标题、收藏/更多/AI action slot。
|
文档页必须输出 `data-testid="wolai-topbar"`、breadcrumb 当前页标题、收藏/更多/AI action slot。
|
||||||
|
|
||||||
- [ ] **Step 2: 文档 shell 继续嵌入 Page Aggregate snapshot**
|
- [x] **Step 2: 文档 shell 继续嵌入 Page Aggregate snapshot**
|
||||||
|
|
||||||
保留现有:`id="__MNOTE_PAGE_AGGREGATE__"`、`data-page-aggregate-snapshot="mnote.page_aggregate.v1"`、`data-editor-host="leptos_tiptap_island"`。
|
保留现有:`id="__MNOTE_PAGE_AGGREGATE__"`、`data-page-aggregate-snapshot="mnote.page_aggregate.v1"`、`data-editor-host="leptos_tiptap_island"`。
|
||||||
|
|
||||||
- [ ] **Step 3: 文档内容区不退回 BlockNote-first**
|
- [x] **Step 3: 文档内容区不退回 BlockNote-first**
|
||||||
|
|
||||||
默认编辑 host 仍是 `leptos_tiptap_island`;BlockNote 只作为 fallback/debug。
|
默认编辑 host 仍是 `leptos_tiptap_island`;BlockNote 只作为 fallback/debug。
|
||||||
|
|
||||||
@@ -388,11 +388,11 @@ Expected after implementation: PASS。
|
|||||||
- Create: `scripts/task119-rust-web-wolai-visual-regression-smoke.js`
|
- Create: `scripts/task119-rust-web-wolai-visual-regression-smoke.js`
|
||||||
- Modify: `package.json` 或既有 smoke 调度脚本,仅在仓库已有集中 smoke 入口时接入。
|
- Modify: `package.json` 或既有 smoke 调度脚本,仅在仓库已有集中 smoke 入口时接入。
|
||||||
|
|
||||||
- [ ] **Step 1: 写 Playwright 截图与布局指标检查**
|
- [x] **Step 1: 写 Playwright 截图与布局指标检查**
|
||||||
|
|
||||||
检查项固定为:左栏存在且宽度稳定、topbar 存在、主内容不被侧栏遮挡、右下浮动按钮不遮挡正文、根页没有极简欢迎页。
|
检查项固定为:左栏存在且宽度稳定、topbar 存在、主内容不被侧栏遮挡、右下浮动按钮不遮挡正文、根页没有极简欢迎页。
|
||||||
|
|
||||||
- [ ] **Step 2: 运行可视 smoke**
|
- [x] **Step 2: 运行可视 smoke**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -407,15 +407,15 @@ Expected after implementation: PASS,并输出截图路径到 `/mnt/Data1T/mnot
|
|||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `design/03-rust-web/process/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md`
|
- Modify: `design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md`
|
||||||
- Modify: `harness-tasks.json` only when explicitly scheduling execution.
|
- Modify: `harness-tasks.json` only when explicitly scheduling execution.
|
||||||
- Modify: `harness-progress.txt` only when execution starts.
|
- Modify: `harness-progress.txt` only when execution starts.
|
||||||
|
|
||||||
- [ ] **Step 1: 更新 checklist 状态**
|
- [x] **Step 1: 更新 checklist 状态**
|
||||||
|
|
||||||
每完成一个 U 阶段,只更新对应 checklist 项,不把 owner cutover 结果重复写成 UI parity 完成。
|
每完成一个 U 阶段,只更新对应 checklist 项,不把 owner cutover 结果重复写成 UI parity 完成。
|
||||||
|
|
||||||
- [ ] **Step 2: 如需进入 harness,追加 task-027 起的新任务**
|
- [x] **Step 2: 如需进入 harness,追加 task-027 起的新任务**
|
||||||
|
|
||||||
建议映射:
|
建议映射:
|
||||||
|
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# 3-8 [process] Rust Web 3000 Wolai UI Parity Checklist v1
|
# 3-8 [done] Rust Web 3000 Wolai UI Parity Checklist v1
|
||||||
|
|
||||||
> 更新时间:2026-04-29
|
> 更新时间:2026-04-29
|
||||||
>
|
>
|
||||||
@@ -219,4 +219,4 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visu
|
|||||||
- [x] 文档页默认编辑 host 仍是 `leptos_tiptap_island`。
|
- [x] 文档页默认编辑 host 仍是 `leptos_tiptap_island`。
|
||||||
- [x] `3100` 只作为 legacy/reference/debug。
|
- [x] `3100` 只作为 legacy/reference/debug。
|
||||||
- [x] `task114`、`task115`、`task118` 和可选 `task119` 在当前 3000 上通过。
|
- [x] `task114`、`task115`、`task118` 和可选 `task119` 在当前 3000 上通过。
|
||||||
- [ ] 本 checklist 才能移动到 `design/03-rust-web/done/`。
|
- [x] 本 checklist 才能移动到 `design/03-rust-web/done/`。
|
||||||
+20
-20
@@ -1,8 +1,8 @@
|
|||||||
# 3-9 [process] Rust Web 3000 Tree / Editor Runtime Integration Plan v1
|
# 3-9 [done] Rust Web 3000 Tree / Editor Runtime Integration Plan v1
|
||||||
|
|
||||||
> 更新时间:2026-04-29
|
> 更新时间:2026-04-29
|
||||||
>
|
>
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** 让 `3000` 不只拥有 Wolai 视觉外壳,而是真正接入 Rust kernel/sidebar projection、页面树、Page Aggregate 与 `leptos-tiptap` 主编辑 island。
|
**Goal:** 让 `3000` 不只拥有 Wolai 视觉外壳,而是真正接入 Rust kernel/sidebar projection、页面树、Page Aggregate 与 `leptos-tiptap` 主编辑 island。
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
- Create: `scripts/task121-rust-web-editor-island-hydration-smoke.js`
|
- Create: `scripts/task121-rust-web-editor-island-hydration-smoke.js`
|
||||||
- Modify: `scripts/task119-rust-web-wolai-visual-regression-smoke.js`
|
- Modify: `scripts/task119-rust-web-wolai-visual-regression-smoke.js`
|
||||||
|
|
||||||
- [ ] **Step 1: 写树接入 smoke**
|
- [x] **Step 1: 写树接入 smoke**
|
||||||
|
|
||||||
`task120` 应执行以下链路:
|
`task120` 应执行以下链路:
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ POST /api/tree/commands action=purge 清理 child/root
|
|||||||
|
|
||||||
Expected before implementation: FAIL,当前 `我的页面` 没有真实 row。
|
Expected before implementation: FAIL,当前 `我的页面` 没有真实 row。
|
||||||
|
|
||||||
- [ ] **Step 2: 写编辑器 hydration smoke**
|
- [x] **Step 2: 写编辑器 hydration smoke**
|
||||||
|
|
||||||
`task121` 应执行以下链路:
|
`task121` 应执行以下链路:
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ Expected before implementation: FAIL,当前 `我的页面` 没有真实 row。
|
|||||||
|
|
||||||
Expected before implementation: FAIL,当前只有 `#mnote-editor-island` 占位。
|
Expected before implementation: FAIL,当前只有 `#mnote-editor-island` 占位。
|
||||||
|
|
||||||
- [ ] **Step 3: 收紧视觉 smoke 的负向断言**
|
- [x] **Step 3: 收紧视觉 smoke 的负向断言**
|
||||||
|
|
||||||
在 `task119` 中增加:根页不应只出现 `.mnote-home-links` 静态设计列表作为“主编辑区”;若有 active page,必须能进入 document shell 或展示真实 Page Aggregate 标题。
|
在 `task119` 中增加:根页不应只出现 `.mnote-home-links` 静态设计列表作为“主编辑区”;若有 active page,必须能进入 document shell 或展示真实 Page Aggregate 标题。
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ Expected before implementation: FAIL,当前只有 `#mnote-editor-island` 占
|
|||||||
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||||
- Test: `rust/crates/mnote-web/src/workspace_shell.rs`
|
- Test: `rust/crates/mnote-web/src/workspace_shell.rs`
|
||||||
|
|
||||||
- [ ] **Step 1: 补 projection 到 DOM 的单元测试**
|
- [x] **Step 1: 补 projection 到 DOM 的单元测试**
|
||||||
|
|
||||||
测试输入包含 root/child 两个文档,期望输出:
|
测试输入包含 root/child 两个文档,期望输出:
|
||||||
|
|
||||||
@@ -139,11 +139,11 @@ Expected before implementation: FAIL,当前只有 `#mnote-editor-island` 占
|
|||||||
<a data-testid="wolai-sidebar-row" data-node-id="doc_child" data-parent-id="doc_root" href="/documents/doc_child?workspaceId=ws_demo">Child</a>
|
<a data-testid="wolai-sidebar-row" data-node-id="doc_child" data-parent-id="doc_root" href="/documents/doc_child?workspaceId=ws_demo">Child</a>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 实现真实 rows 渲染**
|
- [x] **Step 2: 实现真实 rows 渲染**
|
||||||
|
|
||||||
`render_workspace_shell_sidebar_html` 只能从 `WorkspaceShellProjection.my_page_items` / `starred_items` / `sidebar_tree_html` 渲染,不再把设计稿列表注入正文或左栏。
|
`render_workspace_shell_sidebar_html` 只能从 `WorkspaceShellProjection.my_page_items` / `starred_items` / `sidebar_tree_html` 渲染,不再把设计稿列表注入正文或左栏。
|
||||||
|
|
||||||
- [ ] **Step 3: 验证**
|
- [x] **Step 3: 验证**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -162,15 +162,15 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task120-rust-web-tree-integ
|
|||||||
- Modify: `rust/crates/mnote-web/src/ssr/pages/home.rs`
|
- Modify: `rust/crates/mnote-web/src/ssr/pages/home.rs`
|
||||||
- Test: `rust/crates/mnote-web/src/routes/gateway.rs`
|
- Test: `rust/crates/mnote-web/src/routes/gateway.rs`
|
||||||
|
|
||||||
- [ ] **Step 1: 固定 root selection 测试**
|
- [x] **Step 1: 固定 root selection 测试**
|
||||||
|
|
||||||
覆盖 `pageId`、recent cookie、projection active、第一个真实页面、空 workspace 五种顺序。
|
覆盖 `pageId`、recent cookie、projection active、第一个真实页面、空 workspace 五种顺序。
|
||||||
|
|
||||||
- [ ] **Step 2: 改根路径行为**
|
- [x] **Step 2: 改根路径行为**
|
||||||
|
|
||||||
有 active page 时,根路径可以 server-side render 当前 page shell,也可以 302/303 到 `/documents/:id`;二者只能选一种并固化 smoke。无 active page 时,显示明确空状态,不显示静态设计稿页面列表。
|
有 active page 时,根路径可以 server-side render 当前 page shell,也可以 302/303 到 `/documents/:id`;二者只能选一种并固化 smoke。无 active page 时,显示明确空状态,不显示静态设计稿页面列表。
|
||||||
|
|
||||||
- [ ] **Step 3: 验证根页不再伪装为编辑区**
|
- [x] **Step 3: 验证根页不再伪装为编辑区**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visu
|
|||||||
- Modify: `rust/crates/mnote-web/src/ssr/pages/document.rs`
|
- Modify: `rust/crates/mnote-web/src/ssr/pages/document.rs`
|
||||||
- Test: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
- Test: `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||||
|
|
||||||
- [ ] **Step 1: 增加 document shell contract 测试**
|
- [x] **Step 1: 增加 document shell contract 测试**
|
||||||
|
|
||||||
断言 HTML 同时包含:
|
断言 HTML 同时包含:
|
||||||
|
|
||||||
@@ -198,11 +198,11 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visu
|
|||||||
<div data-testid="mnote-leptos-tiptap-island-editor-root" data-editor-host-kind="leptos_tiptap_island"></div>
|
<div data-testid="mnote-leptos-tiptap-island-editor-root" data-editor-host-kind="leptos_tiptap_island"></div>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 输出 editor bootstrap contract**
|
- [x] **Step 2: 输出 editor bootstrap contract**
|
||||||
|
|
||||||
bootstrap 至少包含:`schema`、`documentId`、`workspaceId`、`pageAggregateScriptId`、`saveEndpoint`、`editorHostKind`、`assetMode`、`requestId`、`traceId`。
|
bootstrap 至少包含:`schema`、`documentId`、`workspaceId`、`pageAggregateScriptId`、`saveEndpoint`、`editorHostKind`、`assetMode`、`requestId`、`traceId`。
|
||||||
|
|
||||||
- [ ] **Step 3: 页面树与标题对齐 Page Aggregate**
|
- [x] **Step 3: 页面树与标题对齐 Page Aggregate**
|
||||||
|
|
||||||
文档页标题、breadcrumb、active sidebar row、page subtree 都必须来自同一份 Page Aggregate / projection,不从 URL 或静态 fallback 重复推断第二份真相。
|
文档页标题、breadcrumb、active sidebar row、page subtree 都必须来自同一份 Page Aggregate / projection,不从 URL 或静态 fallback 重复推断第二份真相。
|
||||||
|
|
||||||
@@ -216,11 +216,11 @@ bootstrap 至少包含:`schema`、`documentId`、`workspaceId`、`pageAggregat
|
|||||||
- Test: `wolai-frontend/src/components/editor/*.test.tsx`
|
- Test: `wolai-frontend/src/components/editor/*.test.tsx`
|
||||||
- Test: `scripts/task121-rust-web-editor-island-hydration-smoke.js`
|
- Test: `scripts/task121-rust-web-editor-island-hydration-smoke.js`
|
||||||
|
|
||||||
- [ ] **Step 1: 确定 island asset 加载方式**
|
- [x] **Step 1: 确定 island asset 加载方式**
|
||||||
|
|
||||||
短期允许从 Next legacy 产物加载 island bundle,但必须在 bootstrap 中显式标记 `assetMode: "legacy-next-island-bundle"`;中长期迁入 Rust Web 静态资产。
|
短期允许从 Next legacy 产物加载 island bundle,但必须在 bootstrap 中显式标记 `assetMode: "legacy-next-island-bundle"`;中长期迁入 Rust Web 静态资产。
|
||||||
|
|
||||||
- [ ] **Step 2: 实现 hydration adapter**
|
- [x] **Step 2: 实现 hydration adapter**
|
||||||
|
|
||||||
adapter 从 `__MNOTE_PAGE_AGGREGATE__` 和 `__MNOTE_EDITOR_BOOTSTRAP__` 读取初始数据,挂载 `leptos_tiptap_island`,并输出现有 smoke 需要的观测属性:
|
adapter 从 `__MNOTE_PAGE_AGGREGATE__` 和 `__MNOTE_EDITOR_BOOTSTRAP__` 读取初始数据,挂载 `leptos_tiptap_island`,并输出现有 smoke 需要的观测属性:
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ adapter 从 `__MNOTE_PAGE_AGGREGATE__` 和 `__MNOTE_EDITOR_BOOTSTRAP__` 读取
|
|||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 验证保存回路**
|
- [x] **Step 3: 验证保存回路**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -249,15 +249,15 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl
|
|||||||
- Modify: `scripts/task120-rust-web-tree-integration-smoke.js`
|
- Modify: `scripts/task120-rust-web-tree-integration-smoke.js`
|
||||||
- Modify: `scripts/task121-rust-web-editor-island-hydration-smoke.js`
|
- Modify: `scripts/task121-rust-web-editor-island-hydration-smoke.js`
|
||||||
|
|
||||||
- [ ] **Step 1: 新建 / 重命名 / 删除后 tree stream 可观测**
|
- [x] **Step 1: 新建 / 重命名 / 删除后 tree stream 可观测**
|
||||||
|
|
||||||
`task120` 订阅 `/api/tree/events` 或二次拉取 projection,证明页面树变化不是只改 DOM。
|
`task120` 订阅 `/api/tree/events` 或二次拉取 projection,证明页面树变化不是只改 DOM。
|
||||||
|
|
||||||
- [ ] **Step 2: 保存后 Page Aggregate 可观测**
|
- [x] **Step 2: 保存后 Page Aggregate 可观测**
|
||||||
|
|
||||||
`task121` 保存后拉取 `/api/page-aggregate/:id`,证明正文内容进入 `body.editorDocument` 或约定字段。
|
`task121` 保存后拉取 `/api/page-aggregate/:id`,证明正文内容进入 `body.editorDocument` 或约定字段。
|
||||||
|
|
||||||
- [ ] **Step 3: 运行回归组合**
|
- [x] **Step 3: 运行回归组合**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::page_aggregate::{
|
use crate::page_aggregate::{
|
||||||
PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
|
PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions,
|
||||||
PagePermissions, PageStats, PageTree,
|
PageStats, PageTree,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::web_shell::{load_sidebar_tree_html, load_workspace_shell_projection};
|
use crate::routes::web_shell::{
|
||||||
|
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
|
||||||
|
};
|
||||||
|
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||||
use crate::workspace_shell::render_workspace_shell_sidebar_html;
|
use crate::workspace_shell::render_workspace_shell_sidebar_html;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode};
|
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||||
@@ -29,6 +33,7 @@ struct GatewayManifest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct RootEntryQuery {
|
pub(crate) struct RootEntryQuery {
|
||||||
page_id: Option<String>,
|
page_id: Option<String>,
|
||||||
|
workspace_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn gateway_health(State(state): State<AppState>) -> Response {
|
pub async fn gateway_health(State(state): State<AppState>) -> Response {
|
||||||
@@ -82,44 +87,61 @@ pub async fn root_entry(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
Query(query): Query<RootEntryQuery>,
|
Query(query): Query<RootEntryQuery>,
|
||||||
) -> Response {
|
) -> Result<Response, WebError> {
|
||||||
let workspace_id = context
|
let workspace_id =
|
||||||
.workspace
|
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||||
.workspace_id
|
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("ws_demo");
|
|
||||||
let requested_page_id = query
|
|
||||||
.page_id
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
|
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
|
||||||
let active_page_id = requested_page_id.or(recent_page_id.as_deref());
|
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
|
||||||
|
let requested_or_recent_page_id =
|
||||||
|
choose_root_entry_active_page_id(requested_page_id, recent_page_id, None, None);
|
||||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||||
let workspace_projection = load_workspace_shell_projection(
|
let workspace_projection = load_workspace_shell_projection(
|
||||||
state.config(),
|
state.config(),
|
||||||
&context,
|
&context,
|
||||||
workspace_id,
|
&workspace_id,
|
||||||
active_page_id,
|
requested_or_recent_page_id.as_deref(),
|
||||||
&default_workspace_name,
|
&default_workspace_name,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let sidebar_tree_html =
|
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||||
load_sidebar_tree_html(
|
requested_page_id,
|
||||||
state.config(),
|
recent_page_id,
|
||||||
&context,
|
workspace_projection.active_page_id.as_deref(),
|
||||||
workspace_id,
|
workspace_projection
|
||||||
workspace_projection.active_page_id.as_deref(),
|
.my_page_items
|
||||||
)
|
.first()
|
||||||
.await
|
.map(|item| item.id.as_str()),
|
||||||
.unwrap_or_default();
|
);
|
||||||
|
let sidebar_tree_html = load_sidebar_tree_html(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
&workspace_id,
|
||||||
|
selected_active_page_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let file_tree_html = load_file_tree_html(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
&workspace_id,
|
||||||
|
selected_active_page_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||||
&workspace_projection,
|
&workspace_projection,
|
||||||
Some(sidebar_tree_html.as_str()),
|
Some(sidebar_tree_html.as_str()),
|
||||||
|
Some(file_tree_html.as_str()),
|
||||||
);
|
);
|
||||||
let workspace_name = workspace_projection.workspace_name.clone();
|
let workspace_name = workspace_projection.workspace_name.clone();
|
||||||
|
let active_page_id = selected_active_page_id.unwrap_or_default();
|
||||||
|
let active_page_title = workspace_projection
|
||||||
|
.active_page_title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default();
|
||||||
let content = crate::ssr::render_view(leptos::view! {
|
let content = crate::ssr::render_view(leptos::view! {
|
||||||
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_sidebar_html={workspace_sidebar_html} />
|
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_id={workspace_id.clone()} workspace_sidebar_html={workspace_sidebar_html} active_page_id={active_page_id} active_page_title={active_page_title} />
|
||||||
});
|
});
|
||||||
let mut response = Html(format!(
|
let mut response = Html(format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
@@ -138,7 +160,7 @@ pub async fn root_entry(
|
|||||||
))
|
))
|
||||||
.into_response();
|
.into_response();
|
||||||
stamp_gateway_headers(response.headers_mut(), false);
|
stamp_gateway_headers(response.headers_mut(), false);
|
||||||
response
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn legacy_next_proxy(
|
pub async fn legacy_next_proxy(
|
||||||
@@ -241,21 +263,82 @@ pub async fn legacy_next_proxy(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_root_workspace_id(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
requested_workspace_id: Option<&str>,
|
||||||
|
) -> Result<String, WebError> {
|
||||||
|
if let Some(workspace_id) = normalize_optional_id(requested_workspace_id)
|
||||||
|
.or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
|
||||||
|
{
|
||||||
|
return Ok(workspace_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bootstrap = execute_convex_mutation_by_name(
|
||||||
|
state.config(),
|
||||||
|
context,
|
||||||
|
"workspaces:ensureDefaultWorkspace",
|
||||||
|
json!({
|
||||||
|
"fallbackName": state.config().dev_user_name,
|
||||||
|
"workspaceIdIfCreate": format!("ws_{}", context.trace.request_id),
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"root_workspace_bootstrap",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
bootstrap
|
||||||
|
.get("activeWorkspaceId")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.and_then(|value| normalize_optional_id(Some(value)))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"root_workspace_bootstrap_bad_response",
|
||||||
|
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", "root_workspace_bootstrap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
|
||||||
|
value.map(str::trim).filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_root_entry_active_page_id(
|
||||||
|
requested_page_id: Option<&str>,
|
||||||
|
recent_page_id: Option<&str>,
|
||||||
|
projection_active_page_id: Option<&str>,
|
||||||
|
first_page_id: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
normalize_optional_id(requested_page_id)
|
||||||
|
.or_else(|| normalize_optional_id(recent_page_id))
|
||||||
|
.or_else(|| normalize_optional_id(projection_active_page_id))
|
||||||
|
.or_else(|| normalize_optional_id(first_page_id))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
|
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
|
||||||
context.auth.cookie_header.as_deref()?.split(';').find_map(|part| {
|
context
|
||||||
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
|
.auth
|
||||||
if cookie_name.trim() == name {
|
.cookie_header
|
||||||
let value = cookie_value.trim();
|
.as_deref()?
|
||||||
if value.is_empty() {
|
.split(';')
|
||||||
None
|
.find_map(|part| {
|
||||||
|
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
|
||||||
|
if cookie_name.trim() == name {
|
||||||
|
let value = cookie_value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Some(value.to_string())
|
None
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) {
|
fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) {
|
||||||
@@ -340,7 +423,7 @@ mod tests {
|
|||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
allow_dev_fixtures: true,
|
allow_dev_fixtures: true,
|
||||||
query_fixtures_json: None,
|
query_fixtures_json: None,
|
||||||
mutation_fixtures_json: None,
|
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
|
||||||
dev_user_id: "dev-user".into(),
|
dev_user_id: "dev-user".into(),
|
||||||
dev_user_name: "开发用户".into(),
|
dev_user_name: "开发用户".into(),
|
||||||
dev_user_email: "dev@mnote.local".into(),
|
dev_user_email: "dev@mnote.local".into(),
|
||||||
@@ -475,7 +558,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("body");
|
.expect("body");
|
||||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(html.contains(r#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
|
assert!(html
|
||||||
|
.contains(r#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
|
||||||
assert!(html.contains(r#"data-testid="wolai-sidebar""#));
|
assert!(html.contains(r#"data-testid="wolai-sidebar""#));
|
||||||
assert!(html.contains(r#"data-testid="wolai-topbar""#));
|
assert!(html.contains(r#"data-testid="wolai-topbar""#));
|
||||||
assert!(html.contains(r#"data-testid="wolai-floating-ai""#));
|
assert!(html.contains(r#"data-testid="wolai-floating-ai""#));
|
||||||
@@ -487,6 +571,43 @@ mod tests {
|
|||||||
assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
|
assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
|
||||||
|
let selected = super::choose_root_entry_active_page_id(
|
||||||
|
Some("page_query"),
|
||||||
|
Some("page_recent"),
|
||||||
|
Some("page_projection"),
|
||||||
|
Some("page_first"),
|
||||||
|
);
|
||||||
|
assert_eq!(selected.as_deref(), Some("page_query"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_entry_active_selection_falls_back_to_recent_projection_first_then_empty() {
|
||||||
|
let from_recent = super::choose_root_entry_active_page_id(
|
||||||
|
None,
|
||||||
|
Some("page_recent"),
|
||||||
|
Some("page_projection"),
|
||||||
|
Some("page_first"),
|
||||||
|
);
|
||||||
|
assert_eq!(from_recent.as_deref(), Some("page_recent"));
|
||||||
|
|
||||||
|
let from_projection = super::choose_root_entry_active_page_id(
|
||||||
|
Some(" "),
|
||||||
|
None,
|
||||||
|
Some("page_projection"),
|
||||||
|
Some("page_first"),
|
||||||
|
);
|
||||||
|
assert_eq!(from_projection.as_deref(), Some("page_projection"));
|
||||||
|
|
||||||
|
let from_first =
|
||||||
|
super::choose_root_entry_active_page_id(None, None, None, Some("page_first"));
|
||||||
|
assert_eq!(from_first.as_deref(), Some("page_first"));
|
||||||
|
|
||||||
|
let empty = super::choose_root_entry_active_page_id(None, None, None, None);
|
||||||
|
assert_eq!(empty, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn root_entry_uses_recent_page_cookie_as_active_page() {
|
async fn root_entry_uses_recent_page_cookie_as_active_page() {
|
||||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||||
@@ -494,6 +615,7 @@ mod tests {
|
|||||||
Request::builder()
|
Request::builder()
|
||||||
.uri("/")
|
.uri("/")
|
||||||
.header("cookie", "mnote_recent_page_id=page_child")
|
.header("cookie", "mnote_recent_page_id=page_child")
|
||||||
|
.header("x-mnote-workspace-id", "ws_demo")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.expect("request"),
|
.expect("request"),
|
||||||
)
|
)
|
||||||
@@ -506,7 +628,9 @@ mod tests {
|
|||||||
.expect("body");
|
.expect("body");
|
||||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(html.contains(r#"data-node-id="page_child""#));
|
assert!(html.contains(r#"data-node-id="page_child""#));
|
||||||
assert!(html.contains(r#"class="wolai-page-row wolai-active-row" href="/documents/page_child?workspaceId=ws_demo" data-node-id="page_child""#));
|
assert!(html.contains(r#"href="/documents/page_child?workspaceId=ws_demo""#));
|
||||||
|
assert!(html.contains(r#"data-active="true""#));
|
||||||
|
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/page-aggregate/{document_id}",
|
"/api/page-aggregate/{document_id}",
|
||||||
get(web_shell::page_aggregate),
|
get(web_shell::page_aggregate),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/leptos-tiptap-runtime/manifest.json",
|
||||||
|
get(web_shell::leptos_tiptap_manifest),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/leptos-tiptap-runtime/{*asset_path}",
|
||||||
|
get(web_shell::leptos_tiptap_asset),
|
||||||
|
)
|
||||||
.route("/api/search/documents", post(search::documents))
|
.route("/api/search/documents", post(search::documents))
|
||||||
.route("/api/gateway/health", get(gateway::gateway_health))
|
.route("/api/gateway/health", get(gateway::gateway_health))
|
||||||
.route("/api/runtime/config", get(session::runtime_config))
|
.route("/api/runtime/config", get(session::runtime_config))
|
||||||
|
|||||||
@@ -64,10 +64,9 @@ pub async fn shell(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("default");
|
.unwrap_or("default");
|
||||||
let sidebar_tree_html =
|
let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, None)
|
||||||
load_sidebar_tree_html(state.config(), &context, workspace_id, None)
|
.await
|
||||||
.await
|
.unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
|
let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
|
||||||
let contract = json!({
|
let contract = json!({
|
||||||
"schema": "mnote.search_shell.v1",
|
"schema": "mnote.search_shell.v1",
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_filetree_render_rows(
|
pub(crate) fn collect_filetree_render_rows(
|
||||||
projection: &Value,
|
projection: &Value,
|
||||||
active_document_id: Option<&str>,
|
active_document_id: Option<&str>,
|
||||||
) -> Vec<FileTreeRenderRow> {
|
) -> Vec<FileTreeRenderRow> {
|
||||||
@@ -4654,13 +4654,9 @@ pub async fn tree_command(
|
|||||||
None,
|
None,
|
||||||
Some(*sort_order),
|
Some(*sort_order),
|
||||||
),
|
),
|
||||||
TreeCommandRequest::Purge { document_id, .. } => (
|
TreeCommandRequest::Purge { document_id, .. } => {
|
||||||
"purge",
|
("purge", document_id.clone(), None, None, None)
|
||||||
document_id.clone(),
|
}
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
let effective_workspace_id = match &request {
|
let effective_workspace_id = match &request {
|
||||||
TreeCommandRequest::Create { parent_id, .. } => {
|
TreeCommandRequest::Create { parent_id, .. } => {
|
||||||
@@ -5166,7 +5162,10 @@ mod tests {
|
|||||||
payload["result"]["documentId"],
|
payload["result"]["documentId"],
|
||||||
Value::String("page_child".into())
|
Value::String("page_child".into())
|
||||||
);
|
);
|
||||||
assert_eq!(payload["result"]["execution"]["deletedCount"], Value::from(1));
|
assert_eq!(
|
||||||
|
payload["result"]["execution"]["deletedCount"],
|
||||||
|
Value::from(1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -9,19 +9,25 @@ use crate::routes::documents::{
|
|||||||
use crate::routes::snapshot_support::{
|
use crate::routes::snapshot_support::{
|
||||||
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
||||||
};
|
};
|
||||||
use crate::routes::tree::collect_page_tree_render_rows;
|
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
|
||||||
use crate::tree_shell::page_renderer::{
|
use crate::ssr::pages::document::DocumentPage;
|
||||||
render_initial_page_tree_html, PageTreeInitialRenderInput,
|
use crate::tree_shell::filetree_renderer::{
|
||||||
|
render_initial_filetree_html, FileTreeInitialRenderInput,
|
||||||
};
|
};
|
||||||
use crate::workspace_shell::{build_workspace_shell_projection, WorkspaceShellProjection};
|
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
|
||||||
|
use crate::workspace_shell::{
|
||||||
|
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
||||||
|
WorkspaceShellProjection,
|
||||||
|
};
|
||||||
|
use axum::body::Body;
|
||||||
use axum::extract::{Extension, Path, Query, State};
|
use axum::extract::{Extension, Path, Query, State};
|
||||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use core_protocol::KernelProjectionKind;
|
use core_protocol::KernelProjectionKind;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use crate::ssr::pages::document::DocumentPage;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::path::{Component, Path as FsPath, PathBuf};
|
||||||
|
|
||||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||||
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
|
||||||
@@ -47,19 +53,44 @@ pub async fn document_page_shell(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let title = aggregate.head_title();
|
let title = aggregate.head_title();
|
||||||
let workspace_id = query
|
let workspace_id = aggregate.identity.workspace_id.clone();
|
||||||
.workspace_id
|
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||||
.as_deref()
|
let mut workspace_projection = load_workspace_shell_projection(
|
||||||
.map(str::trim)
|
state.config(),
|
||||||
.filter(|value| !value.is_empty())
|
&context,
|
||||||
.unwrap_or("default");
|
&workspace_id,
|
||||||
|
Some(&document_id),
|
||||||
|
&default_workspace_name,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
apply_active_page(&mut workspace_projection, Some(&document_id));
|
||||||
let sidebar_tree_html =
|
let sidebar_tree_html =
|
||||||
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&document_id))
|
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let file_tree_html =
|
||||||
|
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||||
|
&workspace_projection,
|
||||||
|
Some(sidebar_tree_html.as_str()),
|
||||||
|
Some(file_tree_html.as_str()),
|
||||||
|
);
|
||||||
|
let workspace_name = workspace_projection.workspace_name.clone();
|
||||||
|
let page_subtree_json =
|
||||||
|
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
|
||||||
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
||||||
|
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
|
||||||
let body_content = crate::ssr::render_view(leptos::view! {
|
let body_content = crate::ssr::render_view(leptos::view! {
|
||||||
<DocumentPage title={title.to_string()} sidebar_tree_html={sidebar_tree_html} />
|
<DocumentPage
|
||||||
|
title={title.to_string()}
|
||||||
|
document_id={document_id.clone()}
|
||||||
|
sidebar_tree_html={sidebar_tree_html}
|
||||||
|
workspace_name={workspace_name}
|
||||||
|
workspace_sidebar_html={workspace_sidebar_html}
|
||||||
|
page_subtree_json={page_subtree_json}
|
||||||
|
/>
|
||||||
});
|
});
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
@@ -72,6 +103,8 @@ pub async fn document_page_shell(
|
|||||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}">
|
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}">
|
||||||
{}
|
{}
|
||||||
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||||
|
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||||
|
{}
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
escape_html(title),
|
escape_html(title),
|
||||||
@@ -79,6 +112,8 @@ pub async fn document_page_shell(
|
|||||||
escape_html(&document_id),
|
escape_html(&document_id),
|
||||||
body_content,
|
body_content,
|
||||||
escape_script_json(&snapshot_json),
|
escape_script_json(&snapshot_json),
|
||||||
|
escape_script_json(&bootstrap_json),
|
||||||
|
render_editor_island_adapter_script(),
|
||||||
);
|
);
|
||||||
let mut response = Html(html).into_response();
|
let mut response = Html(html).into_response();
|
||||||
stamp_shell_headers(response.headers_mut(), "document");
|
stamp_shell_headers(response.headers_mut(), "document");
|
||||||
@@ -86,6 +121,313 @@ pub async fn document_page_shell(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestContext) -> String {
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"schema": "mnote.editor_bootstrap.v1",
|
||||||
|
"documentId": aggregate.identity.document_id,
|
||||||
|
"workspaceId": aggregate.identity.workspace_id,
|
||||||
|
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
|
||||||
|
"saveEndpoint": "/api/documents/save",
|
||||||
|
"editorHostKind": "leptos_tiptap_island",
|
||||||
|
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
|
||||||
|
"requestId": context.trace.request_id,
|
||||||
|
"traceId": context.trace.trace_id,
|
||||||
|
}))
|
||||||
|
.unwrap_or_else(|_| "{}".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_editor_island_adapter_script() -> &'static str {
|
||||||
|
r#"<script type="module">
|
||||||
|
(() => {
|
||||||
|
const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]';
|
||||||
|
const EVENT_PREFIX = 'mnote:leptos-tiptap-spike';
|
||||||
|
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
|
||||||
|
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
|
||||||
|
const READY_EVENT = `${EVENT_PREFIX}:ready`;
|
||||||
|
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
|
||||||
|
|
||||||
|
const parseJsonScript = (id) => {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (!node) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(node.textContent || 'null');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`mnote rust-web editor bootstrap JSON 解析失败: ${id}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = document.querySelector(ROOT_SELECTOR);
|
||||||
|
const observability = document.querySelector('[data-editor-host-observability]');
|
||||||
|
const aggregate = parseJsonScript('__MNOTE_PAGE_AGGREGATE__');
|
||||||
|
const bootstrap = parseJsonScript('__MNOTE_EDITOR_BOOTSTRAP__');
|
||||||
|
if (!(root instanceof HTMLElement) || !aggregate || !bootstrap) return;
|
||||||
|
|
||||||
|
const setStatus = (status, message) => {
|
||||||
|
root.setAttribute('data-runtime-editor-status', status);
|
||||||
|
if (message) root.setAttribute('data-runtime-editor-error', message);
|
||||||
|
if (observability instanceof HTMLElement) {
|
||||||
|
observability.setAttribute('data-editor-host-status', status);
|
||||||
|
observability.setAttribute('data-editor-host-active', 'leptos_tiptap_island');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenText = (value) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (Array.isArray(value)) return value.map(flattenText).join('');
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return `${flattenText(value.text)}${flattenText(value.content)}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const legacyBlockToTiptap = (block) => {
|
||||||
|
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
|
||||||
|
const text = flattenText(block?.content);
|
||||||
|
const content = text ? [{ type: 'text', text }] : [];
|
||||||
|
if (type === 'heading') {
|
||||||
|
const level = Number(block?.props?.level || block?.level || 1) || 1;
|
||||||
|
return { type: 'heading', attrs: { level: Math.max(1, Math.min(6, level)) }, content };
|
||||||
|
}
|
||||||
|
if (type === 'bulletListItem') {
|
||||||
|
return { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content }] }] };
|
||||||
|
}
|
||||||
|
if (type === 'numberedListItem') {
|
||||||
|
return { type: 'orderedList', content: [{ type: 'listItem', content: [{ type: 'paragraph', content }] }] };
|
||||||
|
}
|
||||||
|
if (type === 'checkListItem' || type === 'advancedTodo') {
|
||||||
|
return { type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: Boolean(block?.props?.checked) }, content: [{ type: 'paragraph', content }] }] };
|
||||||
|
}
|
||||||
|
if (type === 'quote') {
|
||||||
|
return { type: 'blockquote', content: [{ type: 'paragraph', content }] };
|
||||||
|
}
|
||||||
|
if (type === 'codeBlock') {
|
||||||
|
return { type: 'codeBlock', attrs: { language: block?.props?.language || null }, content };
|
||||||
|
}
|
||||||
|
return { type: 'paragraph', content };
|
||||||
|
};
|
||||||
|
|
||||||
|
const textToTiptapDocument = (text) => ({
|
||||||
|
type: 'doc',
|
||||||
|
content: [{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: text ? [{ type: 'text', text }] : [],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentEditorText = () => {
|
||||||
|
const editor = root.querySelector('.editor-surface .ProseMirror');
|
||||||
|
return editor?.textContent || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTiptapDocument = (content) => (
|
||||||
|
content &&
|
||||||
|
typeof content === 'object' &&
|
||||||
|
!Array.isArray(content) &&
|
||||||
|
content.type === 'doc'
|
||||||
|
);
|
||||||
|
|
||||||
|
const toTiptapDocument = (content, fallbackText = '') => {
|
||||||
|
if (isTiptapDocument(content)) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
const blocks = Array.isArray(content)
|
||||||
|
? content
|
||||||
|
: Array.isArray(content?.blocks)
|
||||||
|
? content.blocks
|
||||||
|
: [];
|
||||||
|
const nodes = blocks.map(legacyBlockToTiptap).filter(Boolean);
|
||||||
|
if (nodes.length) {
|
||||||
|
return { type: 'doc', content: nodes };
|
||||||
|
}
|
||||||
|
return textToTiptapDocument(fallbackText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageBody = aggregate.body || {};
|
||||||
|
const permissions = aggregate.head?.permissions || {};
|
||||||
|
const editorMeta = {
|
||||||
|
revision: Number.isInteger(pageBody.revision) ? pageBody.revision : null,
|
||||||
|
conflictDetectionKey: typeof pageBody.conflictDetectionKey === 'string' ? pageBody.conflictDetectionKey : null,
|
||||||
|
};
|
||||||
|
const mountOptions = {
|
||||||
|
documentId: bootstrap.documentId,
|
||||||
|
workspaceId: bootstrap.workspaceId,
|
||||||
|
title: aggregate.head?.title || '无标题',
|
||||||
|
content: toTiptapDocument(pageBody.content),
|
||||||
|
revision: editorMeta.revision,
|
||||||
|
conflictDetectionKey: editorMeta.conflictDetectionKey,
|
||||||
|
readOnly: Boolean(permissions.readOnly),
|
||||||
|
editable: !Boolean(permissions.readOnly),
|
||||||
|
pageOptions: aggregate.layout?.pageOptions || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let saveTimer = 0;
|
||||||
|
let lastSavedSerialized = '';
|
||||||
|
const normalizeEnvelopePayload = (event) => {
|
||||||
|
const detail = event?.detail;
|
||||||
|
if (!detail || typeof detail !== 'object') return null;
|
||||||
|
const payload = detail.payload && typeof detail.payload === 'object' ? detail.payload : detail;
|
||||||
|
return payload && typeof payload === 'object' ? payload : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePayload = async (payload) => {
|
||||||
|
const tiptapDocument = toTiptapDocument(
|
||||||
|
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
|
||||||
|
currentEditorText(),
|
||||||
|
);
|
||||||
|
const serialized = JSON.stringify(tiptapDocument);
|
||||||
|
if (serialized === lastSavedSerialized) {
|
||||||
|
setStatus('saved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('saving');
|
||||||
|
const response = await fetch(bootstrap.saveEndpoint || '/api/documents/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
documentId: bootstrap.documentId,
|
||||||
|
workspaceId: bootstrap.workspaceId,
|
||||||
|
revision: editorMeta.revision,
|
||||||
|
conflictDetectionKey: editorMeta.conflictDetectionKey,
|
||||||
|
content: [],
|
||||||
|
tiptapDocument,
|
||||||
|
blockCount: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result || result.ok !== true) {
|
||||||
|
const message = result?.error?.message || result?.message || `save_failed_${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const saved = result.result || {};
|
||||||
|
if (Number.isInteger(saved.revision)) editorMeta.revision = saved.revision;
|
||||||
|
if (typeof saved.conflict_detection_key === 'string') editorMeta.conflictDetectionKey = saved.conflict_detection_key;
|
||||||
|
if (typeof saved.conflictDetectionKey === 'string') editorMeta.conflictDetectionKey = saved.conflictDetectionKey;
|
||||||
|
lastSavedSerialized = serialized;
|
||||||
|
setStatus('saved');
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueSave = (event) => {
|
||||||
|
const payload = normalizeEnvelopePayload(event);
|
||||||
|
if (!payload) return;
|
||||||
|
if (saveTimer) window.clearTimeout(saveTimer);
|
||||||
|
setStatus('dirty');
|
||||||
|
saveTimer = window.setTimeout(() => {
|
||||||
|
saveTimer = 0;
|
||||||
|
savePayload(payload).catch((error) => {
|
||||||
|
setStatus('error', error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
|
}, 650);
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener(READY_EVENT, () => setStatus('ready'));
|
||||||
|
root.addEventListener(ERROR_EVENT, (event) => {
|
||||||
|
const payload = normalizeEnvelopePayload(event);
|
||||||
|
setStatus('error', payload?.message || 'runtime_error');
|
||||||
|
});
|
||||||
|
root.addEventListener(CHANGE_EVENT, queueSave);
|
||||||
|
root.addEventListener(SAVE_EVENT, queueSave);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setStatus('loading-assets');
|
||||||
|
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
|
||||||
|
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
|
||||||
|
const manifest = await manifestResponse.json();
|
||||||
|
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
|
||||||
|
const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`;
|
||||||
|
const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined;
|
||||||
|
const runtime = await import(entryUrl);
|
||||||
|
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function') {
|
||||||
|
throw new Error('island runtime 导出不完整');
|
||||||
|
}
|
||||||
|
await runtime.default(wasmUrl);
|
||||||
|
const mountId = runtime.mount(root, mountOptions);
|
||||||
|
root.setAttribute('data-runtime-mount-id', String(mountId));
|
||||||
|
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
|
||||||
|
setStatus('ready');
|
||||||
|
};
|
||||||
|
|
||||||
|
start().catch((error) => {
|
||||||
|
setStatus('error', error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_asset_root() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../spikes/leptos-tiptap-spike/generated/island")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
|
||||||
|
let asset_path = asset_path.trim();
|
||||||
|
if asset_path.is_empty() || asset_path.starts_with('/') || asset_path.contains('\\') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut resolved = runtime_asset_root();
|
||||||
|
for component in FsPath::new(asset_path).components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(part) => resolved.push(part),
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_asset_content_type(asset_path: &str) -> &'static str {
|
||||||
|
if asset_path.ends_with(".wasm") {
|
||||||
|
"application/wasm"
|
||||||
|
} else if asset_path.ends_with(".js") {
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
} else if asset_path.ends_with(".json") {
|
||||||
|
"application/json; charset=utf-8"
|
||||||
|
} else {
|
||||||
|
"application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn leptos_tiptap_manifest() -> Response {
|
||||||
|
let manifest = json!({
|
||||||
|
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
|
||||||
|
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
|
||||||
|
"assetPaths": [
|
||||||
|
"mnote-leptos-tiptap-spike-island.js",
|
||||||
|
"mnote-leptos-tiptap-spike-island_bg.wasm"
|
||||||
|
],
|
||||||
|
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
|
||||||
|
});
|
||||||
|
let mut response = Json(manifest).into_response();
|
||||||
|
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Response, WebError> {
|
||||||
|
let Some(resolved) = resolve_runtime_asset_path(&asset_path) else {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"runtime_asset_path_invalid",
|
||||||
|
"leptos-tiptap runtime asset 路径非法",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let bytes = std::fs::read(&resolved).map_err(|_| {
|
||||||
|
WebError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"runtime_asset_not_found",
|
||||||
|
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let response = Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
runtime_asset_content_type(&asset_path),
|
||||||
|
)
|
||||||
|
.header(header::CACHE_CONTROL, "no-store")
|
||||||
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||||
|
.body(Body::from(bytes))
|
||||||
|
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn page_aggregate(
|
pub async fn page_aggregate(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
@@ -163,28 +505,105 @@ async fn build_page_aggregate_snapshot(
|
|||||||
|
|
||||||
Ok(PageAggregate::builder()
|
Ok(PageAggregate::builder()
|
||||||
// identity
|
// identity
|
||||||
.document_id(meta.get("id").and_then(Value::as_str).unwrap_or(document_id))
|
.document_id(
|
||||||
.workspace_id(meta.get("workspace_id").and_then(Value::as_str).unwrap_or("default"))
|
meta.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(document_id),
|
||||||
|
)
|
||||||
|
.workspace_id(
|
||||||
|
meta.get("workspace_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("default"),
|
||||||
|
)
|
||||||
// head
|
// head
|
||||||
.title(meta.get("title").and_then(Value::as_str).unwrap_or("无标题"))
|
.title(
|
||||||
|
meta.get("title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("无标题"),
|
||||||
|
)
|
||||||
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
|
.updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null))
|
||||||
.read_only(meta.get("can_edit").and_then(Value::as_bool).map(|can_edit| !can_edit).unwrap_or(false))
|
.read_only(
|
||||||
.disable_download(meta.get("disable_download").and_then(Value::as_bool).unwrap_or(false))
|
meta.get("can_edit")
|
||||||
.disable_copy(meta.get("disable_copy").and_then(Value::as_bool).unwrap_or(false))
|
.and_then(Value::as_bool)
|
||||||
|
.map(|can_edit| !can_edit)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.disable_download(
|
||||||
|
meta.get("disable_download")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.disable_copy(
|
||||||
|
meta.get("disable_copy")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
// layout
|
// layout
|
||||||
.wide_layout(meta.get("wide_layout").and_then(Value::as_bool).unwrap_or(false))
|
.wide_layout(
|
||||||
.small_text(meta.get("use_small_text").and_then(Value::as_bool).unwrap_or(false))
|
meta.get("wide_layout")
|
||||||
.show_heading_numbers(meta.get("show_heading_numbers").and_then(Value::as_bool).unwrap_or(true))
|
.and_then(Value::as_bool)
|
||||||
.show_toc(meta.get("show_toc").and_then(Value::as_bool).unwrap_or(false))
|
.unwrap_or(false),
|
||||||
.show_structure(meta.get("show_structure").and_then(Value::as_bool).unwrap_or(false))
|
)
|
||||||
.protect_editing(meta.get("protect_editing").and_then(Value::as_bool).unwrap_or(false))
|
.small_text(
|
||||||
.show_word_count(meta.get("show_word_count").and_then(Value::as_bool).unwrap_or(true))
|
meta.get("use_small_text")
|
||||||
.collapse_backlinks(meta.get("collapse_backlinks").and_then(Value::as_bool).unwrap_or(false))
|
.and_then(Value::as_bool)
|
||||||
.page_font(meta.get("page_font").and_then(Value::as_str).unwrap_or("default"))
|
.unwrap_or(false),
|
||||||
.layout_density(meta.get("layout_density").and_then(Value::as_str).unwrap_or("normal"))
|
)
|
||||||
.hide_child_pages(meta.get("hide_child_pages").and_then(Value::as_bool).unwrap_or(false))
|
.show_heading_numbers(
|
||||||
.show_block_ref_count(meta.get("show_block_ref_count").and_then(Value::as_bool).unwrap_or(false))
|
meta.get("show_heading_numbers")
|
||||||
.embed_default_block_id(meta.get("embed_default_block_id").cloned().unwrap_or(Value::Null))
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(true),
|
||||||
|
)
|
||||||
|
.show_toc(
|
||||||
|
meta.get("show_toc")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.show_structure(
|
||||||
|
meta.get("show_structure")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.protect_editing(
|
||||||
|
meta.get("protect_editing")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.show_word_count(
|
||||||
|
meta.get("show_word_count")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(true),
|
||||||
|
)
|
||||||
|
.collapse_backlinks(
|
||||||
|
meta.get("collapse_backlinks")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.page_font(
|
||||||
|
meta.get("page_font")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("default"),
|
||||||
|
)
|
||||||
|
.layout_density(
|
||||||
|
meta.get("layout_density")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("normal"),
|
||||||
|
)
|
||||||
|
.hide_child_pages(
|
||||||
|
meta.get("hide_child_pages")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.show_block_ref_count(
|
||||||
|
meta.get("show_block_ref_count")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.embed_default_block_id(
|
||||||
|
meta.get("embed_default_block_id")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
)
|
||||||
// body
|
// body
|
||||||
.content(content.get("content").cloned().unwrap_or(Value::Null))
|
.content(content.get("content").cloned().unwrap_or(Value::Null))
|
||||||
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
|
.revision(content.get("revision").cloned().unwrap_or(Value::Null))
|
||||||
@@ -193,14 +612,17 @@ async fn build_page_aggregate_snapshot(
|
|||||||
.page_subtree(page_subtree)
|
.page_subtree(page_subtree)
|
||||||
// stats
|
// stats
|
||||||
.word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0))
|
.word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0))
|
||||||
.character_count(meta.get("character_count").and_then(Value::as_u64).unwrap_or(0))
|
.character_count(
|
||||||
|
meta.get("character_count")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
)
|
||||||
.block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0))
|
.block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0))
|
||||||
.todo_total(todo_total)
|
.todo_total(todo_total)
|
||||||
.todo_done(todo_done)
|
.todo_done(todo_done)
|
||||||
.build())
|
.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
|
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
|
||||||
let value = document_id
|
let value = document_id
|
||||||
.chars()
|
.chars()
|
||||||
@@ -311,14 +733,23 @@ pub(crate) async fn load_sidebar_tree_html(
|
|||||||
let result = match load_projection_snapshot(config, context, &spec).await {
|
let result = match load_projection_snapshot(config, context, &spec).await {
|
||||||
Ok(snapshot) => Some(snapshot.projection),
|
Ok(snapshot) => Some(snapshot.projection),
|
||||||
Err(_) if config.allow_dev_fixtures => {
|
Err(_) if config.allow_dev_fixtures => {
|
||||||
// Dev 模式降级:使用内建示例页面树数据集
|
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
|
||||||
|
let documents = active_document_id
|
||||||
|
.map(|document_id| {
|
||||||
|
serde_json::json!([
|
||||||
|
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
serde_json::json!([
|
||||||
|
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
|
||||||
|
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
|
||||||
|
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 }
|
||||||
|
])
|
||||||
|
});
|
||||||
let dev_dataset = serde_json::json!({
|
let dev_dataset = serde_json::json!({
|
||||||
"active_workspace_id": workspace_id,
|
"active_workspace_id": workspace_id,
|
||||||
"documents": [
|
"documents": documents,
|
||||||
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
|
|
||||||
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
|
|
||||||
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 },
|
|
||||||
],
|
|
||||||
"trashed_documents": [],
|
"trashed_documents": [],
|
||||||
"media_assets": [],
|
"media_assets": [],
|
||||||
"trashed_media_assets": [],
|
"trashed_media_assets": [],
|
||||||
@@ -343,6 +774,61 @@ pub(crate) async fn load_sidebar_tree_html(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载文件树 HTML(SSR)
|
||||||
|
///
|
||||||
|
/// 文件树与页面树共用同一份 sidebar dataset,再由 Rust kernel 输出 file_tree projection。
|
||||||
|
pub(crate) async fn load_file_tree_html(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
|
active_document_id: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let spec = ProjectionSnapshotSpec {
|
||||||
|
workspace_id,
|
||||||
|
root_node_id: None,
|
||||||
|
depth: Some(99),
|
||||||
|
projection: KernelProjectionKind::FileTree,
|
||||||
|
query: None,
|
||||||
|
max_results: None,
|
||||||
|
};
|
||||||
|
let result = match load_projection_snapshot(config, context, &spec).await {
|
||||||
|
Ok(snapshot) => Some(snapshot.projection),
|
||||||
|
Err(_) if config.allow_dev_fixtures => {
|
||||||
|
let documents = active_document_id
|
||||||
|
.map(|document_id| {
|
||||||
|
serde_json::json!([
|
||||||
|
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
serde_json::json!([
|
||||||
|
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
|
||||||
|
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }
|
||||||
|
])
|
||||||
|
});
|
||||||
|
let dev_dataset = serde_json::json!({
|
||||||
|
"active_workspace_id": workspace_id,
|
||||||
|
"documents": documents,
|
||||||
|
"trashed_documents": [],
|
||||||
|
"media_assets": [],
|
||||||
|
"trashed_media_assets": [],
|
||||||
|
"mindmap_assets": [],
|
||||||
|
"trashed_mindmap_assets": [],
|
||||||
|
"table_assets": [],
|
||||||
|
"trashed_table_assets": [],
|
||||||
|
"mindmap_docs": [],
|
||||||
|
"mindmap_asset_children": {}
|
||||||
|
});
|
||||||
|
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
result.map(|projection| {
|
||||||
|
let rows = collect_filetree_render_rows(&projection, active_document_id);
|
||||||
|
render_initial_filetree_html(&FileTreeInitialRenderInput { rows })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{build_app, AppConfig, AppState};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
@@ -440,6 +926,8 @@ mod tests {
|
|||||||
assert!(html.contains("垃圾箱"));
|
assert!(html.contains("垃圾箱"));
|
||||||
assert!(html.contains("模板中心"));
|
assert!(html.contains("模板中心"));
|
||||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||||
|
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
|
||||||
|
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
|
||||||
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
||||||
assert!(!html.contains("mnote-web-document-shell"));
|
assert!(!html.contains("mnote-web-document-shell"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ pub fn render_view(view: impl RenderHtml) -> String {
|
|||||||
view,
|
view,
|
||||||
&mut buf,
|
&mut buf,
|
||||||
&mut position,
|
&mut position,
|
||||||
true, // escape — 对 HTML 特殊字符进行转义
|
true, // escape — 对 HTML 特殊字符进行转义
|
||||||
false, // mark_branches — 不标记分支注释
|
false, // mark_branches — 不标记分支注释
|
||||||
vec![], // extra_attrs — 无需额外属性
|
vec![], // extra_attrs — 无需额外属性
|
||||||
);
|
);
|
||||||
buf
|
buf
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! MNOTE 登录页面组件
|
//! MNOTE 登录页面组件
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use super::layout::PageLayout;
|
use super::layout::PageLayout;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
/// MNOTE 登录页面
|
/// MNOTE 登录页面
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//! 提供文档编辑器的服务器端渲染壳。
|
//! 提供文档编辑器的服务器端渲染壳。
|
||||||
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
|
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use crate::ssr::pages::layout::PageLayout;
|
use crate::ssr::pages::layout::PageLayout;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
/// MNOTE 文档页面
|
/// MNOTE 文档页面
|
||||||
///
|
///
|
||||||
@@ -17,21 +17,53 @@ use crate::ssr::pages::layout::PageLayout;
|
|||||||
pub fn DocumentPage(
|
pub fn DocumentPage(
|
||||||
/// 文档标题
|
/// 文档标题
|
||||||
title: String,
|
title: String,
|
||||||
|
/// 文档 id
|
||||||
|
document_id: String,
|
||||||
/// 侧栏页面树 HTML(可选)
|
/// 侧栏页面树 HTML(可选)
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
sidebar_tree_html: Option<String>,
|
sidebar_tree_html: Option<String>,
|
||||||
/// 工作区名称(可选)
|
/// 工作区名称(可选)
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
workspace_name: Option<String>,
|
workspace_name: Option<String>,
|
||||||
|
/// workspace shell 侧栏 sections HTML(可选)
|
||||||
|
#[prop(optional)]
|
||||||
|
workspace_sidebar_html: Option<String>,
|
||||||
|
/// Page Aggregate 子树 JSON(可选)
|
||||||
|
#[prop(optional)]
|
||||||
|
page_subtree_json: Option<String>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
let has_page_subtree = page_subtree_json
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && *value != "null")
|
||||||
|
.is_some();
|
||||||
view! {
|
view! {
|
||||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
|
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
|
||||||
<main class="document-shell" data-editor-host="leptos_tiptap_island">
|
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()}>
|
||||||
<header class="document-shell-header">
|
<header class="document-shell-header">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
</header>
|
</header>
|
||||||
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
|
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
|
||||||
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island"></section>
|
<section
|
||||||
|
data-testid="mnote-page-subtree"
|
||||||
|
data-page-tree-source="page_aggregate.tree.pageSubtree"
|
||||||
|
data-page-subtree-present={has_page_subtree.to_string()}
|
||||||
|
></section>
|
||||||
|
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island">
|
||||||
|
<div
|
||||||
|
id="mnote-leptos-tiptap-island-editor-root"
|
||||||
|
data-testid="mnote-leptos-tiptap-island-editor-root"
|
||||||
|
data-editor-host-kind="leptos_tiptap_island"
|
||||||
|
data-runtime-editor-status="booting"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="sr-only"
|
||||||
|
data-editor-host-observability="rust-web-inline-island"
|
||||||
|
data-editor-host-active="leptos_tiptap_island"
|
||||||
|
data-editor-host-requested="leptos_tiptap_island"
|
||||||
|
data-editor-host-status="booting"
|
||||||
|
></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! MNOTE 首页组件(Wolai 风格)
|
//! MNOTE 首页组件(Wolai 风格)
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use super::layout::PageLayout;
|
use super::layout::PageLayout;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
/// MNOTE 首页。
|
/// MNOTE 首页。
|
||||||
#[component]
|
#[component]
|
||||||
@@ -12,25 +12,61 @@ pub fn HomePage(
|
|||||||
/// 工作区名称(可选)
|
/// 工作区名称(可选)
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
workspace_name: Option<String>,
|
workspace_name: Option<String>,
|
||||||
|
/// 工作区 id(可选)
|
||||||
|
#[prop(optional)]
|
||||||
|
workspace_id: Option<String>,
|
||||||
/// workspace shell 侧栏 sections HTML(可选)
|
/// workspace shell 侧栏 sections HTML(可选)
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
workspace_sidebar_html: Option<String>,
|
workspace_sidebar_html: Option<String>,
|
||||||
|
/// 当前选中的页面 id(可选)
|
||||||
|
#[prop(optional)]
|
||||||
|
active_page_id: Option<String>,
|
||||||
|
/// 当前选中的页面标题(可选)
|
||||||
|
#[prop(optional)]
|
||||||
|
active_page_title: Option<String>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
let active_page_id = active_page_id.unwrap_or_default();
|
||||||
|
let active_page_title = active_page_title
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "个人".to_string());
|
||||||
|
let has_active_page = !active_page_id.trim().is_empty();
|
||||||
|
let workspace_id = workspace_id
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let workspace_id_value = workspace_id.clone().unwrap_or_default();
|
||||||
|
let active_href = workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
|
||||||
|
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
|
||||||
view! {
|
view! {
|
||||||
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}>
|
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()}>
|
||||||
<div class="mnote-home">
|
{move || if has_active_page {
|
||||||
<div class="mnote-home-icon" aria-hidden="true">"⌂"</div>
|
view! {
|
||||||
<h1>"个人"</h1>
|
<main class="document-shell" data-root-active-page-id={active_page_id.clone()} data-editor-host="leptos_tiptap_island">
|
||||||
<div class="mnote-home-links">
|
<header class="document-shell-header">
|
||||||
<a href="/documents/backup"><span>"◰"</span>"正版软件备份"</a>
|
<h1>{active_page_title.clone()}</h1>
|
||||||
<a href="/documents/growth"><span>"◜"</span>"个人发展"</a>
|
</header>
|
||||||
<a href="/documents/notes"><span>"▦"</span>"杂记"</a>
|
<section class="mnote-workspace-active-state" data-testid="mnote-workspace-active-page-state">
|
||||||
<a href="/documents/passwords"><span>"▣"</span>"密码"</a>
|
<a class="mnote-workspace-active-link" href={active_href.clone()}>"打开当前页面"</a>
|
||||||
<a href="/documents/wolai-guide"><span>"▰"</span>"Wolai 教程"</a>
|
</section>
|
||||||
<a href="/documents/health"><span>"⌂"</span>"个人健康"</a>
|
</main>
|
||||||
<a href="/documents/software"><span>"▤"</span>"软件开发"</a>
|
}.into_any()
|
||||||
</div>
|
} else {
|
||||||
</div>
|
view! {
|
||||||
|
<section class="mnote-workspace-empty-state" data-testid="mnote-workspace-empty-state">
|
||||||
|
<h1>"暂无页面"</h1>
|
||||||
|
<p>"当前工作区还没有可显示的页面。"</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mnote-empty-create-page"
|
||||||
|
data-testid="mnote-empty-create-page"
|
||||||
|
data-mnote-action="create-page"
|
||||||
|
data-workspace-id={workspace_id_value.clone()}
|
||||||
|
>"新建页面"</button>
|
||||||
|
</section>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,65 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
const SIDEBAR_TREE_JS: &str = r##"
|
const SIDEBAR_TREE_JS: &str = r##"
|
||||||
<script>
|
|
||||||
(function(){
|
(function(){
|
||||||
var tree = document.getElementById('sidebar-tree-root');
|
function closestAction(target, selector) {
|
||||||
if (!tree) return;
|
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// 高亮当前页
|
function resolveWorkspaceId(trigger) {
|
||||||
var currentPath = window.location.pathname;
|
var direct = (trigger.getAttribute('data-workspace-id') || '').trim();
|
||||||
var match = currentPath.match(/^\/documents\/([^\/]+)/);
|
if (direct) return direct;
|
||||||
if (match) {
|
var root = trigger.closest('[data-workspace-id]');
|
||||||
var activeId = match[1];
|
return root ? (root.getAttribute('data-workspace-id') || '').trim() : '';
|
||||||
var links = tree.querySelectorAll('[data-rust-action="open"]');
|
}
|
||||||
for (var i = 0; i < links.length; i++) {
|
|
||||||
if (links[i].getAttribute('data-node-id') === activeId) {
|
async function dispatchTreeCommand(trigger, body) {
|
||||||
links[i].closest('.tree-row').setAttribute('data-active', 'true');
|
trigger.setAttribute('data-pending', 'true');
|
||||||
|
trigger.setAttribute('disabled', 'disabled');
|
||||||
|
try {
|
||||||
|
var response = await fetch('/api/tree/commands', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
var payload = await response.json().catch(function(){ return null; });
|
||||||
|
if (!response.ok || !payload || !payload.result) {
|
||||||
|
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
|
||||||
}
|
}
|
||||||
|
return payload.result;
|
||||||
|
} catch (error) {
|
||||||
|
trigger.removeAttribute('disabled');
|
||||||
|
trigger.setAttribute('data-pending', 'false');
|
||||||
|
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 展开/折叠
|
async function createPage(trigger, parentId) {
|
||||||
tree.addEventListener('click', function(e) {
|
var workspaceId = resolveWorkspaceId(trigger);
|
||||||
var btn = e.target.closest('[data-rust-action]');
|
var effectiveParentId = (parentId || trigger.getAttribute('data-parent-id') || '').trim();
|
||||||
|
if (!workspaceId) return;
|
||||||
|
var result = await dispatchTreeCommand(trigger, {
|
||||||
|
action: 'create',
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
parentId: effectiveParentId || null,
|
||||||
|
title: '新页面'
|
||||||
|
});
|
||||||
|
var nextWorkspaceId = result.workspaceId || workspaceId;
|
||||||
|
window.location.href = '/documents/' + encodeURIComponent(result.documentId) + '?workspaceId=' + encodeURIComponent(nextWorkspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var createTrigger = closestAction(e.target, '[data-mnote-action="create-page"]');
|
||||||
|
if (createTrigger) {
|
||||||
|
e.preventDefault();
|
||||||
|
void createPage(createTrigger, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tree = document.getElementById('sidebar-tree-root');
|
||||||
|
if (!tree || !tree.contains(e.target)) return;
|
||||||
|
var btn = closestAction(e.target, '[data-rust-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
var nodeId = btn.getAttribute('data-node-id');
|
var nodeId = btn.getAttribute('data-node-id');
|
||||||
var action = btn.getAttribute('data-rust-action');
|
var action = btn.getAttribute('data-rust-action');
|
||||||
@@ -41,12 +79,40 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (action === 'open') {
|
} else if (action === 'open') {
|
||||||
window.location.href = '/documents/' + encodeURIComponent(nodeId);
|
var workspaceId = resolveWorkspaceId(btn);
|
||||||
|
window.location.href = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
} else if (action === 'create') {
|
||||||
|
e.preventDefault();
|
||||||
|
void createPage(btn, nodeId);
|
||||||
|
} else if (action === 'rename') {
|
||||||
|
e.preventDefault();
|
||||||
|
var title = window.prompt('重命名页面');
|
||||||
|
if (title && title.trim()) {
|
||||||
|
void dispatchTreeCommand(btn, {
|
||||||
|
action: 'rename',
|
||||||
|
workspaceId: resolveWorkspaceId(btn),
|
||||||
|
documentId: nodeId,
|
||||||
|
title: title.trim()
|
||||||
|
}).then(function(){ window.location.reload(); });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var tree = document.getElementById('sidebar-tree-root');
|
||||||
|
if (!tree) return;
|
||||||
|
var currentPath = window.location.pathname;
|
||||||
|
var match = currentPath.match(/^\/documents\/([^\/]+)/);
|
||||||
|
if (match) {
|
||||||
|
var activeId = match[1];
|
||||||
|
var links = tree.querySelectorAll('[data-rust-action="open"]');
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
if (links[i].getAttribute('data-node-id') === activeId) {
|
||||||
|
links[i].closest('.tree-row').setAttribute('data-active', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
/// MNOTE Wolai 风格页面布局
|
/// MNOTE Wolai 风格页面布局
|
||||||
@@ -76,6 +142,9 @@ pub fn PageLayout(
|
|||||||
/// workspace shell 侧栏 sections HTML(可选),由 projection 渲染
|
/// workspace shell 侧栏 sections HTML(可选),由 projection 渲染
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
workspace_sidebar_html: Option<String>,
|
workspace_sidebar_html: Option<String>,
|
||||||
|
/// 顶栏当前页面标题(可选)
|
||||||
|
#[prop(optional)]
|
||||||
|
topbar_title: Option<String>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
|
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
|
||||||
|
|
||||||
@@ -83,6 +152,10 @@ pub fn PageLayout(
|
|||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or_else(|| "开发用户 的空间".to_string());
|
.unwrap_or_else(|| "开发用户 的空间".to_string());
|
||||||
|
let topbar_title = topbar_title
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "个人".to_string());
|
||||||
let sidebar_sections_html = workspace_sidebar_html
|
let sidebar_sections_html = workspace_sidebar_html
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
@@ -92,14 +165,12 @@ pub fn PageLayout(
|
|||||||
"documents": []
|
"documents": []
|
||||||
});
|
});
|
||||||
let projection = crate::workspace_shell::build_workspace_shell_projection(
|
let projection = crate::workspace_shell::build_workspace_shell_projection(
|
||||||
&dataset,
|
&dataset, "default", None, &ws_name,
|
||||||
"default",
|
|
||||||
None,
|
|
||||||
&ws_name,
|
|
||||||
);
|
);
|
||||||
crate::workspace_shell::render_workspace_shell_sidebar_html(
|
crate::workspace_shell::render_workspace_shell_sidebar_html(
|
||||||
&projection,
|
&projection,
|
||||||
Some(sidebar_tree_html.as_str()),
|
Some(sidebar_tree_html.as_str()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +195,7 @@ pub fn PageLayout(
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="mnote-main">
|
<div class="mnote-main">
|
||||||
<header class="wolai-topbar" data-testid="wolai-topbar">
|
<header class="wolai-topbar" data-testid="wolai-topbar">
|
||||||
<div class="wolai-topbar-left"><span class="wolai-menu-icon">"☰"</span><span class="wolai-home-icon">"⌂"</span><span>{"个人"}</span></div>
|
<div class="wolai-topbar-left"><span class="wolai-menu-icon">"☰"</span><span class="wolai-home-icon">"⌂"</span><span>{topbar_title}</span></div>
|
||||||
<div class="wolai-topbar-actions" aria-label="页面操作">
|
<div class="wolai-topbar-actions" aria-label="页面操作">
|
||||||
<span title="收藏">"☆"</span>
|
<span title="收藏">"☆"</span>
|
||||||
<span title="演示">"▻"</span>
|
<span title="演示">"▻"</span>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//! 提供思维导图页面的服务器端渲染壳。
|
//! 提供思维导图页面的服务器端渲染壳。
|
||||||
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
|
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use super::layout::PageLayout;
|
use super::layout::PageLayout;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
/// MNOTE 思维导图页面
|
/// MNOTE 思维导图页面
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//! 提供搜索页面的服务器端渲染壳。
|
//! 提供搜索页面的服务器端渲染壳。
|
||||||
//! 由 `<body>` 外层包装、搜索契约 JSON 嵌入脚本由路由 handler 处理。
|
//! 由 `<body>` 外层包装、搜索契约 JSON 嵌入脚本由路由 handler 处理。
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use crate::ssr::pages::layout::PageLayout;
|
use crate::ssr::pages::layout::PageLayout;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
/// MNOTE 搜索页面
|
/// MNOTE 搜索页面
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -183,8 +183,36 @@ a:hover {
|
|||||||
|
|
||||||
.wolai-section-add {
|
.wolai-section-add {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 22px;
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
color: #B5B5B2;
|
color: #B5B5B2;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-section-add:hover {
|
||||||
|
background: var(--wolai-bg-hover);
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-empty-create-page {
|
||||||
|
margin-top: 14px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--wolai-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-empty-create-page:hover {
|
||||||
|
background: var(--wolai-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-row {
|
.wolai-page-row {
|
||||||
@@ -333,6 +361,17 @@ a:hover {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mnote-sidebar-nav.wolai-quick-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-sidebar-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.mnote-sidebar-nav a {
|
.mnote-sidebar-nav a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -397,6 +436,13 @@ a:hover {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-empty {
|
||||||
|
padding: 6px 8px 8px;
|
||||||
|
color: var(--wolai-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-tree .tree-node {
|
.sidebar-tree .tree-node {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::app::AppConfig;
|
use crate::app::AppConfig;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
|
use base64::Engine;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
build_runtime_command_artifact_plan, RuntimeBridgeContextWire, RuntimeCommandArtifactPlan,
|
build_runtime_command_artifact_plan, RuntimeBridgeContextWire, RuntimeCommandArtifactPlan,
|
||||||
RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan,
|
RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan,
|
||||||
@@ -9,7 +10,6 @@ use serde_json::{json, Value};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||||
use base64::Engine;
|
|
||||||
|
|
||||||
const HEADER_REQUEST_ID: &str = "x-request-id";
|
const HEADER_REQUEST_ID: &str = "x-request-id";
|
||||||
const HEADER_TRACE_ID: &str = "x-trace-id";
|
const HEADER_TRACE_ID: &str = "x-trace-id";
|
||||||
@@ -98,8 +98,8 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
|
|||||||
"name": config.dev_user_name,
|
"name": config.dev_user_name,
|
||||||
"email": config.dev_user_email,
|
"email": config.dev_user_email,
|
||||||
});
|
});
|
||||||
let identity_encoded = base64::engine::general_purpose::STANDARD
|
let identity_encoded =
|
||||||
.encode(identity.to_string().as_bytes());
|
base64::engine::general_purpose::STANDARD.encode(identity.to_string().as_bytes());
|
||||||
Ok(format!("Convex {admin_key}:{identity_encoded}"))
|
Ok(format!("Convex {admin_key}:{identity_encoded}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +332,26 @@ pub async fn execute_sidebar_dataset_query(
|
|||||||
execute_convex_query_plan(config, context, plan).await
|
execute_convex_query_plan(config, context, plan).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||||
|
let mut args = plan.args_json.clone();
|
||||||
|
if matches!(
|
||||||
|
plan.command_name.as_str(),
|
||||||
|
"documents.save" | "page.body.save"
|
||||||
|
) {
|
||||||
|
if let Value::Object(map) = &mut args {
|
||||||
|
// 当前自托管 Convex 的 documents:updateContent 仍是 legacy validator。
|
||||||
|
// Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。
|
||||||
|
map.remove("editorDocument");
|
||||||
|
map.remove("tiptapDocument");
|
||||||
|
map.remove("streamDeltaHint");
|
||||||
|
map.remove("domainEventHint");
|
||||||
|
map.remove("domainEventPlan");
|
||||||
|
map.remove("domainEventPlans");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn execute_convex_command_plan(
|
pub async fn execute_convex_command_plan(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
@@ -356,7 +376,7 @@ pub async fn execute_convex_command_plan(
|
|||||||
let payload = json!({
|
let payload = json!({
|
||||||
"path": plan.function_name,
|
"path": plan.function_name,
|
||||||
"format": "convex_encoded_json",
|
"format": "convex_encoded_json",
|
||||||
"args": [plan.args_json.clone()],
|
"args": [convex_command_args_for_plan(plan)],
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -712,11 +732,13 @@ pub async fn execute_convex_command_plan_with_artifacts(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::build_authorization;
|
use super::{build_authorization, convex_command_args_for_plan};
|
||||||
use crate::app::AppConfig;
|
use crate::app::AppConfig;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use axum::http::{HeaderMap, HeaderValue, Method, Uri};
|
use axum::http::{HeaderMap, HeaderValue, Method, Uri};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use bridge_runtime::RuntimeCommandExecutionPlan;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
fn config() -> AppConfig {
|
fn config() -> AppConfig {
|
||||||
AppConfig {
|
AppConfig {
|
||||||
@@ -750,6 +772,45 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convex_command_args_strips_editor_runtime_fields_for_legacy_document_save() {
|
||||||
|
let plan = RuntimeCommandExecutionPlan {
|
||||||
|
command_name: "documents.save".into(),
|
||||||
|
command_id: "cmd_1".into(),
|
||||||
|
function_name: "documents:updateContent".into(),
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
request_id: "req_1".into(),
|
||||||
|
trace_id: "trace_1".into(),
|
||||||
|
actor_id: "actor_1".into(),
|
||||||
|
idempotency_key: None,
|
||||||
|
payload_json: "{}".into(),
|
||||||
|
args_json: json!({
|
||||||
|
"id": "doc_1",
|
||||||
|
"content": [],
|
||||||
|
"expectedRevision": 0,
|
||||||
|
"conflictDetectionKey": "doc_1:0",
|
||||||
|
"editorDocument": {"rootBlockIds": []},
|
||||||
|
"tiptapDocument": {"type": "doc", "content": []},
|
||||||
|
"streamDeltaHint": {"family": "tree"},
|
||||||
|
"domainEventHint": {"eventType": "page.body.saved"},
|
||||||
|
"domainEventPlan": {"eventType": "page.body.saved"},
|
||||||
|
"domainEventPlans": [{"eventType": "page.body.saved"}],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let args = convex_command_args_for_plan(&plan);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
json!({
|
||||||
|
"id": "doc_1",
|
||||||
|
"content": [],
|
||||||
|
"expectedRevision": 0,
|
||||||
|
"conflictDetectionKey": "doc_1:0",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_authorization_prefers_forwarded_authorization() {
|
fn build_authorization_prefers_forwarded_authorization() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> Strin
|
|||||||
);
|
);
|
||||||
if input.rows.is_empty() {
|
if input.rows.is_empty() {
|
||||||
html.push_str(
|
html.push_str(
|
||||||
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">当前 file tree 没有可渲染的页面。</li>"#,
|
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">暂无文件或页面</li>"#,
|
||||||
);
|
);
|
||||||
html.push_str("</ul>");
|
html.push_str("</ul>");
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
@@ -89,12 +89,21 @@ fn render_page_row(
|
|||||||
} else {
|
} else {
|
||||||
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
|
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
|
||||||
};
|
};
|
||||||
|
let parent_attr = input
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|source| source.node_id == row.node_id)
|
||||||
|
.and_then(|source| source.parent_node_id.as_deref())
|
||||||
|
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
|
||||||
|
.unwrap_or_default();
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
|
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||||
node_id = escape_html(&row.node_id),
|
node_id = escape_html(&row.node_id),
|
||||||
aria_level = row.depth + 1,
|
aria_level = row.depth + 1,
|
||||||
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
|
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
|
||||||
test_id = row.test_id,
|
test_id = row.test_id,
|
||||||
|
parent_attr = parent_attr,
|
||||||
|
depth = row.depth,
|
||||||
active = active,
|
active = active,
|
||||||
focused = focused,
|
focused = focused,
|
||||||
tab_index = if focused { "0" } else { "-1" },
|
tab_index = if focused { "0" } else { "-1" },
|
||||||
@@ -119,7 +128,7 @@ pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> Stri
|
|||||||
String::from(r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#);
|
String::from(r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#);
|
||||||
if input.rows.is_empty() {
|
if input.rows.is_empty() {
|
||||||
html.push_str(
|
html.push_str(
|
||||||
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面。</li>"#,
|
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>"#,
|
||||||
);
|
);
|
||||||
html.push_str("</ul>");
|
html.push_str("</ul>");
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct WorkspaceShellItem {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
|
pub parent_id: Option<String>,
|
||||||
pub href: String,
|
pub href: String,
|
||||||
pub depth: u32,
|
pub depth: u32,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
@@ -71,9 +72,7 @@ pub fn build_workspace_shell_projection(
|
|||||||
})
|
})
|
||||||
.or_else(|| my_page_items.first().map(|item| item.id.clone()));
|
.or_else(|| my_page_items.first().map(|item| item.id.clone()));
|
||||||
|
|
||||||
for item in &mut my_page_items {
|
apply_active_page_to_items(&mut my_page_items, active_page_id.as_deref());
|
||||||
item.active = active_page_id.as_deref().is_some_and(|active_id| active_id == item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut starred_items = documents
|
let mut starred_items = documents
|
||||||
.iter()
|
.iter()
|
||||||
@@ -88,12 +87,7 @@ pub fn build_workspace_shell_projection(
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||||||
|
|
||||||
let active_page_title = active_page_id.as_deref().and_then(|active_id| {
|
let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
|
||||||
my_page_items
|
|
||||||
.iter()
|
|
||||||
.find(|item| item.id == active_id)
|
|
||||||
.map(|item| item.title.clone())
|
|
||||||
});
|
|
||||||
|
|
||||||
WorkspaceShellProjection {
|
WorkspaceShellProjection {
|
||||||
schema: "mnote.workspace_shell.v1".into(),
|
schema: "mnote.workspace_shell.v1".into(),
|
||||||
@@ -176,16 +170,17 @@ fn document_to_item(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("无标题");
|
.unwrap_or("无标题");
|
||||||
|
let parent_id = document
|
||||||
|
.get("parent_id")
|
||||||
|
.or_else(|| document.get("parentId"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
let depth = document
|
let depth = document
|
||||||
.get("depth")
|
.get("depth")
|
||||||
.and_then(Value::as_u64)
|
.and_then(Value::as_u64)
|
||||||
.or_else(|| {
|
.or_else(|| parent_id.as_ref().map(|_| 1))
|
||||||
document
|
|
||||||
.get("parent_id")
|
|
||||||
.or_else(|| document.get("parentId"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|_| 1)
|
|
||||||
})
|
|
||||||
.unwrap_or(0) as u32;
|
.unwrap_or(0) as u32;
|
||||||
Some(WorkspaceShellItem {
|
Some(WorkspaceShellItem {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -196,12 +191,51 @@ fn document_to_item(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned),
|
.map(ToOwned::to_owned),
|
||||||
|
parent_id,
|
||||||
href: format!("/documents/{id}?workspaceId={workspace_id}"),
|
href: format!("/documents/{id}?workspaceId={workspace_id}"),
|
||||||
depth,
|
depth,
|
||||||
active: active_page_id.is_some_and(|active_id| active_id == id),
|
active: active_page_id.is_some_and(|active_id| active_id == id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_active_page_to_items(items: &mut [WorkspaceShellItem], active_page_id: Option<&str>) {
|
||||||
|
for item in items {
|
||||||
|
item.active = active_page_id.is_some_and(|active_id| active_id == item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_title_from_items(
|
||||||
|
items: &[WorkspaceShellItem],
|
||||||
|
active_page_id: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
active_page_id.and_then(|active_id| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.id == active_id)
|
||||||
|
.map(|item| item.title.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_active_page(projection: &mut WorkspaceShellProjection, active_page_id: Option<&str>) {
|
||||||
|
let normalized = active_page_id
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
projection.active_page_id = normalized;
|
||||||
|
apply_active_page_to_items(
|
||||||
|
&mut projection.my_page_items,
|
||||||
|
projection.active_page_id.as_deref(),
|
||||||
|
);
|
||||||
|
apply_active_page_to_items(
|
||||||
|
&mut projection.starred_items,
|
||||||
|
projection.active_page_id.as_deref(),
|
||||||
|
);
|
||||||
|
projection.active_page_title = active_title_from_items(
|
||||||
|
&projection.my_page_items,
|
||||||
|
projection.active_page_id.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
trait StringEmptyExt {
|
trait StringEmptyExt {
|
||||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||||
}
|
}
|
||||||
@@ -248,15 +282,65 @@ mod tests {
|
|||||||
assert_eq!(projection.starred_items[0].title, "个人");
|
assert_eq!(projection.starred_items[0].title, "个人");
|
||||||
assert_eq!(projection.my_page_items.len(), 2);
|
assert_eq!(projection.my_page_items.len(), 2);
|
||||||
assert!(projection.my_page_items[0].active);
|
assert!(projection.my_page_items[0].active);
|
||||||
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "垃圾箱"));
|
assert_eq!(
|
||||||
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "模板中心"));
|
projection.my_page_items[1].parent_id.as_deref(),
|
||||||
|
Some("page_home")
|
||||||
|
);
|
||||||
|
assert!(projection
|
||||||
|
.bottom_entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.label == "垃圾箱"));
|
||||||
|
assert!(projection
|
||||||
|
.bottom_entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.label == "模板中心"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_shell_sidebar_html_outputs_projection_rows_with_active_and_parent() {
|
||||||
|
let dataset = json!({
|
||||||
|
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||||
|
"documents": [
|
||||||
|
{ "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 },
|
||||||
|
{ "id": "doc_child", "workspace_id": "ws_demo", "title": "Child", "parent_id": "doc_root", "sort_order": 1 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let projection = build_workspace_shell_projection(
|
||||||
|
&dataset,
|
||||||
|
"ws_demo",
|
||||||
|
Some("doc_root"),
|
||||||
|
"开发用户 的工作区",
|
||||||
|
);
|
||||||
|
let html = render_workspace_shell_sidebar_html(&projection, None, None);
|
||||||
|
|
||||||
|
assert!(html.contains("data-testid=\"wolai-sidebar-row\""));
|
||||||
|
assert!(html.contains("data-node-id=\"doc_root\""));
|
||||||
|
assert!(html.contains("data-node-id=\"doc_child\""));
|
||||||
|
assert!(html.contains("data-parent-id=\"doc_root\""));
|
||||||
|
assert!(html.contains("data-active=\"true\""));
|
||||||
|
assert!(html.contains("/documents/doc_root?workspaceId=ws_demo"));
|
||||||
|
assert!(html.contains("data-testid=\"wolai-sidebar-create-page\""));
|
||||||
|
assert!(html.contains("data-mnote-action=\"create-page\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_shell_sidebar_html_outputs_empty_state() {
|
||||||
|
let dataset = json!({
|
||||||
|
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||||
|
"documents": []
|
||||||
|
});
|
||||||
|
let projection =
|
||||||
|
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
|
||||||
|
let html = render_workspace_shell_sidebar_html(&projection, None, None);
|
||||||
|
|
||||||
|
assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn render_workspace_shell_sidebar_html(
|
pub fn render_workspace_shell_sidebar_html(
|
||||||
projection: &WorkspaceShellProjection,
|
projection: &WorkspaceShellProjection,
|
||||||
sidebar_tree_html: Option<&str>,
|
sidebar_tree_html: Option<&str>,
|
||||||
|
file_tree_html: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let starred_rows = if projection.starred_items.is_empty() {
|
let starred_rows = if projection.starred_items.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -279,14 +363,28 @@ pub fn render_workspace_shell_sidebar_html(
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(|html| {
|
.map(|html| {
|
||||||
format!(
|
format!(
|
||||||
r#"<div class="sidebar-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree">{html}</div></div>"#
|
r#"<div class="sidebar-tree-section sidebar-page-tree-section" data-testid="wolai-sidebar-page-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree" data-tree-shell-mode="page" data-workspace-id="{}">{html}</div></div>"#,
|
||||||
|
escape_html(&projection.workspace_id),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let my_pages = if projected_my_pages.is_empty() {
|
let file_tree_panel = file_tree_html
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|html| {
|
||||||
|
format!(
|
||||||
|
r#"<section class="wolai-sidebar-section wolai-file-tree-section" aria-label="文件树" data-testid="wolai-sidebar-file-tree-section"><div class="wolai-section-title"><span>文件树</span><span class="wolai-section-caret">⌄</span></div><div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}">{html}</div></div></section>"#,
|
||||||
|
escape_html(&projection.workspace_id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let empty_state = r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-empty-state">暂无页面</div>"#;
|
||||||
|
let my_pages = if !tree_html.is_empty() {
|
||||||
tree_html
|
tree_html
|
||||||
|
} else if !projected_my_pages.is_empty() {
|
||||||
|
projected_my_pages
|
||||||
} else {
|
} else {
|
||||||
format!("{projected_my_pages}{tree_html}")
|
empty_state.to_string()
|
||||||
};
|
};
|
||||||
let bottom_entries = projection
|
let bottom_entries = projection
|
||||||
.bottom_entries
|
.bottom_entries
|
||||||
@@ -303,7 +401,8 @@ pub fn render_workspace_shell_sidebar_html(
|
|||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="我的页面"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><span class="wolai-section-add">+</span></div>{my_pages}</section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#
|
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div>{my_pages}</section>{file_tree_panel}<div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||||
|
escape_html(&projection.workspace_id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,10 +413,22 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
|
|||||||
} else {
|
} else {
|
||||||
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 18)
|
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 18)
|
||||||
};
|
};
|
||||||
|
let parent_attr = item
|
||||||
|
.parent_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let aria_current = if item.active {
|
||||||
|
r#" aria-current="page""#
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
r#"<a class="wolai-page-row{active_class}" href="{}" data-node-id="{}"{depth_style}><span class="wolai-row-icon">{}</span>{}</a>"#,
|
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
|
||||||
escape_html(&item.href),
|
escape_html(&item.href),
|
||||||
escape_html(&item.id),
|
escape_html(&item.id),
|
||||||
|
item.depth,
|
||||||
|
item.active,
|
||||||
escape_html(item.icon.as_deref().unwrap_or("▣")),
|
escape_html(item.icon.as_deref().unwrap_or("▣")),
|
||||||
escape_html(&item.title),
|
escape_html(&item.title),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,12 +56,21 @@ async function main() {
|
|||||||
const floatingAi = page.getByTestId("wolai-floating-ai");
|
const floatingAi = page.getByTestId("wolai-floating-ai");
|
||||||
const floatingHelp = page.getByTestId("wolai-floating-help");
|
const floatingHelp = page.getByTestId("wolai-floating-help");
|
||||||
const content = page.locator(".mnote-content").first();
|
const content = page.locator(".mnote-content").first();
|
||||||
const homeContent = page.locator(".mnote-home").first();
|
const staticHomeLinks = page.locator(".mnote-home-links");
|
||||||
|
const documentShell = page.locator(".document-shell").first();
|
||||||
|
const emptyState = page.locator('[data-testid="mnote-workspace-empty-state"]').first();
|
||||||
|
|
||||||
|
assert.equal(await staticHomeLinks.count(), 0, "根页仍使用 .mnote-home-links 静态设计列表作为主内容");
|
||||||
|
const hasDocumentShell = (await documentShell.count()) > 0;
|
||||||
|
const hasEmptyState = (await emptyState.count()) > 0;
|
||||||
|
assert(hasDocumentShell || hasEmptyState, "根页必须渲染真实 active page shell 或明确空 workspace state");
|
||||||
|
|
||||||
const sidebarBox = await boundingBox(sidebar, "左侧栏");
|
const sidebarBox = await boundingBox(sidebar, "左侧栏");
|
||||||
const topbarBox = await boundingBox(topbar, "顶栏");
|
const topbarBox = await boundingBox(topbar, "顶栏");
|
||||||
const contentBox = await boundingBox(content, "主内容区");
|
const contentBox = await boundingBox(content, "主内容区");
|
||||||
const homeContentBox = await boundingBox(homeContent, "首页正文");
|
const primaryContentBox = hasDocumentShell
|
||||||
|
? await boundingBox(documentShell, "active page shell")
|
||||||
|
: await boundingBox(emptyState, "空 workspace state");
|
||||||
const floatingAiBox = await boundingBox(floatingAi, "AI 浮动按钮");
|
const floatingAiBox = await boundingBox(floatingAi, "AI 浮动按钮");
|
||||||
const floatingHelpBox = await boundingBox(floatingHelp, "帮助浮动按钮");
|
const floatingHelpBox = await boundingBox(floatingHelp, "帮助浮动按钮");
|
||||||
|
|
||||||
@@ -73,8 +82,8 @@ async function main() {
|
|||||||
assert(contentBox.y >= topbarBox.y + topbarBox.height - 2, "主内容区不应被顶栏遮挡");
|
assert(contentBox.y >= topbarBox.y + topbarBox.height - 2, "主内容区不应被顶栏遮挡");
|
||||||
assert(floatingAiBox.x > sidebarBox.width, "AI 浮动按钮不应落入左侧栏");
|
assert(floatingAiBox.x > sidebarBox.width, "AI 浮动按钮不应落入左侧栏");
|
||||||
assert(floatingHelpBox.x > sidebarBox.width, "帮助浮动按钮不应落入左侧栏");
|
assert(floatingHelpBox.x > sidebarBox.width, "帮助浮动按钮不应落入左侧栏");
|
||||||
assert(!boxesIntersect(floatingAiBox, homeContentBox), "AI 浮动按钮不应遮挡首页正文");
|
assert(!boxesIntersect(floatingAiBox, primaryContentBox), "AI 浮动按钮不应遮挡主内容");
|
||||||
assert(!boxesIntersect(floatingHelpBox, homeContentBox), "帮助浮动按钮不应遮挡首页正文");
|
assert(!boxesIntersect(floatingHelpBox, primaryContentBox), "帮助浮动按钮不应遮挡主内容");
|
||||||
assert(floatingHelpBox.y > floatingAiBox.y, "帮助按钮应位于 AI 按钮下方");
|
assert(floatingHelpBox.y > floatingAiBox.y, "帮助按钮应位于 AI 按钮下方");
|
||||||
|
|
||||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||||
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||||
|
|
||||||
|
async function readJsonResponse(response, label) {
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||||
|
}
|
||||||
|
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postTreeCommand(body, label) {
|
||||||
|
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = await readJsonResponse(response, label);
|
||||||
|
const result = payload && typeof payload.result === "object" ? payload.result : null;
|
||||||
|
assert(result, `${label} 缺少 result`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTempPage(title, extra = {}) {
|
||||||
|
const result = await postTreeCommand(
|
||||||
|
{
|
||||||
|
action: "create",
|
||||||
|
title,
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
`创建临时页面 ${title}`,
|
||||||
|
);
|
||||||
|
assert(result.documentId, `创建临时页面 ${title} 缺少 documentId`);
|
||||||
|
assert(result.workspaceId, `创建临时页面 ${title} 缺少 workspaceId`);
|
||||||
|
return {
|
||||||
|
documentId: result.documentId,
|
||||||
|
workspaceId: result.workspaceId,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeTempPage(target) {
|
||||||
|
if (!target?.documentId || !target?.workspaceId) return;
|
||||||
|
await postTreeCommand(
|
||||||
|
{
|
||||||
|
action: "purge",
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
documentId: target.documentId,
|
||||||
|
},
|
||||||
|
`清理临时页面 ${target.documentId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSidebarRow(page, documentId) {
|
||||||
|
const selector = `[data-testid="wolai-sidebar-row"][data-node-id="${documentId}"]`;
|
||||||
|
const row = page.locator(selector).first();
|
||||||
|
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTreeSnapshot(workspaceId) {
|
||||||
|
const response = await fetchWithTimeout(
|
||||||
|
`${BASE_URL}/api/tree/events?workspaceId=${encodeURIComponent(workspaceId)}&maxPolls=0`,
|
||||||
|
{ headers: { accept: "text/event-stream" } },
|
||||||
|
);
|
||||||
|
const text = await response.text();
|
||||||
|
assert.equal(response.status, 200, `/api/tree/events 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||||
|
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web");
|
||||||
|
assert.match(text, /event:\s*snapshot|event:snapshot/, "tree events 未返回 snapshot 事件");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
const rootTitle = `task120-root-${suffix}`;
|
||||||
|
const childTitle = `task120-child-${suffix}`;
|
||||||
|
let rootPage = null;
|
||||||
|
let childPage = null;
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
rootPage = await createTempPage(rootTitle);
|
||||||
|
childPage = await createTempPage(childTitle, {
|
||||||
|
workspaceId: rootPage.workspaceId,
|
||||||
|
parentId: rootPage.documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetUrl = `${BASE_URL}/documents/${encodeURIComponent(rootPage.documentId)}?workspaceId=${encodeURIComponent(rootPage.workspaceId)}`;
|
||||||
|
const response = await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
assert(response, "文档页没有返回响应");
|
||||||
|
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||||
|
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "文档页必须由 mnote-web 拥有");
|
||||||
|
|
||||||
|
const rootRow = await waitForSidebarRow(page, rootPage.documentId);
|
||||||
|
const childRow = await waitForSidebarRow(page, childPage.documentId);
|
||||||
|
await expectRowTitle(rootRow, rootTitle, "root row 标题");
|
||||||
|
await expectRowTitle(childRow, childTitle, "child row 标题");
|
||||||
|
assert.equal(await childRow.getAttribute("data-parent-id"), rootPage.documentId, "子页面 row 缺少真实 parent id");
|
||||||
|
assert.equal(await rootRow.getAttribute("data-active"), "true", "当前文档 root row 没有 active 标记");
|
||||||
|
|
||||||
|
await childRow.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForURL((url) => url.pathname === `/documents/${childPage.documentId}`, {
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
const activeChildRow = await waitForSidebarRow(page, childPage.documentId);
|
||||||
|
assert.equal(await activeChildRow.getAttribute("data-active"), "true", "点击 child row 后 active 标记未切换");
|
||||||
|
|
||||||
|
const renamedTitle = `${childTitle}-renamed`;
|
||||||
|
await postTreeCommand(
|
||||||
|
{
|
||||||
|
action: "rename",
|
||||||
|
workspaceId: childPage.workspaceId,
|
||||||
|
documentId: childPage.documentId,
|
||||||
|
title: renamedTitle,
|
||||||
|
},
|
||||||
|
"重命名临时子页面",
|
||||||
|
);
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
const renamedRow = await waitForSidebarRow(page, childPage.documentId);
|
||||||
|
await expectRowTitle(renamedRow, renamedTitle, "重命名后 child row 标题");
|
||||||
|
|
||||||
|
const snapshotText = await readTreeSnapshot(rootPage.workspaceId);
|
||||||
|
assert(snapshotText.includes(rootPage.documentId), "tree events snapshot 未包含 root 页面");
|
||||||
|
assert(snapshotText.includes(childPage.documentId), "tree events snapshot 未包含 child 页面");
|
||||||
|
|
||||||
|
const purgedChild = childPage;
|
||||||
|
childPage = null;
|
||||||
|
await purgeTempPage(purgedChild);
|
||||||
|
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(documentId) => !document.querySelector(`[data-testid="wolai-sidebar-row"][data-node-id="${documentId}"]`),
|
||||||
|
purgedChild.documentId,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
workspaceId: rootPage.workspaceId,
|
||||||
|
rootDocumentId: rootPage.documentId,
|
||||||
|
childDocumentId: purgedChild.documentId,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (childPage) {
|
||||||
|
await purgeTempPage(childPage).catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (rootPage) {
|
||||||
|
await purgeTempPage(rootPage).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await page.close().catch(() => undefined);
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectRowTitle(row, expected, label) {
|
||||||
|
const text = (await row.innerText({ timeout: UI_TIMEOUT_MS })).trim();
|
||||||
|
assert(text.includes(expected), `${label} 不匹配: expected=${expected}; actual=${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||||
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||||
|
|
||||||
|
async function readJsonResponse(response, label) {
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||||
|
}
|
||||||
|
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postTreeCommand(body, label) {
|
||||||
|
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = await readJsonResponse(response, label);
|
||||||
|
const result = payload && typeof payload.result === "object" ? payload.result : null;
|
||||||
|
assert(result, `${label} 缺少 result`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTempDocument() {
|
||||||
|
const title = `task121-editor-${Date.now().toString(36)}`;
|
||||||
|
const result = await postTreeCommand({ action: "create", title }, "创建临时编辑文档");
|
||||||
|
assert(result.documentId, "创建临时编辑文档缺少 documentId");
|
||||||
|
assert(result.workspaceId, "创建临时编辑文档缺少 workspaceId");
|
||||||
|
return {
|
||||||
|
documentId: result.documentId,
|
||||||
|
workspaceId: result.workspaceId,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeTempDocument(target) {
|
||||||
|
if (!target?.documentId || !target?.workspaceId) return;
|
||||||
|
await postTreeCommand(
|
||||||
|
{
|
||||||
|
action: "purge",
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
documentId: target.documentId,
|
||||||
|
},
|
||||||
|
`清理临时编辑文档 ${target.documentId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPageAggregate(target) {
|
||||||
|
const response = await fetchWithTimeout(
|
||||||
|
`${BASE_URL}/api/page-aggregate/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||||
|
);
|
||||||
|
const payload = await readJsonResponse(response, "读取 Page Aggregate");
|
||||||
|
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web", "Page Aggregate 必须由 mnote-web 拥有");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRuntimeIsland(page) {
|
||||||
|
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||||
|
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
const editor = page
|
||||||
|
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||||
|
.first();
|
||||||
|
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||||
|
const observability = document.querySelector('[data-editor-host-observability]');
|
||||||
|
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||||
|
return (
|
||||||
|
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
|
||||||
|
host?.getAttribute("data-runtime-editor-status") !== "error" &&
|
||||||
|
observability?.getAttribute("data-editor-host-active") === "leptos_tiptap_island" &&
|
||||||
|
editorNode instanceof HTMLElement &&
|
||||||
|
editorNode.isContentEditable
|
||||||
|
);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEditorText(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const editor = document.querySelector(
|
||||||
|
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
|
||||||
|
);
|
||||||
|
return editor?.textContent ?? "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSaved(page, expectedText) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(text) => {
|
||||||
|
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||||
|
const editor = host?.querySelector('.editor-surface .ProseMirror');
|
||||||
|
return host?.getAttribute("data-runtime-editor-status") === "saved" && (editor?.textContent ?? "").includes(text);
|
||||||
|
},
|
||||||
|
expectedText,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
let target = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
target = await createTempDocument();
|
||||||
|
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||||
|
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
assert(response, "文档页没有返回响应");
|
||||||
|
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||||
|
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "文档页必须由 mnote-web 拥有");
|
||||||
|
|
||||||
|
const bootstrap = await page.locator('script#__MNOTE_EDITOR_BOOTSTRAP__[type="application/json"]').textContent({ timeout: UI_TIMEOUT_MS });
|
||||||
|
assert(bootstrap && bootstrap.includes(target.documentId), "editor bootstrap 未包含当前 documentId");
|
||||||
|
const aggregateScript = await page.locator('script#__MNOTE_PAGE_AGGREGATE__[type="application/json"]').textContent({ timeout: UI_TIMEOUT_MS });
|
||||||
|
assert(aggregateScript && aggregateScript.includes(target.documentId), "Page Aggregate script 未包含当前 documentId");
|
||||||
|
|
||||||
|
const editor = await waitForRuntimeIsland(page);
|
||||||
|
const text = `task121-persist-${Date.now().toString(36)}`;
|
||||||
|
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.keyboard.type(text, { delay: 25 });
|
||||||
|
await waitForSaved(page, text);
|
||||||
|
assert((await readEditorText(page)).includes(text), "输入文本未进入 ProseMirror");
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await waitForRuntimeIsland(page);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) => {
|
||||||
|
const editorNode = document.querySelector(
|
||||||
|
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
|
||||||
|
);
|
||||||
|
return (editorNode?.textContent ?? "").includes(expected);
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
const aggregate = await fetchPageAggregate(target);
|
||||||
|
const aggregateText = JSON.stringify(aggregate.result ?? aggregate);
|
||||||
|
assert(aggregateText.includes(text), "保存后 Page Aggregate 未读回唯一文本");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
documentId: target.documentId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (target) {
|
||||||
|
await purgeTempDocument(target).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await page.close().catch(() => undefined);
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||||
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||||
|
|
||||||
|
async function readJsonResponse(response, label) {
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||||
|
}
|
||||||
|
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postTreeCommand(body, label) {
|
||||||
|
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = await readJsonResponse(response, label);
|
||||||
|
const result = payload && typeof payload.result === "object" ? payload.result : null;
|
||||||
|
assert(result, `${label} 缺少 result`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
let created = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
assert(response, "根页没有返回响应");
|
||||||
|
assert.equal(response.status(), 200, `根页状态码异常: ${response.status()}`);
|
||||||
|
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "根页必须由 mnote-web 拥有");
|
||||||
|
|
||||||
|
const createButton = page.getByTestId("wolai-sidebar-create-page").first();
|
||||||
|
await createButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
|
await page.waitForURL((url) => url.pathname.startsWith("/documents/"), {
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
const createdUrl = new URL(page.url());
|
||||||
|
const documentId = decodeURIComponent(createdUrl.pathname.replace(/^\/documents\//, ""));
|
||||||
|
const workspaceId = createdUrl.searchParams.get("workspaceId") || "";
|
||||||
|
assert(documentId, "新建后 URL 缺少 documentId");
|
||||||
|
assert(workspaceId, "新建后 URL 缺少 workspaceId");
|
||||||
|
created = { documentId, workspaceId };
|
||||||
|
|
||||||
|
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
|
assert(created?.documentId, "UI 新建未捕获到 documentId");
|
||||||
|
assert(page.url().includes(created.documentId), "新建后 URL 未进入新文档");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
documentId: created.documentId,
|
||||||
|
workspaceId: created.workspaceId,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (created?.documentId && created?.workspaceId) {
|
||||||
|
await postTreeCommand(
|
||||||
|
{ action: "purge", workspaceId: created.workspaceId, documentId: created.documentId },
|
||||||
|
`清理 UI 新建页面 ${created.documentId}`,
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await page.close().catch(() => undefined);
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 41 KiB |
Reference in New Issue
Block a user