0.1.11 ai修复与全屏
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { getFileTreeRowLabel } from "@/lib/file-tree/types";
|
||||
@@ -38,6 +39,29 @@ export function FileTree({
|
||||
onDropFiles,
|
||||
onInternalDrop,
|
||||
}: FileTreeProps) {
|
||||
const [dragOverRowId, setDragOverRowId] = useState<string | null>(null);
|
||||
|
||||
const dragOverRange = useMemo(() => {
|
||||
if (!dragOverRowId) return null;
|
||||
const startIndex = rows.findIndex((row) => row.rowId === dragOverRowId);
|
||||
if (startIndex < 0) return null;
|
||||
|
||||
const target = rows[startIndex];
|
||||
const targetDepth = target.depth;
|
||||
|
||||
// VS Code 的树在拖拽悬停到“展开的文件夹”时,会把该节点的可渲染范围都
|
||||
// 标记为 drop feedback(包含它的所有可见子节点)。我们用“扁平化 rows +
|
||||
// depth”来近似计算该范围。
|
||||
let endIndex = startIndex + 1;
|
||||
if (target.kind === "doc" && target.isExpanded && target.hasChildren) {
|
||||
while (endIndex < rows.length && rows[endIndex].depth > targetDepth) {
|
||||
endIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { startIndex, endIndex };
|
||||
}, [dragOverRowId, rows]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||
}
|
||||
@@ -47,6 +71,12 @@ export function FileTree({
|
||||
className="w-full min-w-0 max-w-full space-y-0.5 overflow-x-hidden"
|
||||
onDragOver={(event) => {
|
||||
if (!onDropFiles) return;
|
||||
if (event.target === event.currentTarget) {
|
||||
setDragOverRowId(null);
|
||||
}
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length);
|
||||
if (!hasFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
@@ -55,6 +85,7 @@ export function FileTree({
|
||||
onDrop={(event) => {
|
||||
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||
event.preventDefault();
|
||||
setDragOverRowId(null);
|
||||
const files = event.dataTransfer.files;
|
||||
const activeDocRow = rows.find(
|
||||
(row) => row.kind === "doc" && row.docId === activeId,
|
||||
@@ -64,18 +95,27 @@ export function FileTree({
|
||||
onDropFiles(targetDocId, files);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
const relatedTarget = (event as unknown as { relatedTarget?: EventTarget | null }).relatedTarget;
|
||||
if (relatedTarget && event.currentTarget.contains(relatedTarget as Node)) return;
|
||||
setDragOverRowId(null);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onBlankMouseDown?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rows.map((row) => {
|
||||
{rows.map((row, index) => {
|
||||
const label = getFileTreeRowLabel(row);
|
||||
const selected = selectedRowIds.has(row.rowId);
|
||||
const active = row.kind !== "asset" && row.docId === activeId;
|
||||
const active = row.kind !== "asset" && row.docId === activeId;
|
||||
const draggable =
|
||||
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
|
||||
const inDropFeedback =
|
||||
dragOverRange &&
|
||||
index >= dragOverRange.startIndex &&
|
||||
index < dragOverRange.endIndex;
|
||||
const baseClass =
|
||||
"flex w-full min-w-0 max-w-full select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
||||
const activeClass =
|
||||
@@ -90,7 +130,12 @@ export function FileTree({
|
||||
return (
|
||||
<div
|
||||
key={row.rowId}
|
||||
className={cn(baseClass, active && activeClass, selected && "bg-[#e8f2ff] text-[#2563eb]")}
|
||||
className={cn(
|
||||
baseClass,
|
||||
active && activeClass,
|
||||
selected && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
inDropFeedback && "bg-gray-200/70",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={(event) => onRowClick(row, event)}
|
||||
onDoubleClick={(event) => onRowDoubleClick(row, event)}
|
||||
@@ -118,21 +163,29 @@ export function FileTree({
|
||||
event.dataTransfer.setData("text/plain", payload);
|
||||
event.dataTransfer.effectAllowed = event.altKey ? "copyMove" : "move";
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragOverRowId(null);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const hasInternal = types.includes("application/x-mnote-file-tree");
|
||||
if (hasInternal && onInternalDrop) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
|
||||
setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId));
|
||||
return;
|
||||
}
|
||||
if (!onDropFiles) return;
|
||||
const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length);
|
||||
if (!hasFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId));
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
setDragOverRowId(null);
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const isInternal = types.includes("application/x-mnote-file-tree");
|
||||
if (isInternal && onInternalDrop) {
|
||||
|
||||
@@ -48,10 +48,9 @@ import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
||||
import { reduceFileTreeSelection } from "@/lib/file-tree/selection";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { parseFileTreeRowId } from "@/lib/file-tree/types";
|
||||
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||
import {
|
||||
inferPasteTargetDocId,
|
||||
isTextInputTarget,
|
||||
@@ -110,9 +109,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [trashSearch, setTrashSearch] = useState("");
|
||||
const [trashTab, setTrashTab] = useState<"documents" | "assets">("documents");
|
||||
const [emptyingTrash, setEmptyingTrash] = useState(false);
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
||||
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
||||
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -141,9 +142,13 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
setMindmapAssets(sidebarData.mindmapAssets ?? []);
|
||||
}, [sidebarData.mindmapAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableAssets(sidebarData.tableAssets ?? []);
|
||||
}, [sidebarData.tableAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
|
||||
}, [mediaAssets, mindmapAssets, sidebarData.documents]);
|
||||
}, [mediaAssets, mindmapAssets, tableAssets, sidebarData.documents]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
@@ -159,6 +164,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
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;
|
||||
@@ -176,6 +186,17 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
};
|
||||
}, [sidebarQuery]);
|
||||
|
||||
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]);
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
await sidebarQuery.refetch();
|
||||
}, [sidebarQuery]);
|
||||
@@ -241,9 +262,21 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
);
|
||||
}, [sidebarData.trashedDocuments, trashSearch]);
|
||||
|
||||
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]);
|
||||
|
||||
const assetsByDoc = useMemo(() => {
|
||||
const map: Record<string, MediaAsset[]> = {};
|
||||
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? [])];
|
||||
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? []), ...(tableAssets ?? [])];
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (!map[asset.document_id]) {
|
||||
@@ -255,7 +288,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [mediaAssets, mindmapAssets]);
|
||||
}, [mediaAssets, mindmapAssets, tableAssets]);
|
||||
|
||||
const fileTreeRows = useMemo(
|
||||
() =>
|
||||
@@ -290,17 +323,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
return map;
|
||||
}, [sidebarData.documents]);
|
||||
|
||||
const selectedAssetIdsForMenu = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
fileTreeSelection.selectedRowIds.forEach((rowId) => {
|
||||
const parsed = parseFileTreeRowId(rowId);
|
||||
if (parsed?.kind === "asset") {
|
||||
ids.push(parsed.assetId);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [fileTreeSelection.selectedRowIds]);
|
||||
|
||||
const activeWorkspace =
|
||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||
sidebarData.workspaces[0];
|
||||
@@ -386,7 +408,17 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
const handleOpenAsset = useCallback((asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
router.push(`/documents/${asset.document_id}`);
|
||||
router.push(`/mindmap/${asset.document_id}/${asset.id}`);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
if (activeId && activeId === asset.document_id && editorBridge?.openTableFullScreen) {
|
||||
editorBridge.openTableFullScreen(asset.id);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
router.push(`/documents/${asset.document_id}?openTableId=${encodeURIComponent(asset.id)}`);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -398,7 +430,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}, [router, setOpen]);
|
||||
}, [activeId, editorBridge, router, setOpen]);
|
||||
|
||||
const handleFileTreeBlankMouseDown = useCallback(() => {
|
||||
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||
@@ -418,8 +450,20 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 与 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");
|
||||
},
|
||||
[fileTreeVisibleRowIds],
|
||||
[activeId, fileTreeVisibleRowIds, handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => {
|
||||
@@ -602,7 +646,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const handleCopyAssetLink = useCallback(
|
||||
async (asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
await copyText(buildDocumentUrl(asset.document_id), "页面链接已复制");
|
||||
await copyText(buildMindmapUrl(asset.document_id, asset.id), "思维导图链接已复制");
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
await copyText(buildTableUrl(asset.id), "表格链接已复制");
|
||||
return;
|
||||
}
|
||||
const url = asset.signed_url ?? asset.file_url ?? "";
|
||||
@@ -615,16 +663,18 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
|
||||
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
|
||||
const path =
|
||||
asset.asset_type === "mindmap"
|
||||
? ((asset.file_url ? asset.file_url.replace(/^\//, "") : "") ||
|
||||
`documents/${asset.document_id}/${asset.file_name ?? "mindmap.json"}`)
|
||||
: asset.storage_path || asset.file_url || asset.file_name || "附件";
|
||||
: asset.asset_type === "luckysheet"
|
||||
? (`tables/${asset.id}`)
|
||||
: asset.storage_path || asset.file_url || asset.file_name || "附件";
|
||||
await copyText(path, "存储路径已复制");
|
||||
}, []);
|
||||
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
|
||||
if (!resp.ok) {
|
||||
@@ -642,6 +692,10 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel)");
|
||||
return;
|
||||
}
|
||||
const url = asset.signed_url ?? asset.file_url;
|
||||
if (!url) {
|
||||
window.alert("暂无可用的下载链接");
|
||||
@@ -658,7 +712,30 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
window.alert("思维导图暂不支持重命名");
|
||||
return;
|
||||
}
|
||||
const input = window.prompt("输入新文件名", asset.file_name ?? "");
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
const currentTitle =
|
||||
(asset.file_name ?? "").toLowerCase().endsWith(".luckysheet")
|
||||
? (asset.file_name ?? "").slice(0, -".luckysheet".length)
|
||||
: (asset.file_name ?? "");
|
||||
const input = window.prompt("输入新表格名", currentTitle);
|
||||
if (!input || !input.trim()) return;
|
||||
const newTitle = input.trim();
|
||||
const resp = await fetch(`/api/tables/${asset.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: newTitle }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "重命名失败");
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: asset.id } }));
|
||||
await sidebarQuery.refetch();
|
||||
setAssetMenu(null);
|
||||
return;
|
||||
}
|
||||
const input = window.prompt("输入新文件名", asset.file_name ?? "");
|
||||
if (!input || !input.trim()) return;
|
||||
const newName = input.trim();
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
@@ -684,7 +761,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
window.alert("思维导图文件无需移动,请在页面中直接编辑");
|
||||
return;
|
||||
}
|
||||
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
window.alert("在线表格暂不支持移动(后续可实现跨页面迁移)");
|
||||
return;
|
||||
}
|
||||
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
||||
if (!target || !target.trim()) return;
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
@@ -712,7 +793,7 @@ 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) ?? mindmapAssets.find((item) => item.id === id))
|
||||
.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)) {
|
||||
@@ -720,6 +801,10 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
|
||||
const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
|
||||
const fileAssetsToDelete = assets.filter(
|
||||
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
|
||||
);
|
||||
const mindmapIdsByDocId = new Map<string, string[]>();
|
||||
mindmapAssetsToDelete.forEach((item) => {
|
||||
const prev = mindmapIdsByDocId.get(item.document_id) ?? [];
|
||||
@@ -727,13 +812,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
mindmapIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
const fileAssetIdsByDocId = new Map<string, string[]>();
|
||||
assets
|
||||
.filter((item) => item.asset_type !== "mindmap")
|
||||
.forEach((item) => {
|
||||
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
|
||||
prev.push(item.id);
|
||||
fileAssetIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
fileAssetsToDelete.forEach((item) => {
|
||||
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
|
||||
prev.push(item.id);
|
||||
fileAssetIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();
|
||||
|
||||
for (const asset of mindmapAssetsToDelete) {
|
||||
@@ -745,6 +828,16 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of tableAssetsToDelete) {
|
||||
const resp = await fetch(`/api/tables/${asset.id}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除在线表格失败");
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId: asset.id } }));
|
||||
}
|
||||
|
||||
if (fileAssetIds.length > 0) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
@@ -759,14 +852,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
if (uniqueAssetIds.length > 0) {
|
||||
setMediaAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
setMindmapAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
const mindmapSet = new Set(mindmapAssetsToDelete.map((item) => item.id));
|
||||
const tableSet = new Set(tableAssetsToDelete.map((item) => item.id));
|
||||
const fileSet = new Set(fileAssetsToDelete.map((item) => item.id));
|
||||
setMediaAssets((prev) => prev.filter((item) => !fileSet.has(item.id)));
|
||||
setMindmapAssets((prev) => prev.filter((item) => !mindmapSet.has(item.id)));
|
||||
setTableAssets((prev) => prev.filter((item) => !tableSet.has(item.id)));
|
||||
}
|
||||
setAssetMenu(null);
|
||||
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
|
||||
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
|
||||
await sidebarQuery.refetch();
|
||||
},
|
||||
[mediaAssets, mindmapAssets, sidebarQuery],
|
||||
[mediaAssets, mindmapAssets, sidebarQuery, tableAssets],
|
||||
);
|
||||
|
||||
const handleDeleteFileTreeSelection = useCallback(async () => {
|
||||
@@ -781,7 +879,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : "";
|
||||
const assetText = assetIds.length > 0 ? `${assetIds.length} 个附件(彻底删除)` : "";
|
||||
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(" + ") : "";
|
||||
const joinText = docText && assetText ? " + " : "";
|
||||
const ok = window.confirm(`确认删除选中的 ${docText}${joinText}${assetText} 吗?`);
|
||||
if (!ok) return;
|
||||
@@ -826,6 +936,9 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
fileTreeRows,
|
||||
fileTreeSelection.selectedRowIds,
|
||||
handleDeleteAssets,
|
||||
mediaAssets,
|
||||
mindmapAssets,
|
||||
tableAssets,
|
||||
refreshTree,
|
||||
router,
|
||||
]);
|
||||
@@ -1269,6 +1382,124 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
}, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const handleWorkspaceSwitch = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
if (workspaceId === sidebarData.activeWorkspaceId) {
|
||||
@@ -1533,7 +1764,12 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
<Trash2 className="h-4 w-4 text-gray-500" />
|
||||
垃圾桶
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{sidebarData.trashedDocuments.length} 条</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{sidebarData.trashedDocuments.length +
|
||||
(sidebarData.trashedMediaAssets?.length ?? 0) +
|
||||
(sidebarData.trashedMindmapAssets?.length ?? 0)}{" "}
|
||||
条
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1584,12 +1820,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onCopyPath={handleCopyAssetPath}
|
||||
onRename={handleRenameAsset}
|
||||
onMove={handleMoveAsset}
|
||||
onDelete={(ids) =>
|
||||
void handleDeleteAssets(
|
||||
selectedAssetIdsForMenu.length > 0 ? selectedAssetIdsForMenu : ids,
|
||||
assetMenu.asset,
|
||||
)
|
||||
}
|
||||
onDelete={() => void handleDeleteFileTreeSelection()}
|
||||
onDownload={handleDownloadAsset}
|
||||
/>
|
||||
)}
|
||||
@@ -1597,11 +1828,43 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>垃圾桶</DrawerTitle>
|
||||
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
|
||||
{trashTab === "documents" ? (
|
||||
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
附件删除后 10 分钟内可撤销;也可在此手动清空(不可撤销)。
|
||||
</p>
|
||||
)}
|
||||
</DrawerHeader>
|
||||
<div className="space-y-4 px-4 pb-6">
|
||||
<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>
|
||||
<Input
|
||||
placeholder="搜索删除的页面..."
|
||||
placeholder={trashTab === "documents" ? "搜索删除的页面..." : "搜索删除的附件..."}
|
||||
value={trashSearch}
|
||||
onChange={(event) => setTrashSearch(event.target.value)}
|
||||
/>
|
||||
@@ -1618,39 +1881,88 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void handleEmptyTrash()}
|
||||
onClick={() => void (trashTab === "documents" ? handleEmptyTrash() : handleEmptyMediaTrash())}
|
||||
disabled={emptyingTrash}
|
||||
>
|
||||
{emptyingTrash ? "清空中..." : "清空垃圾桶"}
|
||||
{emptyingTrash
|
||||
? "清空中..."
|
||||
: trashTab === "documents"
|
||||
? "清空垃圾桶"
|
||||
: "清空附件垃圾桶"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[#eaeaea]">
|
||||
{filteredTrash.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
|
||||
{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>
|
||||
) : (
|
||||
filteredTrash.map((item) => (
|
||||
filteredTrashedMediaAssets.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="min-w-0 pr-2">
|
||||
<div className="truncate font-medium text-gray-800">{item.file_name || "未命名附件"}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
删除时间:{new Date(item.deleted_at).toLocaleString()}
|
||||
删除时间:{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
类型:{item.mime_type ?? item.asset_type ?? "unknown"}
|
||||
</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)}
|
||||
onClick={() =>
|
||||
void (item.asset_type === "mindmap"
|
||||
? handleRestoreMindmapFromTrash(item.document_id, item.id)
|
||||
: handleRestoreMediaAssetFromTrash(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)}
|
||||
onClick={() =>
|
||||
void (item.asset_type === "mindmap"
|
||||
? handlePurgeMindmapFromTrash(item.document_id, item.id)
|
||||
: handlePurgeMediaAssetFromTrash(item.id))
|
||||
}
|
||||
>
|
||||
彻底删除
|
||||
</button>
|
||||
@@ -1957,6 +2269,20 @@ const buildDocumentUrl = (documentId: string): string => {
|
||||
return `${window.location.origin}/documents/${documentId}`;
|
||||
};
|
||||
|
||||
const buildMindmapUrl = (documentId: string, mindmapId: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/mindmap/${documentId}/${mindmapId}`;
|
||||
}
|
||||
return `${window.location.origin}/mindmap/${documentId}/${mindmapId}`;
|
||||
};
|
||||
|
||||
const buildTableUrl = (tableId: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/tables/${tableId}/view`;
|
||||
}
|
||||
return `${window.location.origin}/tables/${tableId}/view`;
|
||||
};
|
||||
|
||||
const copyText = async (text: string, successMessage: string) => {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface SidebarInitialData {
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets?: MediaAsset[];
|
||||
trashedMindmapAssets?: MediaAsset[];
|
||||
/**
|
||||
* 在线表格(Luckysheet)在文件树中的“虚拟文件”列表。
|
||||
* 仅用于文件树展示与操作(单击跳转 index / 双击全屏打开 / 同步删除)。
|
||||
*/
|
||||
tableAssets?: MediaAsset[];
|
||||
/**
|
||||
* 已存在思维导图文件(本地或 supabase)对应的页面 id 列表
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user