refactor: uiStore.ts

This commit is contained in:
tao.chen
2026-08-07 12:23:50 +08:00
parent 2272e1f390
commit d7934a42ab
10 changed files with 1405 additions and 989 deletions
@@ -0,0 +1,24 @@
import { useNavigate } from "react-router";
import { DashboardPage } from "../../components/admin/DashboardPage";
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
import "../../styles/dashboard.css";
export default function DashboardRoute() {
const scripts = useScriptWorkspaceStore((s) => s.scripts);
const apiOnline = useScriptWorkspaceStore((s) => s.apiOnline);
const navigate = useNavigate();
return (
<DashboardPage
scriptCount={scripts.length}
online={apiOnline}
onNavigate={(page) => {
if (page === "scripts") navigate("/scripts");
else if (page === "schedules") navigate("/schedules");
else navigate("/system");
}}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,321 @@
import { type FormEvent, useEffect, useMemo, useRef } from "react";
import { useAuth } from "../../context/AuthContext";
import { CreateFolderModal } from "../../components/platform/CreateFolderModal";
import { CreateScriptModal } from "../../components/platform/CreateScriptModal";
import Icon from "../../components/common/Icon";
import { PublishModal } from "../../components/platform/PublishModal";
import { ScriptExplorer } from "../../components/platform/ScriptExplorer";
import { TreeContextMenu } from "../../components/platform/TreeContextMenu";
import { VersionReceiptModal } from "../../components/platform/VersionReceiptModal";
import { getSessionCache, useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
import { useUiStore } from "./state/uiStore";
import { ScriptWorkspace } from "./ScriptWorkspace";
export default function ScriptsPage() {
const { currentWorkspace, user } = useAuth();
const uploadInputRef = useRef<HTMLInputElement | null>(null);
// store state
const scripts = useScriptWorkspaceStore((s) => s.scripts);
const directories = useScriptWorkspaceStore((s) => s.directories);
const selectedId = useScriptWorkspaceStore((s) => s.selectedId);
const openTabIds = useScriptWorkspaceStore((s) => s.openTabIds);
const keyword = useScriptWorkspaceStore((s) => s.keyword);
const loading = useScriptWorkspaceStore((s) => s.loading);
const refreshing = useScriptWorkspaceStore((s) => s.refreshing);
const editSession = useScriptWorkspaceStore((s) => s.editSession);
const embeddedJupyterUrl = useScriptWorkspaceStore((s) => s.embeddedJupyterUrl);
const editBusy = useScriptWorkspaceStore((s) => s.editBusy);
const editorOpenError = useScriptWorkspaceStore((s) => s.editorOpenError);
const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion);
const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading);
// store actions
const setKeyword = useScriptWorkspaceStore((s) => s.setKeyword);
const load = useScriptWorkspaceStore((s) => s.load);
const reset = useScriptWorkspaceStore((s) => s.reset);
const selectScript = useScriptWorkspaceStore((s) => s.selectScript);
const openTab = useScriptWorkspaceStore((s) => s.openTab);
const closeTab = useScriptWorkspaceStore((s) => s.closeTab);
const switchTab = useScriptWorkspaceStore((s) => s.switchTab);
const openScriptEditor = useScriptWorkspaceStore((s) => s.openScriptEditor);
const endEditing = useScriptWorkspaceStore((s) => s.endEditing);
const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion);
const createScript = useScriptWorkspaceStore((s) => s.createScript);
const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts);
const createFolder = useScriptWorkspaceStore((s) => s.createFolder);
const deleteScript = useScriptWorkspaceStore((s) => s.deleteScript);
const deleteDirectory = useScriptWorkspaceStore((s) => s.deleteDirectory);
const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog);
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
// ui store
const pushToast = useUiStore((s) => s.pushToast);
const createDialog = useUiStore((s) => s.createDialog);
const setCreateForm = useUiStore((s) => s.setCreateForm);
const closeCreateDialog = useUiStore((s) => s.closeCreateDialog);
const folderDialog = useUiStore((s) => s.folderDialog);
const setFolderName = useUiStore((s) => s.setFolderName);
const closeFolderDialog = useUiStore((s) => s.closeFolderDialog);
const contextMenu = useUiStore((s) => s.contextMenu);
const closeContextMenu = useUiStore((s) => s.closeContextMenu);
const showContextMenu = useUiStore((s) => s.showContextMenu);
const upload = useUiStore((s) => s.upload);
const openCreateDialog = useUiStore((s) => s.openCreateDialog);
const openFolderDialog = useUiStore((s) => s.openFolderDialog);
const chooseUpload = useUiStore((s) => s.chooseUpload);
const publish = useUiStore((s) => s.publish);
const setReleaseNote = useUiStore((s) => s.setReleaseNote);
const setPublishVisibility = useUiStore((s) => s.setPublishVisibility);
const closePublishDialog = useUiStore((s) => s.closePublishDialog);
const clearPublishedVersion = useUiStore((s) => s.clearPublishedVersion);
const workspaceId = currentWorkspace?.workspace_id;
// 1) 初始加载 + workspace 切换时重置
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
reset();
void load();
}, [reset, load, workspaceId]);
// 2) 选中文件变更时加载最新版本
useEffect(() => {
if (!selectedId) {
useScriptWorkspaceStore.setState({ latestVersion: null });
return;
}
let ignore = false;
useScriptWorkspaceStore.setState({ latestVersionLoading: true });
void loadLatestVersion(selectedId).then(() => {
if (!ignore) {
useScriptWorkspaceStore.setState({ latestVersionLoading: false });
}
});
return () => {
ignore = true;
};
}, [selectedId, loadLatestVersion]);
const selected = useMemo(
() => scripts.find((item) => item.script_id === selectedId) ?? null,
[scripts, selectedId],
);
const filteredScripts = useMemo(() => {
const normalized = keyword.trim().toLocaleLowerCase();
if (!normalized) return scripts;
return scripts.filter((item) =>
item.script_name.toLocaleLowerCase().includes(normalized),
);
}, [keyword, scripts]);
// 3) 自动打开 notebook 编辑器
useEffect(() => {
if (
!selected
|| selected.script_type !== "notebook"
|| editBusy
|| (editSession?.script_id === selected.script_id && embeddedJupyterUrl)
|| editorOpenError?.scriptId === selected.script_id
) {
return;
}
const cached = getSessionCache().get(selected.script_id);
if (cached) {
useScriptWorkspaceStore.setState({
editSession: cached.session,
embeddedJupyterUrl: cached.jupyterUrl,
});
return;
}
void openScriptEditor(selected, false);
}, [
selected,
editBusy,
editSession?.script_id,
embeddedJupyterUrl,
editorOpenError?.scriptId,
openScriptEditor,
]);
// 4) context menu 外部点击关闭
useEffect(() => {
if (!contextMenu) return;
const close = () => closeContextMenu();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("pointerdown", close);
window.addEventListener("blur", close);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
window.addEventListener("keydown", closeOnEscape);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("blur", close);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("keydown", closeOnEscape);
};
}, [contextMenu, closeContextMenu]);
// 5) handlers
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0) return;
void uploadScripts(files, upload.parentPath);
};
const handleCreateSubmit = (event: FormEvent) => {
event.preventDefault();
void createScript(createDialog.form);
};
const handleFolderSubmit = (event: FormEvent) => {
event.preventDefault();
void createFolder(folderDialog.name, folderDialog.parentPath);
};
const handlePublishSubmit = (event: FormEvent) => {
event.preventDefault();
void submitPublish(publish.releaseNote, publish.visibility);
};
return (
<section className="workspace-layout">
<ScriptExplorer
scripts={scripts}
filteredScripts={filteredScripts}
directories={directories}
user={user}
selectedId={selectedId}
loading={loading}
refreshing={refreshing}
uploading={upload.uploading}
keyword={keyword}
onKeywordChange={setKeyword}
onRefresh={() => void load(true)}
onUpload={() => chooseUpload("")}
onOpenCreateDialog={(parentPath, scriptType) =>
openCreateDialog(parentPath, scriptType)}
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
onChooseUpload={(parentPath) => chooseUpload(parentPath)}
onContextMenu={showContextMenu}
onSelect={openTab}
uploadInputRef={uploadInputRef}
onHandleUpload={handleUpload}
/>
<section className="editor-area">
{selected ? (
<ScriptWorkspace
script={selected}
sessionCache={getSessionCache()}
editSession={editSession}
jupyterUrl={embeddedJupyterUrl}
editBusy={editBusy}
openError={
editorOpenError?.scriptId === selected.script_id
? editorOpenError.message
: null
}
latestVersion={latestVersion}
versionsLoading={latestVersionLoading}
openTabs={openTabIds.map((id) => {
const s = scripts.find((item) => item.script_id === id);
return {
scriptId: id,
scriptName: s?.script_name ?? "未知",
scriptType: s?.script_type ?? "notebook",
};
})}
onOpenEditor={() => void openScriptEditor(selected)}
onEndEditing={() => void endEditing()}
onClose={(scriptId, event) => void closeTab(scriptId, event)}
onSwitchTab={switchTab}
onNewTab={() => openCreateDialog("")}
onPublish={() => openPublishDialog(selected)}
onInfo={(t) => pushToast(t)}
/>
) : (
<div className="welcome-panel">
<div className="welcome-panel__visual">
<Icon name="script" size={42} />
</div>
<span className="welcome-panel__label"></span>
<h2></h2>
<p>
Notebook 使 Python
</p>
<button className="primary-button" onClick={() => openCreateDialog("")}>
<Icon name="plus" size={17} />
</button>
</div>
)}
</section>
<CreateScriptModal
open={createDialog.open}
creating={createDialog.creating}
form={createDialog.form}
scripts={scripts.map((s) => ({
script_type: s.script_type,
script_name: s.script_name,
}))}
onFormChange={setCreateForm}
onSubmit={handleCreateSubmit}
onClose={closeCreateDialog}
/>
<CreateFolderModal
open={folderDialog.open}
parentPath={folderDialog.parentPath}
name={folderDialog.name}
busy={folderDialog.busy}
onNameChange={setFolderName}
onSubmit={handleFolderSubmit}
onClose={closeFolderDialog}
/>
<TreeContextMenu
contextMenu={contextMenu}
onOpenScript={(scriptId) => {
selectScript(scriptId);
closeContextMenu();
}}
onRemoveScript={(s) => void deleteScript(s)}
onOpenCreateDialog={(parentPath, scriptType) =>
openCreateDialog(parentPath, scriptType)}
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
onChooseUpload={(parentPath) => chooseUpload(parentPath)}
onRemoveDirectory={(p) => void deleteDirectory(p)}
onClose={closeContextMenu}
/>
<PublishModal
publishTarget={publish.target}
releaseNote={publish.releaseNote}
publishVisibility={publish.visibility}
publishing={publish.publishing}
onReleaseNoteChange={setReleaseNote}
onPublishVisibilityChange={setPublishVisibility}
onSubmit={handlePublishSubmit}
onClose={closePublishDialog}
/>
<VersionReceiptModal
publishedVersion={publish.publishedVersion}
onClose={clearPublishedVersion}
onCopy={(message) => pushToast({ tone: "success", message })}
/>
</section>
);
}
@@ -0,0 +1,71 @@
import { useEffect } from "react";
import {
bindScriptWorkspaceApi,
editSessionHandle,
useScriptWorkspaceStore,
} from "../state/scriptWorkspaceStore";
type ActivePage = "home" | "scripts" | "schedules" | "system";
/**
* 必须在 layout 层挂载,不能放在 ScriptsPage。
* 原因:心跳 / cleanup / beforeunload 需要在用户切到 /schedules 时仍运行,
* 否则其他 tab 中的编辑会话锁会过期。
*/
export function useEditSessionLifecycle({
activePage,
}: {
activePage: ActivePage;
}) {
// 1) 切到非 scripts 页时,如果当前有 active session,结束编辑
useEffect(() => {
if (activePage === "scripts") return;
const editBusy = useScriptWorkspaceStore.getState().editBusy;
if (editBusy) return;
if (!editSessionHandle.current) return;
void useScriptWorkspaceStore.getState().endEditing(true, false);
}, [activePage]);
// 2) 心跳 + cleanup 定时器(15s 心跳,60s 检查 cleanup
useEffect(() => {
let heartbeatRunning = false;
let cleanupRunning = false;
const heartbeatTimer = window.setInterval(() => {
if (heartbeatRunning) return;
heartbeatRunning = true;
void useScriptWorkspaceStore.getState().tickHeartbeats().finally(() => {
heartbeatRunning = false;
});
}, 15 * 1000);
const cleanupTimer = window.setInterval(() => {
if (cleanupRunning) return;
cleanupRunning = true;
try {
useScriptWorkspaceStore.getState().tickCleanup();
} finally {
cleanupRunning = false;
}
}, 60 * 1000);
return () => {
window.clearInterval(heartbeatTimer);
window.clearInterval(cleanupTimer);
};
}, []);
// 3) 卸载前释放编辑锁
useEffect(() => {
const handleUnload = () => {
useScriptWorkspaceStore.getState().releaseActiveOnUnload();
};
window.addEventListener("beforeunload", handleUnload);
return () => window.removeEventListener("beforeunload", handleUnload);
}, []);
// 4) layout 卸载时解绑 api
useEffect(() => {
return () => {
bindScriptWorkspaceApi(null);
};
}, []);
}
@@ -0,0 +1,669 @@
import { create } from "zustand";
import type {
ActiveEditSession,
LatestVersion,
ScriptItem,
StableVersion,
Visibility,
WorkspaceBoundApi,
WorkspaceDirectory,
} from "../../../services/api";
import type { NewScriptForm } from "./uiStore";
import { useUiStore } from "./uiStore";
// 缓存的会话类型(多 iframe 共存方案)
type CachedSession = {
session: ActiveEditSession;
jupyterUrl: string;
lastActiveTime: number;
};
// 模块级可变 holder(非响应式,避免 React 重渲)
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;
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
_api = api;
};
export const getSessionCache = () => sessionCache;
export const clearSessionCache = () => {
sessionCache.clear();
};
// handle ref 给 Sidebar 用,避免订阅 store
export const editSessionHandle: { current: ActiveEditSession | null } = {
current: null,
};
type State = {
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
selectedId: string | null;
openTabIds: string[];
keyword: string;
loading: boolean;
refreshing: boolean;
apiOnline: boolean;
editSession: ActiveEditSession | null;
embeddedJupyterUrl: string | null;
editBusy: boolean;
editorOpenError: { scriptId: string; message: string } | null;
latestVersion: LatestVersion | null;
latestVersionLoading: boolean;
// actions
setApiOnline: (online: boolean) => void;
setKeyword: (keyword: string) => void;
reset: () => void;
load: (silent?: boolean) => Promise<void>;
selectScript: (id: string | null) => void;
openTab: (id: string) => void;
closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>;
switchTab: (id: string) => void;
openScriptEditor: (script: ScriptItem, showToast?: boolean) => Promise<void>;
endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise<void>;
loadLatestVersion: (scriptId: string) => Promise<void>;
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
uploadScripts: (files: File[], parentPath: string) => Promise<void>;
createFolder: (name: string, parentPath: string) => Promise<void>;
deleteScript: (script: ScriptItem) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>;
openPublishDialog: (script: ScriptItem) => void;
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
// 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用)
tickHeartbeats: () => Promise<void>;
tickCleanup: () => void;
releaseActiveOnUnload: () => void;
};
function requireApi(): WorkspaceBoundApi {
if (!_api) {
throw new Error("script workspace API 未绑定");
}
return _api;
}
function ownedScriptPath(item: ScriptItem) {
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
}
function pushToast(tone: "success" | "error" | "info", message: string) {
useUiStore.getState().pushToast({ tone, message });
}
export const useScriptWorkspaceStore = create<State>((set, get) => {
const setEditSessionState = (
next: ActiveEditSession | null,
nextJupyterUrl: string | null,
) => {
_editSession = next;
editSessionHandle.current = next;
set({
editSession: next,
embeddedJupyterUrl: next ? nextJupyterUrl : null,
});
};
return {
scripts: [],
directories: [],
selectedId: null,
openTabIds: [],
keyword: "",
loading: true,
refreshing: false,
apiOnline: false,
editSession: null,
embeddedJupyterUrl: null,
editBusy: false,
editorOpenError: null,
latestVersion: null,
latestVersionLoading: false,
setApiOnline: (online) => set({ apiOnline: online }),
setKeyword: (keyword) => set({ keyword }),
reset: () => {
_selectedId = null;
_editSession = null;
editSessionHandle.current = null;
sessionCache.clear();
set({
scripts: [],
directories: [],
selectedId: null,
openTabIds: [],
editSession: null,
embeddedJupyterUrl: null,
editorOpenError: null,
latestVersion: null,
latestVersionLoading: false,
});
},
load: async (silent = false) => {
const api = requireApi();
if (!silent) set({ loading: true });
set({ refreshing: silent });
try {
const [items, folderItems] = await Promise.all([
api.listScripts(),
api.listWorkspaceDirectories(),
]);
set({
scripts: items,
directories: folderItems,
apiOnline: true,
});
const validIds = new Set(items.map((item) => item.script_id));
const currentSelected = get().selectedId;
if (!currentSelected || !validIds.has(currentSelected)) {
_selectedId = null;
set({ selectedId: null });
} else {
_selectedId = currentSelected;
}
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 });
}
},
selectScript: (id) => {
_selectedId = id;
set({ selectedId: id });
},
openTab: (id) => {
if (_selectedId !== id) {
_editorOpenRequest += 1;
set({ editorOpenError: null });
}
_selectedId = id;
set((state) => ({
selectedId: id,
openTabIds: state.openTabIds.includes(id)
? state.openTabIds
: [...state.openTabIds, id],
}));
},
closeTab: async (id, event) => {
event?.stopPropagation();
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;
_selectedId = nextId;
nextSelected = nextId;
}
return { openTabIds: newTabs, selectedId: nextSelected };
});
},
switchTab: (id) => {
if (_selectedId !== id) {
_editorOpenRequest += 1;
set({ editorOpenError: null });
}
_selectedId = id;
set({ selectedId: id });
const cached = sessionCache.get(id);
if (cached) {
cached.lastActiveTime = Date.now();
setEditSessionState(cached.session, cached.jupyterUrl);
}
},
openScriptEditor: async (script, showToast = true) => {
if (!script) return;
if (_editorOpening) return;
_editorOpening = true;
const api = requireApi();
const requestId = _editorOpenRequest + 1;
_editorOpenRequest = requestId;
const requestIsCurrent = () =>
_editorOpenRequest === requestId && _selectedId === script.script_id;
const clearSessionIfActive = (session: ActiveEditSession) => {
if (_editSession?.edit_session_id === session.edit_session_id) {
setEditSessionState(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;
setEditSessionState(cached.session, cached.jupyterUrl);
if (showToast) {
pushToast(
"success",
`${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
);
}
return;
}
let active = _editSession;
let newlyAcquired = false;
if (active && active.script_id !== script.script_id) {
await api.releaseFileLock(active);
setEditSessionState(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,
};
setEditSessionState(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) {
setEditSessionState(null, null);
if (requestIsCurrent()) {
const message =
error instanceof Error ? error.message : "打开编辑器失败";
set({ editorOpenError: { scriptId: script.script_id, message } });
if (showToast) {
pushToast("error", message);
}
}
} finally {
_editorOpening = false;
set({ editBusy: false });
}
},
endEditing: async (closeTabFlag = true, showToast = true) => {
const api = requireApi();
_editorOpenRequest += 1;
set({ editorOpenError: null });
const active = _editSession;
const scriptId = active?.script_id;
if (!active) {
if (closeTabFlag && scriptId) {
await get().closeTab(scriptId);
}
return;
}
set({ editBusy: true });
try {
await api.releaseFileLock(active);
setEditSessionState(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 });
}
},
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) => {
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) =>
script.script_type === form.scriptType
&& script.script_name.toLocaleLowerCase()
=== normalizedName.toLocaleLowerCase(),
);
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);
}
},
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);
await get().load(true);
ui.closeFolderDialog();
pushToast("success", `${trimmed} 文件夹已创建`);
} catch (error) {
ui.setFolderBusy(false);
pushToast(
"error",
error instanceof Error ? error.message : "文件夹创建失败",
);
}
},
deleteScript: async (script) => {
const api = requireApi();
useUiStore.getState().closeContextMenu();
if (
!window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`)
) {
return;
}
if (_editSession?.script_id === script.script_id) {
await get().endEditing(false, false);
if (_editSession?.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 (_selectedId === script.script_id) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
_selectedId = nextId;
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 === _editSession?.script_id,
);
if (
activeScript
&& (ownedScriptPath(activeScript) === path
|| ownedScriptPath(activeScript).startsWith(`${path}/`))
) {
await get().endEditing(false, false);
if (_editSession?.script_id === activeScript.script_id) return;
}
try {
const result = await api.deleteWorkspaceDirectory(path);
const selectedScript = get().scripts.find(
(item) => item.script_id === _selectedId,
);
if (
selectedScript
&& ownedScriptPath(selectedScript).startsWith(`${path}/`)
) {
get().selectScript(null);
}
await get().load(true);
pushToast(
"success",
`${path} 已删除(含 ${result.deleted_scripts} 个脚本)`,
);
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "文件夹删除失败",
);
}
},
openPublishDialog: (script) => {
useUiStore.getState().openPublishDialog(script);
},
submitPublish: async (releaseNote, 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);
}
},
tickHeartbeats: async () => {
if (!_api) return;
// 1) 当前 active session 心跳
const active = _editSession;
if (active) {
try {
const updated = await _api.heartbeatFileLock(active);
if (
_editSession
&& _editSession.edit_session_id === updated.edit_session_id
) {
const merged = {
..._editSession,
session_status: updated.session_status,
expires_at: updated.expires_at,
};
_editSession = merged;
editSessionHandle.current = merged;
set({ editSession: merged });
}
} catch (error) {
_editSession = null;
editSessionHandle.current = null;
set({ editSession: null, embeddedJupyterUrl: null });
pushToast(
"error",
`编辑锁心跳已中断:${
error instanceof Error ? error.message : "请重新打开文件"
}`,
);
}
}
// 2) 缓存会话心跳(不更新 React state,只更新缓存对象本身)
const promises: Promise<void>[] = [];
for (const cached of sessionCache.values()) {
promises.push(
_api.heartbeatFileLock(cached.session)
.then((updated) => {
cached.session.session_status = updated.session_status;
cached.session.expires_at = updated.expires_at;
})
.catch(() => {
// 静默失败:等用户切回来时再处理
}),
);
}
await Promise.allSettled(promises);
},
tickCleanup: () => {
if (!_api) return;
const TEN_MINUTES = 10 * 60 * 1000;
const now = Date.now();
const toCleanup: string[] = [];
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) {
const cached = sessionCache.get(scriptId);
if (cached) {
_api.releaseFileLock(cached.session).catch(console.warn);
sessionCache.delete(scriptId);
}
}
pushToast(
"info",
`已清理 ${toCleanup.length} 个长时间未活动的编辑会话`,
);
},
releaseActiveOnUnload: () => {
if (!_api) return;
const current = _editSession;
if (current) {
_api.releaseFileLockOnUnload(current);
}
},
};
});
@@ -0,0 +1,233 @@
import { create } from "zustand";
import type {
ScriptItem,
ScriptType,
StableVersion,
Visibility,
} from "../../../services/api";
export type ToastTone = "success" | "error" | "info";
export type ToastState = {
tone: ToastTone;
message: string;
};
export type NewScriptForm = {
name: string;
scriptType: ScriptType;
visibility: Visibility;
parentPath: string;
};
export type ContextMenuState = {
x: number;
y: number;
kind: "root" | "directory" | "file";
path: string;
script?: ScriptItem;
};
export type FolderDialogState = {
open: boolean;
parentPath: string;
name: string;
busy: boolean;
};
export type PublishDialogState = {
target: ScriptItem | null;
releaseNote: string;
visibility: Visibility;
publishing: boolean;
publishedVersion: StableVersion | null;
};
export type UploadState = {
parentPath: string;
uploading: boolean;
};
export const initialCreateForm: NewScriptForm = {
name: "",
scriptType: "notebook",
visibility: "workspace",
parentPath: "",
};
type UiState = {
toast: ToastState | null;
createDialog: {
open: boolean;
creating: boolean;
form: NewScriptForm;
};
folderDialog: FolderDialogState;
contextMenu: ContextMenuState | null;
workspaceMenuOpen: boolean;
upload: UploadState;
publish: PublishDialogState;
// toast
pushToast: (toast: ToastState) => void;
dismissToast: () => void;
// create dialog
openCreateDialog: (parentPath?: string, scriptType?: ScriptType) => void;
closeCreateDialog: () => void;
setCreateForm: (form: NewScriptForm) => void;
setCreating: (creating: boolean) => void;
// folder dialog
openFolderDialog: (parentPath?: string) => void;
closeFolderDialog: () => void;
setFolderName: (name: string) => void;
setFolderBusy: (busy: boolean) => void;
// context menu
showContextMenu: (
event: { clientX: number; clientY: number; preventDefault: () => void; stopPropagation: () => void },
target: Omit<ContextMenuState, "x" | "y">,
) => void;
closeContextMenu: () => void;
// workspace menu (topbar)
setWorkspaceMenuOpen: (open: boolean) => void;
// upload
chooseUpload: (parentPath?: string) => void;
setUploading: (uploading: boolean) => void;
setUploadParentPath: (parentPath: string) => void;
// publish dialog
openPublishDialog: (script: ScriptItem) => void;
closePublishDialog: () => void;
setReleaseNote: (note: string) => void;
setPublishVisibility: (visibility: Visibility) => void;
setPublishing: (publishing: boolean) => void;
setPublishedVersion: (version: StableVersion) => void;
clearPublishedVersion: () => void;
};
export const useUiStore = create<UiState>((set) => ({
toast: null,
createDialog: {
open: false,
creating: false,
form: initialCreateForm,
},
folderDialog: {
open: false,
parentPath: "",
name: "",
busy: false,
},
contextMenu: null,
workspaceMenuOpen: false,
upload: {
parentPath: "",
uploading: false,
},
publish: {
target: null,
releaseNote: "",
visibility: "workspace",
publishing: false,
publishedVersion: null,
},
pushToast: (toast) => set({ toast }),
dismissToast: () => set({ toast: null }),
openCreateDialog: (parentPath = "", scriptType = "notebook") =>
set((state) => ({
contextMenu: null,
createDialog: {
open: true,
creating: false,
form: { ...initialCreateForm, parentPath, scriptType },
},
})),
closeCreateDialog: () =>
set((state) => ({
createDialog: { ...state.createDialog, open: false },
})),
setCreateForm: (form) =>
set((state) => ({ createDialog: { ...state.createDialog, form } })),
setCreating: (creating) =>
set((state) => ({ createDialog: { ...state.createDialog, creating } })),
openFolderDialog: (parentPath = "") =>
set((state) => ({
contextMenu: null,
folderDialog: {
open: true,
parentPath,
name: "",
busy: false,
},
})),
closeFolderDialog: () =>
set((state) => ({
folderDialog: { ...state.folderDialog, open: false },
})),
setFolderName: (name) =>
set((state) => ({ folderDialog: { ...state.folderDialog, name } })),
setFolderBusy: (busy) =>
set((state) => ({ folderDialog: { ...state.folderDialog, busy } })),
showContextMenu: (event, target) => {
event.preventDefault();
event.stopPropagation();
const width = 188;
const height = target.kind === "file" ? 92 : 190;
set({
contextMenu: {
...target,
x: Math.min(event.clientX, window.innerWidth - width - 8),
y: Math.min(event.clientY, window.innerHeight - height - 8),
},
});
},
closeContextMenu: () => set({ contextMenu: null }),
setWorkspaceMenuOpen: (open) => set({ workspaceMenuOpen: open }),
chooseUpload: (parentPath = "") =>
set((state) => ({
contextMenu: null,
upload: { ...state.upload, parentPath },
})),
setUploading: (uploading) =>
set((state) => ({ upload: { ...state.upload, uploading } })),
setUploadParentPath: (parentPath) =>
set((state) => ({ upload: { ...state.upload, parentPath } })),
openPublishDialog: (script) =>
set((state) => ({
publish: {
...state.publish,
target: script,
releaseNote: "",
visibility: script.visibility === "private" ? "private" : "workspace",
},
})),
closePublishDialog: () =>
set((state) => ({ publish: { ...state.publish, target: null } })),
setReleaseNote: (note) =>
set((state) => ({ publish: { ...state.publish, releaseNote: note } })),
setPublishVisibility: (visibility) =>
set((state) => ({ publish: { ...state.publish, visibility } })),
setPublishing: (publishing) =>
set((state) => ({ publish: { ...state.publish, publishing } })),
setPublishedVersion: (version) =>
set((state) => ({
publish: {
...state.publish,
target: null,
publishedVersion: version,
},
})),
clearPublishedVersion: () =>
set((state) => ({ publish: { ...state.publish, publishedVersion: null } })),
}));
+9 -4
View File
@@ -1,6 +1,11 @@
import { type RouteConfig, route } from "@react-router/dev/routes";
import {type RouteConfig, route} from "@react-router/dev/routes";
export default [
route("login", "routes/login.tsx"),
route("*", "routes/platform.tsx"),
] satisfies RouteConfig;
route("login", "routes/login.tsx"),
route("", "routes/platform.tsx", [
route("workbench", "features/platform/DashboardRoute.tsx"),
route("scripts", "features/platform/ScriptsPage.tsx"),
route("schedules", "features/schedules/SchedulesPageRoute.tsx"),
route("system", "features/admin/SystemAdminRoute.tsx"),
]),
] satisfies RouteConfig;
+2 -1
View File
@@ -14,7 +14,8 @@
"isbot": "^5.1.36",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8"
"react-router": "^8",
"zustand": "^5.0.14"
},
"devDependencies": {
"@react-router/dev": "^8",
+26
View File
@@ -26,6 +26,9 @@ importers:
react-router:
specifier: ^8
version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
zustand:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.17)(react@19.2.8)
devDependencies:
'@react-router/dev':
specifier: ^8
@@ -1234,6 +1237,24 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
zustand@5.0.14:
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@babel/code-frame@7.29.7':
@@ -2325,3 +2346,8 @@ snapshots:
wrappy@1.0.2: {}
yallist@3.1.1: {}
zustand@5.0.14(@types/react@19.2.17)(react@19.2.8):
optionalDependencies:
'@types/react': 19.2.17
react: 19.2.8
+1
View File
@@ -2,6 +2,7 @@ import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
export default defineConfig({
base: '/',
plugins: [reactRouter()],
server: {
host: "0.0.0.0",