339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
// ---- 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 });
|
||
}
|
||
}
|
||
},
|
||
};
|
||
}; |