refactor: scriptWorkspaceStore.ts

This commit is contained in:
tao.chen
2026-08-25 11:12:22 +08:00
parent 2ed8d7b2cc
commit baa1e04af6
12 changed files with 2133 additions and 1446 deletions
@@ -0,0 +1,339 @@
// ---- 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 });
}
}
},
};
};