Files
model-platform/frontend/app/features/platform/ModelPlatformApp copy.tsx
T
2026-08-04 18:46:18 +08:00

1514 lines
50 KiB
TypeScript

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 SchedulePage from "../schedules/SchedulePage";
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
import "../../styles/platform.css";
import { WorkspaceTreeGroup, scriptIcon } from "./WorkspaceTree";
import { ScriptWorkspace } from "./ScriptWorkspace";
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 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()];
}
export default function ModelPlatformApp() {
const { currentWorkspace } = useAuth();
if (!currentWorkspace) {
return (
<div className="app-shell" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ textAlign: "center" }}>
<span style={{ fontSize: 18 }}>加载中…</span>
</div>
</div>
);
}
return <AuthenticatedModelPlatformApp />;
}
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<ScriptItem[]>([]);
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [openTabIds, setOpenTabIds] = useState<string[]>([]);
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 [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 [latestVersion, setLatestVersion] = useState<LatestVersion | null>(
null,
);
const [latestVersionLoading, setLatestVersionLoading] = 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 [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const load = async (silent = false) => {
if (!silent) setLoading(true);
setRefreshing(silent);
try {
const [items, folderItems] = await Promise.all([
api.listScripts(),
api.listWorkspaceDirectories(),
]);
setScripts(items);
setDirectories(folderItems);
setApiOnline(true);
setSelectedId((current) => {
if (current && items.some((item) => item.script_id === current)) {
selectedIdRef.current = current;
return current;
}
const nextSelectedId = (
items.find((item) => item.script_type === "notebook")?.script_id
?? items[0]?.script_id
?? null
);
selectedIdRef.current = nextSelectedId;
return nextSelectedId;
});
} catch (error) {
setApiOnline(false);
setToast({
tone: "error",
message: error instanceof Error ? error.message : "脚本列表加载失败",
});
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void load();
}, []);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 3200);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("pointerdown", close);
window.addEventListener("blur", close);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
window.addEventListener("keydown", closeOnEscape);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("blur", close);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("keydown", closeOnEscape);
};
}, [contextMenu]);
useEffect(() => {
editSessionRef.current = editSession;
}, [editSession]);
useEffect(() => {
if (!selectedId) {
setLatestVersion(null);
return;
}
let ignore = false;
setLatestVersionLoading(true);
void api.getLatestScriptVersion(selectedId)
.then((item) => {
if (!ignore) setLatestVersion(item);
})
.catch((error) => {
if (!ignore) {
setToast({
tone: "error",
message:
error instanceof Error
? error.message
: "最新版本加载失败",
});
}
})
.finally(() => {
if (!ignore) setLatestVersionLoading(false);
});
return () => {
ignore = true;
};
}, [selectedId]);
useEffect(() => {
if (!editSession) return;
const intervalSeconds = Math.max(
5,
editSession.heartbeat_interval_seconds || 15,
);
let heartbeatRunning = false;
const timer = window.setInterval(() => {
if (heartbeatRunning) return;
const current = editSessionRef.current;
if (!current || current.edit_session_id !== editSession.edit_session_id) {
return;
}
heartbeatRunning = true;
void api.heartbeatFileLock(current)
.then((updated) => {
setEditSession((active) => active
&& active.edit_session_id === updated.edit_session_id
? {
...active,
session_status: updated.session_status,
expires_at: updated.expires_at,
}
: active);
})
.catch((error) => {
setEditSession(null);
setEmbeddedJupyterUrl(null);
setToast({
tone: "error",
message: `编辑锁心跳已中断:${
error instanceof Error ? error.message : "请重新打开文件"
}`,
});
})
.finally(() => {
heartbeatRunning = false;
});
}, intervalSeconds * 1000);
return () => window.clearInterval(timer);
}, [editSession?.edit_session_id, editSession?.heartbeat_interval_seconds]);
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 memberScriptGroups = (() => {
const currentUserScripts = filteredScripts.filter(
(item) => item.owner_user_id === user?.user_id,
);
const inferred = inferredDirectories(currentUserScripts);
return [{
user: user,
scripts: currentUserScripts,
directories: mergeDirectories(directories, inferred),
}];
})();
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 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 (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<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 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<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${sidebarCollapsed ? " is-collapsed" : ""}`}>
<div className="brand">
<span className="brand__mark"><Icon name="brand" size={31} /></span>
{!sidebarCollapsed && <span className="brand__name">模型实验开发平台</span>}
</div>
{!sidebarCollapsed && (
<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" onClick={() => setSidebarCollapsed((v) => !v)}>
<Icon name="menu" size={19} />
<span>{sidebarCollapsed ? "展开菜单" : "收起菜单"}</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); }}>
<span className="workspace-switcher__icon"><Icon name="workspace" size={18} /></span>
<span><small>当前 Workspace</small><strong>{currentWorkspace.workspace_name}</strong></span>
<Icon name="chevron" size={15} />
</button>
{workspaceMenuOpen && (
<div className="topbar-dropdown">
{workspaces.map((workspace) => (
<button className={workspace.workspace_id === currentWorkspace.workspace_id ? "is-selected" : ""} type="button" key={workspace.workspace_id} onClick={() => { setCurrentWorkspace(workspace.workspace_id); setWorkspaceMenuOpen(false); }}>
<Icon name="workspace" size={15} /><span><strong>{workspace.workspace_name}</strong><small>{workspace.workspace_id === currentWorkspace.workspace_id ? "当前使用" : "点击切换"}</small></span>
</button>
))}
</div>
)}
</div>
<div className="topbar-menu-wrap">
<button className="user-menu" type="button">
<span className="avatar">{user?.display_name?.slice(0, 1) ?? "?"}</span>
<span className="user-menu__copy"><strong>{user?.display_name ?? "未知用户"}</strong><small>{user?.role_code === "admin" ? "管理员" : "开发人员"}</small></span>
</button>
<button className="text-button" type="button" onClick={() => { logout(); window.location.assign("/login"); }}>
登出
</button>
</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?.user_id ?? "anon"}
title={`${group.user?.display_name}的文件`}
scripts={group.scripts}
directories={group.directories}
selectedId={selectedId}
onSelect={openTab}
onContextMenu={
group.user?.user_id === user?.user_id
? showContextMenu
: undefined
}
readOnly={group.user?.user_id !== user?.user_id}
/>
))}
{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={latestVersion}
versionsLoading={latestVersionLoading}
openTabs={openTabIds.map((id) => {
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}
/>
) : (
<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={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
onNotify={setToast}
onConnectionChange={setApiOnline}
/>
) : activePage === "system" ? (
<SystemAdminPage
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
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>
);
}