refactor: scriptWorkspaceStore.ts
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
// ---- mutationsSlice ----
|
||||
//
|
||||
// 拥有 latestVersion / latestVersionLoading。
|
||||
// 所有写后端的 action (CRUD + 发布 + 锁) 集中在这里。
|
||||
//
|
||||
// cross-slice 写:
|
||||
// - createScript 写 scripts (scriptsSlice) + 调 openTab (selectionSlice)
|
||||
// - uploadScripts 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) +
|
||||
// 调 load (scriptsSlice)
|
||||
// - uploadDataResource 不写 store state (只走 uiStore)
|
||||
// - 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,
|
||||
});
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user