feat(kernel): finish phase3 bridge cutover and phase4 tree projection
- add generic mnote-web query transport and bridge routes for workspace/request/trace - route sidebar compat traffic through mnote-web and tighten fixture fallback to dev/test - unify sidebar/page tree/file tree/picker consumers on page_tree projection - sync phase3/phase4 checklist, breakdown docs, and harness progress state
This commit is contained in:
@@ -17,6 +17,10 @@ import {
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
type SidebarDatasetListQueryResult,
|
||||
} from "@/lib/sidebar-data";
|
||||
import {
|
||||
fetchSidebarDatasetFromMnoteWeb,
|
||||
getMnoteWebBaseUrl,
|
||||
} from "@/lib/server/mnote-web";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -37,6 +41,24 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
if (getMnoteWebBaseUrl()) {
|
||||
const proxied = await fetchSidebarDatasetFromMnoteWeb({
|
||||
workspaceId: targetWorkspaceId,
|
||||
request,
|
||||
});
|
||||
const result = mapSidebarDatasetListQueryResultToInitialData(proxied.dataset);
|
||||
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
meta: {
|
||||
requestId: proxied.meta.requestId,
|
||||
traceId: proxied.meta.traceId,
|
||||
queryName: "compat.next.sidebar",
|
||||
workspaceId: proxied.meta.workspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: targetWorkspaceId,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildDocumentTree } from "@/lib/documents";
|
||||
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||
@@ -155,19 +155,11 @@ function MoveEmbedPickerDialogBody({
|
||||
}
|
||||
|
||||
if (isEmptyQuery) {
|
||||
const docs = sidebarQuery.data?.documents ?? [];
|
||||
const tree = buildDocumentTree(docs);
|
||||
|
||||
const flattened: Array<{ id: string; title: string; depth: number }> = [];
|
||||
const walk = (nodes: ReturnType<typeof buildDocumentTree>, depth: number) => {
|
||||
for (const node of nodes) {
|
||||
flattened.push({ id: node.id, title: node.title ?? "无标题", depth });
|
||||
if (node.children?.length) {
|
||||
walk(node.children, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(tree, 0);
|
||||
const tree = sidebarQuery.data?.kernelSidebarTree ?? [];
|
||||
const flattened = buildPickerTreeItems(
|
||||
buildPageTreeProjectionItems(tree),
|
||||
excluded,
|
||||
);
|
||||
|
||||
for (const item of flattened) {
|
||||
if (excluded.has(item.id)) continue;
|
||||
@@ -195,7 +187,7 @@ function MoveEmbedPickerDialogBody({
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.documents]);
|
||||
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
|
||||
|
||||
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
@@ -17,11 +17,11 @@ import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
||||
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
|
||||
interface PrivateTreeProps {
|
||||
nodes: SidebarTreeNode[];
|
||||
rows: PageTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
@@ -33,7 +33,7 @@ interface PrivateTreeProps {
|
||||
const ROW_HEIGHT = 36;
|
||||
|
||||
export function PrivateTree({
|
||||
nodes,
|
||||
rows,
|
||||
expanded,
|
||||
activeId,
|
||||
onToggleExpand,
|
||||
@@ -44,18 +44,6 @@ export function PrivateTree({
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
|
||||
const flatNodes = useMemo(() => {
|
||||
const flattened = flattenDocumentTree(nodes, expanded);
|
||||
const seen = new Set<string>();
|
||||
const deduped: typeof flattened = [];
|
||||
for (const item of flattened) {
|
||||
if (seen.has(item.node.id)) continue;
|
||||
seen.add(item.node.id);
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}, [nodes, expanded]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
@@ -64,7 +52,7 @@ export function PrivateTree({
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatNodes.length,
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollAreaRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 10,
|
||||
@@ -82,25 +70,25 @@ export function PrivateTree({
|
||||
if (!overId || activeId === overId) {
|
||||
return;
|
||||
}
|
||||
const activeIndex = flatNodes.findIndex((item) => item.node.id === activeId);
|
||||
const overIndex = flatNodes.findIndex((item) => item.node.id === overId);
|
||||
const activeIndex = rows.findIndex((item) => item.nodeId === activeId);
|
||||
const overIndex = rows.findIndex((item) => item.nodeId === overId);
|
||||
if (activeIndex === -1 || overIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const targetParent = flatNodes[overIndex].parentId;
|
||||
const siblingList = flatNodes.filter((item) => item.parentId === targetParent);
|
||||
const siblingIndex = siblingList.findIndex((item) => item.node.id === overId);
|
||||
const targetParent = rows[overIndex].parentNodeId;
|
||||
const siblingList = rows.filter((item) => item.parentNodeId === targetParent);
|
||||
const siblingIndex = siblingList.findIndex((item) => item.nodeId === overId);
|
||||
const position = siblingIndex === -1 ? siblingList.length : siblingIndex;
|
||||
onMove(activeId, targetParent, position);
|
||||
},
|
||||
[flatNodes, onMove],
|
||||
[onMove, rows],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveDragId(null);
|
||||
}, []);
|
||||
|
||||
if (flatNodes.length === 0) {
|
||||
if (rows.length === 0) {
|
||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||
}
|
||||
|
||||
@@ -114,22 +102,22 @@ export function PrivateTree({
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={flatNodes.map((item) => item.node.id)} strategy={verticalListSortingStrategy}>
|
||||
<SortableContext items={rows.map((item) => item.nodeId)} strategy={verticalListSortingStrategy}>
|
||||
<div className="relative h-full">
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const item = flatNodes[virtualRow.index];
|
||||
const item = rows[virtualRow.index];
|
||||
return (
|
||||
<VirtualRow key={item.node.id} virtualRow={virtualRow}>
|
||||
<VirtualRow key={item.nodeId} virtualRow={virtualRow}>
|
||||
<SortableTreeRow
|
||||
node={item.node}
|
||||
depth={item.depth}
|
||||
expanded={expanded.has(item.node.id)}
|
||||
hasChildren={item.node.children.length > 0}
|
||||
expanded={expanded.has(item.nodeId)}
|
||||
hasChildren={item.childCount > 0}
|
||||
activeId={activeId}
|
||||
isDragging={activeDragId === item.node.id}
|
||||
onToggleExpand={() => onToggleExpand(item.node.id)}
|
||||
onCreateChild={() => onCreateChild(item.node.id)}
|
||||
isDragging={activeDragId === item.nodeId}
|
||||
onToggleExpand={() => onToggleExpand(item.nodeId)}
|
||||
onCreateChild={() => onCreateChild(item.nodeId)}
|
||||
onContextMenu={(event) => onContextMenu(event, item.node)}
|
||||
/>
|
||||
</VirtualRow>
|
||||
|
||||
@@ -38,7 +38,11 @@ import { useSidebarStore } from "@/store/sidebar";
|
||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
|
||||
import {
|
||||
buildPageTreeProjectionItems,
|
||||
filterVisiblePageTreeProjectionItems,
|
||||
} from "@/lib/tree-projection";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
@@ -172,7 +176,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const activeId = segments?.[1] ?? "";
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
|
||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree ?? []);
|
||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
@@ -243,7 +247,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
useEffect(() => {
|
||||
setTree(() => {
|
||||
const nextTree = sidebarData.kernelSidebarTree ?? [];
|
||||
const nextTree = sidebarData.kernelSidebarTree;
|
||||
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
|
||||
return nextTree;
|
||||
});
|
||||
@@ -468,9 +472,21 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
|
||||
[filter, privateTree],
|
||||
);
|
||||
const privatePageRows = useMemo(
|
||||
() => buildPageTreeProjectionItems(privateTree),
|
||||
[privateTree],
|
||||
);
|
||||
const filteredPrivatePageRows = useMemo(
|
||||
() => buildPageTreeProjectionItems(filteredPrivateTree),
|
||||
[filteredPrivateTree],
|
||||
);
|
||||
const flattenedPrivate = useMemo(
|
||||
() => flattenDocumentTree(privateTree, expanded),
|
||||
[privateTree, expanded],
|
||||
() => filterVisiblePageTreeProjectionItems(privatePageRows, expanded),
|
||||
[expanded, privatePageRows],
|
||||
);
|
||||
const visibleFilteredPrivatePageRows = useMemo(
|
||||
() => filterVisiblePageTreeProjectionItems(filteredPrivatePageRows, expanded),
|
||||
[expanded, filteredPrivatePageRows],
|
||||
);
|
||||
const filteredTrash = useMemo(() => {
|
||||
const keyword = trashSearch.trim().toLowerCase();
|
||||
@@ -574,13 +590,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const fileTreeRows = useMemo(
|
||||
() =>
|
||||
buildVisibleRows({
|
||||
nodes: filteredPrivateTree,
|
||||
pageRows: visibleFilteredPrivatePageRows,
|
||||
expanded,
|
||||
assetsByDoc,
|
||||
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||
expandedAssetFolderIds: expandedAssetFolders,
|
||||
}),
|
||||
[assetsByDoc, expanded, expandedAssetFolders, filteredPrivateTree, mindmapChildrenSnapshot.childAssetsByMindmapId],
|
||||
[
|
||||
assetsByDoc,
|
||||
expanded,
|
||||
expandedAssetFolders,
|
||||
mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||
visibleFilteredPrivatePageRows,
|
||||
],
|
||||
);
|
||||
|
||||
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
|
||||
@@ -1816,12 +1838,12 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const handleConvertToChild = useCallback(
|
||||
async (documentId: string) => {
|
||||
const flat = flattenedPrivate;
|
||||
const currentIndex = flat.findIndex((item) => item.node.id === documentId);
|
||||
const currentIndex = flat.findIndex((item) => item.nodeId === documentId);
|
||||
if (currentIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
const targetParentId = flat[currentIndex - 1].node.id;
|
||||
await handleMove(documentId, targetParentId, flat[currentIndex - 1].node.children.length);
|
||||
const targetParentId = flat[currentIndex - 1].nodeId;
|
||||
await handleMove(documentId, targetParentId, flat[currentIndex - 1].childCount);
|
||||
},
|
||||
[flattenedPrivate, handleMove],
|
||||
);
|
||||
@@ -2271,20 +2293,20 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
|
||||
{(() => {
|
||||
const renderList = (nodes: SidebarTreeNode[]) => {
|
||||
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes));
|
||||
const flat = buildPageTreeProjectionItems(nodes);
|
||||
if (flat.length === 0) {
|
||||
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{flat.map(({ node, depth }) => (
|
||||
{flat.map((item) => (
|
||||
<Link
|
||||
key={node.id}
|
||||
href={`/documents/${node.id}`}
|
||||
key={item.nodeId}
|
||||
href={`/documents/${item.nodeId}`}
|
||||
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
||||
style={{ paddingLeft: 8 + depth * 12 }}
|
||||
style={{ paddingLeft: 8 + item.depth * 12 }}
|
||||
>
|
||||
{node.title || "无标题"}
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -2499,7 +2521,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<PrivateTree
|
||||
nodes={filteredPrivateTree}
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={toggleExpand}
|
||||
|
||||
@@ -17,8 +17,8 @@ export interface SidebarInitialData {
|
||||
activeWorkspaceId: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
kernelSidebarProjection?: KernelSidebarProjection | null;
|
||||
kernelSidebarTree?: SidebarTreeNode[];
|
||||
kernelSidebarProjection: KernelSidebarProjection;
|
||||
kernelSidebarTree: SidebarTreeNode[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets?: MediaAsset[];
|
||||
trashedMindmapAssets?: MediaAsset[];
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface DocumentNode extends DocumentRecord {
|
||||
children: DocumentNode[];
|
||||
}
|
||||
|
||||
// 过渡兼容 helper:保留给旧单测和非树域场景使用。
|
||||
// Sidebar / 页面树 / 文件树 / move-embed picker 主路径已统一改读 projection family。
|
||||
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
|
||||
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
|
||||
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
|
||||
@@ -33,7 +35,6 @@ export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
|
||||
uniqueRecords.reverse();
|
||||
|
||||
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
||||
import { buildVisibleRows } from "./rows";
|
||||
import { parseFileTreeRowId } from "./types";
|
||||
|
||||
@@ -33,7 +34,7 @@ describe("buildVisibleRows", () => {
|
||||
};
|
||||
|
||||
const rows = buildVisibleRows({
|
||||
nodes: [a],
|
||||
pageRows: buildPageTreeProjectionItems([a]),
|
||||
expanded: new Set(["a"]),
|
||||
assetsByDoc: {
|
||||
a: [
|
||||
@@ -108,7 +109,7 @@ describe("buildVisibleRows", () => {
|
||||
};
|
||||
|
||||
const rows = buildVisibleRows({
|
||||
nodes: [a, a],
|
||||
pageRows: buildPageTreeProjectionItems([a, a]),
|
||||
expanded: new Set(["a"]),
|
||||
assetsByDoc: {},
|
||||
});
|
||||
|
||||
@@ -1,55 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type { FileTreeRow } from "./types";
|
||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||
|
||||
export function buildVisibleRows({
|
||||
nodes,
|
||||
pageRows,
|
||||
expanded,
|
||||
assetsByDoc,
|
||||
assetChildrenByAssetId,
|
||||
expandedAssetFolderIds,
|
||||
}: {
|
||||
nodes: SidebarTreeNode[];
|
||||
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
|
||||
pageRows: PageTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
assetsByDoc: Record<string, MediaAsset[]>;
|
||||
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
|
||||
expandedAssetFolderIds?: Set<string>;
|
||||
}): FileTreeRow[] {
|
||||
const rows: FileTreeRow[] = [];
|
||||
const visitedDocIds = new Set<string>();
|
||||
|
||||
const walk = (node: SidebarTreeNode, depth: number) => {
|
||||
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
|
||||
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
|
||||
if (visitedDocIds.has(node.id)) return;
|
||||
visitedDocIds.add(node.id);
|
||||
|
||||
const assets = assetsByDoc[node.id] ?? [];
|
||||
const hasChildren = node.children.length > 0 || assets.length > 0;
|
||||
const isExpanded = expanded.has(node.id);
|
||||
pageRows.forEach((item) => {
|
||||
const assets = assetsByDoc[item.nodeId] ?? [];
|
||||
const hasChildren = item.childCount > 0 || assets.length > 0;
|
||||
const isExpanded = expanded.has(item.nodeId);
|
||||
rows.push({
|
||||
kind: "doc",
|
||||
rowId: makeDocRowId(node.id),
|
||||
depth,
|
||||
docId: node.id,
|
||||
parentDocId: node.parent_id,
|
||||
node,
|
||||
rowId: makeDocRowId(item.nodeId),
|
||||
depth: item.depth,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.parentNodeId,
|
||||
node: item.node,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
if (!isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: "index",
|
||||
rowId: makeIndexRowId(node.id),
|
||||
depth: depth + 1,
|
||||
docId: node.id,
|
||||
parentDocId: node.id,
|
||||
node,
|
||||
rowId: makeIndexRowId(item.nodeId),
|
||||
depth: item.depth + 1,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.nodeId,
|
||||
node: item.node,
|
||||
});
|
||||
|
||||
assets.forEach((asset) => {
|
||||
@@ -60,9 +57,9 @@ export function buildVisibleRows({
|
||||
rows.push({
|
||||
kind: "asset-folder",
|
||||
rowId: makeAssetFolderRowId(asset.id),
|
||||
depth: depth + 1,
|
||||
docId: node.id,
|
||||
parentDocId: node.id,
|
||||
depth: item.depth + 1,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.nodeId,
|
||||
asset,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
@@ -72,9 +69,9 @@ export function buildVisibleRows({
|
||||
rows.push({
|
||||
kind: "asset",
|
||||
rowId: makeAssetRowId(child.id),
|
||||
depth: depth + 2,
|
||||
docId: node.id,
|
||||
parentDocId: node.id,
|
||||
depth: item.depth + 2,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.nodeId,
|
||||
asset: child,
|
||||
});
|
||||
});
|
||||
@@ -85,16 +82,12 @@ export function buildVisibleRows({
|
||||
rows.push({
|
||||
kind: "asset",
|
||||
rowId: makeAssetRowId(asset.id),
|
||||
depth: depth + 1,
|
||||
docId: node.id,
|
||||
parentDocId: node.id,
|
||||
depth: item.depth + 1,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.nodeId,
|
||||
asset,
|
||||
});
|
||||
});
|
||||
|
||||
node.children.forEach((child) => walk(child, depth + 1));
|
||||
};
|
||||
|
||||
nodes.forEach((node) => walk(node, 0));
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { headers } from "next/headers";
|
||||
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
|
||||
import { isDevAuthEnabled } from "@/lib/auth/devUser";
|
||||
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||
|
||||
type MnoteWebSidebarCompatResponse = {
|
||||
ok?: boolean;
|
||||
requestId?: string;
|
||||
traceId?: string;
|
||||
workspaceId?: string;
|
||||
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) {
|
||||
target.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildForwardHeaders(request?: Request): Promise<Headers> {
|
||||
const source = request?.headers ?? new Headers(await headers());
|
||||
const forwarded = new Headers();
|
||||
|
||||
copyHeaderIfPresent(forwarded, source, "cookie");
|
||||
copyHeaderIfPresent(forwarded, source, "authorization");
|
||||
copyHeaderIfPresent(forwarded, source, "x-request-id");
|
||||
copyHeaderIfPresent(forwarded, source, "x-trace-id");
|
||||
copyHeaderIfPresent(forwarded, source, "x-session-id");
|
||||
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
|
||||
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
|
||||
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
|
||||
copyHeaderIfPresent(forwarded, source, "user-agent");
|
||||
|
||||
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
|
||||
const token = await convexAuthNextjsToken();
|
||||
if (token?.trim()) {
|
||||
forwarded.set("authorization", `Bearer ${token.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!forwarded.has("x-mnote-source-channel")) {
|
||||
forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
|
||||
}
|
||||
if (!forwarded.has("x-mnote-source-client")) {
|
||||
forwarded.set("x-mnote-source-client", "wolai-frontend");
|
||||
}
|
||||
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
export function getMnoteWebBaseUrl(): string | null {
|
||||
return MNOTE_WEB_BASE_URL || null;
|
||||
}
|
||||
|
||||
export async function fetchSidebarDatasetFromMnoteWeb(input: {
|
||||
workspaceId: string;
|
||||
request?: Request;
|
||||
}): Promise<{
|
||||
dataset: SidebarDatasetListQueryResult;
|
||||
meta: {
|
||||
requestId: string | null;
|
||||
traceId: string | null;
|
||||
workspaceId: string;
|
||||
};
|
||||
}> {
|
||||
const baseUrl = getMnoteWebBaseUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error("未配置 MNOTE_WEB_BASE_URL");
|
||||
}
|
||||
|
||||
const url = new URL("/api/compat/next/sidebar", baseUrl);
|
||||
url.searchParams.set("workspaceId", input.workspaceId);
|
||||
|
||||
const forwardedHeaders = await buildForwardHeaders(input.request);
|
||||
forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: forwardedHeaders,
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload = (await response
|
||||
.json()
|
||||
.catch(() => null)) as MnoteWebSidebarCompatResponse | null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload && typeof (payload as Record<string, unknown>).message === "string"
|
||||
? String((payload as Record<string, unknown>).message)
|
||||
: "mnote-web 侧边栏兼容接口请求失败";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!payload?.result) {
|
||||
throw new Error("mnote-web /api/compat/next/sidebar 未返回 result");
|
||||
}
|
||||
|
||||
return {
|
||||
dataset: payload.result,
|
||||
meta: {
|
||||
requestId: payload.requestId ?? null,
|
||||
traceId: payload.traceId ?? null,
|
||||
workspaceId: payload.workspaceId?.trim() || input.workspaceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,10 @@ 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;
|
||||
@@ -57,9 +61,15 @@ export async function loadSidebarDataFromConvex(
|
||||
};
|
||||
}
|
||||
|
||||
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
})) as SidebarDatasetListQueryResult;
|
||||
const sidebarDataset = getMnoteWebBaseUrl()
|
||||
? (
|
||||
await fetchSidebarDatasetFromMnoteWeb({
|
||||
workspaceId: targetWorkspaceId,
|
||||
})
|
||||
).dataset
|
||||
: ((await input.client.query(sidebarDatasetListQuery, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
})) as SidebarDatasetListQueryResult);
|
||||
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
|
||||
|
||||
return {
|
||||
|
||||
@@ -51,7 +51,7 @@ export type SidebarDatasetListQueryResult = {
|
||||
active_workspace_id: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
kernel_sidebar_projection?: KernelSidebarProjection | null;
|
||||
kernel_sidebar_projection: KernelSidebarProjection;
|
||||
trashed_documents: SidebarInitialData["trashedDocuments"];
|
||||
media_assets: MediaAsset[];
|
||||
trashed_media_assets: MediaAsset[];
|
||||
@@ -241,16 +241,14 @@ export function buildSidebarDatasetListQueryResult(
|
||||
export function mapSidebarDatasetListQueryResultToInitialData(
|
||||
result: SidebarDatasetListQueryResult,
|
||||
): SidebarInitialData {
|
||||
const kernelSidebarProjection =
|
||||
result.kernel_sidebar_projection ?? buildKernelSidebarProjection(result.documents);
|
||||
return {
|
||||
activeWorkspaceId: result.active_workspace_id,
|
||||
workspaces: [...result.workspaces],
|
||||
documents: [...result.documents],
|
||||
kernelSidebarProjection,
|
||||
kernelSidebarProjection: result.kernel_sidebar_projection,
|
||||
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
|
||||
records: result.documents,
|
||||
projection: kernelSidebarProjection,
|
||||
projection: result.kernel_sidebar_projection,
|
||||
}),
|
||||
trashedDocuments: [...result.trashed_documents],
|
||||
trashedMediaAssets: [...result.trashed_media_assets],
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPageTreeProjectionItems,
|
||||
buildPickerTreeItems,
|
||||
filterVisiblePageTreeProjectionItems,
|
||||
} from "./tree-projection";
|
||||
|
||||
describe("buildPageTreeProjectionItems", () => {
|
||||
it("按当前树结构重建 parent/depth,而不是信任陈旧节点字段", () => {
|
||||
const rows = buildPageTreeProjectionItems([
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "root",
|
||||
workspace_id: "w",
|
||||
title: "Root",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 1,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "child",
|
||||
workspace_id: "w",
|
||||
title: "Child",
|
||||
parent_id: null,
|
||||
sort_order: 9,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: 9,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rows.map((item) => ({
|
||||
nodeId: item.nodeId,
|
||||
parentNodeId: item.parentNodeId,
|
||||
depth: item.depth,
|
||||
}))).toEqual([
|
||||
{ nodeId: "root", parentNodeId: null, depth: 0 },
|
||||
{ nodeId: "child", parentNodeId: "root", depth: 1 },
|
||||
]);
|
||||
expect(rows[1]?.node.parent_id).toBe("root");
|
||||
expect(rows[1]?.node.kernel?.depth).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterVisiblePageTreeProjectionItems", () => {
|
||||
it("只返回当前展开路径上的可见页面", () => {
|
||||
const items = buildPageTreeProjectionItems([
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "root",
|
||||
workspace_id: "w",
|
||||
title: "Root",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "child",
|
||||
workspace_id: "w",
|
||||
title: "Child",
|
||||
parent_id: "root",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "leaf",
|
||||
workspace_id: "w",
|
||||
title: "Leaf",
|
||||
parent_id: "child",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(filterVisiblePageTreeProjectionItems(items, new Set()).map((item) => item.nodeId)).toEqual(["root"]);
|
||||
expect(filterVisiblePageTreeProjectionItems(items, new Set(["root"])).map((item) => item.nodeId)).toEqual([
|
||||
"root",
|
||||
"child",
|
||||
]);
|
||||
expect(
|
||||
filterVisiblePageTreeProjectionItems(items, new Set(["root", "child"])).map(
|
||||
(item) => item.nodeId,
|
||||
),
|
||||
).toEqual(["root", "child", "leaf"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPickerTreeItems", () => {
|
||||
it("复用 page_tree projection 生成 picker 列表", () => {
|
||||
const items = buildPageTreeProjectionItems([
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "a",
|
||||
workspace_id: "w",
|
||||
title: "A",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "b",
|
||||
workspace_id: "w",
|
||||
title: null,
|
||||
parent_id: "a",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(buildPickerTreeItems(items, ["a"])).toEqual([
|
||||
{ id: "b", title: "无标题", depth: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
|
||||
export type TreeProjectionKind = "page_tree";
|
||||
|
||||
export type TreeProjectionNodeType = "page";
|
||||
|
||||
export type TreeProjectionCapability =
|
||||
| "expand"
|
||||
| "open"
|
||||
| "drag"
|
||||
| "drop"
|
||||
| "select"
|
||||
| "create-child";
|
||||
|
||||
export type TreeProjectionResourceMeta = {
|
||||
resourceKind: "document";
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
export type PageTreeProjectionItem = {
|
||||
rowId: `page:${string}`;
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: TreeProjectionNodeType;
|
||||
projectionKind: TreeProjectionKind;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
title: string;
|
||||
childCount: number;
|
||||
capabilities: TreeProjectionCapability[];
|
||||
resourceMeta: TreeProjectionResourceMeta;
|
||||
node: SidebarTreeNode;
|
||||
};
|
||||
|
||||
export type PickerTreeItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||
const capabilities: TreeProjectionCapability[] = [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
];
|
||||
if (childCount > 0) {
|
||||
capabilities.unshift("expand");
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
export function buildPageTreeProjectionItems(
|
||||
nodes: SidebarTreeNode[],
|
||||
): PageTreeProjectionItem[] {
|
||||
const items: PageTreeProjectionItem[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const walk = (
|
||||
entries: SidebarTreeNode[],
|
||||
depth: number,
|
||||
parentNodeId: string | null,
|
||||
) => {
|
||||
entries.forEach((node, index) => {
|
||||
if (visited.has(node.id)) {
|
||||
return;
|
||||
}
|
||||
visited.add(node.id);
|
||||
|
||||
const childCount = node.children.length;
|
||||
items.push({
|
||||
rowId: `page:${node.id}`,
|
||||
nodeId: node.id,
|
||||
parentNodeId,
|
||||
nodeType: "page",
|
||||
projectionKind: "page_tree",
|
||||
depth,
|
||||
position: node.kernel?.position ?? node.sort_order ?? index,
|
||||
title: node.title ?? "无标题",
|
||||
childCount,
|
||||
capabilities: buildPageCapabilities(childCount),
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: node.id,
|
||||
},
|
||||
node: {
|
||||
...node,
|
||||
parent_id: parentNodeId,
|
||||
kernel: node.kernel
|
||||
? {
|
||||
...node.kernel,
|
||||
depth,
|
||||
position: node.kernel.position ?? node.sort_order ?? index,
|
||||
childCount,
|
||||
}
|
||||
: {
|
||||
nodeType: "page",
|
||||
depth,
|
||||
position: node.sort_order ?? index,
|
||||
childCount,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (childCount > 0) {
|
||||
walk(node.children, depth + 1, node.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(nodes, 0, null);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function filterVisiblePageTreeProjectionItems(
|
||||
items: PageTreeProjectionItem[],
|
||||
expanded: Set<string>,
|
||||
): PageTreeProjectionItem[] {
|
||||
const visible: PageTreeProjectionItem[] = [];
|
||||
const visibleIds = new Set<string>();
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!item.parentNodeId) {
|
||||
visible.push(item);
|
||||
visibleIds.add(item.nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!visibleIds.has(item.parentNodeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expanded.has(item.parentNodeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
visible.push(item);
|
||||
visibleIds.add(item.nodeId);
|
||||
});
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
export function buildPickerTreeItems(
|
||||
items: PageTreeProjectionItem[],
|
||||
excludeIds: Iterable<string> = [],
|
||||
): PickerTreeItem[] {
|
||||
const excluded = new Set(excludeIds);
|
||||
return items
|
||||
.filter((item) => !excluded.has(item.nodeId))
|
||||
.map((item) => ({
|
||||
id: item.nodeId,
|
||||
title: item.title,
|
||||
depth: item.depth,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user