feat: 收口文档桥接与 OnlyOffice/Sidebar 回归
- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器 - 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线 - 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
This commit is contained in:
@@ -50,3 +50,10 @@ artifacts/
|
|||||||
artifacts/**
|
artifacts/**
|
||||||
tmp
|
tmp
|
||||||
design
|
design
|
||||||
|
|
||||||
|
# Rust 本地构建与调试产物
|
||||||
|
/rust/target/
|
||||||
|
|
||||||
|
# Harness 本地运行状态/调试产物
|
||||||
|
/.harness-stop-counter
|
||||||
|
/harness-tasks.json.bak
|
||||||
|
|||||||
+2
-2
@@ -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. 文档宿主层
|
||||||
- 文档页面壳
|
- 文档页面壳
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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,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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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())),
|
||||||
|
|||||||
@@ -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,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 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
console.log(
|
.then((onlyofficeTarget) => {
|
||||||
`[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"})`,
|
console.log(
|
||||||
);
|
`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${onlyofficeTarget.toString().replace(/\/$/, "")}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log(
|
||||||
|
`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || DEFAULT_ONLYOFFICE_INTERNAL_URL}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, () => {
|
||||||
console.log(
|
resolveOnlyOfficeInternalUrl()
|
||||||
`[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"})`,
|
.then((onlyofficeTarget) => {
|
||||||
);
|
console.log(
|
||||||
|
`[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${onlyofficeTarget.toString().replace(/\/$/, "")}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log(
|
||||||
|
`[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || DEFAULT_ONLYOFFICE_INTERNAL_URL}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
if (existing.dataset.mnoteLoaded === "1") {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.dataset.mnoteFailed === "1") {
|
||||||
|
reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
existing.addEventListener("load", () => resolve(), { once: true });
|
existing.addEventListener("load", () => resolve(), { once: true });
|
||||||
resolve();
|
existing.addEventListener("error", () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`)), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
return;
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
}),
|
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
|
||||||
input.client.query(api.documents.listTrashedByWorkspace, {
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(.*)",
|
||||||
|
|||||||
Reference in New Issue
Block a user