diff --git a/.gitignore b/.gitignore index 383ef2ed..20056e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,9 @@ __pycache__/ # 大模型文件,防止超出 LFS 配额 services/mineru/models/ *.safetensors +# ONLYOFFICE 挂载/同步目录(体积很大,禁止入库) +/onlyoffice-web-apps/ +/onlyoffice-plugins/ +/onlyoffice-data/ +/onlyoffice-fonts/ +/onlyoffice-pg-data/ diff --git a/docker-compose.onlyoffice.dev.yml b/docker-compose.onlyoffice.dev.yml new file mode 100644 index 00000000..9d91a01e --- /dev/null +++ b/docker-compose.onlyoffice.dev.yml @@ -0,0 +1,50 @@ +version: "3.9" + +services: + onlyoffice-dev: + image: onlyoffice/documentserver:9.2.1 + container_name: onlyoffice-dev + restart: unless-stopped + ports: + - "8081:80" # 本地访问 http://localhost:8081 + environment: + - JWT_ENABLED=false # 关闭内置 JWT,改用外部 Supabase JWT + - DOCUMENT_SERVER_JWT_ENABLED=false + - LANG=zh_CN.UTF-8 + - LC_ALL=zh_CN.UTF-8 + - ALLOW_PRIVATE_IP_ADDRESS=true # 允许访问宿主机私网地址(Supabase 签名 URL) + - DB_TYPE=postgres + - DB_HOST=onlyoffice-postgres + - DB_PORT=5432 + - DB_NAME=onlyoffice + - DB_USER=onlyoffice + - DB_PWD=onlyoffice + volumes: + - ./onlyoffice-web-apps:/var/www/onlyoffice/documentserver/web-apps # 前端源码,可直接改工具栏/菜单 + - ./onlyoffice-plugins:/var/www/onlyoffice/documentserver/sdkjs-plugins # 插件源码,支持热更新 + - ./onlyoffice-data:/var/www/onlyoffice/Data # 用户数据、缓存 + - ./onlyoffice-fonts:/usr/share/fonts/truetype/custom # 自定义字体(可选) + extra_hosts: + - "host.docker.internal:host-gateway" # 方便插件/前端直连宿主机 FastAPI/LightRAG + depends_on: + - onlyoffice-postgres + + onlyoffice-postgres: + image: postgres:14 + container_name: onlyoffice-postgres + restart: unless-stopped + environment: + - POSTGRES_DB=onlyoffice + - POSTGRES_USER=onlyoffice + - POSTGRES_PASSWORD=onlyoffice + volumes: + - onlyoffice-pg-data:/var/lib/postgresql/data + ports: + - "55432:5432" + +networks: + default: + name: onlyoffice-dev-net + +volumes: + onlyoffice-pg-data: diff --git a/scripts/desktop-hot.js b/scripts/desktop-hot.js index 492f83bb..300fc19a 100644 --- a/scripts/desktop-hot.js +++ b/scripts/desktop-hot.js @@ -16,6 +16,7 @@ const { spawn } = require("child_process"); const path = require("path"); const net = require("net"); const { URL } = require("url"); +const fs = require("fs"); const rootDir = path.resolve(__dirname, ".."); const frontendDir = path.join(rootDir, "wolai-frontend"); @@ -47,6 +48,32 @@ const tasks = [ const children = []; let shuttingDown = false; +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return {}; + const content = fs.readFileSync(filePath, "utf8"); + return content + .split(/\r?\n/) + .filter((line) => line.trim() && !line.trim().startsWith("#")) + .reduce((acc, line) => { + const idx = line.indexOf("="); + if (idx === -1) return acc; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + acc[key] = value; + return acc; + }, {}); +} + +const envFromRoot = loadEnvFile(path.join(rootDir, ".env.local")); +const envFromFrontend = loadEnvFile(path.join(frontendDir, ".env.local")); +const envFromBackend = loadEnvFile(path.join(backendDir, ".env.local")); +const mergedEnv = { + ...process.env, + ...envFromRoot, + ...envFromFrontend, + ...envFromBackend, +}; + function logPrefix(name, message) { console.log(`[${name}] ${message}`); } @@ -57,7 +84,7 @@ function startTask(task) { cwd: task.cwd, stdio: "inherit", shell: true, - env: { ...process.env }, + env: mergedEnv, }); child.on("exit", (code, signal) => { diff --git a/wolai-frontend/src/app/api/media/signed-url/route.ts b/wolai-frontend/src/app/api/media/signed-url/route.ts new file mode 100644 index 00000000..a1aa5594 --- /dev/null +++ b/wolai-frontend/src/app/api/media/signed-url/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import supabaseAdmin from "@/lib/supabase/admin"; + +export const dynamic = "force-dynamic"; + +const parseStoragePath = (fileUrl: string) => { + try { + const url = new URL(fileUrl); + const segments = url.pathname.split("/").filter(Boolean); + const objectIdx = segments.findIndex((seg) => seg === "object"); + if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null; + // pattern 1: /storage/v1/object/public// + if (segments[objectIdx + 1] === "public") { + const bucket = segments[objectIdx + 2]; + const path = segments.slice(objectIdx + 3).join("/"); + return { bucket, path }; + } + // pattern 2: /storage/v1/object/sign// (token in query) + if (segments[objectIdx + 1] === "sign") { + const bucket = segments[objectIdx + 2]; + const path = segments.slice(objectIdx + 3).join("/"); + return { bucket, path }; + } + return null; + } catch { + return null; + } +}; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const fileUrl = searchParams.get("fileUrl"); + const fileName = searchParams.get("fileName") ?? undefined; + const forOnlyOffice = searchParams.get("for") === "onlyoffice"; + const hostOverride = process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE; + if (searchParams.get("debug") === "1") { + return NextResponse.json({ + keyLen: (process.env.SUPABASE_SERVICE_ROLE_KEY || "").length, + url: process.env.SUPABASE_URL, + }); + } + + if (!fileUrl) { + return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 }); + } + + const parsed = parseStoragePath(fileUrl); + if (!parsed) { + return NextResponse.json({ error: "无法解析 Supabase 存储路径" }, { status: 400 }); + } + + const { bucket, path } = parsed; + + const { data, error } = await supabaseAdmin.storage + .from(bucket) + .createSignedUrl(path, 60 * 60, { download: fileName }); + + if (error || !data?.signedUrl) { + return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 }); + } + + let signedUrl = data.signedUrl; + if ( + forOnlyOffice && + hostOverride && + (signedUrl.includes("127.0.0.1") || signedUrl.includes("localhost")) + ) { + try { + const url = new URL(signedUrl); + url.hostname = hostOverride; + signedUrl = url.toString(); + } catch { + // ignore parse errors + } + } + + return NextResponse.json({ signedUrl }); +} diff --git a/wolai-frontend/src/app/api/media/upload/route.ts b/wolai-frontend/src/app/api/media/upload/route.ts index ddb79e0c..54c24166 100644 --- a/wolai-frontend/src/app/api/media/upload/route.ts +++ b/wolai-frontend/src/app/api/media/upload/route.ts @@ -5,7 +5,7 @@ import { extname } from "path"; export const dynamic = "force-dynamic"; -const MEDIA_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "media"; +const DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents"; const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => { if (mime.startsWith("image/")) return "image"; @@ -41,7 +41,7 @@ export async function POST(request: Request) { const path = `${workspaceId}/${Date.now()}-${uniqueId}${extension}`; const assetType = resolveAssetType(file.type || ""); - const { error: uploadError } = await supabase.storage.from(MEDIA_BUCKET).upload(path, buffer, { + const { error: uploadError } = await supabase.storage.from(DOC_BUCKET).upload(path, buffer, { contentType: file.type, upsert: false, }); @@ -50,17 +50,17 @@ export async function POST(request: Request) { return NextResponse.json({ error: uploadError.message }, { status: 500 }); } - const { - data: { publicUrl }, - } = supabase.storage.from(MEDIA_BUCKET).getPublicUrl(path); + // 为私有桶生成临时访问链接(7 天);前端可在需要时通过 /api/media/signed-url 刷新 + const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7); + const signedUrl = signed?.signedUrl ?? ""; const { data: asset, error } = await supabase .from("media_assets") .insert({ workspace_id: workspaceId, document_id: documentId, - file_url: publicUrl, - thumbnail_url: publicUrl, + file_url: signedUrl, + thumbnail_url: signedUrl, asset_type: assetType, file_name: file.name, file_size: file.size, diff --git a/wolai-frontend/src/app/onlyoffice/page.tsx b/wolai-frontend/src/app/onlyoffice/page.tsx new file mode 100644 index 00000000..908097ac --- /dev/null +++ b/wolai-frontend/src/app/onlyoffice/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +type EditorMode = "view" | "edit"; + +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 docTypeFromExt = (ext: string) => { + const word = ["doc", "docx", "odt", "rtf"]; + const slide = ["ppt", "pptx", "odp"]; + const sheet = ["xls", "xlsx", "ods", "csv"]; + if (word.includes(ext)) return "text"; + if (slide.includes(ext)) return "presentation"; + if (sheet.includes(ext)) return "spreadsheet"; + return "text"; +}; + +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 [error, setError] = useState(null); + const baseUrl = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL; + const storageHostOverride = + process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE; + + const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]); + const resolvedFileUrl = useMemo(() => { + if (!fileUrl) return ""; + try { + const u = new URL(fileUrl); + if ( + storageHostOverride && + (u.hostname === "127.0.0.1" || + u.hostname === "localhost" || + u.hostname === "host.docker.internal") + ) { + u.hostname = storageHostOverride; + } + return u.toString(); + } catch { + return fileUrl; + } + }, [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 + 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 }, + }, + }, + }); + }) + .catch((err: Error) => { + setError(err.message); + }); + }, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]); + + if (error) { + return ( +
+

