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

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

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

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
This commit is contained in:
lix-2026
2026-04-15 03:06:29 +08:00
parent 84a8454fa9
commit b33ffb99e7
51 changed files with 3260 additions and 379 deletions
@@ -93,6 +93,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
title={doc.title ?? "无标题"}
updatedAt={doc.updated_at}
initialContent={null}
initialContentRevision={null}
initialConflictDetectionKey={null}
initialOptions={initialOptions}
initialStats={initialStats}
openTableId={openTableId}
-1
View File
@@ -17,7 +17,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
sidebarInitialData,
} = await loadSidebarDataFromConvex({
client,
userId: auth.userId,
fallbackName: auth.name ?? auth.email ?? "我的空间",
});
@@ -44,6 +44,14 @@ export async function GET(request: Request) {
return NextResponse.json({
content: result.content ?? null,
revision:
typeof result.revision === "number" && Number.isInteger(result.revision)
? result.revision
: 0,
conflictDetectionKey:
typeof result.conflict_detection_key === "string" && result.conflict_detection_key.trim()
? result.conflict_detection_key
: `${documentId}:0`,
meta: {
requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId,
@@ -1,58 +1,54 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
interface SavePayload {
documentId: string;
workspaceId?: string | null;
content: unknown;
}
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import {
buildDocumentSavePayload,
type DocumentSavePayload,
} from "@/lib/documents/save-contract";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { documentId, workspaceId, content }: SavePayload = await request.json();
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedWorkspaceId = workspaceId?.trim() || null;
const body = await request.json() as Partial<DocumentSavePayload> & { content: unknown };
const normalizedDocumentId = assertDocumentId(body.documentId);
const payload = buildDocumentSavePayload({
documentId: normalizedDocumentId,
workspaceId: body.workspaceId,
revision: body.revision,
content: body.content as DocumentSavePayload["content"],
conflictDetectionKey: body.conflictDetectionKey,
});
const normalizedWorkspaceId = payload.workspaceId;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
content,
},
payload: payload satisfies DocumentSavePayload,
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
});
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.updateContent, {
id: normalizedDocumentId,
content: envelope.payload.content,
});
await recordBridgeCommandArtifacts({
const result = await executeSaveBridgeCommand({
context: bridgeContext,
envelope,
});
return NextResponse.json({
ok: true,
revision: result.revision,
conflictDetectionKey: result.conflictDetectionKey,
meta: {
requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -9,15 +10,30 @@ const defaultMindmapData = {
};
export async function GET(
_req: Request,
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
return NextResponse.json({
data: res?.data ?? defaultMindmapData,
source: "convex",
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: res?.meta?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
exists: Boolean(res?.meta?.exists),
deletedAt: res?.meta?.deleted_at ?? null,
createdAt: res?.meta?.created_at ?? null,
updatedAt: res?.meta?.updated_at ?? null,
},
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -30,7 +46,7 @@ export async function POST(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
@@ -43,7 +59,18 @@ export async function POST(
data: data ?? defaultMindmapData,
...(typeof createOnly === "boolean" ? { createOnly } : {}),
});
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
updatedAt: result?.updated_at ?? null,
},
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
@@ -53,16 +80,27 @@ export async function POST(
}
export async function DELETE(
_req: Request,
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
try {
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
deletedAt: result?.deleted_at ?? null,
},
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
@@ -78,7 +116,7 @@ export async function PATCH(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
if (action !== "restore" && action !== "purge") {
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
@@ -87,10 +125,29 @@ export async function PATCH(
try {
if (action === "purge") {
const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId });
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
});
}
const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId });
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
updatedAt: result?.updated_at ?? null,
},
});
} catch (error) {
const msg = (error as Error).message ?? "操作失败";
const status = msg.includes("未找到") ? 404 : 400;
@@ -100,4 +157,3 @@ export async function PATCH(
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -9,16 +10,31 @@ const defaultMindmapData = {
};
export async function GET(
_req: Request,
request: Request,
{ params }: { params: Promise<{ docId: string }> },
) {
const { docId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`;
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
return NextResponse.json({
data: res?.data ?? defaultMindmapData,
source: "convex",
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: res?.meta?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
exists: Boolean(res?.meta?.exists),
deletedAt: res?.meta?.deleted_at ?? null,
createdAt: res?.meta?.created_at ?? null,
updatedAt: res?.meta?.updated_at ?? null,
},
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -31,7 +47,7 @@ export async function POST(
const { docId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`;
const payload = (await request.json().catch(() => ({}))) as { data?: unknown };
const result = await client.mutation(api.mindmaps.put, {
@@ -39,25 +55,46 @@ export async function POST(
mindmapId,
data: payload.data ?? defaultMindmapData,
});
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
updatedAt: result?.updated_at ?? null,
},
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
export async function DELETE(
_req: Request,
request: Request,
{ params }: { params: Promise<{ docId: string }> },
) {
const { docId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`;
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
deletedAt: result?.deleted_at ?? null,
},
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -2,10 +2,10 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim();
type OnlyOfficeCallbackBody = {
@@ -27,7 +27,7 @@ const normalizeSecret = (raw: string) => {
return trimmed;
};
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
const tryRewriteOnlyOfficeDownloadUrl = (raw: string, onlyofficeInternalUrl: string) => {
try {
const u = new URL(raw);
@@ -36,7 +36,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
const prefix = "/onlyoffice-server";
if (u.pathname.startsWith(prefix)) {
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
return `${ONLYOFFICE_INTERNAL_URL}/${nextPath}${u.search}`;
return `${onlyofficeInternalUrl}/${nextPath}${u.search}`;
}
return raw;
@@ -46,6 +46,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
};
export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId") || "";
@@ -107,7 +108,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 1 });
}
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url, onlyofficeInternalUrl);
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
if (!upstream.ok) {
return NextResponse.json({ error: 1 });
@@ -3,11 +3,10 @@ import crypto from "crypto";
import { api } from "@/lib/convex/api";
import { HttpError } from "@/lib/auth/authContext";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const base64Url = (input: Buffer | string) =>
Buffer.from(input)
.toString("base64")
@@ -38,6 +37,7 @@ const normalizeSecret = (raw: string) => {
};
export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
let auth;
let client;
try {
@@ -82,7 +82,7 @@ export async function POST(request: Request) {
// 优先按文档推荐:使用 /command + token
if (secret) {
const token = signHs256(payload, secret);
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, {
const r = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
@@ -93,7 +93,7 @@ export async function POST(request: Request) {
}
// 兜底:部分环境可能暴露 /forcesave 直连接口
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -110,7 +110,7 @@ export async function POST(request: Request) {
}
// JWT 未启用:尝试 /forcesave 直连
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -121,7 +121,7 @@ export async function POST(request: Request) {
}
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, {
const r2 = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -140,4 +140,3 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
}
}
+4 -2
View File
@@ -6,6 +6,9 @@ import {
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
buildSidebarDatasetListQueryPayload,
} from "@/lib/sidebar-data";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
export const dynamic = "force-dynamic";
@@ -21,7 +24,6 @@ export async function GET(request: Request) {
sidebarInitialData,
} = await loadSidebarDataFromConvex({
client,
userId: auth.userId,
fallbackName: auth.email ?? auth.name ?? "我的空间",
requestedWorkspaceId: workspaceIdParam,
});
@@ -37,7 +39,7 @@ export async function GET(request: Request) {
});
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: { workspaceId: targetWorkspaceId },
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
});
if (!sidebarInitialData) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
+3 -6
View File
@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { gzipSync } from "node:zlib";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -9,11 +10,6 @@ export const runtime = "nodejs";
// 当我们通过 `/onlyoffice-server/*` 反代文档服务器时,这些 `/cache/*` 请求会落到 Next 上,
// 若未额外反代,会导致 404,进而触发 ONLYOFFICE “下载失败(-4)/无法打开文档”。
// 因此这里把 `/cache/*` 同样反代到本机 ONLYOFFICE。
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(
/\/+$/,
"",
);
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:Hop-by-hop headers 不应被代理转发/透传
const hopByHop = [
@@ -71,9 +67,10 @@ const shouldGzip = (request: NextRequest, contentType: string) => {
};
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
const incomingUrl = new URL(request.url);
const target = new URL(
`${ONLYOFFICE_INTERNAL_URL}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`,
`${onlyofficeInternalUrl}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`,
);
target.search = incomingUrl.search;
@@ -1,13 +1,12 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
import { gzipSync } from "node:zlib";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const SERVICE_WORKER_SAFE_PATCH_SNIPPET = `
<script>
// 说明:
@@ -62,9 +61,13 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
var internal = {
'http://127.0.0.1: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': true,
'https://localhost:8081': true
'https://localhost:8081': true,
'https://127.0.0.1:8082': true,
'https://localhost:8082': true
};
function rewrite(u) {
try {
@@ -261,8 +264,9 @@ const shouldGzip = (request: NextRequest, contentType: string) => {
};
const proxy = async (request: NextRequest, pathParts: string[]) => {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
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;
const headers = new Headers(request.headers);
@@ -102,19 +102,49 @@ setupOnlyOfficeGlobalErrorCapture();
const loadScript = (src: string) =>
new Promise<void>((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
document.body.appendChild(script);
});
const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null;
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("error", () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`)), {
once: true,
});
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => {
script.dataset.mnoteLoaded = "1";
delete script.dataset.mnoteFailed;
resolve();
};
script.onerror = () => {
script.dataset.mnoteFailed = "1";
reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
};
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) => {
let hash = 0;
@@ -156,10 +186,14 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
const internalOrigins = new Set<string>([
"http://127.0.0.1:8081",
"http://localhost:8081",
"http://127.0.0.1:8082",
"http://localhost:8082",
// 说明:部分环境下 ONLYOFFICE 会错误拼出 https://127.0.0.1:8081 这类 URL
// 浏览器会报 ERR_SSL_PROTOCOL_ERROR(因为 8081 实际是 http)。这里也一起兜底重写。
"https://127.0.0.1:8081",
"https://localhost:8081",
"https://127.0.0.1:8082",
"https://localhost:8082",
]);
try {
if (onlyofficeBaseUrlDesktop) {
@@ -847,8 +881,12 @@ export default function OnlyOfficePage() {
if (documentId && !permissionResolved) return;
if (resolvedMode !== "view" && assetId && !authedUserId) return;
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
loadScript(scriptUrl)
const scriptUrls = [
`${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 () => {
// 说明:api.js 的 onload 并不代表 DocsAPI/DocEditor 已完全就绪(在慢网/高负载时会出现空白页)。
// 因此这里额外等待 DocEditor 挂载,避免偶发“白屏但无错误”的体验。