429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
// ---- 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);
|
|
}
|
|
},
|
|
};
|
|
}; |