0.3.5 共享功能修复
This commit is contained in:
@@ -24,6 +24,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
notFound();
|
||||
}
|
||||
const readOnly = (doc as any).can_edit === false;
|
||||
const disableDownload = Boolean((doc as any).disable_download);
|
||||
const disableCopy = Boolean((doc as any).disable_copy);
|
||||
|
||||
const initialOptions: PageOptionsState = {
|
||||
wideLayout: doc.wide_layout ?? false,
|
||||
@@ -54,6 +56,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
initialStats={initialStats}
|
||||
openTableId={openTableId}
|
||||
readOnly={readOnly}
|
||||
disableDownload={disableDownload}
|
||||
disableCopy={disableCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getUserFacingErrorMessage } from "@/lib/auth/errors";
|
||||
|
||||
type AuthStep = "signIn" | "signUp";
|
||||
|
||||
@@ -43,11 +44,47 @@ export default function AuthPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [pendingUsernameToSave, setPendingUsernameToSave] = useState<string | null>(null);
|
||||
const [isSavingUsername, setIsSavingUsername] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error" | "info";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
// 注册完成后,等待登录态同步完成再写入用户名,避免出现“未登录”的竞态
|
||||
useEffect(() => {
|
||||
if (!pendingUsernameToSave) return;
|
||||
if (!isAuthenticated) return;
|
||||
if (currentUser === undefined || currentUser === null) return;
|
||||
if (currentUser.name) {
|
||||
setPendingUsernameToSave(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsSavingUsername(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await setMyUsername({ username: pendingUsernameToSave });
|
||||
if (cancelled) return;
|
||||
setPendingUsernameToSave(null);
|
||||
setMessage({ type: "success", text: "注册成功!" });
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
setPendingUsernameToSave(null);
|
||||
setMessage({ type: "error", text: getUserFacingErrorMessage(err, "用户名设置失败,请重试") });
|
||||
} finally {
|
||||
if (!cancelled) setIsSavingUsername(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentUser, isAuthenticated, pendingUsernameToSave, router, setMyUsername]);
|
||||
|
||||
// 执行登录的核心逻辑
|
||||
const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => {
|
||||
setMessage(null);
|
||||
@@ -67,13 +104,11 @@ export default function AuthPage() {
|
||||
|
||||
if (result.signingIn) {
|
||||
if (flow === "signUp") {
|
||||
try {
|
||||
await setMyUsername({ username });
|
||||
} catch (e: any) {
|
||||
// 说明:注册已成功,但用户名可能重复;此时保持登录状态,转入“设置用户名”步骤继续处理。
|
||||
setMessage({ type: "error", text: e?.message ?? "用户名设置失败,请重试" });
|
||||
return;
|
||||
}
|
||||
// 说明:注册已成功,但此刻登录态可能尚未同步到 Convex functions。
|
||||
// 这里先进入“待写入用户名”状态,等 currentUser 可用后再写,避免出现“未登录”的竞态。
|
||||
setMessage({ type: "info", text: "注册成功,正在保存用户名..." });
|
||||
setPendingUsernameToSave(username.trim());
|
||||
return;
|
||||
}
|
||||
setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" });
|
||||
setTimeout(() => router.push("/"), 500);
|
||||
@@ -89,9 +124,9 @@ export default function AuthPage() {
|
||||
|
||||
setMessage({ type: "error", text: "操作失败,请重试" });
|
||||
} catch (error: any) {
|
||||
setMessage({ type: "error", text: error.message || "操作失败,请重试" });
|
||||
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "操作失败,请重试") });
|
||||
}
|
||||
}, [router, setMyUsername, signIn, username]);
|
||||
}, [router, signIn, username]);
|
||||
|
||||
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -134,12 +169,12 @@ export default function AuthPage() {
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
if (currentUser === undefined) {
|
||||
if (currentUser === undefined || currentUser === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">加载中...</p>
|
||||
<p className="text-gray-600">正在同步登录状态...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -148,11 +183,15 @@ export default function AuthPage() {
|
||||
const saveUsername = async () => {
|
||||
setMessage(null);
|
||||
try {
|
||||
await setMyUsername({ username });
|
||||
if (isSavingUsername) return;
|
||||
setIsSavingUsername(true);
|
||||
await setMyUsername({ username: username.trim() });
|
||||
setMessage({ type: "success", text: "用户名已保存!" });
|
||||
router.replace("/");
|
||||
} catch (error: any) {
|
||||
setMessage({ type: "error", text: error.message || "保存失败,请重试" });
|
||||
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "保存失败,请重试") });
|
||||
} finally {
|
||||
setIsSavingUsername(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,6 +224,7 @@ export default function AuthPage() {
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isSavingUsername}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
placeholder="用户名(2-32 位,不含空格)"
|
||||
/>
|
||||
@@ -192,9 +232,10 @@ export default function AuthPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveUsername()}
|
||||
disabled={isSavingUsername}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
保存并继续
|
||||
{isSavingUsername ? "保存中..." : "保存并继续"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,8 @@ export async function GET(request: Request) {
|
||||
file_name: asset.file_name,
|
||||
mime_type: asset.mime_type,
|
||||
file_size: asset.file_size,
|
||||
storage_id: (asset as any)?.storage_id ?? null,
|
||||
updated_at: (asset as any)?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,6 +94,19 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
// 只读权限:不允许通过 ONLYOFFICE 回调写回
|
||||
try {
|
||||
const perm = await client.query(api.documents.getPermissionForUser, {
|
||||
userId,
|
||||
id: String((asset as any).document_id || ""),
|
||||
});
|
||||
if (!perm || (perm as any).permission !== "edit") {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
|
||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||
if (!upstream.ok) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
|
||||
|
||||
const base64Url = (input: Buffer | string) =>
|
||||
Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const signHs256 = (payload: unknown, secret: string) => {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const headerPart = base64Url(JSON.stringify(header));
|
||||
const payloadPart = base64Url(JSON.stringify(payload));
|
||||
const signingInput = `${headerPart}.${payloadPart}`;
|
||||
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
|
||||
return `${signingInput}.${base64Url(signature)}`;
|
||||
};
|
||||
|
||||
const normalizeSecret = (raw: string) => {
|
||||
const trimmed = String(raw || "").trim();
|
||||
if (!trimmed) return "";
|
||||
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
|
||||
if (
|
||||
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
||||
) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let auth;
|
||||
let client;
|
||||
try {
|
||||
const authed = await getAuthedConvexClient();
|
||||
auth = authed.auth;
|
||||
client = authed.client;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = String(searchParams.get("assetId") || "").trim();
|
||||
const key = String(searchParams.get("key") || "").trim();
|
||||
if (!assetId) return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
if (!key) return NextResponse.json({ error: "缺少 key" }, { status: 400 });
|
||||
|
||||
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 只读权限:不允许触发 forcesave(避免只读用户间接写回存储)
|
||||
const docId = String((asset as any).document_id || "").trim();
|
||||
if (!docId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const doc = await client.query(api.documents.getMeta, { id: docId });
|
||||
if (!doc || (doc as any).can_edit === false) {
|
||||
return NextResponse.json({ error: "无权修改(只读共享的文件)" }, { status: 403 });
|
||||
}
|
||||
|
||||
const payload = { c: "forcesave", key, userdata: `asset:${assetId}` };
|
||||
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tryJson = async (res: Response) => (await res.json().catch(() => null)) as any;
|
||||
|
||||
try {
|
||||
// 优先按文档推荐:使用 /command + token
|
||||
if (secret) {
|
||||
const token = signHs256(payload, secret);
|
||||
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const j = await tryJson(r);
|
||||
if (r.ok && Number(j?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: "command", result: j });
|
||||
}
|
||||
|
||||
// 兜底:部分环境可能暴露 /forcesave 直连接口
|
||||
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j2 = await tryJson(r2);
|
||||
if (r2.ok && Number(j2?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: "forcesave", result: j2 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "触发 forcesave 失败", detail: { command: j, forcesave: j2 } },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// JWT 未启用:尝试 /forcesave 直连
|
||||
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j = await tryJson(r);
|
||||
if (r.ok && Number(j?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: "forcesave", result: j });
|
||||
}
|
||||
|
||||
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
|
||||
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j2 = await tryJson(r2);
|
||||
if (r2.ok && Number(j2?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: "command_no_token", result: j2 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "触发 forcesave 失败", detail: { forcesave: j, command: j2 } },
|
||||
{ status: 502 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[onlyoffice/forcesave] failed:", error);
|
||||
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import type {
|
||||
DocumentSearchFilters,
|
||||
DocumentSearchRequest,
|
||||
@@ -33,100 +34,389 @@ const TIME_FIELD_COLUMN = {
|
||||
created: "created_at",
|
||||
} as const;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
type RangeIso = { fromIso: string | null; toIso: string | null };
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filters: DocumentSearchFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
...payload.filters,
|
||||
};
|
||||
|
||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const normalizedLower = normalizedQuery.toLowerCase();
|
||||
|
||||
const docs = await client.query(api.documents.listByWorkspace, { workspaceId });
|
||||
const docMap = new Map(docs.map((d) => [d.id, d]));
|
||||
|
||||
const recentRows = await client.query(api.recents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 10,
|
||||
});
|
||||
const recent: DocumentSearchResult[] = recentRows
|
||||
.map((r) => docMap.get(r.document_id))
|
||||
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: "",
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "recent",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
if (!normalizedQuery) {
|
||||
const response: DocumentSearchResponse = { results: [], recent };
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
|
||||
const timeField = TIME_FIELD_COLUMN[filters.timeField];
|
||||
const boundaryIso =
|
||||
typeof timeRangeMs === "number"
|
||||
? new Date(Date.now() - timeRangeMs).toISOString()
|
||||
: null;
|
||||
|
||||
const narrowed = [...docs]
|
||||
.sort((a, b) => {
|
||||
const ta = a.updated_at ?? a.created_at ?? "";
|
||||
const tb = b.updated_at ?? b.created_at ?? "";
|
||||
return tb.localeCompare(ta);
|
||||
})
|
||||
.filter((row) => {
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
if (row.id !== payload.documentId) return false;
|
||||
}
|
||||
|
||||
if (boundaryIso) {
|
||||
const ts = (row as any)?.[timeField] ?? null;
|
||||
if (!ts || typeof ts !== "string") return false;
|
||||
if (ts < boundaryIso) return false;
|
||||
}
|
||||
|
||||
const title = (row.title ?? "无标题").toLowerCase();
|
||||
if (filters.exact) {
|
||||
return title === normalizedLower;
|
||||
}
|
||||
return title.includes(normalizedLower);
|
||||
});
|
||||
|
||||
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: buildSnippet(row.title ?? "", normalizedQuery),
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "title",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 2,
|
||||
}));
|
||||
|
||||
const response: DocumentSearchResponse = { results, recent };
|
||||
return NextResponse.json(response);
|
||||
function parseDateToIso(value: string | undefined): string | null {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function resolveCustomRangeIso(filters: DocumentSearchFilters): RangeIso {
|
||||
const fromIso = parseDateToIso(filters.customRange?.from);
|
||||
const toIso = parseDateToIso(filters.customRange?.to);
|
||||
return { fromIso, toIso };
|
||||
}
|
||||
|
||||
type MindmapNode = { data?: { text?: unknown }; children?: unknown[] };
|
||||
|
||||
function extractTextFromMindmapData(data: unknown, maxChars = 60000): string {
|
||||
const out: string[] = [];
|
||||
const pushText = (value: unknown) => {
|
||||
const s = typeof value === "string" ? value : null;
|
||||
if (!s) return;
|
||||
const trimmed = s.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return;
|
||||
out.push(trimmed);
|
||||
};
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
if (!node || typeof node !== "object") return;
|
||||
const n = node as MindmapNode;
|
||||
pushText(n.data?.text);
|
||||
if (Array.isArray(n.children)) {
|
||||
for (const c of n.children) {
|
||||
walk(c);
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(data);
|
||||
const joined = out.join("\n").trim();
|
||||
return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filters: DocumentSearchFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
...payload.filters,
|
||||
};
|
||||
|
||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const normalizedLower = normalizedQuery.toLowerCase();
|
||||
|
||||
const docs = await client.query(api.documents.listSearchDataByWorkspace, { workspaceId });
|
||||
const docMap = new Map(docs.map((d) => [d.id, d]));
|
||||
|
||||
const recentRows = await client.query(api.recents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 10,
|
||||
});
|
||||
const recent: DocumentSearchResult[] = recentRows
|
||||
.map((r) => docMap.get(r.document_id))
|
||||
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: "",
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "recent",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
if (!normalizedQuery) {
|
||||
const response: DocumentSearchResponse = { results: [], recent };
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
|
||||
const timeField = TIME_FIELD_COLUMN[filters.timeField];
|
||||
const boundaryIso =
|
||||
typeof timeRangeMs === "number"
|
||||
? new Date(Date.now() - timeRangeMs).toISOString()
|
||||
: null;
|
||||
const { fromIso, toIso } = resolveCustomRangeIso(filters);
|
||||
|
||||
const eligibleDocs = [...docs]
|
||||
.sort((a, b) => {
|
||||
const ta = a.updated_at ?? a.created_at ?? "";
|
||||
const tb = b.updated_at ?? b.created_at ?? "";
|
||||
return tb.localeCompare(ta);
|
||||
})
|
||||
.filter((row) => {
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
if (row.id !== payload.documentId) return false;
|
||||
}
|
||||
|
||||
if (boundaryIso) {
|
||||
const ts = (row as any)?.[timeField] ?? null;
|
||||
if (!ts || typeof ts !== "string") return false;
|
||||
if (ts < boundaryIso) return false;
|
||||
}
|
||||
|
||||
if (fromIso || toIso) {
|
||||
const ts = (row as any)?.[timeField] ?? null;
|
||||
if (!ts || typeof ts !== "string") return false;
|
||||
if (fromIso && ts < fromIso) return false;
|
||||
if (toIso && ts > toIso) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const eligibleDocIds = new Set(eligibleDocs.map((d) => d.id));
|
||||
|
||||
type MatchInfo = {
|
||||
score: number;
|
||||
matchField: "title" | "content";
|
||||
snippet: string;
|
||||
hasOcr: boolean;
|
||||
};
|
||||
|
||||
const matches = new Map<string, MatchInfo>();
|
||||
|
||||
const upsertMatch = (docId: string, patch: Partial<MatchInfo>) => {
|
||||
const prev = matches.get(docId);
|
||||
if (!prev) {
|
||||
const score = typeof patch.score === "number" ? patch.score : 0;
|
||||
const matchField = patch.matchField ?? "content";
|
||||
const snippet = patch.snippet ?? "暂无正文内容";
|
||||
const hasOcr = Boolean(patch.hasOcr);
|
||||
matches.set(docId, { score, matchField, snippet, hasOcr });
|
||||
return;
|
||||
}
|
||||
|
||||
const next: MatchInfo = {
|
||||
score: typeof patch.score === "number" ? Math.max(prev.score, patch.score) : prev.score,
|
||||
matchField: patch.matchField ?? prev.matchField,
|
||||
snippet: patch.snippet ?? prev.snippet,
|
||||
hasOcr: prev.hasOcr || Boolean(patch.hasOcr),
|
||||
};
|
||||
|
||||
// 若现有是标题匹配,不用较低分覆盖文案。
|
||||
if (prev.matchField === "title" && next.matchField !== "title") {
|
||||
next.matchField = "title";
|
||||
next.snippet = prev.snippet;
|
||||
}
|
||||
|
||||
// 若新分数更高,则用新片段(除标题外)
|
||||
if (typeof patch.score === "number" && patch.score > prev.score && prev.matchField !== "title") {
|
||||
next.snippet = patch.snippet ?? next.snippet;
|
||||
next.matchField = patch.matchField ?? next.matchField;
|
||||
}
|
||||
|
||||
matches.set(docId, next);
|
||||
};
|
||||
|
||||
for (const row of eligibleDocs) {
|
||||
const title = (row.title ?? "无标题").trim() || "无标题";
|
||||
const titleLower = title.toLowerCase();
|
||||
const hitTitle = filters.exact ? titleLower === normalizedLower : titleLower.includes(normalizedLower);
|
||||
if (hitTitle) {
|
||||
upsertMatch(row.id, {
|
||||
score: 3,
|
||||
matchField: "title",
|
||||
snippet: buildSnippet(title, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.titleOnly) continue;
|
||||
|
||||
const rawText = String(row.raw_text ?? "").trim();
|
||||
if (rawText) {
|
||||
const hitContent = rawText.toLowerCase().includes(normalizedLower);
|
||||
if (hitContent) {
|
||||
upsertMatch(row.id, {
|
||||
score: 2,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(rawText, normalizedQuery),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.titleOnly) {
|
||||
// 思维导图(默认纳入“正文”搜索)
|
||||
const mindmaps = await client
|
||||
.query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false })
|
||||
.catch(() => []);
|
||||
for (const m of mindmaps) {
|
||||
const docId = String(m.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
|
||||
const text = extractTextFromMindmapData(m.data ?? null);
|
||||
if (!text) continue;
|
||||
if (!text.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
upsertMatch(docId, {
|
||||
score: 1.6,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`思维导图:${text}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
// Luckysheet 在线表格(标题 + 行内容)
|
||||
const [tables, tableRows] = await Promise.all([
|
||||
client
|
||||
.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
includeArchived: false,
|
||||
limit: 3000,
|
||||
})
|
||||
.catch(() => []),
|
||||
client
|
||||
.query(api.tables.listRowsByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 8000,
|
||||
})
|
||||
.catch(() => []),
|
||||
]);
|
||||
|
||||
const tableTitleById = new Map<string, string>();
|
||||
for (const t of tables) {
|
||||
if (!eligibleDocIds.has(t.document_id)) continue;
|
||||
tableTitleById.set(t.id, String(t.title ?? "").trim() || "未命名表格");
|
||||
|
||||
const hitTableTitle = String(t.title ?? "").toLowerCase().includes(normalizedLower);
|
||||
if (hitTableTitle && docMap.has(t.document_id)) {
|
||||
upsertMatch(t.document_id, {
|
||||
score: 1.5,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`表格:${t.title}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of tableRows) {
|
||||
const docId = String(r.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
const rowHash = String(r.row_hash ?? "").trim();
|
||||
if (!rowHash) continue;
|
||||
if (!rowHash.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
const tableTitle = tableTitleById.get(String(r.table_id ?? "").trim()) ?? "未命名表格";
|
||||
upsertMatch(docId, {
|
||||
score: 1.4,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`表格:${tableTitle}\n${rowHash}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
// 附件/图片:默认搜索文件名;勾选“搜索附件内容”后再纳入 OCR/解析文本。
|
||||
const assets = await client
|
||||
.query(api.mediaAssets.listSearchDataByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
includeDeleted: false,
|
||||
limit: 5000,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
const toEnqueueExtract: { id: string; mime: string | null; name: string | null; status: string | null; type: string | null }[] = [];
|
||||
|
||||
for (const a of assets) {
|
||||
const docId = String(a.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
|
||||
const fileName = String(a.file_name ?? "").trim();
|
||||
if (fileName && fileName.toLowerCase().includes(normalizedLower)) {
|
||||
upsertMatch(docId, {
|
||||
score: 1.2,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`附件:${fileName}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
if (!filters.includeOcr) continue;
|
||||
|
||||
// 说明:勾选“搜索附件内容”时,若附件尚未生成 ocr_text,则后台排队解析(避免用户长期搜不到)。
|
||||
const ocrTextValue = String(a.ocr_text ?? "").trim();
|
||||
if (!ocrTextValue) {
|
||||
const assetType = String((a as any).asset_type ?? "").trim() || null;
|
||||
const mimeType = String((a as any).mime_type ?? "").toLowerCase().trim() || null;
|
||||
const ocrStatus = String((a as any).ocr_status ?? "").trim() || null;
|
||||
const nameLower = String(a.file_name ?? "").toLowerCase().trim() || null;
|
||||
|
||||
const supported =
|
||||
assetType === "file" &&
|
||||
(mimeType === "application/pdf" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||
(nameLower ? [".pdf", ".docx", ".pptx", ".xlsx"].some((ext) => nameLower.endsWith(ext)) : false));
|
||||
|
||||
const busy = ocrStatus === "queued" || ocrStatus === "running";
|
||||
if (supported && !busy) {
|
||||
toEnqueueExtract.push({ id: a.id, mime: mimeType, name: a.file_name ?? null, status: ocrStatus, type: assetType });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ocrTextValue.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
upsertMatch(docId, {
|
||||
score: 1.7,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`附件:${fileName || a.id}\n${ocrTextValue}`, normalizedQuery),
|
||||
hasOcr: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (toEnqueueExtract.length > 0) {
|
||||
// 说明:限制每次搜索触发的解析数量,避免请求变慢;剩余附件可继续通过下一次搜索逐步排队。
|
||||
await Promise.all(
|
||||
toEnqueueExtract
|
||||
.slice(0, 3)
|
||||
.map((item) =>
|
||||
client.mutation(api.mediaAssets.enqueueExtractText, { userId: auth.userId, id: item.id }).catch(() => null),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const results: DocumentSearchResult[] = [];
|
||||
for (const [docId, match] of matches.entries()) {
|
||||
const row = docMap.get(docId);
|
||||
if (!row) continue;
|
||||
results.push({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: match.snippet,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: match.matchField,
|
||||
hasOcr: match.hasOcr,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: match.score,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
const ta = a.updatedAt ?? a.createdAt ?? "";
|
||||
const tb = b.updatedAt ?? b.createdAt ?? "";
|
||||
const byTime = tb.localeCompare(ta);
|
||||
if (byTime) return byTime;
|
||||
return String(a.title ?? "").localeCompare(String(b.title ?? ""));
|
||||
});
|
||||
|
||||
const limitedResults = results.slice(0, limit);
|
||||
|
||||
const response: DocumentSearchResponse = { results: limitedResults, recent };
|
||||
return NextResponse.json(response);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: err.message }, { status: err.status });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "搜索失败,请稍后再试";
|
||||
console.error("[search/documents] failed:", err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -37,9 +37,20 @@ const pickCacheControl = (pathParts: string[], contentType: string, method: stri
|
||||
const p = `/${(pathParts ?? []).join("/")}`.toLowerCase();
|
||||
// 说明:/cache 主要是 ONLYOFFICE 运行期二进制缓存,适合短缓存提升性能,但不宜过长。
|
||||
if (p.includes("editor.bin") || p.endsWith(".bin")) {
|
||||
return "public, max-age=3600, stale-while-revalidate=600";
|
||||
return "public, max-age=604800, stale-while-revalidate=86400, immutable";
|
||||
}
|
||||
return "public, max-age=300, stale-while-revalidate=300";
|
||||
return "public, max-age=86400, stale-while-revalidate=3600";
|
||||
};
|
||||
|
||||
const buildStableEtag = (pathParts: string[], search: string) => {
|
||||
const p = `/${(pathParts ?? []).join("/")}`;
|
||||
const s = String(search || "");
|
||||
return `W/"mnote-oo-cache:${p}${s}"`;
|
||||
};
|
||||
|
||||
const isStrongCache = (cc: string) => {
|
||||
const v = String(cc || "");
|
||||
return v.includes("max-age=604800");
|
||||
};
|
||||
|
||||
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
|
||||
@@ -73,11 +84,21 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => {
|
||||
stripHopByHopHeaders(outHeaders);
|
||||
outHeaders.delete("content-encoding");
|
||||
outHeaders.delete("content-length");
|
||||
outHeaders.set("cache-control", pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method));
|
||||
const cacheControl = pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method);
|
||||
outHeaders.set("cache-control", cacheControl);
|
||||
outHeaders.delete("pragma");
|
||||
outHeaders.delete("expires");
|
||||
outHeaders.delete("set-cookie");
|
||||
|
||||
if (isStrongCache(cacheControl)) {
|
||||
const etag = buildStableEtag(pathParts, incomingUrl.search);
|
||||
outHeaders.set("etag", etag);
|
||||
const inm = String(request.headers.get("if-none-match") || "").trim();
|
||||
if (inm && (inm === etag || inm.split(",").map((s) => s.trim()).includes(etag))) {
|
||||
return new NextResponse(null, { status: 304, headers: outHeaders });
|
||||
}
|
||||
}
|
||||
|
||||
return new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: outHeaders,
|
||||
|
||||
@@ -38,7 +38,8 @@ const XHR_REWRITE_SNIPPET = `
|
||||
// 说明:ONLYOFFICE 在被反向代理(/onlyoffice-server)时,运行期仍可能发起指向内部端口
|
||||
// http://127.0.0.1:8081/cache/... 的绝对请求(来自 ONLYOFFICE 内部逻辑)。
|
||||
// 这会导致浏览器从 origin(3000) 跨域请求 8081 并触发 CORS 拦截。
|
||||
// 这里在 ONLYOFFICE 页面(含其 iframe)内统一重写 XHR:把内部 8081 的请求改写回同源 /onlyoffice-server/*。
|
||||
// 这里在 ONLYOFFICE 页面(含其 iframe)内统一重写 XHR/fetch/window.open/location:
|
||||
// 把内部 8081 的请求改写回同源 /onlyoffice-server/*。
|
||||
window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
(function () {
|
||||
try {
|
||||
@@ -68,10 +69,47 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
return u;
|
||||
}
|
||||
}
|
||||
var origOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
|
||||
return origOpen.call(this, method, rewrite(url), async, user, password);
|
||||
};
|
||||
|
||||
try {
|
||||
var origOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
|
||||
return origOpen.call(this, method, rewrite(url), async, user, password);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
var origFetch = window.fetch;
|
||||
if (typeof origFetch === 'function') {
|
||||
window.fetch = function (input, init) {
|
||||
try {
|
||||
if (typeof input === 'string') return origFetch.call(this, rewrite(input), init);
|
||||
if (input && typeof input === 'object' && typeof input.url === 'string') {
|
||||
return origFetch.call(this, new Request(rewrite(input.url), input), init);
|
||||
}
|
||||
} catch (e) {}
|
||||
return origFetch.call(this, input, init);
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
var origWinOpen = window.open;
|
||||
if (typeof origWinOpen === 'function') {
|
||||
window.open = function (url, target, features) {
|
||||
try {
|
||||
if (typeof url === 'string') url = rewrite(url);
|
||||
} catch (e) {}
|
||||
return origWinOpen.call(this, url, target, features);
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
var origAssign = location.assign && location.assign.bind(location);
|
||||
if (origAssign) location.assign = function (url) { return origAssign(rewrite(url)); };
|
||||
var origReplace = location.replace && location.replace.bind(location);
|
||||
if (origReplace) location.replace = function (url) { return origReplace(rewrite(url)); };
|
||||
} catch (e) {}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
@@ -155,6 +193,21 @@ const pickCacheControl = (pathParts: string[], contentType: string, method: stri
|
||||
return "public, max-age=300, stale-while-revalidate=300";
|
||||
};
|
||||
|
||||
const buildStableEtag = (pathParts: string[], search: string) => {
|
||||
// 说明:ONLYOFFICE 的静态资源路径通常包含版本号/哈希(例如 /9.2.1-<hash>/sdkjs/...)。
|
||||
// 在 frp/隧道场景下,如果浏览器端请求带 `cache-control: no-cache`,会强制走 revalidate;
|
||||
// 但只要我们提供稳定 ETag,就可以快速返回 304,避免重复下载几十 MB 的资源。
|
||||
const p = `/${(pathParts ?? []).join("/")}`;
|
||||
const s = String(search || "");
|
||||
return `W/"mnote-oo:${p}${s}"`;
|
||||
};
|
||||
|
||||
const isStaticCacheControl = (cc: string) => {
|
||||
const v = String(cc || "");
|
||||
// 与 pickCacheControl 的“静态资源”分支对齐
|
||||
return v.includes("max-age=604800");
|
||||
};
|
||||
|
||||
const proxy = async (request: NextRequest, pathParts: string[]) => {
|
||||
const incomingUrl = new URL(request.url);
|
||||
const target = new URL(`${ONLYOFFICE_INTERNAL_URL}/${pathParts.map(encodeURIComponent).join("/")}`);
|
||||
@@ -244,10 +297,23 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
|
||||
outHeaders.delete("content-length");
|
||||
|
||||
const contentType = upstream.headers.get("content-type") || "";
|
||||
outHeaders.set("cache-control", pickCacheControl(pathParts, contentType, request.method));
|
||||
const cacheControl = pickCacheControl(pathParts, contentType, request.method);
|
||||
outHeaders.set("cache-control", cacheControl);
|
||||
outHeaders.delete("pragma");
|
||||
outHeaders.delete("expires");
|
||||
outHeaders.delete("set-cookie");
|
||||
|
||||
// 说明:为静态资源提供稳定 ETag,并支持 if-none-match → 304,提升重复打开速度。
|
||||
// 注意:仅对我们判定为“强缓存静态资源”的请求启用,避免影响协同接口/动态响应。
|
||||
if (isStaticCacheControl(cacheControl)) {
|
||||
const etag = buildStableEtag(pathParts, incomingUrl.search);
|
||||
outHeaders.set("etag", etag);
|
||||
const inm = String(request.headers.get("if-none-match") || "").trim();
|
||||
if (inm && (inm === etag || inm.split(",").map((s) => s.trim()).includes(etag))) {
|
||||
return new NextResponse(null, { status: 304, headers: outHeaders });
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = await upstream.text();
|
||||
const injected = injectDisableServiceWorker(html);
|
||||
|
||||
@@ -5,6 +5,8 @@ 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";
|
||||
|
||||
@@ -136,7 +138,8 @@ const base64UrlEncodeUtf8 = (input: string) => {
|
||||
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
|
||||
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
|
||||
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
|
||||
// 这里在 ONLYOFFICE 页面内对 XHR 做一次 URL 重写:把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
|
||||
// 这里在 ONLYOFFICE 页面内对 XHR/fetch/window.open/location.assign 等做 URL 重写:
|
||||
// 把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
|
||||
// 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。
|
||||
if (typeof window === "undefined") return;
|
||||
if (!baseUrl) return;
|
||||
@@ -193,20 +196,87 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
return input;
|
||||
}
|
||||
};
|
||||
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
|
||||
if (typeof origOpen !== "function") return;
|
||||
|
||||
(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);
|
||||
};
|
||||
|
||||
// 说明:修复 “下载为 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
|
||||
@@ -279,10 +349,22 @@ export default function OnlyOfficePage() {
|
||||
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
|
||||
const mode = (params.get("mode") ?? "edit") as EditorMode;
|
||||
const assetId = params.get("assetId") ?? "";
|
||||
const documentId = params.get("documentId") ?? "";
|
||||
const initialUserId = params.get("userId") ?? "";
|
||||
const channel = (params.get("channel") ?? "").trim().toLowerCase();
|
||||
const convex = useConvex();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authedUserId, setAuthedUserId] = useState<string>(initialUserId);
|
||||
const [resolvedMode, setResolvedMode] = useState<EditorMode | null>(mode === "view" ? "view" : null);
|
||||
const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false);
|
||||
const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false);
|
||||
const [assetSignedUrl, setAssetSignedUrl] = useState<string>("");
|
||||
const [assetStorageId, setAssetStorageId] = useState<string>("");
|
||||
const [forceSaveState, setForceSaveState] = useState<{
|
||||
busy: boolean;
|
||||
message: string | null;
|
||||
ok: boolean | null;
|
||||
}>({ busy: false, message: null, ok: null });
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const baseUrlCandidates = useMemo(() => {
|
||||
const uniq: string[] = [];
|
||||
@@ -332,6 +414,33 @@ export default function OnlyOfficePage() {
|
||||
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
||||
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
|
||||
|
||||
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 能以真实用户身份写回存储,
|
||||
@@ -357,6 +466,52 @@ export default function OnlyOfficePage() {
|
||||
};
|
||||
}, [authedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
// 只读权限:强制以 view 模式打开(避免分享页面只读但 ONLYOFFICE 仍可编辑)。
|
||||
if (!documentId) {
|
||||
setResolvedMode(mode === "view" ? "view" : mode);
|
||||
setResolvedDisableDownload(false);
|
||||
setResolvedDisableCopy(false);
|
||||
return;
|
||||
}
|
||||
if (!authedUserId) return;
|
||||
|
||||
let canceled = false;
|
||||
void (async () => {
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [authedUserId, convex, documentId, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
// 说明:在部分 ONLYOFFICE 版本/环境下,编辑器内部会触发 DOMException(NotFoundError: removeChild),
|
||||
// 该异常会被 Next.js 捕获并显示“客户端异常”大红屏,但实际文档仍可继续使用。
|
||||
@@ -470,8 +625,22 @@ export default function OnlyOfficePage() {
|
||||
}, []);
|
||||
|
||||
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 (!fileUrl) return "";
|
||||
if (!effectiveFileUrl) return "";
|
||||
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
|
||||
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
|
||||
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000,
|
||||
@@ -483,7 +652,7 @@ export default function OnlyOfficePage() {
|
||||
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
|
||||
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
|
||||
try {
|
||||
const u = new URL(fileUrl);
|
||||
const u = new URL(effectiveFileUrl);
|
||||
return u.pathname.startsWith("/api/storage/");
|
||||
} catch {
|
||||
return false;
|
||||
@@ -492,8 +661,8 @@ export default function OnlyOfficePage() {
|
||||
|
||||
let base =
|
||||
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
|
||||
? fileUrl
|
||||
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
|
||||
? effectiveFileUrl
|
||||
: rewriteToPublicOrigin(effectiveFileUrl, runtimeConfig.supabaseUrl);
|
||||
try {
|
||||
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
|
||||
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
|
||||
@@ -548,7 +717,7 @@ export default function OnlyOfficePage() {
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
|
||||
}, [effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl, runtimeConfig.useConvex]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -557,24 +726,43 @@ export default function OnlyOfficePage() {
|
||||
baseUrl,
|
||||
proxyOrigin,
|
||||
callbackOrigin,
|
||||
fileUrlInput: fileUrl,
|
||||
fileUrlInput: effectiveFileUrl,
|
||||
resolvedFileUrl,
|
||||
fileName,
|
||||
fileType,
|
||||
mode,
|
||||
assetId,
|
||||
docKey,
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [assetId, authedUserId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
|
||||
}, [
|
||||
assetId,
|
||||
authedUserId,
|
||||
baseUrl,
|
||||
callbackOrigin,
|
||||
docKey,
|
||||
effectiveFileUrl,
|
||||
fileName,
|
||||
fileType,
|
||||
resolvedMode,
|
||||
proxyOrigin,
|
||||
resolvedFileUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// 说明:如果是附件(assetId)编辑模式,则必须拿到真实 userId 才能让
|
||||
// /api/onlyoffice/callback 通过工作空间成员校验并把文件写回存储。
|
||||
// 否则会出现:编辑器里看似“已保存”,但下载/再次打开仍是旧文件。
|
||||
if (mode !== "view" && assetId && !authedUserId) {
|
||||
return;
|
||||
}
|
||||
if (!baseUrl) {
|
||||
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
|
||||
return;
|
||||
}
|
||||
if (!fileUrl) {
|
||||
if (!effectiveFileUrl) {
|
||||
setError("缺少 fileUrl 参数。");
|
||||
return;
|
||||
}
|
||||
@@ -588,7 +776,7 @@ export default function OnlyOfficePage() {
|
||||
const pageHost = window.location.hostname;
|
||||
const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost";
|
||||
const isPageRemote = !isPageLocal;
|
||||
const u = new URL(fileUrl);
|
||||
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(
|
||||
@@ -600,6 +788,9 @@ export default function OnlyOfficePage() {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!resolvedMode) return;
|
||||
if (resolvedMode !== "view" && assetId && !authedUserId) return;
|
||||
|
||||
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
|
||||
loadScript(scriptUrl)
|
||||
.then(async () => {
|
||||
@@ -617,7 +808,8 @@ export default function OnlyOfficePage() {
|
||||
title: fileName,
|
||||
url: resolvedFileUrl,
|
||||
// 说明:key 用于 ONLYOFFICE 内部区分文档实例;应随 URL/文件名变化,避免缓存/冲突。
|
||||
key: hashKey(`${resolvedFileUrl}-${fileName}`),
|
||||
// 这里改为“对同一附件尽量稳定”的 key,以提升远端场景的缓存命中与加载速度。
|
||||
key: docKey,
|
||||
},
|
||||
documentType: targetDocType,
|
||||
events: {
|
||||
@@ -655,6 +847,9 @@ export default function OnlyOfficePage() {
|
||||
})(),
|
||||
customization: {
|
||||
feedback: { visible: false },
|
||||
// 说明:启用 forcesave 后,用户点击 ONLYOFFICE 的“保存”会触发 status=6 回调,
|
||||
// 从而立即把最新版本写回主存储(无需等待关闭文档触发 status=2)。
|
||||
forcesave: true,
|
||||
},
|
||||
plugins: {
|
||||
autostart: [MNOTE_AGENT_PLUGIN_GUID],
|
||||
@@ -665,6 +860,24 @@ export default function OnlyOfficePage() {
|
||||
|
||||
// 兜底:有些版本不会触发 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(() => {
|
||||
@@ -725,7 +938,58 @@ export default function OnlyOfficePage() {
|
||||
}
|
||||
setError(err.message);
|
||||
});
|
||||
}, [baseUrl, baseUrlCandidates.length, baseUrlIndex, fileName, fileType, fileUrl, mode, resolvedFileUrl, targetDocType]);
|
||||
}, [
|
||||
assetId,
|
||||
authedUserId,
|
||||
baseUrl,
|
||||
baseUrlCandidates.length,
|
||||
baseUrlIndex,
|
||||
callbackOrigin,
|
||||
docKey,
|
||||
effectiveFileUrl,
|
||||
fileName,
|
||||
fileType,
|
||||
mode,
|
||||
proxyOrigin,
|
||||
resolvedFileUrl,
|
||||
targetDocType,
|
||||
runtimeConfig.onlyofficeBaseUrlDesktop,
|
||||
]);
|
||||
|
||||
const canForceSave = resolvedMode === "edit" && Boolean(assetId) && Boolean(docKey);
|
||||
const triggerForceSave = async () => {
|
||||
if (!canForceSave) return;
|
||||
if (forceSaveState.busy) return;
|
||||
setForceSaveState({ busy: true, message: "正在触发同步保存…", ok: null });
|
||||
try {
|
||||
const url = new URL("/api/onlyoffice/forcesave", window.location.origin);
|
||||
url.searchParams.set("assetId", assetId);
|
||||
url.searchParams.set("key", docKey);
|
||||
const r = await fetch(url.toString(), { method: "POST" });
|
||||
if (!r.ok) {
|
||||
const payload = (await r.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(payload?.error ?? "触发失败");
|
||||
}
|
||||
setForceSaveState({ busy: false, message: "已触发同步保存:请稍等 1~3 秒后再下载/刷新。", ok: true });
|
||||
window.setTimeout(() => {
|
||||
setForceSaveState((s) => (s.ok ? { ...s, message: null } : s));
|
||||
}, 4000);
|
||||
} catch (e) {
|
||||
setForceSaveState({
|
||||
busy: false,
|
||||
message: `同步保存失败:${(e as Error).message}`,
|
||||
ok: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!error && mode !== "view" && assetId && !authedUserId) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-50 text-sm text-gray-600">
|
||||
正在获取用户身份,用于写回保存…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -739,9 +1003,27 @@ export default function OnlyOfficePage() {
|
||||
return (
|
||||
<div className="relative h-screen w-screen bg-slate-50">
|
||||
<div id="onlyoffice-frame" className="h-full w-full" />
|
||||
{canForceSave && (
|
||||
<div className="pointer-events-none absolute right-3 top-3 z-50 flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto rounded-md bg-white/90 px-3 py-2 text-xs text-gray-700 shadow-sm ring-1 ring-gray-200 hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={forceSaveState.busy}
|
||||
onClick={() => void triggerForceSave()}
|
||||
title="不依赖 ONLYOFFICE 内置保存按钮,直接触发 forcesave 写回主存储"
|
||||
>
|
||||
{forceSaveState.busy ? "同步保存中…" : "同步保存"}
|
||||
</button>
|
||||
{forceSaveState.message && (
|
||||
<div className="pointer-events-none max-w-[320px] rounded-md bg-black/70 px-3 py-2 text-xs text-white">
|
||||
{forceSaveState.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<OnlyOfficeAiAgentPanel
|
||||
openFile={{
|
||||
id: assetId || `onlyoffice_${hashKey(`${resolvedFileUrl}-${fileName}`)}`,
|
||||
id: assetId || `onlyoffice_${docKey || hashKey(`${resolvedFileUrl}-${fileName}`)}`,
|
||||
title: fileName,
|
||||
fileUrl: resolvedFileUrl,
|
||||
mimeType: null,
|
||||
|
||||
Reference in New Issue
Block a user