"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>; __MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean; __MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean; __MNOTE_ONLYOFFICE_DEBUG__?: Record; } } const setupOnlyOfficeGlobalErrorCapture = () => { if (typeof window === "undefined") return; if (window.__MNOTE_ONLYOFFICE_ERR_HOOKED__) return; window.__MNOTE_ONLYOFFICE_ERR_HOOKED__ = true; const push = (payload: Record) => { 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(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((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 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([ "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:///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(null); const [authedUserId, setAuthedUserId] = useState(initialUserId); const [resolvedMode, setResolvedMode] = useState(mode === "view" ? "view" : null); const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false); const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false); const [permissionResolved, setPermissionResolved] = useState(false); const [assetSignedUrl, setAssetSignedUrl] = useState(""); const [assetStorageId, setAssetStorageId] = useState(""); 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-server(Next 代理到 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(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/ // - https:///api/storage/ // 这类 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 或完整 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; } }, [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 (
正在获取用户身份,用于写回保存…
); } if (error) { return (

ONLYOFFICE 加载失败

{error}

); } return (
{canForceSave && (
{forceSaveState.message && (
{forceSaveState.message}
)}
)}
); }