"use client"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { useAuthActions } from "@convex-dev/auth/react"; import { useConvex } from "convex/react"; import { ArrowRightLeft, ArrowUpRight, ChevronRight, Copy, Edit3, GitMerge, Globe, Hash, LayoutGrid, Library, Link as LinkIcon, LogOut, MoreHorizontal, PanelRightOpen, Plus, Search as SearchIcon, Share2, Shield, Star, Trash2, Upload, Users, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; import type { DocumentNode } from "@/lib/documents"; import { buildDocumentTree } from "@/lib/documents"; import { useSidebarStore } from "@/store/sidebar"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data"; import { PrivateTree } from "@/components/sidebar/private-tree"; import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree"; import { useSearchPaletteStore } from "@/store/search-palette"; import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useCurrentDocumentStore } from "@/store/current-document"; import { FileTree } from "@/components/sidebar/file-tree"; import { buildVisibleRows } from "@/lib/file-tree/rows"; import type { FileTreeSelectionState } from "@/lib/file-tree/selection"; import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection"; import type { FileTreeRow } from "@/lib/file-tree/types"; import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd"; import { isRealFileAsset } from "@/lib/file-tree/asset"; import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete"; import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog"; import { inferPasteTargetDocId, isTextInputTarget, readFileTreeClipboardPayload, writeFileTreeClipboardPayload, } from "@/lib/file-tree/clipboard"; import type { MediaAsset } from "@/types/media"; import { AssetContextMenu } from "@/components/sidebar/asset-context-menu"; import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { DocumentShareDialog } from "@/components/sharing/document-share-dialog"; import { api } from "@/lib/convex/api"; import { GroupManagerDialog } from "@/components/groups/group-manager-dialog"; const TOP_BUTTONS = [ { id: "search", icon: SearchIcon, label: "搜索" }, { id: "graph", icon: Share2, label: "关系图" }, { id: "import", icon: Upload, label: "导入" }, { id: "members", icon: Users, label: "成员" }, { id: "starred", icon: Star, label: "星标置顶" }, { id: "public", icon: Globe, label: "公共页面" }, { id: "shared", icon: Shield, label: "共享页面" }, { id: "templates", icon: LayoutGrid, label: "模板中心" }, ] as const; const SECTION_ICONS: Record, React.ReactNode> = { starred: , public: , shared: , templates: , }; const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/"); const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => { const name = (fileName ?? "").trim().toLowerCase(); const mt = (mimeType ?? "").trim().toLowerCase(); const ext = name.includes(".") ? name.split(".").pop() : null; if (ext && ["doc", "docx", "odt", "rtf"].includes(ext)) return ext; if (ext && ["ppt", "pptx", "odp"].includes(ext)) return ext; if (ext && ["xls", "xlsx", "ods", "csv"].includes(ext)) return ext; if (ext && ["pdf"].includes(ext)) return ext; if (mt.includes("wordprocessingml")) return "docx"; if (mt.includes("presentationml")) return "pptx"; if (mt.includes("spreadsheetml")) return "xlsx"; if (mt.includes("pdf")) return "pdf"; return null; }; const extractMindmapIdFromStoragePath = ( storagePath: string | null | undefined, ): string | null => { if (!storagePath) return null; const normalized = normalizeStoragePath(storagePath); const prefix = "mindmaps/"; if (normalized.startsWith(prefix)) { const rest = normalized.slice(prefix.length); const id = rest.split("/")[0]; return id ? id : null; } const marker = "/mindmaps/"; const idx = normalized.indexOf(marker); if (idx === -1) return null; const rest = normalized.slice(idx + marker.length); const id = rest.split("/")[0]; return id ? id : null; }; interface SidebarProps { initialData: SidebarInitialData; } interface ContextMenuState { node: DocumentNode; x: number; y: number; } export function Sidebar({ initialData }: SidebarProps) { return ; } // Convex 模式专用组件 - 只调用 Convex hooks function SidebarConvex({ initialData }: SidebarProps) { const convexData = useConvexSidebarData(initialData.activeWorkspaceId); return ; } // 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑 interface SidebarContentProps { initialData: SidebarInitialData; sidebarQuery: { data: SidebarInitialData | null | undefined; isLoading: boolean; refetch: () => Promise; }; } function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { const convex = useConvex(); const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } = useSidebarStore(); const viewMode = useSidebarStore((state) => state.viewMode); const setViewMode = useSidebarStore((state) => state.setViewMode); const openSearchPalette = useSearchPaletteStore((state) => state.openSearch); // 处理数据 const sidebarData = useMemo(() => { return sidebarQuery.data ?? initialData; }, [sidebarQuery, initialData]); // isLoading 判断 const isLoading = sidebarQuery.isLoading; const segments = useSelectedLayoutSegments(); const router = useRouter(); const { signOut } = useAuthActions(); const activeId = segments?.[1] ?? ""; const editorBridge = useEditorBridgeStore((state) => state.bridge); const [tree, setTree] = useState(() => buildDocumentTree(sidebarData.documents)); const [filter, setFilter] = useState(""); const [expanded, setExpanded] = useState>(() => collectNodeIds(tree)); const [contextMenu, setContextMenu] = useState(null); const [topPanel, setTopPanel] = useState<"starred" | "public" | "shared" | "templates" | null>(null); const [shareSummary, setShareSummary] = useState<{ incoming: Array<{ workspaceId: string; workspaceName: string | null; documentId: string; documentTitle: string | null; permission: "read" | "edit"; includeDescendants: boolean; createdBy: string; updatedAt: string; }>; outgoing: Array<{ workspaceId: string; workspaceName: string | null; documentId: string; documentTitle: string | null; includeDescendants: boolean; sharedWithCount: number; updatedAt: string; }>; } | null>(null); const [shareSummaryError, setShareSummaryError] = useState(null); const [groupPublicSummary, setGroupPublicSummary] = useState< Array<{ groupId: string; groupName: string | null; documents: Array<{ documentId: string; includeDescendants: boolean }>; }> >([]); const [groupPublicError, setGroupPublicError] = useState(null); const [openPublicGroups, setOpenPublicGroups] = useState>(() => new Set()); const [shareDialogOpen, setShareDialogOpen] = useState(false); const [shareTarget, setShareTarget] = useState<{ id: string; title: string | null; workspaceId: string; allowIncludeDescendants: boolean; } | null>(null); const [groupManagerOpen, setGroupManagerOpen] = useState(false); const [signingOut, setSigningOut] = useState(false); const [trashOpen, setTrashOpen] = useState(false); const [trashSearch, setTrashSearch] = useState(""); const [trashTab, setTrashTab] = useState<"documents" | "assets">("documents"); const [emptyingTrash, setEmptyingTrash] = useState(false); const [mediaAssets, setMediaAssets] = useState(sidebarData.mediaAssets ?? []); const [mindmapAssets, setMindmapAssets] = useState(sidebarData.mindmapAssets ?? []); const [tableAssets, setTableAssets] = useState(sidebarData.tableAssets ?? []); const [moveEmbedOpen, setMoveEmbedOpen] = useState(false); const [moveEmbedMode, setMoveEmbedMode] = useState("move"); const [moveEmbedSource, setMoveEmbedSource] = useState(null); const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>( null, ); const [fileTreeSelection, setFileTreeSelection] = useState(() => ({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null, })); const fileTreeContainerRef = useRef(null); const creatingDocumentUnderParentRef = useRef>(new Set()); // 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。 const hiddenTableIdsRef = useRef>(new Map()); useEffect(() => { setTree(() => { const nextTree = buildDocumentTree(sidebarData.documents); setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev))); return nextTree; }); }, [sidebarData.documents]); useEffect(() => { setMediaAssets(sidebarData.mediaAssets ?? []); }, [sidebarData.mediaAssets]); useEffect(() => { setMindmapAssets(sidebarData.mindmapAssets ?? []); }, [sidebarData.mindmapAssets]); useEffect(() => { const hidden = hiddenTableIdsRef.current; const now = Date.now(); hidden.forEach((ts, id) => { if (now - ts > 15000) { hidden.delete(id); } }); setTableAssets((sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id))); }, [sidebarData.tableAssets]); useEffect(() => { setOpen(false); }, [activeId, setOpen]); useEffect(() => { const onSaved = (event: Event) => { const detail = (event as CustomEvent<{ tableId?: string }>).detail; const tableId = typeof detail?.tableId === "string" ? detail.tableId : ""; if (tableId) { hiddenTableIdsRef.current.delete(tableId); } void sidebarQuery.refetch(); }; const onDeleted = (event: Event) => { const detail = (event as CustomEvent<{ tableId?: string }>).detail; const tableId = typeof detail?.tableId === "string" ? detail.tableId : ""; if (tableId) { hiddenTableIdsRef.current.set(tableId, Date.now()); setTableAssets((prev) => prev.filter((item) => item.id !== tableId)); } void sidebarQuery.refetch(); }; window.addEventListener("online-table-saved", onSaved as EventListener); window.addEventListener("online-table-deleted", onDeleted as EventListener); return () => { window.removeEventListener("online-table-saved", onSaved as EventListener); window.removeEventListener("online-table-deleted", onDeleted as EventListener); }; }, [sidebarQuery]); const refreshTree = useCallback(async () => { await sidebarQuery.refetch(); }, [sidebarQuery]); const refreshShareSummary = useCallback(async () => { try { const resp = await convex.query(api.documentShares.listMyShareRoots, {}); setShareSummary(resp as any); setShareSummaryError(null); } catch (e: any) { const msg = e?.message ?? "加载共享摘要失败"; if (String(msg).includes("Could not find public function for 'documentShares:listMyShareRoots'")) { setShareSummaryError( "共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。", ); } else { setShareSummaryError(msg); } setShareSummary(null); } }, [convex]); useEffect(() => { void refreshShareSummary(); }, [refreshShareSummary]); const refreshGroupPublicSummary = useCallback(async () => { const workspaceId = sidebarData.activeWorkspaceId; if (!workspaceId) { setGroupPublicSummary([]); setGroupPublicError(null); return; } try { const resp = await convex.query(api.documentGroupShares.listPublicByWorkspace, { workspaceId }); const rows = Array.isArray(resp) ? (resp as any[]) : []; setGroupPublicSummary( rows.map((r) => ({ groupId: String(r.groupId), groupName: r.groupName ? String(r.groupName) : null, documents: Array.isArray(r.documents) ? r.documents.map((d: any) => ({ documentId: String(d.documentId), includeDescendants: Boolean(d.includeDescendants), })) : [], })), ); setGroupPublicError(null); } catch (e: any) { const msg = e?.message ?? "加载群组公开摘要失败"; if (String(msg).includes("Could not find public function for 'documentGroupShares:listPublicByWorkspace'")) { setGroupPublicError("群组公开功能后端未部署/未更新:请执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。"); } else { setGroupPublicError(msg); } setGroupPublicSummary([]); } }, [convex, sidebarData.activeWorkspaceId]); useEffect(() => { void refreshGroupPublicSummary(); }, [refreshGroupPublicSummary]); useEffect(() => { const handler = (event: Event) => { const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>; const asset = custom.detail?.asset as MediaAsset | undefined; if (asset?.id) { if (asset.asset_type === "mindmap") { setMindmapAssets((prev) => { if (prev.find((item) => item.id === asset.id)) return prev; return [asset, ...prev]; }); } else if (asset.asset_type === "luckysheet") { setTableAssets((prev) => { if (prev.find((item) => item.id === asset.id)) return prev; return [asset, ...prev]; }); } else { setMediaAssets((prev) => { if (prev.find((item) => item.id === asset.id)) return prev; return [asset, ...prev]; }); } } void sidebarQuery.refetch(); // 同步刷新共享/公共摘要,避免跨页面操作后出现“幽灵共享条目”(点开 404 / 无标题)。 void refreshShareSummary(); void refreshGroupPublicSummary(); }; window.addEventListener(ASSETS_CHANGED_EVENT, handler); window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler); return () => { window.removeEventListener(ASSETS_CHANGED_EVENT, handler); window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler); }; }, [sidebarQuery, refreshShareSummary, refreshGroupPublicSummary]); useEffect(() => { // 说明:shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state, // 为了让 A 侧删除/清空回收站后,B 侧能自动消失(而不是保留 404 幽灵项),这里做轻量轮询刷新。 if (topPanel !== "shared" && topPanel !== "public") { return; } const refresh = () => { if (topPanel === "shared") void refreshShareSummary(); if (topPanel === "public") void refreshGroupPublicSummary(); }; refresh(); const intervalId = window.setInterval(refresh, 2500); const onFocus = () => { if (document.visibilityState && document.visibilityState !== "visible") return; refresh(); }; window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onFocus); return () => { window.clearInterval(intervalId); window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onFocus); }; }, [topPanel, refreshShareSummary, refreshGroupPublicSummary]); const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]); const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]); const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]); const sharedNodes = useMemo(() => sections.find((section) => section.id === "shared")?.nodes ?? [], [sections]); const templateNodes = useMemo(() => sections.find((section) => section.id === "templates")?.nodes ?? [], [sections]); const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]); const nodeById = useMemo(() => { const map = new Map(); const walk = (nodes: DocumentNode[]) => { nodes.forEach((node) => { map.set(node.id, node); if (node.children.length > 0) { walk(node.children); } }); }; walk(tree); return map; }, [tree]); const publicGroupNodesByGroupId = useMemo(() => { const map = new Map(); for (const g of groupPublicSummary) { const nodes: DocumentNode[] = []; const uniq = new Map(); for (const d of g.documents ?? []) { const node = nodeById.get(d.documentId); if (node) { uniq.set(node.id, node); } } uniq.forEach((v) => nodes.push(v)); map.set(g.groupId, nodes); } return map; }, [groupPublicSummary, nodeById]); const filteredPrivateTree = useMemo( () => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree), [filter, privateTree], ); const flattenedPrivate = useMemo( () => flattenDocumentTree(privateTree, expanded), [privateTree, expanded], ); const filteredTrash = useMemo(() => { const keyword = trashSearch.trim().toLowerCase(); if (!keyword) { return sidebarData.trashedDocuments; } return sidebarData.trashedDocuments.filter((item) => (item.title ?? "无标题").toLowerCase().includes(keyword), ); }, [sidebarData.trashedDocuments, trashSearch]); const filteredTrashedMediaAssets = useMemo(() => { const assets = [ ...(sidebarData.trashedMediaAssets ?? []), ...(sidebarData.trashedMindmapAssets ?? []), ...(sidebarData.trashedTableAssets ?? []), ]; const keyword = trashSearch.trim().toLowerCase(); if (!keyword) { return assets; } return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword)); }, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]); const mindmapChildrenSnapshot = useMemo(() => { const mapping = sidebarData.mindmapAssetChildren ?? {}; const mindmapDocById = new Map( (mindmapAssets ?? []) .filter((asset) => asset.asset_type === "mindmap") .map((asset) => [asset.id, asset.document_id]), ); const mindmapIds = new Set(mindmapDocById.keys()); const mediaById = new Map( (mediaAssets ?? []).map((asset) => [asset.id, asset]), ); const childAssetsByMindmapId: Record = {}; const childIds = new Set(); const assigned = new Set(); // 物理目录:storage_path 归属到 mindmaps// 的附件,作为导图文件夹内容 (mediaAssets ?? []).forEach((asset) => { const sp = asset.storage_path; if (!sp || typeof sp !== "string") return; const mindmapId = extractMindmapIdFromStoragePath(sp); if (!mindmapId) return; if (!mindmapIds.has(mindmapId)) return; const docId = mindmapDocById.get(mindmapId); if (docId && asset.document_id !== docId) return; if (assigned.has(asset.id)) return; assigned.add(asset.id); childIds.add(asset.id); if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = []; childAssetsByMindmapId[mindmapId].push(asset); }); // 引用图片:从 mindmap JSON 解析出的 assetIds,也放到导图文件夹下(去重) (mindmapAssets ?? []).forEach((mindmapAsset) => { const ids = mapping[mindmapAsset.id] ?? []; if (!Array.isArray(ids) || ids.length === 0) return; ids.forEach((id) => { const asset = mediaById.get(id); if (!asset) return; if (asset.document_id !== mindmapAsset.document_id) return; if (assigned.has(asset.id)) return; assigned.add(asset.id); childIds.add(asset.id); if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = []; childAssetsByMindmapId[mindmapAsset.id].push(asset); }); }); return { childAssetsByMindmapId, childIds }; }, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]); const assetsByDoc = useMemo(() => { const map: Record = {}; const assets = [ ...((mediaAssets ?? []).filter( (asset) => !mindmapChildrenSnapshot.childIds.has(asset.id), )), ...(mindmapAssets ?? []), ...(tableAssets ?? []), ]; assets.forEach((asset) => { if (!map[asset.document_id]) { map[asset.document_id] = []; } const exists = map[asset.document_id].some((a) => a.id === asset.id && a.asset_type === asset.asset_type); if (!exists) { map[asset.document_id].push(asset); } }); return map; }, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]); const [expandedAssetFolders, setExpandedAssetFolders] = useState>(() => new Set()); const fileTreeRows = useMemo( () => buildVisibleRows({ nodes: filteredPrivateTree, expanded, assetsByDoc, assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId, expandedAssetFolderIds: expandedAssetFolders, }), [assetsByDoc, expanded, expandedAssetFolders, filteredPrivateTree, mindmapChildrenSnapshot.childAssetsByMindmapId], ); const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]); const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]); useEffect(() => { setFileTreeSelection((prev) => normalizeFileTreeSelectionForVisibleRows(prev, fileTreeVisibleRowIds)); }, [fileTreeVisibleRowIds]); const docParentById = useMemo( () => buildParentById( (sidebarData.documents ?? []).map((doc) => ({ id: doc.id, parentId: doc.parent_id ?? null, })), ), [sidebarData.documents], ); const childrenCountByParentId = useMemo(() => { const map = new Map(); (sidebarData.documents ?? []).forEach((doc) => { const parentId = doc.parent_id ?? null; map.set(parentId, (map.get(parentId) ?? 0) + 1); }); return map; }, [sidebarData.documents]); const activeWorkspace = sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ?? sidebarData.workspaces[0]; const handleOpenDocument = useCallback( (documentId: string, mode: "main" | "sidebar") => { const targetPath = `/documents/${documentId}`; if (mode === "main") { router.push(targetPath); setOpen(false); return; } if (typeof window !== "undefined") { const sidebarUrl = `${buildDocumentUrl(documentId)}?preview=sidebar`; window.open(sidebarUrl, "_blank", "noopener,noreferrer"); } }, [router, setOpen], ); const handleCopyLink = useCallback(async (node: DocumentNode, includeTitle = false) => { const url = buildDocumentUrl(node.id); const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url; await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制"); }, []); const handleCopyReference = useCallback(async (node: DocumentNode, mode: "inline" | "embed") => { const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`; await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制"); }, []); const handleCopyId = useCallback(async (node: DocumentNode) => { await copyText(node.id, "页面 ID 已复制"); }, []); const handleDuplicateDocument = useCallback( async (node: DocumentNode) => { const response = await fetch("/api/documents/duplicate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId: node.id }), }); if (!response.ok) { window.alert("复制失败,请稍后再试"); return; } await refreshTree(); }, [refreshTree], ); const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => { setMoveEmbedSource(node); setMoveEmbedMode(nextMode); setMoveEmbedOpen(true); }, []); const handleEmbedPrompt = useCallback(async (node: DocumentNode) => { openMoveEmbedPicker(node, "embed"); }, [openMoveEmbedPicker]); const toggleExpand = useCallback((id: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const knownMindmapFolderIdsRef = useRef>(new Set()); useEffect(() => { setExpandedAssetFolders((prev) => { const next = new Set(prev); (mindmapAssets ?? []).forEach((asset) => { if (asset.asset_type !== "mindmap") return; if (knownMindmapFolderIdsRef.current.has(asset.id)) return; knownMindmapFolderIdsRef.current.add(asset.id); next.add(asset.id); }); return next; }); }, [mindmapAssets]); const toggleAssetFolderExpand = useCallback((assetId: string) => { setExpandedAssetFolders((prev) => { const next = new Set(prev); if (next.has(assetId)) next.delete(assetId); else next.add(assetId); return next; }); }, []); const handleOpenAsset = useCallback((asset: MediaAsset) => { if (asset.asset_type === "mindmap") { router.push(`/mindmap/${asset.document_id}/${asset.id}`); setOpen(false); return; } if (asset.asset_type === "luckysheet") { if (activeId && activeId === asset.document_id && editorBridge?.openTableFullScreen) { editorBridge.openTableFullScreen(asset.id); setOpen(false); return; } router.push(`/documents/${asset.document_id}?openTableId=${encodeURIComponent(asset.id)}`); setOpen(false); return; } const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null); if (officeFileType) { const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl; if (!officeBase) { window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL"); return; } void (async () => { try { const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`); if (!res.ok) { const payload = await res.json().catch(() => null); throw new Error(payload?.error ?? "生成签名链接失败"); } const { signedUrl } = (await res.json()) as { signedUrl: string }; const target = new URL("/onlyoffice", window.location.origin); target.searchParams.set("fileUrl", signedUrl); target.searchParams.set("fileName", asset.file_name ?? `未命名.${officeFileType}`); target.searchParams.set("fileType", officeFileType); target.searchParams.set("assetId", asset.id); target.searchParams.set("documentId", asset.document_id); target.searchParams.set("mode", "edit"); window.open(target.toString(), "_blank", "noopener,noreferrer"); setOpen(false); } catch (error) { window.alert((error as Error).message); } })(); return; } void (async () => { try { const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`); if (!res.ok) { const payload = await res.json().catch(() => null); throw new Error(payload?.error ?? "生成签名链接失败"); } const { signedUrl } = (await res.json()) as { signedUrl: string }; if (!signedUrl) { throw new Error("暂无可用的文件链接"); } if (typeof window !== "undefined") { window.open(signedUrl, "_blank", "noopener,noreferrer"); } setOpen(false); } catch { const fallback = asset.signed_url ?? asset.file_url; if (!fallback) { window.alert("暂无可用的文件链接"); return; } if (typeof window !== "undefined") { window.open(fallback, "_blank", "noopener,noreferrer"); } setOpen(false); } })(); }, [activeId, editorBridge, router, setOpen]); const handleFileTreeBlankMouseDown = useCallback(() => { setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); }, []); const handleFileTreeRowClick = useCallback( (row: FileTreeRow, event: React.MouseEvent) => { setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "click", rowId: row.rowId, visibleRowIds: fileTreeVisibleRowIds, modifiers: { shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, }, }), ); // 与 VS Code 的单击打开不同:为了避免误触导致重资源文件(思维导图/表格等)被 // 直接打开,我们在“无修饰键”的单击时只跳转到对应页面的 index.md(即文档本身)。 if (event.button !== 0) return; if (event.shiftKey || event.ctrlKey || event.metaKey) return; const targetDocId = row.docId; if (!targetDocId) return; if (activeId && activeId === targetDocId) return; // doc/index/asset 都统一跳转到所属页面(index.md) handleOpenDocument(targetDocId, "main"); }, [activeId, fileTreeVisibleRowIds, handleOpenDocument], ); const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => { setFileTreeSelection((prev) => { if (prev.selectedRowIds.has(row.rowId)) return prev; return reduceFileTreeSelection(prev, { type: "click", rowId: row.rowId, visibleRowIds: fileTreeVisibleRowIds, modifiers: { shiftKey: false, ctrlKey: false, metaKey: false }, }); }); }, [fileTreeVisibleRowIds]); const handleFileTreeRowDoubleClick = useCallback( (row: FileTreeRow, _event?: React.MouseEvent) => { if (row.kind === "asset" || row.kind === "asset-folder") { handleOpenAsset(row.asset); return; } handleOpenDocument(row.docId, "main"); }, [handleOpenAsset, handleOpenDocument], ); const handleFileTreeRowContextMenu = useCallback( (row: FileTreeRow, event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }), ); if (row.kind === "asset" || row.kind === "asset-folder") { setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY }); return; } setContextMenu({ node: row.node, x: event.clientX, y: event.clientY, }); }, [], ); useEffect(() => { const handler = async (event: KeyboardEvent) => { const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && (event.key === "c" || event.key === "C"); const isPaste = (event.ctrlKey || event.metaKey) && !event.altKey && (event.key === "v" || event.key === "V"); if (!isCopy && !isPaste) { return; } if (isTextInputTarget(event.target)) { return; } const container = fileTreeContainerRef.current; const activeElement = document.activeElement; if (!container || !activeElement || !container.contains(activeElement)) { return; } if (isCopy) { if (fileTreeSelection.selectedRowIds.size === 0) { return; } event.preventDefault(); const orderedRowIds = fileTreeRows .filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId)) .map((row) => row.rowId); await writeFileTreeClipboardPayload({ type: "mnote-file-tree", version: 1, action: "copy", rowIds: orderedRowIds, }); return; } if (isPaste) { event.preventDefault(); const payload = await readFileTreeClipboardPayload(); if (!payload || payload.rowIds.length === 0) { return; } const targetDocId = inferPasteTargetDocId({ focusedRowId: fileTreeSelection.focusedRowId, rowById: fileTreeRowById, activeDocId: activeId || null, }); if (!targetDocId) { setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0); return; } const rows = payload.rowIds .map((rowId) => fileTreeRowById.get(rowId as any)) .filter(Boolean) as FileTreeRow[]; const docItemsMap = new Map(); rows.forEach((row) => { if (row.kind === "doc") { docItemsMap.set(row.docId, true); } else if (row.kind === "index") { if (!docItemsMap.has(row.docId)) { docItemsMap.set(row.docId, false); } } }); if (docItemsMap.size > 0) { const resp = await fetch("/api/documents/copy-tree", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({ documentId, recursive, })), targetParentId: targetDocId, }), }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); setTimeout(() => window.alert(data?.error ?? "粘贴页面失败"), 0); return; } await sidebarQuery.refetch(); emitDocumentsChanged(targetDocId); } const assetRows = rows.filter((row) => row.kind === "asset") as Extract[]; const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id); if (copyableAssetIds.length > 0) { const resp = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "copy", assetIds: copyableAssetIds, targetDocumentId: targetDocId, }), }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); setTimeout(() => window.alert(data?.error ?? "粘贴附件失败"), 0); return; } await sidebarQuery.refetch(); emitAssetsChanged(targetDocId); } else if (docItemsMap.size === 0) { setTimeout(() => window.alert("没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0); } } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [ activeId, fileTreeRowById, fileTreeRows, fileTreeSelection.focusedRowId, fileTreeSelection.selectedRowIds, sidebarQuery, ]); const handleCopyAssetLink = useCallback( async (asset: MediaAsset) => { if (asset.asset_type === "mindmap") { await copyText(buildMindmapUrl(asset.document_id, asset.id), "思维导图链接已复制"); return; } if (asset.asset_type === "luckysheet") { await copyText(buildTableUrl(asset.id), "表格链接已复制"); return; } const url = asset.signed_url ?? asset.file_url ?? ""; if (!url) { window.alert("暂无可用的文件链接"); return; } await copyText(url, "附件链接已复制"); }, [], ); const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => { const path = asset.asset_type === "mindmap" ? ((asset.file_url ? asset.file_url.replace(/^\//, "") : "") || `documents/${asset.document_id}/${asset.file_name ?? "mindmap.json"}`) : asset.asset_type === "luckysheet" ? (`tables/${asset.id}`) : asset.storage_path || asset.file_url || asset.file_name || "附件"; await copyText(path, "存储路径已复制"); }, []); const handleDownloadAsset = useCallback(async (asset: MediaAsset) => { const current = useCurrentDocumentStore.getState(); if ( current.disableDownload && current.documentId && asset.document_id && String(asset.document_id) === String(current.documentId) ) { window.alert("该页面已禁止下载"); return; } if (asset.asset_type === "mindmap") { const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`); if (!resp.ok) { window.alert("下载失败"); return; } const payload = await resp.json().catch(() => null); const data = payload?.data ?? {}; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = asset.file_name ?? "mindmap.json"; a.click(); URL.revokeObjectURL(url); return; } if (asset.asset_type === "luckysheet") { window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel)"); return; } try { const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`); if (!res.ok) { const payload = await res.json().catch(() => null); throw new Error(payload?.error ?? "生成签名链接失败"); } const { signedUrl } = (await res.json()) as { signedUrl: string }; if (!signedUrl) { throw new Error("暂无可用的下载链接"); } if (typeof window !== "undefined") { window.open(signedUrl, "_blank", "noopener,noreferrer"); } } catch { const fallback = asset.signed_url ?? asset.file_url; if (!fallback) { window.alert("暂无可用的下载链接"); return; } if (typeof window !== "undefined") { window.open(fallback, "_blank", "noopener,noreferrer"); } } }, []); const handleRenameAsset = useCallback( async (asset: MediaAsset) => { if (asset.asset_type === "mindmap") { window.alert("思维导图暂不支持重命名"); return; } if (asset.asset_type === "luckysheet") { const currentTitle = (asset.file_name ?? "").toLowerCase().endsWith(".luckysheet") ? (asset.file_name ?? "").slice(0, -".luckysheet".length) : (asset.file_name ?? ""); const input = window.prompt("输入新表格名", currentTitle); if (!input || !input.trim()) return; const newTitle = input.trim(); const resp = await fetch(`/api/tables/${asset.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newTitle }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "重命名失败"); return; } window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: asset.id } })); await sidebarQuery.refetch(); setAssetMenu(null); return; } const input = window.prompt("输入新文件名", asset.file_name ?? ""); if (!input || !input.trim()) return; const newName = input.trim(); const resp = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "重命名失败"); return; } await sidebarQuery.refetch(); setAssetMenu(null); emitAssetsChanged(asset.document_id); }, [sidebarQuery], ); const handleMoveAsset = useCallback( async (asset: MediaAsset) => { if (asset.asset_type === "mindmap") { window.alert("思维导图文件无需移动,请在页面中直接编辑"); return; } if (asset.asset_type === "luckysheet") { window.alert("在线表格暂不支持移动(后续可实现跨页面迁移)"); return; } const target = window.prompt("输入目标页面 ID", asset.document_id); if (!target || !target.trim()) return; const resp = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "move", assetIds: [asset.id], targetDocumentId: target.trim(), }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "移动失败"); return; } await sidebarQuery.refetch(); setAssetMenu(null); emitAssetsChanged(asset.document_id); emitAssetsChanged(target.trim()); }, [sidebarQuery], ); const handleDeleteAssets = useCallback( async (assetIds: string[], assetHint?: MediaAsset | MediaAsset[]) => { const uniqueAssetIds = Array.from(new Set(assetIds)); const assets = uniqueAssetIds .map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id)) .filter(Boolean) as MediaAsset[]; const hints = Array.isArray(assetHint) ? assetHint : assetHint ? [assetHint] : []; hints.forEach((hint) => { if (!hint) return; if (!assets.find((item) => item.id === hint.id)) { assets.unshift(hint); } }); const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap"); const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet"); const fileAssetsToDelete = assets.filter( (item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet", ); const mindmapIdsByDocId = new Map(); mindmapAssetsToDelete.forEach((item) => { const prev = mindmapIdsByDocId.get(item.document_id) ?? []; prev.push(item.id); mindmapIdsByDocId.set(item.document_id, prev); }); const fileAssetIdsByDocId = new Map(); fileAssetsToDelete.forEach((item) => { const prev = fileAssetIdsByDocId.get(item.document_id) ?? []; prev.push(item.id); fileAssetIdsByDocId.set(item.document_id, prev); }); const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat(); for (const asset of mindmapAssetsToDelete) { const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`, { method: "DELETE" }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "删除思维导图失败"); return; } } for (const asset of tableAssetsToDelete) { const resp = await fetch(`/api/tables/${asset.id}`, { method: "DELETE" }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "删除在线表格失败"); return; } window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId: asset.id } })); } if (fileAssetIds.length > 0) { const resp = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "删除失败"); return; } } if (uniqueAssetIds.length > 0) { const mindmapSet = new Set(mindmapAssetsToDelete.map((item) => item.id)); const tableSet = new Set(tableAssetsToDelete.map((item) => item.id)); const fileSet = new Set(fileAssetsToDelete.map((item) => item.id)); setMediaAssets((prev) => prev.filter((item) => !fileSet.has(item.id))); setMindmapAssets((prev) => prev.filter((item) => !mindmapSet.has(item.id))); setTableAssets((prev) => prev.filter((item) => !tableSet.has(item.id))); } setAssetMenu(null); mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids)); fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids)); await sidebarQuery.refetch(); }, [mediaAssets, mindmapAssets, sidebarQuery, tableAssets], ); const handleDeleteFileTreeSelection = useCallback(async () => { const { docIds, assetIds } = computeFileTreeDeleteTargets({ visibleRows: fileTreeRows, selectedRowIds: fileTreeSelection.selectedRowIds, parentById: docParentById, }); if (docIds.length === 0 && assetIds.length === 0) { return; } const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : ""; // 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。 // 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。 const isAssetRow = ( row: FileTreeRow, ): row is Extract => row.kind === "asset" || row.kind === "asset-folder"; const selectedAssetHints = Array.from( new Map( fileTreeRows .filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId)) .filter(isAssetRow) .map((row) => [row.asset.id, row.asset] as const), ).values(), ); const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length; const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length; const fileCount = selectedAssetHints.filter( (item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet", ).length; const unknownCount = Math.max(0, assetIds.length - mindmapCount - tableCount - fileCount); const assetTextParts: string[] = []; if (fileCount > 0) assetTextParts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`); if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(移入垃圾桶,10 分钟内可恢复)`); if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格(删除)`); if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象(删除)`); const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : ""; const joinText = docText && assetText ? " + " : ""; const ok = window.confirm(`确认删除选中的 ${docText}${joinText}${assetText} 吗?`); if (!ok) return; try { if (docIds.length > 0) { const results = await Promise.all( docIds.map(async (documentId) => { const resp = await fetch("/api/documents/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId }), }); return { documentId, ok: resp.ok }; }), ); const failed = results.filter((item) => !item.ok).map((item) => item.documentId); if (failed.length > 0) { window.alert(`部分页面删除失败:${failed.slice(0, 5).join(", ")}${failed.length > 5 ? "…" : ""}`); return; } docIds.forEach((documentId) => emitDocumentsChanged(documentId)); if (activeId && docIds.includes(activeId)) { router.push("/"); } } if (assetIds.length > 0) { await handleDeleteAssets(assetIds, selectedAssetHints); } await refreshTree(); setContextMenu(null); setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); } catch (error) { window.alert(error instanceof Error ? error.message : "删除失败"); } }, [ activeId, docParentById, fileTreeRows, fileTreeSelection.selectedRowIds, handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets, refreshTree, router, ]); const handleResizeStart = useCallback( (event: React.MouseEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = width; const onMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientX - startX; const targetWidth = Math.min(420, Math.max(240, startWidth + delta)); setWidth(targetWidth); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [setWidth, width], ); const handleCreate = useCallback( async (parentId: string | null) => { const creatingKey = parentId ?? "__root__"; if (creatingDocumentUnderParentRef.current.has(creatingKey)) { return; } creatingDocumentUnderParentRef.current.add(creatingKey); try { const response = await fetch("/api/documents/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parentId }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); window.alert(payload?.error ?? "新建页面失败,请稍后再试"); return; } const payload = (await response.json()) as DocumentNode; const nextNode: DocumentNode = { ...payload, access_scope: payload.access_scope ?? "private", is_template: payload.is_template ?? false, updated_at: payload.updated_at ?? payload.created_at, title: payload.title ?? "无标题", children: [], }; setTree((prev) => { // 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”) const exists = (nodes: DocumentNode[]): boolean => { for (const node of nodes) { if (node.id === nextNode.id) return true; if (node.children.length > 0 && exists(node.children)) return true; } return false; }; if (exists(prev)) return prev; return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode); }); setExpanded((prev) => { const next = new Set(prev); if (parentId) { next.add(parentId); } if (!parentId) { next.add(nextNode.id); } return next; }); await refreshTree(); router.push(`/documents/${nextNode.id}`); } finally { creatingDocumentUnderParentRef.current.delete(creatingKey); } }, [refreshTree, router], ); const handleRename = useCallback( async (documentId: string, currentTitle: string | null) => { const title = window.prompt("输入新的标题", currentTitle ?? "无标题") ?? ""; if (!title.trim()) { return; } await fetch("/api/documents/title", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId, workspaceId: sidebarData.activeWorkspaceId ?? null, title: title.trim(), }), }); await refreshTree(); }, [refreshTree], ); const moveLocalNode = useCallback((currentTree: DocumentNode[], nodeId: string, parentId: string | null, index: number) => { const cloned = cloneNodes(currentTree); const { removed, tree: withoutTarget } = removeNode(cloned, nodeId); if (!removed) { return currentTree; } const next = insertNode(withoutTarget, parentId, index, removed); return next; }, []); const handleMove = useCallback( async (documentId: string, parentId: string | null, index: number) => { setTree((prev) => moveLocalNode(prev, documentId, parentId, index)); if (parentId) { setExpanded((prev) => new Set(prev).add(parentId)); } await fetch("/api/documents/move", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId, parentId, position: index, }), }); await refreshTree(); }, [moveLocalNode, refreshTree, setExpanded], ); const handleFileTreeDropFiles = useCallback( (docId: string, files: FileList, targetRow?: FileTreeRow) => { void (async () => { const droppedFiles = Array.from(files ?? []); if (droppedFiles.length === 0) return; const targetMindmapId = (() => { if (!targetRow) return null; if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") { return targetRow.asset.id; } if (targetRow.kind === "asset") { return extractMindmapIdFromStoragePath(targetRow.asset.storage_path); } return null; })(); if (targetMindmapId) { setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId)); } const inferredTargetDocId = docId || inferPasteTargetDocId({ focusedRowId: fileTreeSelection.focusedRowId, rowById: fileTreeRowById, activeDocId: activeId || null, }) || ""; if (!inferredTargetDocId) { setTimeout(() => window.alert("请选择一个目标页面后再拖入文件"), 0); return; } const targetDoc = sidebarData.documents.find((doc) => doc.id === inferredTargetDocId) ?? null; const workspaceId = targetDoc?.workspace_id ?? sidebarData.activeWorkspaceId ?? ""; if (!workspaceId) { setTimeout(() => window.alert("无法识别当前工作区,上传失败"), 0); return; } const errors: string[] = []; for (const file of droppedFiles) { try { const form = new FormData(); form.append("file", file); form.append("workspaceId", workspaceId); form.append("documentId", inferredTargetDocId); if (targetMindmapId) { form.append("mindmapId", targetMindmapId); } const resp = await fetch("/api/media/upload", { method: "POST", body: form }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); errors.push(`${file.name}: ${payload?.error ?? "上传失败"}`); continue; } const payload = (await resp.json()) as { asset?: MediaAsset }; if (payload.asset?.id) { emitAssetsChanged(inferredTargetDocId, payload.asset); // 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区 if (inferredTargetDocId === activeId && !targetMindmapId) { editorBridge?.insertMediaAsset?.(payload.asset); } } else { errors.push(`${file.name}: 返回数据缺少 asset`); } } catch (err) { errors.push(`${file.name}: ${(err as Error).message}`); } } await sidebarQuery.refetch(); if (errors.length > 0) { setTimeout(() => { const shown = errors.slice(0, 6).join("\n"); window.alert( errors.length === 1 ? `部分文件上传失败:\n${shown}` : `部分文件上传失败(${errors.length}个):\n${shown}${errors.length > 6 ? "\n..." : ""}`, ); }, 0); } })(); }, [ activeId, editorBridge, fileTreeRowById, fileTreeSelection.focusedRowId, sidebarData.activeWorkspaceId, sidebarData.documents, sidebarQuery, ], ); const handleFileTreeInternalDrop = useCallback( (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => { void (async () => { const targetDocId = inferDropTargetDocId(args.targetRow); if (!targetDocId) { setTimeout(() => window.alert("无法识别拖拽目标页面"), 0); return; } const targetMindmapId = (() => { if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") { return args.targetRow.asset.id; } if (args.targetRow.kind === "asset") { return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path); } return null; })(); const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined; if (targetMindmapId) { setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId)); } const uniqueRowIds: string[] = []; const seen = new Set(); args.rowIds.forEach((id) => { if (!id || seen.has(id)) return; seen.add(id); uniqueRowIds.push(id); }); const rows = uniqueRowIds .map((rowId) => fileTreeRowById.get(rowId as any)) .filter(Boolean) as FileTreeRow[]; const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId); const assetRows = rows.filter((row) => row.kind === "asset") as Extract[]; const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id); if (docIds.length === 0 && copyableAssetIds.length === 0) { setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0); return; } if (args.copy) { if (docIds.length > 0) { const resp = await fetch("/api/documents/copy-tree", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: docIds.map((documentId) => ({ documentId, recursive: true })), targetParentId: targetDocId, }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); setTimeout(() => window.alert(payload?.error ?? "复制页面失败"), 0); return; } await sidebarQuery.refetch(); emitDocumentsChanged(targetDocId); } if (copyableAssetIds.length > 0) { const resp = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "copy", assetIds: copyableAssetIds, targetDocumentId: targetDocId, targetSubPath, }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); setTimeout(() => window.alert(payload?.error ?? "复制附件失败"), 0); return; } await sidebarQuery.refetch(); emitAssetsChanged(targetDocId); } return; } const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById); if (topLevelDocIds.length > 0) { if ( isInvalidDocDrop({ sourceDocIds: topLevelDocIds, targetParentId: targetDocId, parentById: docParentById, }) ) { setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0); return; } const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0; setTree((prev) => { let next = prev; topLevelDocIds.forEach((id, offset) => { next = moveLocalNode(next, id, targetDocId, baseIndex + offset); }); return next; }); setExpanded((prev) => new Set(prev).add(targetDocId)); for (let i = 0; i < topLevelDocIds.length; i += 1) { await fetch("/api/documents/move", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId: topLevelDocIds[i], parentId: targetDocId, position: baseIndex + i, }), }); } await refreshTree(); emitDocumentsChanged(targetDocId); } if (copyableAssetIds.length > 0) { const resp = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "move", assetIds: copyableAssetIds, targetDocumentId: targetDocId, targetSubPath, }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); setTimeout(() => window.alert(payload?.error ?? "移动附件失败"), 0); return; } await sidebarQuery.refetch(); const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id)); sourceDocIds.forEach((id) => emitAssetsChanged(id)); emitAssetsChanged(targetDocId); } })(); }, [ childrenCountByParentId, docParentById, fileTreeRowById, moveLocalNode, refreshTree, sidebarQuery, ], ); const handleMovePrompt = useCallback( async (node: DocumentNode) => { openMoveEmbedPicker(node, "move"); }, [openMoveEmbedPicker], ); const handleDelete = useCallback( async (documentId: string) => { await fetch("/api/documents/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId }), }); await refreshTree(); emitDocumentsChanged(documentId); if (activeId === documentId) { router.push("/"); } }, [activeId, refreshTree, router], ); const handleDeleteFromContextMenuNode = useCallback( async (node: DocumentNode) => { if (viewMode === "filesystem") { await handleDeleteFileTreeSelection(); return; } const ok = window.confirm("确认删除该页面到垃圾桶吗?"); if (!ok) return; try { await handleDelete(node.id); } catch (error) { window.alert(error instanceof Error ? error.message : "删除失败"); } }, [handleDelete, handleDeleteFileTreeSelection, viewMode], ); const handleDeleteFromAssetContextMenu = useCallback( async (assetIds: string[], assetHint?: MediaAsset) => { const uniqueAssetIds = Array.from(new Set(assetIds)); if (uniqueAssetIds.length === 0) return; const assets = uniqueAssetIds .map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id)) .filter(Boolean) as MediaAsset[]; if (assetHint && !assets.find((item) => item.id === assetHint.id)) { assets.unshift(assetHint); } const mindmapCount = assets.filter((item) => item.asset_type === "mindmap").length; const tableCount = assets.filter((item) => item.asset_type === "luckysheet").length; const fileCount = assets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length; const unknownCount = Math.max(0, uniqueAssetIds.length - mindmapCount - tableCount - fileCount); const parts: string[] = []; if (fileCount > 0) parts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`); if (mindmapCount > 0) parts.push(`${mindmapCount} 个思维导图(删除)`); if (tableCount > 0) parts.push(`${tableCount} 个在线表格(删除)`); if (unknownCount > 0) parts.push(`${unknownCount} 个对象(删除)`); const ok = window.confirm(`确认删除选中的 ${parts.join(" + ")} 吗?`); if (!ok) return; try { await handleDeleteAssets(uniqueAssetIds, assetHint); setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); } catch (error) { window.alert(error instanceof Error ? error.message : "删除失败"); } }, [handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets], ); const handleConvertToChild = useCallback( async (documentId: string) => { const flat = flattenedPrivate; const currentIndex = flat.findIndex((item) => item.node.id === documentId); if (currentIndex <= 0) { return; } const targetParentId = flat[currentIndex - 1].node.id; await handleMove(documentId, targetParentId, flat[currentIndex - 1].node.children.length); }, [flattenedPrivate, handleMove], ); const confirmTrashAction = useCallback( (message: string) => { if (!trashConfirm) { return true; } return window.confirm(message); }, [trashConfirm], ); const handleRestoreFromTrash = useCallback( async (documentId: string) => { if (!confirmTrashAction("确认恢复该页面吗?")) { return; } await fetch("/api/documents/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId }), }); await Promise.all([sidebarQuery.refetch(), refreshTree()]); }, [confirmTrashAction, refreshTree, sidebarQuery], ); const handlePurgeFromTrash = useCallback( async (documentId: string) => { if (!confirmTrashAction("彻底删除后将无法找回,是否继续?")) { return; } await fetch("/api/documents/purge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId }), }); await Promise.all([sidebarQuery.refetch(), refreshTree()]); }, [confirmTrashAction, refreshTree, sidebarQuery], ); const handleEmptyTrash = useCallback(async () => { if (!sidebarData.activeWorkspaceId) { window.alert("暂无可清空的工作空间"); return; } if (!confirmTrashAction("清空垃圾桶后将无法恢复,是否继续?")) { return; } setEmptyingTrash(true); try { const response = await fetch("/api/documents/empty-trash", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); window.alert(payload?.error ?? "清空垃圾桶失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); } finally { setEmptyingTrash(false); } }, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]); const handleRestoreMediaAssetFromTrash = useCallback( async (assetId: string) => { if (!confirmTrashAction("确认恢复该附件吗?")) { return; } const assetHint = (filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ?? (sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ?? null; const response = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "restore", assetIds: [assetId] }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); window.alert(payload?.error ?? "恢复附件失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); // 重要:删除附件时会同步移除主编辑区块;恢复后向编辑器广播“恢复事件”,由编辑器决定是否插入。 let assetForInsert = assetHint as any; if (assetHint?.document_id && String(assetHint.document_id) === String(activeId)) { const fallback = (assetHint.signed_url ?? assetHint.file_url ?? "").trim(); let fileUrl = fallback; if (!fileUrl) { try { const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`); if (res.ok) { const payload = (await res.json().catch(() => null)) as any; fileUrl = String(payload?.signedUrl ?? "").trim(); } } catch { // ignore } } if (fileUrl) { assetForInsert = { ...(assetHint as any), id: assetId, file_url: fileUrl, thumbnail_url: (assetHint.thumbnail_url ?? fileUrl) as any, } as any; editorBridge?.insertMediaAsset?.(assetForInsert); } } if (assetHint?.document_id) { emitAssetsRestored({ docId: String(assetHint.document_id), kind: "media", assetId, asset: assetForInsert }); } }, [ activeId, confirmTrashAction, editorBridge, filteredTrashedMediaAssets, refreshTree, sidebarData.trashedMediaAssets, sidebarQuery, ], ); const handleRestoreTableFromTrash = useCallback( async (tableId: string) => { if (!confirmTrashAction("确认恢复该在线表格吗?")) { return; } const tableHint = (filteredTrashedMediaAssets ?? []).find((a) => a.id === tableId && a.asset_type === "luckysheet") ?? (sidebarData.trashedTableAssets ?? []).find((a) => a.id === tableId) ?? null; const response = await fetch("/api/tables/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tableId }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); window.alert(payload?.error ?? "恢复在线表格失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); // 重要:删除在线表格会同步移除主编辑区块;恢复后若目标页面正打开,则把表格重新插入主编辑区。 if (tableHint?.document_id && String(tableHint.document_id) === String(activeId)) { editorBridge?.insertOnlineTableAsset?.({ documentId: String(tableHint.document_id), tableId }); } if (tableHint?.document_id) { emitAssetsRestored({ docId: String(tableHint.document_id), kind: "table", tableId }); } }, [ activeId, confirmTrashAction, editorBridge, filteredTrashedMediaAssets, refreshTree, sidebarData.trashedTableAssets, sidebarQuery, ], ); const handlePurgeMediaAssetFromTrash = useCallback( async (assetId: string) => { if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) { return; } const response = await fetch("/api/media/purge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ assetId }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); window.alert(payload?.error ?? "彻底删除附件失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); }, [confirmTrashAction, refreshTree, sidebarQuery], ); const handlePurgeTableFromTrash = useCallback( async (tableId: string) => { if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) { return; } const response = await fetch("/api/tables/purge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tableId }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); window.alert(payload?.error ?? "彻底删除在线表格失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); }, [confirmTrashAction, refreshTree, sidebarQuery], ); const handleEmptyMediaTrash = useCallback(async () => { if (!sidebarData.activeWorkspaceId) { window.alert("暂无可清空的工作空间"); return; } if (!confirmTrashAction("清空附件垃圾桶后将无法恢复,是否继续?")) { return; } setEmptyingTrash(true); try { const [mediaResp, mindmapResp, tableResp] = await Promise.all([ fetch("/api/media/empty-trash", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }), }), fetch("/api/mindmap-trash/empty", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }), }), fetch("/api/tables/empty-trash", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }), }), ]); if (!mediaResp.ok) { const payload = await mediaResp.json().catch(() => ({})); window.alert(payload?.error ?? "清空附件垃圾桶失败,请稍后再试"); return; } if (!mindmapResp.ok) { const payload = await mindmapResp.json().catch(() => ({})); window.alert(payload?.error ?? "清空思维导图垃圾桶失败,请稍后再试"); return; } if (!tableResp.ok) { const payload = await tableResp.json().catch(() => ({})); window.alert(payload?.error ?? "清空在线表格垃圾桶失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); } finally { setEmptyingTrash(false); } }, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]); const handleRestoreMindmapFromTrash = useCallback( async (documentId: string, mindmapId: string) => { if (!confirmTrashAction("确认恢复该思维导图吗?")) { return; } const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "restore" }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); window.alert(payload?.error ?? "恢复思维导图失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); // 重要:删除思维导图会同步移除主编辑区块;恢复后若目标页面正打开,则把导图重新插入主编辑区。 if (documentId && String(documentId) === String(activeId)) { editorBridge?.insertMindmapAsset?.({ documentId, mindmapId }); } if (documentId) { emitAssetsRestored({ docId: String(documentId), kind: "mindmap", mindmapId }); } }, [activeId, confirmTrashAction, editorBridge, refreshTree, sidebarQuery], ); const handlePurgeMindmapFromTrash = useCallback( async (documentId: string, mindmapId: string) => { if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) { return; } const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "purge" }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); window.alert(payload?.error ?? "彻底删除思维导图失败,请稍后再试"); return; } await Promise.all([sidebarQuery.refetch(), refreshTree()]); }, [confirmTrashAction, refreshTree, sidebarQuery], ); const handleSignOut = useCallback(async () => { if (signingOut) { return; } if (!window.confirm("确认退出登录吗?")) { return; } setSigningOut(true); try { await signOut(); router.replace("/auth"); router.refresh(); } catch (error: any) { window.alert(`退出登录失败:${error?.message ?? "请稍后再试"}`); } finally { setSigningOut(false); } }, [router, signOut, signingOut]); const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => { event.preventDefault(); event.stopPropagation(); setContextMenu({ node, x: event.clientX, y: event.clientY, }); }, []); const openShareDialog = useCallback((node: DocumentNode) => { setShareTarget({ id: node.id, title: node.title ?? null, workspaceId: node.workspace_id, allowIncludeDescendants: (node.children?.length ?? 0) > 0, }); setShareDialogOpen(true); }, []); const handleTopButtonClick = useCallback( (buttonId: (typeof TOP_BUTTONS)[number]["id"]) => { if (buttonId === "search") { openSearchPalette(); return; } if ( buttonId === "starred" || buttonId === "public" || buttonId === "shared" || buttonId === "templates" ) { setViewMode("section"); setTopPanel((prev) => (prev === buttonId ? null : buttonId)); return; } if (buttonId === "members") { setGroupManagerOpen(true); return; } window.alert("该功能即将上线,敬请期待"); }, [ openSearchPalette, setViewMode, ], ); useEffect(() => { if (!contextMenu) { return; } const closeMenu = () => setContextMenu(null); window.addEventListener("click", closeMenu); return () => window.removeEventListener("click", closeMenu); }, [contextMenu]); const sidebarBody = (
当前工作空间
{activeWorkspace?.name ?? "我的空间"}
{activeWorkspace?.type === "team" ? "团队空间" : "个人空间"}
提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
{TOP_BUTTONS.map((button) => ( ))}
{topPanel ? (
{SECTION_ICONS[topPanel]} {topPanel === "starred" ? "星标置顶" : topPanel === "public" ? "公共页面" : topPanel === "shared" ? "共享页面" : "模板中心"} 再次点击图标可收起
{(() => { const renderList = (nodes: DocumentNode[]) => { const flat = flattenDocumentTree(nodes, collectNodeIds(nodes)); if (flat.length === 0) { return
暂无内容
; } return (
{flat.map(({ node, depth }) => ( {node.title || "无标题"} ))}
); }; if (topPanel === "shared") { const groupByWorkspace = ( rows: Array<{ workspaceId: string; workspaceName: string | null; documentId: string; documentTitle: string | null; includeDescendants: boolean; permission?: "read" | "edit"; sharedWithCount?: number; }>, ) => { const map = new Map(); for (const r of rows) { const existing = map.get(r.workspaceId); if (!existing) { map.set(r.workspaceId, { workspaceName: r.workspaceName ?? null, rows: [r] }); } else { existing.rows.push(r); } } return Array.from(map.entries()).map(([workspaceId, v]) => ({ workspaceId, workspaceName: v.workspaceName, rows: v.rows, })); }; const renderShareRows = (rows: Array) => { if (!rows || rows.length === 0) { return
暂无内容
; } const groups = groupByWorkspace(rows); return (
{groups.map((g) => (
{g.workspaceName ?? g.workspaceId}
{g.rows.map((r) => ( {r.documentTitle || "无标题"} {typeof r.permission === "string" ? ( {r.permission === "edit" ? "可编辑" : "只读"} ) : null} {typeof r.sharedWithCount === "number" ? ( {r.sharedWithCount} 人 ) : null} ))}
))}
); }; return (
{shareSummaryError ? (
{shareSummaryError}
) : null}
共享给我的({shareSummary?.incoming?.length ?? 0})
{renderShareRows(shareSummary?.incoming ?? [])}
我共享出去的({shareSummary?.outgoing?.length ?? 0})
{renderShareRows(shareSummary?.outgoing ?? [])}
); } if (topPanel === "public") { const toggleGroup = (groupId: string) => { setOpenPublicGroups((prev) => { const next = new Set(prev); if (next.has(groupId)) next.delete(groupId); else next.add(groupId); return next; }); }; return (
{groupPublicError ? (
{groupPublicError}
) : null}
全员公开
{renderList(publicNodes)}
群组公开
{groupPublicSummary.length === 0 ? (
暂无群组公开页面
) : (
{groupPublicSummary.map((g) => { const isOpen = openPublicGroups.has(g.groupId); const nodes = publicGroupNodesByGroupId.get(g.groupId) ?? []; return (
{isOpen ?
{renderList(nodes)}
: null}
); })}
)}
); } const nodes = topPanel === "starred" ? starredNodes : templateNodes; return renderList(nodes); })()}
) : null}
页面树
setFilter(event.target.value)} />
{viewMode === "section" ? ( <>
{!collapsedSections.private ? (
) : (
已折叠
)}
) : (
handleFileTreeRowDoubleClick(row, event)} onRowContextMenu={handleFileTreeRowContextMenu} onRowDragStart={(row) => handleFileTreeRowDragStart(row)} onToggleExpand={toggleExpand} onToggleAssetFolderExpand={toggleAssetFolderExpand} onCreateChild={handleCreate} onBlankMouseDown={handleFileTreeBlankMouseDown} onDropFiles={handleFileTreeDropFiles} onInternalDrop={handleFileTreeInternalDrop} />
)}
); return ( <> 页面目录
{sidebarBody}
{contextMenu && ( setContextMenu(null)} onOpenRight={(node) => handleOpenDocument(node.id, "sidebar")} onShare={openShareDialog} onMove={handleMovePrompt} onEmbed={handleEmbedPrompt} onCopyLink={handleCopyLink} onCopyReference={handleCopyReference} onCopyId={handleCopyId} onDuplicate={handleDuplicateDocument} onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)} onCreateChild={() => void handleCreate(contextMenu.node.id)} onConvertChild={() => void handleConvertToChild(contextMenu.node.id)} onDelete={() => void handleDeleteFromContextMenuNode(contextMenu.node)} /> )} {shareTarget && ( { setShareDialogOpen(nextOpen); if (!nextOpen) { setShareTarget(null); } }} documentId={shareTarget.id} documentTitle={shareTarget.title} workspaceId={shareTarget.workspaceId} allowIncludeDescendants={shareTarget.allowIncludeDescendants} onChanged={async () => { await refreshTree(); await refreshShareSummary(); await refreshGroupPublicSummary(); }} /> )} { const source = moveEmbedSource; if (!source) { return; } if (pickedMode === "move") { await handleMove(source.id, targetId, 0); return; } if (!targetId) { return; } if (typeof window !== "undefined" && source.id === targetId) { window.alert("不能嵌入到自身页面"); return; } const response = await fetch("/api/documents/embed", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sourceId: source.id, targetId }), }); if (!response.ok) { if (typeof window !== "undefined") { window.alert("嵌入失败,请检查目标页面"); } return; } if (typeof window !== "undefined") { window.alert("已在目标页面末尾插入引用块"); } }} /> {assetMenu && ( setAssetMenu(null)} onOpen={handleOpenAsset} onCopyLink={handleCopyAssetLink} onCopyPath={handleCopyAssetPath} onRename={handleRenameAsset} onMove={handleMoveAsset} onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)} onDownload={handleDownloadAsset} /> )}
垃圾桶 {trashTab === "documents" ? (

180 天内的记录都可以在这里恢复。

) : (

附件删除后 10 分钟内可撤销;也可在此手动清空(不可撤销)。

)}
setTrashSearch(event.target.value)} />
{trashTab === "documents" ? ( filteredTrash.length === 0 ? (
暂无已删除页面
) : ( filteredTrash.map((item) => (
{item.title || "无标题"}
删除时间:{new Date(item.deleted_at).toLocaleString()}
)) ) ) : filteredTrashedMediaAssets.length === 0 ? (
暂无已删除附件
) : ( filteredTrashedMediaAssets.map((item) => (
{item.file_name || "未命名附件"}
删除时间:{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
类型:{item.asset_type === "mindmap" ? "思维导图" : (item.mime_type ?? item.asset_type ?? "unknown")}
)) )}
); } interface SectionListProps { label: string; icon: React.ReactNode; nodes: DocumentNode[]; collapsed: boolean; onToggle: () => void; } function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListProps) { return (
{!collapsed && (
{nodes.length === 0 ? (
暂无内容
) : ( nodes.map((node) => ( {node.title || "无标题"} )) )}
)}
); } interface ContextMenuProps { contextMenu: ContextMenuState; onClose: () => void; onOpenRight: (node: DocumentNode) => void; onShare: (node: DocumentNode) => void; onMove: (node: DocumentNode) => void; onEmbed: (node: DocumentNode) => void; onCopyLink: (node: DocumentNode, withTitle?: boolean) => void; onCopyReference: (node: DocumentNode, mode: "inline" | "embed") => void; onCopyId: (node: DocumentNode) => void; onDuplicate: (node: DocumentNode) => void; onRename: () => void; onCreateChild: () => void; onConvertChild: () => void; onDelete: () => void; } function ContextMenu({ contextMenu, onClose, onOpenRight, onShare, onMove, onEmbed, onCopyLink, onCopyReference, onCopyId, onDuplicate, onRename, onCreateChild, onConvertChild, onDelete, }: ContextMenuProps) { const { node } = contextMenu; const menuRef = useRef(null); const [position, setPosition] = useState({ top: contextMenu.y, left: contextMenu.x }); useLayoutEffect(() => { const clampPosition = () => { const element = menuRef.current; if (!element) { setPosition({ top: contextMenu.y, left: contextMenu.x }); return; } const rect = element.getBoundingClientRect(); const padding = 12; const maxLeft = Math.max(padding, window.innerWidth - rect.width - padding); const maxTop = Math.max(padding, window.innerHeight - rect.height - padding); const left = Math.min(Math.max(padding, contextMenu.x), maxLeft); const top = Math.min(Math.max(padding, contextMenu.y), maxTop); setPosition({ top, left }); }; clampPosition(); window.addEventListener("resize", clampPosition); return () => window.removeEventListener("resize", clampPosition); }, [contextMenu]); const handleAction = (action: () => void) => { action(); onClose(); }; const buttonClass = "flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]"; return (
); } const cloneNodes = (nodes: DocumentNode[]): DocumentNode[] => nodes.map((node) => ({ ...node, children: cloneNodes(node.children), })); const removeNode = ( nodes: DocumentNode[], targetId: string, ): { removed: DocumentNode | null; tree: DocumentNode[] } => { let removed: DocumentNode | null = null; const nextTree = nodes .map((node) => { if (removed) return node; if (node.id === targetId) { removed = node; return null; } const { removed: childRemoved, tree: childTree } = removeNode(node.children, targetId); if (childRemoved) { removed = childRemoved; return { ...node, children: childTree }; } return node; }) .filter(Boolean) as DocumentNode[]; return { removed, tree: nextTree }; }; const insertNode = (nodes: DocumentNode[], parentId: string | null, index: number, newNode: DocumentNode): DocumentNode[] => { if (!parentId) { const next = [...nodes]; next.splice(Math.min(index, next.length), 0, newNode); return next; } return nodes.map((node) => { if (node.id === parentId) { const children = [...node.children]; const targetIndex = Math.min(index, children.length); children.splice(targetIndex, 0, newNode); return { ...node, children }; } return { ...node, children: insertNode(node.children, parentId, index, newNode) }; }); }; const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => { if (!keyword) { return nodes; } const filtered: DocumentNode[] = []; nodes.forEach((node) => { const childMatches = filterTree(node.children, keyword); const title = (node.title ?? "").toLowerCase(); if (title.includes(keyword) || childMatches.length > 0) { filtered.push({ ...node, children: childMatches }); } }); return filtered; }; function collectNodeIds(nodes: DocumentNode[], bag: Set = new Set()): Set { nodes.forEach((node) => { bag.add(node.id); collectNodeIds(node.children, bag); }); return bag; } const buildDocumentUrl = (documentId: string): string => { if (typeof window === "undefined" || !window.location) { return `/documents/${documentId}`; } return `${window.location.origin}/documents/${documentId}`; }; const buildMindmapUrl = (documentId: string, mindmapId: string): string => { if (typeof window === "undefined" || !window.location) { return `/mindmap/${documentId}/${mindmapId}`; } return `${window.location.origin}/mindmap/${documentId}/${mindmapId}`; }; const buildTableUrl = (tableId: string): string => { if (typeof window === "undefined" || !window.location) { return `/tables/${tableId}/view`; } return `${window.location.origin}/tables/${tableId}/view`; }; const copyText = async (text: string, successMessage: string) => { if (typeof navigator !== "undefined" && navigator.clipboard) { try { await navigator.clipboard.writeText(text); window.alert(successMessage); return; } catch { // ignore and fallback } } window.prompt("复制失败,请手动复制内容", text); };