2025-11-23 10:55:04 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
|
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
2026-01-21 18:21:10 +08:00
|
|
|
|
import { useAuthActions } from "@convex-dev/auth/react";
|
2026-01-22 18:53:20 +08:00
|
|
|
|
import { useConvex } from "convex/react";
|
2025-11-23 10:55:04 +08:00
|
|
|
|
import {
|
|
|
|
|
|
ArrowRightLeft,
|
|
|
|
|
|
ArrowUpRight,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
Edit3,
|
|
|
|
|
|
GitMerge,
|
|
|
|
|
|
Globe,
|
|
|
|
|
|
Hash,
|
|
|
|
|
|
LayoutGrid,
|
|
|
|
|
|
Library,
|
|
|
|
|
|
Link as LinkIcon,
|
2026-01-21 18:21:10 +08:00
|
|
|
|
LogOut,
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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";
|
2026-01-18 19:01:31 +08:00
|
|
|
|
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
2025-11-23 10:55:04 +08:00
|
|
|
|
import { PrivateTree } from "@/components/sidebar/private-tree";
|
|
|
|
|
|
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
|
|
|
|
|
import { useSearchPaletteStore } from "@/store/search-palette";
|
2026-01-08 18:17:07 +08:00
|
|
|
|
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
2026-01-24 12:32:51 +08:00
|
|
|
|
import { useCurrentDocumentStore } from "@/store/current-document";
|
2025-12-27 20:23:35 +08:00
|
|
|
|
import { FileTree } from "@/components/sidebar/file-tree";
|
2026-01-07 18:38:56 +08:00
|
|
|
|
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
|
|
|
|
|
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
2026-01-24 13:14:24 +08:00
|
|
|
|
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection";
|
2026-01-07 18:38:56 +08:00
|
|
|
|
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";
|
2026-01-10 10:35:21 +08:00
|
|
|
|
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
2026-01-17 10:12:53 +08:00
|
|
|
|
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
|
2026-01-07 18:38:56 +08:00
|
|
|
|
import {
|
|
|
|
|
|
inferPasteTargetDocId,
|
|
|
|
|
|
isTextInputTarget,
|
|
|
|
|
|
readFileTreeClipboardPayload,
|
|
|
|
|
|
writeFileTreeClipboardPayload,
|
|
|
|
|
|
} from "@/lib/file-tree/clipboard";
|
2025-12-27 20:23:35 +08:00
|
|
|
|
import type { MediaAsset } from "@/types/media";
|
2026-01-02 07:25:50 +08:00
|
|
|
|
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
|
|
|
|
|
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
2026-01-15 20:54:21 +08:00
|
|
|
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
2026-01-22 18:53:20 +08:00
|
|
|
|
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
|
|
|
|
|
|
import { api } from "@/lib/convex/api";
|
|
|
|
|
|
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
|
|
|
|
|
const TOP_BUTTONS = [
|
|
|
|
|
|
{ id: "search", icon: SearchIcon, label: "搜索" },
|
|
|
|
|
|
{ id: "graph", icon: Share2, label: "关系图" },
|
|
|
|
|
|
{ id: "import", icon: Upload, label: "导入" },
|
|
|
|
|
|
{ id: "members", icon: Users, label: "成员" },
|
2026-01-11 12:35:53 +08:00
|
|
|
|
{ id: "starred", icon: Star, label: "星标置顶" },
|
|
|
|
|
|
{ id: "public", icon: Globe, label: "公共页面" },
|
|
|
|
|
|
{ id: "shared", icon: Shield, label: "共享页面" },
|
|
|
|
|
|
{ id: "templates", icon: LayoutGrid, label: "模板中心" },
|
2025-11-23 10:55:04 +08:00
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNode> = {
|
|
|
|
|
|
starred: <Star className="h-4 w-4 text-[#f5a623]" />,
|
|
|
|
|
|
public: <Globe className="h-4 w-4 text-[#60a5fa]" />,
|
|
|
|
|
|
shared: <Shield className="h-4 w-4 text-[#9b87f5]" />,
|
|
|
|
|
|
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
interface SidebarProps {
|
|
|
|
|
|
initialData: SidebarInitialData;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ContextMenuState {
|
|
|
|
|
|
node: DocumentNode;
|
|
|
|
|
|
x: number;
|
|
|
|
|
|
y: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function Sidebar({ initialData }: SidebarProps) {
|
2026-01-22 18:53:20 +08:00
|
|
|
|
return <SidebarConvex initialData={initialData} />;
|
2026-01-18 19:01:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Convex 模式专用组件 - 只调用 Convex hooks
|
|
|
|
|
|
function SidebarConvex({ initialData }: SidebarProps) {
|
|
|
|
|
|
const convexData = useConvexSidebarData(initialData.activeWorkspaceId);
|
|
|
|
|
|
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
|
|
|
|
|
interface SidebarContentProps {
|
|
|
|
|
|
initialData: SidebarInitialData;
|
|
|
|
|
|
sidebarQuery: {
|
|
|
|
|
|
data: SidebarInitialData | null | undefined;
|
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
|
refetch: () => Promise<unknown>;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
2026-01-22 18:53:20 +08:00
|
|
|
|
const convex = useConvex();
|
|
|
|
|
|
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
|
2025-11-23 10:55:04 +08:00
|
|
|
|
useSidebarStore();
|
2025-12-27 20:23:35 +08:00
|
|
|
|
const viewMode = useSidebarStore((state) => state.viewMode);
|
|
|
|
|
|
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
2026-01-18 19:01:31 +08:00
|
|
|
|
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
|
|
|
|
|
|
|
|
|
|
|
// 处理数据
|
|
|
|
|
|
const sidebarData = useMemo(() => {
|
|
|
|
|
|
return sidebarQuery.data ?? initialData;
|
|
|
|
|
|
}, [sidebarQuery, initialData]);
|
|
|
|
|
|
|
|
|
|
|
|
// isLoading 判断
|
|
|
|
|
|
const isLoading = sidebarQuery.isLoading;
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const segments = useSelectedLayoutSegments();
|
|
|
|
|
|
const router = useRouter();
|
2026-01-21 18:21:10 +08:00
|
|
|
|
const { signOut } = useAuthActions();
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const activeId = segments?.[1] ?? "";
|
2026-01-08 18:17:07 +08:00
|
|
|
|
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
|
|
|
|
|
const [tree, setTree] = useState<DocumentNode[]>(() => buildDocumentTree(sidebarData.documents));
|
|
|
|
|
|
const [filter, setFilter] = useState("");
|
|
|
|
|
|
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
|
|
|
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
2026-01-22 18:53:20 +08:00
|
|
|
|
const [topPanel, setTopPanel] = useState<"starred" | "public" | "shared" | "templates" | null>(null);
|
|
|
|
|
|
const [shareSummary, setShareSummary] = useState<{
|
|
|
|
|
|
incoming: Array<{
|
2026-01-24 12:32:51 +08:00
|
|
|
|
workspaceId: string;
|
|
|
|
|
|
workspaceName: string | null;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
documentId: string;
|
2026-01-24 12:32:51 +08:00
|
|
|
|
documentTitle: string | null;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
permission: "read" | "edit";
|
|
|
|
|
|
includeDescendants: boolean;
|
|
|
|
|
|
createdBy: string;
|
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
outgoing: Array<{
|
2026-01-24 12:32:51 +08:00
|
|
|
|
workspaceId: string;
|
|
|
|
|
|
workspaceName: string | null;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
documentId: string;
|
2026-01-24 12:32:51 +08:00
|
|
|
|
documentTitle: string | null;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
includeDescendants: boolean;
|
|
|
|
|
|
sharedWithCount: number;
|
2026-01-24 12:32:51 +08:00
|
|
|
|
updatedAt: string;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
}>;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
const [shareSummaryError, setShareSummaryError] = useState<string | null>(null);
|
|
|
|
|
|
const [groupPublicSummary, setGroupPublicSummary] = useState<
|
|
|
|
|
|
Array<{
|
|
|
|
|
|
groupId: string;
|
|
|
|
|
|
groupName: string | null;
|
|
|
|
|
|
documents: Array<{ documentId: string; includeDescendants: boolean }>;
|
|
|
|
|
|
}>
|
|
|
|
|
|
>([]);
|
|
|
|
|
|
const [groupPublicError, setGroupPublicError] = useState<string | null>(null);
|
|
|
|
|
|
const [openPublicGroups, setOpenPublicGroups] = useState<Set<string>>(() => 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);
|
2026-01-21 18:21:10 +08:00
|
|
|
|
const [signingOut, setSigningOut] = useState(false);
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const [trashOpen, setTrashOpen] = useState(false);
|
|
|
|
|
|
const [trashSearch, setTrashSearch] = useState("");
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const [trashTab, setTrashTab] = useState<"documents" | "assets">("documents");
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const [emptyingTrash, setEmptyingTrash] = useState(false);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
|
|
|
|
|
|
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
|
|
|
|
|
|
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
|
|
|
|
|
null,
|
|
|
|
|
|
);
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
|
|
|
|
|
|
selectedRowIds: new Set(),
|
|
|
|
|
|
anchorRowId: null,
|
|
|
|
|
|
focusedRowId: null,
|
|
|
|
|
|
}));
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setTree(() => {
|
|
|
|
|
|
const nextTree = buildDocumentTree(sidebarData.documents);
|
|
|
|
|
|
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
|
|
|
|
|
|
return nextTree;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [sidebarData.documents]);
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setMediaAssets(sidebarData.mediaAssets ?? []);
|
|
|
|
|
|
}, [sidebarData.mediaAssets]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
setMindmapAssets(sidebarData.mindmapAssets ?? []);
|
|
|
|
|
|
}, [sidebarData.mindmapAssets]);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setTableAssets(sidebarData.tableAssets ?? []);
|
|
|
|
|
|
}, [sidebarData.tableAssets]);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
}, [activeId, setOpen]);
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onSaved = () => void sidebarQuery.refetch();
|
|
|
|
|
|
const onDeleted = () => 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]);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const refreshTree = useCallback(async () => {
|
|
|
|
|
|
await sidebarQuery.refetch();
|
|
|
|
|
|
}, [sidebarQuery]);
|
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
|
const refreshShareSummary = useCallback(async () => {
|
|
|
|
|
|
try {
|
2026-01-24 12:32:51 +08:00
|
|
|
|
const resp = await convex.query(api.documentShares.listMyShareRoots, {});
|
2026-01-22 18:53:20 +08:00
|
|
|
|
setShareSummary(resp as any);
|
|
|
|
|
|
setShareSummaryError(null);
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
const msg = e?.message ?? "加载共享摘要失败";
|
2026-01-24 12:32:51 +08:00
|
|
|
|
if (String(msg).includes("Could not find public function for 'documentShares:listMyShareRoots'")) {
|
|
|
|
|
|
setShareSummaryError(
|
|
|
|
|
|
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。",
|
|
|
|
|
|
);
|
2026-01-22 18:53:20 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
setShareSummaryError(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
setShareSummary(null);
|
|
|
|
|
|
}
|
2026-01-24 12:32:51 +08:00
|
|
|
|
}, [convex]);
|
2026-01-22 18:53:20 +08:00
|
|
|
|
|
|
|
|
|
|
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.local` 或 `npx convex deploy --env-file .env.local`。");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setGroupPublicError(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
setGroupPublicSummary([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [convex, sidebarData.activeWorkspaceId]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void refreshGroupPublicSummary();
|
|
|
|
|
|
}, [refreshGroupPublicSummary]);
|
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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]);
|
2026-01-22 18:53:20 +08:00
|
|
|
|
|
|
|
|
|
|
const nodeById = useMemo(() => {
|
|
|
|
|
|
const map = new Map<string, DocumentNode>();
|
|
|
|
|
|
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<string, DocumentNode[]>();
|
|
|
|
|
|
for (const g of groupPublicSummary) {
|
|
|
|
|
|
const nodes: DocumentNode[] = [];
|
|
|
|
|
|
const uniq = new Map<string, DocumentNode>();
|
|
|
|
|
|
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]);
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const filteredTrashedMediaAssets = useMemo(() => {
|
|
|
|
|
|
const assets = [
|
|
|
|
|
|
...(sidebarData.trashedMediaAssets ?? []),
|
|
|
|
|
|
...(sidebarData.trashedMindmapAssets ?? []),
|
|
|
|
|
|
];
|
|
|
|
|
|
const keyword = trashSearch.trim().toLowerCase();
|
|
|
|
|
|
if (!keyword) {
|
|
|
|
|
|
return assets;
|
|
|
|
|
|
}
|
|
|
|
|
|
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
|
|
|
|
|
|
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]);
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const mindmapChildrenSnapshot = useMemo(() => {
|
|
|
|
|
|
const mapping = sidebarData.mindmapAssetChildren ?? {};
|
|
|
|
|
|
const mindmapDocById = new Map<string, string>(
|
|
|
|
|
|
(mindmapAssets ?? [])
|
|
|
|
|
|
.filter((asset) => asset.asset_type === "mindmap")
|
|
|
|
|
|
.map((asset) => [asset.id, asset.document_id]),
|
|
|
|
|
|
);
|
|
|
|
|
|
const mindmapIds = new Set(mindmapDocById.keys());
|
|
|
|
|
|
|
|
|
|
|
|
const mediaById = new Map<string, MediaAsset>(
|
|
|
|
|
|
(mediaAssets ?? []).map((asset) => [asset.id, asset]),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const childAssetsByMindmapId: Record<string, MediaAsset[]> = {};
|
|
|
|
|
|
const childIds = new Set<string>();
|
|
|
|
|
|
const assigned = new Set<string>();
|
|
|
|
|
|
|
|
|
|
|
|
// 物理目录:storage_path 归属到 mindmaps/<mindmapId>/ 的附件,作为导图文件夹内容
|
|
|
|
|
|
(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]);
|
|
|
|
|
|
|
2025-12-27 20:23:35 +08:00
|
|
|
|
const assetsByDoc = useMemo(() => {
|
|
|
|
|
|
const map: Record<string, MediaAsset[]> = {};
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const assets = [
|
|
|
|
|
|
...((mediaAssets ?? []).filter(
|
|
|
|
|
|
(asset) => !mindmapChildrenSnapshot.childIds.has(asset.id),
|
|
|
|
|
|
)),
|
|
|
|
|
|
...(mindmapAssets ?? []),
|
|
|
|
|
|
...(tableAssets ?? []),
|
|
|
|
|
|
];
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2025-12-27 20:23:35 +08:00
|
|
|
|
assets.forEach((asset) => {
|
|
|
|
|
|
if (!map[asset.document_id]) {
|
|
|
|
|
|
map[asset.document_id] = [];
|
|
|
|
|
|
}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-12-27 20:23:35 +08:00
|
|
|
|
});
|
|
|
|
|
|
return map;
|
2026-01-11 12:35:53 +08:00
|
|
|
|
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
|
|
|
|
|
|
|
|
|
|
|
|
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const fileTreeRows = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
buildVisibleRows({
|
|
|
|
|
|
nodes: filteredPrivateTree,
|
|
|
|
|
|
expanded,
|
|
|
|
|
|
assetsByDoc,
|
2026-01-11 12:35:53 +08:00
|
|
|
|
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
|
|
|
|
|
expandedAssetFolderIds: expandedAssetFolders,
|
2026-01-07 18:38:56 +08:00
|
|
|
|
}),
|
2026-01-11 12:35:53 +08:00
|
|
|
|
[assetsByDoc, expanded, expandedAssetFolders, filteredPrivateTree, mindmapChildrenSnapshot.childAssetsByMindmapId],
|
2026-01-07 18:38:56 +08:00
|
|
|
|
);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
|
|
|
|
|
|
const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]);
|
|
|
|
|
|
|
2026-01-24 13:14:24 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setFileTreeSelection((prev) => normalizeFileTreeSelectionForVisibleRows(prev, fileTreeVisibleRowIds));
|
|
|
|
|
|
}, [fileTreeVisibleRowIds]);
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
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<string | null, number>();
|
|
|
|
|
|
(sidebarData.documents ?? []).forEach((doc) => {
|
|
|
|
|
|
const parentId = doc.parent_id ?? null;
|
|
|
|
|
|
map.set(parentId, (map.get(parentId) ?? 0) + 1);
|
|
|
|
|
|
});
|
2026-01-02 07:25:50 +08:00
|
|
|
|
return map;
|
2026-01-07 18:38:56 +08:00
|
|
|
|
}, [sidebarData.documents]);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
|
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => {
|
|
|
|
|
|
setMoveEmbedSource(node);
|
|
|
|
|
|
setMoveEmbedMode(nextMode);
|
|
|
|
|
|
setMoveEmbedOpen(true);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
|
2026-01-17 10:12:53 +08:00
|
|
|
|
openMoveEmbedPicker(node, "embed");
|
2026-01-18 19:01:31 +08:00
|
|
|
|
}, [openMoveEmbedPicker]);
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
const knownMindmapFolderIdsRef = useRef<Set<string>>(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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-12-27 20:23:35 +08:00
|
|
|
|
const handleOpenAsset = useCallback((asset: MediaAsset) => {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (asset.asset_type === "mindmap") {
|
2026-01-10 10:35:21 +08:00
|
|
|
|
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)}`);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
setOpen(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-20 07:24:12 +08:00
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
|
|
|
|
|
|
if (officeFileType) {
|
|
|
|
|
|
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
|
|
|
|
|
if (!officeBase) {
|
|
|
|
|
|
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 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);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
target.searchParams.set("documentId", asset.document_id);
|
|
|
|
|
|
target.searchParams.set("mode", "edit");
|
2026-01-15 20:54:21 +08:00
|
|
|
|
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
window.alert((error as Error).message);
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-20 07:24:12 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
2026-01-10 10:35:21 +08:00
|
|
|
|
}, [activeId, editorBridge, router, setOpen]);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-01-10 10:35:21 +08:00
|
|
|
|
|
|
|
|
|
|
// 与 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");
|
2026-01-02 07:25:50 +08:00
|
|
|
|
},
|
2026-01-10 10:35:21 +08:00
|
|
|
|
[activeId, fileTreeVisibleRowIds, handleOpenDocument],
|
2026-01-02 07:25:50 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
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) => {
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (row.kind === "asset" || row.kind === "asset-folder") {
|
2026-01-07 18:38:56 +08:00
|
|
|
|
handleOpenAsset(row.asset);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-07 18:38:56 +08:00
|
|
|
|
handleOpenDocument(row.docId, "main");
|
2026-01-02 07:25:50 +08:00
|
|
|
|
},
|
2026-01-07 18:38:56 +08:00
|
|
|
|
[handleOpenAsset, handleOpenDocument],
|
2026-01-02 07:25:50 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const handleFileTreeRowContextMenu = useCallback(
|
|
|
|
|
|
(row: FileTreeRow, event: React.MouseEvent) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
setFileTreeSelection((prev) =>
|
|
|
|
|
|
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
|
|
|
|
|
|
);
|
2025-12-27 20:23:35 +08:00
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (row.kind === "asset" || row.kind === "asset-folder") {
|
|
|
|
|
|
setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY });
|
2026-01-07 18:38:56 +08:00
|
|
|
|
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
|
2026-01-08 06:28:14 +08:00
|
|
|
|
.map((rowId) => fileTreeRowById.get(rowId as any))
|
2026-01-07 18:38:56 +08:00
|
|
|
|
.filter(Boolean) as FileTreeRow[];
|
|
|
|
|
|
|
|
|
|
|
|
const docItemsMap = new Map<string, boolean>();
|
|
|
|
|
|
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<FileTreeRow, { kind: "asset" }>[];
|
|
|
|
|
|
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,
|
|
|
|
|
|
]);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
|
|
|
|
|
const handleCopyAssetLink = useCallback(
|
|
|
|
|
|
async (asset: MediaAsset) => {
|
|
|
|
|
|
if (asset.asset_type === "mindmap") {
|
2026-01-10 10:35:21 +08:00
|
|
|
|
await copyText(buildMindmapUrl(asset.document_id, asset.id), "思维导图链接已复制");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (asset.asset_type === "luckysheet") {
|
|
|
|
|
|
await copyText(buildTableUrl(asset.id), "表格链接已复制");
|
2026-01-02 07:25:50 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const url = asset.signed_url ?? asset.file_url ?? "";
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
|
window.alert("暂无可用的文件链接");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await copyText(url, "附件链接已复制");
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const path =
|
|
|
|
|
|
asset.asset_type === "mindmap"
|
2026-01-08 06:28:14 +08:00
|
|
|
|
? ((asset.file_url ? asset.file_url.replace(/^\//, "") : "") ||
|
|
|
|
|
|
`documents/${asset.document_id}/${asset.file_name ?? "mindmap.json"}`)
|
2026-01-10 10:35:21 +08:00
|
|
|
|
: asset.asset_type === "luckysheet"
|
|
|
|
|
|
? (`tables/${asset.id}`)
|
|
|
|
|
|
: asset.storage_path || asset.file_url || asset.file_name || "附件";
|
2026-01-02 07:25:50 +08:00
|
|
|
|
await copyText(path, "存储路径已复制");
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (asset.asset_type === "mindmap") {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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;
|
2026-01-08 06:28:14 +08:00
|
|
|
|
a.download = asset.file_name ?? "mindmap.json";
|
2026-01-02 07:25:50 +08:00
|
|
|
|
a.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-10 10:35:21 +08:00
|
|
|
|
if (asset.asset_type === "luckysheet") {
|
|
|
|
|
|
window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-20 07:24:12 +08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const handleRenameAsset = useCallback(
|
|
|
|
|
|
async (asset: MediaAsset) => {
|
|
|
|
|
|
if (asset.asset_type === "mindmap") {
|
|
|
|
|
|
window.alert("思维导图暂不支持重命名");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-10 10:35:21 +08:00
|
|
|
|
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 ?? "");
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-10 10:35:21 +08:00
|
|
|
|
if (asset.asset_type === "luckysheet") {
|
|
|
|
|
|
window.alert("在线表格暂不支持移动(后续可实现跨页面迁移)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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) => {
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const uniqueAssetIds = Array.from(new Set(assetIds));
|
|
|
|
|
|
const assets = uniqueAssetIds
|
2026-01-10 10:35:21 +08:00
|
|
|
|
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
|
2026-01-07 18:38:56 +08:00
|
|
|
|
.filter(Boolean) as MediaAsset[];
|
|
|
|
|
|
|
|
|
|
|
|
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
|
|
|
|
|
|
assets.unshift(assetHint);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
|
|
|
|
|
|
const fileAssetsToDelete = assets.filter(
|
|
|
|
|
|
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
|
|
|
|
|
|
);
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const mindmapIdsByDocId = new Map<string, string[]>();
|
|
|
|
|
|
mindmapAssetsToDelete.forEach((item) => {
|
|
|
|
|
|
const prev = mindmapIdsByDocId.get(item.document_id) ?? [];
|
|
|
|
|
|
prev.push(item.id);
|
|
|
|
|
|
mindmapIdsByDocId.set(item.document_id, prev);
|
|
|
|
|
|
});
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const fileAssetIdsByDocId = new Map<string, string[]>();
|
2026-01-10 10:35:21 +08:00
|
|
|
|
fileAssetsToDelete.forEach((item) => {
|
|
|
|
|
|
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
|
|
|
|
|
|
prev.push(item.id);
|
|
|
|
|
|
fileAssetIdsByDocId.set(item.document_id, prev);
|
|
|
|
|
|
});
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();
|
|
|
|
|
|
|
2026-01-08 06:28:14 +08:00
|
|
|
|
for (const asset of mindmapAssetsToDelete) {
|
|
|
|
|
|
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`, { method: "DELETE" });
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
|
const payload = await resp.json().catch(() => ({}));
|
|
|
|
|
|
window.alert(payload?.error ?? "删除思维导图失败");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-07 18:38:56 +08:00
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
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 } }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
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) {
|
2026-01-10 10:35:21 +08:00
|
|
|
|
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)));
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
setAssetMenu(null);
|
2026-01-08 06:28:14 +08:00
|
|
|
|
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
|
2026-01-07 18:38:56 +08:00
|
|
|
|
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
|
2026-01-10 10:35:21 +08:00
|
|
|
|
await sidebarQuery.refetch();
|
2026-01-02 07:25:50 +08:00
|
|
|
|
},
|
2026-01-10 10:35:21 +08:00
|
|
|
|
[mediaAssets, mindmapAssets, sidebarQuery, tableAssets],
|
2026-01-02 07:25:50 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
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} 个页面(删除到垃圾桶)` : "";
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const selectedAssets = assetIds
|
|
|
|
|
|
.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 mindmapCount = selectedAssets.filter((item) => item.asset_type === "mindmap").length;
|
|
|
|
|
|
const tableCount = selectedAssets.filter((item) => item.asset_type === "luckysheet").length;
|
|
|
|
|
|
const fileCount = selectedAssets.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} 个思维导图(删除)`);
|
|
|
|
|
|
if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格(删除)`);
|
|
|
|
|
|
if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象(删除)`);
|
|
|
|
|
|
const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : "";
|
2026-01-07 18:38:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-01-10 10:35:21 +08:00
|
|
|
|
mediaAssets,
|
|
|
|
|
|
mindmapAssets,
|
|
|
|
|
|
tableAssets,
|
2026-01-07 18:38:56 +08:00
|
|
|
|
refreshTree,
|
|
|
|
|
|
router,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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) => {
|
2026-01-24 12:32:51 +08:00
|
|
|
|
const creatingKey = parentId ?? "__root__";
|
|
|
|
|
|
if (creatingDocumentUnderParentRef.current.has(creatingKey)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
creatingDocumentUnderParentRef.current.add(creatingKey);
|
|
|
|
|
|
try {
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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}`);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
|
|
|
|
|
}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
},
|
|
|
|
|
|
[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, 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],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const handleFileTreeDropFiles = useCallback(
|
2026-01-11 12:35:53 +08:00
|
|
|
|
(docId: string, files: FileList, targetRow?: FileTreeRow) => {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
void (async () => {
|
|
|
|
|
|
const droppedFiles = Array.from(files ?? []);
|
|
|
|
|
|
if (droppedFiles.length === 0) return;
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 06:28:14 +08:00
|
|
|
|
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);
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (targetMindmapId) {
|
|
|
|
|
|
form.append("mindmapId", targetMindmapId);
|
|
|
|
|
|
}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
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);
|
2026-01-08 18:17:07 +08:00
|
|
|
|
// 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (inferredTargetDocId === activeId && !targetMindmapId) {
|
2026-01-08 18:17:07 +08:00
|
|
|
|
editorBridge?.insertMediaAsset?.(payload.asset);
|
|
|
|
|
|
}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
} 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,
|
2026-01-08 18:17:07 +08:00
|
|
|
|
editorBridge,
|
2026-01-08 06:28:14 +08:00
|
|
|
|
fileTreeRowById,
|
|
|
|
|
|
fileTreeSelection.focusedRowId,
|
|
|
|
|
|
sidebarData.activeWorkspaceId,
|
|
|
|
|
|
sidebarData.documents,
|
|
|
|
|
|
sidebarQuery,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const handleFileTreeInternalDrop = useCallback(
|
2026-01-08 06:28:14 +08:00
|
|
|
|
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
|
2026-01-07 18:38:56 +08:00
|
|
|
|
void (async () => {
|
|
|
|
|
|
const targetDocId = inferDropTargetDocId(args.targetRow);
|
|
|
|
|
|
if (!targetDocId) {
|
|
|
|
|
|
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
const uniqueRowIds: string[] = [];
|
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
args.rowIds.forEach((id) => {
|
|
|
|
|
|
if (!id || seen.has(id)) return;
|
|
|
|
|
|
seen.add(id);
|
|
|
|
|
|
uniqueRowIds.push(id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const rows = uniqueRowIds
|
2026-01-08 06:28:14 +08:00
|
|
|
|
.map((rowId) => fileTreeRowById.get(rowId as any))
|
2026-01-07 18:38:56 +08:00
|
|
|
|
.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<FileTreeRow, { kind: "asset" }>[];
|
|
|
|
|
|
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,
|
2026-01-11 12:35:53 +08:00
|
|
|
|
targetSubPath,
|
2026-01-07 18:38:56 +08:00
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
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,
|
2026-01-11 12:35:53 +08:00
|
|
|
|
targetSubPath,
|
2026-01-07 18:38:56 +08:00
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
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,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const handleMovePrompt = useCallback(
|
|
|
|
|
|
async (node: DocumentNode) => {
|
2026-01-17 10:12:53 +08:00
|
|
|
|
openMoveEmbedPicker(node, "move");
|
2025-11-23 10:55:04 +08:00
|
|
|
|
},
|
2026-01-18 19:01:31 +08:00
|
|
|
|
[openMoveEmbedPicker],
|
2025-11-23 10:55:04 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = useCallback(
|
|
|
|
|
|
async (documentId: string) => {
|
|
|
|
|
|
await fetch("/api/documents/delete", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ documentId }),
|
|
|
|
|
|
});
|
|
|
|
|
|
await refreshTree();
|
2026-01-02 07:25:50 +08:00
|
|
|
|
emitDocumentsChanged(documentId);
|
|
|
|
|
|
if (activeId === documentId) {
|
|
|
|
|
|
router.push("/");
|
|
|
|
|
|
}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
},
|
2026-01-02 07:25:50 +08:00
|
|
|
|
[activeId, refreshTree, router],
|
2025-11-23 10:55:04 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-24 13:14:24 +08:00
|
|
|
|
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],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const handleRestoreMediaAssetFromTrash = useCallback(
|
|
|
|
|
|
async (assetId: string) => {
|
|
|
|
|
|
if (!confirmTrashAction("确认恢复该附件吗?")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
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()]);
|
|
|
|
|
|
},
|
|
|
|
|
|
[confirmTrashAction, refreshTree, 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 handleEmptyMediaTrash = useCallback(async () => {
|
|
|
|
|
|
if (!sidebarData.activeWorkspaceId) {
|
|
|
|
|
|
window.alert("暂无可清空的工作空间");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!confirmTrashAction("清空附件垃圾桶后将无法恢复,是否继续?")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setEmptyingTrash(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [mediaResp, mindmapResp] = 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 }),
|
|
|
|
|
|
}),
|
|
|
|
|
|
]);
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
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()]);
|
|
|
|
|
|
},
|
|
|
|
|
|
[confirmTrashAction, 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],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-21 18:21:10 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
setContextMenu({
|
|
|
|
|
|
node,
|
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
|
y: event.clientY,
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
const handleTopButtonClick = useCallback(
|
|
|
|
|
|
(buttonId: (typeof TOP_BUTTONS)[number]["id"]) => {
|
|
|
|
|
|
if (buttonId === "search") {
|
|
|
|
|
|
openSearchPalette();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (
|
|
|
|
|
|
buttonId === "starred" ||
|
|
|
|
|
|
buttonId === "public" ||
|
|
|
|
|
|
buttonId === "shared" ||
|
|
|
|
|
|
buttonId === "templates"
|
|
|
|
|
|
) {
|
|
|
|
|
|
setViewMode("section");
|
2026-01-22 18:53:20 +08:00
|
|
|
|
setTopPanel((prev) => (prev === buttonId ? null : buttonId));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (buttonId === "members") {
|
|
|
|
|
|
setGroupManagerOpen(true);
|
2026-01-11 12:35:53 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
window.alert("该功能即将上线,敬请期待");
|
|
|
|
|
|
},
|
2026-01-22 18:53:20 +08:00
|
|
|
|
[
|
|
|
|
|
|
openSearchPalette,
|
|
|
|
|
|
setViewMode,
|
|
|
|
|
|
],
|
2025-11-23 10:55:04 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!contextMenu) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const closeMenu = () => setContextMenu(null);
|
|
|
|
|
|
window.addEventListener("click", closeMenu);
|
|
|
|
|
|
return () => window.removeEventListener("click", closeMenu);
|
|
|
|
|
|
}, [contextMenu]);
|
|
|
|
|
|
|
|
|
|
|
|
const sidebarBody = (
|
2026-01-08 18:17:07 +08:00
|
|
|
|
<div className="flex h-full min-w-0 flex-col">
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<div className="border-b border-[#f1f1f1] p-3">
|
|
|
|
|
|
<div className="text-xs text-gray-400">当前工作空间</div>
|
|
|
|
|
|
<div className="mt-1 flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-base font-semibold text-gray-900">{activeWorkspace?.name ?? "我的空间"}</div>
|
|
|
|
|
|
<div className="text-xs text-gray-500">{activeWorkspace?.type === "team" ? "团队空间" : "个人空间"}</div>
|
|
|
|
|
|
</div>
|
2026-01-24 12:32:51 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-400"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
window.alert("工作空间切换功能暂时停用(正在修复中)。");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
切换(暂停)
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-2 text-xs text-gray-400">
|
|
|
|
|
|
提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="flex items-center gap-2 rounded-md px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
onClick={() => void handleSignOut()}
|
|
|
|
|
|
disabled={signingOut}
|
|
|
|
|
|
>
|
|
|
|
|
|
<LogOut className="h-4 w-4" />
|
|
|
|
|
|
<span>{signingOut ? "正在退出..." : "退出登录"}</span>
|
|
|
|
|
|
</button>
|
2025-11-23 10:55:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-4 gap-2 border-b border-[#f1f1f1] p-3">
|
|
|
|
|
|
{TOP_BUTTONS.map((button) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={button.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={button.label}
|
|
|
|
|
|
aria-label={button.label}
|
|
|
|
|
|
className="flex h-12 items-center justify-center rounded-md border border-transparent text-gray-500 hover:border-[#d6e3ff] hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
|
|
|
|
|
onClick={() => handleTopButtonClick(button.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button.icon className="h-5 w-5" />
|
|
|
|
|
|
<span className="sr-only">{button.label}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
|
{topPanel ? (
|
|
|
|
|
|
<div className="border-b border-[#f1f1f1] bg-white px-3 pb-3">
|
|
|
|
|
|
<div className="flex items-center gap-2 px-1 py-2 text-sm font-medium text-gray-600">
|
|
|
|
|
|
{SECTION_ICONS[topPanel]}
|
|
|
|
|
|
{topPanel === "starred"
|
|
|
|
|
|
? "星标置顶"
|
|
|
|
|
|
: topPanel === "public"
|
|
|
|
|
|
? "公共页面"
|
|
|
|
|
|
: topPanel === "shared"
|
|
|
|
|
|
? "共享页面"
|
|
|
|
|
|
: "模板中心"}
|
|
|
|
|
|
<span className="ml-auto text-xs text-gray-400">再次点击图标可收起</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const renderList = (nodes: DocumentNode[]) => {
|
|
|
|
|
|
const flat = flattenDocumentTree(nodes, collectNodeIds(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 }) => (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
key={node.id}
|
|
|
|
|
|
href={`/documents/${node.id}`}
|
|
|
|
|
|
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 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{node.title || "无标题"}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (topPanel === "shared") {
|
2026-01-24 12:32:51 +08:00
|
|
|
|
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<string, { workspaceName: string | null; rows: typeof rows }>();
|
|
|
|
|
|
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<any>) => {
|
|
|
|
|
|
if (!rows || rows.length === 0) {
|
|
|
|
|
|
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
const groups = groupByWorkspace(rows);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{groups.map((g) => (
|
|
|
|
|
|
<div key={g.workspaceId}>
|
|
|
|
|
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
|
|
|
|
|
{g.workspaceName ?? g.workspaceId}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{g.rows.map((r) => (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
key={`${r.workspaceId}:${r.documentId}`}
|
|
|
|
|
|
href={`/documents/${r.documentId}`}
|
|
|
|
|
|
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
|
|
|
|
|
>
|
|
|
|
|
|
{r.documentTitle || "无标题"}
|
|
|
|
|
|
{typeof r.permission === "string" ? (
|
|
|
|
|
|
<span className="ml-2 text-xs text-gray-400">
|
|
|
|
|
|
{r.permission === "edit" ? "可编辑" : "只读"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{typeof r.sharedWithCount === "number" ? (
|
|
|
|
|
|
<span className="ml-2 text-xs text-gray-400">{r.sharedWithCount} 人</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{shareSummaryError ? (
|
|
|
|
|
|
<div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{shareSummaryError}</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
|
|
|
|
|
共享给我的({shareSummary?.incoming?.length ?? 0})
|
|
|
|
|
|
</div>
|
2026-01-24 12:32:51 +08:00
|
|
|
|
{renderShareRows(shareSummary?.incoming ?? [])}
|
2026-01-22 18:53:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="border-t border-[#f1f1f1] pt-2">
|
|
|
|
|
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
|
|
|
|
|
我共享出去的({shareSummary?.outgoing?.length ?? 0})
|
|
|
|
|
|
</div>
|
2026-01-24 12:32:51 +08:00
|
|
|
|
{renderShareRows(shareSummary?.outgoing ?? [])}
|
2026-01-22 18:53:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{groupPublicError ? (
|
|
|
|
|
|
<div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{groupPublicError}</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">全员公开</div>
|
|
|
|
|
|
{renderList(publicNodes)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="border-t border-[#f1f1f1] pt-2">
|
|
|
|
|
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">群组公开</div>
|
|
|
|
|
|
{groupPublicSummary.length === 0 ? (
|
|
|
|
|
|
<div className="px-2 py-2 text-xs text-gray-400">暂无群组公开页面</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{groupPublicSummary.map((g) => {
|
|
|
|
|
|
const isOpen = openPublicGroups.has(g.groupId);
|
|
|
|
|
|
const nodes = publicGroupNodesByGroupId.get(g.groupId) ?? [];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={g.groupId} className="rounded-md border border-[#eff2f6]">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="flex w-full items-center justify-between px-2 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
|
|
|
|
|
onClick={() => toggleGroup(g.groupId)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
|
|
|
|
<ChevronRight
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"h-4 w-4 text-gray-400 transition-transform",
|
|
|
|
|
|
isOpen && "rotate-90",
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="truncate">{g.groupName ?? "未命名群组"}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs text-gray-400">{g.documents.length}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{isOpen ? <div className="px-1 pb-2">{renderList(nodes)}</div> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nodes = topPanel === "starred" ? starredNodes : templateNodes;
|
|
|
|
|
|
return renderList(nodes);
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-01-08 18:17:07 +08:00
|
|
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
|
|
|
|
<div className="flex h-full min-w-0 flex-col overflow-y-auto overflow-x-hidden">
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<div className="border-b border-[#f1f1f1] p-3">
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
|
|
|
|
<Library className="h-4 w-4" />
|
|
|
|
|
|
页面树
|
|
|
|
|
|
</div>
|
2025-12-27 20:23:35 +08:00
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
className="h-8 flex-1 rounded-md border-[#eeeeee]"
|
|
|
|
|
|
placeholder="搜索页面"
|
|
|
|
|
|
value={filter}
|
|
|
|
|
|
onChange={(event) => setFilter(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex rounded-md border border-[#e5e7eb] bg-white">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"px-3 py-1 text-xs",
|
|
|
|
|
|
viewMode === "section" ? "bg-[#2563eb] text-white" : "text-gray-600",
|
|
|
|
|
|
)}
|
|
|
|
|
|
onClick={() => setViewMode("section")}
|
|
|
|
|
|
>
|
|
|
|
|
|
分组
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"px-3 py-1 text-xs",
|
|
|
|
|
|
viewMode === "filesystem" ? "bg-[#2563eb] text-white" : "text-gray-600",
|
|
|
|
|
|
)}
|
|
|
|
|
|
onClick={() => setViewMode("filesystem")}
|
|
|
|
|
|
>
|
|
|
|
|
|
文件
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-23 10:55:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-27 20:23:35 +08:00
|
|
|
|
{viewMode === "section" ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
|
|
|
|
|
|
onClick={() => toggleSection("private")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>私有 / 我的页面</span>
|
|
|
|
|
|
<MoreHorizontal className="h-4 w-4 text-gray-400" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{!collapsedSections.private ? (
|
|
|
|
|
|
<div className="flex-1 px-1 pb-2">
|
|
|
|
|
|
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
|
|
|
|
|
<PrivateTree
|
|
|
|
|
|
nodes={filteredPrivateTree}
|
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
|
activeId={activeId}
|
|
|
|
|
|
onToggleExpand={toggleExpand}
|
|
|
|
|
|
onMove={handleMove}
|
|
|
|
|
|
onCreateChild={handleCreate}
|
|
|
|
|
|
onContextMenu={openContextMenu}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="px-4 pb-2 text-xs text-gray-400">已折叠</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<div className="flex-1 px-1 pb-2">
|
2026-01-08 06:28:14 +08:00
|
|
|
|
<div
|
|
|
|
|
|
ref={fileTreeContainerRef}
|
|
|
|
|
|
data-testid="file-tree-container"
|
2026-01-08 18:17:07 +08:00
|
|
|
|
className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white"
|
2026-01-08 06:28:14 +08:00
|
|
|
|
>
|
2025-12-27 20:23:35 +08:00
|
|
|
|
<FileTree
|
2026-01-07 18:38:56 +08:00
|
|
|
|
rows={fileTreeRows}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
activeId={activeId}
|
2026-01-07 18:38:56 +08:00
|
|
|
|
selectedRowIds={fileTreeSelection.selectedRowIds}
|
|
|
|
|
|
onRowClick={handleFileTreeRowClick}
|
|
|
|
|
|
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
onRowContextMenu={handleFileTreeRowContextMenu}
|
2026-01-07 18:38:56 +08:00
|
|
|
|
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
onToggleExpand={toggleExpand}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
onToggleAssetFolderExpand={toggleAssetFolderExpand}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
onCreateChild={handleCreate}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
|
|
|
|
|
onDropFiles={handleFileTreeDropFiles}
|
2026-01-07 18:38:56 +08:00
|
|
|
|
onInternalDrop={handleFileTreeInternalDrop}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-27 20:23:35 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
|
|
|
|
|
<div className="border-t border-[#f1f1f1] p-3">
|
|
|
|
|
|
<Button className="w-full justify-center gap-2" variant="outline" onClick={() => handleCreate(null)}>
|
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
新建页面
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="mt-3 flex w-full items-center justify-between rounded-md border border-[#e8e8e8] px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
|
|
|
|
|
onClick={() => setTrashOpen(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
|
<Trash2 className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
垃圾桶
|
|
|
|
|
|
</span>
|
2026-01-10 10:35:21 +08:00
|
|
|
|
<span className="text-xs text-gray-400">
|
|
|
|
|
|
{sidebarData.trashedDocuments.length +
|
|
|
|
|
|
(sidebarData.trashedMediaAssets?.length ?? 0) +
|
|
|
|
|
|
(sidebarData.trashedMindmapAssets?.length ?? 0)}{" "}
|
|
|
|
|
|
条
|
|
|
|
|
|
</span>
|
2025-11-23 10:55:04 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-01-18 19:01:31 +08:00
|
|
|
|
<aside className="hidden h-full min-w-0 overflow-hidden md:flex shrink-0" style={{ width }}>
|
|
|
|
|
|
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar">
|
2026-01-08 18:17:07 +08:00
|
|
|
|
{sidebarBody}
|
|
|
|
|
|
</div>
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<div className="w-1 cursor-col-resize bg-transparent" onMouseDown={handleResizeStart} />
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
<Drawer open={open} onOpenChange={setOpen}>
|
|
|
|
|
|
<DrawerContent className="max-h-[92vh]">
|
|
|
|
|
|
<DrawerHeader className="text-left">
|
|
|
|
|
|
<DrawerTitle>页面目录</DrawerTitle>
|
|
|
|
|
|
</DrawerHeader>
|
|
|
|
|
|
<div className="px-4 pb-6">{sidebarBody}</div>
|
|
|
|
|
|
</DrawerContent>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
{contextMenu && (
|
|
|
|
|
|
<ContextMenu
|
|
|
|
|
|
contextMenu={contextMenu}
|
|
|
|
|
|
onClose={() => setContextMenu(null)}
|
|
|
|
|
|
onOpenRight={(node) => handleOpenDocument(node.id, "sidebar")}
|
2026-01-22 18:53:20 +08:00
|
|
|
|
onShare={openShareDialog}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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)}
|
2026-01-24 13:14:24 +08:00
|
|
|
|
onDelete={() => void handleDeleteFromContextMenuNode(contextMenu.node)}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-01-22 18:53:20 +08:00
|
|
|
|
{shareTarget && (
|
|
|
|
|
|
<DocumentShareDialog
|
|
|
|
|
|
open={shareDialogOpen}
|
|
|
|
|
|
onOpenChange={(nextOpen) => {
|
|
|
|
|
|
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();
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-01-24 12:32:51 +08:00
|
|
|
|
<GroupManagerDialog
|
|
|
|
|
|
open={groupManagerOpen}
|
|
|
|
|
|
onOpenChange={setGroupManagerOpen}
|
|
|
|
|
|
workspaceId={sidebarData.activeWorkspaceId || ""}
|
|
|
|
|
|
/>
|
2026-01-17 10:12:53 +08:00
|
|
|
|
<MoveEmbedPickerDialog
|
|
|
|
|
|
open={moveEmbedOpen}
|
|
|
|
|
|
onOpenChange={setMoveEmbedOpen}
|
|
|
|
|
|
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
|
|
|
|
|
defaultMode={moveEmbedMode}
|
|
|
|
|
|
excludeIds={moveEmbedSource?.id ? [moveEmbedSource.id] : []}
|
|
|
|
|
|
onPick={async (pickedMode, targetId) => {
|
|
|
|
|
|
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("已在目标页面末尾插入引用块");
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-01-02 07:25:50 +08:00
|
|
|
|
{assetMenu && (
|
|
|
|
|
|
<AssetContextMenu
|
|
|
|
|
|
asset={assetMenu.asset}
|
|
|
|
|
|
position={{ x: assetMenu.x, y: assetMenu.y }}
|
|
|
|
|
|
onClose={() => setAssetMenu(null)}
|
|
|
|
|
|
onOpen={handleOpenAsset}
|
|
|
|
|
|
onCopyLink={handleCopyAssetLink}
|
|
|
|
|
|
onCopyPath={handleCopyAssetPath}
|
|
|
|
|
|
onRename={handleRenameAsset}
|
|
|
|
|
|
onMove={handleMoveAsset}
|
2026-01-24 13:14:24 +08:00
|
|
|
|
onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
onDownload={handleDownloadAsset}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
|
|
|
|
|
|
<DrawerContent className="max-h-[90vh]">
|
|
|
|
|
|
<DrawerHeader className="text-left">
|
|
|
|
|
|
<DrawerTitle>垃圾桶</DrawerTitle>
|
2026-01-10 10:35:21 +08:00
|
|
|
|
{trashTab === "documents" ? (
|
|
|
|
|
|
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
|
|
|
|
附件删除后 10 分钟内可撤销;也可在此手动清空(不可撤销)。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
</DrawerHeader>
|
|
|
|
|
|
<div className="space-y-4 px-4 pb-6">
|
2026-01-10 10:35:21 +08:00
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`rounded-md border px-3 py-1 text-sm ${
|
|
|
|
|
|
trashTab === "documents"
|
|
|
|
|
|
? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
|
|
|
|
|
|
: "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => setTrashTab("documents")}
|
|
|
|
|
|
>
|
|
|
|
|
|
页面 ({sidebarData.trashedDocuments.length})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`rounded-md border px-3 py-1 text-sm ${
|
|
|
|
|
|
trashTab === "assets"
|
|
|
|
|
|
? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
|
|
|
|
|
|
: "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => setTrashTab("assets")}
|
|
|
|
|
|
>
|
|
|
|
|
|
附件 (
|
|
|
|
|
|
{(sidebarData.trashedMediaAssets?.length ?? 0) + (sidebarData.trashedMindmapAssets?.length ?? 0)}
|
|
|
|
|
|
)
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<Input
|
2026-01-10 10:35:21 +08:00
|
|
|
|
placeholder={trashTab === "documents" ? "搜索删除的页面..." : "搜索删除的附件..."}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
value={trashSearch}
|
|
|
|
|
|
onChange={(event) => setTrashSearch(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-gray-600">
|
|
|
|
|
|
<label className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={trashConfirm}
|
|
|
|
|
|
onChange={(event) => setTrashConfirm(event.target.checked)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
操作确认
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="destructive"
|
2026-01-10 10:35:21 +08:00
|
|
|
|
onClick={() => void (trashTab === "documents" ? handleEmptyTrash() : handleEmptyMediaTrash())}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
disabled={emptyingTrash}
|
|
|
|
|
|
>
|
2026-01-10 10:35:21 +08:00
|
|
|
|
{emptyingTrash
|
|
|
|
|
|
? "清空中..."
|
|
|
|
|
|
: trashTab === "documents"
|
|
|
|
|
|
? "清空垃圾桶"
|
|
|
|
|
|
: "清空附件垃圾桶"}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-lg border border-[#eaeaea]">
|
2026-01-10 10:35:21 +08:00
|
|
|
|
{trashTab === "documents" ? (
|
|
|
|
|
|
filteredTrash.length === 0 ? (
|
|
|
|
|
|
<div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
filteredTrash.map((item) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="font-medium text-gray-800">{item.title || "无标题"}</div>
|
|
|
|
|
|
<div className="text-xs text-gray-400">
|
|
|
|
|
|
删除时间:{new Date(item.deleted_at).toLocaleString()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
|
|
|
|
|
|
onClick={() => void handleRestoreFromTrash(item.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
恢复
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
|
|
|
|
|
onClick={() => void handlePurgeFromTrash(item.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
彻底删除
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)
|
|
|
|
|
|
) : filteredTrashedMediaAssets.length === 0 ? (
|
|
|
|
|
|
<div className="p-4 text-sm text-gray-400">暂无已删除附件</div>
|
2025-11-23 10:55:04 +08:00
|
|
|
|
) : (
|
2026-01-10 10:35:21 +08:00
|
|
|
|
filteredTrashedMediaAssets.map((item) => (
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
|
|
|
|
|
|
>
|
2026-01-10 10:35:21 +08:00
|
|
|
|
<div className="min-w-0 pr-2">
|
|
|
|
|
|
<div className="truncate font-medium text-gray-800">{item.file_name || "未命名附件"}</div>
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<div className="text-xs text-gray-400">
|
2026-01-10 10:35:21 +08:00
|
|
|
|
删除时间:{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-xs text-gray-400">
|
|
|
|
|
|
类型:{item.mime_type ?? item.asset_type ?? "unknown"}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
|
2026-01-10 10:35:21 +08:00
|
|
|
|
onClick={() =>
|
|
|
|
|
|
void (item.asset_type === "mindmap"
|
|
|
|
|
|
? handleRestoreMindmapFromTrash(item.document_id, item.id)
|
|
|
|
|
|
: handleRestoreMediaAssetFromTrash(item.id))
|
|
|
|
|
|
}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
>
|
|
|
|
|
|
恢复
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
2026-01-10 10:35:21 +08:00
|
|
|
|
onClick={() =>
|
|
|
|
|
|
void (item.asset_type === "mindmap"
|
|
|
|
|
|
? handlePurgeMindmapFromTrash(item.document_id, item.id)
|
|
|
|
|
|
: handlePurgeMediaAssetFromTrash(item.id))
|
|
|
|
|
|
}
|
2025-11-23 10:55:04 +08:00
|
|
|
|
>
|
|
|
|
|
|
彻底删除
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DrawerContent>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SectionListProps {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
|
nodes: DocumentNode[];
|
|
|
|
|
|
collapsed: boolean;
|
|
|
|
|
|
onToggle: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="border-b border-[#f5f5f5]">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
|
|
|
|
|
|
onClick={onToggle}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
|
{icon}
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs text-gray-400">{nodes.length}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
|
<div className="space-y-1 px-4 pb-3">
|
|
|
|
|
|
{nodes.length === 0 ? (
|
|
|
|
|
|
<div className="text-xs text-gray-400">暂无内容</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
nodes.map((node) => (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
key={node.id}
|
|
|
|
|
|
href={`/documents/${node.id}`}
|
|
|
|
|
|
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
|
|
|
|
|
>
|
|
|
|
|
|
{node.title || "无标题"}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ContextMenuProps {
|
|
|
|
|
|
contextMenu: ContextMenuState;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onOpenRight: (node: DocumentNode) => void;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
onShare: (node: DocumentNode) => void;
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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,
|
2026-01-22 18:53:20 +08:00
|
|
|
|
onShare,
|
2025-11-23 10:55:04 +08:00
|
|
|
|
onMove,
|
|
|
|
|
|
onEmbed,
|
|
|
|
|
|
onCopyLink,
|
|
|
|
|
|
onCopyReference,
|
|
|
|
|
|
onCopyId,
|
|
|
|
|
|
onDuplicate,
|
|
|
|
|
|
onRename,
|
|
|
|
|
|
onCreateChild,
|
|
|
|
|
|
onConvertChild,
|
|
|
|
|
|
onDelete,
|
|
|
|
|
|
}: ContextMenuProps) {
|
|
|
|
|
|
const { node } = contextMenu;
|
|
|
|
|
|
const menuRef = useRef<HTMLDivElement>(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 (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={menuRef}
|
|
|
|
|
|
className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
|
|
|
|
|
|
style={{ top: position.top, left: position.left, minWidth: 220 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={buttonClass}
|
|
|
|
|
|
onClick={() => handleAction(() => onOpenRight(node))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<PanelRightOpen className="h-4 w-4 text-[#2563eb]" />
|
|
|
|
|
|
<span>在右侧边栏打开</span>
|
|
|
|
|
|
<span className="ml-auto text-[11px] text-gray-400">Alt + O</span>
|
|
|
|
|
|
</button>
|
2026-01-22 18:53:20 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={buttonClass}
|
|
|
|
|
|
onClick={() => handleAction(() => onShare(node))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Share2 className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>共享...</span>
|
|
|
|
|
|
</button>
|
2025-11-23 10:55:04 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={buttonClass}
|
|
|
|
|
|
onClick={() => handleAction(() => onMove(node))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowRightLeft className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>移动到...</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={buttonClass}
|
|
|
|
|
|
onClick={() => handleAction(() => onEmbed(node))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<GitMerge className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>嵌入到...</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<button type="button" className={`${buttonClass} pr-6`} onClick={() => handleAction(() => onCopyLink(node))}>
|
|
|
|
|
|
<LinkIcon className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>复制访问链接</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`${buttonClass} pr-6`}
|
|
|
|
|
|
onClick={() => handleAction(() => onCopyLink(node, true))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<LinkIcon className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>复制访问链接(带标题)</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="relative group">
|
|
|
|
|
|
<button type="button" className={`${buttonClass} pr-6`}>
|
|
|
|
|
|
<Copy className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>复制页面引用链接</span>
|
|
|
|
|
|
<ChevronRight className="ml-auto h-3.5 w-3.5 text-gray-400" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="invisible absolute left-full top-0 z-50 ml-1 min-w-[180px] rounded-md border border-[#e5e5e5] bg-white py-1 shadow-xl group-hover:visible">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={buttonClass}
|
|
|
|
|
|
onClick={() => handleAction(() => onCopyReference(node, "inline"))}
|
|
|
|
|
|
>
|
|
|
|
|
|
(( 行内页面引用
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={buttonClass}
|
|
|
|
|
|
onClick={() => handleAction(() => onCopyReference(node, "embed"))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{"{{ 嵌入页面引用"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={buttonClass}
|
|
|
|
|
|
onClick={() => handleAction(() => onCopyId(node))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Hash className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>复制页面 ID</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={buttonClass}
|
|
|
|
|
|
onClick={() => handleAction(() => onDuplicate(node))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>拷贝副本</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="my-1 border-t border-[#f2f2f2]" />
|
|
|
|
|
|
<button type="button" className={buttonClass} onClick={() => handleAction(onRename)}>
|
|
|
|
|
|
<Edit3 className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>重命名</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className={buttonClass} onClick={() => handleAction(onCreateChild)}>
|
|
|
|
|
|
<Plus className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>新建子页面</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className={buttonClass} onClick={() => handleAction(onConvertChild)}>
|
|
|
|
|
|
<ArrowUpRight className="h-4 w-4 text-gray-500" />
|
|
|
|
|
|
<span>转为上一个子页面</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
|
|
|
|
|
|
onClick={() => handleAction(onDelete)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
<span>删除到垃圾桶</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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<string> = new Set()): Set<string> {
|
|
|
|
|
|
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}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
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`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|