diff --git a/frontend/app/features/platform/DashboardRoute.tsx b/frontend/app/features/platform/DashboardRoute.tsx new file mode 100644 index 0000000..f28f6fb --- /dev/null +++ b/frontend/app/features/platform/DashboardRoute.tsx @@ -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 ( + { + if (page === "scripts") navigate("/scripts"); + else if (page === "schedules") navigate("/schedules"); + else navigate("/system"); + }} + /> + ); +} \ No newline at end of file diff --git a/frontend/app/features/platform/ModelPlatformApp.tsx b/frontend/app/features/platform/ModelPlatformApp.tsx index 1505377..34246c6 100644 --- a/frontend/app/features/platform/ModelPlatformApp.tsx +++ b/frontend/app/features/platform/ModelPlatformApp.tsx @@ -1,65 +1,26 @@ -import { - type ChangeEvent, - type FormEvent, - type MouseEvent as ReactMouseEvent, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useLocation, useNavigate } from "react-router"; +import { useEffect, useState } from "react"; +import { Outlet, 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 { Topbar } from "../../components/common/Topbar"; +import { useApi, useAuth } from "../../context/AuthContext"; +import { useEditSessionLifecycle } from "./hooks/useEditSessionLifecycle"; +import { + bindScriptWorkspaceApi, + editSessionHandle, + useScriptWorkspaceStore, +} from "./state/scriptWorkspaceStore"; +import { useUiStore } from "./state/uiStore"; + 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 + ? (page as ActivePage) : "home"; } @@ -67,819 +28,65 @@ 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 ; + return ; } -function AuthenticatedModelPlatformApp() { +function AuthenticatedLayout() { + const api = useApi(); 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 { + user, + workspaces, + currentWorkspace, + setCurrentWorkspace, + logout, + } = auth; - // 状态管理 - 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 apiOnline = useScriptWorkspaceStore((s) => s.apiOnline); + const endEditing = useScriptWorkspaceStore((s) => s.endEditing); + const selectScript = useScriptWorkspaceStore((s) => s.selectScript); - // 创建脚本相关 - const [createOpen, setCreateOpen] = useState(false); - const [creating, setCreating] = useState(false); - const [form, setForm] = useState(initialForm); + const toast = useUiStore((s) => s.toast); + const dismissToast = useUiStore((s) => s.dismissToast); + const workspaceMenuOpen = useUiStore((s) => s.workspaceMenuOpen); + const setWorkspaceMenuOpen = useUiStore((s) => s.setWorkspaceMenuOpen); - // 创建文件夹相关 - 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); - - // 编辑会话相关 - 为每个标签维护独立 iframe 和状态 - 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); - - // 为每个打开的标签维护独立的缓存会话(多 iframe 共存方案) - interface CachedSession { - session: ActiveEditSession; - jupyterUrl: string; - lastActiveTime: number; // 最后激活时间戳(用于清理) - } - const sessionCacheRef = useRef>(new Map()); - - // 版本发布相关 - 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) => { - const validIds = new Set(items.map(item => item.script_id)); - // 当前选中的文件还在 → 保持 - if (current && validIds.has(current)) { - selectedIdRef.current = current; - return current; - } - // 否则设为 null(不自动选中) - selectedIdRef.current = null; - return null; - }); - // 同时更新打开的标签页:保留有效标签,移除已删除的文件 - setOpenTabIds((current) => { - const validIds = new Set(items.map(item => item.script_id)); - // 过滤掉已删除的文件 - const preserved = current.filter(id => validIds.has(id)); - // 直接返回(有就有,没有就没有,不自动补充) - return preserved; - }); - } catch (error) { - setApiOnline(false); - setToast({ - tone: "error", - message: error instanceof Error ? error.message : "脚本列表加载失败", - }); - } finally { - setLoading(false); - setRefreshing(false); - } - }; - + // 绑定 api 到 script workspace store useEffect(() => { - void load(); - }, []); + bindScriptWorkspaceApi(api); + return () => { + bindScriptWorkspaceApi(null); + }; + }, [api]); - // 切换 Workspace 时重置状态并重新加载 - useEffect(() => { - setScripts([]); - setDirectories([]); - setSelectedId(null); - setOpenTabIds([]); - setLatestVersion(null); - setEmbeddedJupyterUrl(null); - setEditSession(null); - editSessionRef.current = null; - selectedIdRef.current = null; - void load(); - }, [currentWorkspace.workspace_id]); + // 心跳 / cleanup / 切页结束编辑 / 卸载前释放 + useEditSessionLifecycle({ activePage }); + // toast 自动消失 useEffect(() => { if (!toast) return; - const timer = window.setTimeout(() => setToast(null), 3200); + const timer = window.setTimeout(dismissToast, 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]); - - // 统一心跳管理:为所有缓存的会话维持心跳(包括隐藏的 iframe) - useEffect(() => { - const intervalSeconds = 15; - let heartbeatRunning = false; - const timer = window.setInterval(() => { - if (heartbeatRunning) return; - heartbeatRunning = true; - // 为所有缓存的会话调用心跳 - const promises: Promise[] = []; - for (const cached of sessionCacheRef.current.values()) { - // 只更新缓存中的 session 对象,不更新 React 状态 - promises.push( - api.heartbeatFileLock(cached.session) - .then((updated) => { - cached.session.session_status = updated.session_status; - cached.session.expires_at = updated.expires_at; - }) - .catch(() => { - // 不删除缓存,等待用户切回来时再处理 - }) - ); - } - Promise.allSettled(promises).finally(() => { - heartbeatRunning = false; - }); - }, intervalSeconds * 1000); - return () => window.clearInterval(timer); - }, []); - - // 定时清理:10 分钟无活动的会话 - useEffect(() => { - const TEN_MINUTES = 10 * 60 * 1000; - const CHECK_INTERVAL = 60 * 1000; // 每分钟检查一次 - - const cleanupTimer = window.setInterval(() => { - const now = Date.now(); - const toCleanup: string[] = []; - - // 找出需要清理的会话 - for (const [scriptId, cached] of sessionCacheRef.current.entries()) { - // 只清理非激活的会话 - if (scriptId !== selectedIdRef.current) { - const inactiveTime = now - cached.lastActiveTime; - if (inactiveTime > TEN_MINUTES) { - toCleanup.push(scriptId); - } - } - } - - // 执行清理 - if (toCleanup.length > 0) { - for (const scriptId of toCleanup) { - const cached = sessionCacheRef.current.get(scriptId); - if (cached) { - // 释放编辑锁 - api.releaseFileLock(cached.session).catch(console.warn); - // 从缓存删除 - sessionCacheRef.current.delete(scriptId); - } - } - // 通知用户 - setToast({ - tone: "info", - message: `已清理 ${toCleanup.length} 个长时间未活动的编辑会话`, - }); - } - }, CHECK_INTERVAL); - - return () => window.clearInterval(cleanupTimer); - }, []); - - // 页面卸载时释放编辑锁 - 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); - selectedIdRef.current = 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); - } - // 从缓存中删除 - sessionCacheRef.current.delete(scriptId); - 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 cached = sessionCacheRef.current.get(scriptId); - if (cached) { - // 更新最后激活时间 - cached.lastActiveTime = Date.now(); - setEditSession(cached.session); - editSessionRef.current = cached.session; - setEmbeddedJupyterUrl(cached.jupyterUrl); - } - }; - - // 打开脚本编辑器 - const openScriptEditor = async (script: ScriptItem, showToast = true) => { - if (!script) return; - 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); - - try { - // 检查是否已有缓存的编辑会话 - const cached = sessionCacheRef.current.get(script.script_id); - if (cached) { - if (!requestIsCurrent()) return; - // 直接复用缓存的会话和 URL - setEditSession(cached.session); - editSessionRef.current = cached.session; - setEmbeddedJupyterUrl(cached.jupyterUrl); - if (showToast) { - setToast({ - tone: "success", - message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`, - }); - } - return; - } - - // 没有缓存,需要获取新锁 - let active = editSessionRef.current; - let newlyAcquired = false; - 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()) { - if (newlyAcquired) { - 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); - // 缓存会话(多 iframe 方案:增加 lastActiveTime) - sessionCacheRef.current.set(script.script_id, { - session: readySession, - jupyterUrl: ticket.jupyter_url, - lastActiveTime: Date.now(), - }); - if (showToast) { - setToast({ - tone: "success", - message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`, - }); - } - } catch (error) { - 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; - } - // 检查是否有缓存的会话,有则直接复用,不触发自动打开 - const cached = sessionCacheRef.current.get(selected.script_id); - if (cached) { - setEditSession(cached.session); - editSessionRef.current = cached.session; - setEmbeddedJupyterUrl(cached.jupyterUrl); - 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 (scriptId) sessionCacheRef.current.delete(scriptId); - 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(() => { - 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); - } - // 和新建文件逻辑一致:直接更新 scripts,不调用 load() - setScripts((items) => [lastCreated!, ...items]); - // 只打开最后一个文件 - if (lastCreated) { - openTab(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); - // 从标签栏删除该文件,并选中相邻的文件 - setOpenTabIds((current) => { - const index = current.indexOf(script.script_id); - const newTabs = current.filter((id) => id !== script.script_id); - // 如果删除的是当前选中的文件,选中相邻的 - if (selectedIdRef.current === script.script_id) { - const nextId = newTabs[index] ?? newTabs[index - 1] ?? null; - setSelectedId(nextId); - selectedIdRef.current = nextId; - } - return newTabs; - }); - 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), - }); - }; + }, [toast, dismissToast]); return (
@@ -890,7 +97,7 @@ function AuthenticatedModelPlatformApp() { onToggleCollapse={() => setSidebarCollapsed((v) => !v)} onEndEditing={() => void endEditing(true)} onSelectScript={selectScript} - editSessionRef={editSessionRef} + editSessionRef={editSessionHandle} />
@@ -898,7 +105,7 @@ function AuthenticatedModelPlatformApp() { activePage={activePage} apiOnline={apiOnline} user={user} - currentWorkspace={currentWorkspace} + currentWorkspace={currentWorkspace!} workspaces={workspaces} workspaceMenuOpen={workspaceMenuOpen} onSetWorkspaceMenuOpen={setWorkspaceMenuOpen} @@ -906,152 +113,10 @@ function AuthenticatedModelPlatformApp() { onLogout={logout} /> - {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("/"); -} +} \ No newline at end of file diff --git a/frontend/app/features/platform/ScriptsPage.tsx b/frontend/app/features/platform/ScriptsPage.tsx new file mode 100644 index 0000000..396a906 --- /dev/null +++ b/frontend/app/features/platform/ScriptsPage.tsx @@ -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(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) => { + 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 ( +
+ 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} + /> + +
+ {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={(t) => pushToast(t)} + /> + ) : ( +
+
+ +
+ 构建脚本工作台 +

创建你的第一个模型脚本

+

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

+ +
+ )} +
+ + ({ + script_type: s.script_type, + script_name: s.script_name, + }))} + onFormChange={setCreateForm} + onSubmit={handleCreateSubmit} + onClose={closeCreateDialog} + /> + + + + { + 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} + /> + + + + pushToast({ tone: "success", message })} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/app/features/platform/hooks/useEditSessionLifecycle.ts b/frontend/app/features/platform/hooks/useEditSessionLifecycle.ts new file mode 100644 index 0000000..a70c9b6 --- /dev/null +++ b/frontend/app/features/platform/hooks/useEditSessionLifecycle.ts @@ -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); + }; + }, []); +} \ No newline at end of file diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts new file mode 100644 index 0000000..42cae9b --- /dev/null +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -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(); +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; + selectScript: (id: string | null) => void; + openTab: (id: string) => void; + closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise; + switchTab: (id: string) => void; + openScriptEditor: (script: ScriptItem, showToast?: boolean) => Promise; + endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise; + loadLatestVersion: (scriptId: string) => Promise; + createScript: (form: NewScriptForm) => Promise; + uploadScripts: (files: File[], parentPath: string) => Promise; + createFolder: (name: string, parentPath: string) => Promise; + deleteScript: (script: ScriptItem) => Promise; + deleteDirectory: (path: string) => Promise; + openPublishDialog: (script: ScriptItem) => void; + submitPublish: (releaseNote: string, visibility: Visibility) => Promise; + + // 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用) + tickHeartbeats: () => Promise; + 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((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[] = []; + 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); + } + }, + }; +}); \ No newline at end of file diff --git a/frontend/app/features/platform/state/uiStore.ts b/frontend/app/features/platform/state/uiStore.ts new file mode 100644 index 0000000..45dedb2 --- /dev/null +++ b/frontend/app/features/platform/state/uiStore.ts @@ -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, + ) => 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((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 } })), +})); \ No newline at end of file diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index df6430f..d74832f 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -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; \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 83ef744..2c97729 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c70aa89..8b22ada 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6cd955d..d397e9d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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",