feat: 收口文档桥接与 OnlyOffice/Sidebar 回归
- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器 - 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线 - 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
This commit is contained in:
@@ -93,6 +93,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
title={doc.title ?? "无标题"}
|
||||
updatedAt={doc.updated_at}
|
||||
initialContent={null}
|
||||
initialContentRevision={null}
|
||||
initialConflictDetectionKey={null}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
openTableId={openTableId}
|
||||
|
||||
@@ -17,7 +17,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
sidebarInitialData,
|
||||
} = await loadSidebarDataFromConvex({
|
||||
client,
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json({
|
||||
content: result.content ?? null,
|
||||
revision:
|
||||
typeof result.revision === "number" && Number.isInteger(result.revision)
|
||||
? result.revision
|
||||
: 0,
|
||||
conflictDetectionKey:
|
||||
typeof result.conflict_detection_key === "string" && result.conflict_detection_key.trim()
|
||||
? result.conflict_detection_key
|
||||
: `${documentId}:0`,
|
||||
meta: {
|
||||
requestId: bridgeContext.requestId,
|
||||
traceId: bridgeContext.traceId,
|
||||
|
||||
@@ -1,58 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
|
||||
interface SavePayload {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
content: unknown;
|
||||
}
|
||||
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
|
||||
import {
|
||||
buildDocumentSavePayload,
|
||||
type DocumentSavePayload,
|
||||
} from "@/lib/documents/save-contract";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const { documentId, workspaceId, content }: SavePayload = await request.json();
|
||||
const normalizedDocumentId = assertDocumentId(documentId);
|
||||
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
||||
const body = await request.json() as Partial<DocumentSavePayload> & { content: unknown };
|
||||
const normalizedDocumentId = assertDocumentId(body.documentId);
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: normalizedDocumentId,
|
||||
workspaceId: body.workspaceId,
|
||||
revision: body.revision,
|
||||
content: body.content as DocumentSavePayload["content"],
|
||||
conflictDetectionKey: body.conflictDetectionKey,
|
||||
});
|
||||
const normalizedWorkspaceId = payload.workspaceId;
|
||||
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.save",
|
||||
payload: {
|
||||
documentId: normalizedDocumentId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
content,
|
||||
},
|
||||
payload: payload satisfies DocumentSavePayload,
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: normalizedDocumentId,
|
||||
},
|
||||
});
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: normalizedDocumentId,
|
||||
content: envelope.payload.content,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
const result = await executeSaveBridgeCommand({
|
||||
context: bridgeContext,
|
||||
envelope,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
revision: result.revision,
|
||||
conflictDetectionKey: result.conflictDetectionKey,
|
||||
meta: {
|
||||
requestId: bridgeContext.requestId,
|
||||
traceId: bridgeContext.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -9,15 +10,30 @@ const defaultMindmapData = {
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||
) {
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
|
||||
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
|
||||
return NextResponse.json({
|
||||
data: res?.data ?? defaultMindmapData,
|
||||
source: "convex",
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: res?.meta?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
exists: Boolean(res?.meta?.exists),
|
||||
deletedAt: res?.meta?.deleted_at ?? null,
|
||||
createdAt: res?.meta?.created_at ?? null,
|
||||
updatedAt: res?.meta?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -30,7 +46,7 @@ export async function POST(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
|
||||
data?: unknown;
|
||||
createOnly?: boolean;
|
||||
@@ -43,7 +59,18 @@ export async function POST(
|
||||
data: data ?? defaultMindmapData,
|
||||
...(typeof createOnly === "boolean" ? { createOnly } : {}),
|
||||
});
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
updatedAt: result?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
@@ -53,16 +80,27 @@ export async function POST(
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||
) {
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
deletedAt: result?.deleted_at ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
@@ -78,7 +116,7 @@ export async function PATCH(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
|
||||
if (action !== "restore" && action !== "purge") {
|
||||
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||
@@ -87,10 +125,29 @@ export async function PATCH(
|
||||
try {
|
||||
if (action === "purge") {
|
||||
const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
updatedAt: result?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message ?? "操作失败";
|
||||
const status = msg.includes("未找到") ? 404 : 400;
|
||||
@@ -100,4 +157,3 @@ export async function PATCH(
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -9,16 +10,31 @@ const defaultMindmapData = {
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
) {
|
||||
const { docId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
|
||||
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
|
||||
return NextResponse.json({
|
||||
data: res?.data ?? defaultMindmapData,
|
||||
source: "convex",
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: res?.meta?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
exists: Boolean(res?.meta?.exists),
|
||||
deletedAt: res?.meta?.deleted_at ?? null,
|
||||
createdAt: res?.meta?.created_at ?? null,
|
||||
updatedAt: res?.meta?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -31,7 +47,7 @@ export async function POST(
|
||||
const { docId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const payload = (await request.json().catch(() => ({}))) as { data?: unknown };
|
||||
const result = await client.mutation(api.mindmaps.put, {
|
||||
@@ -39,25 +55,46 @@ export async function POST(
|
||||
mindmapId,
|
||||
data: payload.data ?? defaultMindmapData,
|
||||
});
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
updatedAt: result?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
) {
|
||||
const { docId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
deletedAt: result?.deleted_at ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
|
||||
const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim();
|
||||
|
||||
type OnlyOfficeCallbackBody = {
|
||||
@@ -27,7 +27,7 @@ const normalizeSecret = (raw: string) => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
|
||||
const tryRewriteOnlyOfficeDownloadUrl = (raw: string, onlyofficeInternalUrl: string) => {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
|
||||
@@ -36,7 +36,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
|
||||
const prefix = "/onlyoffice-server";
|
||||
if (u.pathname.startsWith(prefix)) {
|
||||
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
|
||||
return `${ONLYOFFICE_INTERNAL_URL}/${nextPath}${u.search}`;
|
||||
return `${onlyofficeInternalUrl}/${nextPath}${u.search}`;
|
||||
}
|
||||
|
||||
return raw;
|
||||
@@ -46,6 +46,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId") || "";
|
||||
|
||||
@@ -107,7 +108,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url, onlyofficeInternalUrl);
|
||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
|
||||
@@ -3,11 +3,10 @@ import crypto from "crypto";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
||||
|
||||
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")
|
||||
@@ -38,6 +37,7 @@ const normalizeSecret = (raw: string) => {
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||
let auth;
|
||||
let client;
|
||||
try {
|
||||
@@ -82,7 +82,7 @@ export async function POST(request: Request) {
|
||||
// 优先按文档推荐:使用 /command + token
|
||||
if (secret) {
|
||||
const token = signHs256(payload, secret);
|
||||
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, {
|
||||
const r = await fetch(`${onlyofficeInternalUrl}/command`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
@@ -93,7 +93,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// 兜底:部分环境可能暴露 /forcesave 直连接口
|
||||
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
|
||||
const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -110,7 +110,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// JWT 未启用:尝试 /forcesave 直连
|
||||
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
|
||||
const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -121,7 +121,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
|
||||
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, {
|
||||
const r2 = await fetch(`${onlyofficeInternalUrl}/command`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -140,4 +140,3 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
buildSidebarDatasetListQueryPayload,
|
||||
} from "@/lib/sidebar-data";
|
||||
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -21,7 +24,6 @@ export async function GET(request: Request) {
|
||||
sidebarInitialData,
|
||||
} = await loadSidebarDataFromConvex({
|
||||
client,
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
requestedWorkspaceId: workspaceIdParam,
|
||||
});
|
||||
@@ -37,7 +39,7 @@ export async function GET(request: Request) {
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "sidebar.dataset.list",
|
||||
payload: { workspaceId: targetWorkspaceId },
|
||||
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
|
||||
});
|
||||
if (!sidebarInitialData) {
|
||||
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
||||
|
||||
+3
-6
@@ -1,6 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -9,11 +10,6 @@ export const runtime = "nodejs";
|
||||
// 当我们通过 `/onlyoffice-server/*` 反代文档服务器时,这些 `/cache/*` 请求会落到 Next 上,
|
||||
// 若未额外反代,会导致 404,进而触发 ONLYOFFICE “下载失败(-4)/无法打开文档”。
|
||||
// 因此这里把 `/cache/*` 同样反代到本机 ONLYOFFICE。
|
||||
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
|
||||
const stripHopByHopHeaders = (headers: Headers) => {
|
||||
// 说明:Hop-by-hop headers 不应被代理转发/透传
|
||||
const hopByHop = [
|
||||
@@ -71,9 +67,10 @@ const shouldGzip = (request: NextRequest, contentType: string) => {
|
||||
};
|
||||
|
||||
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
|
||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||
const incomingUrl = new URL(request.url);
|
||||
const target = new URL(
|
||||
`${ONLYOFFICE_INTERNAL_URL}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`,
|
||||
`${onlyofficeInternalUrl}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`,
|
||||
);
|
||||
target.search = incomingUrl.search;
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
|
||||
|
||||
const SERVICE_WORKER_SAFE_PATCH_SNIPPET = `
|
||||
<script>
|
||||
// 说明:
|
||||
@@ -62,9 +61,13 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
var internal = {
|
||||
'http://127.0.0.1:8081': true,
|
||||
'http://localhost:8081': true,
|
||||
'http://127.0.0.1:8082': true,
|
||||
'http://localhost:8082': true,
|
||||
// 说明:同上,兜底错误的 https://127.0.0.1:8081
|
||||
'https://127.0.0.1:8081': true,
|
||||
'https://localhost:8081': true
|
||||
'https://localhost:8081': true,
|
||||
'https://127.0.0.1:8082': true,
|
||||
'https://localhost:8082': true
|
||||
};
|
||||
function rewrite(u) {
|
||||
try {
|
||||
@@ -261,8 +264,9 @@ const shouldGzip = (request: NextRequest, contentType: string) => {
|
||||
};
|
||||
|
||||
const proxy = async (request: NextRequest, pathParts: string[]) => {
|
||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||
const incomingUrl = new URL(request.url);
|
||||
const target = new URL(`${ONLYOFFICE_INTERNAL_URL}/${pathParts.map(encodeURIComponent).join("/")}`);
|
||||
const target = new URL(`${onlyofficeInternalUrl}/${pathParts.map(encodeURIComponent).join("/")}`);
|
||||
target.search = incomingUrl.search;
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
@@ -102,19 +102,49 @@ 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 existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
if (existing.dataset.mnoteLoaded === "1") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (existing.dataset.mnoteFailed === "1") {
|
||||
reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
|
||||
return;
|
||||
}
|
||||
existing.addEventListener("load", () => resolve(), { once: true });
|
||||
existing.addEventListener("error", () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`)), {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
script.dataset.mnoteLoaded = "1";
|
||||
delete script.dataset.mnoteFailed;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
script.dataset.mnoteFailed = "1";
|
||||
reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
const loadScriptCandidates = async (candidates: string[]) => {
|
||||
let lastError: Error | null = null;
|
||||
for (const src of candidates) {
|
||||
try {
|
||||
await loadScript(src);
|
||||
return src;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("加载 ONLYOFFICE 脚本失败");
|
||||
};
|
||||
|
||||
const hashKey = (input: string) => {
|
||||
let hash = 0;
|
||||
@@ -156,10 +186,14 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
const internalOrigins = new Set<string>([
|
||||
"http://127.0.0.1:8081",
|
||||
"http://localhost:8081",
|
||||
"http://127.0.0.1:8082",
|
||||
"http://localhost:8082",
|
||||
// 说明:部分环境下 ONLYOFFICE 会错误拼出 https://127.0.0.1:8081 这类 URL,
|
||||
// 浏览器会报 ERR_SSL_PROTOCOL_ERROR(因为 8081 实际是 http)。这里也一起兜底重写。
|
||||
"https://127.0.0.1:8081",
|
||||
"https://localhost:8081",
|
||||
"https://127.0.0.1:8082",
|
||||
"https://localhost:8082",
|
||||
]);
|
||||
try {
|
||||
if (onlyofficeBaseUrlDesktop) {
|
||||
@@ -847,8 +881,12 @@ export default function OnlyOfficePage() {
|
||||
if (documentId && !permissionResolved) return;
|
||||
if (resolvedMode !== "view" && assetId && !authedUserId) return;
|
||||
|
||||
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
|
||||
loadScript(scriptUrl)
|
||||
const scriptUrls = [
|
||||
`${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`,
|
||||
`${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api-all.js`,
|
||||
`${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/editor.js`,
|
||||
];
|
||||
loadScriptCandidates(scriptUrls)
|
||||
.then(async () => {
|
||||
// 说明:api.js 的 onload 并不代表 DocsAPI/DocEditor 已完全就绪(在慢网/高负载时会出现空白页)。
|
||||
// 因此这里额外等待 DocEditor 挂载,避免偶发“白屏但无错误”的体验。
|
||||
|
||||
@@ -35,16 +35,23 @@ import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
initialContent: unknown;
|
||||
initialRevision?: number | null;
|
||||
initialConflictDetectionKey?: string | null;
|
||||
pageOptions: PageOptionsState;
|
||||
readOnly?: boolean;
|
||||
onStatsChange?: (stats: DocumentStats) => void;
|
||||
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
|
||||
onCloseToc?: () => void;
|
||||
onPersistedMetaChange?: (payload: {
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const extractInitialBlocks = (content: unknown): Json | undefined => {
|
||||
@@ -174,16 +181,22 @@ export function BlockNoteEditor({
|
||||
documentId,
|
||||
workspaceId,
|
||||
initialContent,
|
||||
initialRevision = null,
|
||||
initialConflictDetectionKey = null,
|
||||
pageOptions,
|
||||
readOnly = false,
|
||||
onStatsChange,
|
||||
onSnapshot,
|
||||
onCloseToc,
|
||||
onPersistedMetaChange,
|
||||
}: BlockNoteEditorProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
|
||||
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
|
||||
const isFullScreenTableOpen = fullScreenTableId !== null;
|
||||
const revisionRef = useRef<number | null>(initialRevision);
|
||||
const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey);
|
||||
|
||||
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
|
||||
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
|
||||
@@ -255,20 +268,76 @@ export function BlockNoteEditor({
|
||||
[collaboration],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
revisionRef.current = initialRevision;
|
||||
}, [initialRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
conflictDetectionKeyRef.current = initialConflictDetectionKey;
|
||||
}, [initialConflictDetectionKey]);
|
||||
|
||||
const saveContent = useCallback(
|
||||
async (content: Json) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await fetch("/api/documents/save", {
|
||||
setSaveError(null);
|
||||
const blockCount = Array.isArray(content) ? content.length : null;
|
||||
const response = await fetch("/api/documents/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, workspaceId, content }),
|
||||
body: JSON.stringify(
|
||||
buildDocumentSavePayload({
|
||||
documentId,
|
||||
workspaceId,
|
||||
revision: revisionRef.current,
|
||||
content,
|
||||
conflictDetectionKey: conflictDetectionKeyRef.current,
|
||||
snapshotCapturedAt: new Date().toISOString(),
|
||||
blockCount,
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = "保存失败";
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload === "object" && typeof payload.error === "string") {
|
||||
message = payload.error;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setSaveError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
const payload = await response.json() as {
|
||||
revision?: number | null;
|
||||
conflictDetectionKey?: string | null;
|
||||
};
|
||||
const nextRevision =
|
||||
typeof payload.revision === "number" && Number.isInteger(payload.revision)
|
||||
? payload.revision
|
||||
: revisionRef.current;
|
||||
const nextConflictDetectionKey =
|
||||
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
|
||||
? payload.conflictDetectionKey
|
||||
: conflictDetectionKeyRef.current;
|
||||
revisionRef.current = nextRevision ?? null;
|
||||
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
|
||||
onPersistedMetaChange?.({
|
||||
revision: revisionRef.current,
|
||||
conflictDetectionKey: conflictDetectionKeyRef.current,
|
||||
});
|
||||
setSaveError(null);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setSaveError(error.message);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[documentId, workspaceId],
|
||||
[documentId, onPersistedMetaChange, workspaceId],
|
||||
);
|
||||
|
||||
const debouncedSave = useDebouncedCallback(saveContent, 800);
|
||||
@@ -1262,7 +1331,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||
</BlockNoteView>
|
||||
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
|
||||
{isSaving ? "保存中..." : "已保存"}
|
||||
{isSaving ? "保存中..." : saveError ? saveError : "已保存"}
|
||||
</div>
|
||||
</div>
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
|
||||
@@ -1279,4 +1348,4 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +102,21 @@ type MindMapInstance = {
|
||||
setLayout: (layout: string) => void;
|
||||
};
|
||||
|
||||
type MindMapData = {
|
||||
data: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
};
|
||||
type MindMapData = {
|
||||
data: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
};
|
||||
|
||||
type MindmapRouteMeta = {
|
||||
requestId?: string;
|
||||
traceId?: string;
|
||||
workspaceId?: string | null;
|
||||
documentId?: string;
|
||||
pageId?: string;
|
||||
mindmapId?: string;
|
||||
attachmentId?: string;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -505,15 +516,35 @@ const MindmapBlockView = ({
|
||||
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
|
||||
}, [mindmap]);
|
||||
|
||||
const docId = useMemo(
|
||||
() =>
|
||||
block.props.docId ||
|
||||
(typeof window !== "undefined"
|
||||
? window.location.pathname.split("/").pop() ?? ""
|
||||
: ""),
|
||||
[block.props.docId],
|
||||
);
|
||||
const mindmapId = block.id;
|
||||
const docId = useMemo(
|
||||
() =>
|
||||
block.props.docId ||
|
||||
(typeof window !== "undefined"
|
||||
? window.location.pathname.split("/").pop() ?? ""
|
||||
: ""),
|
||||
[block.props.docId],
|
||||
);
|
||||
const mindmapId = block.id;
|
||||
const pageId = docId;
|
||||
const attachmentId = mindmapId;
|
||||
const [requestMeta, setRequestMeta] = useState<{ requestId: string; traceId: string } | null>(null);
|
||||
|
||||
const syncMindmapRouteMeta = useCallback((meta: unknown) => {
|
||||
if (!isRecord(meta)) return;
|
||||
const requestId = typeof meta.requestId === "string" ? meta.requestId.trim() : "";
|
||||
const traceId = typeof meta.traceId === "string" ? meta.traceId.trim() : "";
|
||||
if (requestId && traceId) {
|
||||
setRequestMeta((prev) =>
|
||||
prev?.requestId === requestId && prev?.traceId === traceId
|
||||
? prev
|
||||
: { requestId, traceId },
|
||||
);
|
||||
}
|
||||
const nextWorkspaceId = typeof meta.workspaceId === "string" ? meta.workspaceId.trim() : "";
|
||||
if (nextWorkspaceId) {
|
||||
setWorkspaceId((prev) => (prev === nextWorkspaceId ? prev : nextWorkspaceId));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取 workspaceId(用于上传图片)
|
||||
useEffect(() => {
|
||||
@@ -581,12 +612,13 @@ const MindmapBlockView = ({
|
||||
// 多导图:按 docId + mindmapId 拉取
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`);
|
||||
if (!resp.ok) return;
|
||||
const payload = await resp.json().catch(() => null);
|
||||
const data = payload?.data;
|
||||
if (!data || cancelled) return;
|
||||
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
|
||||
if (hasLocalEditsRef.current) return;
|
||||
initialDataRef.current = canonicalizeMindmapData(data);
|
||||
const payload = await resp.json().catch(() => null);
|
||||
const data = payload?.data;
|
||||
if (!data || cancelled) return;
|
||||
syncMindmapRouteMeta(payload?.meta);
|
||||
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
|
||||
if (hasLocalEditsRef.current) return;
|
||||
initialDataRef.current = canonicalizeMindmapData(data);
|
||||
if (mindmap) {
|
||||
applyingRemoteRef.current = true;
|
||||
try {
|
||||
@@ -1388,7 +1420,13 @@ const MindmapBlockView = ({
|
||||
if (resp.ok) {
|
||||
try {
|
||||
const payload = (await resp.json().catch(() => null)) as any;
|
||||
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null;
|
||||
syncMindmapRouteMeta(payload?.meta);
|
||||
const updatedAt =
|
||||
payload && typeof payload.updated_at === "string"
|
||||
? payload.updated_at
|
||||
: payload?.meta && typeof payload.meta.updatedAt === "string"
|
||||
? payload.meta.updatedAt
|
||||
: null;
|
||||
if (updatedAt) {
|
||||
lastLocalSavedAtRef.current = updatedAt;
|
||||
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
|
||||
@@ -1465,12 +1503,15 @@ const MindmapBlockView = ({
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data, createOnly: true }),
|
||||
});
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data, createOnly: true }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
} else {
|
||||
const payload = (await resp.json().catch(() => null)) as { meta?: MindmapRouteMeta } | null;
|
||||
syncMindmapRouteMeta(payload?.meta);
|
||||
}
|
||||
} catch {} finally {
|
||||
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
|
||||
@@ -3155,6 +3196,13 @@ const MindmapBlockView = ({
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
data-testid="mindmap-fullscreen"
|
||||
data-page-id={pageId || undefined}
|
||||
data-document-id={docId || undefined}
|
||||
data-attachment-id={attachmentId}
|
||||
data-mindmap-id={mindmapId}
|
||||
data-workspace-id={workspaceId || undefined}
|
||||
data-request-id={requestMeta?.requestId}
|
||||
data-trace-id={requestMeta?.traceId}
|
||||
tabIndex={0}
|
||||
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
|
||||
>
|
||||
@@ -3166,9 +3214,15 @@ const MindmapBlockView = ({
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
data-testid="mindmap-canvas"
|
||||
data-mindmap-id={mindmapId}
|
||||
contentEditable={false}
|
||||
/>
|
||||
data-page-id={pageId || undefined}
|
||||
data-document-id={docId || undefined}
|
||||
data-attachment-id={attachmentId}
|
||||
data-mindmap-id={mindmapId}
|
||||
data-workspace-id={workspaceId || undefined}
|
||||
data-request-id={requestMeta?.requestId}
|
||||
data-trace-id={requestMeta?.traceId}
|
||||
contentEditable={false}
|
||||
/>
|
||||
|
||||
<MindmapSidebarTrigger
|
||||
activeSidebar={activeSidebar}
|
||||
@@ -3212,12 +3266,19 @@ const MindmapBlockView = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
data-testid="mindmap-embed"
|
||||
tabIndex={0}
|
||||
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
|
||||
>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
data-testid="mindmap-embed"
|
||||
data-page-id={pageId || undefined}
|
||||
data-document-id={docId || undefined}
|
||||
data-attachment-id={attachmentId}
|
||||
data-mindmap-id={mindmapId}
|
||||
data-workspace-id={workspaceId || undefined}
|
||||
data-request-id={requestMeta?.requestId}
|
||||
data-trace-id={requestMeta?.traceId}
|
||||
tabIndex={0}
|
||||
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3">
|
||||
<div className="w-full">
|
||||
<MindmapToolbar {...toolbarProps} />
|
||||
@@ -3254,13 +3315,19 @@ const MindmapBlockView = ({
|
||||
enterLocalFullscreen();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
data-testid="mindmap-canvas"
|
||||
data-mindmap-id={mindmapId}
|
||||
contentEditable={false}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
data-testid="mindmap-canvas"
|
||||
data-page-id={pageId || undefined}
|
||||
data-document-id={docId || undefined}
|
||||
data-attachment-id={attachmentId}
|
||||
data-mindmap-id={mindmapId}
|
||||
data-workspace-id={workspaceId || undefined}
|
||||
data-request-id={requestMeta?.requestId}
|
||||
data-trace-id={requestMeta?.traceId}
|
||||
contentEditable={false}
|
||||
/>
|
||||
|
||||
<MindmapSidebarTrigger
|
||||
activeSidebar={activeSidebar}
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface DocumentContentProps {
|
||||
title: string | null;
|
||||
updatedAt: string | null;
|
||||
initialContent: unknown;
|
||||
initialContentRevision?: number | null;
|
||||
initialConflictDetectionKey?: string | null;
|
||||
initialOptions: PageOptionsState;
|
||||
initialStats: DocumentStats | null;
|
||||
openTableId?: string | null;
|
||||
@@ -69,6 +71,8 @@ export function DocumentContent({
|
||||
title,
|
||||
updatedAt,
|
||||
initialContent,
|
||||
initialContentRevision = null,
|
||||
initialConflictDetectionKey = null,
|
||||
initialOptions,
|
||||
initialStats,
|
||||
openTableId,
|
||||
@@ -90,6 +94,8 @@ export function DocumentContent({
|
||||
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||
const [content, setContent] = useState<unknown>(initialContent);
|
||||
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
|
||||
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
|
||||
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0);
|
||||
@@ -204,6 +210,14 @@ export function DocumentContent({
|
||||
useEffect(() => {
|
||||
setStats(initialStats ?? defaultStats);
|
||||
}, [initialStats]);
|
||||
|
||||
useEffect(() => {
|
||||
setContentRevision(initialContentRevision);
|
||||
}, [initialContentRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
setConflictDetectionKey(initialConflictDetectionKey);
|
||||
}, [initialConflictDetectionKey]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -244,9 +258,23 @@ export function DocumentContent({
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload?.error ?? "加载页面内容失败");
|
||||
}
|
||||
const payload = (await response.json()) as { content?: unknown };
|
||||
const payload = (await response.json()) as {
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflictDetectionKey?: string | null;
|
||||
};
|
||||
if (canceled) return;
|
||||
setContent(payload.content ?? null);
|
||||
setContentRevision(
|
||||
typeof payload.revision === "number" && Number.isInteger(payload.revision)
|
||||
? payload.revision
|
||||
: 0,
|
||||
);
|
||||
setConflictDetectionKey(
|
||||
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
|
||||
? payload.conflictDetectionKey
|
||||
: `${documentId}:0`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (canceled) return;
|
||||
if ((error as { name?: string })?.name === "AbortError") return;
|
||||
@@ -683,11 +711,17 @@ export function DocumentContent({
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
initialRevision={contentRevision}
|
||||
initialConflictDetectionKey={conflictDetectionKey}
|
||||
pageOptions={options}
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
|
||||
setContentRevision(revision);
|
||||
setConflictDetectionKey(nextConflictDetectionKey);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PageBacklinksPanel
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery, useConvexAuth } from "convex/react";
|
||||
import type { FunctionReference } from "convex/server";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildSidebarInitialData } from "@/lib/sidebar-data";
|
||||
import {
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
type SidebarDatasetListQueryResult,
|
||||
} from "@/lib/sidebar-data";
|
||||
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
|
||||
|
||||
type CurrentUserRecord = {
|
||||
_id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convex 模式下的侧边栏数据 hook
|
||||
* 使用 Convex 的 useQuery 实现实时订阅,无需手动 refetch
|
||||
const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sidebar as
|
||||
| Record<string, unknown>
|
||||
| undefined)?.datasetList as FunctionReference<
|
||||
"query",
|
||||
"public",
|
||||
{ workspaceId: string },
|
||||
SidebarDatasetListQueryResult
|
||||
>;
|
||||
|
||||
/**
|
||||
* Convex 模式下的侧边栏数据 hook
|
||||
* 使用 Convex 的 useQuery 实现实时订阅,无需手动 refetch
|
||||
*
|
||||
* 注意:此 hook 仅应在 Convex 模式下使用
|
||||
* 后端从 ctx.auth.getUserIdentity() 获取用户身份,前端无需传入 userId
|
||||
@@ -27,51 +36,9 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
|
||||
// 显式转为 boolean,确保类型正确
|
||||
const shouldFetch = Boolean(isAuthenticated && workspaceId);
|
||||
const currentUser = useQuery(api.users.currentUser, shouldFetch ? {} : "skip");
|
||||
const currentUserRecord =
|
||||
currentUser && typeof currentUser === "object" ? (currentUser as CurrentUserRecord) : null;
|
||||
const userId =
|
||||
currentUserRecord && typeof currentUserRecord._id === "string"
|
||||
? currentUserRecord._id
|
||||
: "";
|
||||
|
||||
const shouldFetchAuthed = Boolean(shouldFetch && userId);
|
||||
|
||||
// 使用 Convex 的 useQuery,自动订阅实时更新
|
||||
// 后端从 ctx.auth.getUserIdentity() 获取用户身份,无需前端传入 userId
|
||||
const documents = useQuery(
|
||||
api.documents.listByWorkspace,
|
||||
shouldFetch ? { workspaceId } : "skip"
|
||||
);
|
||||
|
||||
const trashedDocuments = useQuery(
|
||||
api.documents.listTrashedByWorkspace,
|
||||
shouldFetch ? { workspaceId } : "skip"
|
||||
);
|
||||
|
||||
const mindmaps = useQuery(
|
||||
api.mindmaps.listByWorkspace,
|
||||
shouldFetch ? { workspaceId, includeDeleted: true } : "skip"
|
||||
);
|
||||
|
||||
const mediaAssets = useQuery(
|
||||
api.mediaAssets.listByWorkspace,
|
||||
shouldFetchAuthed ? { userId, workspaceId, limit: 200 } : "skip",
|
||||
);
|
||||
|
||||
const trashedMediaAssets = useQuery(
|
||||
api.mediaAssets.listDeletedByWorkspace,
|
||||
shouldFetchAuthed ? { userId, workspaceId, limit: 2000 } : "skip",
|
||||
);
|
||||
|
||||
const tables = useQuery(
|
||||
api.tables.listByWorkspaceForSearch,
|
||||
shouldFetchAuthed ? { userId, workspaceId, includeArchived: true, limit: 3000 } : "skip",
|
||||
);
|
||||
|
||||
const workspacesResult = useQuery(
|
||||
api.workspaces.fetchWorkspaceSummaries,
|
||||
shouldFetch ? {} : "skip",
|
||||
const sidebarDataset = useQuery(
|
||||
sidebarDatasetListQuery,
|
||||
shouldFetch ? { workspaceId } : "skip",
|
||||
);
|
||||
|
||||
const normalizeAssetUrls = (asset: MediaAsset): MediaAsset => {
|
||||
@@ -80,54 +47,39 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
return { ...asset, file_url: fileUrl, thumbnail_url: thumbUrl };
|
||||
};
|
||||
|
||||
// 组合数据,格式与 SidebarInitialData 一致
|
||||
const data: SidebarInitialData | null = useMemo(() => {
|
||||
// 当 skip 时,返回值是 undefined
|
||||
if (currentUser === undefined ||
|
||||
documents === undefined ||
|
||||
trashedDocuments === undefined ||
|
||||
mindmaps === undefined ||
|
||||
mediaAssets === undefined ||
|
||||
trashedMediaAssets === undefined ||
|
||||
tables === undefined ||
|
||||
workspacesResult === undefined) {
|
||||
const normalizedSidebarDataset = useMemo<SidebarDatasetListQueryResult | null>(() => {
|
||||
if (sidebarDataset === undefined || !sidebarDataset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildSidebarInitialData({
|
||||
activeWorkspaceId: workspacesResult.activeWorkspaceId || workspaceId,
|
||||
workspaces: workspacesResult.workspaces,
|
||||
documents,
|
||||
trashedDocuments,
|
||||
mindmaps,
|
||||
mediaAssets: ((mediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
trashedMediaAssets: ((trashedMediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
tables,
|
||||
});
|
||||
}, [
|
||||
currentUser,
|
||||
documents,
|
||||
trashedDocuments,
|
||||
mindmaps,
|
||||
mediaAssets,
|
||||
trashedMediaAssets,
|
||||
tables,
|
||||
workspacesResult,
|
||||
workspaceId,
|
||||
]);
|
||||
return {
|
||||
...sidebarDataset,
|
||||
media_assets: ((sidebarDataset.media_assets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
trashed_media_assets: ((sidebarDataset.trashed_media_assets ?? []) as MediaAsset[]).map(
|
||||
normalizeAssetUrls,
|
||||
),
|
||||
mindmap_assets: ((sidebarDataset.mindmap_assets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
trashed_mindmap_assets: ((sidebarDataset.trashed_mindmap_assets ?? []) as MediaAsset[]).map(
|
||||
normalizeAssetUrls,
|
||||
),
|
||||
table_assets: ((sidebarDataset.table_assets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
trashed_table_assets: ((sidebarDataset.trashed_table_assets ?? []) as MediaAsset[]).map(
|
||||
normalizeAssetUrls,
|
||||
),
|
||||
};
|
||||
}, [sidebarDataset]);
|
||||
|
||||
// 组合数据,格式与 SidebarInitialData 一致
|
||||
const data: SidebarInitialData | null = useMemo(() => {
|
||||
if (!normalizedSidebarDataset) {
|
||||
return null;
|
||||
}
|
||||
return mapSidebarDatasetListQueryResultToInitialData(normalizedSidebarDataset);
|
||||
}, [normalizedSidebarDataset]);
|
||||
|
||||
// loading 状态:只有当 shouldFetch 为 true 且数据未加载时才算 loading
|
||||
// 使用 === undefined 判断,因为 skip 时返回 undefined
|
||||
const isLoading = shouldFetch && (
|
||||
currentUser === undefined ||
|
||||
documents === undefined ||
|
||||
trashedDocuments === undefined ||
|
||||
mindmaps === undefined ||
|
||||
mediaAssets === undefined ||
|
||||
trashedMediaAssets === undefined ||
|
||||
tables === undefined ||
|
||||
workspacesResult === undefined
|
||||
);
|
||||
const isLoading = shouldFetch && sidebarDataset === undefined;
|
||||
const error = null;
|
||||
|
||||
// Convex 模式下数据自动实时同步,refetch 是空操作
|
||||
|
||||
@@ -29,11 +29,14 @@ import {
|
||||
assertOptionsPatch,
|
||||
assertStats,
|
||||
assertTitle,
|
||||
buildDocumentBridgeMutationRequest,
|
||||
buildDocumentCommandEnvelope,
|
||||
buildDocumentQueryEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { executeMetadataBridgeCommand } from "@/lib/documents/metadata-command-adapter";
|
||||
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: vi.fn(),
|
||||
@@ -131,6 +134,100 @@ describe("documents bridge helpers", () => {
|
||||
expect(envelope.payload).toEqual({ documentId: "doc_1" });
|
||||
});
|
||||
|
||||
it("buildDocumentBridgeMutationRequest builds title update runtime request", () => {
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.title.update",
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
title: "新标题",
|
||||
},
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
});
|
||||
|
||||
const request = buildDocumentBridgeMutationRequest({
|
||||
context: mockContext,
|
||||
envelope,
|
||||
mapConvexArgs: (payload) => ({
|
||||
id: payload.documentId,
|
||||
title: payload.title,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(request.functionName).toBe("documents:updateTitle");
|
||||
expect(request.workspaceId).toBe("ws_1");
|
||||
expect(request.args).toEqual({
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
});
|
||||
expect(JSON.parse(request.payloadJson)).toEqual({
|
||||
kind: "command",
|
||||
name: "documents.title.update",
|
||||
request_id: "req_1",
|
||||
trace_id: "trace_1",
|
||||
deployment_id: null,
|
||||
project_id: null,
|
||||
workspace_id: "ws_1",
|
||||
tenant_id: null,
|
||||
idempotency_key: "idem_1",
|
||||
actor: {
|
||||
type: "user",
|
||||
id: "user_1",
|
||||
session_id: "sess_1",
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "vitest",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("buildDocumentBridgeMutationRequest builds documents.save runtime request", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 7,
|
||||
content: [{ id: "block_1" }],
|
||||
conflictDetectionKey: "conflict_1",
|
||||
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
|
||||
blockCount: 1,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.save",
|
||||
payload,
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
});
|
||||
|
||||
const request = buildDocumentBridgeMutationRequest({
|
||||
context: mockContext,
|
||||
envelope,
|
||||
mapConvexArgs: (nextPayload) => ({
|
||||
id: nextPayload.documentId,
|
||||
content: nextPayload.content,
|
||||
expectedRevision: nextPayload.revision,
|
||||
conflictDetectionKey: nextPayload.conflictDetectionKey,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(request.functionName).toBe("documents:updateContent");
|
||||
expect(request.workspaceId).toBe("ws_1");
|
||||
expect(request.args).toEqual({
|
||||
id: "doc_1",
|
||||
content: [{ id: "block_1" }],
|
||||
expectedRevision: 7,
|
||||
conflictDetectionKey: "conflict_1",
|
||||
});
|
||||
expect(JSON.parse(request.payloadJson)).toMatchObject({
|
||||
kind: "command",
|
||||
name: "documents.save",
|
||||
workspace_id: "ws_1",
|
||||
request_id: "req_1",
|
||||
trace_id: "trace_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("executeMetadataBridgeCommand routes title update through adapter", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
@@ -212,4 +309,95 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
expect(result.commandName).toBe("documents.options.update");
|
||||
});
|
||||
|
||||
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
mutation,
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
|
||||
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 7,
|
||||
content: [{ id: "block_1" }],
|
||||
conflictDetectionKey: "conflict_1",
|
||||
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
|
||||
blockCount: 1,
|
||||
});
|
||||
|
||||
const result = await executeSaveBridgeCommand({
|
||||
context: mockContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.save",
|
||||
payload,
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
||||
id: "doc_1",
|
||||
content: [{ id: "block_1" }],
|
||||
expectedRevision: 7,
|
||||
conflictDetectionKey: "conflict_1",
|
||||
});
|
||||
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
|
||||
previousBridgeArtifactCalls + 1,
|
||||
);
|
||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.save",
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
payload,
|
||||
}),
|
||||
});
|
||||
expect(result.requestId).toBe("req_1");
|
||||
expect(result.traceId).toBe("trace_1");
|
||||
expect(result.commandName).toBe("documents.save");
|
||||
});
|
||||
|
||||
it("executeSaveBridgeCommand 将冲突错误归一为 bridge rejected", async () => {
|
||||
const mutation = vi.fn().mockRejectedValue(new Error("正文内容已变更,请刷新后重试"));
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
mutation,
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 7,
|
||||
content: [{ id: "block_1" }],
|
||||
conflictDetectionKey: "conflict_1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeSaveBridgeCommand({
|
||||
context: mockContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.save",
|
||||
payload,
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "DocumentBridgeError",
|
||||
status: 409,
|
||||
code: "REJECTED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { apiErrorResponse } from "@/lib/api-utils";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
@@ -80,6 +81,28 @@ export type QueryEnvelope<T> = {
|
||||
payload: T;
|
||||
};
|
||||
|
||||
export type DocumentBridgeMutationRequest<
|
||||
TArgs extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = {
|
||||
functionName: string;
|
||||
deploymentId: string | null;
|
||||
projectId: string | null;
|
||||
workspaceId: string | null;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
idempotencyKey: string | null;
|
||||
actorId: string;
|
||||
payloadJson: string;
|
||||
args: TArgs;
|
||||
};
|
||||
|
||||
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
|
||||
"documents.title.update": "documents:updateTitle",
|
||||
"documents.stats.update": "documents:updateStats",
|
||||
"documents.options.update": "documents:updateOptions",
|
||||
"documents.save": "documents:updateContent",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
|
||||
for (const candidate of candidates) {
|
||||
const value = headerList.get(candidate);
|
||||
@@ -187,6 +210,94 @@ export function buildDocumentQueryEnvelope<T>(input: { name: string; payload: T
|
||||
};
|
||||
}
|
||||
|
||||
function getDocumentBridgeMutationFunctionName(commandName: string): string {
|
||||
const functionName =
|
||||
DOCUMENT_BRIDGE_MUTATION_FUNCTIONS[
|
||||
commandName as keyof typeof DOCUMENT_BRIDGE_MUTATION_FUNCTIONS
|
||||
];
|
||||
if (!functionName) {
|
||||
throw new DocumentBridgeError(
|
||||
`未注册文档 bridge mutation: ${commandName}`,
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
);
|
||||
}
|
||||
return functionName;
|
||||
}
|
||||
|
||||
function buildDocumentCommandPayloadJson(input: {
|
||||
context: BridgeContext;
|
||||
commandName: string;
|
||||
workspaceId: string | null;
|
||||
idempotencyKey: string | null;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
kind: "command",
|
||||
name: input.commandName,
|
||||
request_id: input.context.requestId,
|
||||
trace_id: input.context.traceId,
|
||||
deployment_id: input.context.deploymentId,
|
||||
project_id: input.context.projectId,
|
||||
workspace_id: input.workspaceId,
|
||||
tenant_id: input.context.tenantId,
|
||||
idempotency_key: input.idempotencyKey,
|
||||
actor: {
|
||||
type: input.context.actor.actorType,
|
||||
id: input.context.actor.actorId,
|
||||
session_id: input.context.actor.sessionId,
|
||||
},
|
||||
source: {
|
||||
channel: input.context.source.channel,
|
||||
client: input.context.source.client,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDocumentBridgeMutationRequest<
|
||||
TPayload,
|
||||
TArgs extends Record<string, unknown>,
|
||||
>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
mapConvexArgs: (payload: TPayload) => TArgs;
|
||||
}): DocumentBridgeMutationRequest<TArgs> {
|
||||
const workspaceId = input.envelope.target?.workspaceId ?? input.context.workspaceId ?? null;
|
||||
const idempotencyKey = input.envelope.idempotencyKey ?? input.context.idempotencyKey;
|
||||
|
||||
return {
|
||||
functionName: getDocumentBridgeMutationFunctionName(input.envelope.name),
|
||||
deploymentId: input.context.deploymentId,
|
||||
projectId: input.context.projectId,
|
||||
workspaceId,
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
idempotencyKey,
|
||||
actorId: input.context.actor.actorId,
|
||||
payloadJson: buildDocumentCommandPayloadJson({
|
||||
context: input.context,
|
||||
commandName: input.envelope.name,
|
||||
workspaceId,
|
||||
idempotencyKey,
|
||||
}),
|
||||
args: input.mapConvexArgs(input.envelope.payload),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDocumentBridgeMutationRequest<
|
||||
TArgs extends Record<string, unknown>,
|
||||
TResult,
|
||||
>(input: {
|
||||
client: ConvexHttpClient;
|
||||
mutation: unknown;
|
||||
request: DocumentBridgeMutationRequest<TArgs>;
|
||||
}): Promise<TResult> {
|
||||
const mutate = input.client.mutation.bind(input.client) as (
|
||||
mutation: unknown,
|
||||
args: TArgs,
|
||||
) => Promise<TResult>;
|
||||
return mutate(input.mutation, input.request.args);
|
||||
}
|
||||
|
||||
export function assertDocumentId(documentId: string | null | undefined): string {
|
||||
const normalized = typeof documentId === "string" ? documentId.trim() : "";
|
||||
if (!normalized) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import type { CommandEnvelope, BridgeContext } from "@/lib/documents/bridge";
|
||||
import {
|
||||
buildDocumentBridgeMutationRequest,
|
||||
executeDocumentBridgeMutationRequest,
|
||||
type CommandEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
|
||||
@@ -35,9 +40,11 @@ export type MetadataCommandExecutionResult = {
|
||||
commandName: string;
|
||||
};
|
||||
|
||||
type MetadataMutationArgs = Record<string, unknown>;
|
||||
|
||||
type MetadataWriteAdapter<TPayload> = {
|
||||
convexMutation: unknown;
|
||||
mapConvexArgs: (payload: TPayload) => Record<string, unknown>;
|
||||
mapConvexArgs: (payload: TPayload) => MetadataMutationArgs;
|
||||
};
|
||||
|
||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
||||
@@ -101,11 +108,17 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
|
||||
}): Promise<MetadataCommandExecutionResult> {
|
||||
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: adapter.mapConvexArgs,
|
||||
});
|
||||
|
||||
await client.mutation(
|
||||
adapter.convexMutation as Parameters<typeof client.mutation>[0],
|
||||
adapter.mapConvexArgs(input.envelope.payload) as Parameters<typeof client.mutation>[1],
|
||||
);
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
client,
|
||||
mutation: adapter.convexMutation,
|
||||
request: mutationRequest,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeMutationRequest,
|
||||
executeDocumentBridgeMutationRequest,
|
||||
type CommandEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
||||
|
||||
export type DocumentSaveExecutionResult = {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
commandId: string;
|
||||
commandName: string;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
};
|
||||
|
||||
export async function executeSaveBridgeCommand(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<DocumentSavePayload>;
|
||||
}): Promise<DocumentSaveExecutionResult> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: (payload) => ({
|
||||
id: payload.documentId,
|
||||
content: payload.content,
|
||||
expectedRevision: payload.revision,
|
||||
conflictDetectionKey: payload.conflictDetectionKey,
|
||||
}),
|
||||
});
|
||||
|
||||
let mutationResult;
|
||||
try {
|
||||
mutationResult = await executeDocumentBridgeMutationRequest({
|
||||
client,
|
||||
mutation: api.documents.updateContent,
|
||||
request: mutationRequest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /正文(内容已变更|冲突检测失败)/.test(error.message)) {
|
||||
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
|
||||
reason: "content_conflict",
|
||||
revision: input.envelope.payload.revision,
|
||||
conflictDetectionKey: input.envelope.payload.conflictDetectionKey,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
revision:
|
||||
typeof mutationResult?.revision === "number" && Number.isInteger(mutationResult.revision)
|
||||
? mutationResult.revision
|
||||
: null,
|
||||
conflictDetectionKey:
|
||||
typeof mutationResult?.conflict_detection_key === "string" &&
|
||||
mutationResult.conflict_detection_key.trim()
|
||||
? mutationResult.conflict_detection_key
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
|
||||
describe("buildDocumentSavePayload", () => {
|
||||
it("统一规范 documents.save 的共享 payload", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: " doc_1 ",
|
||||
workspaceId: " ws_1 ",
|
||||
revision: 3,
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
conflictDetectionKey: " conflict_1 ",
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 3,
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
conflictDetectionKey: "conflict_1",
|
||||
snapshotCapturedAt: null,
|
||||
blockCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("非法 revision/conflictDetectionKey 会回退到 null", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "",
|
||||
revision: -1,
|
||||
content: [],
|
||||
conflictDetectionKey: " ",
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
documentId: "doc_1",
|
||||
workspaceId: null,
|
||||
revision: null,
|
||||
content: [],
|
||||
conflictDetectionKey: null,
|
||||
snapshotCapturedAt: null,
|
||||
blockCount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("保留正文快照采集元数据", () => {
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 4,
|
||||
content: [{ id: "block_1" }],
|
||||
conflictDetectionKey: "doc_1:4",
|
||||
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
|
||||
blockCount: 1,
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
revision: 4,
|
||||
content: [{ id: "block_1" }],
|
||||
conflictDetectionKey: "doc_1:4",
|
||||
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
|
||||
blockCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
export type DocumentSavePayload = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
content: Json;
|
||||
conflictDetectionKey: string | null;
|
||||
snapshotCapturedAt: string | null;
|
||||
blockCount: number | null;
|
||||
};
|
||||
|
||||
export function buildDocumentSavePayload(input: {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
revision?: number | null;
|
||||
content: Json;
|
||||
conflictDetectionKey?: string | null;
|
||||
snapshotCapturedAt?: string | null;
|
||||
blockCount?: number | null;
|
||||
}): DocumentSavePayload {
|
||||
const revision =
|
||||
typeof input.revision === "number" && Number.isInteger(input.revision) && input.revision >= 0
|
||||
? input.revision
|
||||
: null;
|
||||
const conflictDetectionKey =
|
||||
typeof input.conflictDetectionKey === "string" && input.conflictDetectionKey.trim()
|
||||
? input.conflictDetectionKey.trim()
|
||||
: null;
|
||||
const snapshotCapturedAt =
|
||||
typeof input.snapshotCapturedAt === "string" && input.snapshotCapturedAt.trim()
|
||||
? input.snapshotCapturedAt.trim()
|
||||
: null;
|
||||
const blockCount =
|
||||
typeof input.blockCount === "number" && Number.isInteger(input.blockCount) && input.blockCount >= 0
|
||||
? input.blockCount
|
||||
: null;
|
||||
|
||||
return {
|
||||
documentId: input.documentId.trim(),
|
||||
workspaceId: input.workspaceId?.trim() || null,
|
||||
revision,
|
||||
content: input.content,
|
||||
conflictDetectionKey,
|
||||
snapshotCapturedAt,
|
||||
blockCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export type MindmapRouteMeta = {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
workspaceId: string | null;
|
||||
documentId: string;
|
||||
pageId: string;
|
||||
mindmapId: string;
|
||||
attachmentId: string;
|
||||
ownerUserId: string;
|
||||
source: "convex";
|
||||
};
|
||||
|
||||
function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
|
||||
for (const candidate of candidates) {
|
||||
const value = headerList.get(candidate);
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeFallbackId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function buildMindmapRouteMeta(
|
||||
request: Request,
|
||||
input: {
|
||||
workspaceId?: string | null;
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
ownerUserId: string;
|
||||
},
|
||||
): MindmapRouteMeta {
|
||||
const requestId =
|
||||
readHeaderValue(request.headers, "x-request-id", "x-mnote-request-id") ??
|
||||
makeFallbackId("req");
|
||||
const traceId =
|
||||
readHeaderValue(request.headers, "x-trace-id", "x-mnote-trace-id", "x-request-id") ??
|
||||
makeFallbackId("trace");
|
||||
|
||||
return {
|
||||
requestId,
|
||||
traceId,
|
||||
workspaceId: input.workspaceId?.trim() || null,
|
||||
documentId: input.documentId,
|
||||
pageId: input.documentId,
|
||||
mindmapId: input.mindmapId,
|
||||
attachmentId: input.mindmapId,
|
||||
ownerUserId: input.ownerUserId,
|
||||
source: "convex",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081";
|
||||
const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js";
|
||||
const RESOLVE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
const DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES = [
|
||||
DEFAULT_ONLYOFFICE_INTERNAL_URL,
|
||||
"http://127.0.0.1:8082",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
];
|
||||
|
||||
let cachedOnlyOfficeInternalUrl = "";
|
||||
let cachedOnlyOfficeInternalUrlAt = 0;
|
||||
let pendingOnlyOfficeInternalUrl: Promise<string> | null = null;
|
||||
|
||||
const normalizeOnlyOfficeInternalUrl = (raw?: string | null) => {
|
||||
const value = String(raw || "").trim().replace(/\/+$/, "");
|
||||
if (!value) return "";
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getOnlyOfficeInternalUrlCandidates = () => {
|
||||
const candidates: string[] = [];
|
||||
const push = (value?: string | null) => {
|
||||
const normalized = normalizeOnlyOfficeInternalUrl(value);
|
||||
if (!normalized) return;
|
||||
if (!candidates.includes(normalized)) candidates.push(normalized);
|
||||
};
|
||||
|
||||
push(process.env.ONLYOFFICE_INTERNAL_URL);
|
||||
|
||||
for (const raw of String(process.env.ONLYOFFICE_INTERNAL_URL_CANDIDATES || "").split(",")) {
|
||||
push(raw);
|
||||
}
|
||||
|
||||
for (const value of DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES) {
|
||||
push(value);
|
||||
}
|
||||
|
||||
return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
|
||||
};
|
||||
|
||||
const probeOnlyOfficeInternalUrl = async (candidate: string) => {
|
||||
try {
|
||||
const probeUrl = new URL(ONLYOFFICE_PROBE_PATH, `${candidate}/`);
|
||||
const response = await fetch(probeUrl, {
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(2_500),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveOnlyOfficeInternalUrl = async () => {
|
||||
const now = Date.now();
|
||||
if (cachedOnlyOfficeInternalUrl && now - cachedOnlyOfficeInternalUrlAt < RESOLVE_CACHE_TTL_MS) {
|
||||
return cachedOnlyOfficeInternalUrl;
|
||||
}
|
||||
|
||||
if (pendingOnlyOfficeInternalUrl) {
|
||||
return pendingOnlyOfficeInternalUrl;
|
||||
}
|
||||
|
||||
pendingOnlyOfficeInternalUrl = (async () => {
|
||||
const candidates = getOnlyOfficeInternalUrlCandidates();
|
||||
for (const candidate of candidates) {
|
||||
if (await probeOnlyOfficeInternalUrl(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return candidates[0] || DEFAULT_ONLYOFFICE_INTERNAL_URL;
|
||||
})();
|
||||
|
||||
try {
|
||||
const resolved = await pendingOnlyOfficeInternalUrl;
|
||||
cachedOnlyOfficeInternalUrl = resolved;
|
||||
cachedOnlyOfficeInternalUrlAt = Date.now();
|
||||
return resolved;
|
||||
} finally {
|
||||
pendingOnlyOfficeInternalUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import type { FunctionReference } from "convex/server";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildSidebarInitialData } from "@/lib/sidebar-data";
|
||||
import {
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
type SidebarDatasetListQueryResult,
|
||||
} from "@/lib/sidebar-data";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
|
||||
type LoadSidebarDataFromConvexInput = {
|
||||
client: ConvexHttpClient;
|
||||
userId: string;
|
||||
fallbackName: string;
|
||||
requestedWorkspaceId?: string | null;
|
||||
};
|
||||
@@ -17,10 +20,20 @@ type LoadSidebarDataFromConvexResult = {
|
||||
workspaces: WorkspaceSummary[];
|
||||
activeWorkspaceId: string;
|
||||
targetWorkspaceId: string | null;
|
||||
sidebarDataset: SidebarDatasetListQueryResult | null;
|
||||
sidebarInitialData: SidebarInitialData | null;
|
||||
documents: DocumentRecord[];
|
||||
};
|
||||
|
||||
const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sidebar as
|
||||
| Record<string, unknown>
|
||||
| undefined)?.datasetList as FunctionReference<
|
||||
"query",
|
||||
"public",
|
||||
{ workspaceId: string },
|
||||
SidebarDatasetListQueryResult
|
||||
>;
|
||||
|
||||
export async function loadSidebarDataFromConvex(
|
||||
input: LoadSidebarDataFromConvexInput,
|
||||
): Promise<LoadSidebarDataFromConvexResult> {
|
||||
@@ -29,9 +42,8 @@ export async function loadSidebarDataFromConvex(
|
||||
workspaceIdIfCreate: randomUUID(),
|
||||
});
|
||||
|
||||
const summaries = await input.client.query(api.workspaces.fetchWorkspaceSummaries, {});
|
||||
const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces;
|
||||
const activeWorkspaceId = summaries.activeWorkspaceId || bootstrap.activeWorkspaceId;
|
||||
const workspaces = bootstrap.workspaces;
|
||||
const activeWorkspaceId = bootstrap.activeWorkspaceId;
|
||||
const targetWorkspaceId = input.requestedWorkspaceId?.trim() || activeWorkspaceId || null;
|
||||
|
||||
if (!targetWorkspaceId) {
|
||||
@@ -39,56 +51,23 @@ export async function loadSidebarDataFromConvex(
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
targetWorkspaceId: null,
|
||||
sidebarDataset: null,
|
||||
sidebarInitialData: null,
|
||||
documents: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [documents, trashedDocuments, mindmaps, mediaAssets, trashedMediaAssets, tables] = await Promise.all([
|
||||
input.client.query(api.documents.listByWorkspace, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
}),
|
||||
input.client.query(api.documents.listTrashedByWorkspace, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
}),
|
||||
input.client.query(api.mindmaps.listByWorkspace, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
includeDeleted: true,
|
||||
}),
|
||||
input.client.query(api.mediaAssets.listByWorkspace, {
|
||||
userId: input.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
limit: 200,
|
||||
}),
|
||||
input.client.query(api.mediaAssets.listDeletedByWorkspace, {
|
||||
userId: input.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
limit: 2000,
|
||||
}),
|
||||
input.client.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: input.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
includeArchived: true,
|
||||
limit: 3000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const normalizedDocuments = documents as DocumentRecord[];
|
||||
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
})) as SidebarDatasetListQueryResult;
|
||||
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
workspaces: sidebarDataset.workspaces ?? workspaces,
|
||||
activeWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
sidebarInitialData: buildSidebarInitialData({
|
||||
activeWorkspaceId: targetWorkspaceId,
|
||||
workspaces,
|
||||
documents: normalizedDocuments,
|
||||
trashedDocuments,
|
||||
mindmaps: mindmaps ?? [],
|
||||
mediaAssets: mediaAssets ?? [],
|
||||
trashedMediaAssets: trashedMediaAssets ?? [],
|
||||
tables: tables ?? [],
|
||||
}),
|
||||
sidebarDataset,
|
||||
sidebarInitialData: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
|
||||
documents: normalizedDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildSidebarInitialData, extractMindmapImageAssetIdsFromData } from "@/lib/sidebar-data";
|
||||
import {
|
||||
buildSidebarDatasetListQueryPayload,
|
||||
buildSidebarDatasetListQueryResult,
|
||||
buildSidebarInitialData,
|
||||
extractMindmapImageAssetIdsFromData,
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
} from "@/lib/sidebar-data";
|
||||
|
||||
describe("extractMindmapImageAssetIdsFromData", () => {
|
||||
it("提取导图节点里的 asset 图片引用并去重", () => {
|
||||
@@ -140,4 +146,116 @@ describe("buildSidebarInitialData", () => {
|
||||
expect(payload.trashedTableAssets?.map((item) => item.id)).toEqual(["table_2"]);
|
||||
expect(payload.mediaAssets?.map((item) => item.id)).toEqual(["asset_file_1"]);
|
||||
});
|
||||
|
||||
it("冻结 sidebar.dataset.list 的 Rust query 契约字段", () => {
|
||||
const queryPayload = buildSidebarDatasetListQueryPayload(" ws_1 ");
|
||||
expect(queryPayload).toEqual({ workspace_id: "ws_1" });
|
||||
|
||||
const queryResult = buildSidebarDatasetListQueryResult({
|
||||
activeWorkspaceId: "ws_1",
|
||||
workspaces: [
|
||||
{
|
||||
id: "ws_1",
|
||||
name: "工作区",
|
||||
type: "personal",
|
||||
iconUrl: null,
|
||||
memberCount: 1,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 1",
|
||||
parent_id: null,
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-14T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
trashedDocuments: [],
|
||||
mindmaps: [],
|
||||
mediaAssets: [],
|
||||
trashedMediaAssets: [],
|
||||
tables: [],
|
||||
});
|
||||
|
||||
expect(queryResult).toEqual({
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [
|
||||
{
|
||||
id: "ws_1",
|
||||
name: "工作区",
|
||||
type: "personal",
|
||||
iconUrl: null,
|
||||
memberCount: 1,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 1",
|
||||
parent_id: null,
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-14T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
});
|
||||
|
||||
expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toEqual({
|
||||
activeWorkspaceId: "ws_1",
|
||||
workspaces: [
|
||||
{
|
||||
id: "ws_1",
|
||||
name: "工作区",
|
||||
type: "personal",
|
||||
iconUrl: null,
|
||||
memberCount: 1,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 1",
|
||||
parent_id: null,
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-14T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
trashedTableAssets: [],
|
||||
mindmapDocs: [],
|
||||
mindmapAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
tableAssets: [],
|
||||
mediaAssets: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ type TableRow = {
|
||||
is_archived?: boolean | null;
|
||||
};
|
||||
|
||||
type SidebarDatasetInput = {
|
||||
export type SidebarDatasetInput = {
|
||||
activeWorkspaceId: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
@@ -38,10 +38,37 @@ type SidebarDatasetInput = {
|
||||
tables?: TableRow[] | null;
|
||||
};
|
||||
|
||||
export type SidebarDatasetListQueryPayload = {
|
||||
workspace_id: string;
|
||||
};
|
||||
|
||||
export type SidebarDatasetListQueryResult = {
|
||||
active_workspace_id: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
trashed_documents: SidebarInitialData["trashedDocuments"];
|
||||
media_assets: MediaAsset[];
|
||||
trashed_media_assets: MediaAsset[];
|
||||
mindmap_assets: MediaAsset[];
|
||||
trashed_mindmap_assets: MediaAsset[];
|
||||
table_assets: MediaAsset[];
|
||||
trashed_table_assets: MediaAsset[];
|
||||
mindmap_docs: string[];
|
||||
mindmap_asset_children: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function normalizeStringArray(values: Iterable<string>): string[] {
|
||||
return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
|
||||
}
|
||||
|
||||
export function buildSidebarDatasetListQueryPayload(
|
||||
workspaceId: string,
|
||||
): SidebarDatasetListQueryPayload {
|
||||
return {
|
||||
workspace_id: workspaceId.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
|
||||
const root = (() => {
|
||||
if (!input || typeof input !== "object") return input;
|
||||
@@ -154,7 +181,7 @@ function toTrashedTableAsset(row: TableRow, workspaceId: string): MediaAsset {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInitialData {
|
||||
function deriveSidebarDataset(input: SidebarDatasetInput) {
|
||||
const activeMindmaps = input.mindmaps.filter((row) => !row.deleted_at);
|
||||
const trashedMindmaps = input.mindmaps.filter((row) => Boolean(row.deleted_at));
|
||||
const activeTables = (input.tables ?? []).filter((row) => !row.is_archived);
|
||||
@@ -169,21 +196,61 @@ export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInit
|
||||
});
|
||||
|
||||
return {
|
||||
activeWorkspaceId: input.activeWorkspaceId,
|
||||
workspaces: input.workspaces,
|
||||
documents: input.documents,
|
||||
trashedDocuments: input.trashedDocuments,
|
||||
trashedMediaAssets: [...(input.trashedMediaAssets ?? [])],
|
||||
mindmapAssetChildren,
|
||||
mindmapAssets: activeMindmaps.map((row) => toMindmapAsset(row, input.activeWorkspaceId)),
|
||||
mindmapDocs: normalizeStringArray(activeMindmaps.map((row) => row.document_id)),
|
||||
tableAssets: activeTables.map((row) => toTableAsset(row, input.activeWorkspaceId)),
|
||||
trashedMindmapAssets: trashedMindmaps.map((row) =>
|
||||
toTrashedMindmapAsset(row, input.activeWorkspaceId),
|
||||
),
|
||||
trashedTableAssets: trashedTables.map((row) =>
|
||||
toTrashedTableAsset(row, input.activeWorkspaceId),
|
||||
),
|
||||
mindmapDocs: normalizeStringArray(activeMindmaps.map((row) => row.document_id)),
|
||||
mindmapAssets: activeMindmaps.map((row) => toMindmapAsset(row, input.activeWorkspaceId)),
|
||||
mindmapAssetChildren,
|
||||
tableAssets: activeTables.map((row) => toTableAsset(row, input.activeWorkspaceId)),
|
||||
mediaAssets: [...(input.mediaAssets ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarDatasetListQueryResult(
|
||||
input: SidebarDatasetInput,
|
||||
): SidebarDatasetListQueryResult {
|
||||
const derived = deriveSidebarDataset(input);
|
||||
|
||||
return {
|
||||
active_workspace_id: input.activeWorkspaceId,
|
||||
workspaces: [...input.workspaces],
|
||||
documents: [...input.documents],
|
||||
trashed_documents: [...input.trashedDocuments],
|
||||
media_assets: [...(input.mediaAssets ?? [])],
|
||||
trashed_media_assets: [...(input.trashedMediaAssets ?? [])],
|
||||
mindmap_assets: derived.mindmapAssets,
|
||||
trashed_mindmap_assets: derived.trashedMindmapAssets,
|
||||
table_assets: derived.tableAssets,
|
||||
trashed_table_assets: derived.trashedTableAssets,
|
||||
mindmap_docs: derived.mindmapDocs,
|
||||
mindmap_asset_children: { ...derived.mindmapAssetChildren },
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSidebarDatasetListQueryResultToInitialData(
|
||||
result: SidebarDatasetListQueryResult,
|
||||
): SidebarInitialData {
|
||||
return {
|
||||
activeWorkspaceId: result.active_workspace_id,
|
||||
workspaces: [...result.workspaces],
|
||||
documents: [...result.documents],
|
||||
trashedDocuments: [...result.trashed_documents],
|
||||
trashedMediaAssets: [...result.trashed_media_assets],
|
||||
trashedMindmapAssets: [...result.trashed_mindmap_assets],
|
||||
trashedTableAssets: [...result.trashed_table_assets],
|
||||
mindmapDocs: [...result.mindmap_docs],
|
||||
mindmapAssets: [...result.mindmap_assets],
|
||||
mindmapAssetChildren: { ...result.mindmap_asset_children },
|
||||
tableAssets: [...result.table_assets],
|
||||
mediaAssets: [...result.media_assets],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInitialData {
|
||||
const queryResult = buildSidebarDatasetListQueryResult(input);
|
||||
|
||||
return mapSidebarDatasetListQueryResultToInitialData(queryResult);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ const isPublicRoute = createRouteMatcher([
|
||||
// 说明:/onlyoffice-server 与 /cache 主要承载 ONLYOFFICE 静态资源与二进制缓存。
|
||||
// 这些资源不依赖用户态,且需要浏览器强缓存;若经过 Auth middleware 可能被追加 no-store,导致每次都重下几十 MB。
|
||||
"/onlyoffice-server(.*)",
|
||||
// 说明:本地自定义 ONLYOFFICE 插件页面运行在同源 /onlyoffice/plugins/* 下,由文档编辑器 iframe 直接加载。
|
||||
// 若被鉴权重定向到 /auth,会导致插件桥永远收不到 ready。
|
||||
"/onlyoffice/plugins(.*)",
|
||||
"/cache(.*)",
|
||||
"/api/health(.*)",
|
||||
"/_next(.*)",
|
||||
|
||||
Reference in New Issue
Block a user