diff --git a/frontend/app/components/platform/ScriptExplorer.tsx b/frontend/app/components/platform/ScriptExplorer.tsx index bda8c0b..c65f477 100644 --- a/frontend/app/components/platform/ScriptExplorer.tsx +++ b/frontend/app/components/platform/ScriptExplorer.tsx @@ -1,6 +1,7 @@ import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useMemo } from "react"; import Icon from "../common/Icon"; import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree"; +import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore"; import type { ScriptItem, WorkspaceDirectory } from "~/services/api"; import { type AuthUser } from "~/context/AuthContext"; @@ -50,6 +51,12 @@ export function ScriptExplorer({ uploadInputRef, onHandleUpload, }: ScriptExplorerProps) { + const expandedPaths = useScriptWorkspaceStore((s) => s.expandedPaths); + const loadingChildrenPaths = useScriptWorkspaceStore( + (s) => s.loadingChildrenPaths, + ); + const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded); + const memberScriptGroups = useMemo(() => { const visibleScripts = user?.is_system_admin === true @@ -185,6 +192,9 @@ export function ScriptExplorer({ : undefined } readOnly={group.user?.user_id !== user?.user_id} + expandedPaths={expandedPaths} + onToggle={onToggle} + loadingChildrenPaths={loadingChildrenPaths} /> ))} {filteredScripts.length === 0 && ( diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index cf16851..d4eca28 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -230,7 +230,8 @@ export function useApi(): WorkspaceBoundApi { deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId), setScriptLock: (scriptId, isLocked) => rawApi.setScriptLock(workspaceId, scriptId, isLocked), - listWorkspaceDirectories: () => rawApi.listWorkspaceDirectories(workspaceId), + listWorkspaceDirectories: (parentPath?: string) => + rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? ""), createWorkspaceDirectory: (directoryName, parentPath) => rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath), deleteWorkspaceDirectory: (path) => diff --git a/frontend/app/features/platform/WorkspaceTree.tsx b/frontend/app/features/platform/WorkspaceTree.tsx index ad6bb53..b52cda2 100644 --- a/frontend/app/features/platform/WorkspaceTree.tsx +++ b/frontend/app/features/platform/WorkspaceTree.tsx @@ -1,4 +1,4 @@ -import { type MouseEvent as ReactMouseEvent, useState } from "react"; +import { type MouseEvent as ReactMouseEvent } from "react"; import Icon from "../../components/common/Icon"; import type { @@ -23,6 +23,9 @@ type WorkspaceTreeProps = { target: WorkspaceTreeTarget, ) => void; readOnly?: boolean; + expandedPaths: Set; + onToggle: (path: string) => void; + loadingChildrenPaths: Set; }; type WorkspaceTreeItemsProps = Omit & { @@ -62,15 +65,19 @@ export function WorkspaceTreeGroup({ onSelect, onContextMenu, readOnly = false, + expandedPaths, + onToggle, + loadingChildrenPaths, }: WorkspaceTreeProps) { - const [open, setOpen] = useState(true); + const open = expandedPaths.has(""); + const childrenLoading = loadingChildrenPaths.has(""); return (
{open && (
@@ -90,6 +98,9 @@ export function WorkspaceTreeGroup({ selectedId={selectedId} onSelect={onSelect} onContextMenu={onContextMenu} + expandedPaths={expandedPaths} + onToggle={onToggle} + loadingChildrenPaths={loadingChildrenPaths} /> {scripts.length === 0 && directories.length === 0 && (

@@ -110,6 +121,9 @@ function WorkspaceTreeItems({ selectedId, onSelect, onContextMenu, + expandedPaths, + onToggle, + loadingChildrenPaths, }: WorkspaceTreeItemsProps) { const childDirectories = directories.filter( (item) => item.parent_path === path, @@ -129,6 +143,9 @@ function WorkspaceTreeItems({ selectedId={selectedId} onSelect={onSelect} onContextMenu={onContextMenu} + expandedPaths={expandedPaths} + onToggle={onToggle} + loadingChildrenPaths={loadingChildrenPaths} /> ))} {childScripts.map((item) => ( @@ -177,17 +194,21 @@ function DirectoryBranch({ selectedId, onSelect, onContextMenu, + expandedPaths, + onToggle, + loadingChildrenPaths, }: Omit & { directory: WorkspaceDirectory; }) { - const [open, setOpen] = useState(true); + const open = expandedPaths.has(directory.path); + const childrenLoading = loadingChildrenPaths.has(directory.path); return (

{open && ( )}
diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index d7077c6..c367b7b 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -79,6 +79,9 @@ type State = { previewError: string | null; pythonEditorBuffers: Record; + expandedPaths: Set; + loadingChildrenPaths: Set; + loadedChildPaths: Set; // actions setApiOnline: (online: boolean) => void; @@ -103,6 +106,8 @@ type State = { createFolder: (name: string, parentPath: string) => Promise; deleteScript: (script: ScriptItem) => Promise; deleteDirectory: (path: string) => Promise; + toggleExpanded: (path: string) => Promise; + loadChildren: (parentPath: string) => Promise; toggleScriptLock: (script: ScriptItem) => Promise; openPublishDialog: (script: ScriptItem) => void; submitPublish: (releaseNote: string, visibility: Visibility) => Promise; @@ -166,6 +171,9 @@ export const useScriptWorkspaceStore = create((set, get) => { previewError: null, pythonEditorBuffers: {}, + expandedPaths: new Set(), + loadingChildrenPaths: new Set(), + loadedChildPaths: new Set(), setApiOnline: (online) => set({ apiOnline: online }), setKeyword: (keyword) => set({ keyword }), @@ -197,6 +205,9 @@ export const useScriptWorkspaceStore = create((set, get) => { previewLoading: false, previewError: null, pythonEditorBuffers: {}, + expandedPaths: new Set(), + loadingChildrenPaths: new Set(), + loadedChildPaths: new Set(), }); }, @@ -207,12 +218,15 @@ export const useScriptWorkspaceStore = create((set, get) => { try { const [items, folderItems] = await Promise.all([ api.listScripts(), - api.listWorkspaceDirectories(), + api.listWorkspaceDirectories(""), ]); + const nextLoaded = new Set(get().loadedChildPaths); + nextLoaded.add(""); set({ scripts: items, directories: folderItems, apiOnline: true, + loadedChildPaths: nextLoaded, }); const validIds = new Set(items.map((item) => item.script_id)); const currentSelected = get().selectedId; @@ -236,6 +250,56 @@ export const useScriptWorkspaceStore = create((set, get) => { } }, + loadChildren: async (parentPath) => { + const api = requireApi(); + if (get().loadedChildPaths.has(parentPath)) return; + const next = new Set(get().loadingChildrenPaths); + next.add(parentPath); + set({ loadingChildrenPaths: next }); + try { + const children = await api.listWorkspaceDirectories(parentPath); + set((state) => { + const nextLoaded = new Set(state.loadedChildPaths); + nextLoaded.add(parentPath); + const trimmed = state.directories.filter( + (d) => d.parent_path !== parentPath, + ); + return { + directories: [...trimmed, ...children], + loadedChildPaths: nextLoaded, + loadingChildrenPaths: new Set( + [...state.loadingChildrenPaths].filter((p) => p !== parentPath), + ), + }; + }); + } catch (error) { + set((state) => ({ + loadingChildrenPaths: new Set( + [...state.loadingChildrenPaths].filter((p) => p !== parentPath), + ), + })); + pushToast( + "error", + error instanceof Error ? error.message : "目录加载失败", + ); + } + }, + + toggleExpanded: async (path) => { + const state = get(); + const isOpen = state.expandedPaths.has(path); + const next = new Set(state.expandedPaths); + if (isOpen) { + next.delete(path); + } else { + next.add(path); + if (!state.loadedChildPaths.has(path)) { + void get().loadChildren(path); + } + } + set({ expandedPaths: next }); + }, + selectScript: (id) => { _selectedId = id; set({ selectedId: id }); @@ -712,7 +776,25 @@ export const useScriptWorkspaceStore = create((set, get) => { ui.setFolderBusy(true); try { await api.createWorkspaceDirectory(trimmed, parentPath); - await get().load(true); + if (parentPath === "") { + await get().load(true); + } else { + set((state) => { + const nextLoaded = new Set(state.loadedChildPaths); + for (const p of state.loadedChildPaths) { + if (p.startsWith(`${parentPath}/`)) nextLoaded.delete(p); + } + nextLoaded.delete(parentPath); + return { + loadedChildPaths: nextLoaded, + directories: state.directories.filter( + (d) => !d.parent_path.startsWith(`${parentPath}/`), + ), + expandedPaths: new Set(state.expandedPaths), + }; + }); + await get().loadChildren(parentPath); + } ui.closeFolderDialog(); pushToast("success", `${trimmed} 文件夹已创建`); } catch (error) { @@ -779,6 +861,9 @@ export const useScriptWorkspaceStore = create((set, get) => { await get().endEditing(false, false); if (_editSession?.script_id === activeScript.script_id) return; } + const parentPath = path.includes("/") + ? path.split("/").slice(0, -1).join("/") + : ""; try { const result = await api.deleteWorkspaceDirectory(path); const selectedScript = get().scripts.find( @@ -790,7 +875,28 @@ export const useScriptWorkspaceStore = create((set, get) => { ) { get().selectScript(null); } - await get().load(true); + set((state) => { + const nextLoaded = new Set(state.loadedChildPaths); + const nextExpanded = new Set(state.expandedPaths); + for (const p of state.loadedChildPaths) { + if (p === path || p.startsWith(`${path}/`)) nextLoaded.delete(p); + } + for (const p of state.expandedPaths) { + if (p === path || p.startsWith(`${path}/`)) nextExpanded.delete(p); + } + return { + loadedChildPaths: nextLoaded, + expandedPaths: nextExpanded, + directories: state.directories.filter( + (d) => d.parent_path !== path && !d.parent_path.startsWith(`${path}/`), + ), + }; + }); + if (parentPath === "") { + await get().load(true); + } else { + await get().loadChildren(parentPath); + } pushToast( "success", `${path} 已删除(含 ${result.deleted_scripts} 个脚本)`, diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index b4db2f9..3032c89 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -191,6 +191,7 @@ export type WorkspaceDirectory = { path: string; name: string; parent_path: string; + has_children?: boolean; }; type ApiEnvelope = { @@ -443,9 +444,13 @@ export async function deleteScript( export async function listWorkspaceDirectories( workspaceId: string, + parentPath: string = "", ): Promise { + const query = parentPath + ? `?parent_path=${encodeURIComponent(parentPath)}` + : ""; const data = await apiRequest<{ directories: WorkspaceDirectory[] }>( - "/api/v1/workspace-tree", + `/api/v1/workspace-directories${query}`, {}, workspaceId, ); @@ -1325,9 +1330,9 @@ export type WorkspaceBoundApi = { setScriptLock: ( scriptId: string, isLocked: boolean, - ) => Promise; - listWorkspaceDirectories: () => Promise; - createWorkspaceDirectory: ( + ) => Promise; + listWorkspaceDirectories: (parentPath?: string) => Promise; + createWorkspaceDirectory: ( directoryName: string, parentPath?: string, ) => Promise;