0.1.07 文件树拖拽与多思维导图

This commit is contained in:
liaibo
2026-01-08 06:28:14 +08:00
parent 9de2d69c55
commit 9af8b9e489
30 changed files with 817 additions and 410 deletions
+124 -72
View File
@@ -110,7 +110,7 @@ export function Sidebar({ initialData }: SidebarProps) {
const [trashSearch, setTrashSearch] = useState("");
const [emptyingTrash, setEmptyingTrash] = useState(false);
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
const [mindmapDocs, setMindmapDocs] = useState<string[]>(sidebarData.mindmapDocs ?? []);
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
@@ -136,12 +136,12 @@ export function Sidebar({ initialData }: SidebarProps) {
}, [sidebarData.mediaAssets]);
useEffect(() => {
setMindmapDocs(sidebarData.mindmapDocs ?? []);
}, [sidebarData.mindmapDocs]);
setMindmapAssets(sidebarData.mindmapAssets ?? []);
}, [sidebarData.mindmapAssets]);
useEffect(() => {
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
}, [mediaAssets, sidebarData.documents]);
}, [mediaAssets, mindmapAssets, sidebarData.documents]);
useEffect(() => {
setOpen(false);
@@ -152,10 +152,17 @@ export function Sidebar({ initialData }: SidebarProps) {
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];
});
if (asset.asset_type === "mindmap") {
setMindmapAssets((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();
};
@@ -234,53 +241,19 @@ export function Sidebar({ initialData }: SidebarProps) {
const assetsByDoc = useMemo(() => {
const map: Record<string, MediaAsset[]> = {};
const assets = mediaAssets;
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? [])];
assets.forEach((asset) => {
if (!map[asset.document_id]) {
map[asset.document_id] = [];
}
map[asset.document_id].push(asset);
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);
}
});
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.documents, mediaAssets, mindmapDocs]);
}, [mediaAssets, mindmapAssets]);
const fileTreeRows = useMemo(
() =>
@@ -552,7 +525,7 @@ export function Sidebar({ initialData }: SidebarProps) {
}
const rows = payload.rowIds
.map((rowId) => fileTreeRowById.get(rowId))
.map((rowId) => fileTreeRowById.get(rowId as any))
.filter(Boolean) as FileTreeRow[];
const docItemsMap = new Map<string, boolean>();
@@ -643,14 +616,15 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
const path =
asset.asset_type === "mindmap"
? `documents/${asset.document_id}/mindmap.json`
? ((asset.file_url ? asset.file_url.replace(/^\//, "") : "") ||
`documents/${asset.document_id}/${asset.file_name ?? "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}`);
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
if (!resp.ok) {
window.alert("下载失败");
return;
@@ -661,7 +635,7 @@ export function Sidebar({ initialData }: SidebarProps) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "mindmap.json";
a.download = asset.file_name ?? "mindmap.json";
a.click();
URL.revokeObjectURL(url);
return;
@@ -736,16 +710,20 @@ export function Sidebar({ initialData }: SidebarProps) {
async (assetIds: string[], assetHint?: MediaAsset) => {
const uniqueAssetIds = Array.from(new Set(assetIds));
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id))
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
const mindmapDocIds = Array.from(
new Set(assets.filter((item) => item.asset_type === "mindmap").map((item) => item.document_id)),
);
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
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);
});
const fileAssetIdsByDocId = new Map<string, string[]>();
assets
.filter((item) => item.asset_type !== "mindmap")
@@ -756,8 +734,8 @@ export function Sidebar({ initialData }: SidebarProps) {
});
const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();
for (const docId of mindmapDocIds) {
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
for (const asset of mindmapAssetsToDelete) {
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
@@ -778,17 +756,15 @@ export function Sidebar({ initialData }: SidebarProps) {
}
}
if (mindmapDocIds.length > 0) {
setMindmapDocs((prev) => prev.filter((id) => !mindmapDocIds.includes(id)));
}
if (uniqueAssetIds.length > 0) {
setMediaAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
setMindmapAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
}
setAssetMenu(null);
mindmapDocIds.forEach((docId) => emitAssetsChanged(docId, undefined, undefined, true));
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
},
[mediaAssets, sidebarQuery],
[mediaAssets, mindmapAssets, sidebarQuery],
);
const handleDeleteFileTreeSelection = useCallback(async () => {
@@ -960,8 +936,83 @@ export function Sidebar({ initialData }: SidebarProps) {
[moveLocalNode, refreshTree, setExpanded],
);
const handleFileTreeDropFiles = useCallback(
(docId: string, files: FileList) => {
void (async () => {
const droppedFiles = Array.from(files ?? []);
if (droppedFiles.length === 0) return;
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);
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);
} 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,
fileTreeRowById,
fileTreeSelection.focusedRowId,
sidebarData.activeWorkspaceId,
sidebarData.documents,
sidebarQuery,
],
);
const handleFileTreeInternalDrop = useCallback(
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
void (async () => {
const targetDocId = inferDropTargetDocId(args.targetRow);
if (!targetDocId) {
@@ -978,7 +1029,7 @@ export function Sidebar({ initialData }: SidebarProps) {
});
const rows = uniqueRowIds
.map((rowId) => fileTreeRowById.get(rowId))
.map((rowId) => fileTreeRowById.get(rowId as any))
.filter(Boolean) as FileTreeRow[];
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
@@ -1378,7 +1429,6 @@ export function Sidebar({ initialData }: SidebarProps) {
{viewMode === "section" ? (
<>
<SectionList
id="starred"
label="星标置顶"
icon={SECTION_ICONS.starred}
nodes={starredNodes}
@@ -1386,7 +1436,6 @@ export function Sidebar({ initialData }: SidebarProps) {
onToggle={() => toggleSection("starred")}
/>
<SectionList
id="public"
label="公共页面"
icon={SECTION_ICONS.public}
nodes={publicNodes}
@@ -1394,7 +1443,6 @@ export function Sidebar({ initialData }: SidebarProps) {
onToggle={() => toggleSection("public")}
/>
<SectionList
id="shared"
label="共享页面"
icon={SECTION_ICONS.shared}
nodes={sharedNodes}
@@ -1402,7 +1450,6 @@ export function Sidebar({ initialData }: SidebarProps) {
onToggle={() => toggleSection("shared")}
/>
<SectionList
id="templates"
label="模板中心"
icon={SECTION_ICONS.templates}
nodes={templateNodes}
@@ -1441,18 +1488,23 @@ export function Sidebar({ initialData }: SidebarProps) {
) : (
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<div className="flex-1 px-1 pb-2">
<div ref={fileTreeContainerRef} className="h-full rounded-md border border-[#eff2f6] bg-white">
<div
ref={fileTreeContainerRef}
data-testid="file-tree-container"
className="h-full rounded-md border border-[#eff2f6] bg-white"
>
<FileTree
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>