feat: 收口文档桥接与 OnlyOffice/Sidebar 回归

- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器

- 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
This commit is contained in:
lix-2026
2026-04-15 03:06:29 +08:00
parent 84a8454fa9
commit b33ffb99e7
51 changed files with 3260 additions and 379 deletions
+7
View File
@@ -50,3 +50,10 @@ artifacts/
artifacts/** artifacts/**
tmp tmp
design design
# Rust 本地构建与调试产物
/rust/target/
# Harness 本地运行状态/调试产物
/.harness-stop-counter
/harness-tasks.json.bak
+2 -2
View File
@@ -533,7 +533,7 @@ API 层主要位于:
### 9.6 当前结论 ### 9.6 当前结论
如果后续迁移到 `mnote-rust`,这两套树不应该混为一个组件重写,而应该拆成: 如果后续继续把这套能力收口到主仓 Rust 协议层,这两套树不应该混为一个组件重写,而应该拆成:
1. 统一侧边栏数据聚合层 1. 统一侧边栏数据聚合层
2. 页面树投影层 2. 页面树投影层
@@ -741,7 +741,7 @@ API 层主要位于:
## 13. 后续重构建议 ## 13. 后续重构建议
如果后面你要把这套能力迁到 `mnote-rust`,最值得优先抽象的是三层: 如果后面你要把这套能力继续收口到 `/mnt/Data1T/mnote/rust/`,最值得优先抽象的是三层:
1. 文档宿主层 1. 文档宿主层
- 文档页面壳 - 文档页面壳
+25
View File
@@ -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
```
+20
View File
@@ -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
+19
View File
@@ -72,6 +72,25 @@ pub struct UpdatePageOptions {
pub embed_default_block_id: Option<String>, pub embed_default_block_id: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SavePageContent {
pub page_id: String,
pub workspace_id: Option<String>,
pub revision: Option<u64>,
pub content_json: String,
pub conflict_detection_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchPageBlock {
pub page_id: String,
pub block_id: String,
pub workspace_id: Option<String>,
pub revision: Option<u64>,
pub block_snapshot_json: String,
pub conflict_detection_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct InsertBlock { pub struct InsertBlock {
pub page_id: String, pub page_id: String,
+6 -2
View File
@@ -6,13 +6,17 @@ pub mod tool;
pub use command::{ pub use command::{
CommandEnvelope, CreatePage, CreateWorkspace, DeleteBlock, InsertBlock, MoveBlock, CommandEnvelope, CreatePage, CreateWorkspace, DeleteBlock, InsertBlock, MoveBlock,
UpdateBlock, UpdatePageStats, UpdatePageTitle, PatchPageBlock, SavePageContent, UpdateBlock, UpdatePageOptions, UpdatePageStats,
UpdatePageTitle,
}; };
pub use common::{ pub use common::{
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta, ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
SourcePayload, TargetRef, 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}; pub use tool::{InvocationKind, ToolInvocation};
#[cfg(test)] #[cfg(test)]
+17
View File
@@ -15,6 +15,23 @@ pub struct GetPage {
pub page_id: String, pub page_id: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetPageMeta {
pub page_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetPageContent {
pub page_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSidebarDataset {
pub workspace_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListPageBlocks { pub struct ListPageBlocks {
pub page_id: String, pub page_id: String,
+131 -2
View File
@@ -21,8 +21,11 @@ pub use write_path::{
mod tests { mod tests {
use super::*; use super::*;
use core_protocol::{ use core_protocol::{
command::{CreatePage, CreateWorkspace, UpdatePageOptions, UpdatePageStats, UpdatePageTitle}, command::{
query::GetPage, CreatePage, CreateWorkspace, PatchPageBlock, SavePageContent, UpdatePageOptions,
UpdatePageStats, UpdatePageTitle,
},
query::{GetPage, GetPageContent, GetPageMeta, ListSidebarDataset},
ActorPayload, CommandEnvelope, QueryEnvelope, SourcePayload, TargetRef, ActorPayload, CommandEnvelope, QueryEnvelope, SourcePayload, TargetRef,
}; };
@@ -176,6 +179,85 @@ mod tests {
assert!(request.payload_json.contains("\"name\":\"documents.title.update\"")); 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] #[test]
fn document_stats_command_maps_to_documents_update_stats() { fn document_stats_command_maps_to_documents_update_stats() {
let command = CommandEnvelope { let command = CommandEnvelope {
@@ -263,4 +345,51 @@ mod tests {
assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request.payload_json.contains("\"name\":\"documents.options.update\"")); 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\""));
}
} }
@@ -29,6 +29,8 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
match command_name { match command_name {
"create_workspace" => "workspaces:create", "create_workspace" => "workspaces:create",
"create_page" => "pages:create", "create_page" => "pages:create",
"blocks.patch" => "documents:updateContent",
"documents.save" => "documents:updateContent",
"documents.title.update" => "documents:updateTitle", "documents.title.update" => "documents:updateTitle",
"documents.stats.update" => "documents:updateStats", "documents.stats.update" => "documents:updateStats",
"documents.options.update" => "documents:updateOptions", "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 { pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
match query_name { match query_name {
"documents.meta.get" => "documents:getMeta",
"documents.content.get" => "documents:getContent",
"sidebar.dataset.list" => "sidebar:datasetList",
"get_page" => "pages:get", "get_page" => "pages:get",
"list_page_blocks" => "blocks:list_by_page", "list_page_blocks" => "blocks:list_by_page",
"search_pages" => "search:pages", "search_pages" => "search:pages",
+177
View File
@@ -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);
});
+373
View File
@@ -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);
});
+414
View File
@@ -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 || "<empty>"}`);
}
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);
});
+4
View File
@@ -10,6 +10,7 @@
import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js"; import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js";
import type * as _utils_auth from "../_utils/auth.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_documentTree from "../_utils/documentTree.js";
import type * as _utils_id from "../_utils/id.js"; import type * as _utils_id from "../_utils/id.js";
import type * as _utils_ingestJobs from "../_utils/ingestJobs.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 ping from "../ping.js";
import type * as recents from "../recents.js"; import type * as recents from "../recents.js";
import type * as references from "../references.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 tables from "../tables.js";
import type * as users from "../users.js"; import type * as users from "../users.js";
import type * as workspaces from "../workspaces.js"; import type * as workspaces from "../workspaces.js";
@@ -53,6 +55,7 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
"_utils/attachmentExtract": typeof _utils_attachmentExtract; "_utils/attachmentExtract": typeof _utils_attachmentExtract;
"_utils/auth": typeof _utils_auth; "_utils/auth": typeof _utils_auth;
"_utils/documentRecord": typeof _utils_documentRecord;
"_utils/documentTree": typeof _utils_documentTree; "_utils/documentTree": typeof _utils_documentTree;
"_utils/id": typeof _utils_id; "_utils/id": typeof _utils_id;
"_utils/ingestJobs": typeof _utils_ingestJobs; "_utils/ingestJobs": typeof _utils_ingestJobs;
@@ -83,6 +86,7 @@ declare const fullApi: ApiFromModules<{
ping: typeof ping; ping: typeof ping;
recents: typeof recents; recents: typeof recents;
references: typeof references; references: typeof references;
sidebar: typeof sidebar;
tables: typeof tables; tables: typeof tables;
users: typeof users; users: typeof users;
workspaces: typeof workspaces; workspaces: typeof workspaces;
+72 -7
View File
@@ -445,14 +445,32 @@ export const getContent = query({
} }
if (doc.user_id === userId) { 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") { 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)); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (!perm) return null; 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); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) return null; if (!doc) return null;
if (doc.user_id !== args.userId) 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, parent_id: args.parentId,
title, title,
content, content,
content_revision: 0,
content_conflict_key: `${args.id}:0`,
raw_text: rawText, raw_text: rawText,
access_scope: args.accessScope, access_scope: args.accessScope,
sort_order: sortOrder, sort_order: sortOrder,
@@ -837,7 +863,12 @@ export const create = mutation({
}); });
export const updateContent = 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) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx); 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)); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (perm !== "edit") throw new Error("无权限"); 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 ts = nowIso();
const rawText = extractTextFromDocumentContent(args.content); 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。 // 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
// 采用 debounce,避免频繁保存时触发过多任务。 // 采用 debounce,避免频繁保存时触发过多任务。
await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 }); 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,
};
}, },
}); });
+64 -8
View File
@@ -138,7 +138,15 @@ export const put = mutation({
const payload = args.data ?? defaultMindmapData; const payload = args.data ?? defaultMindmapData;
if (existing && existing.deleted_at == null && args.createOnly) { 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) { if (existing) {
@@ -149,7 +157,15 @@ export const put = mutation({
deleted_by: null, deleted_by: null,
}); });
await enqueueIngestMindmapJob(ctx, { userId, docId: args.docId, mindmapId, debounceMs: 1500 }); 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", { await ctx.db.insert("mindmaps", {
@@ -166,7 +182,15 @@ export const put = mutation({
}); });
await enqueueIngestMindmapJob(ctx, { userId, docId: args.docId, mindmapId, debounceMs: 1500 }); 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) { 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) { if (existing.deleted_at != null) {
// 已在垃圾桶:保持幂等,避免重复删除导致 updated_at 抖动/重复调度 // 已在垃圾桶:保持幂等,避免重复删除导致 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(); const ts = nowIso();
@@ -204,7 +242,14 @@ export const softDelete = mutation({
mindmapId, mindmapId,
deletedAt: ts, 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(); const ts = nowIso();
await ctx.db.patch(existing._id, { deleted_at: null, deleted_by: null, updated_at: ts }); 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); await ctx.db.delete(existing._id);
return { ok: true }; return {
ok: true,
workspace_id: existing.workspace_id,
document_id: args.docId,
mindmap_id: mindmapId,
};
}, },
}); });
+2
View File
@@ -68,6 +68,8 @@ export default defineSchema({
// 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。 // 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。
content: v.any(), 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())), raw_text: v.optional(v.union(v.string(), v.null())),
+223
View File
@@ -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>): 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<string, unknown>;
return record && typeof record === "object" && "root" in record ? record.root : input;
})();
const ids: string[] = [];
const seen = new Set<string>();
const push = (value: unknown) => {
if (typeof value !== "string") return;
if (!value.startsWith("asset:")) return;
const id = value.slice("asset:".length).trim();
if (!id || seen.has(id)) return;
seen.add(id);
ids.push(id);
};
const get = (obj: unknown, key: string): unknown => {
if (!obj || typeof obj !== "object") return undefined;
return (obj as Record<string, unknown>)[key];
};
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return;
const data = get(node, "data");
const image = get(node, "image");
push(get(data, "image"));
push(image);
push(get(image, "url"));
push(get(get(data, "image"), "url"));
const children = get(node, "children");
if (Array.isArray(children)) {
children.forEach(walk);
}
};
walk(root);
return ids;
}
function 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<string, string[]> = {};
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,
};
},
});
+5 -4
View File
@@ -5,10 +5,11 @@
"supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y", "supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y",
"backendUrl": "https://frp-dry.com:44399", "backendUrl": "https://frp-dry.com:44399",
"onlyofficeBaseUrlWeb": "/onlyoffice-server", "onlyofficeBaseUrlWeb": "/onlyoffice-server",
"onlyofficeBaseUrlDesktop": "http://localhost:8081", "onlyofficeBaseUrlDesktop": "http://localhost:8082",
"onlyofficeProxyOrigin": "http://host.docker.internal:3001",
"onlyofficeStorageHostOverrideWeb": "", "onlyofficeStorageHostOverrideWeb": "",
"onlyofficeStorageHostOverrideDesktop": "", "onlyofficeStorageHostOverrideDesktop": "",
"onlyofficeProxyOriginWeb": "http://172.31.224.1:3000", "onlyofficeProxyOriginWeb": "http://host.docker.internal:3001",
"onlyofficeCallbackOriginWeb": "http://172.31.224.1:3000", "onlyofficeCallbackOriginWeb": "http://host.docker.internal:3001",
"onlyofficeCallbackOriginDesktop": "http://127.0.0.1:3000" "onlyofficeCallbackOriginDesktop": "http://host.docker.internal:3001"
} }
@@ -10,8 +10,12 @@
"icons": ["icon.svg"], "icons": ["icon.svg"],
"isViewer": true, "isViewer": true,
"EditorsSupport": ["word", "cell", "slide", "pdf"], "EditorsSupport": ["word", "cell", "slide", "pdf"],
"isVisual": false "isVisual": false,
"isModal": false,
"isInsideMode": false,
"initDataType": "none",
"initData": "",
"buttons": []
} }
] ]
} }
@@ -6,7 +6,7 @@
<title>MNOTE OnlyOffice Agent Tools</title> <title>MNOTE OnlyOffice Agent Tools</title>
</head> </head>
<body> <body>
<script src="/onlyoffice-server/sdkjs-plugins/v1/plugins.js"></script>
<script src="plugin.js"></script> <script src="plugin.js"></script>
</body> </body>
</html> </html>
@@ -2,6 +2,8 @@
// 宿主页面通过 postMessage 下发 oo_* 工具调用;插件执行后再 postMessage 回传结果。 // 宿主页面通过 postMessage 下发 oo_* 工具调用;插件执行后再 postMessage 回传结果。
(function () { (function () {
const CHANNEL = "mnote_onlyoffice_agent_tools_v1"; const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
let readyPulseTimer = null;
let readyPulseDeadline = 0;
const safePostToTop = (payload) => { const safePostToTop = (payload) => {
try { 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) => const execMethod = (method, args) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
try { try {
@@ -74,15 +120,19 @@
window.Asc.plugin = window.Asc.plugin || {}; window.Asc.plugin = window.Asc.plugin || {};
window.Asc.plugin.init = function () { window.Asc.plugin.init = function () {
safePostToTop({ type: "ready" }); startReadyPulse();
}; };
waitForPluginApiReady();
window.addEventListener("message", async (ev) => { window.addEventListener("message", async (ev) => {
const msg = ev && ev.data ? ev.data : null; const msg = ev && ev.data ? ev.data : null;
if (!msg || typeof msg !== "object") return; if (!msg || typeof msg !== "object") return;
if (msg.channel !== CHANNEL) return; if (msg.channel !== CHANNEL) return;
if (msg.type !== "call") return; if (msg.type !== "call") return;
stopReadyPulse();
const callId = String(msg.callId ?? "").trim(); const callId = String(msg.callId ?? "").trim();
const tool = String(msg.tool ?? "").trim(); const tool = String(msg.tool ?? "").trim();
const args = msg.args && typeof msg.args === "object" ? msg.args : {}; const args = msg.args && typeof msg.args === "object" ? msg.args : {};
@@ -97,4 +147,3 @@
} }
}); });
})(); })();
+109 -5
View File
@@ -9,7 +9,7 @@
* - node scripts/dev-server.js -p 3000 * - node scripts/dev-server.js -p 3000
* *
* 依赖环境变量: * 依赖环境变量:
* - ONLYOFFICE_INTERNAL_URL默认 http://127.0.0.1:8081 * - ONLYOFFICE_INTERNAL_URL可显式指定;未指定或失效时会自动探测 8081/8082
*/ */
const http = require("http"); const http = require("http");
@@ -20,6 +20,13 @@ const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server"; const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex"; 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) { function readArgValue(flag) {
const idx = process.argv.findIndex((x) => x === flag); const idx = process.argv.findIndex((x) => x === flag);
@@ -58,6 +65,84 @@ function isConvexPath(urlString) {
} }
} }
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) { function buildUpstreamRequestHead(req, targetUrl, prefix) {
const incoming = new URL(req.url || "/", "http://localhost"); const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/"; const rawPath = incoming.pathname || "/";
@@ -127,8 +212,20 @@ function buildUpstreamRequestHead(req, targetUrl, prefix) {
return lines.join("\r\n"); return lines.join("\r\n");
} }
function proxyOnlyOfficeUpgrade(req, socket, head) { async function proxyOnlyOfficeUpgrade(req, socket, head) {
const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/"); 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 port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => { const upstream = net.connect({ host: target.hostname, port }, () => {
@@ -353,10 +450,17 @@ async function main() {
}); });
server.listen(port, hostname, () => { server.listen(port, hostname, () => {
resolveOnlyOfficeInternalUrl()
.then((onlyofficeTarget) => {
console.log( 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"})`, `[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"})`,
);
});
}); });
} }
+109 -4
View File
@@ -9,7 +9,7 @@
* - pnpm start (默认会执行本脚本) * - pnpm start (默认会执行本脚本)
* *
* 依赖环境变量: * 依赖环境变量:
* - ONLYOFFICE_INTERNAL_URL默认 http://127.0.0.1:8081 * - ONLYOFFICE_INTERNAL_URL可显式指定;未指定或失效时会自动探测 8081/8082
* - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210 * - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210
*/ */
@@ -21,6 +21,13 @@ const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server"; const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex"; 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) { function readArgValue(flag) {
const idx = process.argv.findIndex((x) => x === flag); const idx = process.argv.findIndex((x) => x === flag);
@@ -59,6 +66,84 @@ function isConvexPath(urlString) {
} }
} }
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) { function buildUpstreamRequestHead(req, targetUrl, prefix) {
const incoming = new URL(req.url || "/", "http://localhost"); const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/"; const rawPath = incoming.pathname || "/";
@@ -123,8 +208,20 @@ function buildUpstreamRequestHead(req, targetUrl, prefix) {
return lines.join("\r\n"); return lines.join("\r\n");
} }
function proxyOnlyOfficeUpgrade(req, socket, head) { async function proxyOnlyOfficeUpgrade(req, socket, head) {
const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/"); 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 port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => { const upstream = net.connect({ host: target.hostname, port }, () => {
@@ -317,9 +414,17 @@ async function main() {
}); });
server.listen(port, hostname, () => { server.listen(port, hostname, () => {
resolveOnlyOfficeInternalUrl()
.then((onlyofficeTarget) => {
console.log( 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"})`, `[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"})`,
);
});
}); });
} }
@@ -93,6 +93,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
title={doc.title ?? "无标题"} title={doc.title ?? "无标题"}
updatedAt={doc.updated_at} updatedAt={doc.updated_at}
initialContent={null} initialContent={null}
initialContentRevision={null}
initialConflictDetectionKey={null}
initialOptions={initialOptions} initialOptions={initialOptions}
initialStats={initialStats} initialStats={initialStats}
openTableId={openTableId} openTableId={openTableId}
-1
View File
@@ -17,7 +17,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
sidebarInitialData, sidebarInitialData,
} = await loadSidebarDataFromConvex({ } = await loadSidebarDataFromConvex({
client, client,
userId: auth.userId,
fallbackName: auth.name ?? auth.email ?? "我的空间", fallbackName: auth.name ?? auth.email ?? "我的空间",
}); });
@@ -44,6 +44,14 @@ export async function GET(request: Request) {
return NextResponse.json({ return NextResponse.json({
content: result.content ?? null, 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: { meta: {
requestId: bridgeContext.requestId, requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId, traceId: bridgeContext.traceId,
@@ -1,58 +1,54 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { import {
assertDocumentId, assertDocumentId,
buildDocumentBridgeContext, buildDocumentBridgeContext,
buildDocumentCommandEnvelope, buildDocumentCommandEnvelope,
documentBridgeErrorResponse, documentBridgeErrorResponse,
} from "@/lib/documents/bridge"; } from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log"; import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import {
interface SavePayload { buildDocumentSavePayload,
documentId: string; type DocumentSavePayload,
workspaceId?: string | null; } from "@/lib/documents/save-contract";
content: unknown;
}
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
try { try {
const { documentId, workspaceId, content }: SavePayload = await request.json(); const body = await request.json() as Partial<DocumentSavePayload> & { content: unknown };
const normalizedDocumentId = assertDocumentId(documentId); const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = workspaceId?.trim() || null; 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 bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({ const envelope = buildDocumentCommandEnvelope({
name: "documents.save", name: "documents.save",
payload: { payload: payload satisfies DocumentSavePayload,
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
content,
},
context: bridgeContext, context: bridgeContext,
target: { target: {
workspaceId: normalizedWorkspaceId, workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId, pageId: normalizedDocumentId,
}, },
}); });
const result = await executeSaveBridgeCommand({
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.updateContent, {
id: normalizedDocumentId,
content: envelope.payload.content,
});
await recordBridgeCommandArtifacts({
context: bridgeContext, context: bridgeContext,
envelope, envelope,
}); });
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
revision: result.revision,
conflictDetectionKey: result.conflictDetectionKey,
meta: { meta: {
requestId: bridgeContext.requestId, requestId: result.requestId,
traceId: bridgeContext.traceId, traceId: result.traceId,
commandId: envelope.commandId, commandId: result.commandId,
commandName: envelope.name, commandName: result.commandName,
}, },
}); });
} catch (error) { } catch (error) {
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
const defaultMindmapData = { const defaultMindmapData = {
data: { text: "中心主题" }, data: { text: "中心主题" },
@@ -9,15 +10,30 @@ const defaultMindmapData = {
}; };
export async function GET( export async function GET(
_req: Request, request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> }, { params }: { params: Promise<{ docId: string; mindmapId: string }> },
) { ) {
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
const res = await client.query(api.mindmaps.get, { docId, mindmapId }); 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 }); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -30,7 +46,7 @@ export async function POST(
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as { const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown; data?: unknown;
createOnly?: boolean; createOnly?: boolean;
@@ -43,7 +59,18 @@ export async function POST(
data: data ?? defaultMindmapData, data: data ?? defaultMindmapData,
...(typeof createOnly === "boolean" ? { createOnly } : {}), ...(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) { } catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 }); return NextResponse.json({ error: (error as Error).message }, { status: 400 });
} }
@@ -53,16 +80,27 @@ export async function POST(
} }
export async function DELETE( export async function DELETE(
_req: Request, request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> }, { params }: { params: Promise<{ docId: string; mindmapId: string }> },
) { ) {
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
try { try {
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId }); 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) { } catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 }); return NextResponse.json({ error: (error as Error).message }, { status: 400 });
} }
@@ -78,7 +116,7 @@ export async function PATCH(
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
const { action } = (await request.json().catch(() => ({}))) as { action?: string }; const { action } = (await request.json().catch(() => ({}))) as { action?: string };
if (action !== "restore" && action !== "purge") { if (action !== "restore" && action !== "purge") {
return NextResponse.json({ error: "不支持的操作" }, { status: 400 }); return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
@@ -87,10 +125,29 @@ export async function PATCH(
try { try {
if (action === "purge") { if (action === "purge") {
const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId }); 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 }); 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) { } catch (error) {
const msg = (error as Error).message ?? "操作失败"; const msg = (error as Error).message ?? "操作失败";
const status = msg.includes("未找到") ? 404 : 400; const status = msg.includes("未找到") ? 404 : 400;
@@ -100,4 +157,3 @@ export async function PATCH(
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
} }
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
const defaultMindmapData = { const defaultMindmapData = {
data: { text: "中心主题" }, data: { text: "中心主题" },
@@ -9,16 +10,31 @@ const defaultMindmapData = {
}; };
export async function GET( export async function GET(
_req: Request, request: Request,
{ params }: { params: Promise<{ docId: string }> }, { params }: { params: Promise<{ docId: string }> },
) { ) {
const { docId } = await params; const { docId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`; const mindmapId = `legacy-${docId}`;
const res = await client.query(api.mindmaps.get, { docId, mindmapId }); 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 }); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -31,7 +47,7 @@ export async function POST(
const { docId } = await params; const { docId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`; const mindmapId = `legacy-${docId}`;
const payload = (await request.json().catch(() => ({}))) as { data?: unknown }; const payload = (await request.json().catch(() => ({}))) as { data?: unknown };
const result = await client.mutation(api.mindmaps.put, { const result = await client.mutation(api.mindmaps.put, {
@@ -39,25 +55,46 @@ export async function POST(
mindmapId, mindmapId,
data: payload.data ?? defaultMindmapData, 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 }); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
} }
export async function DELETE( export async function DELETE(
_req: Request, request: Request,
{ params }: { params: Promise<{ docId: string }> }, { params }: { params: Promise<{ docId: string }> },
) { ) {
const { docId } = await params; const { docId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`; const mindmapId = `legacy-${docId}`;
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId }); 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 }); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
} }
@@ -2,10 +2,10 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim(); const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim();
type OnlyOfficeCallbackBody = { type OnlyOfficeCallbackBody = {
@@ -27,7 +27,7 @@ const normalizeSecret = (raw: string) => {
return trimmed; return trimmed;
}; };
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => { const tryRewriteOnlyOfficeDownloadUrl = (raw: string, onlyofficeInternalUrl: string) => {
try { try {
const u = new URL(raw); const u = new URL(raw);
@@ -36,7 +36,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
const prefix = "/onlyoffice-server"; const prefix = "/onlyoffice-server";
if (u.pathname.startsWith(prefix)) { if (u.pathname.startsWith(prefix)) {
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, ""); const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
return `${ONLYOFFICE_INTERNAL_URL}/${nextPath}${u.search}`; return `${onlyofficeInternalUrl}/${nextPath}${u.search}`;
} }
return raw; return raw;
@@ -46,6 +46,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
}; };
export async function POST(request: Request) { export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId") || ""; const assetId = searchParams.get("assetId") || "";
@@ -107,7 +108,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 1 }); 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" }); const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
if (!upstream.ok) { if (!upstream.ok) {
return NextResponse.json({ error: 1 }); return NextResponse.json({ error: 1 });
@@ -3,11 +3,10 @@ import crypto from "crypto";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { HttpError } from "@/lib/auth/authContext"; import { HttpError } from "@/lib/auth/authContext";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const base64Url = (input: Buffer | string) => const base64Url = (input: Buffer | string) =>
Buffer.from(input) Buffer.from(input)
.toString("base64") .toString("base64")
@@ -38,6 +37,7 @@ const normalizeSecret = (raw: string) => {
}; };
export async function POST(request: Request) { export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
let auth; let auth;
let client; let client;
try { try {
@@ -82,7 +82,7 @@ export async function POST(request: Request) {
// 优先按文档推荐:使用 /command + token // 优先按文档推荐:使用 /command + token
if (secret) { if (secret) {
const token = signHs256(payload, secret); const token = signHs256(payload, secret);
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, { const r = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
@@ -93,7 +93,7 @@ export async function POST(request: Request) {
} }
// 兜底:部分环境可能暴露 /forcesave 直连接口 // 兜底:部分环境可能暴露 /forcesave 直连接口
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, { const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -110,7 +110,7 @@ export async function POST(request: Request) {
} }
// JWT 未启用:尝试 /forcesave 直连 // JWT 未启用:尝试 /forcesave 直连
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, { const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -121,7 +121,7 @@ export async function POST(request: Request) {
} }
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证) // 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, { const r2 = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -140,4 +140,3 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 }); return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
} }
} }
+4 -2
View File
@@ -6,6 +6,9 @@ import {
buildDocumentQueryEnvelope, buildDocumentQueryEnvelope,
documentBridgeErrorResponse, documentBridgeErrorResponse,
} from "@/lib/documents/bridge"; } from "@/lib/documents/bridge";
import {
buildSidebarDatasetListQueryPayload,
} from "@/lib/sidebar-data";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data"; import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,7 +24,6 @@ export async function GET(request: Request) {
sidebarInitialData, sidebarInitialData,
} = await loadSidebarDataFromConvex({ } = await loadSidebarDataFromConvex({
client, client,
userId: auth.userId,
fallbackName: auth.email ?? auth.name ?? "我的空间", fallbackName: auth.email ?? auth.name ?? "我的空间",
requestedWorkspaceId: workspaceIdParam, requestedWorkspaceId: workspaceIdParam,
}); });
@@ -37,7 +39,7 @@ export async function GET(request: Request) {
}); });
const envelope = buildDocumentQueryEnvelope({ const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list", name: "sidebar.dataset.list",
payload: { workspaceId: targetWorkspaceId }, payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
}); });
if (!sidebarInitialData) { if (!sidebarInitialData) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 }); return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
+3 -6
View File
@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { gzipSync } from "node:zlib"; import { gzipSync } from "node:zlib";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -9,11 +10,6 @@ export const runtime = "nodejs";
// 当我们通过 `/onlyoffice-server/*` 反代文档服务器时,这些 `/cache/*` 请求会落到 Next 上, // 当我们通过 `/onlyoffice-server/*` 反代文档服务器时,这些 `/cache/*` 请求会落到 Next 上,
// 若未额外反代,会导致 404,进而触发 ONLYOFFICE “下载失败(-4)/无法打开文档”。 // 若未额外反代,会导致 404,进而触发 ONLYOFFICE “下载失败(-4)/无法打开文档”。
// 因此这里把 `/cache/*` 同样反代到本机 ONLYOFFICE。 // 因此这里把 `/cache/*` 同样反代到本机 ONLYOFFICE。
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(
/\/+$/,
"",
);
const stripHopByHopHeaders = (headers: Headers) => { const stripHopByHopHeaders = (headers: Headers) => {
// 说明:Hop-by-hop headers 不应被代理转发/透传 // 说明:Hop-by-hop headers 不应被代理转发/透传
const hopByHop = [ const hopByHop = [
@@ -71,9 +67,10 @@ const shouldGzip = (request: NextRequest, contentType: string) => {
}; };
const proxyCache = async (request: NextRequest, pathParts: string[]) => { const proxyCache = async (request: NextRequest, pathParts: string[]) => {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
const incomingUrl = new URL(request.url); const incomingUrl = new URL(request.url);
const target = new URL( const target = new URL(
`${ONLYOFFICE_INTERNAL_URL}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`, `${onlyofficeInternalUrl}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`,
); );
target.search = incomingUrl.search; target.search = incomingUrl.search;
@@ -1,13 +1,12 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
import { gzipSync } from "node:zlib"; import { gzipSync } from "node:zlib";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; 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 = ` const SERVICE_WORKER_SAFE_PATCH_SNIPPET = `
<script> <script>
// 说明: // 说明:
@@ -62,9 +61,13 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
var internal = { var internal = {
'http://127.0.0.1:8081': true, 'http://127.0.0.1:8081': true,
'http://localhost:8081': true, 'http://localhost:8081': true,
'http://127.0.0.1:8082': true,
'http://localhost:8082': true,
// 说明:同上,兜底错误的 https://127.0.0.1:8081 // 说明:同上,兜底错误的 https://127.0.0.1:8081
'https://127.0.0.1:8081': true, 'https://127.0.0.1:8081': true,
'https://localhost:8081': true 'https://localhost:8081': true,
'https://127.0.0.1:8082': true,
'https://localhost:8082': true
}; };
function rewrite(u) { function rewrite(u) {
try { try {
@@ -261,8 +264,9 @@ const shouldGzip = (request: NextRequest, contentType: string) => {
}; };
const proxy = async (request: NextRequest, pathParts: string[]) => { const proxy = async (request: NextRequest, pathParts: string[]) => {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
const incomingUrl = new URL(request.url); const incomingUrl = new URL(request.url);
const target = new URL(`${ONLYOFFICE_INTERNAL_URL}/${pathParts.map(encodeURIComponent).join("/")}`); const target = new URL(`${onlyofficeInternalUrl}/${pathParts.map(encodeURIComponent).join("/")}`);
target.search = incomingUrl.search; target.search = incomingUrl.search;
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
@@ -102,20 +102,50 @@ setupOnlyOfficeGlobalErrorCapture();
const loadScript = (src: string) => const loadScript = (src: string) =>
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`); const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null;
if (existing) { if (existing) {
existing.addEventListener("load", () => resolve(), { once: true }); if (existing.dataset.mnoteLoaded === "1") {
resolve(); resolve();
return; return;
} }
if (existing.dataset.mnoteFailed === "1") {
reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`)), {
once: true,
});
return;
}
const script = document.createElement("script"); const script = document.createElement("script");
script.src = src; script.src = src;
script.async = true; script.async = true;
script.onload = () => resolve(); script.onload = () => {
script.onerror = () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`)); script.dataset.mnoteLoaded = "1";
delete script.dataset.mnoteFailed;
resolve();
};
script.onerror = () => {
script.dataset.mnoteFailed = "1";
reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
};
document.body.appendChild(script); document.body.appendChild(script);
}); });
const loadScriptCandidates = async (candidates: string[]) => {
let lastError: Error | null = null;
for (const src of candidates) {
try {
await loadScript(src);
return src;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw lastError ?? new Error("加载 ONLYOFFICE 脚本失败");
};
const hashKey = (input: string) => { const hashKey = (input: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < input.length; i += 1) { for (let i = 0; i < input.length; i += 1) {
@@ -156,10 +186,14 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
const internalOrigins = new Set<string>([ const internalOrigins = new Set<string>([
"http://127.0.0.1:8081", "http://127.0.0.1:8081",
"http://localhost:8081", "http://localhost:8081",
"http://127.0.0.1:8082",
"http://localhost:8082",
// 说明:部分环境下 ONLYOFFICE 会错误拼出 https://127.0.0.1:8081 这类 URL // 说明:部分环境下 ONLYOFFICE 会错误拼出 https://127.0.0.1:8081 这类 URL
// 浏览器会报 ERR_SSL_PROTOCOL_ERROR(因为 8081 实际是 http)。这里也一起兜底重写。 // 浏览器会报 ERR_SSL_PROTOCOL_ERROR(因为 8081 实际是 http)。这里也一起兜底重写。
"https://127.0.0.1:8081", "https://127.0.0.1:8081",
"https://localhost:8081", "https://localhost:8081",
"https://127.0.0.1:8082",
"https://localhost:8082",
]); ]);
try { try {
if (onlyofficeBaseUrlDesktop) { if (onlyofficeBaseUrlDesktop) {
@@ -847,8 +881,12 @@ export default function OnlyOfficePage() {
if (documentId && !permissionResolved) return; if (documentId && !permissionResolved) return;
if (resolvedMode !== "view" && assetId && !authedUserId) return; if (resolvedMode !== "view" && assetId && !authedUserId) return;
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`; const scriptUrls = [
loadScript(scriptUrl) `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`,
`${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api-all.js`,
`${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/editor.js`,
];
loadScriptCandidates(scriptUrls)
.then(async () => { .then(async () => {
// 说明:api.js 的 onload 并不代表 DocsAPI/DocEditor 已完全就绪(在慢网/高负载时会出现空白页)。 // 说明:api.js 的 onload 并不代表 DocsAPI/DocEditor 已完全就绪(在慢网/高负载时会出现空白页)。
// 因此这里额外等待 DocEditor 挂载,避免偶发“白屏但无错误”的体验。 // 因此这里额外等待 DocEditor 挂载,避免偶发“白屏但无错误”的体验。
@@ -35,16 +35,23 @@ import { useAppPreferencesStore } from "@/store/app-preferences";
import { useCommentsUiStore } from "@/store/comments-ui"; import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react"; import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
interface BlockNoteEditorProps { interface BlockNoteEditorProps {
documentId: string; documentId: string;
workspaceId: string; workspaceId: string;
initialContent: unknown; initialContent: unknown;
initialRevision?: number | null;
initialConflictDetectionKey?: string | null;
pageOptions: PageOptionsState; pageOptions: PageOptionsState;
readOnly?: boolean; readOnly?: boolean;
onStatsChange?: (stats: DocumentStats) => void; onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void; onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
onCloseToc?: () => void; onCloseToc?: () => void;
onPersistedMetaChange?: (payload: {
revision: number | null;
conflictDetectionKey: string | null;
}) => void;
} }
const extractInitialBlocks = (content: unknown): Json | undefined => { const extractInitialBlocks = (content: unknown): Json | undefined => {
@@ -174,16 +181,22 @@ export function BlockNoteEditor({
documentId, documentId,
workspaceId, workspaceId,
initialContent, initialContent,
initialRevision = null,
initialConflictDetectionKey = null,
pageOptions, pageOptions,
readOnly = false, readOnly = false,
onStatsChange, onStatsChange,
onSnapshot, onSnapshot,
onCloseToc, onCloseToc,
onPersistedMetaChange,
}: BlockNoteEditorProps) { }: BlockNoteEditorProps) {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]); const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null); const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
const isFullScreenTableOpen = fullScreenTableId !== null; const isFullScreenTableOpen = fullScreenTableId !== null;
const revisionRef = useRef<number | null>(initialRevision);
const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference); const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge); const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
@@ -255,20 +268,76 @@ export function BlockNoteEditor({
[collaboration], [collaboration],
); );
useEffect(() => {
revisionRef.current = initialRevision;
}, [initialRevision]);
useEffect(() => {
conflictDetectionKeyRef.current = initialConflictDetectionKey;
}, [initialConflictDetectionKey]);
const saveContent = useCallback( const saveContent = useCallback(
async (content: Json) => { async (content: Json) => {
setIsSaving(true); setIsSaving(true);
try { try {
await fetch("/api/documents/save", { setSaveError(null);
const blockCount = Array.isArray(content) ? content.length : null;
const response = await fetch("/api/documents/save", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, workspaceId, content }), body: JSON.stringify(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
),
}); });
if (!response.ok) {
let message = "保存失败";
try {
const payload = await response.json();
if (payload && typeof payload === "object" && typeof payload.error === "string") {
message = payload.error;
}
} catch {
// ignore
}
setSaveError(message);
throw new Error(message);
}
const payload = await response.json() as {
revision?: number | null;
conflictDetectionKey?: string | null;
};
const nextRevision =
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const nextConflictDetectionKey =
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
revisionRef.current = nextRevision ?? null;
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
onPersistedMetaChange?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
});
setSaveError(null);
} catch (error) {
if (error instanceof Error) {
setSaveError(error.message);
}
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, },
[documentId, workspaceId], [documentId, onPersistedMetaChange, workspaceId],
); );
const debouncedSave = useDebouncedCallback(saveContent, 800); const debouncedSave = useDebouncedCallback(saveContent, 800);
@@ -1262,7 +1331,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
<CustomSlashMenu editor={editor} currentDocumentId={documentId} /> <CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView> </BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400"> <div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
{isSaving ? "保存中..." : "已保存"} {isSaving ? "保存中..." : saveError ? saveError : "已保存"}
</div> </div>
</div> </div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} /> <DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
@@ -107,6 +107,17 @@ type MindMapData = {
children?: unknown[]; children?: unknown[];
}; };
type MindmapRouteMeta = {
requestId?: string;
traceId?: string;
workspaceId?: string | null;
documentId?: string;
pageId?: string;
mindmapId?: string;
attachmentId?: string;
updatedAt?: string | null;
};
export const defaultMindmapData = { export const defaultMindmapData = {
data: { text: "中心主题" }, data: { text: "中心主题" },
children: [], children: [],
@@ -514,6 +525,26 @@ const MindmapBlockView = ({
[block.props.docId], [block.props.docId],
); );
const mindmapId = block.id; const mindmapId = block.id;
const pageId = docId;
const attachmentId = mindmapId;
const [requestMeta, setRequestMeta] = useState<{ requestId: string; traceId: string } | null>(null);
const syncMindmapRouteMeta = useCallback((meta: unknown) => {
if (!isRecord(meta)) return;
const requestId = typeof meta.requestId === "string" ? meta.requestId.trim() : "";
const traceId = typeof meta.traceId === "string" ? meta.traceId.trim() : "";
if (requestId && traceId) {
setRequestMeta((prev) =>
prev?.requestId === requestId && prev?.traceId === traceId
? prev
: { requestId, traceId },
);
}
const nextWorkspaceId = typeof meta.workspaceId === "string" ? meta.workspaceId.trim() : "";
if (nextWorkspaceId) {
setWorkspaceId((prev) => (prev === nextWorkspaceId ? prev : nextWorkspaceId));
}
}, []);
// 获取 workspaceId(用于上传图片) // 获取 workspaceId(用于上传图片)
useEffect(() => { useEffect(() => {
@@ -584,6 +615,7 @@ const MindmapBlockView = ({
const payload = await resp.json().catch(() => null); const payload = await resp.json().catch(() => null);
const data = payload?.data; const data = payload?.data;
if (!data || cancelled) return; if (!data || cancelled) return;
syncMindmapRouteMeta(payload?.meta);
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置” // 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
if (hasLocalEditsRef.current) return; if (hasLocalEditsRef.current) return;
initialDataRef.current = canonicalizeMindmapData(data); initialDataRef.current = canonicalizeMindmapData(data);
@@ -1388,7 +1420,13 @@ const MindmapBlockView = ({
if (resp.ok) { if (resp.ok) {
try { try {
const payload = (await resp.json().catch(() => null)) as any; const payload = (await resp.json().catch(() => null)) as any;
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null; syncMindmapRouteMeta(payload?.meta);
const updatedAt =
payload && typeof payload.updated_at === "string"
? payload.updated_at
: payload?.meta && typeof payload.meta.updatedAt === "string"
? payload.meta.updatedAt
: null;
if (updatedAt) { if (updatedAt) {
lastLocalSavedAtRef.current = updatedAt; lastLocalSavedAtRef.current = updatedAt;
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁 // 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
@@ -1471,6 +1509,9 @@ const MindmapBlockView = ({
body: JSON.stringify({ data, createOnly: true }), body: JSON.stringify({ data, createOnly: true }),
}); });
if (!resp.ok) { if (!resp.ok) {
} else {
const payload = (await resp.json().catch(() => null)) as { meta?: MindmapRouteMeta } | null;
syncMindmapRouteMeta(payload?.meta);
} }
} catch {} finally { } catch {} finally {
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新 // 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
@@ -3155,6 +3196,13 @@ const MindmapBlockView = ({
<div <div
ref={wrapperRef} ref={wrapperRef}
data-testid="mindmap-fullscreen" data-testid="mindmap-fullscreen"
data-page-id={pageId || undefined}
data-document-id={docId || undefined}
data-attachment-id={attachmentId}
data-mindmap-id={mindmapId}
data-workspace-id={workspaceId || undefined}
data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId}
tabIndex={0} tabIndex={0}
className="fixed inset-0 z-[9999] overflow-hidden bg-white" className="fixed inset-0 z-[9999] overflow-hidden bg-white"
> >
@@ -3166,7 +3214,13 @@ const MindmapBlockView = ({
ref={containerRef} ref={containerRef}
className="h-full w-full" className="h-full w-full"
data-testid="mindmap-canvas" data-testid="mindmap-canvas"
data-page-id={pageId || undefined}
data-document-id={docId || undefined}
data-attachment-id={attachmentId}
data-mindmap-id={mindmapId} data-mindmap-id={mindmapId}
data-workspace-id={workspaceId || undefined}
data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId}
contentEditable={false} contentEditable={false}
/> />
@@ -3215,6 +3269,13 @@ const MindmapBlockView = ({
<div <div
ref={wrapperRef} ref={wrapperRef}
data-testid="mindmap-embed" data-testid="mindmap-embed"
data-page-id={pageId || undefined}
data-document-id={docId || undefined}
data-attachment-id={attachmentId}
data-mindmap-id={mindmapId}
data-workspace-id={workspaceId || undefined}
data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId}
tabIndex={0} tabIndex={0}
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm" className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
> >
@@ -3258,7 +3319,13 @@ const MindmapBlockView = ({
ref={containerRef} ref={containerRef}
className="h-full w-full" className="h-full w-full"
data-testid="mindmap-canvas" data-testid="mindmap-canvas"
data-page-id={pageId || undefined}
data-document-id={docId || undefined}
data-attachment-id={attachmentId}
data-mindmap-id={mindmapId} data-mindmap-id={mindmapId}
data-workspace-id={workspaceId || undefined}
data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId}
contentEditable={false} contentEditable={false}
/> />
@@ -38,6 +38,8 @@ export interface DocumentContentProps {
title: string | null; title: string | null;
updatedAt: string | null; updatedAt: string | null;
initialContent: unknown; initialContent: unknown;
initialContentRevision?: number | null;
initialConflictDetectionKey?: string | null;
initialOptions: PageOptionsState; initialOptions: PageOptionsState;
initialStats: DocumentStats | null; initialStats: DocumentStats | null;
openTableId?: string | null; openTableId?: string | null;
@@ -69,6 +71,8 @@ export function DocumentContent({
title, title,
updatedAt, updatedAt,
initialContent, initialContent,
initialContentRevision = null,
initialConflictDetectionKey = null,
initialOptions, initialOptions,
initialStats, initialStats,
openTableId, openTableId,
@@ -90,6 +94,8 @@ export function DocumentContent({
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker); const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const [pageTitle, setPageTitle] = useState(title ?? "无标题"); const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const [content, setContent] = useState<unknown>(initialContent); const [content, setContent] = useState<unknown>(initialContent);
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const [contentLoading, setContentLoading] = useState(() => initialContent == null); const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(null); const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0); const [contentReloadKey, setContentReloadKey] = useState(0);
@@ -205,6 +211,14 @@ export function DocumentContent({
setStats(initialStats ?? defaultStats); setStats(initialStats ?? defaultStats);
}, [initialStats]); }, [initialStats]);
useEffect(() => {
setContentRevision(initialContentRevision);
}, [initialContentRevision]);
useEffect(() => {
setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]);
useEffect(() => { useEffect(() => {
let canceled = false; let canceled = false;
@@ -244,9 +258,23 @@ export function DocumentContent({
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error ?? "加载页面内容失败"); throw new Error(payload?.error ?? "加载页面内容失败");
} }
const payload = (await response.json()) as { content?: unknown }; const payload = (await response.json()) as {
content?: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
};
if (canceled) return; if (canceled) return;
setContent(payload.content ?? null); setContent(payload.content ?? null);
setContentRevision(
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: 0,
);
setConflictDetectionKey(
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: `${documentId}:0`,
);
} catch (error) { } catch (error) {
if (canceled) return; if (canceled) return;
if ((error as { name?: string })?.name === "AbortError") return; if ((error as { name?: string })?.name === "AbortError") return;
@@ -683,11 +711,17 @@ export function DocumentContent({
documentId={documentId} documentId={documentId}
workspaceId={workspaceId} workspaceId={workspaceId}
initialContent={content} initialContent={content}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options} pageOptions={options}
readOnly={readOnly} readOnly={readOnly}
onStatsChange={handleStatsChange} onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot} onSnapshot={handleSnapshot}
onCloseToc={closeToc} onCloseToc={closeToc}
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
setContentRevision(revision);
setConflictDetectionKey(nextConflictDetectionKey);
}}
/> />
)} )}
<PageBacklinksPanel <PageBacklinksPanel
@@ -1,14 +1,23 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useQuery, useConvexAuth } from "convex/react"; import { useQuery, useConvexAuth } from "convex/react";
import type { FunctionReference } from "convex/server";
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { buildSidebarInitialData } from "@/lib/sidebar-data"; import {
mapSidebarDatasetListQueryResultToInitialData,
type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url"; import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
type CurrentUserRecord = { const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sidebar as
_id?: string; | Record<string, unknown>
}; | undefined)?.datasetList as FunctionReference<
"query",
"public",
{ workspaceId: string },
SidebarDatasetListQueryResult
>;
/** /**
* Convex hook * Convex hook
@@ -27,51 +36,9 @@ export function useConvexSidebarData(workspaceId: string): {
// 显式转为 boolean,确保类型正确 // 显式转为 boolean,确保类型正确
const shouldFetch = Boolean(isAuthenticated && workspaceId); const shouldFetch = Boolean(isAuthenticated && workspaceId);
const currentUser = useQuery(api.users.currentUser, shouldFetch ? {} : "skip"); const sidebarDataset = useQuery(
const currentUserRecord = sidebarDatasetListQuery,
currentUser && typeof currentUser === "object" ? (currentUser as CurrentUserRecord) : null; shouldFetch ? { workspaceId } : "skip",
const userId =
currentUserRecord && typeof currentUserRecord._id === "string"
? currentUserRecord._id
: "";
const shouldFetchAuthed = Boolean(shouldFetch && userId);
// 使用 Convex 的 useQuery,自动订阅实时更新
// 后端从 ctx.auth.getUserIdentity() 获取用户身份,无需前端传入 userId
const documents = useQuery(
api.documents.listByWorkspace,
shouldFetch ? { workspaceId } : "skip"
);
const trashedDocuments = useQuery(
api.documents.listTrashedByWorkspace,
shouldFetch ? { workspaceId } : "skip"
);
const mindmaps = useQuery(
api.mindmaps.listByWorkspace,
shouldFetch ? { workspaceId, includeDeleted: true } : "skip"
);
const mediaAssets = useQuery(
api.mediaAssets.listByWorkspace,
shouldFetchAuthed ? { userId, workspaceId, limit: 200 } : "skip",
);
const trashedMediaAssets = useQuery(
api.mediaAssets.listDeletedByWorkspace,
shouldFetchAuthed ? { userId, workspaceId, limit: 2000 } : "skip",
);
const tables = useQuery(
api.tables.listByWorkspaceForSearch,
shouldFetchAuthed ? { userId, workspaceId, includeArchived: true, limit: 3000 } : "skip",
);
const workspacesResult = useQuery(
api.workspaces.fetchWorkspaceSummaries,
shouldFetch ? {} : "skip",
); );
const normalizeAssetUrls = (asset: MediaAsset): MediaAsset => { const normalizeAssetUrls = (asset: MediaAsset): MediaAsset => {
@@ -80,54 +47,39 @@ export function useConvexSidebarData(workspaceId: string): {
return { ...asset, file_url: fileUrl, thumbnail_url: thumbUrl }; return { ...asset, file_url: fileUrl, thumbnail_url: thumbUrl };
}; };
// 组合数据,格式与 SidebarInitialData 一致 const normalizedSidebarDataset = useMemo<SidebarDatasetListQueryResult | null>(() => {
const data: SidebarInitialData | null = useMemo(() => { if (sidebarDataset === undefined || !sidebarDataset) {
// 当 skip 时,返回值是 undefined
if (currentUser === undefined ||
documents === undefined ||
trashedDocuments === undefined ||
mindmaps === undefined ||
mediaAssets === undefined ||
trashedMediaAssets === undefined ||
tables === undefined ||
workspacesResult === undefined) {
return null; return null;
} }
return buildSidebarInitialData({ return {
activeWorkspaceId: workspacesResult.activeWorkspaceId || workspaceId, ...sidebarDataset,
workspaces: workspacesResult.workspaces, media_assets: ((sidebarDataset.media_assets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
documents, trashed_media_assets: ((sidebarDataset.trashed_media_assets ?? []) as MediaAsset[]).map(
trashedDocuments, normalizeAssetUrls,
mindmaps, ),
mediaAssets: ((mediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls), mindmap_assets: ((sidebarDataset.mindmap_assets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
trashedMediaAssets: ((trashedMediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls), trashed_mindmap_assets: ((sidebarDataset.trashed_mindmap_assets ?? []) as MediaAsset[]).map(
tables, normalizeAssetUrls,
}); ),
}, [ table_assets: ((sidebarDataset.table_assets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
currentUser, trashed_table_assets: ((sidebarDataset.trashed_table_assets ?? []) as MediaAsset[]).map(
documents, normalizeAssetUrls,
trashedDocuments, ),
mindmaps, };
mediaAssets, }, [sidebarDataset]);
trashedMediaAssets,
tables, // 组合数据,格式与 SidebarInitialData 一致
workspacesResult, const data: SidebarInitialData | null = useMemo(() => {
workspaceId, if (!normalizedSidebarDataset) {
]); return null;
}
return mapSidebarDatasetListQueryResultToInitialData(normalizedSidebarDataset);
}, [normalizedSidebarDataset]);
// loading 状态:只有当 shouldFetch 为 true 且数据未加载时才算 loading // loading 状态:只有当 shouldFetch 为 true 且数据未加载时才算 loading
// 使用 === undefined 判断,因为 skip 时返回 undefined // 使用 === undefined 判断,因为 skip 时返回 undefined
const isLoading = shouldFetch && ( const isLoading = shouldFetch && sidebarDataset === undefined;
currentUser === undefined ||
documents === undefined ||
trashedDocuments === undefined ||
mindmaps === undefined ||
mediaAssets === undefined ||
trashedMediaAssets === undefined ||
tables === undefined ||
workspacesResult === undefined
);
const error = null; const error = null;
// Convex 模式下数据自动实时同步,refetch 是空操作 // Convex 模式下数据自动实时同步,refetch 是空操作
@@ -29,11 +29,14 @@ import {
assertOptionsPatch, assertOptionsPatch,
assertStats, assertStats,
assertTitle, assertTitle,
buildDocumentBridgeMutationRequest,
buildDocumentCommandEnvelope, buildDocumentCommandEnvelope,
buildDocumentQueryEnvelope, buildDocumentQueryEnvelope,
type BridgeContext, type BridgeContext,
} from "@/lib/documents/bridge"; } from "@/lib/documents/bridge";
import { executeMetadataBridgeCommand } from "@/lib/documents/metadata-command-adapter"; import { executeMetadataBridgeCommand } from "@/lib/documents/metadata-command-adapter";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
vi.mock("@/lib/convex/route", () => ({ vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(), getAuthedConvexClient: vi.fn(),
@@ -131,6 +134,100 @@ describe("documents bridge helpers", () => {
expect(envelope.payload).toEqual({ documentId: "doc_1" }); expect(envelope.payload).toEqual({ documentId: "doc_1" });
}); });
it("buildDocumentBridgeMutationRequest builds title update runtime request", () => {
const envelope = buildDocumentCommandEnvelope({
name: "documents.title.update",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
});
const request = buildDocumentBridgeMutationRequest({
context: mockContext,
envelope,
mapConvexArgs: (payload) => ({
id: payload.documentId,
title: payload.title,
}),
});
expect(request.functionName).toBe("documents:updateTitle");
expect(request.workspaceId).toBe("ws_1");
expect(request.args).toEqual({
id: "doc_1",
title: "新标题",
});
expect(JSON.parse(request.payloadJson)).toEqual({
kind: "command",
name: "documents.title.update",
request_id: "req_1",
trace_id: "trace_1",
deployment_id: null,
project_id: null,
workspace_id: "ws_1",
tenant_id: null,
idempotency_key: "idem_1",
actor: {
type: "user",
id: "user_1",
session_id: "sess_1",
},
source: {
channel: "next-route",
client: "vitest",
},
});
});
it("buildDocumentBridgeMutationRequest builds documents.save runtime request", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
});
const request = buildDocumentBridgeMutationRequest({
context: mockContext,
envelope,
mapConvexArgs: (nextPayload) => ({
id: nextPayload.documentId,
content: nextPayload.content,
expectedRevision: nextPayload.revision,
conflictDetectionKey: nextPayload.conflictDetectionKey,
}),
});
expect(request.functionName).toBe("documents:updateContent");
expect(request.workspaceId).toBe("ws_1");
expect(request.args).toEqual({
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
});
expect(JSON.parse(request.payloadJson)).toMatchObject({
kind: "command",
name: "documents.save",
workspace_id: "ws_1",
request_id: "req_1",
trace_id: "trace_1",
});
});
it("executeMetadataBridgeCommand routes title update through adapter", async () => { it("executeMetadataBridgeCommand routes title update through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true }); const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route"); const { getAuthedConvexClient } = await import("@/lib/convex/route");
@@ -212,4 +309,95 @@ describe("documents bridge helpers", () => {
}); });
expect(result.commandName).toBe("documents.options.update"); expect(result.commandName).toBe("documents.options.update");
}); });
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation,
} as unknown as ConvexHttpClient,
});
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
const result = await executeSaveBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toEqual({
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
});
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
previousBridgeArtifactCalls + 1,
);
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.save",
target: { workspaceId: "ws_1", pageId: "doc_1" },
payload,
}),
});
expect(result.requestId).toBe("req_1");
expect(result.traceId).toBe("trace_1");
expect(result.commandName).toBe("documents.save");
});
it("executeSaveBridgeCommand 将冲突错误归一为 bridge rejected", async () => {
const mutation = vi.fn().mockRejectedValue(new Error("正文内容已变更,请刷新后重试"));
const { getAuthedConvexClient } = await import("@/lib/convex/route");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation,
} as unknown as ConvexHttpClient,
});
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
});
await expect(
executeSaveBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
}),
).rejects.toMatchObject({
name: "DocumentBridgeError",
status: 409,
code: "REJECTED",
});
});
}); });
+111
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import type { ConvexHttpClient } from "convex/browser";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { apiErrorResponse } from "@/lib/api-utils"; import { apiErrorResponse } from "@/lib/api-utils";
import type { PageOptionsState } from "@/types/page-options"; import type { PageOptionsState } from "@/types/page-options";
@@ -80,6 +81,28 @@ export type QueryEnvelope<T> = {
payload: T; payload: T;
}; };
export type DocumentBridgeMutationRequest<
TArgs extends Record<string, unknown> = Record<string, unknown>,
> = {
functionName: string;
deploymentId: string | null;
projectId: string | null;
workspaceId: string | null;
requestId: string;
traceId: string;
idempotencyKey: string | null;
actorId: string;
payloadJson: string;
args: TArgs;
};
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.title.update": "documents:updateTitle",
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
"documents.save": "documents:updateContent",
} as const satisfies Record<string, string>;
function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null { function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
for (const candidate of candidates) { for (const candidate of candidates) {
const value = headerList.get(candidate); const value = headerList.get(candidate);
@@ -187,6 +210,94 @@ export function buildDocumentQueryEnvelope<T>(input: { name: string; payload: T
}; };
} }
function getDocumentBridgeMutationFunctionName(commandName: string): string {
const functionName =
DOCUMENT_BRIDGE_MUTATION_FUNCTIONS[
commandName as keyof typeof DOCUMENT_BRIDGE_MUTATION_FUNCTIONS
];
if (!functionName) {
throw new DocumentBridgeError(
`未注册文档 bridge mutation: ${commandName}`,
500,
"TRANSPORT_ERROR",
);
}
return functionName;
}
function buildDocumentCommandPayloadJson(input: {
context: BridgeContext;
commandName: string;
workspaceId: string | null;
idempotencyKey: string | null;
}): string {
return JSON.stringify({
kind: "command",
name: input.commandName,
request_id: input.context.requestId,
trace_id: input.context.traceId,
deployment_id: input.context.deploymentId,
project_id: input.context.projectId,
workspace_id: input.workspaceId,
tenant_id: input.context.tenantId,
idempotency_key: input.idempotencyKey,
actor: {
type: input.context.actor.actorType,
id: input.context.actor.actorId,
session_id: input.context.actor.sessionId,
},
source: {
channel: input.context.source.channel,
client: input.context.source.client,
},
});
}
export function buildDocumentBridgeMutationRequest<
TPayload,
TArgs extends Record<string, unknown>,
>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
mapConvexArgs: (payload: TPayload) => TArgs;
}): DocumentBridgeMutationRequest<TArgs> {
const workspaceId = input.envelope.target?.workspaceId ?? input.context.workspaceId ?? null;
const idempotencyKey = input.envelope.idempotencyKey ?? input.context.idempotencyKey;
return {
functionName: getDocumentBridgeMutationFunctionName(input.envelope.name),
deploymentId: input.context.deploymentId,
projectId: input.context.projectId,
workspaceId,
requestId: input.context.requestId,
traceId: input.context.traceId,
idempotencyKey,
actorId: input.context.actor.actorId,
payloadJson: buildDocumentCommandPayloadJson({
context: input.context,
commandName: input.envelope.name,
workspaceId,
idempotencyKey,
}),
args: input.mapConvexArgs(input.envelope.payload),
};
}
export async function executeDocumentBridgeMutationRequest<
TArgs extends Record<string, unknown>,
TResult,
>(input: {
client: ConvexHttpClient;
mutation: unknown;
request: DocumentBridgeMutationRequest<TArgs>;
}): Promise<TResult> {
const mutate = input.client.mutation.bind(input.client) as (
mutation: unknown,
args: TArgs,
) => Promise<TResult>;
return mutate(input.mutation, input.request.args);
}
export function assertDocumentId(documentId: string | null | undefined): string { export function assertDocumentId(documentId: string | null | undefined): string {
const normalized = typeof documentId === "string" ? documentId.trim() : ""; const normalized = typeof documentId === "string" ? documentId.trim() : "";
if (!normalized) { if (!normalized) {
@@ -1,6 +1,11 @@
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import type { CommandEnvelope, BridgeContext } from "@/lib/documents/bridge"; import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log"; import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
import type { PageOptionsState } from "@/types/page-options"; import type { PageOptionsState } from "@/types/page-options";
@@ -35,9 +40,11 @@ export type MetadataCommandExecutionResult = {
commandName: string; commandName: string;
}; };
type MetadataMutationArgs = Record<string, unknown>;
type MetadataWriteAdapter<TPayload> = { type MetadataWriteAdapter<TPayload> = {
convexMutation: unknown; convexMutation: unknown;
mapConvexArgs: (payload: TPayload) => Record<string, unknown>; mapConvexArgs: (payload: TPayload) => MetadataMutationArgs;
}; };
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) { function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
@@ -101,11 +108,17 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
}): Promise<MetadataCommandExecutionResult> { }): Promise<MetadataCommandExecutionResult> {
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name); const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
const { client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const mutationRequest = buildDocumentBridgeMutationRequest({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs,
});
await client.mutation( await executeDocumentBridgeMutationRequest({
adapter.convexMutation as Parameters<typeof client.mutation>[0], client,
adapter.mapConvexArgs(input.envelope.payload) as Parameters<typeof client.mutation>[1], mutation: adapter.convexMutation,
); request: mutationRequest,
});
await recordBridgeCommandArtifacts({ await recordBridgeCommandArtifacts({
context: input.context, context: input.context,
envelope: input.envelope, envelope: input.envelope,
@@ -0,0 +1,75 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
import { DocumentBridgeError } from "@/lib/documents/bridge";
export type DocumentSaveExecutionResult = {
requestId: string;
traceId: string;
commandId: string;
commandName: string;
revision: number | null;
conflictDetectionKey: string | null;
};
export async function executeSaveBridgeCommand(input: {
context: BridgeContext;
envelope: CommandEnvelope<DocumentSavePayload>;
}): Promise<DocumentSaveExecutionResult> {
const { client } = await getAuthedConvexClient();
const mutationRequest = buildDocumentBridgeMutationRequest({
context: input.context,
envelope: input.envelope,
mapConvexArgs: (payload) => ({
id: payload.documentId,
content: payload.content,
expectedRevision: payload.revision,
conflictDetectionKey: payload.conflictDetectionKey,
}),
});
let mutationResult;
try {
mutationResult = await executeDocumentBridgeMutationRequest({
client,
mutation: api.documents.updateContent,
request: mutationRequest,
});
} catch (error) {
if (error instanceof Error && /正文(内容已变更|冲突检测失败)/.test(error.message)) {
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
reason: "content_conflict",
revision: input.envelope.payload.revision,
conflictDetectionKey: input.envelope.payload.conflictDetectionKey,
});
}
throw error;
}
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision:
typeof mutationResult?.revision === "number" && Number.isInteger(mutationResult.revision)
? mutationResult.revision
: null,
conflictDetectionKey:
typeof mutationResult?.conflict_detection_key === "string" &&
mutationResult.conflict_detection_key.trim()
? mutationResult.conflict_detection_key
: null,
};
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
describe("buildDocumentSavePayload", () => {
it("统一规范 documents.save 的共享 payload", () => {
const payload = buildDocumentSavePayload({
documentId: " doc_1 ",
workspaceId: " ws_1 ",
revision: 3,
content: [{ id: "block_1", type: "paragraph", content: [] }],
conflictDetectionKey: " conflict_1 ",
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 3,
content: [{ id: "block_1", type: "paragraph", content: [] }],
conflictDetectionKey: "conflict_1",
snapshotCapturedAt: null,
blockCount: null,
});
});
it("非法 revision/conflictDetectionKey 会回退到 null", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "",
revision: -1,
content: [],
conflictDetectionKey: " ",
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: null,
revision: null,
content: [],
conflictDetectionKey: null,
snapshotCapturedAt: null,
blockCount: null,
});
});
it("保留正文快照采集元数据", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 4,
content: [{ id: "block_1" }],
conflictDetectionKey: "doc_1:4",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 4,
content: [{ id: "block_1" }],
conflictDetectionKey: "doc_1:4",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
});
});
@@ -0,0 +1,48 @@
import type { Json } from "@/types/supabase";
export type DocumentSavePayload = {
documentId: string;
workspaceId: string | null;
revision: number | null;
content: Json;
conflictDetectionKey: string | null;
snapshotCapturedAt: string | null;
blockCount: number | null;
};
export function buildDocumentSavePayload(input: {
documentId: string;
workspaceId?: string | null;
revision?: number | null;
content: Json;
conflictDetectionKey?: string | null;
snapshotCapturedAt?: string | null;
blockCount?: number | null;
}): DocumentSavePayload {
const revision =
typeof input.revision === "number" && Number.isInteger(input.revision) && input.revision >= 0
? input.revision
: null;
const conflictDetectionKey =
typeof input.conflictDetectionKey === "string" && input.conflictDetectionKey.trim()
? input.conflictDetectionKey.trim()
: null;
const snapshotCapturedAt =
typeof input.snapshotCapturedAt === "string" && input.snapshotCapturedAt.trim()
? input.snapshotCapturedAt.trim()
: null;
const blockCount =
typeof input.blockCount === "number" && Number.isInteger(input.blockCount) && input.blockCount >= 0
? input.blockCount
: null;
return {
documentId: input.documentId.trim(),
workspaceId: input.workspaceId?.trim() || null,
revision,
content: input.content,
conflictDetectionKey,
snapshotCapturedAt,
blockCount,
};
}
@@ -0,0 +1,56 @@
import { randomUUID } from "crypto";
export type MindmapRouteMeta = {
requestId: string;
traceId: string;
workspaceId: string | null;
documentId: string;
pageId: string;
mindmapId: string;
attachmentId: string;
ownerUserId: string;
source: "convex";
};
function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
for (const candidate of candidates) {
const value = headerList.get(candidate);
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function makeFallbackId(prefix: string): string {
return `${prefix}_${randomUUID()}`;
}
export function buildMindmapRouteMeta(
request: Request,
input: {
workspaceId?: string | null;
documentId: string;
mindmapId: string;
ownerUserId: string;
},
): MindmapRouteMeta {
const requestId =
readHeaderValue(request.headers, "x-request-id", "x-mnote-request-id") ??
makeFallbackId("req");
const traceId =
readHeaderValue(request.headers, "x-trace-id", "x-mnote-trace-id", "x-request-id") ??
makeFallbackId("trace");
return {
requestId,
traceId,
workspaceId: input.workspaceId?.trim() || null,
documentId: input.documentId,
pageId: input.documentId,
mindmapId: input.mindmapId,
attachmentId: input.mindmapId,
ownerUserId: input.ownerUserId,
source: "convex",
};
}
@@ -0,0 +1,93 @@
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081";
const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js";
const RESOLVE_CACHE_TTL_MS = 30_000;
const DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES = [
DEFAULT_ONLYOFFICE_INTERNAL_URL,
"http://127.0.0.1:8082",
"http://localhost:8081",
"http://localhost:8082",
];
let cachedOnlyOfficeInternalUrl = "";
let cachedOnlyOfficeInternalUrlAt = 0;
let pendingOnlyOfficeInternalUrl: Promise<string> | null = null;
const normalizeOnlyOfficeInternalUrl = (raw?: string | null) => {
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 "";
}
};
const getOnlyOfficeInternalUrlCandidates = () => {
const candidates: string[] = [];
const push = (value?: string | null) => {
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);
}
for (const value of DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES) {
push(value);
}
return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
};
const probeOnlyOfficeInternalUrl = async (candidate: string) => {
try {
const probeUrl = new URL(ONLYOFFICE_PROBE_PATH, `${candidate}/`);
const response = await fetch(probeUrl, {
method: "HEAD",
redirect: "follow",
cache: "no-store",
signal: AbortSignal.timeout(2_500),
});
return response.ok;
} catch {
return false;
}
};
export const resolveOnlyOfficeInternalUrl = async () => {
const now = Date.now();
if (cachedOnlyOfficeInternalUrl && now - cachedOnlyOfficeInternalUrlAt < RESOLVE_CACHE_TTL_MS) {
return cachedOnlyOfficeInternalUrl;
}
if (pendingOnlyOfficeInternalUrl) {
return pendingOnlyOfficeInternalUrl;
}
pendingOnlyOfficeInternalUrl = (async () => {
const candidates = getOnlyOfficeInternalUrlCandidates();
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 resolved;
} finally {
pendingOnlyOfficeInternalUrl = null;
}
};
+24 -45
View File
@@ -1,14 +1,17 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import type { ConvexHttpClient } from "convex/browser"; import type { ConvexHttpClient } from "convex/browser";
import type { FunctionReference } from "convex/server";
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import type { DocumentRecord } from "@/lib/documents"; import type { DocumentRecord } from "@/lib/documents";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { buildSidebarInitialData } from "@/lib/sidebar-data"; import {
mapSidebarDatasetListQueryResultToInitialData,
type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
import type { WorkspaceSummary } from "@/lib/workspaces"; import type { WorkspaceSummary } from "@/lib/workspaces";
type LoadSidebarDataFromConvexInput = { type LoadSidebarDataFromConvexInput = {
client: ConvexHttpClient; client: ConvexHttpClient;
userId: string;
fallbackName: string; fallbackName: string;
requestedWorkspaceId?: string | null; requestedWorkspaceId?: string | null;
}; };
@@ -17,10 +20,20 @@ type LoadSidebarDataFromConvexResult = {
workspaces: WorkspaceSummary[]; workspaces: WorkspaceSummary[];
activeWorkspaceId: string; activeWorkspaceId: string;
targetWorkspaceId: string | null; targetWorkspaceId: string | null;
sidebarDataset: SidebarDatasetListQueryResult | null;
sidebarInitialData: SidebarInitialData | null; sidebarInitialData: SidebarInitialData | null;
documents: DocumentRecord[]; documents: DocumentRecord[];
}; };
const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sidebar as
| Record<string, unknown>
| undefined)?.datasetList as FunctionReference<
"query",
"public",
{ workspaceId: string },
SidebarDatasetListQueryResult
>;
export async function loadSidebarDataFromConvex( export async function loadSidebarDataFromConvex(
input: LoadSidebarDataFromConvexInput, input: LoadSidebarDataFromConvexInput,
): Promise<LoadSidebarDataFromConvexResult> { ): Promise<LoadSidebarDataFromConvexResult> {
@@ -29,9 +42,8 @@ export async function loadSidebarDataFromConvex(
workspaceIdIfCreate: randomUUID(), workspaceIdIfCreate: randomUUID(),
}); });
const summaries = await input.client.query(api.workspaces.fetchWorkspaceSummaries, {}); const workspaces = bootstrap.workspaces;
const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces; const activeWorkspaceId = bootstrap.activeWorkspaceId;
const activeWorkspaceId = summaries.activeWorkspaceId || bootstrap.activeWorkspaceId;
const targetWorkspaceId = input.requestedWorkspaceId?.trim() || activeWorkspaceId || null; const targetWorkspaceId = input.requestedWorkspaceId?.trim() || activeWorkspaceId || null;
if (!targetWorkspaceId) { if (!targetWorkspaceId) {
@@ -39,56 +51,23 @@ export async function loadSidebarDataFromConvex(
workspaces, workspaces,
activeWorkspaceId, activeWorkspaceId,
targetWorkspaceId: null, targetWorkspaceId: null,
sidebarDataset: null,
sidebarInitialData: null, sidebarInitialData: null,
documents: [], documents: [],
}; };
} }
const [documents, trashedDocuments, mindmaps, mediaAssets, trashedMediaAssets, tables] = await Promise.all([ const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
input.client.query(api.documents.listByWorkspace, {
workspaceId: targetWorkspaceId, workspaceId: targetWorkspaceId,
}), })) as SidebarDatasetListQueryResult;
input.client.query(api.documents.listTrashedByWorkspace, { const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
workspaceId: targetWorkspaceId,
}),
input.client.query(api.mindmaps.listByWorkspace, {
workspaceId: targetWorkspaceId,
includeDeleted: true,
}),
input.client.query(api.mediaAssets.listByWorkspace, {
userId: input.userId,
workspaceId: targetWorkspaceId,
limit: 200,
}),
input.client.query(api.mediaAssets.listDeletedByWorkspace, {
userId: input.userId,
workspaceId: targetWorkspaceId,
limit: 2000,
}),
input.client.query(api.tables.listByWorkspaceForSearch, {
userId: input.userId,
workspaceId: targetWorkspaceId,
includeArchived: true,
limit: 3000,
}),
]);
const normalizedDocuments = documents as DocumentRecord[];
return { return {
workspaces, workspaces: sidebarDataset.workspaces ?? workspaces,
activeWorkspaceId, activeWorkspaceId,
targetWorkspaceId, targetWorkspaceId,
sidebarInitialData: buildSidebarInitialData({ sidebarDataset,
activeWorkspaceId: targetWorkspaceId, sidebarInitialData: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
workspaces,
documents: normalizedDocuments,
trashedDocuments,
mindmaps: mindmaps ?? [],
mediaAssets: mediaAssets ?? [],
trashedMediaAssets: trashedMediaAssets ?? [],
tables: tables ?? [],
}),
documents: normalizedDocuments, documents: normalizedDocuments,
}; };
} }
+119 -1
View File
@@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest";
import type { DocumentRecord } from "@/lib/documents"; import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces"; import type { WorkspaceSummary } from "@/lib/workspaces";
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarInitialData, extractMindmapImageAssetIdsFromData } from "@/lib/sidebar-data"; import {
buildSidebarDatasetListQueryPayload,
buildSidebarDatasetListQueryResult,
buildSidebarInitialData,
extractMindmapImageAssetIdsFromData,
mapSidebarDatasetListQueryResultToInitialData,
} from "@/lib/sidebar-data";
describe("extractMindmapImageAssetIdsFromData", () => { describe("extractMindmapImageAssetIdsFromData", () => {
it("提取导图节点里的 asset 图片引用并去重", () => { it("提取导图节点里的 asset 图片引用并去重", () => {
@@ -140,4 +146,116 @@ describe("buildSidebarInitialData", () => {
expect(payload.trashedTableAssets?.map((item) => item.id)).toEqual(["table_2"]); expect(payload.trashedTableAssets?.map((item) => item.id)).toEqual(["table_2"]);
expect(payload.mediaAssets?.map((item) => item.id)).toEqual(["asset_file_1"]); expect(payload.mediaAssets?.map((item) => item.id)).toEqual(["asset_file_1"]);
}); });
it("冻结 sidebar.dataset.list 的 Rust query 契约字段", () => {
const queryPayload = buildSidebarDatasetListQueryPayload(" ws_1 ");
expect(queryPayload).toEqual({ workspace_id: "ws_1" });
const queryResult = buildSidebarDatasetListQueryResult({
activeWorkspaceId: "ws_1",
workspaces: [
{
id: "ws_1",
name: "工作区",
type: "personal",
iconUrl: null,
memberCount: 1,
isDefault: true,
},
],
documents: [
{
id: "doc_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 1,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
},
],
trashedDocuments: [],
mindmaps: [],
mediaAssets: [],
trashedMediaAssets: [],
tables: [],
});
expect(queryResult).toEqual({
active_workspace_id: "ws_1",
workspaces: [
{
id: "ws_1",
name: "工作区",
type: "personal",
iconUrl: null,
memberCount: 1,
isDefault: true,
},
],
documents: [
{
id: "doc_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 1,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
},
],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
});
expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toEqual({
activeWorkspaceId: "ws_1",
workspaces: [
{
id: "ws_1",
name: "工作区",
type: "personal",
iconUrl: null,
memberCount: 1,
isDefault: true,
},
],
documents: [
{
id: "doc_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 1,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
},
],
trashedDocuments: [],
trashedMediaAssets: [],
trashedMindmapAssets: [],
trashedTableAssets: [],
mindmapDocs: [],
mindmapAssets: [],
mindmapAssetChildren: {},
tableAssets: [],
mediaAssets: [],
});
});
}); });
+79 -12
View File
@@ -27,7 +27,7 @@ type TableRow = {
is_archived?: boolean | null; is_archived?: boolean | null;
}; };
type SidebarDatasetInput = { export type SidebarDatasetInput = {
activeWorkspaceId: string; activeWorkspaceId: string;
workspaces: WorkspaceSummary[]; workspaces: WorkspaceSummary[];
documents: DocumentRecord[]; documents: DocumentRecord[];
@@ -38,10 +38,37 @@ type SidebarDatasetInput = {
tables?: TableRow[] | null; tables?: TableRow[] | null;
}; };
export type SidebarDatasetListQueryPayload = {
workspace_id: string;
};
export type SidebarDatasetListQueryResult = {
active_workspace_id: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
trashed_documents: SidebarInitialData["trashedDocuments"];
media_assets: MediaAsset[];
trashed_media_assets: MediaAsset[];
mindmap_assets: MediaAsset[];
trashed_mindmap_assets: MediaAsset[];
table_assets: MediaAsset[];
trashed_table_assets: MediaAsset[];
mindmap_docs: string[];
mindmap_asset_children: Record<string, string[]>;
};
function normalizeStringArray(values: Iterable<string>): string[] { function normalizeStringArray(values: Iterable<string>): string[] {
return Array.from(new Set(values)).filter((value) => value.trim().length > 0); return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
} }
export function buildSidebarDatasetListQueryPayload(
workspaceId: string,
): SidebarDatasetListQueryPayload {
return {
workspace_id: workspaceId.trim(),
};
}
export function extractMindmapImageAssetIdsFromData(input: unknown): string[] { export function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
const root = (() => { const root = (() => {
if (!input || typeof input !== "object") return input; if (!input || typeof input !== "object") return input;
@@ -154,7 +181,7 @@ function toTrashedTableAsset(row: TableRow, workspaceId: string): MediaAsset {
}; };
} }
export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInitialData { function deriveSidebarDataset(input: SidebarDatasetInput) {
const activeMindmaps = input.mindmaps.filter((row) => !row.deleted_at); const activeMindmaps = input.mindmaps.filter((row) => !row.deleted_at);
const trashedMindmaps = input.mindmaps.filter((row) => Boolean(row.deleted_at)); const trashedMindmaps = input.mindmaps.filter((row) => Boolean(row.deleted_at));
const activeTables = (input.tables ?? []).filter((row) => !row.is_archived); const activeTables = (input.tables ?? []).filter((row) => !row.is_archived);
@@ -169,21 +196,61 @@ export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInit
}); });
return { return {
activeWorkspaceId: input.activeWorkspaceId, mindmapAssetChildren,
workspaces: input.workspaces, mindmapAssets: activeMindmaps.map((row) => toMindmapAsset(row, input.activeWorkspaceId)),
documents: input.documents, mindmapDocs: normalizeStringArray(activeMindmaps.map((row) => row.document_id)),
trashedDocuments: input.trashedDocuments, tableAssets: activeTables.map((row) => toTableAsset(row, input.activeWorkspaceId)),
trashedMediaAssets: [...(input.trashedMediaAssets ?? [])],
trashedMindmapAssets: trashedMindmaps.map((row) => trashedMindmapAssets: trashedMindmaps.map((row) =>
toTrashedMindmapAsset(row, input.activeWorkspaceId), toTrashedMindmapAsset(row, input.activeWorkspaceId),
), ),
trashedTableAssets: trashedTables.map((row) => trashedTableAssets: trashedTables.map((row) =>
toTrashedTableAsset(row, input.activeWorkspaceId), toTrashedTableAsset(row, input.activeWorkspaceId),
), ),
mindmapDocs: normalizeStringArray(activeMindmaps.map((row) => row.document_id)),
mindmapAssets: activeMindmaps.map((row) => toMindmapAsset(row, input.activeWorkspaceId)),
mindmapAssetChildren,
tableAssets: activeTables.map((row) => toTableAsset(row, input.activeWorkspaceId)),
mediaAssets: [...(input.mediaAssets ?? [])],
}; };
} }
export function buildSidebarDatasetListQueryResult(
input: SidebarDatasetInput,
): SidebarDatasetListQueryResult {
const derived = deriveSidebarDataset(input);
return {
active_workspace_id: input.activeWorkspaceId,
workspaces: [...input.workspaces],
documents: [...input.documents],
trashed_documents: [...input.trashedDocuments],
media_assets: [...(input.mediaAssets ?? [])],
trashed_media_assets: [...(input.trashedMediaAssets ?? [])],
mindmap_assets: derived.mindmapAssets,
trashed_mindmap_assets: derived.trashedMindmapAssets,
table_assets: derived.tableAssets,
trashed_table_assets: derived.trashedTableAssets,
mindmap_docs: derived.mindmapDocs,
mindmap_asset_children: { ...derived.mindmapAssetChildren },
};
}
export function mapSidebarDatasetListQueryResultToInitialData(
result: SidebarDatasetListQueryResult,
): SidebarInitialData {
return {
activeWorkspaceId: result.active_workspace_id,
workspaces: [...result.workspaces],
documents: [...result.documents],
trashedDocuments: [...result.trashed_documents],
trashedMediaAssets: [...result.trashed_media_assets],
trashedMindmapAssets: [...result.trashed_mindmap_assets],
trashedTableAssets: [...result.trashed_table_assets],
mindmapDocs: [...result.mindmap_docs],
mindmapAssets: [...result.mindmap_assets],
mindmapAssetChildren: { ...result.mindmap_asset_children },
tableAssets: [...result.table_assets],
mediaAssets: [...result.media_assets],
};
}
export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInitialData {
const queryResult = buildSidebarDatasetListQueryResult(input);
return mapSidebarDatasetListQueryResultToInitialData(queryResult);
}
+3
View File
@@ -21,6 +21,9 @@ const isPublicRoute = createRouteMatcher([
// 说明:/onlyoffice-server 与 /cache 主要承载 ONLYOFFICE 静态资源与二进制缓存。 // 说明:/onlyoffice-server 与 /cache 主要承载 ONLYOFFICE 静态资源与二进制缓存。
// 这些资源不依赖用户态,且需要浏览器强缓存;若经过 Auth middleware 可能被追加 no-store,导致每次都重下几十 MB。 // 这些资源不依赖用户态,且需要浏览器强缓存;若经过 Auth middleware 可能被追加 no-store,导致每次都重下几十 MB。
"/onlyoffice-server(.*)", "/onlyoffice-server(.*)",
// 说明:本地自定义 ONLYOFFICE 插件页面运行在同源 /onlyoffice/plugins/* 下,由文档编辑器 iframe 直接加载。
// 若被鉴权重定向到 /auth,会导致插件桥永远收不到 ready。
"/onlyoffice/plugins(.*)",
"/cache(.*)", "/cache(.*)",
"/api/health(.*)", "/api/health(.*)",
"/_next(.*)", "/_next(.*)",