0.1.07 文件树拖拽与多思维导图
This commit is contained in:
@@ -46,14 +46,21 @@ export function FileTree({
|
||||
<div
|
||||
className="space-y-0.5"
|
||||
onDragOver={(event) => {
|
||||
if (onDropFiles) event.preventDefault();
|
||||
if (!onDropFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer.files;
|
||||
const activeDocRow = rows.find(
|
||||
(row) => row.kind === "doc" && row.docId === activeId,
|
||||
);
|
||||
const firstDocRow = rows.find((row) => row.kind === "doc");
|
||||
const targetDocId = firstDocRow?.docId ?? "";
|
||||
const targetDocId = activeDocRow?.docId ?? firstDocRow?.docId ?? "";
|
||||
onDropFiles(targetDocId, files);
|
||||
}
|
||||
}}
|
||||
@@ -119,7 +126,11 @@ export function FileTree({
|
||||
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
|
||||
return;
|
||||
}
|
||||
if (onDropFiles) event.preventDefault();
|
||||
if (!onDropFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface SidebarInitialData {
|
||||
* 已存在思维导图文件(本地或 supabase)对应的页面 id 列表
|
||||
*/
|
||||
mindmapDocs?: string[];
|
||||
/**
|
||||
* 思维导图文件列表(用于文件树显示,多导图时一页可有多个)
|
||||
*/
|
||||
mindmapAssets?: MediaAsset[];
|
||||
/**
|
||||
* 可选的媒体资源列表(旧字段向后兼容)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user