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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
"移动失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user