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