双向删除同步

This commit is contained in:
liaibo
2026-01-02 07:25:50 +08:00
parent 1db64c1c55
commit b1288487af
48 changed files with 2406 additions and 8841 deletions
@@ -22,6 +22,7 @@ interface FileTreeProps {
disableSelection?: boolean;
onDropFiles?: (docId: string, files: FileList) => void;
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
}
const INDENT = 16;
@@ -42,6 +43,7 @@ export function FileTree({
disableSelection = false,
onDropFiles,
onAssetContextMenu,
onAssetClick,
}: FileTreeProps) {
if (nodes.length === 0) {
return <div className="px-4 py-6 text-sm text-gray-400"></div>;
@@ -83,6 +85,7 @@ export function FileTree({
disableSelection={disableSelection}
onDropFiles={onDropFiles}
onAssetContextMenu={onAssetContextMenu}
onAssetClick={onAssetClick}
/>
))}
</div>
@@ -106,6 +109,7 @@ interface FileTreeNodeProps {
disableSelection: boolean;
onDropFiles?: (docId: string, files: FileList) => void;
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
}
function FileTreeNode({
@@ -125,6 +129,7 @@ function FileTreeNode({
disableSelection,
onDropFiles,
onAssetContextMenu,
onAssetClick,
}: FileTreeNodeProps) {
const isExpanded = expanded.has(node.id);
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
@@ -202,13 +207,23 @@ function FileTreeNode({
onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined
}
onSelectOnly={onSelectOnlyAsset ? () => onSelectOnlyAsset(asset.id) : undefined}
onClick={() => onOpenAsset(asset)}
onClick={(e) => {
if (onAssetClick) {
onAssetClick(asset, e);
} else {
onOpenAsset(asset);
}
}}
onDoubleClick={() => onOpenAsset(asset)}
stopBubble
onContextMenu={
onAssetContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
if (onAssetClick) {
onAssetClick(asset, e);
}
onAssetContextMenu(e, asset);
}
: undefined
@@ -264,17 +279,23 @@ function FileLeafRow({
onSelectOnly?: () => void;
stopBubble?: boolean;
onContextMenu?: (event: React.MouseEvent) => void;
onDoubleClick?: () => void;
}) {
return (
<div
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]",
selected && "bg-[#e8f2ff] text-[#2563eb]",
)}
style={{ paddingLeft: depth * INDENT + 32 }}
onClick={(e) => {
if (stopBubble) e.stopPropagation();
// 附件行默认只负责选中,不直接打开,避免误触下载
if (selectable) return;
onClick();
}}
onDoubleClick={(e) => {
if (stopBubble) e.stopPropagation();
onDoubleClick?.();
}}
onMouseDown={(e) => {
if (stopBubble) e.stopPropagation();
}}
@@ -44,6 +44,8 @@ import { supabaseBrowser } from "@/lib/supabase/client";
import { useSearchPaletteStore } from "@/store/search-palette";
import { FileTree } from "@/components/sidebar/file-tree";
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
@@ -93,6 +95,13 @@ export function Sidebar({ initialData }: SidebarProps) {
const [trashOpen, setTrashOpen] = useState(false);
const [trashSearch, setTrashSearch] = useState("");
const [emptyingTrash, setEmptyingTrash] = useState(false);
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
const [mindmapDocs, setMindmapDocs] = useState<string[]>(sidebarData.mindmapDocs ?? []);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
const [lastSelectedAssetId, setLastSelectedAssetId] = useState<string | null>(null);
const workspaceMenuRef = useRef<HTMLDivElement>(null);
@@ -104,10 +113,43 @@ export function Sidebar({ initialData }: SidebarProps) {
});
}, [sidebarData.documents]);
useEffect(() => {
setMediaAssets(sidebarData.mediaAssets ?? []);
}, [sidebarData.mediaAssets]);
useEffect(() => {
setMindmapDocs(sidebarData.mindmapDocs ?? []);
}, [sidebarData.mindmapDocs]);
useEffect(() => {
setSelectedAssetIds(new Set());
setLastSelectedAssetId(null);
}, [mediaAssets, sidebarData.documents]);
useEffect(() => {
setOpen(false);
}, [activeId, setOpen]);
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) {
setMediaAssets((prev) => {
if (prev.find((item) => item.id === asset.id)) return prev;
return [asset, ...prev];
});
}
void sidebarQuery.refetch();
};
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]);
const refreshTree = useCallback(async () => {
await sidebarQuery.refetch();
}, [sidebarQuery]);
@@ -128,6 +170,27 @@ export function Sidebar({ initialData }: SidebarProps) {
};
}, [refreshTree]);
useEffect(() => {
const channel = supabaseBrowser
.channel("media-assets-feed")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "media_assets",
filter: `workspace_id=eq.${sidebarData.activeWorkspaceId}`,
},
() => {
void sidebarQuery.refetch();
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [sidebarData.activeWorkspaceId, sidebarQuery]);
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]);
@@ -154,15 +217,67 @@ export function Sidebar({ initialData }: SidebarProps) {
const assetsByDoc = useMemo(() => {
const map: Record<string, MediaAsset[]> = {};
const assets = sidebarData.mediaAssets ?? [];
const assets = mediaAssets;
assets.forEach((asset) => {
if (!map[asset.document_id]) {
map[asset.document_id] = [];
}
map[asset.document_id].push(asset);
});
if (mindmapDocs.length > 0) {
mindmapDocs.forEach((docId) => {
const doc = sidebarData.documents.find((d) => d.id === docId);
const workspaceId = doc?.workspace_id ?? "";
if (!map[docId]) {
map[docId] = [];
}
// 避免重复插入
const alreadyExists = map[docId].some(
(asset) => asset.asset_type === "mindmap",
);
if (!alreadyExists) {
map[docId].push({
id: `mindmap-${docId}`,
workspace_id: workspaceId,
document_id: docId,
asset_type: "mindmap",
file_url: `/documents/${docId}`,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: "mindmap.json",
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "",
updated_at: "",
});
}
});
}
return map;
}, [sidebarData.mediaAssets]);
}, [sidebarData.documents, mediaAssets, mindmapDocs]);
const assetOrder = useMemo(() => {
const list: string[] = [];
Object.values(assetsByDoc).forEach((arr) => {
arr.forEach((asset) => list.push(asset.id));
});
return list;
}, [assetsByDoc]);
const assetIndexMap = useMemo(() => {
const map = new Map<string, number>();
assetOrder.forEach((id, idx) => map.set(id, idx));
return map;
}, [assetOrder]);
const activeWorkspace =
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
@@ -170,6 +285,8 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleOpenDocument = useCallback(
(documentId: string, mode: "main" | "sidebar") => {
setSelectedAssetIds(new Set());
setLastSelectedAssetId(null);
const targetPath = `/documents/${documentId}`;
if (mode === "main") {
router.push(targetPath);
@@ -248,6 +365,11 @@ export function Sidebar({ initialData }: SidebarProps) {
}, []);
const handleOpenAsset = useCallback((asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
router.push(`/documents/${asset.document_id}`);
setOpen(false);
return;
}
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的文件链接");
@@ -256,8 +378,213 @@ export function Sidebar({ initialData }: SidebarProps) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, [router, setOpen]);
const handleAssetContextMenu = useCallback(
(event: React.MouseEvent, asset: MediaAsset) => {
event.preventDefault();
if (!selectedAssetIds.has(asset.id)) {
setSelectedAssetIds(new Set([asset.id]));
setLastSelectedAssetId(asset.id);
}
setAssetMenu({ asset, x: event.clientX, y: event.clientY });
},
[selectedAssetIds],
);
const handleSelectAsset = useCallback(
(asset: MediaAsset, event?: { type?: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {
const evtType = event?.type ?? "";
if (evtType === "contextmenu" && selectedAssetIds.has(asset.id)) {
setLastSelectedAssetId(asset.id);
return;
}
setSelectedAssetIds((prev) => {
let next = new Set(prev);
const withShift = Boolean(event?.shiftKey) && lastSelectedAssetId && assetIndexMap.has(lastSelectedAssetId);
if (withShift) {
const start = assetIndexMap.get(lastSelectedAssetId!) ?? 0;
const end = assetIndexMap.get(asset.id) ?? start;
const [lo, hi] = start < end ? [start, end] : [end, start];
const idsInRange = assetOrder.slice(lo, hi + 1);
next = new Set([...prev, ...idsInRange]);
} else if (event?.metaKey || event?.ctrlKey) {
if (next.has(asset.id)) {
next.delete(asset.id);
} else {
next.add(asset.id);
}
} else {
next = new Set([asset.id]);
}
return next;
});
setLastSelectedAssetId(asset.id);
},
[assetIndexMap, assetOrder, lastSelectedAssetId, selectedAssetIds],
);
const toggleAssetCheckbox = useCallback((assetId: string) => {
setSelectedAssetIds((prev) => {
const next = new Set(prev);
if (next.has(assetId)) {
next.delete(assetId);
} else {
next.add(assetId);
}
return next;
});
setLastSelectedAssetId(assetId);
}, []);
const selectOnlyAsset = useCallback((assetId: string) => {
setSelectedAssetIds(new Set([assetId]));
setLastSelectedAssetId(assetId);
}, []);
const handleCopyAssetLink = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
await copyText(buildDocumentUrl(asset.document_id), "页面链接已复制");
return;
}
const url = asset.signed_url ?? asset.file_url ?? "";
if (!url) {
window.alert("暂无可用的文件链接");
return;
}
await copyText(url, "附件链接已复制");
},
[],
);
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
const path =
asset.asset_type === "mindmap"
? `documents/${asset.document_id}/mindmap.json`
: asset.storage_path || asset.file_url || asset.file_name || "附件";
await copyText(path, "存储路径已复制");
}, []);
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
const resp = await fetch(`/api/mindmap/${asset.document_id}`);
if (!resp.ok) {
window.alert("下载失败");
return;
}
const payload = await resp.json().catch(() => null);
const data = payload?.data ?? {};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "mindmap.json";
a.click();
URL.revokeObjectURL(url);
return;
}
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的下载链接");
return;
}
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, []);
const handleRenameAsset = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
window.alert("思维导图暂不支持重命名");
return;
}
const input = window.prompt("输入新文件名", asset.file_name ?? "");
if (!input || !input.trim()) return;
const newName = input.trim();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "重命名失败");
return;
}
await sidebarQuery.refetch();
setAssetMenu(null);
emitAssetsChanged(asset.document_id);
},
[sidebarQuery],
);
const handleMoveAsset = useCallback(
async (asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
window.alert("思维导图文件无需移动,请在页面中直接编辑");
return;
}
const target = window.prompt("输入目标页面 ID", asset.document_id);
if (!target || !target.trim()) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: [asset.id],
targetDocumentId: target.trim(),
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "移动失败");
return;
}
await sidebarQuery.refetch();
setAssetMenu(null);
emitAssetsChanged(asset.document_id);
emitAssetsChanged(target.trim());
},
[sidebarQuery],
);
const handleDeleteAssets = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
const target =
assetHint ??
mediaAssets.find((item) => assetIds.includes(item.id)) ??
null;
if (target?.asset_type === "mindmap") {
const resp = await fetch(`/api/mindmap/${target.document_id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
setMindmapDocs((prev) => prev.filter((id) => id !== target.document_id));
setAssetMenu(null);
emitAssetsChanged(target.document_id, undefined, undefined, true);
return;
}
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
return;
}
setMediaAssets((prev) => prev.filter((item) => !assetIds.includes(item.id)));
setAssetMenu(null);
emitAssetsChanged(target?.document_id, undefined, assetIds);
},
[mediaAssets, sidebarQuery],
);
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
@@ -389,8 +716,12 @@ export function Sidebar({ initialData }: SidebarProps) {
body: JSON.stringify({ documentId }),
});
await refreshTree();
emitDocumentsChanged(documentId);
if (activeId === documentId) {
router.push("/");
}
},
[refreshTree],
[activeId, refreshTree, router],
);
const handleConvertToChild = useCallback(
@@ -713,6 +1044,11 @@ export function Sidebar({ initialData }: SidebarProps) {
onOpenAsset={handleOpenAsset}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
onAssetContextMenu={handleAssetContextMenu}
selectedAssetIds={selectedAssetIds}
onAssetClick={(asset, e) => handleSelectAsset(asset, e)}
onToggleAssetSelect={toggleAssetCheckbox}
onSelectOnlyAsset={selectOnlyAsset}
/>
</div>
</div>
@@ -772,6 +1108,25 @@ export function Sidebar({ initialData }: SidebarProps) {
onDelete={() => void handleDelete(contextMenu.node.id)}
/>
)}
{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}
onDelete={(ids) =>
void handleDeleteAssets(
selectedAssetIds.size > 0 ? Array.from(selectedAssetIds) : ids,
assetMenu.asset,
)
}
onDownload={handleDownloadAsset}
/>
)}
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader className="text-left">
@@ -1,5 +1,6 @@
import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media";
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
@@ -16,4 +17,12 @@ export interface SidebarInitialData {
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
trashedDocuments: TrashRecord[];
/**
* 已存在思维导图文件(本地或 supabase)对应的页面 id 列表
*/
mindmapDocs?: string[];
/**
* 可选的媒体资源列表(旧字段向后兼容)
*/
mediaAssets?: MediaAsset[];
}