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

361 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---- scriptsSlice ----
//
// 拥有 scripts / directories / dataResources / scriptCount / members /
// loadedOwnerGroups / loading / refreshing / apiOnline / readOnlyRefreshVersion。
// 所有 "从后端拉数据" 的 action 集中在这里(load / loadScripts /
// loadOwnerGroup / loadDataResources / loadScriptCount / refreshReadOnlyContent /
// setApiOnline)。
//
// cross-slice 依赖:
// - load 调用 selectionSlice 的 selectedId/openTabIds (清空失效项)
// - load 调用 treeSlice 的 loadedScriptPaths/loadedChildPaths/loadedOwnerGroups
// - loadScripts 读/写 treeSlice 的 loadedScriptPaths/loadingScriptPaths
// - loadOwnerGroup 读/写 treeSlice 的 loadedScriptPaths/loadedChildPaths
// - loadDataResources 读 helpers 的 _currentUserId
import type { StateCreator } from "zustand";
import type {
ResourceItem,
ScriptItem,
WorkspaceDirectory,
WorkspaceMember,
} from "~/services/api";
import {
bumpScriptCountSeq,
getCurrentUserId,
getScriptCountSeq,
getWorkspaceId,
ownerCacheKey,
pushToast,
requireApi,
} from "./helpers";
import type {
ScriptsSliceActions,
ScriptsSliceState,
} from "./types";
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
export const createScriptsSlice: StateCreator<
ScriptWorkspaceStore,
[],
[],
ScriptsSliceState & ScriptsSliceActions
> = (set, get) => {
const initial: ScriptsSliceState = {
scripts: [],
directories: [],
dataResources: [],
dataResourcesLoading: false,
scriptCount: null,
scriptCountLoading: false,
members: [],
loadedOwnerGroups: new Set<string>(),
loadingOwnerGroups: new Set<string>(),
loading: true,
refreshing: false,
apiOnline: false,
readOnlyRefreshVersion: 0,
};
return {
...initial,
setApiOnline: (online) => set({ apiOnline: online }),
refreshReadOnlyContent: () =>
set((state) => ({
readOnlyRefreshVersion: state.readOnlyRefreshVersion + 1,
})),
load: async (silent = false) => {
const api = requireApi();
if (!silent) set({ loading: true });
set({ refreshing: silent });
try {
const me = getCurrentUserId();
const workspaceId = getWorkspaceId();
// 拉取工作区成员列表 —— 顶层"我 / user1 / user2 / …"折叠分组的来源。
const memberList =
workspaceId != null
? await api
.listWorkspaceMembers(workspaceId)
.catch(() => [] as WorkspaceMember[])
: [];
// 只重新拉取"我"的已缓存脚本路径(含根)。首次挂载缓存为空 → 退化为
// 单次根级拉取。他人脚本不动(保留在 flat scripts 里,见下方合并)。
const myCachedScriptKeys = Array.from(get().loadedScriptPaths).filter(
(k) => k.startsWith(`${me}:`) || k.startsWith("me:"),
);
const rootScriptKey = ownerCacheKey(undefined, "");
const scriptFetches =
myCachedScriptKeys.length > 0
? myCachedScriptKeys.map((k) => {
const p = k.slice(k.indexOf(":") + 1);
return api.listScripts(p).catch(() => [] as ScriptItem[]);
})
: [api.listScripts("").catch(() => [] as ScriptItem[])];
// 只重新拉取"我"的已缓存目录路径(含根)。他人目录不动(保留在
// flat directories 里,按 (owner,path) 去重合并)。
const myCachedDirKeys = Array.from(get().loadedChildPaths).filter(
(k) => (me != null && k.startsWith(`${me}:`)) || k.startsWith("me:"),
);
const rootDirKey = ownerCacheKey(undefined, "");
const dirFetches =
myCachedDirKeys.length > 0
? myCachedDirKeys.map((k) => {
const p = k.slice(k.indexOf(":") + 1);
return api
.listWorkspaceDirectories(p)
.catch(() => [] as WorkspaceDirectory[]);
})
: [api
.listWorkspaceDirectories("")
.catch(() => [] as WorkspaceDirectory[])];
const [scriptLists, dirLists] = await Promise.all([
Promise.all(scriptFetches),
Promise.all(dirFetches),
]);
const myFreshScripts = scriptLists.flat();
// "我"的脚本用 fresh 集合替换;他人脚本原样保留(按 script_id 去重合并)。
const otherScripts = get().scripts.filter(
(s) => s.owner_user_id !== me,
);
const dedupedScripts = Array.from(
new Map(
[...otherScripts, ...myFreshScripts].map((s) => [s.script_id, s]),
).values(),
);
// "我"的目录用 fresh 集合替换;他人目录原样保留(按 (owner,path) 去重)。
const otherDirs = get().directories.filter(
(d) => d.owner_user_id !== me,
);
const dedupedDirs = Array.from(
new Map(
[...otherDirs, ...dirLists.flat()].map((d) => [
`${d.owner_user_id}:${d.path}`,
d,
]),
).values(),
);
const nextLoadedScripts = new Set(myCachedScriptKeys);
nextLoadedScripts.add(rootScriptKey);
const nextLoadedChildren = new Set(get().loadedChildPaths);
nextLoadedChildren.add(rootDirKey);
set({
scripts: dedupedScripts,
directories: dedupedDirs,
members: memberList,
apiOnline: true,
loadedScriptPaths: nextLoadedScripts,
loadedChildPaths: nextLoadedChildren,
});
// 刷新已展开的其他成员分组(loadOwnerGroup 总是发起请求,刷新安全)。
for (const owner of get().loadedOwnerGroups) {
if (owner !== me) void get().loadOwnerGroup(owner);
}
const validIds = new Set(dedupedScripts.map((item) => item.script_id));
const currentSelected = get().selectedId;
if (!currentSelected || !validIds.has(currentSelected)) {
set({ selectedId: null });
}
set((state) => ({
openTabIds: state.openTabIds.filter((id) => validIds.has(id)),
}));
} catch (error) {
set({ apiOnline: false });
pushToast(
"error",
error instanceof Error ? error.message : "脚本列表加载失败",
);
} finally {
set({ loading: false, refreshing: false });
}
},
loadScripts: async (parentPath, ownerUserId) => {
const api = requireApi();
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
if (get().loadedScriptPaths.has(cacheKey)) return;
// Dedupe in-flight requests for the same owner×path.
if (get().loadingScriptPaths.has(cacheKey)) return;
const next = new Set(get().loadingScriptPaths);
next.add(cacheKey);
set({ loadingScriptPaths: next });
try {
const items = await api.listScripts(parentPath, ownerUserId);
set((state) => {
// append-only 合并(按 script_id 去重,fresh 覆盖 stale 同 id 值)。
// 该 owner×path 的全量刷新由 load()(我)/ loadOwnerGroup(他人)
// 负责 drop-by-owner 后重并入;这里是子目录展开,append 即可。
const byId = new Map(state.scripts.map((s) => [s.script_id, s]));
for (const item of items) byId.set(item.script_id, item);
const nextLoaded = new Set(state.loadedScriptPaths);
nextLoaded.add(cacheKey);
return {
scripts: Array.from(byId.values()),
loadedScriptPaths: nextLoaded,
loadingScriptPaths: new Set(
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
),
};
});
} catch (error) {
set((state) => ({
loadingScriptPaths: new Set(
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
),
}));
pushToast(
"error",
error instanceof Error ? error.message : "脚本列表加载失败",
);
}
},
loadOwnerGroup: async (ownerUserId) => {
const api = requireApi();
if (get().loadingOwnerGroups.has(ownerUserId)) return;
const nextLoading = new Set(get().loadingOwnerGroups);
nextLoading.add(ownerUserId);
set({ loadingOwnerGroups: nextLoading });
try {
const [scripts, resources, directories] = await Promise.all([
api.listScripts("", ownerUserId).catch(() => [] as ScriptItem[]),
api.listResources("", { ownerUserId }).catch(
() => [] as ResourceItem[],
),
api.listWorkspaceDirectories("", ownerUserId).catch(
() => [] as WorkspaceDirectory[],
),
]);
set((state) => {
// 丢弃该 owner 的旧脚本/资源/目录(按 owner 过滤后保留他人),再并入 fresh。
const keptScripts = state.scripts.filter(
(s) => s.owner_user_id !== ownerUserId,
);
const keptResources = state.dataResources.filter(
(r) => r.owner_user_id !== ownerUserId,
);
const keptDirs = state.directories.filter(
(d) => d.owner_user_id !== ownerUserId,
);
const scriptIds = new Set(keptScripts.map((s) => s.script_id));
const freshScripts = scripts.filter((s) => !scriptIds.has(s.script_id));
const resourceIds = new Set(keptResources.map((r) => r.resource_id));
const freshResources = resources.filter(
(r) => !resourceIds.has(r.resource_id),
);
const dirIds = new Set(
keptDirs.map((d) => `${d.owner_user_id}:${d.path}`),
);
const freshDirs = directories.filter(
(d) => !dirIds.has(`${d.owner_user_id}:${d.path}`),
);
const nextLoaded = new Set(state.loadedOwnerGroups);
nextLoaded.add(ownerUserId);
const nextScriptPaths = new Set(state.loadedScriptPaths);
nextScriptPaths.add(ownerCacheKey(ownerUserId, ""));
const nextChildPaths = new Set(state.loadedChildPaths);
nextChildPaths.add(ownerCacheKey(ownerUserId, ""));
return {
scripts: [...keptScripts, ...freshScripts],
dataResources: [...keptResources, ...freshResources],
directories: [...keptDirs, ...freshDirs],
loadedOwnerGroups: nextLoaded,
loadedScriptPaths: nextScriptPaths,
loadedChildPaths: nextChildPaths,
loadingOwnerGroups: new Set(
[...state.loadingOwnerGroups].filter((o) => o !== ownerUserId),
),
};
});
} catch {
set((state) => ({
loadingOwnerGroups: new Set(
[...state.loadingOwnerGroups].filter((o) => o !== ownerUserId),
),
}));
}
},
loadDataResources: async (parentPath = "", ownerUserId) => {
const api = requireApi();
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
// 命中缓存:listResources 是非递归的,同一 (owner, parent_path) 拉过的
// 内容不会自己变化;省去 toggleExpanded 重复展开同一目录时的网络往返。
if (get().loadedDataResourcePaths.has(cacheKey)) return;
set({ dataResourcesLoading: true });
try {
const list = await api.listResources(parentPath, { ownerUserId });
const fresh = Array.isArray(list) ? list : [];
set((state) => {
// 按 (owner, parent_path) 局部替换:丢掉该 owner 在 parentPath 下的
// 旧条目,保留该 owner 在其它路径下的条目,再并入 fresh。
// 这样 toggleExpanded 在子目录展开时按需拉取不会把根已加载的数据
// 资源擦掉(修"根加载后子目录展开丢数据 / 子目录数据本来不显示")。
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
const kept = state.dataResources.filter((r) => {
if (r.owner_user_id !== targetOwner) return true;
return parentPathOf(r.jupyter_accessible_path) !== parentPath;
});
const byId = new Map(kept.map((r) => [r.resource_id, r]));
for (const item of fresh) byId.set(item.resource_id, item);
const nextLoaded = new Set(state.loadedDataResourcePaths);
nextLoaded.add(cacheKey);
return {
dataResources: Array.from(byId.values()),
loadedDataResourcePaths: nextLoaded,
};
});
} catch {
set((state) => {
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
return {
dataResources: state.dataResources.filter((r) => {
if (r.owner_user_id !== targetOwner) return true;
return parentPathOf(r.jupyter_accessible_path) !== parentPath;
}),
};
});
} finally {
set({ dataResourcesLoading: false });
}
},
loadScriptCount: async () => {
const api = requireApi();
// Always fire — don't dedupe via the loading flag. Rapid workspace
// switches would otherwise drop the new fetch and leave the
// dashboard showing the previous workspace's count. The sequence
// counter below discards stale responses instead.
const seq = bumpScriptCountSeq();
set({ scriptCountLoading: true, scriptCount: null });
try {
const total = await api.countScripts();
if (getScriptCountSeq() !== seq) return; // a newer fetch superseded us
set({ scriptCount: total });
} catch {
if (getScriptCountSeq() !== seq) return;
// Leave previous value in place; the dashboard already tolerates
// a stale count by rendering `scriptCount ?? 0`. Don't toast —
// the dashboard's other metrics are best-effort.
} finally {
if (getScriptCountSeq() === seq) {
set({ scriptCountLoading: false });
}
}
},
};
};
// 提取 jupyter-accessible 路径的父目录;用于 `loadDataResources` 局部替换时
// 判断一条缓存资源是否落在目标 parent_path 下(list-resources 按 parent_path
// 精确匹配,非递归)。
function parentPathOf(path: string): string {
const parts = path.split("/");
parts.pop();
return parts.join("/");
}