refactor: scriptWorkspaceStore.ts
This commit is contained in:
@@ -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 的旧资源再并入 fresh(fresh 覆盖
|
||||
// 同 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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user