commit c74b2abb4834eb27dbc61ff7727f548e5572e4c1 Author: Winnie <3308978791@qq.com> Date: Thu Jul 30 19:03:00 2026 +0800 重构模型平台前后端并移除Redis依赖 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e336878 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +**/__pycache__ +**/*.py[cod] +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +**/.venv +frontend/node_modules +frontend/build +frontend/.react-router +deploy/data +*.zip diff --git a/.env b/.env new file mode 100644 index 0000000..a238f3e --- /dev/null +++ b/.env @@ -0,0 +1,28 @@ +COMPOSE_PROJECT_NAME=model-develop-new + +# Local development ports +GATEWAY_PORT=28081 +MYSQL_PORT=23318 +RUSTFS_API_PORT=29010 +RUSTFS_CONSOLE_PORT=29011 +BACKEND_PORT=28010 +RUNTIME_PORT=28012 +SCHEDULE_PORT=28013 + +# Jupyter +JUPYTER_IMAGE=quay.io/jupyter/base-notebook:2025-12-31 +JUPYTER_TOKEN=ModelDevelop_Jupyter_Isolated_2026_A7K9 + +# MySQL +MYSQL_DATABASE=model_develop_isolated +MYSQL_USER=model_develop_app +MYSQL_PASSWORD=ModelDevelop_MySQL_App_2026_B8M4 +MYSQL_ROOT_PASSWORD=ModelDevelop_MySQL_Root_2026_C6R2 + +# RustFS +RUSTFS_IMAGE=rustfs/rustfs:latest +RUSTFS_ACCESS_KEY=modeldevelopisolated +RUSTFS_SECRET_KEY=ModelDevelop_RustFS_2026_D9P5 + +# Internal service authentication +INTERNAL_SERVICE_TOKEN=ModelDevelop_Internal_Service_2026_E4T8 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7439f6c --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +COMPOSE_PROJECT_NAME=model-platform-refactored + +# Local development ports +GATEWAY_PORT=8081 +MYSQL_PORT=3308 +RUSTFS_API_PORT=9010 +RUSTFS_CONSOLE_PORT=9011 +BACKEND_PORT=8010 +RUNTIME_PORT=8012 +SCHEDULE_PORT=8013 + +# Jupyter only exposes port 8888 inside the Compose network. +JUPYTER_IMAGE=quay.io/jupyter/base-notebook:2025-12-31 +JUPYTER_TOKEN=ChangeMe_Jupyter_Internal_2026 + +# MySQL 8 local development credentials +MYSQL_DATABASE=model_platform +MYSQL_USER=model_platform +MYSQL_PASSWORD=ChangeMe_MySQL_App_2026 +MYSQL_ROOT_PASSWORD=ChangeMe_MySQL_Root_2026 + +# RustFS local development image and credentials +RUSTFS_IMAGE=rustfs/rustfs:latest +RUSTFS_ACCESS_KEY=modelplatform +RUSTFS_SECRET_KEY=ChangeMe_RustFS_2026 + +# Internal service authentication; replace in every non-local environment. +INTERNAL_SERVICE_TOKEN=ChangeMe_Internal_Service_2026 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..862d09b --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/model-develop-refactored.iml b/.idea/model-develop-refactored.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/model-develop-refactored.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..4cd2323 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000..044636a Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/.dockerignore b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/.dockerignore new file mode 100644 index 0000000..9b8d514 --- /dev/null +++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/.gitignore b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/.gitignore new file mode 100644 index 0000000..039ee62 --- /dev/null +++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/README.md b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/README.md new file mode 100644 index 0000000..a3844c1 --- /dev/null +++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/README.md @@ -0,0 +1,22 @@ +# Frontend + +React Router SPA,已将原前端按功能拆分到 `app/`: + +```text +app/components/ 通用图标组件 +app/features/platform/ 主框架、脚本工作区和 Jupyter 编辑 +app/features/schedules/ DAG 调度页面 +app/features/admin/ 工作台与系统管理 +app/routes/ React Router 路由入口 +app/services/ API 请求、DTO 和演示上下文 +app/styles/ 分功能样式 +``` + +本地开发: + +```bash +pnpm install +pnpm dev +``` + +生产构建由根目录 `nginx/Dockerfile` 完成,构建结果复制到 Nginx 静态目录。 diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/app.css b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/app.css new file mode 100644 index 0000000..928fa83 --- /dev/null +++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/app.css @@ -0,0 +1,11 @@ +:root { + font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + color: #1f354b; + background: #f3f6f9; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-width: 1024px; min-height: 100%; } +button, input, select, textarea { font: inherit; } diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/components/Icon.tsx b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/components/Icon.tsx new file mode 100644 index 0000000..ec50beb --- /dev/null +++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/components/Icon.tsx @@ -0,0 +1,183 @@ +type IconName = + | "brand" + | "home" + | "script" + | "schedule" + | "experiment" + | "database" + | "settings" + | "chevron" + | "search" + | "plus" + | "upload" + | "folder" + | "notebook" + | "python" + | "refresh" + | "close" + | "check" + | "info" + | "workspace" + | "menu" + | "external" + | "release" + | "play"; + +type IconProps = { + name: IconName; + size?: number; + strokeWidth?: number; +}; + +export default function Icon({ + name, + size = 18, + strokeWidth = 1.8, +}: IconProps) { + const common = { + width: size, + height: size, + viewBox: "0 0 24 24", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": true, + }; + + if (name === "brand") { + return ( + + + + + + ); + } + + const paths: Record, React.ReactNode> = { + home: ( + <> + + + + ), + script: ( + <> + + + + ), + schedule: ( + <> + + + + ), + experiment: ( + <> + + + + ), + database: ( + <> + + + + + ), + settings: ( + <> + + + + ), + chevron: , + search: ( + <> + + + + ), + plus: , + upload: ( + <> + + + + ), + folder: ( + + ), + notebook: ( + <> + + + + ), + python: ( + <> + + + + + + ), + refresh: ( + <> + + + + ), + close: , + check: , + info: ( + <> + + + + ), + workspace: ( + <> + + + + ), + menu: ( + <> + + + ), + external: ( + <> + + + + ), + release: ( + <> + + + + ), + play: , + }; + + return ( + + {paths[name]} + + ); +} diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/features/admin/AdminPages.tsx b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/features/admin/AdminPages.tsx new file mode 100644 index 0000000..1f7ad1d --- /dev/null +++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/features/admin/AdminPages.tsx @@ -0,0 +1,256 @@ +import { FormEvent, useEffect, useState } from "react"; + +import { + ApiRequestError, + createEmployee, + deleteEmployee, + demoContext, + listEmployees, + updateEmployee, + type Employee, +} from "../../services/api"; +import Icon from "../../components/Icon"; +import "../../styles/admin.css"; +import "../../styles/dashboard.css"; + + +type Notice = { + tone: "success" | "error" | "info"; + message: string; +}; + +export function DashboardPage({ + scriptCount, + online, + onNavigate, +}: { + scriptCount: number; + online: boolean; + onNavigate: (page: "scripts" | "schedules" | "system") => void; +}) { + return ( +
+
+
+ MODEL DEVELOPMENT PLATFORM +

