refactor: uiStore.ts
This commit is contained in:
@@ -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 } })),
|
||||||
|
}));
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
import {type RouteConfig, route} from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
route("login", "routes/login.tsx"),
|
route("login", "routes/login.tsx"),
|
||||||
route("*", "routes/platform.tsx"),
|
route("", "routes/platform.tsx", [
|
||||||
] satisfies RouteConfig;
|
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;
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"isbot": "^5.1.36",
|
"isbot": "^5.1.36",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router": "^8"
|
"react-router": "^8",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^8",
|
"@react-router/dev": "^8",
|
||||||
|
|||||||
Generated
+26
@@ -26,6 +26,9 @@ importers:
|
|||||||
react-router:
|
react-router:
|
||||||
specifier: ^8
|
specifier: ^8
|
||||||
version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.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:
|
devDependencies:
|
||||||
'@react-router/dev':
|
'@react-router/dev':
|
||||||
specifier: ^8
|
specifier: ^8
|
||||||
@@ -1234,6 +1237,24 @@ packages:
|
|||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
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:
|
snapshots:
|
||||||
|
|
||||||
'@babel/code-frame@7.29.7':
|
'@babel/code-frame@7.29.7':
|
||||||
@@ -2325,3 +2346,8 @@ snapshots:
|
|||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { reactRouter } from "@react-router/dev/vite";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/',
|
||||||
plugins: [reactRouter()],
|
plugins: [reactRouter()],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user