2025-12-26 07:52:40 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
import { useSearchParams } from "next/navigation";
|
2026-01-11 12:35:53 +08:00
|
|
|
|
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
|
|
|
|
|
|
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
2025-12-26 07:52:40 +08:00
|
|
|
|
|
|
|
|
|
|
type EditorMode = "view" | "edit";
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const MNOTE_AGENT_PLUGIN_GUID = "asc.{F2B9A7B4-3A22-4E0E-8B32-3B0DE7AE8A60}";
|
|
|
|
|
|
|
2025-12-26 07:52:40 +08:00
|
|
|
|
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 docTypeFromExt = (ext: string) => {
|
|
|
|
|
|
const word = ["doc", "docx", "odt", "rtf"];
|
|
|
|
|
|
const slide = ["ppt", "pptx", "odp"];
|
|
|
|
|
|
const sheet = ["xls", "xlsx", "ods", "csv"];
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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";
|
2025-12-26 07:52:40 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const assetId = params.get("assetId") ?? "";
|
2025-12-26 07:52:40 +08:00
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const baseUrl = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL;
|
|
|
|
|
|
const storageHostOverride =
|
|
|
|
|
|
process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE;
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]);
|
2025-12-26 07:52:40 +08:00
|
|
|
|
const resolvedFileUrl = useMemo(() => {
|
|
|
|
|
|
if (!fileUrl) return "";
|
2026-01-11 12:35:53 +08:00
|
|
|
|
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
|
|
|
|
|
|
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
|
|
|
|
|
|
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000,
|
|
|
|
|
|
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
|
|
|
|
|
|
const base = storageHostOverride
|
|
|
|
|
|
? fileUrl
|
|
|
|
|
|
: rewriteToPublicOrigin(fileUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
2025-12-26 07:52:40 +08:00
|
|
|
|
try {
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const u = new URL(base);
|
|
|
|
|
|
if (!storageHostOverride) 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();
|
2025-12-26 07:52:40 +08:00
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
|
|
|
|
|
|
u.hostname = storageHostOverride;
|
2025-12-26 07:52:40 +08:00
|
|
|
|
return u.toString();
|
|
|
|
|
|
} catch {
|
2026-01-11 12:35:53 +08:00
|
|
|
|
return base;
|
2025-12-26 07:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, [fileUrl, storageHostOverride]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!baseUrl) {
|
|
|
|
|
|
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!fileUrl) {
|
|
|
|
|
|
setError("缺少 fileUrl 参数。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
|
|
|
|
|
|
loadScript(scriptUrl)
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
// @ts-expect-error ONLYOFFICE 全局对象
|
|
|
|
|
|
if (!window.DocsAPI) {
|
|
|
|
|
|
throw new Error("未检测到 DocsAPI,请检查 ONLYOFFICE 版本。");
|
|
|
|
|
|
}
|
|
|
|
|
|
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const pluginConfigUrl = `${window.location.origin}/onlyoffice/plugins/agent-tools/config.json`;
|
2025-12-26 07:52:40 +08:00
|
|
|
|
new (window as any).DocsAPI.DocEditor("onlyoffice-frame", {
|
|
|
|
|
|
width: "100%",
|
|
|
|
|
|
height: "100%",
|
|
|
|
|
|
document: {
|
|
|
|
|
|
fileType,
|
|
|
|
|
|
title: fileName,
|
|
|
|
|
|
url: resolvedFileUrl,
|
|
|
|
|
|
key: hashKey(`${resolvedFileUrl}-${fileName}`),
|
|
|
|
|
|
},
|
|
|
|
|
|
documentType: targetDocType,
|
|
|
|
|
|
editorConfig: {
|
|
|
|
|
|
mode: mode === "view" ? "view" : "edit",
|
|
|
|
|
|
lang: "zh-CN",
|
|
|
|
|
|
customization: {
|
|
|
|
|
|
feedback: { visible: false },
|
|
|
|
|
|
},
|
2026-01-11 12:35:53 +08:00
|
|
|
|
plugins: {
|
|
|
|
|
|
autostart: [MNOTE_AGENT_PLUGIN_GUID],
|
|
|
|
|
|
pluginsData: [pluginConfigUrl],
|
|
|
|
|
|
},
|
2025-12-26 07:52:40 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err: Error) => {
|
|
|
|
|
|
setError(err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-screen flex-col items-center justify-center gap-3 bg-slate-50">
|
|
|
|
|
|
<p className="text-base font-semibold text-red-600">ONLYOFFICE 加载失败</p>
|
|
|
|
|
|
<p className="text-sm text-gray-600">{error}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="relative h-screen w-screen bg-slate-50">
|
|
|
|
|
|
<div id="onlyoffice-frame" className="h-full w-full" />
|
|
|
|
|
|
<OnlyOfficeAiAgentPanel
|
|
|
|
|
|
openFile={{
|
|
|
|
|
|
id: assetId || `onlyoffice_${hashKey(`${resolvedFileUrl}-${fileName}`)}`,
|
|
|
|
|
|
title: fileName,
|
|
|
|
|
|
fileUrl: resolvedFileUrl,
|
|
|
|
|
|
mimeType: null,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2025-12-26 07:52:40 +08:00
|
|
|
|
}
|