Files
model-platform/frontend/app/features/platform/ScriptsPage.tsx
T
2026-08-07 12:23:50 +08:00

321 lines
12 KiB
TypeScript

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>
);
}