ONLYOFFICE 加载失败

+

{error}

+
+ ); + } + + return
; +} diff --git a/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx b/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx index fe6ac7d7..990d8039 100644 --- a/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx @@ -94,7 +94,20 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => { const displayFileName = block.props.fileName ?? deriveFileName(fileUrl); const captionRef = useRef(null); const [captionEditing, setCaptionEditing] = useState(false); - const shouldShowCaption = captionEditing || Boolean(block.props.caption); + const shouldShowCaption = captionEditing || Boolean(block.props.caption); + const officeBase = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL; + const extension = useMemo(() => { + const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase(); + const match = /\.([a-z0-9]+)$/.exec(name); + return match?.[1] ?? ""; + }, [block.props.fileName, fileUrl]); + const isOfficeDoc = useMemo( + () => + ["doc", "docx", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt"].includes( + extension, + ), + [extension], + ); const handleChoose = () => { openPicker({ @@ -207,11 +220,38 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => { window.open(fileUrl, "_blank", "noopener,noreferrer"); }; + const openWithOnlyOffice = async () => { + if (!fileUrl) return; + if (!officeBase) { + window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL"); + return; + } + try { + const res = await fetch( + `/api/media/signed-url?fileUrl=${encodeURIComponent(fileUrl)}&fileName=${encodeURIComponent( + displayFileName, + )}&for=onlyoffice`, + ); + if (!res.ok) { + const payload = await res.json().catch(() => null); + throw new Error(payload?.error ?? "生成签名链接失败"); + } + const { signedUrl } = (await res.json()) as { signedUrl: string }; + const target = new URL("/onlyoffice", window.location.origin); + target.searchParams.set("fileUrl", signedUrl); + target.searchParams.set("fileName", displayFileName); + target.searchParams.set("fileType", extension || "docx"); + window.open(target.toString(), "_blank", "noopener,noreferrer"); + } catch (error) { + window.alert((error as Error).message); + } + }; + const downloadAsset = () => { if (!fileUrl) return; const anchor = document.createElement("a"); anchor.href = fileUrl; - anchor.download = block.props.fileName || block.props.caption || typeLabel; + anchor.download = block.props.fileName || block.props.caption || typeLabel; anchor.click(); }; @@ -278,7 +318,7 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => { if (assetType === "file") { return (
- +
@@ -361,7 +401,17 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => { return (
-
+
{ + if (assetType === "file" && isOfficeDoc) { + void openWithOnlyOffice(); + } else if (assetType === "file") { + viewOriginal(); + } + }} + > {block.props.linkUrl ? ( {figure} @@ -410,6 +460,9 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => { {dropdownLinkLabel} handleCopyLink(fileUrl)}>复制链接 查看原文件 + {assetType === "file" && isOfficeDoc && ( + void openWithOnlyOffice()}>使用 ONLYOFFICE 打开 + )} 下载到本地 {canTriggerOcr && ( <> diff --git a/wolai-frontend/src/lib/supabase/admin.ts b/wolai-frontend/src/lib/supabase/admin.ts new file mode 100644 index 00000000..d180b697 --- /dev/null +++ b/wolai-frontend/src/lib/supabase/admin.ts @@ -0,0 +1,14 @@ +import { createClient } from "@supabase/supabase-js"; + +const supabaseAdmin = createClient( + process.env.SUPABASE_URL ?? "", + process.env.SUPABASE_SERVICE_ROLE_KEY ?? "", + { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }, +); + +export default supabaseAdmin;