feat: 接入 mnote web tree shell 与主页链路整理

- 增加 mnote-web tree/command 支持与前端 MnoteWebTreeShell 集成
- 调整 sidebar、documents、runtime config 与 dev/prod server 配套逻辑
- 补充 homepage/tree shell smoke 脚本并更新 harness 进度文件
This commit is contained in:
lix-2026
2026-04-17 23:36:24 +08:00
parent cfc3af8984
commit d8de820d93
40 changed files with 4668 additions and 4143 deletions
-130
View File
@@ -1,130 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildDocumentTree, type DocumentRecord } from "./documents";
type TreeLike = { id: string; children: TreeLike[] };
function collectIds(nodes: TreeLike[]): string[] {
const ids: string[] = [];
const walk = (list: TreeLike[]) => {
list.forEach((node) => {
ids.push(node.id);
if (node.children.length > 0) {
walk(node.children);
}
});
};
walk(nodes);
return ids;
}
describe("buildDocumentTree", () => {
let warnSpy: ReturnType<typeof vi.spyOn> | null = null;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy?.mockRestore();
warnSpy = null;
});
it("应当去重重复的记录 id(根节点)", () => {
const base: DocumentRecord = {
access_scope: "private",
id: "doc-1",
workspace_id: "ws-1",
title: "A",
parent_id: null,
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:00.000Z",
updated_at: null,
};
const tree = buildDocumentTree([base, { ...base, title: "B" }]);
expect(tree).toHaveLength(1);
expect(tree[0].id).toBe("doc-1");
expect(tree[0].title).toBe("B");
});
it("应当去重重复的记录 id(子节点)", () => {
const parent: DocumentRecord = {
access_scope: "private",
id: "doc-p",
workspace_id: "ws-1",
title: "P",
parent_id: null,
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:00.000Z",
updated_at: null,
};
const child: DocumentRecord = {
access_scope: "private",
id: "doc-c",
workspace_id: "ws-1",
title: "C",
parent_id: "doc-p",
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:01.000Z",
updated_at: null,
};
const tree = buildDocumentTree([parent, child, { ...child, title: "C2" }]);
expect(tree).toHaveLength(1);
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].id).toBe("doc-c");
expect(tree[0].children[0].title).toBe("C2");
});
it("生成的树中不应出现重复 id", () => {
const records: DocumentRecord[] = [
{
access_scope: "private",
id: "a",
workspace_id: "ws-1",
title: "A",
parent_id: null,
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:00.000Z",
updated_at: null,
},
{
access_scope: "private",
id: "b",
workspace_id: "ws-1",
title: "B",
parent_id: "a",
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:01.000Z",
updated_at: null,
},
{
access_scope: "private",
id: "b",
workspace_id: "ws-1",
title: "B2",
parent_id: "a",
sort_order: null,
is_starred: null,
is_template: false,
created_at: "2025-01-01T00:00:01.000Z",
updated_at: "2025-01-01T00:00:02.000Z",
},
];
const tree = buildDocumentTree(records);
const ids = collectIds(tree);
const unique = new Set(ids);
expect(unique.size).toBe(ids.length);
});
});
-57
View File
@@ -15,63 +15,6 @@ export interface DocumentNode extends DocumentRecord {
children: DocumentNode[];
}
// 过渡兼容 helper:保留给旧单测和非树域场景使用。
// Sidebar / 页面树 / 文件树 / move-embed picker 主路径已统一改读 projection family。
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
const seen = new Set<string>();
const duplicatedIds = new Set<string>();
const uniqueRecords: DocumentRecord[] = [];
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i];
if (seen.has(record.id)) {
duplicatedIds.add(record.id);
continue;
}
seen.add(record.id);
uniqueRecords.push(record);
}
uniqueRecords.reverse();
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
console.warn(
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
);
}
const nodeMap = new Map<string, DocumentNode>();
uniqueRecords.forEach((record) => {
nodeMap.set(record.id, { ...record, children: [] });
});
const roots: DocumentNode[] = [];
uniqueRecords.forEach((record) => {
const node = nodeMap.get(record.id);
if (!node) return;
if (record.parent_id && nodeMap.has(record.parent_id)) {
nodeMap.get(record.parent_id)!.children.push(node);
} else {
roots.push(node);
}
});
const sortTree = (nodes: DocumentNode[]) => {
nodes.sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
nodes.forEach((child) => sortTree(child.children));
};
sortTree(roots);
return roots;
}
export function findBreadcrumb(records: DocumentRecord[], targetId: string): DocumentRecord[] {
const map = new Map<string, DocumentRecord>();
records.forEach((item) => map.set(item.id, item));
@@ -0,0 +1,108 @@
"use client";
type DocumentCommandMeta = {
requestId?: string;
traceId?: string;
commandId?: string;
commandName?: string;
};
type DocumentCommandErrorPayload = {
error?: string;
};
export type DocumentCreateCommandResult = {
id: string;
title?: string | null;
parent_id?: string | null;
sort_order?: number | null;
workspace_id?: string;
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
meta?: DocumentCommandMeta;
};
export type DocumentCreateChildCommandResult = {
pageId: string;
title?: string | null;
meta?: DocumentCommandMeta;
};
type RenameDocumentInput = {
documentId: string;
workspaceId?: string | null;
title: string;
};
type MoveDocumentInput = {
documentId: string;
parentId?: string | null;
position: number;
workspaceId?: string | null;
};
type CreateChildDocumentInput = {
parentId: string | null;
title: string;
blocks: unknown[];
};
async function postDocumentCommand<TResult>(path: string, payload: unknown, fallbackMessage: string): Promise<TResult> {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as TResult | DocumentCommandErrorPayload | null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
export async function createDocumentCommand(parentId: string | null): Promise<DocumentCreateCommandResult> {
return postDocumentCommand<DocumentCreateCommandResult>("/api/documents/create", { parentId }, "新建页面失败,请稍后再试");
}
export async function createChildDocumentCommand(
input: CreateChildDocumentInput,
): Promise<DocumentCreateChildCommandResult> {
return postDocumentCommand<DocumentCreateChildCommandResult>(
"/api/documents/create-child",
input,
"创建子页面失败,请稍后再试",
);
}
export async function renameDocumentCommand(input: RenameDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/title",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
title: input.title,
},
"重命名失败,请稍后再试",
);
}
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/move",
{
documentId: input.documentId,
parentId: input.parentId ?? null,
position: input.position,
workspaceId: input.workspaceId ?? null,
},
"移动失败,请稍后再试",
);
}
+6 -3
View File
@@ -136,11 +136,14 @@ export function buildSidebarTreeFromKernelProjection(input: {
records: DocumentRecord[];
projection: KernelSidebarProjection;
}): SidebarTreeNode[] {
const projectionItems = Array.isArray(input.projection?.items)
? input.projection.items
: [];
const recordById = new Map(input.records.map((record) => [record.id, record]));
const nodeMap = new Map<string, SidebarTreeNode>();
const itemById = new Map(input.projection.items.map((item) => [item.nodeId, item]));
const itemById = new Map(projectionItems.map((item) => [item.nodeId, item]));
for (const item of input.projection.items) {
for (const item of projectionItems) {
const record = recordById.get(item.nodeId);
nodeMap.set(item.nodeId, {
access_scope: record?.access_scope ?? "private",
@@ -165,7 +168,7 @@ export function buildSidebarTreeFromKernelProjection(input: {
}
const roots: SidebarTreeNode[] = [];
for (const item of input.projection.items) {
for (const item of projectionItems) {
const node = nodeMap.get(item.nodeId);
if (!node) continue;
const parentId = item.parentNodeId;
@@ -1,12 +1,12 @@
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081";
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8082";
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://127.0.0.1:8081",
"http://localhost:8082",
"http://localhost:8081",
];
let cachedOnlyOfficeInternalUrl = "";
@@ -90,4 +90,3 @@ export const resolveOnlyOfficeInternalUrl = async () => {
pendingOnlyOfficeInternalUrl = null;
}
};
+60 -3
View File
@@ -22,6 +22,16 @@ export type MnoteRuntimeConfig = {
onlyofficeProxyOriginWeb?: string;
onlyofficeCallbackOriginWeb?: string;
onlyofficeCallbackOriginDesktop?: string;
/**
* Rust Web 主入口,仅用于客户端渐进增强能力(例如独立 tree shell)。
* 说明:禁止把它作为主页面 SSR 首屏依赖。
*/
mnoteWebBaseUrl?: string;
/**
* 是否启用 Rust Web tree shell 客户端增强。
* 说明:实验壳必须显式开启,禁止仅因配置了 mnoteWebBaseUrl 就自动进入主界面链路。
*/
mnoteWebTreeShellEnabled?: boolean;
/**
* 是否为桌面端(Electron)运行。
*/
@@ -43,12 +53,50 @@ declare global {
}
}
const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
};
const readFromEnv = (): MnoteRuntimeConfig => ({
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
...((process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ?? process.env.MNOTE_WEB_BASE_URL) !== undefined
? {
mnoteWebBaseUrl:
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ??
process.env.MNOTE_WEB_BASE_URL,
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
) !== undefined
? {
mnoteWebTreeShellEnabled: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
),
}
: {}),
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
@@ -63,9 +111,12 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
const fs = require("fs") as typeof import("fs");
const path = require("path") as typeof import("path");
// 说明:这里不能直接写 `require("fs")` / `require("path")`
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖,触发持续重编译或空白页。
// 仅在服务端运行时通过惰性 require 读取本地 public/mnote-env.json。
const runtimeRequire = new Function("return require")() as NodeJS.Require;
const fs = runtimeRequire("node:fs") as typeof import("fs");
const path = runtimeRequire("node:path") as typeof import("path");
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
@@ -152,9 +203,15 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
cfg.onlyofficeCallbackOriginDesktop ||
"";
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
return {
...cfg,
isDesktop,
mnoteWebBaseUrl,
mnoteWebTreeShellEnabled,
onlyofficeBaseUrl,
onlyofficeStorageHostOverride,
onlyofficeProxyOrigin,
+18 -7
View File
@@ -1,6 +1,8 @@
import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
type MnoteWebSidebarCompatResponse = {
@@ -11,12 +13,6 @@ type MnoteWebSidebarCompatResponse = {
result?: SidebarDatasetListQueryResult;
};
const MNOTE_WEB_BASE_URL = (
process.env.MNOTE_WEB_BASE_URL ??
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ??
""
).trim();
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
@@ -36,6 +32,8 @@ async function buildForwardHeaders(request?: Request): Promise<Headers> {
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
@@ -51,12 +49,25 @@ async function buildForwardHeaders(request?: Request): Promise<Headers> {
if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend");
}
if (!forwarded.has("x-mnote-actor-id")) {
try {
const auth = await getAuthContext();
if (auth.userId?.trim()) {
forwarded.set("x-mnote-actor-id", auth.userId.trim());
forwarded.set("x-mnote-actor-type", "user");
}
} catch {
// 说明:未登录或当前上下文无法解析用户时,继续走已有 header / admin fallback。
}
}
return forwarded;
}
export function getMnoteWebBaseUrl(): string | null {
return MNOTE_WEB_BASE_URL || null;
const runtime = getMnoteRuntimeConfig();
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
return baseUrl || null;
}
export async function fetchSidebarDatasetFromMnoteWeb(input: {
+7 -13
View File
@@ -9,10 +9,6 @@ import {
type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
import type { WorkspaceSummary } from "@/lib/workspaces";
import {
fetchSidebarDatasetFromMnoteWeb,
getMnoteWebBaseUrl,
} from "@/lib/server/mnote-web";
type LoadSidebarDataFromConvexInput = {
client: ConvexHttpClient;
@@ -61,15 +57,13 @@ export async function loadSidebarDataFromConvex(
};
}
const sidebarDataset = getMnoteWebBaseUrl()
? (
await fetchSidebarDatasetFromMnoteWeb({
workspaceId: targetWorkspaceId,
})
).dataset
: ((await input.client.query(sidebarDatasetListQuery, {
workspaceId: targetWorkspaceId,
})) as SidebarDatasetListQueryResult);
// 说明:SSR 首屏阶段必须优先保证“可进入页面”。
// mnote-web compat sidebar 仍处于渐进接入期,若这里优先走远端/Rust 兼容接口,
// 一旦 actor/header 权限链不一致,就会把整个 (app) layout 阻塞住,表现为“登录后进不去页面”。
// 因此服务端首屏一律先走稳定的 Convex 直查;mnote-web 仅允许用于客户端树壳渐进增强。
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
workspaceId: targetWorkspaceId,
})) as SidebarDatasetListQueryResult;
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
return {
+19 -3
View File
@@ -51,7 +51,8 @@ export type SidebarDatasetListQueryResult = {
active_workspace_id: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
kernel_sidebar_projection: KernelSidebarProjection;
kernel_sidebar_projection?: KernelSidebarProjection;
kernelSidebarProjection?: KernelSidebarProjection;
trashed_documents: SidebarInitialData["trashedDocuments"];
media_assets: MediaAsset[];
trashed_media_assets: MediaAsset[];
@@ -241,14 +242,29 @@ export function buildSidebarDatasetListQueryResult(
export function mapSidebarDatasetListQueryResultToInitialData(
result: SidebarDatasetListQueryResult,
): SidebarInitialData {
// 说明:兼容旧的 Convex `sidebar.dataset.list` 返回体。
// 若上游暂未附带 `kernel_sidebar_projection`,这里按 documents 现算一份,
// 避免 SSR 因契约未完全切齐而直接崩掉。
const candidateProjection =
result.kernel_sidebar_projection ??
result.kernelSidebarProjection ??
null;
const kernelSidebarProjection =
candidateProjection &&
typeof candidateProjection === "object" &&
Array.isArray(candidateProjection.items) &&
Array.isArray(candidateProjection.edges)
? candidateProjection
: buildKernelSidebarProjection(result.documents ?? []);
return {
activeWorkspaceId: result.active_workspace_id,
workspaces: [...result.workspaces],
documents: [...result.documents],
kernelSidebarProjection: result.kernel_sidebar_projection,
kernelSidebarProjection,
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
records: result.documents,
projection: result.kernel_sidebar_projection,
projection: kernelSidebarProjection,
}),
trashedDocuments: [...result.trashed_documents],
trashedMediaAssets: [...result.trashed_media_assets],