Files
model-platform/frontend/app/features/platform/state/mutationsSlice.ts
T

442 lines
15 KiB
TypeScript

// ---- 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();
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();
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();
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("/");
}