// ---- mutationsSlice ---- // // 拥有 latestVersion / latestVersionLoading。 // 所有写后端的 action (CRUD + 发布 + 锁) 集中在这里。 // // cross-slice 写: // - createScript 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) // - uploadScripts 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) + // 调 load (scriptsSlice) // - uploadDataResource 写 dataResources (scriptsSlice) + 失效对应路径缓存 // - createFolder 写 loadedChildPaths/directories/expandedPaths (treeSlice) + // 调 loadChildren / load (treeSlice/scriptsSlice) // - deleteScript 写 openTabIds/selectedId (selectionSlice) + 调 // endEditing (editSessionSlice) + 调 load (scriptsSlice) // - deleteDataResource 写 dataResources (scriptsSlice) // - deleteDirectory 写 loadedChildPaths/expandedPaths/directories (treeSlice) // + 调 selectScript (selectionSlice) + 调 loadChildren / load // - toggleScriptLock 写 scripts (scriptsSlice) // - submitPublish 不写 store state (只走 uiStore) // - openPublishDialog 委托 uiStore (selectionSlice 也有,这里只放空,实际由 // selectionSlice.openPublishDialog 提供——不要重复定义,这里只保留版本相关) import type { StateCreator } from "zustand"; import type { ResourceItem, ScriptItem, StableVersion, Visibility, } from "~/services/api"; import { useUiStore } from "./uiStore"; import { getCurrentUserId, getEditSession, getSelectedId, ownedScriptPath, ownerCacheKey, pushToast, requireApi, sha256Hex, } from "./helpers"; import type { NewScriptForm } from "./uiStore"; import type { DataResourceMeta, MutationsSliceActions, MutationsSliceState, } from "./types"; import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore"; export const createMutationsSlice: StateCreator< ScriptWorkspaceStore, [], [], MutationsSliceState & MutationsSliceActions > = (set, get) => { const initial: MutationsSliceState = { latestVersion: null, latestVersionLoading: false, }; return { ...initial, loadLatestVersion: async (scriptId) => { const api = requireApi(); set({ latestVersionLoading: true }); try { const item = await api.getLatestScriptVersion(scriptId); set({ latestVersion: item }); } catch (error) { pushToast( "error", error instanceof Error ? error.message : "最新版本加载失败", ); } finally { set({ latestVersionLoading: false }); } }, createScript: async (form: NewScriptForm) => { const api = requireApi(); const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py"; const requestedName = form.name.trim(); const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix) ? requestedName : `${requestedName}${suffix}`; const duplicate = get().scripts.some((script) => { if (script.script_type !== form.scriptType) return false; if (script.script_name.toLocaleLowerCase() !== normalizedName.toLocaleLowerCase()) return false; // Same name in a different subdirectory is allowed: mirror the // backend name_clash check, which JOINs StorageObjects and scopes // by relative_path. const existingUserPath = ownedScriptPath(script); const existingParent = existingUserPath.includes("/") ? existingUserPath.slice(0, existingUserPath.lastIndexOf("/")) : ""; return existingParent === form.parentPath; }); if (duplicate) { pushToast("error", `${normalizedName} 已存在,请更换名称`); return null; } const ui = useUiStore.getState(); ui.setCreating(true); try { const created = await api.createScript(form); set((state) => ({ scripts: [created, ...state.scripts] })); get().openTab(created.script_id); ui.closeCreateDialog(); pushToast("success", `${created.script_name} 已创建`); return created; } catch (error) { pushToast( "error", error instanceof Error ? error.message : "创建失败", ); return null; } finally { ui.setCreating(false); } }, uploadScripts: async (files, parentPath) => { const api = requireApi(); const ui = useUiStore.getState(); ui.setUploading(true); let lastCreated: ScriptItem | null = null; try { for (const file of files) { lastCreated = await api.uploadScript(file, parentPath, "workspace"); } set((state) => ({ scripts: [lastCreated!, ...state.scripts] })); if (lastCreated) { get().openTab(lastCreated.script_id); } pushToast( "success", `${files.length} 个文件已上传到${parentPath ? ` ${parentPath}` : "当前目录"}`, ); } catch (error) { await get().load(true); pushToast( "error", error instanceof Error ? error.message : "文件上传失败", ); } finally { ui.setUploading(false); } }, uploadDataResource: async (file: File, meta: DataResourceMeta) => { const api = requireApi(); const ui = useUiStore.getState(); ui.setDataResourceUploading(true); try { const buffer = await file.arrayBuffer(); const hash = await sha256Hex(buffer); const { upload_id: uploadId } = await api.createResourceUpload({ file_name: file.name, content_type: file.type || "application/octet-stream", expected_size_bytes: file.size, expected_hash: hash, target_path: meta.targetPath, }); await api.uploadResourceBytes( uploadId, buffer, file.type || "application/octet-stream", ); const resource = await api.bindResourceUpload(uploadId, { resource_name: meta.resourceName, description: meta.description, visibility: meta.visibility, }); // 与 uploadScripts 一致:直接写入 store。loadDataResources 有路径缓存, // 上传后再调会命中已加载路径直接 return,列表不会更新。 const parentPath = parentPathOfResource(resource.jupyter_accessible_path); set((state) => { const nextLoaded = new Set(state.loadedDataResourcePaths); nextLoaded.delete(ownerCacheKey(resource.owner_user_id, parentPath)); // targetPath 与 jupyter 父路径不一致时一并失效(例如带前缀差异)。 if (meta.targetPath !== parentPath) { nextLoaded.delete( ownerCacheKey(resource.owner_user_id, meta.targetPath), ); } const withoutDup = state.dataResources.filter( (r) => r.resource_id !== resource.resource_id, ); return { dataResources: [resource, ...withoutDup], loadedDataResourcePaths: nextLoaded, }; }); ui.closeDataResourceDialog(); pushToast( "success", `数据资源 ${resource.resource_name} 上传成功,路径:${resource.jupyter_accessible_path}`, ); return resource; } catch (error) { pushToast( "error", error instanceof Error ? error.message : "数据资源上传失败", ); return null; } finally { ui.setDataResourceUploading(false); } }, deleteDataResource: async (resourceId) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); if (!window.confirm(`确定删除数据资源吗?稳定版本会保留。`)) { return; } try { await api.deleteResource(resourceId); set((state) => ({ dataResources: state.dataResources.filter( (r) => r.resource_id !== resourceId, ), })); pushToast("success", "数据资源已删除"); } catch (error) { pushToast( "error", error instanceof Error ? error.message : "数据资源删除失败", ); } }, createFolder: async (name, parentPath) => { const api = requireApi(); const trimmed = name.trim(); if (!trimmed) return; const ui = useUiStore.getState(); ui.setFolderBusy(true); try { await api.createWorkspaceDirectory(trimmed, parentPath); if (parentPath === "") { await get().load(true); } else { const me = getCurrentUserId() ?? "me"; const parentKey = ownerCacheKey(me, parentPath); set((state) => { const nextLoaded = new Set(state.loadedChildPaths); for (const p of state.loadedChildPaths) { // 仅失效"我"在该 parent 之下的缓存(namespaced key)。 if ( p.startsWith(`${me}:`) && p.slice(me.length + 1).startsWith(`${parentPath}/`) ) { nextLoaded.delete(p); } } nextLoaded.delete(parentKey); return { loadedChildPaths: nextLoaded, directories: state.directories.filter( (d) => !( d.owner_user_id === me && d.parent_path.startsWith(`${parentPath}/`) ), ), expandedPaths: new Set(state.expandedPaths), }; }); await get().loadChildren(parentPath); } ui.closeFolderDialog(); pushToast("success", `${trimmed} 文件夹已创建`); } catch (error) { ui.setFolderBusy(false); pushToast( "error", error instanceof Error ? error.message : "文件夹创建失败", ); } }, deleteScript: async (script: ScriptItem) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); if ( !window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`) ) { return; } const editSession = getEditSession(); if (editSession?.script_id === script.script_id) { await get().endEditing(false, false); if (getEditSession()?.script_id === script.script_id) return; } try { await api.deleteScript(script.script_id); set((state) => { const index = state.openTabIds.indexOf(script.script_id); const newTabs = state.openTabIds.filter( (id) => id !== script.script_id, ); if (getSelectedId() === script.script_id) { const nextId = newTabs[index] ?? newTabs[index - 1] ?? null; return { openTabIds: newTabs, selectedId: nextId }; } return { openTabIds: newTabs }; }); await get().load(true); pushToast("success", `${script.script_name} 已删除`); } catch (error) { pushToast( "error", error instanceof Error ? error.message : "文件删除失败", ); } }, deleteDirectory: async (path) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); if ( !window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`) ) { return; } const activeScript = get().scripts.find( (item) => item.script_id === getEditSession()?.script_id, ); if ( activeScript && (ownedScriptPath(activeScript) === path || ownedScriptPath(activeScript).startsWith(`${path}/`)) ) { await get().endEditing(false, false); if (getEditSession()?.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( (item) => item.script_id === getSelectedId(), ); if ( selectedScript && ownedScriptPath(selectedScript).startsWith(`${path}/`) ) { get().selectScript(null); } const me = getCurrentUserId() ?? "me"; const pathKey = ownerCacheKey(me, path); set((state) => { const nextLoaded = new Set(state.loadedChildPaths); const nextExpanded = new Set(state.expandedPaths); for (const p of state.loadedChildPaths) { // 仅失效"我"该 path 及其子目录的缓存(namespaced key)。 if (!p.startsWith(`${me}:`)) continue; const bare = p.slice(me.length + 1); if (bare === path || bare.startsWith(`${path}/`)) { nextLoaded.delete(p); } } for (const p of state.expandedPaths) { if (p === path || p.startsWith(`${path}/`)) nextExpanded.delete(p); } void pathKey; return { loadedChildPaths: nextLoaded, expandedPaths: nextExpanded, directories: state.directories.filter( (d) => !( d.owner_user_id === me && (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} 个脚本)`, ); } catch (error) { pushToast( "error", error instanceof Error ? error.message : "文件夹删除失败", ); } }, toggleScriptLock: async (script: ScriptItem) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); try { const updated = await api.setScriptLock(script.script_id, !script.is_locked); set((state) => ({ scripts: state.scripts.map((s) => s.script_id === updated.script_id ? updated : s, ), })); pushToast( "success", `${updated.script_name} 已${updated.is_locked ? "锁定" : "解锁"}`, ); } catch (error) { pushToast( "error", error instanceof Error ? error.message : "锁定状态更新失败", ); } }, submitPublish: async (releaseNote: string, visibility: Visibility) => { const api = requireApi(); const ui = useUiStore.getState(); const target = ui.publish.target; if (!target) return; ui.setPublishing(true); try { const version: StableVersion = await api.publishScriptVersion({ script: target, releaseNote, visibility, }); ui.setPublishedVersion(version); pushToast("success", `${version.version_label} 稳定版本发布成功`); } catch (error) { pushToast( "error", error instanceof Error ? error.message : "稳定版本发布失败", ); } finally { ui.setPublishing(false); } }, }; }; function parentPathOfResource(path: string): string { const parts = path.split("/"); parts.pop(); return parts.join("/"); }