1514 lines
50 KiB
TypeScript
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>
|
|
);
|
|
}
|