Files
mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx
T

1092 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, 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";
import { useConvex } from "convex/react";
import { api } from "@/lib/convex/api";
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;
__MNOTE_ONLYOFFICE_DEBUG__?: Record<string, unknown>;
}
}
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;
(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) => {
// 说明:浏览器端 base64urlUTF-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 setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
// 这里在 ONLYOFFICE 页面内对 XHR/fetch/window.open/location.assign 等做 URL 重写:
// 把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
// 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。
if (typeof window === "undefined") return;
if (!baseUrl) return;
const normalizedBase = String(baseUrl || "").trim().replace(/\/+$/, "");
const isProxyMode = normalizedBase === "/onlyoffice-server" || normalizedBase.endsWith("/onlyoffice-server");
if (!isProxyMode) return;
const proxyPrefix = (() => {
if (/^https?:\/\//i.test(normalizedBase)) return normalizedBase;
return `${window.location.origin.replace(/\/+$/, "")}${normalizedBase}`;
})();
const internalOrigins = new Set<string>([
"http://127.0.0.1:8081",
"http://localhost:8081",
// 说明:部分环境下 ONLYOFFICE 会错误拼出 https://127.0.0.1:8081 这类 URL
// 浏览器会报 ERR_SSL_PROTOCOL_ERROR(因为 8081 实际是 http)。这里也一起兜底重写。
"https://127.0.0.1:8081",
"https://localhost:8081",
]);
try {
if (onlyofficeBaseUrlDesktop) {
const u = new URL(onlyofficeBaseUrlDesktop);
internalOrigins.add(`${u.protocol}//${u.host}`);
}
} catch {
// ignore
}
const patchWindow = (win: Window) => {
try {
if ((win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__) return;
const rewriteUrl = (input: string) => {
try {
const u = new (win as any).URL(input, (win as any).location?.origin || window.location.origin);
// 说明:外网 https 访问时,若 ONLYOFFICE 错误生成 http://<host>/onlyoffice-server/... 会被 Mixed Content 阻止;
// 这里提前升级为 https,避免浏览器直接拦截请求。
try {
const loc = (win as any).location;
if (loc?.protocol === "https:" && u.protocol === "http:" && u.host === loc.host) {
if (u.pathname === "/onlyoffice-server" || u.pathname.startsWith("/onlyoffice-server/")) {
u.protocol = "https:";
return u.toString();
}
}
} catch {
// ignore
}
const origin = `${u.protocol}//${u.host}`;
if (!internalOrigins.has(origin)) return input;
return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`;
} catch {
return input;
}
};
// 说明:修复 “下载为 xlsx/docx” 等场景:这类下载常通过 window.open/location 跳转,
// 如果 URL 指向 127.0.0.1:8081,会导致浏览器无法下载(因为 8081 只在 Docker 内部/代理后可用)。
try {
const origWinOpen = (win as any).open;
if (typeof origWinOpen === "function") {
(win as any).open = function openPatched(
url?: string | URL,
target?: string,
features?: string,
) {
const nextUrl =
typeof url === "string"
? rewriteUrl(url)
: url instanceof URL
? new URL(rewriteUrl(url.toString()))
: url;
return origWinOpen.call(this, nextUrl as any, target as any, features as any);
};
}
} catch {
// ignore
}
try {
const loc = (win as any).location as Location | undefined;
if (loc && typeof loc.assign === "function") {
const origAssign = loc.assign.bind(loc);
loc.assign = ((url: string) => origAssign(rewriteUrl(url))) as any;
}
if (loc && typeof loc.replace === "function") {
const origReplace = loc.replace.bind(loc);
loc.replace = ((url: string) => origReplace(rewriteUrl(url))) as any;
}
} catch {
// ignore
}
try {
const origFetch = (win as any).fetch;
if (typeof origFetch === "function") {
(win as any).fetch = function fetchPatched(input: any, init?: any) {
try {
if (typeof input === "string") {
return origFetch.call(this, rewriteUrl(input), init);
}
if (input instanceof URL) {
return origFetch.call(this, new URL(rewriteUrl(input.toString())), init);
}
if (input && typeof input === "object" && typeof input.url === "string") {
const next = new Request(rewriteUrl(input.url), input);
return origFetch.call(this, next, init);
}
} catch {
// ignore
}
return origFetch.call(this, input, init);
};
}
} catch {
// ignore
}
try {
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
if (typeof origOpen === "function") {
(win as any).XMLHttpRequest.prototype.open = function openPatched(
method: string,
url: string,
async?: boolean,
user?: string | null,
password?: string | null,
) {
const nextUrl = typeof url === "string" ? rewriteUrl(url) : url;
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
};
}
} catch {
// ignore
}
(win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
} catch {
// ignore
}
};
patchWindow(window);
try {
const start = Date.now();
const timer = window.setInterval(() => {
try {
const frames = Array.from(document.querySelectorAll("iframe"));
for (const f of frames) {
try {
const w = (f as HTMLIFrameElement).contentWindow;
if (!w) continue;
// 说明:同源时才能访问 location;跨域会抛异常,直接跳过。
w.location?.origin;
patchWindow(w);
} catch {
// ignore
}
}
} catch {
// ignore
}
if (Date.now() - start > 120_000) {
window.clearInterval(timer);
}
}, 1000);
} catch {
// ignore
}
};
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();
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 初始化超时");
}
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 documentId = params.get("documentId") ?? "";
const initialUserId = params.get("userId") ?? "";
const channel = (params.get("channel") ?? "").trim().toLowerCase();
const convex = useConvex();
const [error, setError] = useState<string | null>(null);
const [authedUserId, setAuthedUserId] = useState<string>(initialUserId);
const [resolvedMode, setResolvedMode] = useState<EditorMode | null>(mode === "view" ? "view" : null);
const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false);
const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false);
const [permissionResolved, setPermissionResolved] = useState(false);
const [assetSignedUrl, setAssetSignedUrl] = useState<string>("");
const [assetStorageId, setAssetStorageId] = useState<string>("");
const [forceSaveState, setForceSaveState] = useState<{
busy: boolean;
message: string | null;
ok: boolean | null;
}>({ busy: false, message: null, ok: null });
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrlCandidates = useMemo(() => {
const uniq: string[] = [];
const push = (v?: string | null) => {
const s = String(v || "").trim().replace(/\/+$/, "");
if (!s) return;
if (!uniq.includes(s)) uniq.push(s);
};
// 说明:网页端优先走同源 /onlyoffice-serverNext 代理到 ONLYOFFICE_INTERNAL_URL),
// 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。
// 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。
try {
push("/onlyoffice-server");
push(`${window.location.origin.replace(/\/+$/, "")}/onlyoffice-server`);
} catch {
// ignore
}
// 说明:默认优先使用运行期根据 isDesktop 归一化后的 onlyofficeBaseUrl
// 如遇到端口转发/本机服务不可达,可自动回退到另一套配置。
if (channel === "web") {
push(runtimeConfig.onlyofficeBaseUrlWeb);
push(runtimeConfig.onlyofficeBaseUrl);
push(runtimeConfig.onlyofficeBaseUrlDesktop);
return uniq;
}
if (channel === "desktop") {
push(runtimeConfig.onlyofficeBaseUrlDesktop);
push(runtimeConfig.onlyofficeBaseUrl);
push(runtimeConfig.onlyofficeBaseUrlWeb);
return uniq;
}
push(runtimeConfig.onlyofficeBaseUrl);
if (runtimeConfig.isDesktop) {
push(runtimeConfig.onlyofficeBaseUrlWeb);
} else {
push(runtimeConfig.onlyofficeBaseUrlDesktop);
}
return uniq;
}, [channel, runtimeConfig]);
const [baseUrlIndex, setBaseUrlIndex] = useState(0);
const baseUrl = baseUrlCandidates[baseUrlIndex] || "";
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
const docEditorRef = useRef<any>(null);
useEffect(() => {
// 说明:为了让 document.key 在同一附件的多次打开之间保持稳定(提升缓存命中与加载速度),
// 这里在有 assetId 时拉取一次附件元信息(storage_id)。
if (!assetId) return;
let canceled = false;
fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`, { method: "GET" })
.then(async (r) => {
if (!r.ok) return null;
return (await r.json().catch(() => null)) as
| { signedUrl?: string; asset?: { storage_id?: string | null } | null }
| null;
})
.then((payload) => {
if (!payload || canceled) return;
const signed = String(payload.signedUrl || "").trim();
const sid = String(payload.asset?.storage_id || "").trim();
if (signed) setAssetSignedUrl(signed);
if (sid) setAssetStorageId(sid);
})
.catch(() => {
// ignore
});
return () => {
canceled = true;
};
}, [assetId]);
useEffect(() => {
// 说明:ONLYOFFICE 回调由文档服务器触发,不携带用户 Cookie。
// 为了让 /api/onlyoffice/callback 能以真实用户身份写回存储,
// 这里尽量从当前会话获取 userId,并透传到 callbackUrl。
if (authedUserId) return undefined;
let canceled = false;
fetch("/api/auth/whoami", { method: "GET" })
.then(async (r) => {
if (!r.ok) return null;
return (await r.json().catch(() => null)) as { userId?: string } | null;
})
.then((payload) => {
const uid = String(payload?.userId || "").trim();
if (!uid) return;
if (canceled) return;
setAuthedUserId(uid);
})
.catch(() => {
// ignore
});
return () => {
canceled = true;
};
}, [authedUserId]);
useEffect(() => {
// 只读权限:强制以 view 模式打开(避免分享页面只读但 ONLYOFFICE 仍可编辑)。
if (!documentId) {
setResolvedMode(mode === "view" ? "view" : mode);
setResolvedDisableDownload(false);
setResolvedDisableCopy(false);
setPermissionResolved(true);
return;
}
if (!authedUserId) {
setPermissionResolved(false);
return;
}
let canceled = false;
void (async () => {
setPermissionResolved(false);
try {
const perm = await convex.query(api.documents.getPermissionForUser, {
userId: authedUserId,
id: documentId,
});
if (canceled) return;
const disableDownload = Boolean((perm as any)?.disableDownload);
const disableCopy = Boolean((perm as any)?.disableCopy);
setResolvedDisableDownload(disableDownload);
setResolvedDisableCopy(disableCopy);
setPermissionResolved(true);
if (mode === "view") {
setResolvedMode("view");
return;
}
if (!perm || (perm as any).permission !== "edit") {
setResolvedMode("view");
} else {
setResolvedMode("edit");
}
} catch {
if (canceled) return;
setResolvedMode(mode === "view" ? "view" : mode);
setResolvedDisableDownload(false);
setResolvedDisableCopy(false);
setPermissionResolved(true);
}
})();
return () => {
canceled = true;
};
}, [authedUserId, convex, documentId, mode]);
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 effectiveFileUrl = assetSignedUrl || fileUrl;
const docKey = useMemo(() => {
// 说明:ONLYOFFICE 的 document.key 会参与其内部缓存/分片路由(部分版本会把它拼进请求参数),
// 如果 key 每次打开都变化,会导致浏览器缓存命中率极低。
// - 有 assetId:优先用 assetId + storage_id(保存写回后 storage_id 会变化,自然失效)
// - 无 assetId:退回到现有 hash 逻辑
if (assetId) {
const sid = String(assetStorageId || "").trim();
// 说明:ONLYOFFICE 的 document.key 允许字符集为 [0-9a-zA-Z_.=-],不包含 ":" 等字符;
// 否则可能报错(例如 errorCode=-23)。这里用 hash 生成安全 key,同时在 storage_id 变化时自动失效。
return sid ? `${assetId}_${hashKey(sid)}` : assetId;
}
return hashKey(`${effectiveFileUrl}-${fileName}`);
}, [assetId, assetStorageId, effectiveFileUrl, fileName]);
const resolvedFileUrl = useMemo(() => {
if (!effectiveFileUrl) return "";
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
const isConvexStorageUrl = (() => {
// 说明:Convex Files 的直链通常形如:
// - http://127.0.0.1:3210/api/storage/<id>
// - https://<convex-host>/api/storage/<id>
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
try {
const u = new URL(effectiveFileUrl);
return u.pathname.startsWith("/api/storage/");
} catch {
return false;
}
})();
let base =
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
? effectiveFileUrl
: rewriteToPublicOrigin(effectiveFileUrl, runtimeConfig.supabaseUrl);
try {
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
const raw = new URL(base);
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
const isLocalHost =
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
// 关键修复:当 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();
}
// 关键兜底:OnlyOffice 的 document.url 由“文档服务器容器”去拉取。
// 如果这里是 localhost/127.0.0.1(对容器而言指向它自己),会导致“下载失败(-4)”。
// 因此在配置了 proxyOrigin 时,强制走 /api/onlyoffice/proxy 把回源留给 Next 服务端完成。
if (!alreadyProxy && proxyOrigin && isLocalHost) {
const proxyBase = proxyOrigin || window.location.origin;
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
base = proxy.toString();
alreadyProxy = true;
}
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();
alreadyProxy = true;
}
const u = new URL(base);
if (!storageHostOverride || alreadyProxy) return u.toString();
// 兼容两种写法:hostname 或完整 originhttps://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;
}
}, [effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl, runtimeConfig.useConvex]);
useEffect(() => {
try {
window.__MNOTE_ONLYOFFICE_DEBUG__ = {
pageOrigin: window.location.origin,
baseUrl,
proxyOrigin,
callbackOrigin,
fileUrlInput: effectiveFileUrl,
resolvedFileUrl,
fileName,
fileType,
mode,
resolvedMode,
resolvedDisableDownload,
resolvedDisableCopy,
permissionResolved,
computedPermissions: {
edit: resolvedMode === "edit",
download: !resolvedDisableDownload,
print: !resolvedDisableDownload,
copy: !resolvedDisableCopy,
},
assetId,
documentId,
docKey,
};
} catch {
// ignore
}
}, [
assetId,
authedUserId,
baseUrl,
callbackOrigin,
docKey,
effectiveFileUrl,
fileName,
fileType,
mode,
resolvedMode,
resolvedDisableCopy,
resolvedDisableDownload,
permissionResolved,
documentId,
proxyOrigin,
resolvedFileUrl,
]);
useEffect(() => {
let canceled = false;
const destroyEditor = () => {
try {
const inst = docEditorRef.current;
docEditorRef.current = null;
if (inst && typeof inst.destroyEditor === "function") {
inst.destroyEditor();
}
} catch {
// ignore
}
try {
const el = document.getElementById("onlyoffice-frame");
if (el) el.innerHTML = "";
} catch {
// ignore
}
};
// 说明:如果是附件(assetId)编辑模式,则必须拿到真实 userId 才能让
// /api/onlyoffice/callback 通过工作空间成员校验并把文件写回存储。
// 否则会出现:编辑器里看似“已保存”,但下载/再次打开仍是旧文件。
if (mode !== "view" && assetId && !authedUserId) {
return;
}
if (!baseUrl) {
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
return;
}
if (!effectiveFileUrl) {
setError("缺少 fileUrl 参数。");
return;
}
setupOnlyOfficeInternalRequestRewrite(baseUrl, runtimeConfig.onlyofficeBaseUrlDesktop);
// 说明:外网访问(例如 frp/隧道)时,OnlyOffice 文档服务器运行在本机 Docker 容器内,无法直接访问
// document.url 里的 127.0.0.1/localhost。此时必须把 document.url 指向一个“容器可访问”的 Next Origin
//onlyofficeProxyOrigin / onlyofficeProxyOriginWeb),让 Next 服务端代为回源下载。
try {
const pageHost = window.location.hostname;
const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost";
const isPageRemote = !isPageLocal;
const u = new URL(effectiveFileUrl);
const isFileLocal = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
if (isPageRemote && isFileLocal && !proxyOrigin) {
setError(
"外网访问时检测到 fileUrl 为本机地址(127.0.0.1/localhost),但未配置 onlyofficeProxyOriginWeb。请在 public/mnote-env.json 配置 onlyofficeProxyOriginWeb/onlyofficeCallbackOriginWeb(例如 http://host.docker.internal:3000 或当前 Docker 可达的主机 IP)。",
);
return;
}
} catch {
// ignore
}
if (!resolvedMode) return;
if (documentId && !permissionResolved) return;
if (resolvedMode !== "view" && assetId && !authedUserId) 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);
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,以提升远端场景的缓存命中与加载速度。
key: docKey,
},
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);
if (authedUserId) cb.searchParams.set("userId", authedUserId);
return cb.toString();
})(),
customization: {
feedback: { visible: false },
// 说明:启用 forcesave 后,用户点击 ONLYOFFICE 的“保存”会触发 status=6 回调,
// 从而立即把最新版本写回主存储(无需等待关闭文档触发 status=2)。
forcesave: true,
},
plugins: {
autostart: [MNOTE_AGENT_PLUGIN_GUID],
pluginsData: [pluginConfigUrl],
},
},
};
// 兜底:有些版本不会触发 onDocumentReady/onAppReady,这里用轮询判断“编辑器 DOM 已出现”
// 来设置 ready flag,保证远程 E2E 判定稳定。
// 只读权限兜底:即使外部传了 mode=edit,也强制改为 view,且禁用编辑写回。
try {
config.document = config.document || {};
config.document.permissions = {
...(config.document.permissions || {}),
edit: resolvedMode !== "view",
download: !resolvedDisableDownload,
print: !resolvedDisableDownload,
copy: !resolvedDisableCopy,
};
config.editorConfig = config.editorConfig || {};
config.editorConfig.mode = resolvedMode === "view" ? "view" : "edit";
config.editorConfig.customization = config.editorConfig.customization || {};
config.editorConfig.customization.forcesave = resolvedMode !== "view";
} catch {
// ignore
}
(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;
};
if (canceled) return;
// 兼容不同 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 @typescript-eslint/no-explicit-any
if (canceled) return;
docEditorRef.current = new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config);
})
.catch((err: Error) => {
// 说明:如果服务端强制 JWT 且你未配置 ONLYOFFICE_JWT_SECRET,会在这里失败或在编辑器内报错。
setError(err.message);
});
})
.catch((err: Error) => {
// 说明:优先“无感回退”到备选 baseUrl(常见于本机 8081 未启动/外网转发不可达)。
const hasNext = baseUrlIndex + 1 < baseUrlCandidates.length;
if (hasNext) {
setBaseUrlIndex((i) => i + 1);
return;
}
setError(err.message);
});
return () => {
canceled = true;
destroyEditor();
};
}, [
assetId,
authedUserId,
baseUrl,
baseUrlCandidates.length,
baseUrlIndex,
callbackOrigin,
docKey,
effectiveFileUrl,
fileName,
fileType,
mode,
proxyOrigin,
resolvedFileUrl,
documentId,
permissionResolved,
resolvedDisableCopy,
resolvedDisableDownload,
resolvedMode,
targetDocType,
runtimeConfig.onlyofficeBaseUrlDesktop,
]);
const canForceSave = resolvedMode === "edit" && Boolean(assetId) && Boolean(docKey);
const triggerForceSave = async () => {
if (!canForceSave) return;
if (forceSaveState.busy) return;
setForceSaveState({ busy: true, message: "正在触发同步保存…", ok: null });
try {
const url = new URL("/api/onlyoffice/forcesave", window.location.origin);
url.searchParams.set("assetId", assetId);
url.searchParams.set("key", docKey);
const r = await fetch(url.toString(), { method: "POST" });
if (!r.ok) {
const payload = (await r.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error ?? "触发失败");
}
setForceSaveState({ busy: false, message: "已触发同步保存:请稍等 1~3 秒后再下载/刷新。", ok: true });
window.setTimeout(() => {
setForceSaveState((s) => (s.ok ? { ...s, message: null } : s));
}, 4000);
} catch (e) {
setForceSaveState({
busy: false,
message: `同步保存失败:${(e as Error).message}`,
ok: false,
});
}
};
if (!error && mode !== "view" && assetId && !authedUserId) {
return (
<div className="flex h-screen items-center justify-center bg-slate-50 text-sm text-gray-600">
正在获取用户身份,用于写回保存…
</div>
);
}
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" />
{canForceSave && (
<div className="pointer-events-none absolute right-3 top-3 z-50 flex flex-col items-end gap-2">
<button
type="button"
className="pointer-events-auto rounded-md bg-white/90 px-3 py-2 text-xs text-gray-700 shadow-sm ring-1 ring-gray-200 hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={forceSaveState.busy}
onClick={() => void triggerForceSave()}
title="不依赖 ONLYOFFICE 内置保存按钮,直接触发 forcesave 写回主存储"
>
{forceSaveState.busy ? "同步保存中…" : "同步保存"}
</button>
{forceSaveState.message && (
<div className="pointer-events-none max-w-[320px] rounded-md bg-black/70 px-3 py-2 text-xs text-white">
{forceSaveState.message}
</div>
)}
</div>
)}
<OnlyOfficeAiAgentPanel
openFile={{
id: assetId || `onlyoffice_${docKey || hashKey(`${resolvedFileUrl}-${fileName}`)}`,
title: fileName,
fileUrl: resolvedFileUrl,
mimeType: null,
}}
/>
</div>
);
}