refactor: scriptWorkspaceStore.ts
This commit is contained in:
@@ -102,6 +102,25 @@ Lessons from splitting `frontend/app/features/platform/ModelPlatformApp.tsx` (10
|
||||
- **An unmatched nested child leaves `<Outlet />` blank → white screen inside the layout.** Always cover `/` either with `index(...)` or by letting the parent layout `<Navigate>` on a location check. `/login` is the only top-level path that escapes this trap.
|
||||
- **`pnpm typecheck` runs `react-router typegen && tsc`.** Type errors from the generated `+types/...` files surface here too. New route files must be registered in `routes.ts` first.
|
||||
|
||||
### Frontend store slice split (zustand)
|
||||
|
||||
Lessons from splitting `frontend/app/features/schedules/state/schedulesStore.ts` (1251 lines, 40KB) into 8 files: `types.ts`, `helpers.ts`, `canvasSlice.ts`, `listSlice.ts`, `dialogSlice.ts`, `runsSlice.ts`, `logsSlice.ts`, `useSchedulesStore.ts`. Public exports (`useSchedulesStore`, `bindSchedulesApi`) and selector protocol unchanged.
|
||||
|
||||
- **`StateCreator<any, ...>` cascades implicit `any` through the slice body.** Writing `StateCreator<any, [], [], SliceState & SliceActions>` to give `get()` cross-slice access makes every nested call (`get().schedules.filter((item) => ...)`) fail with TS7006 — `eslint-disable @typescript-eslint/no-explicit-any` does NOT save you, because TS still infers `any`. Fix: declare a combined `SchedulesStore = State & Actions` type in the **root composition file** (`useSchedulesStore.ts`), then each slice `import type { SchedulesStore } from "./useSchedulesStore"` and uses `StateCreator<SchedulesStore, [], [], SliceState & SliceActions>`. TypeScript accepts the circular `import type` because it erases at build time. `get()` now returns a fully typed snapshot and `.map((item) => ...)` infers correctly.
|
||||
- **Don't write `<S extends ListFieldSlice & { positionDraftCount: number; busy: string | null; refreshLists: ... }>` constraints on cross-slice helpers.** Every helper needs to know about every other slice's fields, and the constraint chain keeps growing. Better: have helpers (`withMutation`, `applyServerUpdatedSchedule`, `handleError`) take `set: (partial: Partial<SchedulesStore> | ((s: SchedulesStore) => Partial<SchedulesStore>)) => void` and `get: () => SchedulesStore` directly. `SchedulesStore` already enumerates everything; no constraint to extend.
|
||||
- **Inner-closure helpers become standalone functions.** Monolithic `create((set, get) => { async function withMutation(...) { ... } })` captures `set`/`get` implicitly. When splitting the store you must reify these as exported functions in `helpers.ts` taking `(set, get)` arguments. This forces signatures to spell out exactly which fields they touch — which is what makes the `SchedulesStore`-typed approach pay off.
|
||||
- **"De-duplicate" requires value comparison, not just name matching.** Plan item "make `constants.ts` re-export `EMPTY_SCHEDULE_FORM` from `state/types`" looks like obvious dedup, but the values differ:
|
||||
```ts
|
||||
// constants.ts (utils.ts imports this)
|
||||
cronExpression: "0 9 * * *",
|
||||
// state/types (canvasSlice initial state)
|
||||
cronExpression: "",
|
||||
```
|
||||
Consolidating silently changes `utils.ts`'s runtime defaults. Per "never break userspace", leave `constants.ts` alone. **Rule: before any dedup, grep both call sites and diff the actual values, not just the symbol names.**
|
||||
- **Cross-slice field ownership belongs to layout, not data.** `scheduleKeyword` / `artifactKeyword` filter inputs look like listSlice state because they filter schedules, but they're bound to the left-panel UI and updated by the canvas layout component — they belong in `canvasSlice`. Decision rule: "which layout component writes this field?" not "which data does this field filter?".
|
||||
- **`positionDrafts` Map stays at module scope in `helpers.ts`.** It must NOT move into `CanvasSliceState`. The original behavior — `reset()` does NOT clear drag-in-progress drafts — is a feature; users expect their unsaved drag to survive reset. If you move it into slice state, audit every `clearAllPositionDrafts()` call site and decide whether each should fire on reset.
|
||||
- **`reset()` mirrors original `set({ ...initial, loading: true })` semantics.** The legacy reset wipes ALL slice fields including user-edited `scheduleForm` / `nodeForm` (because `initial.scheduleForm = EMPTY_SCHEDULE_FORM`). Any "preserve user input in reset()" change silently diverges from original behavior. If you want to preserve form values, do it as an intentional new feature with its own API, not a side-effect of refactoring.
|
||||
|
||||
### Frontend coupling
|
||||
|
||||
- **`UserManagementPage.tsx` and `api.ts` still call `/api/v1/admin/employees`.** Don't migrate them in the same change as a `/api/v1/platform/employees` addition — the contract surface is intentionally duplicated.
|
||||
@@ -0,0 +1,227 @@
|
||||
// ---- editSessionSlice ----
|
||||
//
|
||||
// 拥有 editSession / embeddedJupyterUrl / editBusy / editorOpenError。
|
||||
// Actions: openScriptEditor / endEditing / tickCleanup / releaseActiveOnUnload。
|
||||
// tickCleanup/releaseActiveOnUnload 放在这里因为它们都围绕 editSession 的生命周期。
|
||||
//
|
||||
// 模块级状态 (_editSession / sessionCache / _editorOpening /
|
||||
// _editorOpenRequest / _api) 通过 helpers 访问。
|
||||
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { ActiveEditSession, ScriptItem } from "~/services/api";
|
||||
|
||||
import {
|
||||
applyEditSessionState,
|
||||
bumpEditorOpenRequest,
|
||||
getApi,
|
||||
getEditorOpening,
|
||||
getEditorOpenRequest,
|
||||
getSelectedId,
|
||||
pushToast,
|
||||
requireApi,
|
||||
sessionCache,
|
||||
setEditorOpening,
|
||||
} from "./helpers";
|
||||
|
||||
import type {
|
||||
EditSessionSliceActions,
|
||||
EditSessionSliceState,
|
||||
} from "./types";
|
||||
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
|
||||
|
||||
export const createEditSessionSlice: StateCreator<
|
||||
ScriptWorkspaceStore,
|
||||
[],
|
||||
[],
|
||||
EditSessionSliceState & EditSessionSliceActions
|
||||
> = (set, get) => {
|
||||
const initial: EditSessionSliceState = {
|
||||
editSession: null,
|
||||
embeddedJupyterUrl: null,
|
||||
editBusy: false,
|
||||
editorOpenError: null,
|
||||
};
|
||||
|
||||
return {
|
||||
...initial,
|
||||
|
||||
openScriptEditor: async (script: ScriptItem, showToast = true) => {
|
||||
if (!script) return;
|
||||
if (getEditorOpening()) return;
|
||||
setEditorOpening(true);
|
||||
const api = requireApi();
|
||||
const requestId = getEditorOpenRequest() + 1;
|
||||
// 记录本次请求的 id (供 requestIsCurrent 校验)。
|
||||
// 原版用模块级 _editorOpenRequest 配合闭包变量;现在 requestIsCurrent
|
||||
// 通过 helpers 读取最新的模块级值 + getSelectedId()。
|
||||
const requestIsCurrent = () =>
|
||||
getEditorOpenRequest() === requestId &&
|
||||
getSelectedId() === script.script_id;
|
||||
const clearSessionIfActive = (session: ActiveEditSession) => {
|
||||
const active = get().editSession;
|
||||
if (active?.edit_session_id === session.edit_session_id) {
|
||||
applyEditSessionState((p) => set(p), null, null);
|
||||
}
|
||||
};
|
||||
|
||||
set({ editBusy: true });
|
||||
set((state) => ({
|
||||
editorOpenError:
|
||||
state.editorOpenError?.scriptId === script.script_id
|
||||
? null
|
||||
: state.editorOpenError,
|
||||
}));
|
||||
|
||||
try {
|
||||
const cached = sessionCache.get(script.script_id);
|
||||
if (cached) {
|
||||
if (!requestIsCurrent()) return;
|
||||
applyEditSessionState(
|
||||
(p) => set(p),
|
||||
cached.session,
|
||||
cached.jupyterUrl,
|
||||
);
|
||||
if (showToast) {
|
||||
pushToast(
|
||||
"success",
|
||||
`${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let active = get().editSession;
|
||||
let newlyAcquired = false;
|
||||
if (active && active.script_id !== script.script_id) {
|
||||
await api.releaseFileLock(active);
|
||||
applyEditSessionState((p) => set(p), null, null);
|
||||
active = null;
|
||||
}
|
||||
if (!requestIsCurrent()) return;
|
||||
|
||||
if (!active) {
|
||||
active = await api.acquireFileLock(script);
|
||||
newlyAcquired = true;
|
||||
}
|
||||
if (!requestIsCurrent()) {
|
||||
if (active) {
|
||||
await api.releaseFileLock(active);
|
||||
clearSessionIfActive(active);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = await api.createJupyterAccessTicket(active);
|
||||
if (!requestIsCurrent()) {
|
||||
if (newlyAcquired) {
|
||||
await api.releaseFileLock(active);
|
||||
clearSessionIfActive(active);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const readySession = {
|
||||
...active,
|
||||
ticket_expires_at: ticket.expires_at,
|
||||
};
|
||||
applyEditSessionState(
|
||||
(p) => set(p),
|
||||
readySession,
|
||||
ticket.jupyter_url,
|
||||
);
|
||||
sessionCache.set(script.script_id, {
|
||||
session: readySession,
|
||||
jupyterUrl: ticket.jupyter_url,
|
||||
lastActiveTime: Date.now(),
|
||||
});
|
||||
if (showToast) {
|
||||
pushToast(
|
||||
"success",
|
||||
`${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
applyEditSessionState((p) => set(p), null, null);
|
||||
if (requestIsCurrent()) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "打开编辑器失败";
|
||||
set({ editorOpenError: { scriptId: script.script_id, message } });
|
||||
if (showToast) {
|
||||
pushToast("error", message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setEditorOpening(false);
|
||||
set({ editBusy: false });
|
||||
}
|
||||
},
|
||||
|
||||
endEditing: async (closeTabFlag = true, showToast = true) => {
|
||||
const api = requireApi();
|
||||
// bump editorOpenRequest 让未完成的 openScriptEditor 失效
|
||||
bumpEditorOpenRequest();
|
||||
set({ editorOpenError: null });
|
||||
const active = get().editSession;
|
||||
const scriptId = active?.script_id;
|
||||
if (!active) {
|
||||
if (closeTabFlag && scriptId) {
|
||||
await get().closeTab(scriptId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
set({ editBusy: true });
|
||||
try {
|
||||
await api.releaseFileLock(active);
|
||||
applyEditSessionState((p) => set(p), null, null);
|
||||
if (scriptId) sessionCache.delete(scriptId);
|
||||
if (closeTabFlag && scriptId) {
|
||||
await get().closeTab(scriptId);
|
||||
}
|
||||
if (showToast) {
|
||||
pushToast("success", `${active.script_name} 的编辑锁已释放`);
|
||||
}
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "释放编辑锁失败",
|
||||
);
|
||||
} finally {
|
||||
set({ editBusy: false });
|
||||
}
|
||||
},
|
||||
|
||||
tickCleanup: () => {
|
||||
// 本地锁:清缓存即可,不要发 releaseFileLock 请求。
|
||||
// 接口是异步的,但在本地实现里等价于无操作,promise 没人 await。
|
||||
const TEN_MINUTES = 10 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const toCleanup: string[] = [];
|
||||
const selectedId = getSelectedId();
|
||||
for (const [scriptId, cached] of sessionCache.entries()) {
|
||||
if (scriptId !== selectedId) {
|
||||
const inactiveTime = now - cached.lastActiveTime;
|
||||
if (inactiveTime > TEN_MINUTES) {
|
||||
toCleanup.push(scriptId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toCleanup.length === 0) return;
|
||||
for (const scriptId of toCleanup) {
|
||||
sessionCache.delete(scriptId);
|
||||
}
|
||||
pushToast(
|
||||
"info",
|
||||
`已清理 ${toCleanup.length} 个长时间未活动的本地编辑会话`,
|
||||
);
|
||||
},
|
||||
|
||||
releaseActiveOnUnload: () => {
|
||||
const api = getApi();
|
||||
if (!api) return;
|
||||
const current = get().editSession;
|
||||
if (current) {
|
||||
api.releaseFileLockOnUnload(current);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
// ---- Module-level non-reactive holders ----
|
||||
// 这些变量不放在 store 里 (避免 React 重渲 + 跨切片共享单例)。
|
||||
// 每一个切片在 helpers.ts 顶层导入它们,确保所有 slice 看到的同一份。
|
||||
|
||||
import type {
|
||||
ActiveEditSession,
|
||||
ScriptItem,
|
||||
WorkspaceBoundApi,
|
||||
} from "~/services/api";
|
||||
|
||||
import { useUiStore } from "./uiStore";
|
||||
|
||||
// 缓存的会话类型(多 iframe 共存方案)
|
||||
type CachedSession = {
|
||||
session: ActiveEditSession;
|
||||
jupyterUrl: string;
|
||||
lastActiveTime: number;
|
||||
};
|
||||
|
||||
export type PythonEditorBuffer = {
|
||||
initialContent: string | null;
|
||||
content: string | null;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
initial: boolean;
|
||||
loadError: string | null;
|
||||
};
|
||||
|
||||
// 模块级可变 holder(非响应式,避免 React 重渲)
|
||||
export const sessionCache = new Map<string, CachedSession>();
|
||||
let _selectedId: string | null = null;
|
||||
let _editSession: ActiveEditSession | null = null;
|
||||
let _editorOpening = false;
|
||||
let _editorOpenRequest = 0;
|
||||
let _api: WorkspaceBoundApi | null = null;
|
||||
let _previewController: AbortController | null = null;
|
||||
let _previewRequest = 0;
|
||||
let _pythonEditorOpeningIds = new Set<string>();
|
||||
let _scriptCountSeq = 0;
|
||||
// 当前登录用户 id —— 与 `_api` 一样由 layout 在 render body 绑定。
|
||||
// 用于:(1) `loadScripts`/`loadOwnerGroup` 区分"我"与他人;
|
||||
// (2) `toggleExpanded` 判定展开真实目录时是否需要 loadChildren(他人的
|
||||
// 目录全靠脚本路径推断,不调 listWorkspaceDirectories)。
|
||||
let _currentUserId: string | null = null;
|
||||
// 当前工作区 id —— listWorkspaceMembers 不像 listScripts 那样把 workspaceId
|
||||
// 烤进 bound api,需要显式传入,故由 layout 绑定。
|
||||
let _workspaceId: string | null = null;
|
||||
|
||||
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
|
||||
_api = api;
|
||||
};
|
||||
|
||||
export const bindScriptWorkspaceUser = (userId: string | null) => {
|
||||
_currentUserId = userId;
|
||||
};
|
||||
|
||||
export const bindScriptWorkspaceId = (workspaceId: string | null) => {
|
||||
_workspaceId = workspaceId;
|
||||
};
|
||||
|
||||
// `loadedScriptPaths` / `loadedChildPaths` 的 key 命名空间:
|
||||
// `${owner_user_id}:${parent_path}`,让"我"与他人的同名子目录缓存互不串扰。
|
||||
// owner 缺省时回退当前用户,再回退字面量 "me"(仅作占位 key,不会发到后端)。
|
||||
export function ownerCacheKey(
|
||||
ownerUserId: string | undefined,
|
||||
path: string,
|
||||
): string {
|
||||
const owner = ownerUserId ?? _currentUserId ?? "me";
|
||||
return `${owner}:${path}`;
|
||||
}
|
||||
|
||||
export const getSessionCache = () => sessionCache;
|
||||
export const clearSessionCache = () => {
|
||||
sessionCache.clear();
|
||||
};
|
||||
|
||||
// handle ref 给 Sidebar 用,避免订阅 store
|
||||
export const editSessionHandle: { current: ActiveEditSession | null } = {
|
||||
current: null,
|
||||
};
|
||||
|
||||
// ---- 跨切片共享的内部 getters (供切片实现使用) ----
|
||||
|
||||
export function getApi(): WorkspaceBoundApi | null {
|
||||
return _api;
|
||||
}
|
||||
|
||||
export function getCurrentUserId(): string | null {
|
||||
return _currentUserId;
|
||||
}
|
||||
|
||||
export function getWorkspaceId(): string | null {
|
||||
return _workspaceId;
|
||||
}
|
||||
|
||||
export function getSelectedId(): string | null {
|
||||
return _selectedId;
|
||||
}
|
||||
|
||||
export function setSelectedId(value: string | null): void {
|
||||
_selectedId = value;
|
||||
}
|
||||
|
||||
export function getEditSession(): ActiveEditSession | null {
|
||||
return _editSession;
|
||||
}
|
||||
|
||||
export function getEditorOpening(): boolean {
|
||||
return _editorOpening;
|
||||
}
|
||||
|
||||
export function setEditorOpening(value: boolean): void {
|
||||
_editorOpening = value;
|
||||
}
|
||||
|
||||
export function getEditorOpenRequest(): number {
|
||||
return _editorOpenRequest;
|
||||
}
|
||||
|
||||
export function bumpEditorOpenRequest(): number {
|
||||
_editorOpenRequest += 1;
|
||||
return _editorOpenRequest;
|
||||
}
|
||||
|
||||
export function getPreviewController(): AbortController | null {
|
||||
return _previewController;
|
||||
}
|
||||
|
||||
export function setPreviewController(value: AbortController | null): void {
|
||||
_previewController = value;
|
||||
}
|
||||
|
||||
export function bumpPreviewRequest(): number {
|
||||
_previewRequest += 1;
|
||||
return _previewRequest;
|
||||
}
|
||||
|
||||
export function getPreviewRequest(): number {
|
||||
return _previewRequest;
|
||||
}
|
||||
|
||||
export function getPythonEditorOpeningIds(): Set<string> {
|
||||
return _pythonEditorOpeningIds;
|
||||
}
|
||||
|
||||
export function bumpScriptCountSeq(): number {
|
||||
_scriptCountSeq += 1;
|
||||
return _scriptCountSeq;
|
||||
}
|
||||
|
||||
export function getScriptCountSeq(): number {
|
||||
return _scriptCountSeq;
|
||||
}
|
||||
|
||||
// ---- 工具函数 (原模块级 helper, 不依赖 set/get) ----
|
||||
|
||||
export function requireApi(): WorkspaceBoundApi {
|
||||
if (!_api) {
|
||||
throw new Error("script workspace API 未绑定");
|
||||
}
|
||||
return _api;
|
||||
}
|
||||
|
||||
export function ownedScriptPath(item: ScriptItem) {
|
||||
return item.relative_path
|
||||
.replaceAll("\\", "/")
|
||||
.split("/")
|
||||
.slice(2)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export function pushToast(
|
||||
tone: "success" | "error" | "info",
|
||||
message: string,
|
||||
): void {
|
||||
useUiStore.getState().pushToast({ tone, message });
|
||||
}
|
||||
|
||||
export async function sha256Hex(
|
||||
buffer: ArrayBuffer,
|
||||
): Promise<string | null> {
|
||||
// `crypto.subtle` 仅在 secure context(HTTPS / localhost)下可用;不可用时
|
||||
// 跳过 hash 计算,让后端走非校验路径。
|
||||
if (typeof crypto === "undefined" || !crypto.subtle?.digest) {
|
||||
return null;
|
||||
}
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
// ---- setEditSessionState (跨切片 helper, 原 store 闭包内 function) ----
|
||||
//
|
||||
// 必须在每个使用它的 slice 里手动传 `set`,因为 store 不再是单 closure。
|
||||
// 内部更新模块级 `_editSession` + `editSessionHandle.current`,再 `set` 切片 state。
|
||||
export function applyEditSessionState(
|
||||
set: (partial: {
|
||||
editSession: ActiveEditSession | null;
|
||||
embeddedJupyterUrl: string | null;
|
||||
}) => void,
|
||||
next: ActiveEditSession | null,
|
||||
nextJupyterUrl: string | null,
|
||||
): void {
|
||||
_editSession = next;
|
||||
editSessionHandle.current = next;
|
||||
set({
|
||||
editSession: next,
|
||||
embeddedJupyterUrl: next ? nextJupyterUrl : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 模块级 reset (供 useScriptWorkspaceStore.reset 调用) ----
|
||||
//
|
||||
// 同步模块级可变状态 + abort preview controller;不写响应式 state(state 由根 reset 处理)。
|
||||
export function resetModuleState(): void {
|
||||
if (_previewController) {
|
||||
_previewController.abort();
|
||||
_previewController = null;
|
||||
}
|
||||
_previewRequest += 1;
|
||||
_selectedId = null;
|
||||
_editSession = null;
|
||||
editSessionHandle.current = null;
|
||||
sessionCache.clear();
|
||||
_pythonEditorOpeningIds.clear();
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// ---- mutationsSlice ----
|
||||
//
|
||||
// 拥有 latestVersion / latestVersionLoading。
|
||||
// 所有写后端的 action (CRUD + 发布 + 锁) 集中在这里。
|
||||
//
|
||||
// cross-slice 写:
|
||||
// - createScript 写 scripts (scriptsSlice) + 调 openTab (selectionSlice)
|
||||
// - uploadScripts 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) +
|
||||
// 调 load (scriptsSlice)
|
||||
// - uploadDataResource 不写 store state (只走 uiStore)
|
||||
// - createFolder 写 loadedChildPaths/directories/expandedPaths (treeSlice) +
|
||||
// 调 loadChildren / load (treeSlice/scriptsSlice)
|
||||
// - deleteScript 写 openTabIds/selectedId (selectionSlice) + 调
|
||||
// endEditing (editSessionSlice) + 调 load (scriptsSlice)
|
||||
// - deleteDataResource 写 dataResources (scriptsSlice)
|
||||
// - deleteDirectory 写 loadedChildPaths/expandedPaths/directories (treeSlice)
|
||||
// + 调 selectScript (selectionSlice) + 调 loadChildren / load
|
||||
// - toggleScriptLock 写 scripts (scriptsSlice)
|
||||
// - submitPublish 不写 store state (只走 uiStore)
|
||||
// - openPublishDialog 委托 uiStore (selectionSlice 也有,这里只放空,实际由
|
||||
// selectionSlice.openPublishDialog 提供——不要重复定义,这里只保留版本相关)
|
||||
|
||||
import type { StateCreator } from "zustand";
|
||||
import type {
|
||||
ResourceItem,
|
||||
ScriptItem,
|
||||
StableVersion,
|
||||
Visibility,
|
||||
} from "~/services/api";
|
||||
|
||||
import { useUiStore } from "./uiStore";
|
||||
|
||||
import {
|
||||
getCurrentUserId,
|
||||
getEditSession,
|
||||
getSelectedId,
|
||||
ownedScriptPath,
|
||||
ownerCacheKey,
|
||||
pushToast,
|
||||
requireApi,
|
||||
sha256Hex,
|
||||
} from "./helpers";
|
||||
import type { NewScriptForm } from "./uiStore";
|
||||
|
||||
import type {
|
||||
DataResourceMeta,
|
||||
MutationsSliceActions,
|
||||
MutationsSliceState,
|
||||
} from "./types";
|
||||
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
|
||||
|
||||
export const createMutationsSlice: StateCreator<
|
||||
ScriptWorkspaceStore,
|
||||
[],
|
||||
[],
|
||||
MutationsSliceState & MutationsSliceActions
|
||||
> = (set, get) => {
|
||||
const initial: MutationsSliceState = {
|
||||
latestVersion: null,
|
||||
latestVersionLoading: false,
|
||||
};
|
||||
|
||||
return {
|
||||
...initial,
|
||||
|
||||
loadLatestVersion: async (scriptId) => {
|
||||
const api = requireApi();
|
||||
set({ latestVersionLoading: true });
|
||||
try {
|
||||
const item = await api.getLatestScriptVersion(scriptId);
|
||||
set({ latestVersion: item });
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "最新版本加载失败",
|
||||
);
|
||||
} finally {
|
||||
set({ latestVersionLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createScript: async (form: NewScriptForm) => {
|
||||
const api = requireApi();
|
||||
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
|
||||
const requestedName = form.name.trim();
|
||||
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
|
||||
? requestedName
|
||||
: `${requestedName}${suffix}`;
|
||||
const duplicate = get().scripts.some((script) => {
|
||||
if (script.script_type !== form.scriptType) return false;
|
||||
if (script.script_name.toLocaleLowerCase()
|
||||
!== normalizedName.toLocaleLowerCase()) return false;
|
||||
// Same name in a different subdirectory is allowed: mirror the
|
||||
// backend name_clash check, which JOINs StorageObjects and scopes
|
||||
// by relative_path.
|
||||
const existingUserPath = ownedScriptPath(script);
|
||||
const existingParent = existingUserPath.includes("/")
|
||||
? existingUserPath.slice(0, existingUserPath.lastIndexOf("/"))
|
||||
: "";
|
||||
return existingParent === form.parentPath;
|
||||
});
|
||||
if (duplicate) {
|
||||
pushToast("error", `${normalizedName} 已存在,请更换名称`);
|
||||
return null;
|
||||
}
|
||||
const ui = useUiStore.getState();
|
||||
ui.setCreating(true);
|
||||
try {
|
||||
const created = await api.createScript(form);
|
||||
set((state) => ({ scripts: [created, ...state.scripts] }));
|
||||
get().openTab(created.script_id);
|
||||
ui.closeCreateDialog();
|
||||
pushToast("success", `${created.script_name} 已创建`);
|
||||
return created;
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "创建失败",
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
ui.setCreating(false);
|
||||
}
|
||||
},
|
||||
|
||||
uploadScripts: async (files, parentPath) => {
|
||||
const api = requireApi();
|
||||
const ui = useUiStore.getState();
|
||||
ui.setUploading(true);
|
||||
let lastCreated: ScriptItem | null = null;
|
||||
try {
|
||||
for (const file of files) {
|
||||
lastCreated = await api.uploadScript(file, parentPath, "workspace");
|
||||
}
|
||||
set((state) => ({ scripts: [lastCreated!, ...state.scripts] }));
|
||||
if (lastCreated) {
|
||||
get().openTab(lastCreated.script_id);
|
||||
}
|
||||
pushToast(
|
||||
"success",
|
||||
`${files.length} 个文件已上传到${parentPath ? ` ${parentPath}` : "当前目录"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
await get().load(true);
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "文件上传失败",
|
||||
);
|
||||
} finally {
|
||||
ui.setUploading(false);
|
||||
}
|
||||
},
|
||||
|
||||
uploadDataResource: async (file: File, meta: DataResourceMeta) => {
|
||||
const api = requireApi();
|
||||
const ui = useUiStore.getState();
|
||||
ui.setDataResourceUploading(true);
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const hash = await sha256Hex(buffer);
|
||||
const { upload_id: uploadId } = await api.createResourceUpload({
|
||||
file_name: file.name,
|
||||
content_type: file.type || "application/octet-stream",
|
||||
expected_size_bytes: file.size,
|
||||
expected_hash: hash,
|
||||
target_path: meta.targetPath,
|
||||
});
|
||||
await api.uploadResourceBytes(
|
||||
uploadId,
|
||||
buffer,
|
||||
file.type || "application/octet-stream",
|
||||
);
|
||||
const resource = await api.bindResourceUpload(uploadId, {
|
||||
resource_name: meta.resourceName,
|
||||
description: meta.description,
|
||||
visibility: meta.visibility,
|
||||
});
|
||||
ui.closeDataResourceDialog();
|
||||
pushToast(
|
||||
"success",
|
||||
`数据资源 ${resource.resource_name} 上传成功,路径:${resource.jupyter_accessible_path}`,
|
||||
);
|
||||
return resource;
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "数据资源上传失败",
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
ui.setDataResourceUploading(false);
|
||||
}
|
||||
},
|
||||
|
||||
deleteDataResource: async (resourceId) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (!window.confirm(`确定删除数据资源吗?稳定版本会保留。`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteResource(resourceId);
|
||||
set((state) => ({
|
||||
dataResources: state.dataResources.filter(
|
||||
(r) => r.resource_id !== resourceId,
|
||||
),
|
||||
}));
|
||||
pushToast("success", "数据资源已删除");
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "数据资源删除失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
createFolder: async (name, parentPath) => {
|
||||
const api = requireApi();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const ui = useUiStore.getState();
|
||||
ui.setFolderBusy(true);
|
||||
try {
|
||||
await api.createWorkspaceDirectory(trimmed, parentPath);
|
||||
if (parentPath === "") {
|
||||
await get().load(true);
|
||||
} else {
|
||||
const me = getCurrentUserId() ?? "me";
|
||||
const parentKey = ownerCacheKey(me, parentPath);
|
||||
set((state) => {
|
||||
const nextLoaded = new Set(state.loadedChildPaths);
|
||||
for (const p of state.loadedChildPaths) {
|
||||
// 仅失效"我"在该 parent 之下的缓存(namespaced key)。
|
||||
if (
|
||||
p.startsWith(`${me}:`) &&
|
||||
p.slice(me.length + 1).startsWith(`${parentPath}/`)
|
||||
) {
|
||||
nextLoaded.delete(p);
|
||||
}
|
||||
}
|
||||
nextLoaded.delete(parentKey);
|
||||
return {
|
||||
loadedChildPaths: nextLoaded,
|
||||
directories: state.directories.filter(
|
||||
(d) =>
|
||||
!(
|
||||
d.owner_user_id === me
|
||||
&& d.parent_path.startsWith(`${parentPath}/`)
|
||||
),
|
||||
),
|
||||
expandedPaths: new Set(state.expandedPaths),
|
||||
};
|
||||
});
|
||||
await get().loadChildren(parentPath);
|
||||
}
|
||||
ui.closeFolderDialog();
|
||||
pushToast("success", `${trimmed} 文件夹已创建`);
|
||||
} catch (error) {
|
||||
ui.setFolderBusy(false);
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "文件夹创建失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
deleteScript: async (script: ScriptItem) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (
|
||||
!window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const editSession = getEditSession();
|
||||
if (editSession?.script_id === script.script_id) {
|
||||
await get().endEditing(false, false);
|
||||
if (getEditSession()?.script_id === script.script_id) return;
|
||||
}
|
||||
try {
|
||||
await api.deleteScript(script.script_id);
|
||||
set((state) => {
|
||||
const index = state.openTabIds.indexOf(script.script_id);
|
||||
const newTabs = state.openTabIds.filter(
|
||||
(id) => id !== script.script_id,
|
||||
);
|
||||
if (getSelectedId() === script.script_id) {
|
||||
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
|
||||
return { openTabIds: newTabs, selectedId: nextId };
|
||||
}
|
||||
return { openTabIds: newTabs };
|
||||
});
|
||||
await get().load(true);
|
||||
pushToast("success", `${script.script_name} 已删除`);
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "文件删除失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
deleteDirectory: async (path) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (
|
||||
!window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const activeScript = get().scripts.find(
|
||||
(item) => item.script_id === getEditSession()?.script_id,
|
||||
);
|
||||
if (
|
||||
activeScript
|
||||
&& (ownedScriptPath(activeScript) === path
|
||||
|| ownedScriptPath(activeScript).startsWith(`${path}/`))
|
||||
) {
|
||||
await get().endEditing(false, false);
|
||||
if (getEditSession()?.script_id === activeScript.script_id) return;
|
||||
}
|
||||
const parentPath = path.includes("/")
|
||||
? path.split("/").slice(0, -1).join("/")
|
||||
: "";
|
||||
try {
|
||||
const result = await api.deleteWorkspaceDirectory(path);
|
||||
const selectedScript = get().scripts.find(
|
||||
(item) => item.script_id === getSelectedId(),
|
||||
);
|
||||
if (
|
||||
selectedScript
|
||||
&& ownedScriptPath(selectedScript).startsWith(`${path}/`)
|
||||
) {
|
||||
get().selectScript(null);
|
||||
}
|
||||
const me = getCurrentUserId() ?? "me";
|
||||
const pathKey = ownerCacheKey(me, path);
|
||||
set((state) => {
|
||||
const nextLoaded = new Set(state.loadedChildPaths);
|
||||
const nextExpanded = new Set(state.expandedPaths);
|
||||
for (const p of state.loadedChildPaths) {
|
||||
// 仅失效"我"该 path 及其子目录的缓存(namespaced key)。
|
||||
if (!p.startsWith(`${me}:`)) continue;
|
||||
const bare = p.slice(me.length + 1);
|
||||
if (bare === path || bare.startsWith(`${path}/`)) {
|
||||
nextLoaded.delete(p);
|
||||
}
|
||||
}
|
||||
for (const p of state.expandedPaths) {
|
||||
if (p === path || p.startsWith(`${path}/`)) nextExpanded.delete(p);
|
||||
}
|
||||
void pathKey;
|
||||
return {
|
||||
loadedChildPaths: nextLoaded,
|
||||
expandedPaths: nextExpanded,
|
||||
directories: state.directories.filter(
|
||||
(d) =>
|
||||
!(
|
||||
d.owner_user_id === me
|
||||
&& (d.parent_path === path
|
||||
|| d.parent_path.startsWith(`${path}/`))
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
if (parentPath === "") {
|
||||
await get().load(true);
|
||||
} else {
|
||||
await get().loadChildren(parentPath);
|
||||
}
|
||||
pushToast(
|
||||
"success",
|
||||
`${path} 已删除(含 ${result.deleted_scripts} 个脚本)`,
|
||||
);
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "文件夹删除失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
toggleScriptLock: async (script: ScriptItem) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
try {
|
||||
const updated = await api.setScriptLock(script.script_id, !script.is_locked);
|
||||
set((state) => ({
|
||||
scripts: state.scripts.map((s) =>
|
||||
s.script_id === updated.script_id ? updated : s,
|
||||
),
|
||||
}));
|
||||
pushToast(
|
||||
"success",
|
||||
`${updated.script_name} 已${updated.is_locked ? "锁定" : "解锁"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "锁定状态更新失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
submitPublish: async (releaseNote: string, visibility: Visibility) => {
|
||||
const api = requireApi();
|
||||
const ui = useUiStore.getState();
|
||||
const target = ui.publish.target;
|
||||
if (!target) return;
|
||||
ui.setPublishing(true);
|
||||
try {
|
||||
const version: StableVersion = await api.publishScriptVersion({
|
||||
script: target,
|
||||
releaseNote,
|
||||
visibility,
|
||||
});
|
||||
ui.setPublishedVersion(version);
|
||||
pushToast("success", `${version.version_label} 稳定版本发布成功`);
|
||||
} catch (error) {
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "稳定版本发布失败",
|
||||
);
|
||||
} finally {
|
||||
ui.setPublishing(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
// ---- previewSlice ----
|
||||
//
|
||||
// 拥有 previewKey / previewCode / previewCodeSize / previewLoading / previewError。
|
||||
// Action: loadPreview (用 AbortController 取消旧请求 + request-id 序列号忽略过期响应)。
|
||||
|
||||
import type { StateCreator } from "zustand";
|
||||
|
||||
import {
|
||||
bumpPreviewRequest,
|
||||
getPreviewController,
|
||||
getPreviewRequest,
|
||||
setPreviewController,
|
||||
} from "./helpers";
|
||||
|
||||
import type {
|
||||
PreviewSliceActions,
|
||||
PreviewSliceState,
|
||||
} from "./types";
|
||||
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
|
||||
|
||||
export const createPreviewSlice: StateCreator<
|
||||
ScriptWorkspaceStore,
|
||||
[],
|
||||
[],
|
||||
PreviewSliceState & PreviewSliceActions
|
||||
> = (set) => {
|
||||
const initial: PreviewSliceState = {
|
||||
previewKey: null,
|
||||
previewCode: null,
|
||||
previewCodeSize: null,
|
||||
previewLoading: false,
|
||||
previewError: null,
|
||||
};
|
||||
|
||||
return {
|
||||
...initial,
|
||||
|
||||
loadPreview: async (workspaceId, filePath) => {
|
||||
const existing = getPreviewController();
|
||||
if (existing) {
|
||||
existing.abort();
|
||||
}
|
||||
const requestId = bumpPreviewRequest();
|
||||
const previewKey = `${workspaceId}::${filePath}`;
|
||||
|
||||
set({ previewKey, previewLoading: true, previewError: null });
|
||||
|
||||
const controller = new AbortController();
|
||||
setPreviewController(controller);
|
||||
|
||||
try {
|
||||
const url =
|
||||
`/jupyter/${workspaceId}/api/contents/${filePath}` +
|
||||
`?type=file&content=1&hash=1&format=text`;
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (getPreviewRequest() !== requestId) return;
|
||||
set({
|
||||
previewKey,
|
||||
previewCode: data.content ?? "",
|
||||
previewCodeSize: data.size ?? 0,
|
||||
previewLoading: false,
|
||||
previewError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (getPreviewRequest() !== requestId) return;
|
||||
if (error instanceof Error && error.name === "AbortError") return;
|
||||
set({
|
||||
previewKey,
|
||||
previewCode: null,
|
||||
previewCodeSize: null,
|
||||
previewLoading: false,
|
||||
previewError:
|
||||
error instanceof Error ? error.message : "加载预览失败",
|
||||
});
|
||||
} finally {
|
||||
if (getPreviewRequest() === requestId) {
|
||||
setPreviewController(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
// ---- pythonEditorSlice ----
|
||||
//
|
||||
// 拥有 pythonEditorBuffers (per-scriptId 临时编辑缓冲)。
|
||||
// Actions: openPythonEditor / setPythonEditorContent / savePythonEditor /
|
||||
// exitPythonEditor / exitAllPythonEditors。
|
||||
// 模块级 _pythonEditorOpeningIds (去重 in-flight open) 通过 helpers 访问。
|
||||
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { ScriptItem } from "~/services/api";
|
||||
|
||||
import {
|
||||
getPythonEditorOpeningIds,
|
||||
pushToast,
|
||||
requireApi,
|
||||
} from "./helpers";
|
||||
|
||||
import type {
|
||||
PythonEditorSliceActions,
|
||||
PythonEditorSliceState,
|
||||
} from "./types";
|
||||
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
|
||||
|
||||
export const createPythonEditorSlice: StateCreator<
|
||||
ScriptWorkspaceStore,
|
||||
[],
|
||||
[],
|
||||
PythonEditorSliceState & PythonEditorSliceActions
|
||||
> = (set, get) => {
|
||||
const initial: PythonEditorSliceState = {
|
||||
pythonEditorBuffers: {},
|
||||
};
|
||||
|
||||
return {
|
||||
...initial,
|
||||
|
||||
openPythonEditor: async (script: ScriptItem, showToast = true) => {
|
||||
if (!script) return;
|
||||
const opening = getPythonEditorOpeningIds();
|
||||
if (opening.has(script.script_id)) return;
|
||||
opening.add(script.script_id);
|
||||
try {
|
||||
const workspaceId = script.workspace_id;
|
||||
const url =
|
||||
`/jupyter/${workspaceId}/api/contents/${script.jupyter_path}` +
|
||||
`?type=file&content=1&hash=1&format=text`;
|
||||
const response = await fetch(url, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`加载文件失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const content = typeof data.content === "string" ? data.content : "";
|
||||
set((state) => ({
|
||||
pythonEditorBuffers: {
|
||||
...state.pythonEditorBuffers,
|
||||
[script.script_id]: {
|
||||
initialContent: content,
|
||||
content,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
initial: true,
|
||||
loadError: null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "打开 Python 编辑器失败";
|
||||
set((state) => ({
|
||||
pythonEditorBuffers: {
|
||||
...state.pythonEditorBuffers,
|
||||
[script.script_id]: {
|
||||
initialContent: null,
|
||||
content: null,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
initial: false,
|
||||
loadError: message,
|
||||
},
|
||||
},
|
||||
}));
|
||||
if (showToast) {
|
||||
pushToast("error", message);
|
||||
}
|
||||
} finally {
|
||||
opening.delete(script.script_id);
|
||||
}
|
||||
},
|
||||
|
||||
setPythonEditorContent: (scriptId, value) => {
|
||||
set((state) => {
|
||||
const buffer = state.pythonEditorBuffers[scriptId];
|
||||
if (!buffer) return state;
|
||||
return {
|
||||
pythonEditorBuffers: {
|
||||
...state.pythonEditorBuffers,
|
||||
[scriptId]: {
|
||||
...buffer,
|
||||
content: value,
|
||||
dirty: value !== buffer.initialContent,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
savePythonEditor: async (scriptId) => {
|
||||
const buffer = get().pythonEditorBuffers[scriptId];
|
||||
if (!buffer || buffer.content === null || !buffer.dirty || buffer.saving) {
|
||||
return;
|
||||
}
|
||||
const script = get().scripts.find((s) => s.script_id === scriptId);
|
||||
if (!script) return;
|
||||
const savedContent = buffer.content;
|
||||
set((state) => ({
|
||||
pythonEditorBuffers: {
|
||||
...state.pythonEditorBuffers,
|
||||
[scriptId]: { ...buffer, saving: true },
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const api = requireApi();
|
||||
await api.updateScript(scriptId, { content: savedContent });
|
||||
set((state) => {
|
||||
const current = state.pythonEditorBuffers[scriptId];
|
||||
if (!current) return state;
|
||||
return {
|
||||
pythonEditorBuffers: {
|
||||
...state.pythonEditorBuffers,
|
||||
[scriptId]: {
|
||||
...current,
|
||||
initialContent: savedContent,
|
||||
dirty: current.content !== savedContent,
|
||||
saving: false,
|
||||
initial: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
pushToast("success", "已保存");
|
||||
} catch (error) {
|
||||
set((state) => {
|
||||
const current = state.pythonEditorBuffers[scriptId];
|
||||
if (!current) return state;
|
||||
return {
|
||||
pythonEditorBuffers: {
|
||||
...state.pythonEditorBuffers,
|
||||
[scriptId]: { ...current, saving: false },
|
||||
},
|
||||
};
|
||||
});
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "保存失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
exitPythonEditor: (scriptId) => {
|
||||
set((state) => {
|
||||
const next = { ...state.pythonEditorBuffers };
|
||||
delete next[scriptId];
|
||||
return { pythonEditorBuffers: next };
|
||||
});
|
||||
},
|
||||
|
||||
exitAllPythonEditors: () => {
|
||||
const opening = getPythonEditorOpeningIds();
|
||||
opening.clear();
|
||||
set({ pythonEditorBuffers: {} });
|
||||
},
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
// ---- selectionSlice ----
|
||||
//
|
||||
// 拥有 selectedId / openTabIds / keyword + 选择/标签页/搜索关键词相关 action。
|
||||
// cross-slice 依赖: closeTab 调用 pythonEditorSlice.exitPythonEditor +
|
||||
// editSessionSlice.endEditing + helpers.sessionCache;
|
||||
// switchTab 触发 pythonEditorSlice.savePythonEditor + helpers.applyEditSessionState
|
||||
// (恢复缓存中的 editSession);
|
||||
// openPublishDialog 委托 uiStore。
|
||||
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { ScriptItem } from "~/services/api";
|
||||
|
||||
import {
|
||||
applyEditSessionState,
|
||||
bumpEditorOpenRequest,
|
||||
getSelectedId,
|
||||
sessionCache,
|
||||
setSelectedId,
|
||||
} from "./helpers";
|
||||
import { useUiStore } from "./uiStore";
|
||||
|
||||
import type {
|
||||
SelectionSliceActions,
|
||||
SelectionSliceState,
|
||||
} from "./types";
|
||||
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
|
||||
|
||||
export const createSelectionSlice: StateCreator<
|
||||
ScriptWorkspaceStore,
|
||||
[],
|
||||
[],
|
||||
SelectionSliceState & SelectionSliceActions
|
||||
> = (set, get) => {
|
||||
const initial: SelectionSliceState = {
|
||||
selectedId: null,
|
||||
openTabIds: [],
|
||||
keyword: "",
|
||||
};
|
||||
|
||||
return {
|
||||
...initial,
|
||||
|
||||
setKeyword: (keyword) => set({ keyword }),
|
||||
|
||||
selectScript: (id) => {
|
||||
setSelectedId(id);
|
||||
set({ selectedId: id });
|
||||
},
|
||||
|
||||
openTab: (id) => {
|
||||
const current = getSelectedId();
|
||||
if (current !== id) {
|
||||
bumpEditorOpenRequest();
|
||||
set({ editorOpenError: null });
|
||||
}
|
||||
setSelectedId(id);
|
||||
set((state) => ({
|
||||
selectedId: id,
|
||||
openTabIds: state.openTabIds.includes(id)
|
||||
? state.openTabIds
|
||||
: [...state.openTabIds, id],
|
||||
}));
|
||||
},
|
||||
|
||||
closeTab: async (id, event) => {
|
||||
event?.stopPropagation();
|
||||
const buffer = get().pythonEditorBuffers[id];
|
||||
if (buffer?.dirty && !buffer.saving) {
|
||||
const name =
|
||||
get().scripts.find((s) => s.script_id === id)?.script_name ?? "该脚本";
|
||||
const ok = window.confirm(`当前脚本有未保存修改,确定关闭 "${name}" 吗?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
if (buffer) {
|
||||
get().exitPythonEditor(id);
|
||||
}
|
||||
const editSession = get().editSession;
|
||||
if (editSession?.script_id === id) {
|
||||
await get().endEditing(false, false);
|
||||
}
|
||||
sessionCache.delete(id);
|
||||
set((state) => {
|
||||
const index = state.openTabIds.indexOf(id);
|
||||
if (index === -1) return {};
|
||||
const newTabs = state.openTabIds.filter((tabId) => tabId !== id);
|
||||
let nextSelected = state.selectedId;
|
||||
if (state.selectedId === id) {
|
||||
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
|
||||
setSelectedId(nextId);
|
||||
nextSelected = nextId;
|
||||
}
|
||||
return { openTabIds: newTabs, selectedId: nextSelected };
|
||||
});
|
||||
},
|
||||
|
||||
switchTab: (id) => {
|
||||
const current = get().selectedId;
|
||||
if (current && current !== id) {
|
||||
const curBuffer = get().pythonEditorBuffers[current];
|
||||
if (curBuffer?.dirty && !curBuffer.saving) {
|
||||
void get().savePythonEditor(current);
|
||||
}
|
||||
}
|
||||
const selected = getSelectedId();
|
||||
if (selected !== id) {
|
||||
bumpEditorOpenRequest();
|
||||
set({ editorOpenError: null });
|
||||
}
|
||||
setSelectedId(id);
|
||||
set({ selectedId: id });
|
||||
const cached = sessionCache.get(id);
|
||||
if (cached) {
|
||||
cached.lastActiveTime = Date.now();
|
||||
// set 来自 StateCreator, 接受 Partial<ScriptWorkspaceStore>。
|
||||
// applyEditSessionState 接受更窄的 partial;通过函数协变兼容。
|
||||
applyEditSessionState(
|
||||
(p) => set(p),
|
||||
cached.session,
|
||||
cached.jupyterUrl,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
openPublishDialog: (script: ScriptItem) => {
|
||||
useUiStore.getState().openPublishDialog(script);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
// ---- treeSlice ----
|
||||
//
|
||||
// 拥有 expandedPaths / loadingChildrenPaths / loadedChildPaths /
|
||||
// loadedScriptPaths / loadingScriptPaths (5 个 directory-tree 缓存集合)。
|
||||
// 负责 toggleExpanded 和 loadChildren。
|
||||
//
|
||||
// 注意:
|
||||
// - loadedScriptPaths/loadingScriptPaths 也由 scriptsSlice 写 (loadScripts /
|
||||
// loadOwnerGroup),但 ownership 在 treeSlice 里 (因为是 cache set,不是数据)
|
||||
// - scriptsSlice.load 也会写这俩,所以这里只保留这两个 setter (toggleExpanded
|
||||
// 也要写 expandedPaths)。
|
||||
|
||||
import type { StateCreator } from "zustand";
|
||||
|
||||
import {
|
||||
getCurrentUserId,
|
||||
ownerCacheKey,
|
||||
pushToast,
|
||||
requireApi,
|
||||
} from "./helpers";
|
||||
|
||||
import type { TreeSliceActions, TreeSliceState } from "./types";
|
||||
import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore";
|
||||
|
||||
export const createTreeSlice: StateCreator<
|
||||
ScriptWorkspaceStore,
|
||||
[],
|
||||
[],
|
||||
TreeSliceState & TreeSliceActions
|
||||
> = (set, get) => {
|
||||
const initial: TreeSliceState = {
|
||||
expandedPaths: new Set<string>(),
|
||||
loadingChildrenPaths: new Set<string>(),
|
||||
loadedChildPaths: new Set<string>(),
|
||||
loadedScriptPaths: new Set<string>(),
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
};
|
||||
|
||||
return {
|
||||
...initial,
|
||||
|
||||
loadChildren: async (parentPath, ownerUserId) => {
|
||||
const api = requireApi();
|
||||
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
|
||||
if (get().loadedChildPaths.has(cacheKey)) return;
|
||||
const next = new Set(get().loadingChildrenPaths);
|
||||
next.add(cacheKey);
|
||||
set({ loadingChildrenPaths: next });
|
||||
try {
|
||||
const children = await api.listWorkspaceDirectories(parentPath, ownerUserId);
|
||||
set((state) => {
|
||||
const nextLoaded = new Set(state.loadedChildPaths);
|
||||
nextLoaded.add(cacheKey);
|
||||
// 丢弃该 owner 该 parent 下的旧目录行,再并入 fresh(按 (owner,path) 去重)。
|
||||
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
|
||||
const trimmed = state.directories.filter(
|
||||
(d) =>
|
||||
!(d.owner_user_id === targetOwner && d.parent_path === parentPath),
|
||||
);
|
||||
const byId = new Map(
|
||||
trimmed.map((d) => [`${d.owner_user_id}:${d.path}`, d]),
|
||||
);
|
||||
for (const c of children) byId.set(`${c.owner_user_id}:${c.path}`, c);
|
||||
return {
|
||||
directories: Array.from(byId.values()),
|
||||
loadedChildPaths: nextLoaded,
|
||||
loadingChildrenPaths: new Set(
|
||||
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set((state) => ({
|
||||
loadingChildrenPaths: new Set(
|
||||
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
|
||||
),
|
||||
}));
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "目录加载失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
toggleExpanded: async (path, loadPath, ownerUserId) => {
|
||||
const state = get();
|
||||
const isOpen = state.expandedPaths.has(path);
|
||||
const next = new Set(state.expandedPaths);
|
||||
if (isOpen) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
const me = getCurrentUserId();
|
||||
// 用 `loadPath === undefined` 区分分组头与真实目录,而不是用
|
||||
// `path.startsWith("__group__")`:目录的 expandKey 是
|
||||
// `${groupKey}/${dir.path}` 即 `__group__<owner>/dir`,同样以
|
||||
// `__group__` 开头,前缀判断会把子目录点击误当成分组头,导致
|
||||
// 既不调 loadChildren 也不调 loadScripts("子目录点击不触发接口")。
|
||||
// 分组头 always 传 loadPath=undefined;目录 always 传 loadPath=dir.path。
|
||||
if (loadPath === undefined) {
|
||||
// 分组头:他人分组首次展开 → loadOwnerGroup 按需拉取其根级可见
|
||||
// 脚本+数据+目录(守门去重)。仅翻转 expand;真实子目录的懒加载
|
||||
// 由目录分支(loadPath !== undefined)负责。
|
||||
if (
|
||||
ownerUserId &&
|
||||
ownerUserId !== me &&
|
||||
!state.loadedOwnerGroups.has(ownerUserId)
|
||||
) {
|
||||
void get().loadOwnerGroup(ownerUserId);
|
||||
}
|
||||
} else {
|
||||
// 真实目录展开:loadChildren(owner 限定的显式目录行)+ loadScripts
|
||||
// 并行。两者都 idempotent + 缓存;ownerUserId 缺省=我。他人目录同样
|
||||
// 调 loadChildren(owner) 拉取其目录结构,否则嵌套子目录无法被发现
|
||||
// (list_scripts 非递归,只能看到直接子脚本)。
|
||||
if (!state.loadedChildPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
|
||||
void get().loadChildren(loadPath, ownerUserId);
|
||||
}
|
||||
if (!state.loadedScriptPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
|
||||
void get().loadScripts(loadPath, ownerUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
set({ expandedPaths: next });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
// ---- Slice State / Action type declarations ----
|
||||
//
|
||||
// 每个 slice 导出自己的 `*SliceState` / `*SliceActions` 类型。
|
||||
// 根组合类型 `ScriptWorkspaceStore` 在 `useScriptWorkspaceStore.ts`,
|
||||
// 切片用 `import type { ScriptWorkspaceStore } from "./useScriptWorkspaceStore"`
|
||||
// 循环引用(type-only import 在编译期被擦除)。
|
||||
|
||||
import type {
|
||||
ActiveEditSession,
|
||||
LatestVersion,
|
||||
ResourceItem,
|
||||
ScriptItem,
|
||||
Visibility,
|
||||
WorkspaceDirectory,
|
||||
WorkspaceMember,
|
||||
} from "~/services/api";
|
||||
|
||||
import type { NewScriptForm } from "./uiStore";
|
||||
import type { PythonEditorBuffer } from "./helpers";
|
||||
|
||||
// ---- scriptsSlice ----
|
||||
|
||||
export type ScriptsSliceState = {
|
||||
scripts: ScriptItem[];
|
||||
directories: WorkspaceDirectory[];
|
||||
dataResources: ResourceItem[];
|
||||
dataResourcesLoading: boolean;
|
||||
// Workspace-wide active-script total — separate from the lazy-loaded
|
||||
// `scripts` array so dashboards don't underreport. `null` until the
|
||||
// first loadScriptCount() resolves; the count endpoint is cheap.
|
||||
scriptCount: number | null;
|
||||
scriptCountLoading: boolean;
|
||||
// 工作区成员列表 —— 目录树顶层"我 / user1 / user2 / …"折叠分组的来源。
|
||||
members: WorkspaceMember[];
|
||||
// 已拉取根级内容的 owner(loadOwnerGroup 标记),避免重复拉取。
|
||||
loadedOwnerGroups: Set<string>;
|
||||
loadingOwnerGroups: Set<string>;
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
apiOnline: boolean;
|
||||
// 只读内容刷新版本号(用于触发已打开标签页的内容刷新)
|
||||
readOnlyRefreshVersion: number;
|
||||
};
|
||||
|
||||
export type ScriptsSliceActions = {
|
||||
setApiOnline: (online: boolean) => void;
|
||||
load: (silent?: boolean) => Promise<void>;
|
||||
loadScripts: (parentPath: string, ownerUserId?: string) => Promise<void>;
|
||||
loadOwnerGroup: (ownerUserId: string) => Promise<void>;
|
||||
loadDataResources: (parentPath?: string, ownerUserId?: string) => Promise<void>;
|
||||
loadScriptCount: () => Promise<void>;
|
||||
refreshReadOnlyContent: () => void;
|
||||
};
|
||||
|
||||
// ---- treeSlice ----
|
||||
|
||||
export type TreeSliceState = {
|
||||
expandedPaths: Set<string>;
|
||||
loadingChildrenPaths: Set<string>;
|
||||
loadedChildPaths: Set<string>;
|
||||
// Per-owner × parent-path script cache. Keys are namespaced
|
||||
// `${owner_user_id}:${parent_path}` (see ownerCacheKey).
|
||||
loadedScriptPaths: Set<string>;
|
||||
loadingScriptPaths: Set<string>;
|
||||
};
|
||||
|
||||
export type TreeSliceActions = {
|
||||
toggleExpanded: (
|
||||
path: string,
|
||||
loadPath?: string,
|
||||
ownerUserId?: string,
|
||||
) => Promise<void>;
|
||||
loadChildren: (parentPath: string, ownerUserId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
// ---- selectionSlice ----
|
||||
|
||||
export type SelectionSliceState = {
|
||||
selectedId: string | null;
|
||||
openTabIds: string[];
|
||||
keyword: string;
|
||||
};
|
||||
|
||||
export type SelectionSliceActions = {
|
||||
setKeyword: (keyword: string) => void;
|
||||
selectScript: (id: string | null) => void;
|
||||
openTab: (id: string) => void;
|
||||
closeTab: (
|
||||
id: string,
|
||||
event?: { stopPropagation: () => void },
|
||||
) => Promise<void>;
|
||||
switchTab: (id: string) => void;
|
||||
openPublishDialog: (script: ScriptItem) => void;
|
||||
};
|
||||
|
||||
// ---- editSessionSlice ----
|
||||
|
||||
export type EditSessionSliceState = {
|
||||
editSession: ActiveEditSession | null;
|
||||
embeddedJupyterUrl: string | null;
|
||||
editBusy: boolean;
|
||||
editorOpenError: { scriptId: string; message: string } | null;
|
||||
};
|
||||
|
||||
export type EditSessionSliceActions = {
|
||||
openScriptEditor: (
|
||||
script: ScriptItem,
|
||||
showToast?: boolean,
|
||||
) => Promise<void>;
|
||||
endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise<void>;
|
||||
// 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用)
|
||||
tickCleanup: () => void;
|
||||
releaseActiveOnUnload: () => void;
|
||||
};
|
||||
|
||||
// ---- previewSlice ----
|
||||
|
||||
export type PreviewSliceState = {
|
||||
previewKey: string | null;
|
||||
previewCode: string | null;
|
||||
previewCodeSize: number | null;
|
||||
previewLoading: boolean;
|
||||
previewError: string | null;
|
||||
};
|
||||
|
||||
export type PreviewSliceActions = {
|
||||
loadPreview: (workspaceId: string, filePath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
// ---- pythonEditorSlice ----
|
||||
|
||||
export type PythonEditorSliceState = {
|
||||
pythonEditorBuffers: Record<string, PythonEditorBuffer>;
|
||||
};
|
||||
|
||||
export type PythonEditorSliceActions = {
|
||||
openPythonEditor: (
|
||||
script: ScriptItem,
|
||||
showToast?: boolean,
|
||||
) => Promise<void>;
|
||||
setPythonEditorContent: (scriptId: string, value: string) => void;
|
||||
savePythonEditor: (scriptId: string) => Promise<void>;
|
||||
exitPythonEditor: (scriptId: string) => void;
|
||||
exitAllPythonEditors: () => void;
|
||||
};
|
||||
|
||||
// ---- mutationsSlice ----
|
||||
|
||||
export type MutationsSliceState = {
|
||||
latestVersion: LatestVersion | null;
|
||||
latestVersionLoading: boolean;
|
||||
};
|
||||
|
||||
export type DataResourceMeta = {
|
||||
resourceName: string;
|
||||
visibility: Visibility;
|
||||
description: string;
|
||||
targetPath: string;
|
||||
};
|
||||
|
||||
export type MutationsSliceActions = {
|
||||
loadLatestVersion: (scriptId: string) => Promise<void>;
|
||||
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
|
||||
uploadScripts: (files: File[], parentPath: string) => Promise<void>;
|
||||
uploadDataResource: (
|
||||
file: File,
|
||||
meta: DataResourceMeta,
|
||||
) => Promise<ResourceItem | null>;
|
||||
createFolder: (name: string, parentPath: string) => Promise<void>;
|
||||
deleteScript: (script: ScriptItem) => Promise<void>;
|
||||
deleteDataResource: (resourceId: string) => Promise<void>;
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
toggleScriptLock: (script: ScriptItem) => Promise<void>;
|
||||
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
// ---- 根 store 组合 ----
|
||||
//
|
||||
// 把 7 个 slice (`scripts` / `tree` / `selection` / `editSession` /
|
||||
// `preview` / `pythonEditor` / `mutations`) 拼装成一个 zustand store,
|
||||
// 公开 `bindApi` 和 `reset` 两个跨 slice 的全局 action。
|
||||
//
|
||||
// 公开 API 通过 re-export 全部保持 (`useScriptWorkspaceStore` /
|
||||
// `bindScriptWorkspaceApi` / `bindScriptWorkspaceUser` /
|
||||
// `bindScriptWorkspaceId` / `getSessionCache` / `clearSessionCache` /
|
||||
// `editSessionHandle` / `PythonEditorBuffer`),consumer 无需改 import。
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { WorkspaceBoundApi } from "~/services/api";
|
||||
|
||||
import {
|
||||
bindScriptWorkspaceApi,
|
||||
clearSessionCache,
|
||||
editSessionHandle,
|
||||
getSessionCache,
|
||||
resetModuleState,
|
||||
} from "./helpers";
|
||||
import type { PythonEditorBuffer } from "./helpers";
|
||||
|
||||
import { createScriptsSlice } from "./scriptsSlice";
|
||||
import type {
|
||||
ScriptsSliceActions,
|
||||
ScriptsSliceState,
|
||||
} from "./types";
|
||||
|
||||
import { createTreeSlice } from "./treeSlice";
|
||||
import type { TreeSliceActions, TreeSliceState } from "./types";
|
||||
|
||||
import { createSelectionSlice } from "./selectionSlice";
|
||||
import type {
|
||||
SelectionSliceActions,
|
||||
SelectionSliceState,
|
||||
} from "./types";
|
||||
|
||||
import { createEditSessionSlice } from "./editSessionSlice";
|
||||
import type {
|
||||
EditSessionSliceActions,
|
||||
EditSessionSliceState,
|
||||
} from "./types";
|
||||
|
||||
import { createPreviewSlice } from "./previewSlice";
|
||||
import type {
|
||||
PreviewSliceActions,
|
||||
PreviewSliceState,
|
||||
} from "./types";
|
||||
|
||||
import { createPythonEditorSlice } from "./pythonEditorSlice";
|
||||
import type {
|
||||
PythonEditorSliceActions,
|
||||
PythonEditorSliceState,
|
||||
} from "./types";
|
||||
|
||||
import { createMutationsSlice } from "./mutationsSlice";
|
||||
import type {
|
||||
MutationsSliceActions,
|
||||
MutationsSliceState,
|
||||
} from "./types";
|
||||
|
||||
// ---- Combined types (re-used by each slice's StateCreator generic) ----
|
||||
|
||||
export type ScriptWorkspaceState =
|
||||
& ScriptsSliceState
|
||||
& TreeSliceState
|
||||
& SelectionSliceState
|
||||
& EditSessionSliceState
|
||||
& PreviewSliceState
|
||||
& PythonEditorSliceState
|
||||
& MutationsSliceState;
|
||||
|
||||
export type ScriptWorkspaceActions =
|
||||
& ScriptsSliceActions
|
||||
& TreeSliceActions
|
||||
& SelectionSliceActions
|
||||
& EditSessionSliceActions
|
||||
& PreviewSliceActions
|
||||
& PythonEditorSliceActions
|
||||
& MutationsSliceActions
|
||||
& {
|
||||
bindApi: (api: WorkspaceBoundApi | null) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export type ScriptWorkspaceStore = ScriptWorkspaceState & ScriptWorkspaceActions;
|
||||
|
||||
// ---- Initial values (used by reset() to mirror the legacy monolithic state) ----
|
||||
//
|
||||
// 完整枚举所有 slice 字段 + 跨 slice reset 时需要重置的 loading 状态。
|
||||
// reset() 由根组合负责,不能在 slice 里做 (跨 slice)。
|
||||
|
||||
const INITIAL: ScriptWorkspaceState = {
|
||||
// scriptsSlice
|
||||
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,
|
||||
// treeSlice
|
||||
expandedPaths: new Set<string>(),
|
||||
loadingChildrenPaths: new Set<string>(),
|
||||
loadedChildPaths: new Set<string>(),
|
||||
loadedScriptPaths: new Set<string>(),
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
// selectionSlice
|
||||
selectedId: null,
|
||||
openTabIds: [],
|
||||
keyword: "",
|
||||
// editSessionSlice
|
||||
editSession: null,
|
||||
embeddedJupyterUrl: null,
|
||||
editBusy: false,
|
||||
editorOpenError: null,
|
||||
// previewSlice
|
||||
previewKey: null,
|
||||
previewCode: null,
|
||||
previewCodeSize: null,
|
||||
previewLoading: false,
|
||||
previewError: null,
|
||||
// pythonEditorSlice
|
||||
pythonEditorBuffers: {},
|
||||
// mutationsSlice
|
||||
latestVersion: null,
|
||||
latestVersionLoading: false,
|
||||
};
|
||||
|
||||
export const useScriptWorkspaceStore = create<ScriptWorkspaceStore>()(
|
||||
(set, get, store) => ({
|
||||
...createScriptsSlice(set, get, store),
|
||||
...createTreeSlice(set, get, store),
|
||||
...createSelectionSlice(set, get, store),
|
||||
...createEditSessionSlice(set, get, store),
|
||||
...createPreviewSlice(set, get, store),
|
||||
...createPythonEditorSlice(set, get, store),
|
||||
...createMutationsSlice(set, get, store),
|
||||
|
||||
// ---- 跨 slice 全局 actions ----
|
||||
|
||||
bindApi: (api) => {
|
||||
bindScriptWorkspaceApi(api);
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
// 1) 重置模块级可变状态 (preview controller abort + session cache 等)
|
||||
resetModuleState();
|
||||
// 2) 重置响应式 state —— `INITIAL` 是根文件定义的副本,包含所有 slice 的初值
|
||||
// (loading 默认 true,和原版 `set({ ...initial, loading: true })` 一致)。
|
||||
set({ ...INITIAL });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- 公开 API re-export ----
|
||||
//
|
||||
// 保持与原 monolith 文件相同的导出表面,consumer 无需改动 import path。
|
||||
|
||||
export {
|
||||
bindScriptWorkspaceApi,
|
||||
// bindScriptWorkspaceUser 与 bindScriptWorkspaceId 由 routes/platform.tsx 直接使用,
|
||||
// 也从这里 re-export 保持向后兼容。
|
||||
} from "./helpers";
|
||||
export { bindScriptWorkspaceUser, bindScriptWorkspaceId } from "./helpers";
|
||||
export { getSessionCache, clearSessionCache, editSessionHandle };
|
||||
export type { PythonEditorBuffer };
|
||||
Reference in New Issue
Block a user