From b33ffb99e79b9ae60b8616488b8b1166d16d84ec Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Wed, 15 Apr 2026 03:06:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E5=8F=A3=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E4=B8=8E=20OnlyOffice/Sidebar=20=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器 - 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线 - 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史 --- .gitignore | 11 +- ARCHITECTURE.md | 4 +- infra/onlyoffice/README.md | 25 ++ infra/onlyoffice/docker-compose.yml | 20 + rust/crates/core-protocol/src/command.rs | 19 + rust/crates/core-protocol/src/lib.rs | 8 +- rust/crates/core-protocol/src/query.rs | 17 + rust/crates/storage-convex-bridge/src/lib.rs | 133 +++++- .../storage-convex-bridge/src/mapping.rs | 5 + scripts/task019-document-ui-regression.js | 177 ++++++++ scripts/task021-mindmap-ui-regression.js | 373 ++++++++++++++++ scripts/task022-onlyoffice-ui-regression.js | 414 ++++++++++++++++++ wolai-frontend/convex/_generated/api.d.ts | 4 + wolai-frontend/convex/documents.ts | 79 +++- wolai-frontend/convex/mindmaps.ts | 72 ++- wolai-frontend/convex/schema.ts | 2 + wolai-frontend/convex/sidebar.ts | 223 ++++++++++ wolai-frontend/public/mnote-env.json | 9 +- .../plugins/agent-tools/config.json | 8 +- .../onlyoffice/plugins/agent-tools/index.html | 2 +- .../onlyoffice/plugins/agent-tools/plugin.js | 53 ++- wolai-frontend/scripts/dev-server.js | 156 +++++-- wolai-frontend/scripts/prod-server.js | 157 +++++-- .../src/app/(app)/documents/[id]/page.tsx | 2 + wolai-frontend/src/app/(app)/layout.tsx | 1 - .../src/app/api/documents/content/route.ts | 8 + .../src/app/api/documents/save/route.ts | 52 +-- .../api/mindmap/[docId]/[mindmapId]/route.ts | 80 +++- .../src/app/api/mindmap/[docId]/route.ts | 55 ++- .../src/app/api/onlyoffice/callback/route.ts | 9 +- .../src/app/api/onlyoffice/forcesave/route.ts | 13 +- wolai-frontend/src/app/api/sidebar/route.ts | 6 +- .../src/app/cache/[...path]/route.ts | 9 +- .../app/onlyoffice-server/[...path]/route.ts | 12 +- .../app/onlyoffice/OnlyOfficeClientPage.tsx | 68 ++- .../components/editor/blocknote-editor.tsx | 79 +++- .../components/editor/blocks/MindmapBlock.tsx | 149 +++++-- .../components/editor/document-content.tsx | 36 +- .../src/hooks/use-convex-sidebar-data.ts | 142 ++---- .../src/lib/documents/bridge.test.ts | 188 ++++++++ wolai-frontend/src/lib/documents/bridge.ts | 111 +++++ .../lib/documents/metadata-command-adapter.ts | 25 +- .../src/lib/documents/save-command-adapter.ts | 75 ++++ .../src/lib/documents/save-contract.test.ts | 66 +++ .../src/lib/documents/save-contract.ts | 48 ++ .../src/lib/mindmap/mindmapRouteMeta.ts | 56 +++ .../src/lib/onlyoffice/internal-url.ts | 93 ++++ wolai-frontend/src/lib/server/sidebar-data.ts | 71 ++- wolai-frontend/src/lib/sidebar-data.test.ts | 120 ++++- wolai-frontend/src/lib/sidebar-data.ts | 91 +++- wolai-frontend/src/middleware.ts | 3 + 51 files changed, 3260 insertions(+), 379 deletions(-) create mode 100644 infra/onlyoffice/README.md create mode 100644 infra/onlyoffice/docker-compose.yml create mode 100644 scripts/task019-document-ui-regression.js create mode 100644 scripts/task021-mindmap-ui-regression.js create mode 100644 scripts/task022-onlyoffice-ui-regression.js create mode 100644 wolai-frontend/convex/sidebar.ts create mode 100644 wolai-frontend/src/lib/documents/save-command-adapter.ts create mode 100644 wolai-frontend/src/lib/documents/save-contract.test.ts create mode 100644 wolai-frontend/src/lib/documents/save-contract.ts create mode 100644 wolai-frontend/src/lib/mindmap/mindmapRouteMeta.ts create mode 100644 wolai-frontend/src/lib/onlyoffice/internal-url.ts diff --git a/.gitignore b/.gitignore index 18b367b1..f655d43e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,12 @@ wolai-frontend/public/documents/ wolai-frontend/public/documents/** artifacts/ artifacts/** -tmp -design \ No newline at end of file +tmp +design + +# Rust 本地构建与调试产物 +/rust/target/ + +# Harness 本地运行状态/调试产物 +/.harness-stop-counter +/harness-tasks.json.bak diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c9381ba7..d072518b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -533,7 +533,7 @@ API 层主要位于: ### 9.6 当前结论 -如果后续迁移到 `mnote-rust`,这两套树不应该混为一个组件重写,而应该拆成: +如果后续继续把这套能力收口到主仓 Rust 协议层,这两套树不应该混为一个组件重写,而应该拆成: 1. 统一侧边栏数据聚合层 2. 页面树投影层 @@ -741,7 +741,7 @@ API 层主要位于: ## 13. 后续重构建议 -如果后面你要把这套能力迁到 `mnote-rust`,最值得优先抽象的是三层: +如果后面你要把这套能力继续收口到 `/mnt/Data1T/mnote/rust/`,最值得优先抽象的是三层: 1. 文档宿主层 - 文档页面壳 diff --git a/infra/onlyoffice/README.md b/infra/onlyoffice/README.md new file mode 100644 index 00000000..dea99571 --- /dev/null +++ b/infra/onlyoffice/README.md @@ -0,0 +1,25 @@ +# ONLYOFFICE 部署说明 + +当前主仓 ONLYOFFICE 文档服务部署入口在: + +- `./docker-compose.yml` + +约定: + +- `8082` 作为当前主仓默认 ONLYOFFICE DocumentServer 端口。 +- 镜像版本统一由当前主仓维护,不再依赖历史仓 `mnote-rust` 的 compose。 +- 仅挂载 `src/components/onlyoffice/onlyoffice-plugins` 到容器的 `sdkjs-plugins`。 +- 不覆盖容器自带 `web-apps`,避免升级后被历史静态资源压回旧版本。 + +启动: + +```bash +cd /mnt/Data1T/mnote/infra/onlyoffice +docker compose up -d +``` + +检查: + +```bash +curl -I http://127.0.0.1:8082/web-apps/apps/api/documents/api.js +``` diff --git a/infra/onlyoffice/docker-compose.yml b/infra/onlyoffice/docker-compose.yml new file mode 100644 index 00000000..a90e2a62 --- /dev/null +++ b/infra/onlyoffice/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.9" + +services: + onlyoffice-documentserver: + image: onlyoffice/documentserver:9.3.1 + container_name: mnote-onlyoffice-documentserver + restart: unless-stopped + ports: + - "8082:80" + environment: + JWT_ENABLED: "true" + JWT_SECRET: "change-me-to-strong-secret" + JWT_HEADER: "Authorization" + JWT_IN_BODY: "true" + USE_UNAUTHORIZED_STORAGE: "true" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + # 说明:只挂插件目录,不覆盖 DocumentServer 自带 web-apps,避免升级后静态资源被旧版本文件覆盖。 + - ../../src/components/onlyoffice/onlyoffice-plugins:/var/www/onlyoffice/documentserver/sdkjs-plugins:ro diff --git a/rust/crates/core-protocol/src/command.rs b/rust/crates/core-protocol/src/command.rs index b11e1ef6..1dc6f742 100644 --- a/rust/crates/core-protocol/src/command.rs +++ b/rust/crates/core-protocol/src/command.rs @@ -72,6 +72,25 @@ pub struct UpdatePageOptions { pub embed_default_block_id: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavePageContent { + pub page_id: String, + pub workspace_id: Option, + pub revision: Option, + pub content_json: String, + pub conflict_detection_key: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchPageBlock { + pub page_id: String, + pub block_id: String, + pub workspace_id: Option, + pub revision: Option, + pub block_snapshot_json: String, + pub conflict_detection_key: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct InsertBlock { pub page_id: String, diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index 1c4cd651..00238264 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -6,13 +6,17 @@ pub mod tool; pub use command::{ CommandEnvelope, CreatePage, CreateWorkspace, DeleteBlock, InsertBlock, MoveBlock, - UpdateBlock, UpdatePageStats, UpdatePageTitle, + PatchPageBlock, SavePageContent, UpdateBlock, UpdatePageOptions, UpdatePageStats, + UpdatePageTitle, }; pub use common::{ ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta, SourcePayload, TargetRef, }; -pub use query::{GetPage, ListPageBlocks, QueryEnvelope, SearchBlocks, SearchPages}; +pub use query::{ + GetPage, GetPageContent, GetPageMeta, ListPageBlocks, ListSidebarDataset, QueryEnvelope, + SearchBlocks, SearchPages, +}; pub use tool::{InvocationKind, ToolInvocation}; #[cfg(test)] diff --git a/rust/crates/core-protocol/src/query.rs b/rust/crates/core-protocol/src/query.rs index 5e513939..0ea41ba3 100644 --- a/rust/crates/core-protocol/src/query.rs +++ b/rust/crates/core-protocol/src/query.rs @@ -15,6 +15,23 @@ pub struct GetPage { pub page_id: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetPageMeta { + pub page_id: String, + pub workspace_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetPageContent { + pub page_id: String, + pub workspace_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListSidebarDataset { + pub workspace_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListPageBlocks { pub page_id: String, diff --git a/rust/crates/storage-convex-bridge/src/lib.rs b/rust/crates/storage-convex-bridge/src/lib.rs index 93da3b81..3ddadf50 100644 --- a/rust/crates/storage-convex-bridge/src/lib.rs +++ b/rust/crates/storage-convex-bridge/src/lib.rs @@ -21,8 +21,11 @@ pub use write_path::{ mod tests { use super::*; use core_protocol::{ - command::{CreatePage, CreateWorkspace, UpdatePageOptions, UpdatePageStats, UpdatePageTitle}, - query::GetPage, + command::{ + CreatePage, CreateWorkspace, PatchPageBlock, SavePageContent, UpdatePageOptions, + UpdatePageStats, UpdatePageTitle, + }, + query::{GetPage, GetPageContent, GetPageMeta, ListSidebarDataset}, ActorPayload, CommandEnvelope, QueryEnvelope, SourcePayload, TargetRef, }; @@ -176,6 +179,85 @@ mod tests { assert!(request.payload_json.contains("\"name\":\"documents.title.update\"")); } + #[test] + fn document_save_command_maps_to_documents_update_content() { + let command = CommandEnvelope { + name: "documents.save".into(), + command_id: "cmd_save_1".into(), + idempotency_key: Some("idem_save".into()), + actor: ActorPayload { + actor_type: "human".into(), + actor_id: "user_1".into(), + session_id: Some("session_1".into()), + }, + source: SourcePayload { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(TargetRef { + workspace_id: Some("ws_1".into()), + page_id: Some("page_1".into()), + block_id: None, + }), + payload: SavePageContent { + page_id: "page_1".into(), + workspace_id: Some("ws_1".into()), + revision: Some(42), + content_json: "{\"type\":\"doc\"}".into(), + conflict_detection_key: Some("rev:42".into()), + }, + reason: Some("保存正文".into()), + refs: vec!["checklist:c3".into()], + dry_run: false, + validate_only: false, + }; + + let request = build_write_request(&demo_context(), &command).expect("write request should build"); + assert_eq!(request.function_name, "documents:updateContent"); + assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); + assert!(request.payload_json.contains("\"name\":\"documents.save\"")); + } + + #[test] + fn block_patch_command_maps_to_documents_update_content() { + let command = CommandEnvelope { + name: "blocks.patch".into(), + command_id: "cmd_patch_1".into(), + idempotency_key: Some("idem_patch".into()), + actor: ActorPayload { + actor_type: "human".into(), + actor_id: "user_1".into(), + session_id: Some("session_1".into()), + }, + source: SourcePayload { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(TargetRef { + workspace_id: Some("ws_1".into()), + page_id: Some("page_1".into()), + block_id: Some("block_1".into()), + }), + payload: PatchPageBlock { + page_id: "page_1".into(), + block_id: "block_1".into(), + workspace_id: Some("ws_1".into()), + revision: Some(7), + block_snapshot_json: "{\"id\":\"block_1\",\"type\":\"paragraph\"}".into(), + conflict_detection_key: Some("page:1:block:1".into()), + }, + reason: Some("替换块快照".into()), + refs: vec!["checklist:c3".into()], + dry_run: false, + validate_only: false, + }; + + let request = build_write_request(&demo_context(), &command).expect("write request should build"); + assert_eq!(request.function_name, "documents:updateContent"); + assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); + assert!(request.payload_json.contains("\"name\":\"blocks.patch\"")); + } + #[test] fn document_stats_command_maps_to_documents_update_stats() { let command = CommandEnvelope { @@ -263,4 +345,51 @@ mod tests { assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); assert!(request.payload_json.contains("\"name\":\"documents.options.update\"")); } + + #[test] + fn document_meta_query_maps_to_documents_get_meta() { + let query = QueryEnvelope { + name: "documents.meta.get".into(), + payload: GetPageMeta { + page_id: "page_1".into(), + workspace_id: Some("ws_1".into()), + }, + }; + + let request = build_query_request(&demo_context(), &query).expect("query request should build"); + assert_eq!(request.function_name, "documents:getMeta"); + assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); + assert!(request.payload_json.contains("\"name\":\"documents.meta.get\"")); + } + + #[test] + fn document_content_query_maps_to_documents_get_content() { + let query = QueryEnvelope { + name: "documents.content.get".into(), + payload: GetPageContent { + page_id: "page_1".into(), + workspace_id: Some("ws_1".into()), + }, + }; + + let request = build_query_request(&demo_context(), &query).expect("query request should build"); + assert_eq!(request.function_name, "documents:getContent"); + assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); + assert!(request.payload_json.contains("\"name\":\"documents.content.get\"")); + } + + #[test] + fn sidebar_dataset_query_maps_to_sidebar_dataset_list() { + let query = QueryEnvelope { + name: "sidebar.dataset.list".into(), + payload: ListSidebarDataset { + workspace_id: "ws_1".into(), + }, + }; + + let request = build_query_request(&demo_context(), &query).expect("query request should build"); + assert_eq!(request.function_name, "sidebar:datasetList"); + assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); + assert!(request.payload_json.contains("\"name\":\"sidebar.dataset.list\"")); + } } diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index bbc3fc1b..7239c3b2 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -29,6 +29,8 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { match command_name { "create_workspace" => "workspaces:create", "create_page" => "pages:create", + "blocks.patch" => "documents:updateContent", + "documents.save" => "documents:updateContent", "documents.title.update" => "documents:updateTitle", "documents.stats.update" => "documents:updateStats", "documents.options.update" => "documents:updateOptions", @@ -42,6 +44,9 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { pub fn map_query_name_to_convex(query_name: &str) -> &'static str { match query_name { + "documents.meta.get" => "documents:getMeta", + "documents.content.get" => "documents:getContent", + "sidebar.dataset.list" => "sidebar:datasetList", "get_page" => "pages:get", "list_page_blocks" => "blocks:list_by_page", "search_pages" => "search:pages", diff --git a/scripts/task019-document-ui-regression.js b/scripts/task019-document-ui-regression.js new file mode 100644 index 00000000..1f14159f --- /dev/null +++ b/scripts/task019-document-ui-regression.js @@ -0,0 +1,177 @@ +"use strict"; + +// 说明: +// - 这是 task-019 的最小真实浏览器回归脚本。 +// - 目标只覆盖文档页元信息、Sidebar、BlockNote 保存链,不扩大到 Mindmap / OnlyOffice。 +// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。 + +const { chromium } = require("playwright"); + +const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001"; +const REQUEST_TIMEOUT_MS = 20_000; +const UI_TIMEOUT_MS = 30_000; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function requestJson(path, init = {}) { + const response = await fetch(`${BASE_URL}${path}`, { + ...init, + headers: { + "content-type": "application/json", + ...(init.headers || {}), + }, + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + + if (!response.ok) { + throw new Error( + `${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, + ); + } + + return payload; +} + +async function createTempDocument() { + const payload = await requestJson("/api/documents/create", { + method: "POST", + body: JSON.stringify({ parentId: null }), + }); + + assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); + assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); + + return { + documentId: payload.id, + workspaceId: payload.workspace_id, + }; +} + +async function purgeTempDocument(documentId) { + await requestJson("/api/documents/purge", { + method: "POST", + body: JSON.stringify({ documentId }), + }); +} + +async function runBrowserRegression(target) { + const uniqueSuffix = Date.now().toString(); + const nextTitle = `task019-ui-${uniqueSuffix}`; + const nextBody = `task019 正文保存回归 ${uniqueSuffix}`; + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + + try { + const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`; + await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + + const sidebarPanel = page.getByText("页面树"); + const privateSection = page.getByText("私有 / 我的页面"); + const titleInput = page.getByLabel("页面标题"); + const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first(); + const saveIndicator = page.locator("text=已保存"); + + await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + await titleInput.fill(nextTitle); + const titleSaveResponse = page.waitForResponse( + (response) => + response.url().includes("/api/documents/title") && + response.request().method() === "POST" && + response.status() === 200, + { timeout: UI_TIMEOUT_MS }, + ); + await titleInput.evaluate((node) => { + node.blur(); + }); + await titleSaveResponse; + + const saveResponse = page.waitForResponse( + (response) => + response.url().includes("/api/documents/save") && + response.request().method() === "POST" && + response.status() === 200 && + (response.request().postData() || "").includes(nextBody), + { timeout: UI_TIMEOUT_MS }, + ); + await editorSurface.click({ timeout: UI_TIMEOUT_MS }); + await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS }); + await saveResponse; + await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + const persistedTitle = await titleInput.inputValue(); + assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`); + const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS }); + assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容"); + + return { + documentUrl, + nextTitle, + nextBody, + }; + } finally { + await page.close(); + await browser.close(); + } +} + +async function main() { + const health = await fetch(`${BASE_URL}/`, { + method: "HEAD", + redirect: "manual", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + assert( + [200, 307, 308].includes(health.status), + `首页探活失败:收到状态码 ${health.status}`, + ); + + const tempDocument = await createTempDocument(); + let regressionResult = null; + + try { + regressionResult = await runBrowserRegression(tempDocument); + console.log( + JSON.stringify( + { + ok: true, + workspaceId: tempDocument.workspaceId, + documentId: tempDocument.documentId, + ...regressionResult, + }, + null, + 2, + ), + ); + } finally { + await purgeTempDocument(tempDocument.documentId); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task021-mindmap-ui-regression.js b/scripts/task021-mindmap-ui-regression.js new file mode 100644 index 00000000..e13599a0 --- /dev/null +++ b/scripts/task021-mindmap-ui-regression.js @@ -0,0 +1,373 @@ +"use strict"; + +// 说明: +// - 这是 task-021 的最小真实浏览器回归脚本。 +// - 目标覆盖 Mindmap 全屏页、节点新增/删除、保存链与 requestId/traceId 元信息同步。 +// - 脚本会创建临时页面和临时导图,回归结束后清理,避免污染现有数据。 + +const { chromium } = require("playwright"); + +const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001"; +const REQUEST_TIMEOUT_MS = 20_000; +const UI_TIMEOUT_MS = 30_000; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function requestJson(path, init = {}) { + const response = await fetch(`${BASE_URL}${path}`, { + ...init, + headers: { + "content-type": "application/json", + ...(init.headers || {}), + }, + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + + if (!response.ok) { + throw new Error( + `${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, + ); + } + + return payload; +} + +async function createTempDocument() { + const payload = await requestJson("/api/documents/create", { + method: "POST", + body: JSON.stringify({ parentId: null }), + }); + + assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); + assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); + + return { + documentId: payload.id, + workspaceId: payload.workspace_id, + }; +} + +async function createTempMindmap(documentId, mindmapId) { + await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, { + method: "POST", + body: JSON.stringify({ + createOnly: true, + data: { + data: { text: "中心主题" }, + children: [], + }, + }), + }); +} + +async function cleanupTempMindmap(documentId, mindmapId) { + try { + await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, { + method: "DELETE", + }); + } catch { + // 忽略清理失败,继续尝试 purge 文档。 + } +} + +async function purgeTempDocument(documentId) { + await requestJson("/api/documents/purge", { + method: "POST", + body: JSON.stringify({ documentId }), + }); +} + +async function waitForMindmapInstance(page, mindmapId) { + await page.waitForFunction( + (id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance), + mindmapId, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readMindmapMetaAttrs(page) { + const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]"); + return { + documentId: await fullscreen.getAttribute("data-document-id"), + pageId: await fullscreen.getAttribute("data-page-id"), + attachmentId: await fullscreen.getAttribute("data-attachment-id"), + mindmapId: await fullscreen.getAttribute("data-mindmap-id"), + workspaceId: await fullscreen.getAttribute("data-workspace-id"), + requestId: await fullscreen.getAttribute("data-request-id"), + traceId: await fullscreen.getAttribute("data-trace-id"), + }; +} + +async function waitForMetaAttrs(page, meta) { + await page.waitForFunction( + ({ requestId, traceId }) => { + const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); + if (!el) return false; + return ( + el.getAttribute("data-request-id") === requestId && + el.getAttribute("data-trace-id") === traceId + ); + }, + { + requestId: meta.requestId, + traceId: meta.traceId, + }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +function assertRouteMeta(meta, expected) { + assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId"); + assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId"); + assert(meta.documentId === expected.documentId, `documentId 不一致:${meta.documentId}`); + assert(meta.pageId === expected.documentId, `pageId 不一致:${meta.pageId}`); + assert(meta.mindmapId === expected.mindmapId, `mindmapId 不一致:${meta.mindmapId}`); + assert(meta.attachmentId === expected.mindmapId, `attachmentId 不一致:${meta.attachmentId}`); + assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`); +} + +async function persistInsertAndRename(page, mindmapId, childText) { + return page.evaluate( + ({ currentMindmapId, nextChildText }) => { + const instance = + window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; + if (!instance) { + throw new Error("未找到 mindmap 实例"); + } + + const renderer = instance.renderer; + const root = renderer?.root ?? renderer?.renderTree?._node; + if (!root) { + throw new Error("未找到根节点"); + } + + renderer?.clearActiveNodeList?.(); + renderer?.addNodeToActiveList?.(root, true); + renderer.lastActiveNodeList = [root]; + renderer?.emitNodeActiveEvent?.(root); + instance.execCommand?.("SET_NODE_ACTIVE", root, true); + instance.execCommand?.("INSERT_CHILD_NODE", false, [root]); + + const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; + if (!snapshot?.root?.children?.[0]?.data) { + throw new Error("插入子节点后未拿到快照"); + } + + snapshot.root.children[0].data.text = nextChildText; + window.__mindmapPersistById?.[currentMindmapId]?.(snapshot); + + return { + childText: snapshot.root.children[0].data.text, + childCount: snapshot.root.children.length, + }; + }, + { + currentMindmapId: mindmapId, + nextChildText: childText, + }, + ); +} + +async function persistDeleteChild(page, mindmapId) { + return page.evaluate((currentMindmapId) => { + const instance = + window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; + if (!instance) { + throw new Error("未找到 mindmap 实例"); + } + + const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; + if (!snapshot?.root) { + throw new Error("删除子节点时未拿到快照"); + } + + snapshot.root.children = []; + window.__mindmapPersistById?.[currentMindmapId]?.(snapshot); + + return { + childCount: snapshot.root.children.length, + }; + }, mindmapId); +} + +async function openOutlinePanel(page) { + const outlineButton = page.getByRole("button", { name: "大纲" }); + await outlineButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await outlineButton.click(); +} + +async function runBrowserRegression(target) { + const uniqueSuffix = Date.now().toString(); + const mindmapId = `task021-${uniqueSuffix}`; + const childText = `task021-节点-${uniqueSuffix}`; + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + + try { + await createTempMindmap(target.documentId, mindmapId); + + const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`; + await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + + const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]"); + const canvas = page.locator("[data-testid=\"mindmap-canvas\"]"); + const rootText = page.getByText("中心主题").first(); + + await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await waitForMindmapInstance(page, mindmapId); + + const initialMetaAttrs = await readMindmapMetaAttrs(page); + assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确"); + assert(initialMetaAttrs.pageId === target.documentId, "页面 data-page-id 不正确"); + assert(initialMetaAttrs.mindmapId === mindmapId, "页面 data-mindmap-id 不正确"); + assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确"); + assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确"); + + const insertSaveResponsePromise = page.waitForResponse( + (response) => + response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) && + response.request().method() === "POST" && + response.status() === 200 && + (response.request().postData() || "").includes(childText), + { timeout: UI_TIMEOUT_MS }, + ); + + const insertMutation = await persistInsertAndRename(page, mindmapId, childText); + assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`); + assert(insertMutation.childText === childText, `插入子节点名称异常:${insertMutation.childText}`); + + const insertSaveResponse = await insertSaveResponsePromise; + const insertSavePayload = await insertSaveResponse.json(); + assertRouteMeta(insertSavePayload.meta, { + documentId: target.documentId, + mindmapId, + workspaceId: target.workspaceId, + }); + await waitForMetaAttrs(page, insertSavePayload.meta); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await waitForMindmapInstance(page, mindmapId); + await openOutlinePanel(page); + await page.getByRole("button", { name: new RegExp(childText) }).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + + const insertSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`); + assert( + Array.isArray(insertSavedMindmap.data?.children) && + insertSavedMindmap.data.children.length === 1, + "刷新后导图子节点数量不正确", + ); + assert( + insertSavedMindmap.data.children[0]?.data?.text === childText, + `刷新后导图子节点名称不正确:${insertSavedMindmap.data.children[0]?.data?.text}`, + ); + + const deleteSaveResponsePromise = page.waitForResponse( + (response) => + response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) && + response.request().method() === "POST" && + response.status() === 200 && + !(response.request().postData() || "").includes(childText), + { timeout: UI_TIMEOUT_MS }, + ); + + const deleteMutation = await persistDeleteChild(page, mindmapId); + assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`); + + const deleteSaveResponse = await deleteSaveResponsePromise; + const deleteSavePayload = await deleteSaveResponse.json(); + assertRouteMeta(deleteSavePayload.meta, { + documentId: target.documentId, + mindmapId, + workspaceId: target.workspaceId, + }); + await waitForMetaAttrs(page, deleteSavePayload.meta); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await waitForMindmapInstance(page, mindmapId); + await openOutlinePanel(page); + + const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS }); + assert(bodyText.includes("中心主题"), "刷新后未渲染根节点"); + assert(!bodyText.includes(childText), "删除子节点后页面仍残留旧节点文本"); + + const deleteSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`); + assert( + Array.isArray(deleteSavedMindmap.data?.children) && + deleteSavedMindmap.data.children.length === 0, + "删除子节点后后端仍保留子节点", + ); + + return { + mindmapUrl, + mindmapId, + childText, + initialMetaAttrs, + insertMeta: insertSavePayload.meta, + deleteMeta: deleteSavePayload.meta, + }; + } finally { + await page.close(); + await browser.close(); + await cleanupTempMindmap(target.documentId, mindmapId); + } +} + +async function main() { + const health = await fetch(`${BASE_URL}/`, { + method: "HEAD", + redirect: "manual", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + assert( + [200, 307, 308].includes(health.status), + `首页探活失败:收到状态码 ${health.status}`, + ); + + const tempDocument = await createTempDocument(); + let regressionResult = null; + + try { + regressionResult = await runBrowserRegression(tempDocument); + console.log( + JSON.stringify( + { + ok: true, + workspaceId: tempDocument.workspaceId, + documentId: tempDocument.documentId, + ...regressionResult, + }, + null, + 2, + ), + ); + } finally { + await purgeTempDocument(tempDocument.documentId); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task022-onlyoffice-ui-regression.js b/scripts/task022-onlyoffice-ui-regression.js new file mode 100644 index 00000000..7cc4bbc5 --- /dev/null +++ b/scripts/task022-onlyoffice-ui-regression.js @@ -0,0 +1,414 @@ +"use strict"; + +// 说明: +// - 这是 task-022 的最小真实浏览器回归脚本。 +// - 目标覆盖 OnlyOffice 页面打开、插件桥接插入文本、forcesave 按钮、callback 写回闭环。 +// - 脚本会创建临时页面并上传临时 docx,回归结束后 purge 页面,避免污染现有数据。 + +const fs = require("node:fs"); +const { chromium } = require("playwright"); + +const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001"; +const REQUEST_TIMEOUT_MS = 20_000; +const UI_TIMEOUT_MS = 120_000; +const CALLBACK_TIMEOUT_MS = 90_000; +const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || "/tmp/mnote-onlyoffice-probe/probe.docx"; +const ONLYOFFICE_PLUGIN_CHANNEL = "mnote_onlyoffice_agent_tools_v1"; +const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; +const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function requestPayload(requestContext, path, init = {}) { + const headers = + init.multipart || init.form + ? { ...(init.headers || {}) } + : init.data !== undefined + ? { + "content-type": "application/json", + ...(init.headers || {}), + } + : { ...(init.headers || {}) }; + + const response = await requestContext.fetch(`${BASE_URL}${path}`, { + ...init, + headers, + timeout: REQUEST_TIMEOUT_MS, + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + + if (!response.ok()) { + throw new Error( + `${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, + ); + } + + return payload; +} + +async function createTempDocument(requestContext) { + const payload = await requestPayload(requestContext, "/api/documents/create", { + method: "POST", + data: { parentId: null }, + }); + + assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); + assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); + + return { + documentId: payload.id, + workspaceId: payload.workspace_id, + }; +} + +async function purgeTempDocument(requestContext, documentId) { + await requestPayload(requestContext, "/api/documents/purge", { + method: "POST", + data: { documentId }, + }); +} + +async function getViewerIdentity(requestContext) { + const payload = await requestPayload(requestContext, "/api/auth/whoami", { method: "GET" }); + assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId"); + return payload; +} + +async function uploadProbeDocx(requestContext, target) { + assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`); + const buffer = fs.readFileSync(PROBE_DOCX_PATH); + const payload = await requestPayload(requestContext, "/api/media/upload", { + method: "POST", + multipart: { + file: { + name: "task022-probe.docx", + mimeType: DOCX_MIME, + buffer, + }, + workspaceId: target.workspaceId, + documentId: target.documentId, + }, + }); + + assert(payload && payload.asset && typeof payload.asset.id === "string", "上传探测 docx 失败:缺少 asset.id"); + return payload.asset; +} + +async function getSignedAsset(requestContext, assetId) { + const payload = await requestPayload(requestContext, `/api/media/sign?assetId=${encodeURIComponent(assetId)}`, { + method: "GET", + }); + assert(payload && typeof payload.signedUrl === "string" && payload.signedUrl, "缺少 signedUrl"); + return payload; +} + +async function ensureAuthenticated(page, requestContext) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS }); + + if (page.url().includes("/auth")) { + const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); + await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => !url.toString().includes("/auth"), { + timeout: UI_TIMEOUT_MS, + }); + } + + return await getViewerIdentity(requestContext); +} + +async function installPluginBridge(page) { + await page.addInitScript( + ({ channel }) => { + const state = { + channel, + ready: false, + origin: "*", + target: null, + pending: new Map(), + }; + + window.__TASK022_ONLYOFFICE_PLUGIN__ = state; + window.addEventListener("message", (event) => { + const data = event?.data; + if (!data || typeof data !== "object") return; + if (data.channel !== channel) return; + + if (data.type === "ready") { + state.ready = true; + state.origin = String(event.origin || "*"); + state.target = + event.source && typeof event.source.postMessage === "function" ? event.source : null; + return; + } + + if (data.type === "result") { + const callId = String(data.callId || "").trim(); + if (!callId) return; + const pending = state.pending.get(callId); + if (!pending) return; + state.pending.delete(callId); + window.clearTimeout(pending.timeoutId); + if (data.ok) { + pending.resolve(data.result ?? null); + } else { + pending.reject(new Error(String(data.error || "插件执行失败"))); + } + } + }); + }, + { channel: ONLYOFFICE_PLUGIN_CHANNEL }, + ); +} + +async function waitForOnlyOfficeReady(page) { + await page.waitForFunction(() => window.__MNOTE_ONLYOFFICE_READY__ === true, { + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction( + () => { + const root = document.getElementById("onlyoffice-frame"); + if (root && root.querySelector("iframe,canvas")) return true; + return Boolean(document.querySelector("iframe,canvas")); + }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +function getEditorIframe(page) { + return page.locator('iframe[src*="/documenteditor/main/index.html"]').first(); +} + +async function waitForPluginReady(page) { + await page.waitForFunction( + () => Boolean(window.__TASK022_ONLYOFFICE_PLUGIN__?.ready && window.__TASK022_ONLYOFFICE_PLUGIN__?.target), + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function callOnlyOfficePlugin(page, tool, args) { + return page.evaluate( + async ({ channel, toolName, toolArgs }) => { + const state = window.__TASK022_ONLYOFFICE_PLUGIN__; + if (!state || !state.ready || !state.target) { + throw new Error("OnlyOffice 插件桥未就绪"); + } + + const callId = `task022-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return await new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + state.pending.delete(callId); + reject(new Error(`插件调用超时: ${toolName}`)); + }, 60_000); + + state.pending.set(callId, { resolve, reject, timeoutId }); + state.target.postMessage( + { + channel, + type: "call", + callId, + tool: toolName, + args: toolArgs, + }, + state.origin || "*", + ); + }); + }, + { + channel: ONLYOFFICE_PLUGIN_CHANNEL, + toolName: tool, + toolArgs: args, + }, + ); +} + +async function getOnlyOfficeDebug(page) { + return page.evaluate(() => ({ + ready: Boolean(window.__MNOTE_ONLYOFFICE_READY__), + debug: window.__MNOTE_ONLYOFFICE_DEBUG__ ?? null, + errlog: window.__MNOTE_ONLYOFFICE_ERRLOG__ ?? [], + })); +} + +async function waitForStorageIdChange(requestContext, assetId, previousStorageId) { + const startedAt = Date.now(); + while (Date.now() - startedAt < CALLBACK_TIMEOUT_MS) { + const payload = await getSignedAsset(requestContext, assetId); + const nextStorageId = String(payload.asset?.storage_id || "").trim(); + if (nextStorageId && nextStorageId !== previousStorageId) { + return payload; + } + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + throw new Error(`等待 callback 写回超时:storage_id 仍为 ${previousStorageId || ""}`); +} + +async function runBrowserRegression(page, requestContext, viewer, target) { + const asset = await uploadProbeDocx(requestContext, target); + const initialSigned = await getSignedAsset(requestContext, asset.id); + const initialStorageId = String(initialSigned.asset?.storage_id || "").trim(); + assert(initialStorageId, "初始 storage_id 为空"); + + const uniqueSuffix = Date.now().toString(); + const insertedText = ` task022-onlyoffice-${uniqueSuffix} `; + + await installPluginBridge(page); + + try { + const pageUrl = new URL("/onlyoffice", BASE_URL); + pageUrl.searchParams.set("fileUrl", String(initialSigned.signedUrl)); + pageUrl.searchParams.set("fileName", "task022-probe.docx"); + pageUrl.searchParams.set("fileType", "docx"); + pageUrl.searchParams.set("mode", "edit"); + pageUrl.searchParams.set("assetId", asset.id); + pageUrl.searchParams.set("documentId", target.documentId); + pageUrl.searchParams.set("userId", viewer.userId); + pageUrl.searchParams.set("channel", "web"); + + await page.goto(pageUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.getByText("ONLYOFFICE 加载失败").waitFor({ state: "hidden", timeout: 5_000 }).catch(() => null); + + await waitForOnlyOfficeReady(page); + await waitForPluginReady(page); + + const editorIframe = getEditorIframe(page); + await editorIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await editorIframe.click({ position: { x: 160, y: 120 }, timeout: UI_TIMEOUT_MS }); + + const initialDebug = await getOnlyOfficeDebug(page); + assert(initialDebug.ready === true, "OnlyOffice ready 标记未就绪"); + assert(initialDebug.debug && initialDebug.debug.assetId === asset.id, "OnlyOffice debug.assetId 不正确"); + assert(initialDebug.debug && initialDebug.debug.documentId === target.documentId, "OnlyOffice debug.documentId 不正确"); + assert(initialDebug.debug && initialDebug.debug.baseUrl === "/onlyoffice-server", `OnlyOffice baseUrl 异常:${JSON.stringify(initialDebug.debug)}`); + assert( + initialDebug.debug && typeof initialDebug.debug.resolvedFileUrl === "string" && initialDebug.debug.resolvedFileUrl.includes("/api/onlyoffice/proxy"), + `OnlyOffice resolvedFileUrl 未走 proxy:${JSON.stringify(initialDebug.debug)}`, + ); + assert(initialDebug.debug && typeof initialDebug.debug.docKey === "string" && initialDebug.debug.docKey, "OnlyOffice debug.docKey 为空"); + + const pluginResult = await callOnlyOfficePlugin(page, "oo_insert_text", { text: insertedText }); + assert(pluginResult && pluginResult.ok === true, `插件插入文本失败:${JSON.stringify(pluginResult)}`); + + await page.waitForTimeout(2_000); + + const forceSaveResponsePromise = page.waitForResponse( + (response) => + response.url().includes(`/api/onlyoffice/forcesave?assetId=${encodeURIComponent(asset.id)}`) && + response.request().method() === "POST" && + response.status() === 200, + { timeout: UI_TIMEOUT_MS }, + ); + + const forceSaveButton = page.getByRole("button", { name: "同步保存" }); + await forceSaveButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await forceSaveButton.click({ timeout: UI_TIMEOUT_MS }); + const forceSaveResponse = await forceSaveResponsePromise; + const forceSavePayload = await forceSaveResponse.json(); + assert(forceSavePayload && forceSavePayload.ok === true, `forcesave 返回异常:${JSON.stringify(forceSavePayload)}`); + + await page.getByText("已触发同步保存").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const updatedSigned = await waitForStorageIdChange(requestContext, asset.id, initialStorageId); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForOnlyOfficeReady(page); + await waitForPluginReady(page); + + const reloadDebug = await getOnlyOfficeDebug(page); + assert(reloadDebug.ready === true, "刷新后 OnlyOffice ready 标记未就绪"); + assert( + String(updatedSigned.asset?.storage_id || "").trim() !== initialStorageId, + "callback 写回后 storage_id 未发生变化", + ); + + return { + pageUrl: pageUrl.toString(), + assetId: asset.id, + initialStorageId, + updatedStorageId: String(updatedSigned.asset?.storage_id || "").trim(), + insertedText, + debug: reloadDebug.debug, + errlog: reloadDebug.errlog, + }; + } catch (error) { + const debug = await getOnlyOfficeDebug(page).catch(() => null); + if (debug) { + console.error(JSON.stringify({ onlyofficeDebug: debug }, null, 2)); + } + throw error; + } +} + +async function main() { + const health = await fetch(`${BASE_URL}/`, { + method: "HEAD", + redirect: "manual", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + assert([200, 307, 308].includes(health.status), `首页探活失败:收到状态码 ${health.status}`); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + }); + const page = await context.newPage(); + let tempDocument = null; + let caughtError = null; + + try { + const viewer = await ensureAuthenticated(page, context.request); + tempDocument = await createTempDocument(context.request); + const result = await runBrowserRegression(page, context.request, viewer, tempDocument); + console.log( + JSON.stringify( + { + ok: true, + workspaceId: tempDocument.workspaceId, + documentId: tempDocument.documentId, + ...result, + }, + null, + 2, + ), + ); + } catch (error) { + caughtError = error; + } finally { + if (tempDocument?.documentId) { + try { + await purgeTempDocument(context.request, tempDocument.documentId); + } catch (cleanupError) { + if (!caughtError) { + caughtError = cleanupError; + } else { + console.error( + `清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`, + ); + } + } + } + + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + + if (caughtError) { + throw caughtError; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/wolai-frontend/convex/_generated/api.d.ts b/wolai-frontend/convex/_generated/api.d.ts index 3572c963..97710836 100644 --- a/wolai-frontend/convex/_generated/api.d.ts +++ b/wolai-frontend/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js"; import type * as _utils_auth from "../_utils/auth.js"; +import type * as _utils_documentRecord from "../_utils/documentRecord.js"; import type * as _utils_documentTree from "../_utils/documentTree.js"; import type * as _utils_id from "../_utils/id.js"; import type * as _utils_ingestJobs from "../_utils/ingestJobs.js"; @@ -40,6 +41,7 @@ import type * as pages from "../pages.js"; import type * as ping from "../ping.js"; import type * as recents from "../recents.js"; import type * as references from "../references.js"; +import type * as sidebar from "../sidebar.js"; import type * as tables from "../tables.js"; import type * as users from "../users.js"; import type * as workspaces from "../workspaces.js"; @@ -53,6 +55,7 @@ import type { declare const fullApi: ApiFromModules<{ "_utils/attachmentExtract": typeof _utils_attachmentExtract; "_utils/auth": typeof _utils_auth; + "_utils/documentRecord": typeof _utils_documentRecord; "_utils/documentTree": typeof _utils_documentTree; "_utils/id": typeof _utils_id; "_utils/ingestJobs": typeof _utils_ingestJobs; @@ -83,6 +86,7 @@ declare const fullApi: ApiFromModules<{ ping: typeof ping; recents: typeof recents; references: typeof references; + sidebar: typeof sidebar; tables: typeof tables; users: typeof users; workspaces: typeof workspaces; diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index 7aac2204..b218f140 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -445,14 +445,32 @@ export const getContent = query({ } if (doc.user_id === userId) { - return { content: doc.content ?? null }; + return { + content: doc.content ?? null, + revision: doc.content_revision ?? 0, + conflict_detection_key: + doc.content_conflict_key ?? + `${args.id}:${doc.content_revision ?? 0}`, + }; } if (doc.access_scope === "public") { - return { content: doc.content ?? null }; + return { + content: doc.content ?? null, + revision: doc.content_revision ?? 0, + conflict_detection_key: + doc.content_conflict_key ?? + `${args.id}:${doc.content_revision ?? 0}`, + }; } const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (!perm) return null; - return { content: doc.content ?? null }; + return { + content: doc.content ?? null, + revision: doc.content_revision ?? 0, + conflict_detection_key: + doc.content_conflict_key ?? + `${args.id}:${doc.content_revision ?? 0}`, + }; }, }); @@ -462,7 +480,13 @@ export const getContentForIngest = internalQuery({ const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.user_id !== args.userId) return null; - return { content: doc.content ?? null }; + return { + content: doc.content ?? null, + revision: doc.content_revision ?? 0, + conflict_detection_key: + doc.content_conflict_key ?? + `${args.id}:${doc.content_revision ?? 0}`, + }; }, }); @@ -791,6 +815,8 @@ export const create = mutation({ parent_id: args.parentId, title, content, + content_revision: 0, + content_conflict_key: `${args.id}:0`, raw_text: rawText, access_scope: args.accessScope, sort_order: sortOrder, @@ -837,7 +863,12 @@ export const create = mutation({ }); export const updateContent = mutation({ - args: { id: v.string(), content: v.any() }, + args: { + id: v.string(), + content: v.any(), + expectedRevision: v.optional(v.union(v.number(), v.null())), + conflictDetectionKey: v.optional(v.union(v.string(), v.null())), + }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); @@ -851,14 +882,48 @@ export const updateContent = mutation({ const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } + const currentRevision = doc.content_revision ?? 0; + const currentConflictDetectionKey = + doc.content_conflict_key ?? + `${args.id}:${currentRevision}`; + + if ( + typeof args.expectedRevision === "number" && + Number.isInteger(args.expectedRevision) && + args.expectedRevision >= 0 && + args.expectedRevision !== currentRevision + ) { + throw new Error("正文内容已变更,请刷新后重试"); + } + + if ( + typeof args.conflictDetectionKey === "string" && + args.conflictDetectionKey.trim() && + args.conflictDetectionKey.trim() !== currentConflictDetectionKey + ) { + throw new Error("正文冲突检测失败,请刷新后重试"); + } const ts = nowIso(); const rawText = extractTextFromDocumentContent(args.content); - await ctx.db.patch(doc._id, { content: args.content, raw_text: rawText, updated_at: ts }); + const nextRevision = currentRevision + 1; + const nextConflictDetectionKey = `${args.id}:${nextRevision}`; + await ctx.db.patch(doc._id, { + content: args.content, + content_revision: nextRevision, + content_conflict_key: nextConflictDetectionKey, + raw_text: rawText, + updated_at: ts, + }); // 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。 // 采用 debounce,避免频繁保存时触发过多任务。 await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 }); - return { ok: true, updated_at: ts }; + return { + ok: true, + updated_at: ts, + revision: nextRevision, + conflict_detection_key: nextConflictDetectionKey, + }; }, }); diff --git a/wolai-frontend/convex/mindmaps.ts b/wolai-frontend/convex/mindmaps.ts index 10f19752..8471c360 100644 --- a/wolai-frontend/convex/mindmaps.ts +++ b/wolai-frontend/convex/mindmaps.ts @@ -138,7 +138,15 @@ export const put = mutation({ const payload = args.data ?? defaultMindmapData; if (existing && existing.deleted_at == null && args.createOnly) { - return { ok: true, created: false, skipped: true, updated_at: existing.updated_at ?? null }; + return { + ok: true, + created: false, + skipped: true, + workspace_id: doc.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + updated_at: existing.updated_at ?? null, + }; } if (existing) { @@ -149,7 +157,15 @@ export const put = mutation({ deleted_by: null, }); await enqueueIngestMindmapJob(ctx, { userId, docId: args.docId, mindmapId, debounceMs: 1500 }); - return { ok: true, created: false, skipped: false, updated_at: ts }; + return { + ok: true, + created: false, + skipped: false, + workspace_id: doc.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + updated_at: ts, + }; } await ctx.db.insert("mindmaps", { @@ -166,7 +182,15 @@ export const put = mutation({ }); await enqueueIngestMindmapJob(ctx, { userId, docId: args.docId, mindmapId, debounceMs: 1500 }); - return { ok: true, created: true, skipped: false, updated_at: ts }; + return { + ok: true, + created: true, + skipped: false, + workspace_id: doc.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + updated_at: ts, + }; }, }); @@ -185,12 +209,26 @@ export const softDelete = mutation({ if (!existing) { // 兼容:不存在也视为成功 - return { ok: true, moved: 0 }; + return { + ok: true, + moved: 0, + workspace_id: null, + document_id: args.docId, + mindmap_id: mindmapId, + deleted_at: null, + }; } if (existing.deleted_at != null) { // 已在垃圾桶:保持幂等,避免重复删除导致 updated_at 抖动/重复调度 - return { ok: true, moved: 0, deleted_at: existing.deleted_at }; + return { + ok: true, + moved: 0, + workspace_id: existing.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + deleted_at: existing.deleted_at, + }; } const ts = nowIso(); @@ -204,7 +242,14 @@ export const softDelete = mutation({ mindmapId, deletedAt: ts, }); - return { ok: true, moved: 1, deleted_at: ts }; + return { + ok: true, + moved: 1, + workspace_id: existing.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + deleted_at: ts, + }; }, }); @@ -272,7 +317,13 @@ export const restore = mutation({ const ts = nowIso(); await ctx.db.patch(existing._id, { deleted_at: null, deleted_by: null, updated_at: ts }); - return { ok: true }; + return { + ok: true, + workspace_id: existing.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + updated_at: ts, + }; }, }); @@ -294,7 +345,12 @@ export const purge = mutation({ } await ctx.db.delete(existing._id); - return { ok: true }; + return { + ok: true, + workspace_id: existing.workspace_id, + document_id: args.docId, + mindmap_id: mindmapId, + }; }, }); diff --git a/wolai-frontend/convex/schema.ts b/wolai-frontend/convex/schema.ts index 7ba5793f..6259c10f 100644 --- a/wolai-frontend/convex/schema.ts +++ b/wolai-frontend/convex/schema.ts @@ -68,6 +68,8 @@ export default defineSchema({ // 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。 content: v.any(), + content_revision: v.optional(v.number()), + content_conflict_key: v.optional(v.union(v.string(), v.null())), // 说明:后续可用于搜索/索引(目前先留空,不强制写入)。 raw_text: v.optional(v.union(v.string(), v.null())), diff --git a/wolai-frontend/convex/sidebar.ts b/wolai-frontend/convex/sidebar.ts new file mode 100644 index 00000000..82fd7874 --- /dev/null +++ b/wolai-frontend/convex/sidebar.ts @@ -0,0 +1,223 @@ +import { v } from "convex/values"; +import { query } from "./_generated/server"; +import { api } from "./_generated/api"; +import { requireUserId } from "./_utils/auth"; + +type MindmapRow = { + mindmap_id: string; + workspace_id?: string | null; + document_id: string; + data?: unknown; + created_at?: string | null; + updated_at?: string | null; + deleted_at?: string | null; + deleted_by?: string | null; +}; + +type TableRow = { + id: string; + workspace_id?: string | null; + document_id: string; + title?: string | null; + created_at?: string | null; + updated_at?: string | null; + deleted_at?: string | null; + deleted_by?: string | null; + purged_at?: string | null; + is_archived?: boolean | null; +}; + +function normalizeStringArray(values: Iterable): string[] { + return Array.from(new Set(values)).filter((value) => value.trim().length > 0); +} + +function extractMindmapImageAssetIdsFromData(input: unknown): string[] { + const root = (() => { + if (!input || typeof input !== "object") return input; + const record = input as Record; + return record && typeof record === "object" && "root" in record ? record.root : input; + })(); + + const ids: string[] = []; + const seen = new Set(); + + const push = (value: unknown) => { + if (typeof value !== "string") return; + if (!value.startsWith("asset:")) return; + const id = value.slice("asset:".length).trim(); + if (!id || seen.has(id)) return; + seen.add(id); + ids.push(id); + }; + + const get = (obj: unknown, key: string): unknown => { + if (!obj || typeof obj !== "object") return undefined; + return (obj as Record)[key]; + }; + + const walk = (node: unknown) => { + if (!node || typeof node !== "object") return; + + const data = get(node, "data"); + const image = get(node, "image"); + + push(get(data, "image")); + push(image); + push(get(image, "url")); + push(get(get(data, "image"), "url")); + + const children = get(node, "children"); + if (Array.isArray(children)) { + children.forEach(walk); + } + }; + + walk(root); + return ids; +} + +function toMindmapAsset(row: MindmapRow, workspaceId: string) { + const isLegacy = row.mindmap_id.startsWith("legacy-"); + return { + id: row.mindmap_id, + workspace_id: row.workspace_id ?? workspaceId, + document_id: row.document_id, + asset_type: "mindmap", + file_url: null, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: isLegacy ? "mindmap.json" : `mindmap-${row.mindmap_id}.json`, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: row.created_at ?? "", + updated_at: row.updated_at ?? "", + }; +} + +function toTrashedMindmapAsset(row: MindmapRow, workspaceId: string) { + return { + ...toMindmapAsset(row, workspaceId), + deleted_at: row.deleted_at ?? null, + deleted_by: row.deleted_by ?? null, + purged_at: null, + }; +} + +function toTableAsset(row: TableRow, workspaceId: string) { + const base = String(row.title ?? "未命名表格").trim() || "未命名表格"; + const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`; + return { + id: row.id, + workspace_id: row.workspace_id ?? workspaceId, + document_id: row.document_id, + asset_type: "luckysheet", + file_url: null, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: fileName, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: row.created_at ?? "", + updated_at: row.updated_at ?? "", + }; +} + +function toTrashedTableAsset(row: TableRow, workspaceId: string) { + return { + ...toTableAsset(row, workspaceId), + deleted_at: row.deleted_at ?? row.updated_at ?? null, + deleted_by: row.deleted_by ?? null, + purged_at: row.purged_at ?? null, + }; +} + +export const datasetList = query({ + args: { + workspaceId: v.string(), + }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + + // 说明:这条查询作为 Sidebar 的单一数据集入口,先复用现有稳定 query, + // 把前端原先“多 query + 多处拼装”收口成一条主查询契约。 + const [ + workspacesResult, + documents, + trashedDocuments, + mindmaps, + mediaAssets, + trashedMediaAssets, + tables, + ] = await Promise.all([ + ctx.runQuery(api.workspaces.fetchWorkspaceSummaries, {}), + ctx.runQuery(api.documents.listByWorkspace, { + workspaceId: args.workspaceId, + }), + ctx.runQuery(api.documents.listTrashedByWorkspace, { + workspaceId: args.workspaceId, + }), + ctx.runQuery(api.mindmaps.listByWorkspace, { + workspaceId: args.workspaceId, + includeDeleted: true, + }), + ctx.runQuery(api.mediaAssets.listByWorkspace, { + userId, + workspaceId: args.workspaceId, + limit: 200, + }), + ctx.runQuery(api.mediaAssets.listDeletedByWorkspace, { + userId, + workspaceId: args.workspaceId, + limit: 2000, + }), + ctx.runQuery(api.tables.listByWorkspaceForSearch, { + userId, + workspaceId: args.workspaceId, + includeArchived: true, + limit: 3000, + }), + ]); + + const activeMindmaps = (mindmaps as MindmapRow[]).filter((row) => !row.deleted_at); + const trashedMindmaps = (mindmaps as MindmapRow[]).filter((row) => Boolean(row.deleted_at)); + const activeTables = (tables as TableRow[]).filter((row) => !row.is_archived); + const trashedTables = (tables as TableRow[]).filter((row) => Boolean(row.is_archived)); + + const mindmapAssetChildren: Record = {}; + activeMindmaps.forEach((row) => { + const ids = extractMindmapImageAssetIdsFromData(row.data); + if (ids.length > 0) { + mindmapAssetChildren[row.mindmap_id] = ids; + } + }); + + return { + active_workspace_id: args.workspaceId, + workspaces: workspacesResult.workspaces, + documents, + trashed_documents: trashedDocuments, + media_assets: mediaAssets ?? [], + trashed_media_assets: trashedMediaAssets ?? [], + mindmap_assets: activeMindmaps.map((row) => toMindmapAsset(row, args.workspaceId)), + trashed_mindmap_assets: trashedMindmaps.map((row) => + toTrashedMindmapAsset(row, args.workspaceId), + ), + table_assets: activeTables.map((row) => toTableAsset(row, args.workspaceId)), + trashed_table_assets: trashedTables.map((row) => toTrashedTableAsset(row, args.workspaceId)), + mindmap_docs: normalizeStringArray(activeMindmaps.map((row) => row.document_id)), + mindmap_asset_children: mindmapAssetChildren, + }; + }, +}); diff --git a/wolai-frontend/public/mnote-env.json b/wolai-frontend/public/mnote-env.json index 70f6940b..c50ab3c0 100644 --- a/wolai-frontend/public/mnote-env.json +++ b/wolai-frontend/public/mnote-env.json @@ -5,10 +5,11 @@ "supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y", "backendUrl": "https://frp-dry.com:44399", "onlyofficeBaseUrlWeb": "/onlyoffice-server", - "onlyofficeBaseUrlDesktop": "http://localhost:8081", + "onlyofficeBaseUrlDesktop": "http://localhost:8082", + "onlyofficeProxyOrigin": "http://host.docker.internal:3001", "onlyofficeStorageHostOverrideWeb": "", "onlyofficeStorageHostOverrideDesktop": "", - "onlyofficeProxyOriginWeb": "http://172.31.224.1:3000", - "onlyofficeCallbackOriginWeb": "http://172.31.224.1:3000", - "onlyofficeCallbackOriginDesktop": "http://127.0.0.1:3000" + "onlyofficeProxyOriginWeb": "http://host.docker.internal:3001", + "onlyofficeCallbackOriginWeb": "http://host.docker.internal:3001", + "onlyofficeCallbackOriginDesktop": "http://host.docker.internal:3001" } diff --git a/wolai-frontend/public/onlyoffice/plugins/agent-tools/config.json b/wolai-frontend/public/onlyoffice/plugins/agent-tools/config.json index 9d5b7024..34f75718 100644 --- a/wolai-frontend/public/onlyoffice/plugins/agent-tools/config.json +++ b/wolai-frontend/public/onlyoffice/plugins/agent-tools/config.json @@ -10,8 +10,12 @@ "icons": ["icon.svg"], "isViewer": true, "EditorsSupport": ["word", "cell", "slide", "pdf"], - "isVisual": false + "isVisual": false, + "isModal": false, + "isInsideMode": false, + "initDataType": "none", + "initData": "", + "buttons": [] } ] } - diff --git a/wolai-frontend/public/onlyoffice/plugins/agent-tools/index.html b/wolai-frontend/public/onlyoffice/plugins/agent-tools/index.html index aa10e82e..41794c9c 100644 --- a/wolai-frontend/public/onlyoffice/plugins/agent-tools/index.html +++ b/wolai-frontend/public/onlyoffice/plugins/agent-tools/index.html @@ -6,7 +6,7 @@ MNOTE OnlyOffice Agent Tools + - diff --git a/wolai-frontend/public/onlyoffice/plugins/agent-tools/plugin.js b/wolai-frontend/public/onlyoffice/plugins/agent-tools/plugin.js index 6248a9f7..b9816230 100644 --- a/wolai-frontend/public/onlyoffice/plugins/agent-tools/plugin.js +++ b/wolai-frontend/public/onlyoffice/plugins/agent-tools/plugin.js @@ -2,6 +2,8 @@ // 宿主页面通过 postMessage 下发 oo_* 工具调用;插件执行后再 postMessage 回传结果。 (function () { const CHANNEL = "mnote_onlyoffice_agent_tools_v1"; + let readyPulseTimer = null; + let readyPulseDeadline = 0; const safePostToTop = (payload) => { try { @@ -13,6 +15,50 @@ } }; + const isPluginApiReady = () => + Boolean(window.Asc && window.Asc.plugin && typeof window.Asc.plugin.executeMethod === "function"); + + const stopReadyPulse = () => { + if (readyPulseTimer) { + window.clearInterval(readyPulseTimer); + readyPulseTimer = null; + } + readyPulseDeadline = 0; + }; + + const startReadyPulse = () => { + if (readyPulseTimer) return; + readyPulseDeadline = Date.now() + 20_000; + safePostToTop({ type: "ready" }); + readyPulseTimer = window.setInterval(() => { + if (Date.now() > readyPulseDeadline) { + stopReadyPulse(); + return; + } + safePostToTop({ type: "ready" }); + }, 1000); + }; + + const waitForPluginApiReady = () => { + if (isPluginApiReady()) { + startReadyPulse(); + return; + } + + const deadline = Date.now() + 60_000; + const timer = window.setInterval(() => { + if (isPluginApiReady()) { + window.clearInterval(timer); + startReadyPulse(); + return; + } + if (Date.now() > deadline) { + window.clearInterval(timer); + safePostToTop({ type: "result", callId: "bridge_bootstrap", ok: false, error: "ONLYOFFICE 插件 API 长时间未就绪" }); + } + }, 500); + }; + const execMethod = (method, args) => new Promise((resolve, reject) => { try { @@ -74,15 +120,19 @@ window.Asc.plugin = window.Asc.plugin || {}; window.Asc.plugin.init = function () { - safePostToTop({ type: "ready" }); + startReadyPulse(); }; + waitForPluginApiReady(); + window.addEventListener("message", async (ev) => { const msg = ev && ev.data ? ev.data : null; if (!msg || typeof msg !== "object") return; if (msg.channel !== CHANNEL) return; if (msg.type !== "call") return; + stopReadyPulse(); + const callId = String(msg.callId ?? "").trim(); const tool = String(msg.tool ?? "").trim(); const args = msg.args && typeof msg.args === "object" ? msg.args : {}; @@ -97,4 +147,3 @@ } }); })(); - diff --git a/wolai-frontend/scripts/dev-server.js b/wolai-frontend/scripts/dev-server.js index 7ce4cb15..f32b8951 100644 --- a/wolai-frontend/scripts/dev-server.js +++ b/wolai-frontend/scripts/dev-server.js @@ -4,13 +4,13 @@ * - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。 * - Next App Router 的 Route Handler 无法处理 Upgrade,因此必须在 Node http server 层做透传。 * - * 用法(保持与 next dev 类似): - * - pnpm dev -p 3000 - * - node scripts/dev-server.js -p 3000 - * - * 依赖环境变量: - * - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081 - */ + * 用法(保持与 next dev 类似): + * - pnpm dev -p 3000 + * - node scripts/dev-server.js -p 3000 + * + * 依赖环境变量: + * - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时会自动探测 8081/8082 + */ const http = require("http"); const net = require("net"); @@ -18,8 +18,15 @@ const path = require("path"); const next = require("next"); const { parse: parseUrl } = require("url"); -const ONLYOFFICE_PREFIX = "/onlyoffice-server"; -const CONVEX_PREFIX = "/convex"; +const ONLYOFFICE_PREFIX = "/onlyoffice-server"; +const CONVEX_PREFIX = "/convex"; +const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081"; +const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js"; +const ONLYOFFICE_RESOLVE_CACHE_TTL_MS = 30_000; + +let cachedOnlyOfficeInternalUrl = ""; +let cachedOnlyOfficeInternalUrlAt = 0; +let pendingOnlyOfficeInternalUrl = null; function readArgValue(flag) { const idx = process.argv.findIndex((x) => x === flag); @@ -49,14 +56,92 @@ function isOnlyOfficePath(urlString) { } } -function isConvexPath(urlString) { - try { - const u = new URL(urlString, "http://localhost"); - return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`); - } catch { +function isConvexPath(urlString) { + try { + const u = new URL(urlString, "http://localhost"); + return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`); + } catch { return false; - } -} + } +} + +function normalizeOnlyOfficeInternalUrl(raw) { + const value = String(raw || "").trim().replace(/\/+$/, ""); + if (!value) return ""; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + return url.toString().replace(/\/+$/, ""); + } catch { + return ""; + } +} + +function listOnlyOfficeInternalUrlCandidates() { + const candidates = []; + const push = (value) => { + const normalized = normalizeOnlyOfficeInternalUrl(value); + if (!normalized) return; + if (!candidates.includes(normalized)) candidates.push(normalized); + }; + + push(process.env.ONLYOFFICE_INTERNAL_URL); + + for (const raw of String(process.env.ONLYOFFICE_INTERNAL_URL_CANDIDATES || "").split(",")) { + push(raw); + } + + push(DEFAULT_ONLYOFFICE_INTERNAL_URL); + push("http://127.0.0.1:8082"); + push("http://localhost:8081"); + push("http://localhost:8082"); + + return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL]; +} + +async function probeOnlyOfficeInternalUrl(candidate) { + if (typeof fetch !== "function") return false; + try { + const probeUrl = new URL(ONLYOFFICE_PROBE_PATH, `${candidate}/`); + const response = await fetch(probeUrl, { + method: "HEAD", + redirect: "follow", + cache: "no-store", + signal: AbortSignal.timeout(2500), + }); + return response.ok; + } catch { + return false; + } +} + +async function resolveOnlyOfficeInternalUrl() { + const now = Date.now(); + if (cachedOnlyOfficeInternalUrl && now - cachedOnlyOfficeInternalUrlAt < ONLYOFFICE_RESOLVE_CACHE_TTL_MS) { + return new URL(`${cachedOnlyOfficeInternalUrl}/`); + } + + if (!pendingOnlyOfficeInternalUrl) { + pendingOnlyOfficeInternalUrl = (async () => { + const candidates = listOnlyOfficeInternalUrlCandidates(); + for (const candidate of candidates) { + if (await probeOnlyOfficeInternalUrl(candidate)) { + return candidate; + } + } + return candidates[0] || DEFAULT_ONLYOFFICE_INTERNAL_URL; + })(); + } + + try { + const resolved = await pendingOnlyOfficeInternalUrl; + cachedOnlyOfficeInternalUrl = resolved; + cachedOnlyOfficeInternalUrlAt = Date.now(); + return new URL(`${resolved}/`); + } finally { + pendingOnlyOfficeInternalUrl = null; + } +} function buildUpstreamRequestHead(req, targetUrl, prefix) { const incoming = new URL(req.url || "/", "http://localhost"); @@ -127,9 +212,21 @@ function buildUpstreamRequestHead(req, targetUrl, prefix) { return lines.join("\r\n"); } -function proxyOnlyOfficeUpgrade(req, socket, head) { - const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/"); - const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80); +async function proxyOnlyOfficeUpgrade(req, socket, head) { + let target; + try { + target = await resolveOnlyOfficeInternalUrl(); + } catch (err) { + try { + const msg = err && err.message ? String(err.message) : String(err || ""); + console.log("[dev-server][onlyoffice-ws] resolve error", msg); + } catch {} + try { + socket.destroy(); + } catch {} + return; + } + const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80); const upstream = net.connect({ host: target.hostname, port }, () => { try { @@ -352,13 +449,20 @@ async function main() { } }); - server.listen(port, hostname, () => { - - console.log( - `[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`, - ); - }); -} + server.listen(port, hostname, () => { + resolveOnlyOfficeInternalUrl() + .then((onlyofficeTarget) => { + console.log( + `[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${onlyofficeTarget.toString().replace(/\/$/, "")}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`, + ); + }) + .catch(() => { + console.log( + `[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || DEFAULT_ONLYOFFICE_INTERNAL_URL}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`, + ); + }); + }); +} main().catch((err) => { diff --git a/wolai-frontend/scripts/prod-server.js b/wolai-frontend/scripts/prod-server.js index 0d55d42c..b791e1b1 100644 --- a/wolai-frontend/scripts/prod-server.js +++ b/wolai-frontend/scripts/prod-server.js @@ -4,14 +4,14 @@ * - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。 * - 解决 Convex 在 HTTPS(frp/nginx)场景下浏览器不能连接 ws:// 的问题:通过同源 `/convex/*` 反代到本机 Convex。 * - * 用法: - * - pnpm build - * - pnpm start (默认会执行本脚本) - * - * 依赖环境变量: - * - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081 - * - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210 - */ + * 用法: + * - pnpm build + * - pnpm start (默认会执行本脚本) + * + * 依赖环境变量: + * - ONLYOFFICE_INTERNAL_URL:可显式指定;未指定或失效时会自动探测 8081/8082 + * - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210 + */ const http = require("http"); const net = require("net"); @@ -19,8 +19,15 @@ const path = require("path"); const next = require("next"); const { parse: parseUrl } = require("url"); -const ONLYOFFICE_PREFIX = "/onlyoffice-server"; -const CONVEX_PREFIX = "/convex"; +const ONLYOFFICE_PREFIX = "/onlyoffice-server"; +const CONVEX_PREFIX = "/convex"; +const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081"; +const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js"; +const ONLYOFFICE_RESOLVE_CACHE_TTL_MS = 30_000; + +let cachedOnlyOfficeInternalUrl = ""; +let cachedOnlyOfficeInternalUrlAt = 0; +let pendingOnlyOfficeInternalUrl = null; function readArgValue(flag) { const idx = process.argv.findIndex((x) => x === flag); @@ -50,14 +57,92 @@ function isOnlyOfficePath(urlString) { } } -function isConvexPath(urlString) { - try { - const u = new URL(urlString, "http://localhost"); - return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`); - } catch { +function isConvexPath(urlString) { + try { + const u = new URL(urlString, "http://localhost"); + return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`); + } catch { return false; - } -} + } +} + +function normalizeOnlyOfficeInternalUrl(raw) { + const value = String(raw || "").trim().replace(/\/+$/, ""); + if (!value) return ""; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + return url.toString().replace(/\/+$/, ""); + } catch { + return ""; + } +} + +function listOnlyOfficeInternalUrlCandidates() { + const candidates = []; + const push = (value) => { + const normalized = normalizeOnlyOfficeInternalUrl(value); + if (!normalized) return; + if (!candidates.includes(normalized)) candidates.push(normalized); + }; + + push(process.env.ONLYOFFICE_INTERNAL_URL); + + for (const raw of String(process.env.ONLYOFFICE_INTERNAL_URL_CANDIDATES || "").split(",")) { + push(raw); + } + + push(DEFAULT_ONLYOFFICE_INTERNAL_URL); + push("http://127.0.0.1:8082"); + push("http://localhost:8081"); + push("http://localhost:8082"); + + return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL]; +} + +async function probeOnlyOfficeInternalUrl(candidate) { + if (typeof fetch !== "function") return false; + try { + const probeUrl = new URL(ONLYOFFICE_PROBE_PATH, `${candidate}/`); + const response = await fetch(probeUrl, { + method: "HEAD", + redirect: "follow", + cache: "no-store", + signal: AbortSignal.timeout(2500), + }); + return response.ok; + } catch { + return false; + } +} + +async function resolveOnlyOfficeInternalUrl() { + const now = Date.now(); + if (cachedOnlyOfficeInternalUrl && now - cachedOnlyOfficeInternalUrlAt < ONLYOFFICE_RESOLVE_CACHE_TTL_MS) { + return new URL(`${cachedOnlyOfficeInternalUrl}/`); + } + + if (!pendingOnlyOfficeInternalUrl) { + pendingOnlyOfficeInternalUrl = (async () => { + const candidates = listOnlyOfficeInternalUrlCandidates(); + for (const candidate of candidates) { + if (await probeOnlyOfficeInternalUrl(candidate)) { + return candidate; + } + } + return candidates[0] || DEFAULT_ONLYOFFICE_INTERNAL_URL; + })(); + } + + try { + const resolved = await pendingOnlyOfficeInternalUrl; + cachedOnlyOfficeInternalUrl = resolved; + cachedOnlyOfficeInternalUrlAt = Date.now(); + return new URL(`${resolved}/`); + } finally { + pendingOnlyOfficeInternalUrl = null; + } +} function buildUpstreamRequestHead(req, targetUrl, prefix) { const incoming = new URL(req.url || "/", "http://localhost"); @@ -123,9 +208,21 @@ function buildUpstreamRequestHead(req, targetUrl, prefix) { return lines.join("\r\n"); } -function proxyOnlyOfficeUpgrade(req, socket, head) { - const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/"); - const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80); +async function proxyOnlyOfficeUpgrade(req, socket, head) { + let target; + try { + target = await resolveOnlyOfficeInternalUrl(); + } catch (err) { + try { + const msg = err && err.message ? String(err.message) : String(err || ""); + console.log("[prod-server][onlyoffice-ws] resolve error", msg); + } catch {} + try { + socket.destroy(); + } catch {} + return; + } + const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80); const upstream = net.connect({ host: target.hostname, port }, () => { try { @@ -316,12 +413,20 @@ async function main() { } }); - server.listen(port, hostname, () => { - console.log( - `[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`, - ); - }); -} + server.listen(port, hostname, () => { + resolveOnlyOfficeInternalUrl() + .then((onlyofficeTarget) => { + console.log( + `[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${onlyofficeTarget.toString().replace(/\/$/, "")}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`, + ); + }) + .catch(() => { + console.log( + `[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || DEFAULT_ONLYOFFICE_INTERNAL_URL}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`, + ); + }); + }); +} main().catch((err) => { console.error(err instanceof Error ? err.stack : String(err)); diff --git a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx index 3eb72f81..3936e707 100644 --- a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx +++ b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx @@ -93,6 +93,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag title={doc.title ?? "无标题"} updatedAt={doc.updated_at} initialContent={null} + initialContentRevision={null} + initialConflictDetectionKey={null} initialOptions={initialOptions} initialStats={initialStats} openTableId={openTableId} diff --git a/wolai-frontend/src/app/(app)/layout.tsx b/wolai-frontend/src/app/(app)/layout.tsx index 41309f6e..e073a760 100644 --- a/wolai-frontend/src/app/(app)/layout.tsx +++ b/wolai-frontend/src/app/(app)/layout.tsx @@ -17,7 +17,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) { sidebarInitialData, } = await loadSidebarDataFromConvex({ client, - userId: auth.userId, fallbackName: auth.name ?? auth.email ?? "我的空间", }); diff --git a/wolai-frontend/src/app/api/documents/content/route.ts b/wolai-frontend/src/app/api/documents/content/route.ts index 6d4f1aaf..54057e6c 100644 --- a/wolai-frontend/src/app/api/documents/content/route.ts +++ b/wolai-frontend/src/app/api/documents/content/route.ts @@ -44,6 +44,14 @@ export async function GET(request: Request) { return NextResponse.json({ content: result.content ?? null, + revision: + typeof result.revision === "number" && Number.isInteger(result.revision) + ? result.revision + : 0, + conflictDetectionKey: + typeof result.conflict_detection_key === "string" && result.conflict_detection_key.trim() + ? result.conflict_detection_key + : `${documentId}:0`, meta: { requestId: bridgeContext.requestId, traceId: bridgeContext.traceId, diff --git a/wolai-frontend/src/app/api/documents/save/route.ts b/wolai-frontend/src/app/api/documents/save/route.ts index f9afd86f..140b7719 100644 --- a/wolai-frontend/src/app/api/documents/save/route.ts +++ b/wolai-frontend/src/app/api/documents/save/route.ts @@ -1,58 +1,54 @@ import { NextResponse } from "next/server"; import { isConvexEnabled } from "@/lib/convex/enabled"; -import { getAuthedConvexClient } from "@/lib/convex/route"; -import { api } from "@/lib/convex/api"; import { assertDocumentId, buildDocumentBridgeContext, buildDocumentCommandEnvelope, documentBridgeErrorResponse, } from "@/lib/documents/bridge"; -import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log"; - -interface SavePayload { - documentId: string; - workspaceId?: string | null; - content: unknown; -} +import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter"; +import { + buildDocumentSavePayload, + type DocumentSavePayload, +} from "@/lib/documents/save-contract"; export async function POST(request: Request) { if (isConvexEnabled()) { try { - const { documentId, workspaceId, content }: SavePayload = await request.json(); - const normalizedDocumentId = assertDocumentId(documentId); - const normalizedWorkspaceId = workspaceId?.trim() || null; + const body = await request.json() as Partial & { content: unknown }; + const normalizedDocumentId = assertDocumentId(body.documentId); + const payload = buildDocumentSavePayload({ + documentId: normalizedDocumentId, + workspaceId: body.workspaceId, + revision: body.revision, + content: body.content as DocumentSavePayload["content"], + conflictDetectionKey: body.conflictDetectionKey, + }); + const normalizedWorkspaceId = payload.workspaceId; const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId }); const envelope = buildDocumentCommandEnvelope({ name: "documents.save", - payload: { - documentId: normalizedDocumentId, - workspaceId: normalizedWorkspaceId, - content, - }, + payload: payload satisfies DocumentSavePayload, context: bridgeContext, target: { workspaceId: normalizedWorkspaceId, pageId: normalizedDocumentId, }, }); - - const { client } = await getAuthedConvexClient(); - await client.mutation(api.documents.updateContent, { - id: normalizedDocumentId, - content: envelope.payload.content, - }); - await recordBridgeCommandArtifacts({ + const result = await executeSaveBridgeCommand({ context: bridgeContext, envelope, }); + return NextResponse.json({ ok: true, + revision: result.revision, + conflictDetectionKey: result.conflictDetectionKey, meta: { - requestId: bridgeContext.requestId, - traceId: bridgeContext.traceId, - commandId: envelope.commandId, - commandName: envelope.name, + requestId: result.requestId, + traceId: result.traceId, + commandId: result.commandId, + commandName: result.commandName, }, }); } catch (error) { diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts index 8dc322ac..6e1850e2 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getAuthedConvexClient } from "@/lib/convex/route"; import { api } from "@/lib/convex/api"; +import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta"; const defaultMindmapData = { data: { text: "中心主题" }, @@ -9,15 +10,30 @@ const defaultMindmapData = { }; export async function GET( - _req: Request, + request: Request, { params }: { params: Promise<{ docId: string; mindmapId: string }> }, ) { const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { client } = await getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const res = await client.query(api.mindmaps.get, { docId, mindmapId }); - return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" }); + return NextResponse.json({ + data: res?.data ?? defaultMindmapData, + source: "convex", + meta: { + ...buildMindmapRouteMeta(request, { + workspaceId: res?.meta?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + exists: Boolean(res?.meta?.exists), + deletedAt: res?.meta?.deleted_at ?? null, + createdAt: res?.meta?.created_at ?? null, + updatedAt: res?.meta?.updated_at ?? null, + }, + }); } return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); @@ -30,7 +46,7 @@ export async function POST( const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { client } = await getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as { data?: unknown; createOnly?: boolean; @@ -43,7 +59,18 @@ export async function POST( data: data ?? defaultMindmapData, ...(typeof createOnly === "boolean" ? { createOnly } : {}), }); - return NextResponse.json(result ?? { ok: true }); + return NextResponse.json({ + ...(result ?? { ok: true }), + meta: { + ...buildMindmapRouteMeta(request, { + workspaceId: result?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + updatedAt: result?.updated_at ?? null, + }, + }); } catch (error) { return NextResponse.json({ error: (error as Error).message }, { status: 400 }); } @@ -53,16 +80,27 @@ export async function POST( } export async function DELETE( - _req: Request, + request: Request, { params }: { params: Promise<{ docId: string; mindmapId: string }> }, ) { const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { client } = await getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); try { const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId }); - return NextResponse.json(result ?? { ok: true }); + return NextResponse.json({ + ...(result ?? { ok: true }), + meta: { + ...buildMindmapRouteMeta(request, { + workspaceId: result?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + deletedAt: result?.deleted_at ?? null, + }, + }); } catch (error) { return NextResponse.json({ error: (error as Error).message }, { status: 400 }); } @@ -78,7 +116,7 @@ export async function PATCH( const { docId, mindmapId } = await params; if (isConvexEnabled()) { - const { client } = await getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const { action } = (await request.json().catch(() => ({}))) as { action?: string }; if (action !== "restore" && action !== "purge") { return NextResponse.json({ error: "不支持的操作" }, { status: 400 }); @@ -87,10 +125,29 @@ export async function PATCH( try { if (action === "purge") { const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId }); - return NextResponse.json(result ?? { ok: true }); + return NextResponse.json({ + ...(result ?? { ok: true }), + meta: buildMindmapRouteMeta(request, { + workspaceId: result?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + }); } const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId }); - return NextResponse.json(result ?? { ok: true }); + return NextResponse.json({ + ...(result ?? { ok: true }), + meta: { + ...buildMindmapRouteMeta(request, { + workspaceId: result?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + updatedAt: result?.updated_at ?? null, + }, + }); } catch (error) { const msg = (error as Error).message ?? "操作失败"; const status = msg.includes("未找到") ? 404 : 400; @@ -100,4 +157,3 @@ export async function PATCH( return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); } - diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts index 68a27569..2c1db386 100644 --- a/wolai-frontend/src/app/api/mindmap/[docId]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getAuthedConvexClient } from "@/lib/convex/route"; import { api } from "@/lib/convex/api"; +import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta"; const defaultMindmapData = { data: { text: "中心主题" }, @@ -9,16 +10,31 @@ const defaultMindmapData = { }; export async function GET( - _req: Request, + request: Request, { params }: { params: Promise<{ docId: string }> }, ) { const { docId } = await params; if (isConvexEnabled()) { - const { client } = await getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const mindmapId = `legacy-${docId}`; const res = await client.query(api.mindmaps.get, { docId, mindmapId }); - return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" }); + return NextResponse.json({ + data: res?.data ?? defaultMindmapData, + source: "convex", + meta: { + ...buildMindmapRouteMeta(request, { + workspaceId: res?.meta?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + exists: Boolean(res?.meta?.exists), + deletedAt: res?.meta?.deleted_at ?? null, + createdAt: res?.meta?.created_at ?? null, + updatedAt: res?.meta?.updated_at ?? null, + }, + }); } return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); @@ -31,7 +47,7 @@ export async function POST( const { docId } = await params; if (isConvexEnabled()) { - const { client } = await getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const mindmapId = `legacy-${docId}`; const payload = (await request.json().catch(() => ({}))) as { data?: unknown }; const result = await client.mutation(api.mindmaps.put, { @@ -39,25 +55,46 @@ export async function POST( mindmapId, data: payload.data ?? defaultMindmapData, }); - return NextResponse.json(result ?? { ok: true }); + return NextResponse.json({ + ...(result ?? { ok: true }), + meta: { + ...buildMindmapRouteMeta(request, { + workspaceId: result?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + updatedAt: result?.updated_at ?? null, + }, + }); } return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); } export async function DELETE( - _req: Request, + request: Request, { params }: { params: Promise<{ docId: string }> }, ) { const { docId } = await params; if (isConvexEnabled()) { - const { client } = await getAuthedConvexClient(); + const { auth, client } = await getAuthedConvexClient(); const mindmapId = `legacy-${docId}`; const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId }); - return NextResponse.json(result ?? { ok: true }); + return NextResponse.json({ + ...(result ?? { ok: true }), + meta: { + ...buildMindmapRouteMeta(request, { + workspaceId: result?.workspace_id ?? null, + documentId: docId, + mindmapId, + ownerUserId: auth.userId, + }), + deletedAt: result?.deleted_at ?? null, + }, + }); } return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); } - diff --git a/wolai-frontend/src/app/api/onlyoffice/callback/route.ts b/wolai-frontend/src/app/api/onlyoffice/callback/route.ts index db642329..a9fef252 100644 --- a/wolai-frontend/src/app/api/onlyoffice/callback/route.ts +++ b/wolai-frontend/src/app/api/onlyoffice/callback/route.ts @@ -2,10 +2,10 @@ import { NextResponse } from "next/server"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getConvexHttpClient } from "@/lib/convex/server"; import { api } from "@/lib/convex/api"; +import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url"; export const dynamic = "force-dynamic"; -const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, ""); const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim(); type OnlyOfficeCallbackBody = { @@ -27,7 +27,7 @@ const normalizeSecret = (raw: string) => { return trimmed; }; -const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => { +const tryRewriteOnlyOfficeDownloadUrl = (raw: string, onlyofficeInternalUrl: string) => { try { const u = new URL(raw); @@ -36,7 +36,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => { const prefix = "/onlyoffice-server"; if (u.pathname.startsWith(prefix)) { const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, ""); - return `${ONLYOFFICE_INTERNAL_URL}/${nextPath}${u.search}`; + return `${onlyofficeInternalUrl}/${nextPath}${u.search}`; } return raw; @@ -46,6 +46,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => { }; export async function POST(request: Request) { + const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl(); const { searchParams } = new URL(request.url); const assetId = searchParams.get("assetId") || ""; @@ -107,7 +108,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: 1 }); } - const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url); + const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url, onlyofficeInternalUrl); const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" }); if (!upstream.ok) { return NextResponse.json({ error: 1 }); diff --git a/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts b/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts index 33026e73..79073d7f 100644 --- a/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts +++ b/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts @@ -3,11 +3,10 @@ import crypto from "crypto"; import { api } from "@/lib/convex/api"; import { HttpError } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route"; +import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url"; export const dynamic = "force-dynamic"; -const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, ""); - const base64Url = (input: Buffer | string) => Buffer.from(input) .toString("base64") @@ -38,6 +37,7 @@ const normalizeSecret = (raw: string) => { }; export async function POST(request: Request) { + const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl(); let auth; let client; try { @@ -82,7 +82,7 @@ export async function POST(request: Request) { // 优先按文档推荐:使用 /command + token if (secret) { const token = signHs256(payload, secret); - const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, { + const r = await fetch(`${onlyofficeInternalUrl}/command`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), @@ -93,7 +93,7 @@ export async function POST(request: Request) { } // 兜底:部分环境可能暴露 /forcesave 直连接口 - const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, { + const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -110,7 +110,7 @@ export async function POST(request: Request) { } // JWT 未启用:尝试 /forcesave 直连 - const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, { + const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -121,7 +121,7 @@ export async function POST(request: Request) { } // 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证) - const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, { + const r2 = await fetch(`${onlyofficeInternalUrl}/command`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -140,4 +140,3 @@ export async function POST(request: Request) { return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 }); } } - diff --git a/wolai-frontend/src/app/api/sidebar/route.ts b/wolai-frontend/src/app/api/sidebar/route.ts index d1226a49..462257e3 100644 --- a/wolai-frontend/src/app/api/sidebar/route.ts +++ b/wolai-frontend/src/app/api/sidebar/route.ts @@ -6,6 +6,9 @@ import { buildDocumentQueryEnvelope, documentBridgeErrorResponse, } from "@/lib/documents/bridge"; +import { + buildSidebarDatasetListQueryPayload, +} from "@/lib/sidebar-data"; import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data"; export const dynamic = "force-dynamic"; @@ -21,7 +24,6 @@ export async function GET(request: Request) { sidebarInitialData, } = await loadSidebarDataFromConvex({ client, - userId: auth.userId, fallbackName: auth.email ?? auth.name ?? "我的空间", requestedWorkspaceId: workspaceIdParam, }); @@ -37,7 +39,7 @@ export async function GET(request: Request) { }); const envelope = buildDocumentQueryEnvelope({ name: "sidebar.dataset.list", - payload: { workspaceId: targetWorkspaceId }, + payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId), }); if (!sidebarInitialData) { return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 }); diff --git a/wolai-frontend/src/app/cache/[...path]/route.ts b/wolai-frontend/src/app/cache/[...path]/route.ts index 6dbbc567..81e35956 100644 --- a/wolai-frontend/src/app/cache/[...path]/route.ts +++ b/wolai-frontend/src/app/cache/[...path]/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { gzipSync } from "node:zlib"; +import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -9,11 +10,6 @@ export const runtime = "nodejs"; // 当我们通过 `/onlyoffice-server/*` 反代文档服务器时,这些 `/cache/*` 请求会落到 Next 上, // 若未额外反代,会导致 404,进而触发 ONLYOFFICE “下载失败(-4)/无法打开文档”。 // 因此这里把 `/cache/*` 同样反代到本机 ONLYOFFICE。 -const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace( - /\/+$/, - "", -); - const stripHopByHopHeaders = (headers: Headers) => { // 说明:Hop-by-hop headers 不应被代理转发/透传 const hopByHop = [ @@ -71,9 +67,10 @@ const shouldGzip = (request: NextRequest, contentType: string) => { }; const proxyCache = async (request: NextRequest, pathParts: string[]) => { + const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl(); const incomingUrl = new URL(request.url); const target = new URL( - `${ONLYOFFICE_INTERNAL_URL}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`, + `${onlyofficeInternalUrl}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`, ); target.search = incomingUrl.search; diff --git a/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts b/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts index ecbae909..b208e8cf 100644 --- a/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts +++ b/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts @@ -1,13 +1,12 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url"; import { gzipSync } from "node:zlib"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, ""); - const SERVICE_WORKER_SAFE_PATCH_SNIPPET = `