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

339 lines
13 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();
set({ dataResourcesLoading: true });
try {
const list = await api.listResources(parentPath, { ownerUserId });
const fresh = Array.isArray(list) ? list : [];
set((state) => {
// 按 owner 范围合并:丢弃该 owner 的旧资源再并入 freshfresh 覆盖
// 同 id)。owner 缺省=我,故根加载/刷新我的数据时替换我的一级资源。
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
const kept = state.dataResources.filter(
(r) => r.owner_user_id !== targetOwner,
);
const byId = new Map(kept.map((r) => [r.resource_id, r]));
for (const item of fresh) byId.set(item.resource_id, item);
return { dataResources: Array.from(byId.values()) };
});
} catch {
set((state) => {
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
return {
dataResources: state.dataResources.filter(
(r) => r.owner_user_id !== targetOwner,
),
};
});
} 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 });
}
}
},
};
};