下午好,{demoContext.userName}

+

当前位于 {demoContext.workspaceName},可以继续构建脚本或配置调度。

+
+ {online ? "服务正常" : "服务连接中"} +
+
+
{scriptCount}工作副本
+
2Workspace
+
4平台员工
+
{online ? "正常" : "检查中"}平台状态
+
+
+ + + +
+
+
+
运行趋势

近 7 天调度执行

成功率 92.6%
+
+ {[38, 55, 44, 73, 61, 86, 78].map((value, index) => ( +
+ {Math.round(value / 7)} + + {["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]} +
+ ))} +
+
成功 75失败 6
+
+
+
脚本资产

类型分布

+
+
{scriptCount}全部脚本
+
+ Notebook{Math.max(1, Math.round(scriptCount * .67))} 个 · 67% + Python{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33% + 稳定版本3 个已发布 +
+
+
+
+
ACTIVITY

最近平台活动

+
+
操作内容执行人状态时间
+ {[ + ["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"], + ["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"], + ["批量预测.py 更新工作副本", "王五", "已同步", "14:18"], + ["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"], + ].map((row) => ( +
+ {row[0]} + {row[1]}{row[2]}{row[3]} +
+ ))} +
+
+
+
+ ); +} + + +const EMPTY_FORM = { + username: "", + display_name: "", + email: "", + role_code: "developer" as "admin" | "developer", + status: "active" as "active" | "disabled" | "locked", +}; + +export function SystemAdminPage({ + onNotify, + onConnectionChange, +}: { + onNotify: (notice: Notice) => void; + onConnectionChange: (online: boolean) => void; +}) { + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const canManage = demoContext.roleCode === "admin"; + + const load = async (): Promise => { + setLoading(true); + try { + setEmployees(await listEmployees()); + onConnectionChange(true); + } catch (error) { + onConnectionChange(false); + onNotify({ + tone: "error", + message: error instanceof Error ? error.message : "员工列表加载失败", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const openCreate = (): void => { + setEditing(null); + setForm(EMPTY_FORM); + setDialogOpen(true); + }; + + const openEdit = (employee: Employee): void => { + setEditing(employee); + setForm({ + username: employee.username, + display_name: employee.display_name, + email: employee.email ?? "", + role_code: employee.role_code, + status: employee.status, + }); + setDialogOpen(true); + }; + + const submit = async (event: FormEvent): Promise => { + event.preventDefault(); + if (!form.display_name.trim() || (!editing && !form.username.trim())) return; + setSaving(true); + try { + if (editing) { + const updated = await updateEmployee(editing.user_id, { + display_name: form.display_name.trim(), + email: form.email.trim() || null, + role_code: form.role_code, + status: form.status, + }); + setEmployees((current) => current.map( + (item) => item.user_id === updated.user_id ? updated : item, + )); + } else { + const created = await createEmployee({ + username: form.username.trim(), + display_name: form.display_name.trim(), + email: form.email.trim() || null, + role_code: form.role_code, + }); + setEmployees((current) => [...current, created]); + } + setDialogOpen(false); + onNotify({ tone: "success", message: editing ? "员工信息已更新" : "员工已添加" }); + } catch (error) { + onNotify({ + tone: "error", + message: error instanceof ApiRequestError ? error.message : "保存员工失败", + }); + } finally { + setSaving(false); + } + }; + + const remove = async (employee: Employee): Promise => { + if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return; + try { + await deleteEmployee(employee.user_id); + setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id)); + onNotify({ tone: "success", message: "员工已删除" }); + } catch (error) { + onNotify({ + tone: "error", + message: error instanceof Error ? error.message : "删除员工失败", + }); + } + }; + + return ( +
+
+
系统管理

员工管理

{demoContext.workspaceName} · {employees.length} 名员工

+ +
+ {!canManage &&
当前为开发人员,只能查看员工列表。
} +
+
员工账号角色状态操作
+ {loading ?

正在加载员工…

: employees.map((employee) => { + const isProtectedAdmin = employee.role_code === "admin"; + return ( +
+ {employee.display_name.slice(0, 1)}{employee.display_name}{employee.email ?? "未设置邮箱"} + {employee.username} + {employee.role_name} + {employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"} + + + + +
+ ); + })} +
+ + {dialogOpen && ( +
+
+
EMPLOYEE

{editing ? "编辑员工" : "添加员工"}

+
void submit(event)}> + + + + + {editing && } +
+
+
+
+ )} +
+ ); +} diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/features/platform/ModelPlatformApp.tsx b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/features/platform/ModelPlatformApp.tsx new file mode 100644 index 0000000..73a3b9c --- /dev/null +++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/app/features/platform/ModelPlatformApp.tsx @@ -0,0 +1,1965 @@ +import { + type ChangeEvent, + FormEvent, + type MouseEvent as ReactMouseEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useLocation, useNavigate } from "react-router"; + +import { + acquireFileLock, + createWorkspaceDirectory, + createScript, + createJupyterAccessTicket, + deleteScript, + deleteWorkspaceDirectory, + demoContext, + demoUsers, + demoWorkspaces, + heartbeatFileLock, + listScripts, + listScriptVersions, + listWorkspaceDirectories, + publishScriptVersion, + releaseFileLock, + releaseFileLockOnUnload, + setDemoContext, + uploadScript, + type ActiveEditSession, + type ScriptItem, + type ScriptType, + type StableVersion, + type Visibility, + type WorkspaceDirectory, +} from "../../services/api"; +import Icon from "../../components/Icon"; +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; +}; + +const navigation = [ + { label: "工作台", icon: "home" as const, page: "home" as const }, + { label: "构建脚本", icon: "script" as const, page: "scripts" as const }, + { label: "调度配置", icon: "schedule" as const, page: "schedules" as const }, + { label: "系统管理", icon: "settings" as const, page: "system" as const }, +]; + +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: "", +}; + +function formatTime(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(value)); +} + +function formatBytes(value: number) { + if (value < 1024) return `${value} B`; + return `${(value / 1024).toFixed(1)} KB`; +} + +function shortHash(value: string) { + return value ? `${value.slice(0, 8)}…${value.slice(-6)}` : "—"; +} + +function scriptIcon(item: ScriptItem) { + return item.script_type === "notebook" ? "notebook" : "python"; +} + +function ownedScriptPath(item: ScriptItem) { + return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/"); +} + +function parentOf(path: string) { + const parts = path.split("/"); + parts.pop(); + return parts.join("/"); +} + +function inferredDirectories(items: ScriptItem[]): WorkspaceDirectory[] { + const result = new Map(); + for (const item of items) { + const parts = parentOf(ownedScriptPath(item)).split("/").filter(Boolean); + let parentPath = ""; + for (const name of parts) { + const path = parentPath ? `${parentPath}/${name}` : name; + result.set(path, { path, name, parent_path: parentPath }); + parentPath = path; + } + } + return [...result.values()]; +} + +function mergeDirectories( + left: WorkspaceDirectory[], + right: WorkspaceDirectory[], +): WorkspaceDirectory[] { + return [...new Map( + [...left, ...right].map((item) => [item.path, item]), + ).values()]; +} + +function confineJupyterFrame(frame: HTMLIFrameElement): void { + try { + const document = frame.contentDocument; + if (!document?.documentElement) return; + const keepInside = (): void => { + document.querySelectorAll("button,[role='button']").forEach( + (element) => { + const label = `${element.getAttribute("aria-label") ?? ""} ${ + element.getAttribute("title") ?? "" + } ${element.textContent ?? ""}`.trim(); + if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) { + element.style.setProperty("display", "none", "important"); + } + }, + ); + document.querySelectorAll("a[target]").forEach((link) => { + if (["_blank", "_top", "_parent"].includes(link.target)) { + link.target = "_self"; + } + }); + }; + keepInside(); + new MutationObserver(keepInside).observe(document.documentElement, { + childList: true, + subtree: true, + }); + document.addEventListener("click", (event) => { + const target = event.target as HTMLElement | null; + const link = target?.closest?.("a") as HTMLAnchorElement | null; + if (link && ["_blank", "_top", "_parent"].includes(link.target)) { + link.target = "_self"; + } + }, true); + } catch { + // The iframe remains sandboxed even if its document is not yet accessible. + } +} + +export default function ModelPlatformApp() { + const location = useLocation(); + const navigate = useNavigate(); + const activePage = pageFromPath(location.pathname); + const [scripts, setScripts] = useState([]); + const [directories, setDirectories] = useState([]); + const [selectedId, setSelectedId] = useState(null); + 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); + const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false); + const [userMenuOpen, setUserMenuOpen] = useState(false); + const [uploadParentPath, setUploadParentPath] = useState(""); + const [uploading, setUploading] = useState(false); + const uploadInputRef = useRef(null); + 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 [versions, setVersions] = useState([]); + const [versionsLoading, setVersionsLoading] = 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 load = async (silent = false) => { + if (!silent) setLoading(true); + setRefreshing(silent); + try { + const [items, folderItems] = await Promise.all([ + listScripts(), + 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) { + setVersions([]); + return; + } + let ignore = false; + setVersionsLoading(true); + void listScriptVersions(selectedId) + .then((items) => { + if (!ignore) setVersions(items); + }) + .catch((error) => { + if (!ignore) { + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "版本列表加载失败", + }); + } + }) + .finally(() => { + if (!ignore) setVersionsLoading(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 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]); + + 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 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) 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 memberScriptGroups = [...demoUsers] + .sort((left, right) => ( + Number(right.userId === demoContext.userId) + - Number(left.userId === demoContext.userId) + )) + .map((user) => { + const memberScripts = filteredScripts.filter( + (item) => item.owner_user_id === user.userId, + ); + const inferred = inferredDirectories(memberScripts); + return { + user, + scripts: memberScripts, + directories: user.userId === demoContext.userId + ? mergeDirectories(directories, inferred) + : inferred, + }; + }); + const selected = scripts.find((item) => item.script_id === selectedId) ?? null; + + const selectScript = (scriptId: string | null) => { + if (selectedIdRef.current !== scriptId) { + editorOpenRequestRef.current += 1; + setEditorOpenError(null); + } + selectedIdRef.current = scriptId; + setSelectedId(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 releaseFileLock(active); + setEmbeddedJupyterUrl(null); + setEditSession(null); + editSessionRef.current = null; + active = null; + } + if (!requestIsCurrent()) return; + + if (!active) { + active = await acquireFileLock(script); + newlyAcquired = true; + } + if (!requestIsCurrent()) { + if (active) { + await releaseFileLock(active); + clearSessionIfActive(active); + } + return; + } + + const ticket = await createJupyterAccessTicket(active); + if (!requestIsCurrent()) { + await 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 releaseFileLock(active); + } catch { + // The database lease is the final safety net if compensation cannot reach Runtime. + } + 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); + } + }; + + 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 (closeTab = false, showToast = true) => { + editorOpenRequestRef.current += 1; + setEditorOpenError(null); + const active = editSessionRef.current; + if (!active) { + if (closeTab) selectScript(null); + return; + } + setEditBusy(true); + try { + await releaseFileLock(active); + setEmbeddedJupyterUrl(null); + setEditSession(null); + editSessionRef.current = null; + if (closeTab) selectScript(null); + 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 publishScriptVersion({ + script: publishTarget, + releaseNote, + visibility: publishVisibility, + }); + setVersions((items) => [ + version, + ...items.filter((item) => item.versions_id !== version.versions_id), + ]); + 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; + setCreating(true); + try { + const created = await createScript(form); + setScripts((items) => [created, ...items]); + selectScript(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 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 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 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 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 ( +
+ + +
+
+
+ +
+ 开发工作区 +

{{ + home: "工作台", + scripts: "构建脚本", + schedules: "调度配置", + system: "系统管理", + }[activePage]}

+
+
+
+
+ + {apiOnline ? "服务已连接" : "服务未连接"} +
+
+ + {workspaceMenuOpen && ( +
+ {demoWorkspaces.map((workspace) => ( + + ))} +
+ )} +
+
+ + {userMenuOpen && ( +
+ {demoUsers.map((user) => ( + + ))} +
+ )} +
+
+
+ + {activePage === "scripts" ? ( +
+ + +
+ {selected ? ( + void openScriptEditor(selected)} + onEndEditing={() => void endEditing()} + onClose={() => { + if ( + editSessionRef.current?.script_id === selected.script_id + ) { + void endEditing(true); + } else { + selectScript(null); + } + }} + onPublish={() => openPublishDialog(selected)} + onInfo={setToast} + /> + ) : ( +
+
+ +
+ 构建脚本工作台 +

创建你的第一个模型脚本

+

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

+ +
+ )} +
+
+ ) : activePage === "schedules" ? ( + + ) : activePage === "system" ? ( + + ) : ( + { + navigate(pathForPage(page)); + }} + /> + )} +
+ + {createOpen && ( +
+
+
+
+ 工作副本 +

新建构建脚本

+
+ +
+
+
+ + 保存到:{form.parentPath || "个人根目录"} +
+ + +
+ 脚本类型 + + +
+ + + +
+ + +
+
+
+
+ )} + + {folderDialog.open && ( +
+
+
+
+ WORKSPACE +

新建文件夹

+
+ +
+
+
+ + 创建到:{folderDialog.parentPath || "个人根目录"} +
+ +
+ + +
+
+
+
+ )} + + {contextMenu && ( +
event.stopPropagation()} + > + {contextMenu.kind === "file" && contextMenu.script ? ( + <> + + + + ) : ( + <> + + + + + {contextMenu.kind === "directory" && ( + <> + + + + )} + + )} +
+ )} + + {publishTarget && ( +
+
+
+
+ 不可变制品 +

发布稳定版本

+
+ +
+
+
+ + + + + {publishTarget.script_name} + 当前 Workspace 工作副本 + +
+