feat(editor): save leptos island and page aggregate alignment progress

- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
import type { PageOptionsState } from "@/types/page-options";
const pageOptions: PageOptionsState = {
wideLayout: true,
smallText: false,
showHeadingNumbers: true,
showToc: true,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
describe("page-aggregate", () => {
it("将页面 route 所需真相收口为统一聚合对象", () => {
const aggregate = buildPageAggregate({
documentId: "doc_1",
workspaceId: "ws_1",
title: " 页面标题 ",
updatedAt: "2026-04-21T12:00:00.000Z",
readOnly: true,
disableDownload: true,
disableCopy: false,
pageOptions,
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 7,
conflictDetectionKey: "doc_1:7",
pageSubtree: null,
stats: {
wordCount: 10,
characterCount: 20,
blockCount: 1,
todoTotal: 0,
todoDone: 0,
},
});
expect(aggregate).toEqual({
identity: {
documentId: "doc_1",
workspaceId: "ws_1",
},
head: {
title: "页面标题",
updatedAt: "2026-04-21T12:00:00.000Z",
permissions: {
readOnly: true,
disableDownload: true,
disableCopy: false,
},
},
layout: {
pageOptions,
},
body: {
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 7,
conflictDetectionKey: "doc_1:7",
},
tree: {
pageSubtree: null,
},
stats: {
wordCount: 10,
characterCount: 20,
blockCount: 1,
todoTotal: 0,
todoDone: 0,
},
});
});
it("缺省标题与可选字段时回退到稳定默认值", () => {
const aggregate = buildPageAggregate({
documentId: "doc_2",
workspaceId: "ws_2",
pageOptions,
content: null,
});
expect(aggregate.head.title).toBe("无标题");
expect(aggregate.head.permissions).toEqual({
readOnly: false,
disableDownload: false,
disableCopy: false,
});
expect(aggregate.body).toEqual({
content: null,
revision: null,
conflictDetectionKey: null,
});
expect(aggregate.tree.pageSubtree).toBeNull();
expect(aggregate.stats).toBeNull();
});
});
@@ -0,0 +1,86 @@
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
export interface PageAggregateIdentity {
documentId: string;
workspaceId: string;
}
export interface PageAggregatePermissions {
readOnly: boolean;
disableDownload: boolean;
disableCopy: boolean;
}
export interface PageAggregateHead {
title: string;
updatedAt: string | null;
permissions: PageAggregatePermissions;
}
export interface PageAggregateLayout {
pageOptions: PageOptionsState;
}
export interface PageAggregateBody {
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
}
export interface PageAggregateTree {
pageSubtree: PageSubtreeProjection | null;
}
export interface PageAggregateProjection {
identity: PageAggregateIdentity;
head: PageAggregateHead;
layout: PageAggregateLayout;
body: PageAggregateBody;
tree: PageAggregateTree;
stats: DocumentStats | null;
}
export function buildPageAggregate(input: {
documentId: string;
workspaceId: string;
title?: string | null;
updatedAt?: string | null;
readOnly?: boolean;
disableDownload?: boolean;
disableCopy?: boolean;
pageOptions: PageOptionsState;
content: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
stats?: DocumentStats | null;
}): PageAggregateProjection {
return {
identity: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
head: {
title: input.title?.trim() || "无标题",
updatedAt: input.updatedAt ?? null,
permissions: {
readOnly: Boolean(input.readOnly),
disableDownload: Boolean(input.disableDownload),
disableCopy: Boolean(input.disableCopy),
},
},
layout: {
pageOptions: input.pageOptions,
},
body: {
content: input.content ?? null,
revision: input.revision ?? null,
conflictDetectionKey: input.conflictDetectionKey ?? null,
},
tree: {
pageSubtree: input.pageSubtree ?? null,
},
stats: input.stats ?? null,
};
}
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import { applyPageBodyCommand } from "./page-body-command";
describe("applyPageBodyCommand", () => {
it("应先保存 page body,再回显编辑器快照并回传持久化元信息", async () => {
const blocks = [{ id: "block_1", type: "paragraph", content: "AI 生成正文" }] as Json;
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, revision: 5, conflictDetectionKey: "doc-1:5" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const result = await applyPageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe("/api/documents/save");
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
const body = JSON.parse(String(init?.body ?? "{}")) as {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
content: Json;
blockCount: number | null;
};
expect(body.documentId).toBe("doc-1");
expect(body.workspaceId).toBe("ws-1");
expect(body.revision).toBe(4);
expect(body.conflictDetectionKey).toBe("doc-1:4");
expect(body.content).toEqual(blocks);
expect(body.blockCount).toBe(1);
expect(result).toEqual({ revision: 5, conflictDetectionKey: "doc-1:5" });
expect(applyEditorSnapshot).toHaveBeenCalledWith(blocks);
expect(onPersistedMetaChange).toHaveBeenCalledWith({
revision: 5,
conflictDetectionKey: "doc-1:5",
});
});
it("保存失败时不应回显未持久化的正文", async () => {
const blocks = [{ id: "block_1", type: "paragraph", content: "失败正文" }] as Json;
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ error: "conflict" }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
applyPageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
}),
).rejects.toThrow("conflict");
expect(applyEditorSnapshot).not.toHaveBeenCalled();
expect(onPersistedMetaChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
export type PageBodyPersistedMeta = {
revision: number | null;
conflictDetectionKey: string | null;
};
export type PageBodyPersistedState = PageBodyPersistedMeta & {
workspaceId: string | null;
};
export type ApplyPageBodyCommandInput = {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
blocks: Json;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
fetchImpl?: typeof fetch;
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
};
async function persistPageBodyViaRoute(
payload: DocumentSavePayload,
fetchImpl: typeof fetch,
): Promise<PageBodyPersistedMeta> {
const response = await fetchImpl("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as
| {
ok?: boolean;
revision?: number | null;
conflictDetectionKey?: string | null;
error?: string;
}
| null;
if (!response.ok) {
const message = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : "页面正文保存失败";
throw new Error(message);
}
return {
revision:
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
conflictDetectionKey:
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
? body.conflictDetectionKey.trim()
: payload.conflictDetectionKey,
};
}
export async function applyPageBodyCommand(input: ApplyPageBodyCommandInput): Promise<PageBodyPersistedMeta> {
const payload = buildDocumentSavePayload({
documentId: input.documentId,
workspaceId: input.workspaceId,
revision: input.revision,
conflictDetectionKey: input.conflictDetectionKey,
content: input.blocks,
editorDocument: undefined,
tiptapDocument: undefined,
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
snapshotCapturedAt: new Date().toISOString(),
});
const persistedMeta = input.persistPageBody
? await input.persistPageBody(payload)
: await persistPageBodyViaRoute(payload, input.fetchImpl ?? fetch);
input.applyEditorSnapshot?.(input.blocks);
input.onPersistedMetaChange?.(persistedMeta);
return persistedMeta;
}
@@ -0,0 +1,19 @@
import type { PageOptionsState } from "@/types/page-options";
export type PageTitleCommandInput = {
documentId: string;
workspaceId?: string | null;
title: string;
};
export type PageLayoutCommandInput = {
documentId: string;
workspaceId?: string | null;
pageOptions: Partial<PageOptionsState>;
};
export const PAGE_COMMAND_NAMES = {
updateTitle: "page.head.updateTitle",
updateLayout: "page.layout.updateOptions",
saveBody: "page.body.save",
} as const;
@@ -3,7 +3,6 @@ import { useRouter, useSearchParams } from "next/navigation";
import { createDocumentCommand } from "@/lib/documents/tree-command-client";
export function SidebarCreateDocumentEntry() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
@@ -17,9 +16,8 @@ export function SidebarCreateDocumentEntry() {
export async function createDocumentAndOpenEdit(router: ReturnType<typeof useRouter>, parentId: string | null) {
const payload = await createDocumentCommand(parentId);
const query = new URLSearchParams();
query.set("edit", "1");
const workspaceId = payload.workspace_id ?? null;
if (workspaceId) query.set("workspaceId", workspaceId);
router.push(`/documents/${payload.id}?${query.toString()}`);
const nextQuery = query.toString();
router.push(nextQuery ? `/documents/${payload.id}?${nextQuery}` : `/documents/${payload.id}`);
}
@@ -8,6 +8,8 @@ import {
purgeDocumentCommand,
renameDocumentCommand,
restoreDocumentCommand,
updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client";
describe("tree-command-client", () => {
@@ -29,8 +31,15 @@ describe("tree-command-client", () => {
await purgeDocumentCommand({ documentId: "doc_1" });
await embedDocumentCommand({ sourceId: "doc_1", targetId: "doc_2" });
await copyTreeCommand({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] });
await updatePageTitleCommand({ documentId: "doc_1", title: "页面标题" });
await updatePageOptionsCommand({
documentId: "doc_1",
pageOptions: {
wideLayout: true,
},
});
expect(fetchMock).toHaveBeenCalledTimes(8);
expect(fetchMock).toHaveBeenCalledTimes(10);
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
"/api/documents/create",
"/api/documents/title",
@@ -40,6 +49,8 @@ describe("tree-command-client", () => {
"/api/documents/purge",
"/api/documents/embed",
"/api/documents/copy-tree",
"/api/documents/title",
"/api/documents/options",
]);
});
@@ -1,5 +1,11 @@
"use client";
import type {
PageLayoutCommandInput,
PageTitleCommandInput,
} from "@/lib/documents/page-command-contract";
import type { PageOptionsState } from "@/types/page-options";
type DocumentCommandMeta = {
requestId?: string;
traceId?: string;
@@ -71,6 +77,12 @@ type RenameDocumentInput = {
title: string;
};
type UpdatePageOptionsInput = {
documentId: string;
workspaceId?: string | null;
pageOptions: Partial<PageOptionsState>;
};
type MoveDocumentInput = {
documentId: string;
parentId?: string | null;
@@ -156,6 +168,27 @@ export async function renameDocumentCommand(input: RenameDocumentInput): Promise
);
}
export async function updatePageTitleCommand(
input: PageTitleCommandInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return renameDocumentCommand(input);
}
export async function updatePageOptionsCommand(
input: PageLayoutCommandInput | UpdatePageOptionsInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
const pageOptions = "pageOptions" in input ? input.pageOptions : {};
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/options",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
options: pageOptions,
},
"更新页面选项失败,请稍后再试",
);
}
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/move",