import { type ChangeEvent, type FormEvent, type MouseEvent as ReactMouseEvent, useEffect, useMemo, useRef, useState, } from "react"; import { useLocation, useNavigate } from "react-router"; import { type ActiveEditSession, type LatestVersion, type ScriptItem, type ScriptType, type StableVersion, type Visibility, type WorkspaceDirectory, } from "../../services/api"; import { useApi, useAuth } from "../../context/AuthContext"; import Icon from "../../components/common/Icon"; import { Sidebar } from "../../components/common/Sidebar"; import { Topbar } from "../../components/common/Topbar"; import { ScriptExplorer } from "../../components/platform/ScriptExplorer"; import { CreateScriptModal } from "../../components/platform/CreateScriptModal"; import { CreateFolderModal } from "../../components/platform/CreateFolderModal"; import { TreeContextMenu } from "../../components/platform/TreeContextMenu"; import { PublishModal } from "../../components/platform/PublishModal"; import { VersionReceiptModal } from "../../components/platform/VersionReceiptModal"; import { Toast } from "../../components/common/Toast"; import { ScriptWorkspace } from "./ScriptWorkspace"; import SchedulePage from "../schedules/SchedulePage"; import { DashboardPage, SystemAdminPage } from "../admin/AdminPages"; import "../../styles/platform.css"; type NewScriptForm = { name: string; scriptType: ScriptType; visibility: Visibility; parentPath: string; }; type ContextMenuState = { x: number; y: number; kind: "root" | "directory" | "file"; path: string; script?: ScriptItem; }; type ToastState = { tone: "success" | "error" | "info"; message: string; }; type ActivePage = "home" | "scripts" | "schedules" | "system"; function pageFromPath(pathname: string): ActivePage { const page = pathname.replace(/^\/+|\/+$/g, ""); return ["scripts", "schedules", "system"].includes(page) ? page as ActivePage : "home"; } function pathForPage(page: ActivePage): string { return page === "home" ? "/workbench" : `/${page}`; } const initialForm: NewScriptForm = { name: "", scriptType: "notebook", visibility: "workspace", parentPath: "", }; export default function ModelPlatformApp() { const { currentWorkspace } = useAuth(); if (!currentWorkspace) { return (
加载中…
); } return ; } function AuthenticatedModelPlatformApp() { const location = useLocation(); const navigate = useNavigate(); const activePage = pageFromPath(location.pathname); const auth = useAuth(); const { user, workspaces, setCurrentWorkspace, logout } = auth; const currentWorkspace = auth.currentWorkspace!; const api = useApi(); // 状态管理 const [scripts, setScripts] = useState([]); const [directories, setDirectories] = useState([]); const [selectedId, setSelectedId] = useState(null); const [openTabIds, setOpenTabIds] = useState([]); const [keyword, setKeyword] = useState(""); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [apiOnline, setApiOnline] = useState(false); // 创建脚本相关 const [createOpen, setCreateOpen] = useState(false); const [creating, setCreating] = useState(false); const [form, setForm] = useState(initialForm); // 创建文件夹相关 const [folderDialog, setFolderDialog] = useState<{ open: boolean; parentPath: string; name: string; busy: boolean; }>({ open: false, parentPath: "", name: "", busy: false }); // 右键菜单 const [contextMenu, setContextMenu] = useState(null); // Workspace 菜单 const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false); // 上传相关 const [uploadParentPath, setUploadParentPath] = useState(""); const [uploading, setUploading] = useState(false); const uploadInputRef = useRef(null); // Toast 通知 const [toast, setToast] = useState(null); // 编辑会话相关 const [editSession, setEditSession] = useState(null); const editSessionRef = useRef(null); const selectedIdRef = useRef(null); const editorOpenRequestRef = useRef(0); const editorOpeningRef = useRef(false); const [embeddedJupyterUrl, setEmbeddedJupyterUrl] = useState(null); const [editBusy, setEditBusy] = useState(false); const [editorOpenError, setEditorOpenError] = useState<{ scriptId: string; message: string; } | null>(null); // 版本发布相关 const [latestVersion, setLatestVersion] = useState(null); const [latestVersionLoading, setLatestVersionLoading] = useState(false); const [publishTarget, setPublishTarget] = useState(null); const [releaseNote, setReleaseNote] = useState(""); const [publishVisibility, setPublishVisibility] = useState("workspace"); const [publishing, setPublishing] = useState(false); const [publishedVersion, setPublishedVersion] = useState(null); // 侧边栏折叠 const [sidebarCollapsed, setSidebarCollapsed] = useState(false); // 加载脚本列表 const load = async (silent = false) => { if (!silent) setLoading(true); setRefreshing(silent); try { const [items, folderItems] = await Promise.all([ api.listScripts(), api.listWorkspaceDirectories(), ]); setScripts(items); setDirectories(folderItems); setApiOnline(true); setSelectedId((current) => { if (current && items.some((item) => item.script_id === current)) { selectedIdRef.current = current; return current; } const nextSelectedId = ( items.find((item) => item.script_type === "notebook")?.script_id ?? items[0]?.script_id ?? null ); selectedIdRef.current = nextSelectedId; return nextSelectedId; }); } catch (error) { setApiOnline(false); setToast({ tone: "error", message: error instanceof Error ? error.message : "脚本列表加载失败", }); } finally { setLoading(false); setRefreshing(false); } }; useEffect(() => { void load(); }, []); useEffect(() => { if (!toast) return; const timer = window.setTimeout(() => setToast(null), 3200); return () => window.clearTimeout(timer); }, [toast]); useEffect(() => { if (!contextMenu) return; const close = () => setContextMenu(null); 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]); useEffect(() => { editSessionRef.current = editSession; }, [editSession]); useEffect(() => { if (!selectedId) { setLatestVersion(null); return; } let ignore = false; setLatestVersionLoading(true); void api.getLatestScriptVersion(selectedId) .then((item) => { if (!ignore) setLatestVersion(item); }) .catch((error) => { if (!ignore) { setToast({ tone: "error", message: error instanceof Error ? error.message : "最新版本加载失败", }); } }) .finally(() => { if (!ignore) setLatestVersionLoading(false); }); return () => { ignore = true; }; }, [selectedId]); // 编辑锁心跳 useEffect(() => { if (!editSession) return; const intervalSeconds = Math.max(5, editSession.heartbeat_interval_seconds || 15); let heartbeatRunning = false; const timer = window.setInterval(() => { if (heartbeatRunning) return; const current = editSessionRef.current; if (!current || current.edit_session_id !== editSession.edit_session_id) { return; } heartbeatRunning = true; void api.heartbeatFileLock(current) .then((updated) => { setEditSession((active) => active && active.edit_session_id === updated.edit_session_id ? { ...active, session_status: updated.session_status, expires_at: updated.expires_at, } : active); }) .catch((error) => { setEditSession(null); setEmbeddedJupyterUrl(null); setToast({ tone: "error", message: `编辑锁心跳已中断:${error instanceof Error ? error.message : "请重新打开文件"}`, }); }) .finally(() => { heartbeatRunning = false; }); }, intervalSeconds * 1000); return () => window.clearInterval(timer); }, [editSession?.edit_session_id, editSession?.heartbeat_interval_seconds]); // Jupyter 访问票据续签 useEffect(() => { if (!editSession?.ticket_expires_at) return; const expiresAt = new Date(editSession.ticket_expires_at).getTime(); const renewAfter = Math.max(15_000, expiresAt - Date.now() - 60_000); const timer = window.setTimeout(() => { const current = editSessionRef.current; if (!current || current.edit_session_id !== editSession.edit_session_id) { return; } void api.createJupyterAccessTicket(current) .then((ticket) => { setEditSession((active) => active && active.edit_session_id === ticket.edit_session_id ? { ...active, ticket_expires_at: ticket.expires_at } : active); }) .catch((error) => { setToast({ tone: "error", message: `Jupyter 访问票据续签失败:${error instanceof Error ? error.message : "请重新打开文件"}`, }); }); }, renewAfter); return () => window.clearTimeout(timer); }, [editSession?.edit_session_id, editSession?.ticket_expires_at]); // 页面卸载时释放编辑锁 useEffect(() => { if (!editSession) return; const handleUnload = () => { const current = editSessionRef.current; if (current) api.releaseFileLockOnUnload(current); }; window.addEventListener("beforeunload", handleUnload); return () => window.removeEventListener("beforeunload", handleUnload); }, [editSession?.edit_session_id]); // 过滤脚本 const filteredScripts = useMemo(() => { const normalized = keyword.trim().toLocaleLowerCase(); if (!normalized) return scripts; return scripts.filter((item) => item.script_name.toLocaleLowerCase().includes(normalized), ); }, [keyword, scripts]); const selected = scripts.find((item) => item.script_id === selectedId) ?? null; // 选择脚本 const selectScript = (scriptId: string | null) => { setSelectedId(scriptId); }; // 打开标签 const openTab = (scriptId: string) => { if (selectedIdRef.current !== scriptId) { editorOpenRequestRef.current += 1; setEditorOpenError(null); } selectedIdRef.current = scriptId; setSelectedId(scriptId); setOpenTabIds((current) => { if (current.includes(scriptId)) { return current; } return [...current, scriptId]; }); }; // 关闭标签 const closeTab = async (scriptId: string, event?: ReactMouseEvent) => { event?.stopPropagation(); if (editSessionRef.current?.script_id === scriptId) { await endEditing(false, false); } setOpenTabIds((current) => { const index = current.indexOf(scriptId); if (index === -1) return current; const newTabs = current.filter((id) => id !== scriptId); if (selectedId === scriptId) { const nextId = newTabs[index] ?? newTabs[index - 1] ?? null; setSelectedId(nextId); selectedIdRef.current = nextId; } return newTabs; }); }; // 切换标签 const switchTab = (scriptId: string) => { if (selectedIdRef.current !== scriptId) { editorOpenRequestRef.current += 1; setEditorOpenError(null); } setSelectedId(scriptId); selectedIdRef.current = scriptId; }; // 打开脚本编辑器 const openScriptEditor = async (script: ScriptItem, showToast = true) => { if (editorOpeningRef.current) return; editorOpeningRef.current = true; const requestId = editorOpenRequestRef.current + 1; editorOpenRequestRef.current = requestId; const requestIsCurrent = () => editorOpenRequestRef.current === requestId && selectedIdRef.current === script.script_id; const clearSessionIfActive = (session: ActiveEditSession) => { if (editSessionRef.current?.edit_session_id === session.edit_session_id) { setEmbeddedJupyterUrl(null); setEditSession(null); editSessionRef.current = null; } }; setEditBusy(true); setEditorOpenError((current) => current?.scriptId === script.script_id ? null : current); let active = editSessionRef.current; let newlyAcquired = false; try { if (active && active.script_id !== script.script_id) { await api.releaseFileLock(active); setEmbeddedJupyterUrl(null); setEditSession(null); editSessionRef.current = 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()) { await api.releaseFileLock(active); clearSessionIfActive(active); return; } const readySession = { ...active, ticket_expires_at: ticket.expires_at }; setEditSession(readySession); editSessionRef.current = readySession; setEmbeddedJupyterUrl(ticket.jupyter_url); if (showToast) { setToast({ tone: "success", message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`, }); } } catch (error) { if (newlyAcquired && active) { try { await api.releaseFileLock(active); } catch { // The database lease is the final safety net } setEditSession(null); editSessionRef.current = null; } setEmbeddedJupyterUrl(null); if (requestIsCurrent()) { const message = error instanceof Error ? error.message : "打开编辑器失败"; setEditorOpenError({ scriptId: script.script_id, message }); if (showToast) { setToast({ tone: "error", message }); } } } finally { editorOpeningRef.current = false; setEditBusy(false); } }; // 自动打开 Notebook 编辑器 useEffect(() => { if ( activePage !== "scripts" || !selected || selected.script_type !== "notebook" || editBusy || editorOpenError?.scriptId === selected.script_id || (editSession?.script_id === selected.script_id && embeddedJupyterUrl) ) { return; } void openScriptEditor(selected, false); }, [ activePage, editBusy, editSession?.script_id, editorOpenError?.scriptId, embeddedJupyterUrl, selected?.script_id, selected?.script_type, ]); // 结束编辑 const endEditing = async (closeTabFlag = true, showToast = true) => { editorOpenRequestRef.current += 1; setEditorOpenError(null); const active = editSessionRef.current; const scriptId = active?.script_id; if (!active) { if (closeTabFlag && scriptId) { void closeTab(scriptId); } return; } setEditBusy(true); try { await api.releaseFileLock(active); setEmbeddedJupyterUrl(null); setEditSession(null); editSessionRef.current = null; if (closeTabFlag && scriptId) { void closeTab(scriptId); } if (showToast) { setToast({ tone: "success", message: `${active.script_name} 的编辑锁已释放`, }); } } catch (error) { setToast({ tone: "error", message: error instanceof Error ? error.message : "释放编辑锁失败", }); } finally { setEditBusy(false); } }; // 切换文件时结束编辑 useEffect(() => { const active = editSessionRef.current; if ( !active || !selected || selected.script_type === "notebook" || selected.script_id === active.script_id || editBusy ) { return; } void endEditing(false, false); }, [editBusy, selected?.script_id, selected?.script_type]); // 切换页面时结束编辑 useEffect(() => { if ( activePage === "scripts" || editBusy || (!editSessionRef.current && !editorOpeningRef.current) ) { return; } void endEditing(true, false); }, [activePage, editBusy]); // 打开发布对话框 const openPublishDialog = (script: ScriptItem) => { setPublishTarget(script); setReleaseNote(""); setPublishVisibility(script.visibility === "private" ? "private" : "workspace"); }; // 发布稳定版本 const submitPublish = async (event: FormEvent) => { event.preventDefault(); if (!publishTarget) return; setPublishing(true); try { const version = await api.publishScriptVersion({ script: publishTarget, releaseNote, visibility: publishVisibility, }); setPublishTarget(null); setPublishedVersion(version); setToast({ tone: "success", message: `${version.version_label} 稳定版本发布成功`, }); } catch (error) { setToast({ tone: "error", message: error instanceof Error ? error.message : "稳定版本发布失败", }); } finally { setPublishing(false); } }; // 创建脚本 const submitCreate = async (event: FormEvent) => { event.preventDefault(); if (!form.name.trim()) return; const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py"; const requestedName = form.name.trim(); const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix) ? requestedName : `${requestedName}${suffix}`; const duplicate = scripts.some((script) => script.script_type === form.scriptType && script.script_name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase() ); if (duplicate) { setToast({ tone: "error", message: `${normalizedName} 已存在,请更换名称` }); return; } setCreating(true); try { const created = await api.createScript(form); setScripts((items) => [created, ...items]); openTab(created.script_id); setCreateOpen(false); setForm(initialForm); setToast({ tone: "success", message: `${created.script_name} 已创建` }); } catch (error) { setToast({ tone: "error", message: error instanceof Error ? error.message : "创建失败", }); } finally { setCreating(false); } }; const openCreateDialog = (parentPath = "", scriptType: ScriptType = "notebook") => { setContextMenu(null); setForm({ ...initialForm, parentPath, scriptType }); setCreateOpen(true); }; const openFolderDialog = (parentPath = "") => { setContextMenu(null); setFolderDialog({ open: true, parentPath, name: "", busy: false }); }; const chooseUpload = (parentPath = "") => { setContextMenu(null); setUploadParentPath(parentPath); uploadInputRef.current?.click(); }; // 上传文件 const handleUpload = async (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; if (files.length === 0) return; setUploading(true); let lastCreated: ScriptItem | null = null; try { for (const file of files) { lastCreated = await api.uploadScript(file, uploadParentPath); } await load(true); if (lastCreated) selectScript(lastCreated.script_id); setToast({ tone: "success", message: `${files.length} 个文件已上传到${uploadParentPath ? ` ${uploadParentPath}` : "当前目录"}`, }); } catch (error) { await load(true); setToast({ tone: "error", message: error instanceof Error ? error.message : "文件上传失败", }); } finally { setUploading(false); } }; // 创建文件夹 const submitFolder = async (event: FormEvent) => { event.preventDefault(); if (!folderDialog.name.trim()) return; setFolderDialog((current) => ({ ...current, busy: true })); try { await api.createWorkspaceDirectory(folderDialog.name.trim(), folderDialog.parentPath); await load(true); setFolderDialog({ open: false, parentPath: "", name: "", busy: false }); setToast({ tone: "success", message: `${folderDialog.name.trim()} 文件夹已创建` }); } catch (error) { setFolderDialog((current) => ({ ...current, busy: false })); setToast({ tone: "error", message: error instanceof Error ? error.message : "文件夹创建失败", }); } }; // 删除脚本 const removeScript = async (script: ScriptItem) => { setContextMenu(null); if (!window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`)) { return; } if (editSessionRef.current?.script_id === script.script_id) { await endEditing(false, false); if (editSessionRef.current?.script_id === script.script_id) return; } try { await api.deleteScript(script.script_id); if (selectedIdRef.current === script.script_id) selectScript(null); await load(true); setToast({ tone: "success", message: `${script.script_name} 已删除` }); } catch (error) { setToast({ tone: "error", message: error instanceof Error ? error.message : "文件删除失败", }); } }; // 删除文件夹 const removeDirectory = async (path: string) => { setContextMenu(null); if (!window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`)) { return; } const activeScript = scripts.find( (item) => item.script_id === editSessionRef.current?.script_id, ); if ( activeScript && (ownedScriptPath(activeScript) === path || ownedScriptPath(activeScript).startsWith(`${path}/`)) ) { await endEditing(false, false); if (editSessionRef.current?.script_id === activeScript.script_id) return; } try { const result = await api.deleteWorkspaceDirectory(path); const selectedScript = scripts.find( (item) => item.script_id === selectedIdRef.current, ); if (selectedScript && ownedScriptPath(selectedScript).startsWith(`${path}/`)) { selectScript(null); } await load(true); setToast({ tone: "success", message: `${path} 已删除(含 ${result.deleted_scripts} 个脚本)` }); } catch (error) { setToast({ tone: "error", message: error instanceof Error ? error.message : "文件夹删除失败", }); } }; // 显示右键菜单 const showContextMenu = ( event: ReactMouseEvent, target: Omit, ) => { event.preventDefault(); event.stopPropagation(); const width = 188; const height = target.kind === "file" ? 92 : 190; setContextMenu({ ...target, x: Math.min(event.clientX, window.innerWidth - width - 8), y: Math.min(event.clientY, window.innerHeight - height - 8), }); }; return (
navigate(pathForPage(page))} onToggleCollapse={() => setSidebarCollapsed((v) => !v)} onEndEditing={() => void endEditing(true)} onSelectScript={selectScript} editSessionRef={editSessionRef} />
{activePage === "scripts" ? (
void load(true)} onUpload={() => chooseUpload("")} onOpenCreateDialog={openCreateDialog} onOpenFolderDialog={openFolderDialog} onChooseUpload={chooseUpload} onContextMenu={showContextMenu} onSelect={openTab} uploadInputRef={uploadInputRef} onHandleUpload={handleUpload} />
{selected ? ( { 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={setToast} /> ) : (
构建脚本工作台

创建你的第一个模型脚本

通过 Notebook 完成数据探索,或使用 Python 脚本构建可调度的处理任务。

)}
) : activePage === "schedules" ? ( ) : activePage === "system" ? ( ) : ( navigate(pathForPage(page))} /> )}
({ script_type: s.script_type, script_name: s.script_name }))} onFormChange={setForm} onSubmit={submitCreate} onClose={() => setCreateOpen(false)} /> setFolderDialog((current) => ({ ...current, name }))} onSubmit={submitFolder} onClose={() => setFolderDialog((current) => ({ ...current, open: false }))} /> { selectScript(scriptId); setContextMenu(null); }} onRemoveScript={removeScript} onOpenCreateDialog={openCreateDialog} onOpenFolderDialog={openFolderDialog} onChooseUpload={chooseUpload} onRemoveDirectory={removeDirectory} onClose={() => setContextMenu(null)} /> setPublishTarget(null)} /> setPublishedVersion(null)} onCopy={(message) => setToast({ tone: "success", message })} />
); } function ownedScriptPath(item: ScriptItem) { return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/"); }