From a4183d86d830e4edf9d5be8dff69dd39180057ba Mon Sep 17 00:00:00 2001 From: liaibo Date: Sat, 24 Jan 2026 13:14:24 +0800 Subject: [PATCH] =?UTF-8?q?0.3.6=20=E4=BF=AE=E5=A4=8Dshitf=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/onlyoffice/OnlyOfficeClientPage.tsx | 56 ++++++++++++++- .../src/components/sidebar/sidebar.tsx | 69 +++++++++++++++++-- .../src/lib/file-tree/selection.test.ts | 13 +++- wolai-frontend/src/lib/file-tree/selection.ts | 48 +++++++++++++ 4 files changed, 174 insertions(+), 12 deletions(-) diff --git a/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx b/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx index 50513fd9..918fc30c 100644 --- a/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx +++ b/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel"; import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; @@ -358,6 +358,7 @@ export default function OnlyOfficePage() { const [resolvedMode, setResolvedMode] = useState(mode === "view" ? "view" : null); const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false); const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false); + const [permissionResolved, setPermissionResolved] = useState(false); const [assetSignedUrl, setAssetSignedUrl] = useState(""); const [assetStorageId, setAssetStorageId] = useState(""); const [forceSaveState, setForceSaveState] = useState<{ @@ -413,6 +414,7 @@ export default function OnlyOfficePage() { const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride; const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin; const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin; + const docEditorRef = useRef(null); useEffect(() => { // 说明:为了让 document.key 在同一附件的多次打开之间保持稳定(提升缓存命中与加载速度), @@ -472,12 +474,17 @@ export default function OnlyOfficePage() { setResolvedMode(mode === "view" ? "view" : mode); setResolvedDisableDownload(false); setResolvedDisableCopy(false); + setPermissionResolved(true); + return; + } + if (!authedUserId) { + setPermissionResolved(false); return; } - if (!authedUserId) return; let canceled = false; void (async () => { + setPermissionResolved(false); try { const perm = await convex.query(api.documents.getPermissionForUser, { userId: authedUserId, @@ -488,6 +495,7 @@ export default function OnlyOfficePage() { const disableCopy = Boolean((perm as any)?.disableCopy); setResolvedDisableDownload(disableDownload); setResolvedDisableCopy(disableCopy); + setPermissionResolved(true); if (mode === "view") { setResolvedMode("view"); @@ -504,6 +512,7 @@ export default function OnlyOfficePage() { setResolvedMode(mode === "view" ? "view" : mode); setResolvedDisableDownload(false); setResolvedDisableCopy(false); + setPermissionResolved(true); } })(); @@ -731,7 +740,12 @@ export default function OnlyOfficePage() { fileName, fileType, mode, + resolvedMode, + resolvedDisableDownload, + resolvedDisableCopy, + permissionResolved, assetId, + documentId, docKey, }; } catch { @@ -747,11 +761,34 @@ export default function OnlyOfficePage() { fileName, fileType, resolvedMode, + resolvedDisableCopy, + resolvedDisableDownload, + permissionResolved, + documentId, proxyOrigin, resolvedFileUrl, ]); useEffect(() => { + let canceled = false; + const destroyEditor = () => { + try { + const inst = docEditorRef.current; + docEditorRef.current = null; + if (inst && typeof inst.destroyEditor === "function") { + inst.destroyEditor(); + } + } catch { + // ignore + } + try { + const el = document.getElementById("onlyoffice-frame"); + if (el) el.innerHTML = ""; + } catch { + // ignore + } + }; + // 说明:如果是附件(assetId)编辑模式,则必须拿到真实 userId 才能让 // /api/onlyoffice/callback 通过工作空间成员校验并把文件写回存储。 // 否则会出现:编辑器里看似“已保存”,但下载/再次打开仍是旧文件。 @@ -789,6 +826,7 @@ export default function OnlyOfficePage() { } if (!resolvedMode) return; + if (documentId && !permissionResolved) return; if (resolvedMode !== "view" && assetId && !authedUserId) return; const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`; @@ -909,6 +947,7 @@ export default function OnlyOfficePage() { documentToken: string | null; editorConfigToken: string | null; }; + if (canceled) return; // 兼容不同 ONLYOFFICE 配置:有些版本/配置会校验 document.token/editorConfig.token。 if (token) config.token = token; @@ -922,7 +961,8 @@ export default function OnlyOfficePage() { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config); + if (canceled) return; + docEditorRef.current = new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config); }) .catch((err: Error) => { // 说明:如果服务端强制 JWT 且你未配置 ONLYOFFICE_JWT_SECRET,会在这里失败或在编辑器内报错。 @@ -938,6 +978,11 @@ export default function OnlyOfficePage() { } setError(err.message); }); + + return () => { + canceled = true; + destroyEditor(); + }; }, [ assetId, authedUserId, @@ -952,6 +997,11 @@ export default function OnlyOfficePage() { mode, proxyOrigin, resolvedFileUrl, + documentId, + permissionResolved, + resolvedDisableCopy, + resolvedDisableDownload, + resolvedMode, targetDocType, runtimeConfig.onlyofficeBaseUrlDesktop, ]); diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index 6d1b8a58..936bf8d3 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -46,7 +46,7 @@ import { useCurrentDocumentStore } from "@/store/current-document"; import { FileTree } from "@/components/sidebar/file-tree"; import { buildVisibleRows } from "@/lib/file-tree/rows"; import type { FileTreeSelectionState } from "@/lib/file-tree/selection"; -import { reduceFileTreeSelection } from "@/lib/file-tree/selection"; +import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection"; import type { FileTreeRow } from "@/lib/file-tree/types"; import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd"; import { isRealFileAsset } from "@/lib/file-tree/asset"; @@ -260,10 +260,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { setTableAssets(sidebarData.tableAssets ?? []); }, [sidebarData.tableAssets]); - useEffect(() => { - setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null }); - }, [mediaAssets, mindmapAssets, tableAssets, sidebarData.documents]); - useEffect(() => { setOpen(false); }, [activeId, setOpen]); @@ -566,6 +562,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]); const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]); + useEffect(() => { + setFileTreeSelection((prev) => normalizeFileTreeSelectionForVisibleRows(prev, fileTreeVisibleRowIds)); + }, [fileTreeVisibleRowIds]); + const docParentById = useMemo( () => buildParentById( @@ -1703,6 +1703,61 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { [activeId, refreshTree, router], ); + const handleDeleteFromContextMenuNode = useCallback( + async (node: DocumentNode) => { + if (viewMode === "filesystem") { + await handleDeleteFileTreeSelection(); + return; + } + + const ok = window.confirm("确认删除该页面到垃圾桶吗?"); + if (!ok) return; + + try { + await handleDelete(node.id); + } catch (error) { + window.alert(error instanceof Error ? error.message : "删除失败"); + } + }, + [handleDelete, handleDeleteFileTreeSelection, viewMode], + ); + + const handleDeleteFromAssetContextMenu = useCallback( + async (assetIds: string[], assetHint?: MediaAsset) => { + const uniqueAssetIds = Array.from(new Set(assetIds)); + if (uniqueAssetIds.length === 0) return; + + const assets = uniqueAssetIds + .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)) { + assets.unshift(assetHint); + } + + const mindmapCount = assets.filter((item) => item.asset_type === "mindmap").length; + const tableCount = assets.filter((item) => item.asset_type === "luckysheet").length; + const fileCount = assets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length; + const unknownCount = Math.max(0, uniqueAssetIds.length - mindmapCount - tableCount - fileCount); + + const parts: string[] = []; + if (fileCount > 0) parts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`); + if (mindmapCount > 0) parts.push(`${mindmapCount} 个思维导图(删除)`); + if (tableCount > 0) parts.push(`${tableCount} 个在线表格(删除)`); + if (unknownCount > 0) parts.push(`${unknownCount} 个对象(删除)`); + + const ok = window.confirm(`确认删除选中的 ${parts.join(" + ")} 吗?`); + if (!ok) return; + + try { + await handleDeleteAssets(uniqueAssetIds, assetHint); + setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); + } catch (error) { + window.alert(error instanceof Error ? error.message : "删除失败"); + } + }, + [handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets], + ); + const handleConvertToChild = useCallback( async (documentId: string) => { const flat = flattenedPrivate; @@ -2373,7 +2428,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)} onCreateChild={() => void handleCreate(contextMenu.node.id)} onConvertChild={() => void handleConvertToChild(contextMenu.node.id)} - onDelete={() => void handleDeleteFileTreeSelection()} + onDelete={() => void handleDeleteFromContextMenuNode(contextMenu.node)} /> )} {shareTarget && ( @@ -2449,7 +2504,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { onCopyPath={handleCopyAssetPath} onRename={handleRenameAsset} onMove={handleMoveAsset} - onDelete={() => void handleDeleteFileTreeSelection()} + onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)} onDownload={handleDownloadAsset} /> )} diff --git a/wolai-frontend/src/lib/file-tree/selection.test.ts b/wolai-frontend/src/lib/file-tree/selection.test.ts index 1ed78621..735e8b61 100644 --- a/wolai-frontend/src/lib/file-tree/selection.test.ts +++ b/wolai-frontend/src/lib/file-tree/selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { reduceFileTreeSelection } from "./selection"; +import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "./selection"; describe("reduceFileTreeSelection", () => { const visible = ["a", "b", "c", "d"]; @@ -56,5 +56,14 @@ describe("reduceFileTreeSelection", () => { expect(next.anchorRowId).toBeNull(); expect(next.focusedRowId).toBeNull(); }); -}); + it("可见行变化时保留仍可见的选择", () => { + const next = normalizeFileTreeSelectionForVisibleRows( + { selectedRowIds: new Set(["b", "x"]), anchorRowId: "b", focusedRowId: "x" }, + ["a", "b", "c"], + ); + expect(Array.from(next.selectedRowIds)).toEqual(["b"]); + expect(next.anchorRowId).toBe("b"); + expect(next.focusedRowId).toBe("b"); + }); +}); diff --git a/wolai-frontend/src/lib/file-tree/selection.ts b/wolai-frontend/src/lib/file-tree/selection.ts index 2d057528..146e5d3b 100644 --- a/wolai-frontend/src/lib/file-tree/selection.ts +++ b/wolai-frontend/src/lib/file-tree/selection.ts @@ -82,3 +82,51 @@ export function reduceFileTreeSelection( } } +export function normalizeFileTreeSelectionForVisibleRows( + prev: FileTreeSelectionState, + visibleRowIds: string[], +): FileTreeSelectionState { + const visibleSet = new Set(visibleRowIds); + + let selectedRowIds = prev.selectedRowIds; + let removedAny = false; + const kept: string[] = []; + prev.selectedRowIds.forEach((id) => { + if (visibleSet.has(id)) { + kept.push(id); + return; + } + removedAny = true; + }); + if (removedAny) { + selectedRowIds = new Set(kept); + } + + let anchorRowId = prev.anchorRowId; + if (anchorRowId && !visibleSet.has(anchorRowId)) { + anchorRowId = null; + } + + let focusedRowId = prev.focusedRowId; + if (focusedRowId && !visibleSet.has(focusedRowId)) { + focusedRowId = null; + } + + if (!focusedRowId && selectedRowIds.size > 0) { + focusedRowId = visibleRowIds.find((id) => selectedRowIds.has(id)) ?? null; + } + + if (!anchorRowId && focusedRowId) { + anchorRowId = focusedRowId; + } + + if ( + selectedRowIds === prev.selectedRowIds && + anchorRowId === prev.anchorRowId && + focusedRowId === prev.focusedRowId + ) { + return prev; + } + + return { selectedRowIds, anchorRowId, focusedRowId }; +}