2026-01-15 20:54:21 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-01-24 13:14:24 +08:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
2026-01-15 20:54:21 +08:00
|
|
|
|
import { useSearchParams } from "next/navigation";
|
|
|
|
|
|
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
|
|
|
|
|
|
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
|
|
|
|
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
2026-01-24 12:32:51 +08:00
|
|
|
|
import { useConvex } from "convex/react";
|
|
|
|
|
|
import { api } from "@/lib/convex/api";
|
2026-01-15 20:54:21 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-17 10:12:53 +08:00
|
|
|
|
__MNOTE_ONLYOFFICE_DEBUG__?: Record<string, unknown>;
|
2026-01-15 20:54:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-21 18:21:10 +08:00
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
(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, "_");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
|
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
|
|
|
|
|
|
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
|
|
|
|
|
|
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
|
2026-01-24 12:32:51 +08:00
|
|
|
|
// 这里在 ONLYOFFICE 页面内对 XHR/fetch/window.open/location.assign 等做 URL 重写:
|
|
|
|
|
|
// 把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
|
2026-01-17 10:12:53 +08:00
|
|
|
|
// 注意: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}`;
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
2026-01-17 12:38:04 +08:00
|
|
|
|
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",
|
|
|
|
|
|
]);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
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);
|
2026-01-17 12:38:04 +08:00
|
|
|
|
// 说明:外网 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
|
|
|
|
|
|
}
|
2026-01-17 10:12:53 +08:00
|
|
|
|
const origin = `${u.protocol}//${u.host}`;
|
|
|
|
|
|
if (!internalOrigins.has(origin)) return input;
|
|
|
|
|
|
return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return input;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-01-24 12:32:51 +08:00
|
|
|
|
|
|
|
|
|
|
// 说明:修复 “下载为 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
|
(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;跨域会抛异常,直接跳过。
|
2026-01-21 18:21:10 +08:00
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
|
w.location?.origin;
|
|
|
|
|
|
patchWindow(w);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Date.now() - start > 120_000) {
|
|
|
|
|
|
window.clearInterval(timer);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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();
|
2026-01-21 18:21:10 +08:00
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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 初始化超时");
|
|
|
|
|
|
}
|
2026-01-21 18:21:10 +08:00
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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") ?? "";
|
2026-01-24 12:32:51 +08:00
|
|
|
|
const documentId = params.get("documentId") ?? "";
|
2026-01-20 07:24:12 +08:00
|
|
|
|
const initialUserId = params.get("userId") ?? "";
|
2026-01-17 10:12:53 +08:00
|
|
|
|
const channel = (params.get("channel") ?? "").trim().toLowerCase();
|
2026-01-24 12:32:51 +08:00
|
|
|
|
const convex = useConvex();
|
2026-01-15 20:54:21 +08:00
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-01-20 07:24:12 +08:00
|
|
|
|
const [authedUserId, setAuthedUserId] = useState<string>(initialUserId);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
const [resolvedMode, setResolvedMode] = useState<EditorMode | null>(mode === "view" ? "view" : null);
|
|
|
|
|
|
const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false);
|
|
|
|
|
|
const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false);
|
2026-01-24 13:14:24 +08:00
|
|
|
|
const [permissionResolved, setPermissionResolved] = useState(false);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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 });
|
2026-01-15 20:54:21 +08:00
|
|
|
|
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
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] || "";
|
2026-01-15 20:54:21 +08:00
|
|
|
|
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
|
|
|
|
|
|
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
|
|
|
|
|
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
|
2026-01-24 13:14:24 +08:00
|
|
|
|
const docEditorRef = useRef<any>(null);
|
2026-01-15 20:54:21 +08:00
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-20 07:24:12 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
// 只读权限:强制以 view 模式打开(避免分享页面只读但 ONLYOFFICE 仍可编辑)。
|
|
|
|
|
|
if (!documentId) {
|
|
|
|
|
|
setResolvedMode(mode === "view" ? "view" : mode);
|
|
|
|
|
|
setResolvedDisableDownload(false);
|
|
|
|
|
|
setResolvedDisableCopy(false);
|
2026-01-24 13:14:24 +08:00
|
|
|
|
setPermissionResolved(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!authedUserId) {
|
|
|
|
|
|
setPermissionResolved(false);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let canceled = false;
|
|
|
|
|
|
void (async () => {
|
2026-01-24 13:14:24 +08:00
|
|
|
|
setPermissionResolved(false);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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);
|
2026-01-24 13:14:24 +08:00
|
|
|
|
setPermissionResolved(true);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-01-24 13:14:24 +08:00
|
|
|
|
setPermissionResolved(true);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
canceled = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [authedUserId, convex, documentId, mode]);
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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]);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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]);
|
2026-01-15 20:54:21 +08:00
|
|
|
|
const resolvedFileUrl = useMemo(() => {
|
2026-01-24 12:32:51 +08:00
|
|
|
|
if (!effectiveFileUrl) return "";
|
2026-01-15 20:54:21 +08:00
|
|
|
|
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
|
|
|
|
|
|
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
|
|
|
|
|
|
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000,
|
|
|
|
|
|
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
|
2026-01-17 10:12:53 +08:00
|
|
|
|
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 {
|
2026-01-24 12:32:51 +08:00
|
|
|
|
const u = new URL(effectiveFileUrl);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
return u.pathname.startsWith("/api/storage/");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
let base =
|
|
|
|
|
|
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
|
2026-01-24 12:32:51 +08:00
|
|
|
|
? effectiveFileUrl
|
|
|
|
|
|
: rewriteToPublicOrigin(effectiveFileUrl, runtimeConfig.supabaseUrl);
|
2026-01-15 20:54:21 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
|
|
|
|
|
|
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
|
|
|
|
|
|
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
|
|
|
|
|
|
const raw = new URL(base);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
|
|
|
|
|
|
|
|
|
|
|
const isLocalHost =
|
|
|
|
|
|
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
|
2026-01-15 20:54:21 +08:00
|
|
|
|
|
|
|
|
|
|
// 关键修复:当 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();
|
|
|
|
|
|
}
|
2026-01-17 10:12:53 +08:00
|
|
|
|
|
|
|
|
|
|
// 关键兜底: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;
|
|
|
|
|
|
}
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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();
|
2026-01-17 10:12:53 +08:00
|
|
|
|
alreadyProxy = true;
|
2026-01-15 20:54:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-24 12:32:51 +08:00
|
|
|
|
}, [effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl, runtimeConfig.useConvex]);
|
2026-01-15 20:54:21 +08:00
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.__MNOTE_ONLYOFFICE_DEBUG__ = {
|
|
|
|
|
|
pageOrigin: window.location.origin,
|
|
|
|
|
|
baseUrl,
|
|
|
|
|
|
proxyOrigin,
|
|
|
|
|
|
callbackOrigin,
|
2026-01-24 12:32:51 +08:00
|
|
|
|
fileUrlInput: effectiveFileUrl,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
resolvedFileUrl,
|
|
|
|
|
|
fileName,
|
|
|
|
|
|
fileType,
|
|
|
|
|
|
mode,
|
2026-01-24 13:14:24 +08:00
|
|
|
|
resolvedMode,
|
|
|
|
|
|
resolvedDisableDownload,
|
|
|
|
|
|
resolvedDisableCopy,
|
|
|
|
|
|
permissionResolved,
|
2026-01-24 13:45:28 +08:00
|
|
|
|
computedPermissions: {
|
|
|
|
|
|
edit: resolvedMode === "edit",
|
|
|
|
|
|
download: !resolvedDisableDownload,
|
|
|
|
|
|
print: !resolvedDisableDownload,
|
|
|
|
|
|
copy: !resolvedDisableCopy,
|
|
|
|
|
|
},
|
2026-01-17 10:12:53 +08:00
|
|
|
|
assetId,
|
2026-01-24 13:14:24 +08:00
|
|
|
|
documentId,
|
2026-01-24 12:32:51 +08:00
|
|
|
|
docKey,
|
2026-01-17 10:12:53 +08:00
|
|
|
|
};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-24 12:32:51 +08:00
|
|
|
|
}, [
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
authedUserId,
|
|
|
|
|
|
baseUrl,
|
|
|
|
|
|
callbackOrigin,
|
|
|
|
|
|
docKey,
|
|
|
|
|
|
effectiveFileUrl,
|
|
|
|
|
|
fileName,
|
|
|
|
|
|
fileType,
|
2026-01-26 13:19:48 +08:00
|
|
|
|
mode,
|
2026-01-24 12:32:51 +08:00
|
|
|
|
resolvedMode,
|
2026-01-24 13:14:24 +08:00
|
|
|
|
resolvedDisableCopy,
|
|
|
|
|
|
resolvedDisableDownload,
|
|
|
|
|
|
permissionResolved,
|
|
|
|
|
|
documentId,
|
2026-01-24 12:32:51 +08:00
|
|
|
|
proxyOrigin,
|
|
|
|
|
|
resolvedFileUrl,
|
|
|
|
|
|
]);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
useEffect(() => {
|
2026-01-24 13:14:24 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
|
// 说明:如果是附件(assetId)编辑模式,则必须拿到真实 userId 才能让
|
|
|
|
|
|
// /api/onlyoffice/callback 通过工作空间成员校验并把文件写回存储。
|
|
|
|
|
|
// 否则会出现:编辑器里看似“已保存”,但下载/再次打开仍是旧文件。
|
|
|
|
|
|
if (mode !== "view" && assetId && !authedUserId) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-15 20:54:21 +08:00
|
|
|
|
if (!baseUrl) {
|
|
|
|
|
|
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-24 12:32:51 +08:00
|
|
|
|
if (!effectiveFileUrl) {
|
2026-01-15 20:54:21 +08:00
|
|
|
|
setError("缺少 fileUrl 参数。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-17 10:12:53 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-24 12:32:51 +08:00
|
|
|
|
const u = new URL(effectiveFileUrl);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
|
if (!resolvedMode) return;
|
2026-01-24 13:14:24 +08:00
|
|
|
|
if (documentId && !permissionResolved) return;
|
2026-01-24 12:32:51 +08:00
|
|
|
|
if (resolvedMode !== "view" && assetId && !authedUserId) return;
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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);
|
2026-01-21 18:21:10 +08:00
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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/文件名变化,避免缓存/冲突。
|
2026-01-24 12:32:51 +08:00
|
|
|
|
// 这里改为“对同一附件尽量稳定”的 key,以提升远端场景的缓存命中与加载速度。
|
|
|
|
|
|
key: docKey,
|
2026-01-15 20:54:21 +08:00
|
|
|
|
},
|
|
|
|
|
|
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);
|
2026-01-20 07:24:12 +08:00
|
|
|
|
if (authedUserId) cb.searchParams.set("userId", authedUserId);
|
2026-01-15 20:54:21 +08:00
|
|
|
|
return cb.toString();
|
|
|
|
|
|
})(),
|
|
|
|
|
|
customization: {
|
|
|
|
|
|
feedback: { visible: false },
|
2026-01-24 12:32:51 +08:00
|
|
|
|
// 说明:启用 forcesave 后,用户点击 ONLYOFFICE 的“保存”会触发 status=6 回调,
|
|
|
|
|
|
// 从而立即把最新版本写回主存储(无需等待关闭文档触发 status=2)。
|
|
|
|
|
|
forcesave: true,
|
2026-01-15 20:54:21 +08:00
|
|
|
|
},
|
|
|
|
|
|
plugins: {
|
|
|
|
|
|
autostart: [MNOTE_AGENT_PLUGIN_GUID],
|
|
|
|
|
|
pluginsData: [pluginConfigUrl],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 兜底:有些版本不会触发 onDocumentReady/onAppReady,这里用轮询判断“编辑器 DOM 已出现”
|
|
|
|
|
|
// 来设置 ready flag,保证远程 E2E 判定稳定。
|
2026-01-24 12:32:51 +08:00
|
|
|
|
// 只读权限兜底:即使外部传了 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
(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;
|
|
|
|
|
|
};
|
2026-01-24 13:14:24 +08:00
|
|
|
|
if (canceled) return;
|
2026-01-15 20:54:21 +08:00
|
|
|
|
|
|
|
|
|
|
// 兼容不同 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 18:21:10 +08:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
2026-01-24 13:14:24 +08:00
|
|
|
|
if (canceled) return;
|
|
|
|
|
|
docEditorRef.current = new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config);
|
2026-01-15 20:54:21 +08:00
|
|
|
|
})
|
|
|
|
|
|
.catch((err: Error) => {
|
|
|
|
|
|
// 说明:如果服务端强制 JWT 且你未配置 ONLYOFFICE_JWT_SECRET,会在这里失败或在编辑器内报错。
|
|
|
|
|
|
setError(err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err: Error) => {
|
2026-01-17 10:12:53 +08:00
|
|
|
|
// 说明:优先“无感回退”到备选 baseUrl(常见于本机 8081 未启动/外网转发不可达)。
|
|
|
|
|
|
const hasNext = baseUrlIndex + 1 < baseUrlCandidates.length;
|
|
|
|
|
|
if (hasNext) {
|
|
|
|
|
|
setBaseUrlIndex((i) => i + 1);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-15 20:54:21 +08:00
|
|
|
|
setError(err.message);
|
|
|
|
|
|
});
|
2026-01-24 13:14:24 +08:00
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
canceled = true;
|
|
|
|
|
|
destroyEditor();
|
|
|
|
|
|
};
|
2026-01-24 12:32:51 +08:00
|
|
|
|
}, [
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
authedUserId,
|
|
|
|
|
|
baseUrl,
|
|
|
|
|
|
baseUrlCandidates.length,
|
|
|
|
|
|
baseUrlIndex,
|
|
|
|
|
|
callbackOrigin,
|
|
|
|
|
|
docKey,
|
|
|
|
|
|
effectiveFileUrl,
|
|
|
|
|
|
fileName,
|
|
|
|
|
|
fileType,
|
|
|
|
|
|
mode,
|
|
|
|
|
|
proxyOrigin,
|
|
|
|
|
|
resolvedFileUrl,
|
2026-01-24 13:14:24 +08:00
|
|
|
|
documentId,
|
|
|
|
|
|
permissionResolved,
|
|
|
|
|
|
resolvedDisableCopy,
|
|
|
|
|
|
resolvedDisableDownload,
|
|
|
|
|
|
resolvedMode,
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-01-15 20:54:21 +08:00
|
|
|
|
|
|
|
|
|
|
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" />
|
2026-01-24 12:32:51 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-01-15 20:54:21 +08:00
|
|
|
|
<OnlyOfficeAiAgentPanel
|
|
|
|
|
|
openFile={{
|
2026-01-24 12:32:51 +08:00
|
|
|
|
id: assetId || `onlyoffice_${docKey || hashKey(`${resolvedFileUrl}-${fileName}`)}`,
|
2026-01-15 20:54:21 +08:00
|
|
|
|
title: fileName,
|
|
|
|
|
|
fileUrl: resolvedFileUrl,
|
|
|
|
|
|
mimeType: null,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|