0.2 在线版本打通
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
type EditorMode = "view" | "edit";
|
||||
|
||||
const MNOTE_AGENT_PLUGIN_GUID = "asc.{F2B9A7B4-3A22-4E0E-8B32-3B0DE7AE8A60}";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MNOTE_ONLYOFFICE_ERRLOG__?: Array<Record<string, unknown>>;
|
||||
__MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean;
|
||||
__MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const setupOnlyOfficeGlobalErrorCapture = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.__MNOTE_ONLYOFFICE_ERR_HOOKED__) return;
|
||||
window.__MNOTE_ONLYOFFICE_ERR_HOOKED__ = true;
|
||||
|
||||
const push = (payload: Record<string, unknown>) => {
|
||||
try {
|
||||
window.__MNOTE_ONLYOFFICE_ERRLOG__ = window.__MNOTE_ONLYOFFICE_ERRLOG__ || [];
|
||||
window.__MNOTE_ONLYOFFICE_ERRLOG__.push(payload);
|
||||
// 说明:写入 localStorage,便于“发生崩溃导致整页 reload”时仍能回溯最近错误。
|
||||
window.localStorage.setItem("mnote_onlyoffice_last_error", JSON.stringify(payload));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"error",
|
||||
(e) => {
|
||||
try {
|
||||
push({
|
||||
kind: "error",
|
||||
message: String((e as ErrorEvent).message || ""),
|
||||
filename: String((e as ErrorEvent).filename || ""),
|
||||
lineno: Number((e as ErrorEvent).lineno || 0),
|
||||
colno: Number((e as ErrorEvent).colno || 0),
|
||||
name: String(((e as any)?.error as any)?.name || ""),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"unhandledrejection",
|
||||
(e) => {
|
||||
try {
|
||||
const r = (e as PromiseRejectionEvent).reason as any;
|
||||
push({
|
||||
kind: "rejection",
|
||||
message: String(r?.message ?? r ?? ""),
|
||||
name: String(r?.name ?? ""),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// 说明:ONLYOFFICE 内部偶发触发 removeChild 的 NotFoundError(不同环境下 message 可能为空)。
|
||||
// 该异常会导致 Next.js 直接显示“客户端异常”白屏,因此这里在 ONLYOFFICE 页面内对 removeChild 做兜底补丁。
|
||||
// 仅在 /onlyoffice 页面生效,不影响其它页面。
|
||||
if (!window.__MNOTE_ONLYOFFICE_DOMPATCHED__) {
|
||||
window.__MNOTE_ONLYOFFICE_DOMPATCHED__ = true;
|
||||
try {
|
||||
const orig = Node.prototype.removeChild;
|
||||
// eslint-disable-next-line no-extend-native
|
||||
(Node.prototype as any).removeChild = function removeChildPatched<T extends Node>(child: T): T {
|
||||
try {
|
||||
return orig.call(this, child) as T;
|
||||
} catch (e) {
|
||||
const name = (e as any)?.name ? String((e as any).name) : "";
|
||||
if (name === "NotFoundError") {
|
||||
return child;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 hashKey = (input: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString();
|
||||
};
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
// 说明:浏览器端 base64url(UTF-8)编码,用于把带 `?token=...` 的 URL 藏到 `u=...` 里。
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
};
|
||||
|
||||
const docTypeFromExt = (ext: string) => {
|
||||
const word = ["doc", "docx", "odt", "rtf"];
|
||||
const slide = ["ppt", "pptx", "odp"];
|
||||
const sheet = ["xls", "xlsx", "ods", "csv"];
|
||||
const pdf = ["pdf"];
|
||||
// 说明:ONLYOFFICE 文档类型使用 word/cell/slide/pdf(旧的 text/spreadsheet/presentation 已逐步弃用)
|
||||
if (word.includes(ext)) return "word";
|
||||
if (slide.includes(ext)) return "slide";
|
||||
if (sheet.includes(ext)) return "cell";
|
||||
if (pdf.includes(ext)) return "pdf";
|
||||
return "word";
|
||||
};
|
||||
|
||||
const waitForDocEditorReady = async (timeoutMs = 120_000) => {
|
||||
const start = Date.now();
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
// @ts-expect-error ONLYOFFICE 全局对象
|
||||
const ok = Boolean(window.DocsAPI && window.DocsAPI.DocEditor);
|
||||
if (ok) return;
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("等待 ONLYOFFICE DocEditor 初始化超时");
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
};
|
||||
|
||||
export default function OnlyOfficePage() {
|
||||
const params = useSearchParams();
|
||||
const fileUrl = params.get("fileUrl") ?? "";
|
||||
const fileName = params.get("fileName") ?? "未命名文档";
|
||||
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
|
||||
const mode = (params.get("mode") ?? "edit") as EditorMode;
|
||||
const assetId = params.get("assetId") ?? "";
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const baseUrl = runtimeConfig.onlyofficeBaseUrlWeb || runtimeConfig.onlyofficeBaseUrl;
|
||||
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
|
||||
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
||||
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
|
||||
|
||||
useEffect(() => {
|
||||
// 说明:在部分 ONLYOFFICE 版本/环境下,编辑器内部会触发 DOMException(NotFoundError: removeChild),
|
||||
// 该异常会被 Next.js 捕获并显示“客户端异常”大红屏,但实际文档仍可继续使用。
|
||||
// 这里仅对该特定错误做兜底拦截,避免误伤其它真实错误。
|
||||
const onError = (event: ErrorEvent) => {
|
||||
const err = event.error as unknown;
|
||||
const msgFromError = (() => {
|
||||
try {
|
||||
return typeof err === "string" ? err : String((err as any)?.message ?? err);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const msg = msgFromError || event.message || "";
|
||||
const name = (err as any)?.name ? String((err as any).name) : "";
|
||||
|
||||
try {
|
||||
window.__MNOTE_ONLYOFFICE_ERRLOG__ = window.__MNOTE_ONLYOFFICE_ERRLOG__ || [];
|
||||
window.__MNOTE_ONLYOFFICE_ERRLOG__.push({
|
||||
kind: "error",
|
||||
name,
|
||||
message: msg,
|
||||
filename: event.filename || "",
|
||||
lineno: event.lineno || 0,
|
||||
colno: event.colno || 0,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 说明:ONLYOFFICE 内部偶发抛出 DOMException(NotFoundError),在部分版本下 message 可能为空,
|
||||
// 但会被 Next.js 捕获后直接显示“客户端异常”白屏;该错误通常不影响文档继续使用。
|
||||
if (name === "NotFoundError") {
|
||||
event.preventDefault();
|
||||
try {
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 说明:部分隧道/证书环境下,ONLYOFFICE 会尝试注册 ServiceWorker(用于缓存静态资源)。
|
||||
// 但浏览器会以 SecurityError 失败,并触发全局错误,导致 Next.js 显示“客户端异常”白屏。
|
||||
// 该错误不影响文档实际编辑能力,因此这里对其进行兜底拦截。
|
||||
if (
|
||||
name === "SecurityError" ||
|
||||
msg.includes("Failed to register a ServiceWorker") ||
|
||||
msg.includes("An SSL certificate error occurred when fetching the script")
|
||||
) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
const onRejection = (event: PromiseRejectionEvent) => {
|
||||
const reason = event.reason as unknown;
|
||||
const msg = (() => {
|
||||
try {
|
||||
return typeof reason === "string" ? reason : String((reason as any)?.message ?? reason);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const name = (reason as any)?.name ? String((reason as any).name) : "";
|
||||
try {
|
||||
window.__MNOTE_ONLYOFFICE_ERRLOG__ = window.__MNOTE_ONLYOFFICE_ERRLOG__ || [];
|
||||
window.__MNOTE_ONLYOFFICE_ERRLOG__.push({
|
||||
kind: "rejection",
|
||||
name,
|
||||
message: msg,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (name === "NotFoundError") {
|
||||
event.preventDefault();
|
||||
try {
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
name === "SecurityError" ||
|
||||
msg.includes("Failed to register a ServiceWorker") ||
|
||||
msg.includes("An SSL certificate error occurred when fetching the script")
|
||||
) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("error", onError);
|
||||
window.addEventListener("unhandledrejection", onRejection);
|
||||
return () => {
|
||||
window.removeEventListener("error", onError);
|
||||
window.removeEventListener("unhandledrejection", onRejection);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]);
|
||||
const resolvedFileUrl = useMemo(() => {
|
||||
if (!fileUrl) return "";
|
||||
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
|
||||
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
|
||||
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000,
|
||||
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
|
||||
let base = storageHostOverride
|
||||
? fileUrl
|
||||
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
|
||||
try {
|
||||
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
|
||||
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
|
||||
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
|
||||
const raw = new URL(base);
|
||||
const alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
||||
|
||||
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
|
||||
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
|
||||
// 因此这里强制把 proxy 的 origin 改写成我们显式配置的回源(通常是容器可达的 http://172.31.224.1:3000)。
|
||||
if (alreadyProxy && proxyOrigin) {
|
||||
const po = new URL(proxyOrigin);
|
||||
raw.protocol = po.protocol;
|
||||
raw.host = po.host;
|
||||
base = raw.toString();
|
||||
}
|
||||
if (!alreadyProxy && raw.searchParams.has("token")) {
|
||||
const proxyBase = proxyOrigin || window.location.origin;
|
||||
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
||||
base = proxy.toString();
|
||||
}
|
||||
|
||||
const u = new URL(base);
|
||||
if (!storageHostOverride || alreadyProxy) return u.toString();
|
||||
|
||||
// 兼容两种写法:hostname 或完整 origin(https://xxx)
|
||||
if (/^https?:\/\//i.test(storageHostOverride)) {
|
||||
const ov = new URL(storageHostOverride);
|
||||
u.protocol = ov.protocol;
|
||||
u.host = ov.host;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
u.hostname = storageHostOverride;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl) {
|
||||
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
|
||||
return;
|
||||
}
|
||||
if (!fileUrl) {
|
||||
setError("缺少 fileUrl 参数。");
|
||||
return;
|
||||
}
|
||||
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
|
||||
loadScript(scriptUrl)
|
||||
.then(async () => {
|
||||
// 说明:api.js 的 onload 并不代表 DocsAPI/DocEditor 已完全就绪(在慢网/高负载时会出现空白页)。
|
||||
// 因此这里额外等待 DocEditor 挂载,避免偶发“白屏但无错误”的体验。
|
||||
await waitForDocEditorReady(120_000);
|
||||
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
|
||||
const pluginConfigUrl = `${window.location.origin}/onlyoffice/plugins/agent-tools/config.json`;
|
||||
|
||||
const config: any = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
document: {
|
||||
fileType,
|
||||
title: fileName,
|
||||
url: resolvedFileUrl,
|
||||
// 说明:key 用于 ONLYOFFICE 内部区分文档实例;应随 URL/文件名变化,避免缓存/冲突。
|
||||
key: hashKey(`${resolvedFileUrl}-${fileName}`),
|
||||
},
|
||||
documentType: targetDocType,
|
||||
events: {
|
||||
// 说明:用于 E2E 判定“文档已真正打开”,避免仅靠 iframe/canvas 误判。
|
||||
// 注意:部分版本回调名是 onDocumentReady,也有文档提到 onAppReady;两者都注册。
|
||||
onDocumentReady: () => {
|
||||
(window as any).__MNOTE_ONLYOFFICE_READY__ = true;
|
||||
},
|
||||
onAppReady: () => {
|
||||
(window as any).__MNOTE_ONLYOFFICE_READY__ = true;
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
const msg = (() => {
|
||||
try {
|
||||
if (typeof e === "string") return e;
|
||||
return JSON.stringify(e);
|
||||
} catch {
|
||||
return String(e);
|
||||
}
|
||||
})();
|
||||
setError(msg);
|
||||
},
|
||||
},
|
||||
editorConfig: {
|
||||
mode: mode === "view" ? "view" : "edit",
|
||||
lang: "zh-CN",
|
||||
// 说明:ONLYOFFICE 文档服务器会通过 callbackUrl 回传保存事件,
|
||||
// 我们在 /api/onlyoffice/callback 中接收并回写到 Supabase Storage。
|
||||
callbackUrl: (() => {
|
||||
const base = callbackOrigin || proxyOrigin || window.location.origin;
|
||||
const cb = new URL("/api/onlyoffice/callback", base);
|
||||
if (assetId) cb.searchParams.set("assetId", assetId);
|
||||
return cb.toString();
|
||||
})(),
|
||||
customization: {
|
||||
feedback: { visible: false },
|
||||
},
|
||||
plugins: {
|
||||
autostart: [MNOTE_AGENT_PLUGIN_GUID],
|
||||
pluginsData: [pluginConfigUrl],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 兜底:有些版本不会触发 onDocumentReady/onAppReady,这里用轮询判断“编辑器 DOM 已出现”
|
||||
// 来设置 ready flag,保证远程 E2E 判定稳定。
|
||||
(window as any).__MNOTE_ONLYOFFICE_READY__ = false;
|
||||
const readyDeadline = Date.now() + 120_000;
|
||||
const timer = window.setInterval(() => {
|
||||
const root = document.querySelector("#onlyoffice-frame") as HTMLElement | null;
|
||||
const body = document.body as HTMLElement | null;
|
||||
const count =
|
||||
(root ? root.querySelectorAll("iframe,canvas").length : 0) +
|
||||
(body ? body.querySelectorAll("iframe,canvas").length : 0);
|
||||
if (count > 0) {
|
||||
(window as any).__MNOTE_ONLYOFFICE_READY__ = true;
|
||||
window.clearInterval(timer);
|
||||
} else if (Date.now() > readyDeadline) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
fetch("/api/onlyoffice/sign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const payload = await r.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "OnlyOffice 签名失败");
|
||||
}
|
||||
const { token, documentToken, editorConfigToken } = (await r.json()) as {
|
||||
token: string | null;
|
||||
documentToken: string | null;
|
||||
editorConfigToken: string | null;
|
||||
};
|
||||
|
||||
// 兼容不同 ONLYOFFICE 配置:有些版本/配置会校验 document.token/editorConfig.token。
|
||||
if (token) config.token = token;
|
||||
if (documentToken) {
|
||||
config.document = config.document || {};
|
||||
config.document.token = documentToken;
|
||||
}
|
||||
if (editorConfigToken) {
|
||||
config.editorConfig = config.editorConfig || {};
|
||||
config.editorConfig.token = editorConfigToken;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
|
||||
new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// 说明:如果服务端强制 JWT 且你未配置 ONLYOFFICE_JWT_SECRET,会在这里失败或在编辑器内报错。
|
||||
setError(err.message);
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message);
|
||||
});
|
||||
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-3 bg-slate-50">
|
||||
<p className="text-base font-semibold text-red-600">ONLYOFFICE 加载失败</p>
|
||||
<p className="text-sm text-gray-600">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-screen bg-slate-50">
|
||||
<div id="onlyoffice-frame" className="h-full w-full" />
|
||||
<OnlyOfficeAiAgentPanel
|
||||
openFile={{
|
||||
id: assetId || `onlyoffice_${hashKey(`${resolvedFileUrl}-${fileName}`)}`,
|
||||
title: fileName,
|
||||
fileUrl: resolvedFileUrl,
|
||||
mimeType: null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user