Files
model-platform/frontend/app/features/platform/ModelPlatformApp.tsx
T

1966 lines
63 KiB
TypeScript

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<string, WorkspaceDirectory>();
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<HTMLElement>("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<HTMLAnchorElement>("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<ScriptItem[]>([]);
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(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<NewScriptForm>(initialForm);
const [folderDialog, setFolderDialog] = useState<{
open: boolean;
parentPath: string;
name: string;
busy: boolean;
}>({ open: false, parentPath: "", name: "", busy: false });
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [uploadParentPath, setUploadParentPath] = useState("");
const [uploading, setUploading] = useState(false);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [toast, setToast] = useState<ToastState | null>(null);
const [editSession, setEditSession] = useState<ActiveEditSession | null>(null);
const editSessionRef = useRef<ActiveEditSession | null>(null);
const selectedIdRef = useRef<string | null>(null);
const editorOpenRequestRef = useRef(0);
const editorOpeningRef = useRef(false);
const [embeddedJupyterUrl, setEmbeddedJupyterUrl] =
useState<string | null>(null);
const [editBusy, setEditBusy] = useState(false);
const [editorOpenError, setEditorOpenError] = useState<{
scriptId: string;
message: string;
} | null>(null);
const [versions, setVersions] = useState<StableVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [publishTarget, setPublishTarget] = useState<ScriptItem | null>(null);
const [releaseNote, setReleaseNote] = useState("");
const [publishVisibility, setPublishVisibility] =
useState<Visibility>("workspace");
const [publishing, setPublishing] = useState(false);
const [publishedVersion, setPublishedVersion] =
useState<StableVersion | null>(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<HTMLInputElement>) => {
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<ContextMenuState, "x" | "y">,
) => {
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 (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<span className="brand__mark"><Icon name="brand" size={31} /></span>
<span className="brand__name">模型实验开发平台</span>
</div>
<nav className="navigation" aria-label="主导航">
{navigation.map((item) => (
<button
className={`nav-item${
item.page === activePage ? " nav-item--active" : ""
}`}
key={item.label}
type="button"
onClick={() => {
if (item.page !== "scripts") {
if (editSessionRef.current) {
void endEditing(true);
} else {
selectScript(null);
}
}
navigate(pathForPage(item.page));
}}
>
<Icon name={item.icon} size={19} />
<span>{item.label}</span>
</button>
))}
</nav>
<button className="sidebar-footer" type="button">
<Icon name="menu" size={19} />
<span>收起菜单</span>
</button>
</aside>
<main className="main-area">
<header className="topbar">
<div className="page-title">
<button className="icon-button icon-button--back" type="button">
<Icon name="chevron" size={19} />
</button>
<div>
<span className="page-title__eyebrow">开发工作区</span>
<h1>{{
home: "工作台",
scripts: "构建脚本",
schedules: "调度配置",
system: "系统管理",
}[activePage]}</h1>
</div>
</div>
<div className="topbar__actions">
<div className="api-state">
<span className={`api-state__dot${apiOnline ? " is-online" : ""}`} />
{apiOnline ? "服务已连接" : "服务未连接"}
</div>
<div className="topbar-menu-wrap">
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); setUserMenuOpen(false); }}>
<span className="workspace-switcher__icon"><Icon name="workspace" size={18} /></span>
<span><small>当前 Workspace</small><strong>{demoContext.workspaceName}</strong></span>
<Icon name="chevron" size={15} />
</button>
{workspaceMenuOpen && (
<div className="topbar-dropdown">
{demoWorkspaces.map((workspace) => (
<button className={workspace.workspaceId === demoContext.workspaceId ? "is-selected" : ""} type="button" key={workspace.workspaceId} onClick={() => { setDemoContext({ workspace }); window.location.reload(); }}>
<Icon name="workspace" size={15} /><span><strong>{workspace.workspaceName}</strong><small>{workspace.workspaceId === demoContext.workspaceId ? "当前使用" : "点击切换"}</small></span>
</button>
))}
</div>
)}
</div>
<div className="topbar-menu-wrap">
<button className="user-menu" type="button" onClick={() => { setUserMenuOpen((value) => !value); setWorkspaceMenuOpen(false); }}>
<span className="avatar">{demoContext.userName.slice(0, 1)}</span>
<span className="user-menu__copy"><strong>{demoContext.userName}</strong><small>{demoContext.roleName}</small></span>
<Icon name="chevron" size={15} />
</button>
{userMenuOpen && (
<div className="topbar-dropdown topbar-dropdown--users">
{demoUsers.map((user) => (
<button className={user.userId === demoContext.userId ? "is-selected" : ""} type="button" key={user.userId} onClick={() => { setDemoContext({ user }); window.location.reload(); }}>
<span className="avatar">{user.userName.slice(0, 1)}</span><span><strong>{user.userName}</strong><small>{user.roleName} · {user.username}</small></span>
</button>
))}
</div>
)}
</div>
</div>
</header>
{activePage === "scripts" ? (
<section className="workspace-layout">
<aside className="explorer">
<div className="explorer__header">
<div>
<h2>脚本目录</h2>
<span>{scripts.length} 个工作副本</span>
</div>
<div className="explorer__actions">
<button
className="text-button"
type="button"
disabled={uploading}
onClick={() => chooseUpload("")}
>
<Icon name="upload" size={15} />
{uploading ? "上传中…" : "上传"}
</button>
<input
ref={uploadInputRef}
className="visually-hidden"
type="file"
accept=".py,.ipynb"
multiple
onChange={(event) => void handleUpload(event)}
/>
<button
className="text-button"
type="button"
onClick={() => openCreateDialog("")}
>
<Icon name="plus" size={16} />
新建
</button>
</div>
</div>
<div className="search-box">
<Icon name="search" size={17} />
<input
aria-label="搜索脚本"
placeholder="搜索脚本名称"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<button
className={refreshing ? "is-spinning" : ""}
type="button"
aria-label="刷新脚本"
onClick={() => void load(true)}
>
<Icon name="refresh" size={16} />
</button>
</div>
<div className="tree-scroll">
{loading ? (
<div className="tree-skeleton">
<span /><span /><span /><span />
</div>
) : (
<>
{memberScriptGroups.map((group) => (
<WorkspaceTreeGroup
key={group.user.userId}
title={`${group.user.userName}的文件`}
scripts={group.scripts}
directories={group.directories}
selectedId={selectedId}
onSelect={selectScript}
onContextMenu={
group.user.userId === demoContext.userId
? showContextMenu
: undefined
}
readOnly={group.user.userId !== demoContext.userId}
/>
))}
{filteredScripts.length === 0 && (
<div className="tree-empty">
<span className="tree-empty__icon">
<Icon name="script" size={24} />
</span>
<strong>{keyword ? "没有匹配脚本" : "还没有构建脚本"}</strong>
<p>
{keyword
? "换个关键词试试"
: "新建 Notebook 或 Python 脚本开始实验"}
</p>
{!keyword && (
<button type="button" onClick={() => openCreateDialog("")}>
<Icon name="plus" size={15} />
新建脚本
</button>
)}
</div>
)}
</>
)}
</div>
</aside>
<section className="editor-area">
{selected ? (
<ScriptWorkspace
key={selected.script_id}
script={selected}
editSession={
editSession?.script_id === selected.script_id
? editSession
: null
}
jupyterUrl={
editSession?.script_id === selected.script_id
? embeddedJupyterUrl
: null
}
editBusy={editBusy}
openError={
editorOpenError?.scriptId === selected.script_id
? editorOpenError.message
: null
}
latestVersion={versions[0] ?? null}
versionsLoading={versionsLoading}
onOpenEditor={() => 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}
/>
) : (
<div className="welcome-panel">
<div className="welcome-panel__visual">
<Icon name="script" size={42} />
</div>
<span className="welcome-panel__label">构建脚本工作台</span>
<h2>创建你的第一个模型脚本</h2>
<p>
通过 Notebook 完成数据探索,或使用 Python
脚本构建可调度的处理任务。
</p>
<button className="primary-button" onClick={() => openCreateDialog("")}>
<Icon name="plus" size={17} />
新建构建脚本
</button>
</div>
)}
</section>
</section>
) : activePage === "schedules" ? (
<SchedulePage
key={`${demoContext.userId}-${demoContext.workspaceId}`}
onNotify={setToast}
onConnectionChange={setApiOnline}
/>
) : activePage === "system" ? (
<SystemAdminPage
key={`${demoContext.userId}-${demoContext.workspaceId}`}
onNotify={setToast}
onConnectionChange={setApiOnline}
/>
) : (
<DashboardPage
scriptCount={scripts.length}
online={apiOnline}
onNavigate={(page) => {
navigate(pathForPage(page));
}}
/>
)}
</main>
{createOpen && (
<div className="modal-backdrop" role="presentation">
<section className="modal" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">工作副本</span>
<h2>新建构建脚本</h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={() => setCreateOpen(false)}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={submitCreate}>
<div className="destination-chip">
<Icon name="folder" size={16} />
保存到:{form.parentPath || "个人根目录"}
</div>
<label className="form-field">
<span>脚本名称</span>
<input
autoFocus
maxLength={255}
placeholder={form.scriptType === "notebook"
? "例如:数据探索"
: "例如:data_process"}
value={form.name}
onChange={(event) =>
setForm((current) => ({
...current,
name: event.target.value,
}))}
/>
<small>
系统会自动补充
{form.scriptType === "notebook" ? " .ipynb" : " .py"} 后缀
</small>
</label>
<fieldset className="type-picker">
<legend>脚本类型</legend>
<button
className={form.scriptType === "notebook" ? "is-selected" : ""}
type="button"
onClick={() => setForm((current) => ({
...current,
scriptType: "notebook",
}))}
>
<span className="type-picker__icon type-picker__icon--notebook">
<Icon name="notebook" size={22} />
</span>
<span>
<strong>Jupyter Notebook</strong>
<small>交互式数据探索与模型实验</small>
</span>
<span className="type-picker__check">
<Icon name="check" size={14} />
</span>
</button>
<button
className={form.scriptType === "python" ? "is-selected" : ""}
type="button"
onClick={() => setForm((current) => ({
...current,
scriptType: "python",
}))}
>
<span className="type-picker__icon type-picker__icon--python">
<Icon name="python" size={23} />
</span>
<span>
<strong>Python 脚本</strong>
<small>批处理、训练和模型调用任务</small>
</span>
<span className="type-picker__check">
<Icon name="check" size={14} />
</span>
</button>
</fieldset>
<label className="form-field">
<span>可见范围</span>
<select
value={form.visibility}
onChange={(event) =>
setForm((current) => ({
...current,
visibility: event.target.value as Visibility,
}))}
>
<option value="private">仅自己可见</option>
<option value="workspace">Workspace 成员可见</option>
<option value="public">公开</option>
</select>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={() => setCreateOpen(false)}
>
取消
</button>
<button
className="primary-button"
type="submit"
disabled={creating || !form.name.trim()}
>
{creating ? <span className="button-spinner" /> : <Icon name="plus" size={16} />}
{creating ? "正在创建…" : "创建脚本"}
</button>
</div>
</form>
</section>
</div>
)}
{folderDialog.open && (
<div className="modal-backdrop" role="presentation">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">WORKSPACE</span>
<h2>新建文件夹</h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={() => setFolderDialog((current) => ({
...current,
open: false,
}))}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={submitFolder}>
<div className="destination-chip">
<Icon name="folder" size={16} />
创建到:{folderDialog.parentPath || "个人根目录"}
</div>
<label className="form-field">
<span>文件夹名称</span>
<input
autoFocus
maxLength={255}
placeholder="例如:模型训练"
value={folderDialog.name}
onChange={(event) => setFolderDialog((current) => ({
...current,
name: event.target.value,
}))}
/>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={() => setFolderDialog((current) => ({
...current,
open: false,
}))}
>
取消
</button>
<button
className="primary-button"
type="submit"
disabled={folderDialog.busy || !folderDialog.name.trim()}
>
{folderDialog.busy
? <span className="button-spinner" />
: <Icon name="folder" size={16} />}
{folderDialog.busy ? "正在创建…" : "创建文件夹"}
</button>
</div>
</form>
</section>
</div>
)}
{contextMenu && (
<div
className="tree-context-menu"
role="menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onPointerDown={(event) => event.stopPropagation()}
>
{contextMenu.kind === "file" && contextMenu.script ? (
<>
<button
type="button"
role="menuitem"
onClick={() => {
selectScript(contextMenu.script!.script_id);
setContextMenu(null);
}}
>
<Icon name="script" size={16} />
打开文件
</button>
<button
className="is-danger"
type="button"
role="menuitem"
onClick={() => void removeScript(contextMenu.script!)}
>
<Icon name="close" size={16} />
删除文件
</button>
</>
) : (
<>
<button
type="button"
role="menuitem"
onClick={() => openCreateDialog(contextMenu.path, "notebook")}
>
<Icon name="notebook" size={16} />
新建 Notebook
</button>
<button
type="button"
role="menuitem"
onClick={() => openCreateDialog(contextMenu.path, "python")}
>
<Icon name="python" size={16} />
新建 Python 文件
</button>
<button
type="button"
role="menuitem"
onClick={() => openFolderDialog(contextMenu.path)}
>
<Icon name="folder" size={16} />
新建文件夹
</button>
<button
type="button"
role="menuitem"
onClick={() => chooseUpload(contextMenu.path)}
>
<Icon name="upload" size={16} />
上传到此处
</button>
{contextMenu.kind === "directory" && (
<>
<span className="tree-context-menu__separator" />
<button
className="is-danger"
type="button"
role="menuitem"
onClick={() => void removeDirectory(contextMenu.path)}
>
<Icon name="close" size={16} />
删除文件夹
</button>
</>
)}
</>
)}
</div>
)}
{publishTarget && (
<div className="modal-backdrop" role="presentation">
<section className="modal publish-modal" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">不可变制品</span>
<h2>发布稳定版本</h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={() => setPublishTarget(null)}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={submitPublish}>
<div className="publish-source">
<span className={`file-icon file-icon--${publishTarget.script_type}`}>
<Icon name={scriptIcon(publishTarget)} size={18} />
</span>
<span>
<strong>{publishTarget.script_name}</strong>
<small>当前 Workspace 工作副本</small>
</span>
</div>
<label className="form-field">
<span>发布说明</span>
<textarea
maxLength={1000}
placeholder="例如:完成数据清洗和特征工程"
value={releaseNote}
onChange={(event) => setReleaseNote(event.target.value)}
/>
<small>稳定版本内容不可修改,可作为后续调度节点输入。</small>
</label>
<label className="form-field">
<span>可见范围</span>
<select
value={publishVisibility}
onChange={(event) =>
setPublishVisibility(event.target.value as Visibility)}
>
<option value="private">仅自己可见</option>
<option value="workspace">Workspace 成员可见</option>
<option value="public">公开</option>
</select>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={() => setPublishTarget(null)}
>
取消
</button>
<button
className="primary-button"
type="submit"
disabled={publishing}
>
{publishing
? <span className="button-spinner" />
: <Icon name="release" size={16} />}
{publishing ? "正在发布…" : "确认发布"}
</button>
</div>
</form>
</section>
</div>
)}
{publishedVersion && (
<div className="modal-backdrop" role="presentation">
<section className="modal version-receipt" role="dialog" aria-modal="true">
<div className="version-receipt__check">
<Icon name="check" size={28} />
</div>
<span className="modal__eyebrow">STABLE VERSION</span>
<h2>稳定版本发布成功</h2>
<p>
{publishedVersion.version_label} 已成为不可变制品,
后续调度将通过 versions_id 引用它。
</p>
<div className="version-id-box">
<span>versions_id</span>
<code>{publishedVersion.versions_id}</code>
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(
publishedVersion.versions_id,
);
setToast({ tone: "success", message: "versions_id 已复制" });
}}
>
复制
</button>
</div>
<button
className="primary-button version-receipt__close"
type="button"
onClick={() => setPublishedVersion(null)}
>
完成
</button>
</section>
</div>
)}
{toast && (
<div className={`toast toast--${toast.tone}`} role="status">
<span>
<Icon name={toast.tone === "success" ? "check" : "info"} size={17} />
</span>
{toast.message}
</div>
)}
</div>
);
}
function WorkspaceTreeGroup({
title,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
readOnly = false,
}: {
title: string;
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
selectedId: string | null;
onSelect: (id: string) => void;
onContextMenu?: (
event: ReactMouseEvent,
target: Omit<ContextMenuState, "x" | "y">,
) => void;
readOnly?: boolean;
}) {
const [open, setOpen] = useState(true);
return (
<div className="tree-group">
<button
className={`tree-group__title${open ? " is-open" : ""}`}
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, { kind: "root", path: "" })
: undefined}
>
<Icon name="chevron" size={14} />
<Icon name="folder" size={17} />
<span>{title}</span>
<em>{scripts.length}</em>
</button>
{open && (
<div className="tree-group__items">
<WorkspaceTreeItems
path=""
depth={0}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
{scripts.length === 0 && directories.length === 0 && (
<p className="tree-group__empty">
{readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
</p>
)}
</div>
)}
</div>
);
}
function WorkspaceTreeItems({
path,
depth,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
}: {
path: string;
depth: number;
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
selectedId: string | null;
onSelect: (id: string) => void;
onContextMenu?: (
event: ReactMouseEvent,
target: Omit<ContextMenuState, "x" | "y">,
) => void;
}) {
const childDirectories = directories.filter(
(item) => item.parent_path === path,
);
const childScripts = scripts.filter(
(item) => parentOf(ownedScriptPath(item)) === path,
);
return (
<>
{childDirectories.map((directory) => (
<DirectoryBranch
key={directory.path}
directory={directory}
depth={depth}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
))}
{childScripts.map((item) => (
<button
className={`script-row${
selectedId === item.script_id ? " script-row--active" : ""
}`}
style={{ paddingLeft: 20 + depth * 16 }}
key={item.script_id}
type="button"
onClick={() => onSelect(item.script_id)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "file",
path: ownedScriptPath(item),
script: item,
})
: undefined}
>
<span className={`file-icon file-icon--${item.script_type}`}>
<Icon name={scriptIcon(item)} size={17} />
</span>
<span className="script-row__copy">
<strong title={item.script_name}>{item.script_name}</strong>
<small>{formatTime(item.updated_at)}</small>
</span>
{item.visibility !== "private" && (
<span className="visibility-dot" title="Workspace 可见" />
)}
</button>
))}
</>
);
}
function DirectoryBranch({
directory,
depth,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
}: {
directory: WorkspaceDirectory;
depth: number;
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
selectedId: string | null;
onSelect: (id: string) => void;
onContextMenu?: (
event: ReactMouseEvent,
target: Omit<ContextMenuState, "x" | "y">,
) => void;
}) {
const [open, setOpen] = useState(true);
return (
<div className="directory-branch">
<button
className="directory-row"
style={{ paddingLeft: 10 + depth * 16 }}
type="button"
onClick={() => setOpen((current) => !current)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "directory",
path: directory.path,
})
: undefined}
>
<span className={`directory-row__chevron${open ? " is-open" : ""}`}>
<Icon name="chevron" size={13} />
</span>
<Icon name="folder" size={17} />
<strong title={directory.path}>{directory.name}</strong>
</button>
{open && (
<WorkspaceTreeItems
path={directory.path}
depth={depth + 1}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
)}
</div>
);
}
function ScriptWorkspace({
script,
editSession,
jupyterUrl,
editBusy,
openError,
latestVersion,
versionsLoading,
onOpenEditor,
onEndEditing,
onClose,
onPublish,
onInfo,
}: {
script: ScriptItem;
editSession: ActiveEditSession | null;
jupyterUrl: string | null;
editBusy: boolean;
openError: string | null;
latestVersion: StableVersion | null;
versionsLoading: boolean;
onOpenEditor: () => void;
onEndEditing: () => void;
onClose: () => void;
onPublish: () => void;
onInfo: (toast: ToastState) => void;
}) {
const isNotebook = script.script_type === "notebook";
const isEditing = editSession?.session_status === "active";
return (
<>
<div className="tabbar">
<div className="editor-tab editor-tab--active">
<span className={`file-icon file-icon--${script.script_type}`}>
<Icon name={scriptIcon(script)} size={16} />
</span>
<span>{script.script_name}</span>
<button type="button" aria-label="关闭标签" onClick={onClose}>
<Icon name="close" size={14} />
</button>
</div>
<button
className="new-tab"
type="button"
onClick={() => onInfo({ tone: "info", message: "请从左侧选择或新建脚本" })}
>
<Icon name="plus" size={17} />
</button>
</div>
<div className="editor-toolbar">
<div className="editor-toolbar__path">
<span className={`file-icon file-icon--${script.script_type}`}>
<Icon name={scriptIcon(script)} size={17} />
</span>
<span>工作副本</span>
<Icon name="chevron" size={13} />
<strong>{script.script_name}</strong>
</div>
<div className="editor-toolbar__actions">
{isEditing ? (
<button
className="end-edit-button"
type="button"
disabled={editBusy}
onClick={onEndEditing}
>
{editBusy ? "正在释放…" : "结束编辑"}
</button>
) : (
<button
type="button"
onClick={() => onInfo({
tone: "info",
message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
})}
>
保存说明
</button>
)}
<button
className="release-button"
type="button"
onClick={onPublish}
>
发布稳定版
</button>
<span className={`stage-badge${isEditing ? " is-editing" : ""}`}>
<span />
{isEditing
? isNotebook
? "Demo 无锁模式 · Kernel 已连接"
: "Demo 无锁模式 · 编辑中"
: latestVersion
? `最新 ${latestVersion.version_label}`
: "工作副本已就绪"}
</span>
</div>
</div>
<div className={`editor-canvas${isEditing && jupyterUrl ? " is-embedded" : ""}`}>
{isEditing && jupyterUrl ? (
<section className="embedded-jupyter">
<div className="embedded-jupyter__status">
<span>
<i />
Workspace Jupyter Server
</span>
<span>
{isNotebook ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span>
<code title={editSession.runtime_id}>
Runtime {editSession.runtime_id.slice(-8)}
</code>
</div>
<iframe
key={`${editSession.edit_session_id}:${jupyterUrl}`}
src={jupyterUrl}
title={`${script.script_name} Jupyter 编辑器`}
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
/>
</section>
) : isNotebook ? (
<section
className={`editor-opening-state${openError ? " has-error" : ""}`}
aria-live="polite"
>
<div className="editor-opening-state__icon">
{openError
? <Icon name="info" size={28} />
: <span className="button-spinner button-spinner--blue" />}
</div>
<strong>
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong>
<p>
{openError
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className="open-editor-button"
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="refresh" size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : (
<section className="script-overview">
<div className="script-overview__header">
<div>
<span className="section-kicker">
PYTHON SCRIPT
</span>
<h2>{script.script_name}</h2>
<p>{script.relative_path}</p>
</div>
<button
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="external" size={17} />}
{editBusy
? "正在准备编辑器…"
: isEditing
? "继续编辑"
: "打开编辑器"}
</button>
</div>
<div className="metadata-grid">
<div>
<span>脚本类型</span>
<strong>Python</strong>
</div>
<div>
<span>可见范围</span>
<strong>
{script.visibility === "workspace"
? "Workspace"
: script.visibility === "public" ? "公开" : "私有"}
</strong>
</div>
<div>
<span>文件大小</span>
<strong>{formatBytes(script.size_bytes)}</strong>
</div>
<div>
<span>最近更新</span>
<strong>{formatTime(script.updated_at)}</strong>
</div>
</div>
<div className="preview-card">
<div className="preview-card__bar">
<div>
<span className="window-dot window-dot--red" />
<span className="window-dot window-dot--yellow" />
<span className="window-dot window-dot--green" />
</div>
<span>Python 预览</span>
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em>
</div>
<PythonPreview />
</div>
<div className="integrity-row">
<span>
<Icon name="check" size={15} />
{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}
</span>
<span>SHA-256&nbsp; {shortHash(script.content_hash)}</span>
<span>
稳定版本&nbsp;
{versionsLoading
? "加载中"
: latestVersion
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
: "尚未发布"}
</span>
</div>
</section>
)}
</div>
</>
);
}
function PythonPreview() {
return (
<div className="python-preview">
<div className="line-numbers">1<br />2<br />3<br />4<br />5<br />6<br />7<br />8<br />9</div>
<pre>
<span className="code-comment">"""模型实验开发平台构建脚本。"""</span>
{"\n\n"}<b>def</b> <span className="code-function">main</span>() -&gt; <b>None</b>:
{"\n"} print(<i>"Hello, Model Platform!"</i>)
{"\n\n\n"}<b>if</b> __name__ == <i>"__main__"</i>:
{"\n"} main()
</pre>
</div>
);
}