0.1.11 ai修复与全屏

This commit is contained in:
liaibo
2026-01-10 10:35:21 +08:00
parent e74219c802
commit 0bcdc3e730
55 changed files with 6597 additions and 174 deletions
@@ -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) {
+382 -56
View File
@@ -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 列表
*/