944 lines
30 KiB
TypeScript
944 lines
30 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 { Sidebar } from "../../components/common/Sidebar";
|
|
import { Topbar } from "../../components/common/Topbar";
|
|
import { ScriptExplorer } from "../../components/platform/ScriptExplorer";
|
|
import { CreateScriptModal } from "../../components/platform/CreateScriptModal";
|
|
import { CreateFolderModal } from "../../components/platform/CreateFolderModal";
|
|
import { TreeContextMenu } from "../../components/platform/TreeContextMenu";
|
|
import { PublishModal } from "../../components/platform/PublishModal";
|
|
import { VersionReceiptModal } from "../../components/platform/VersionReceiptModal";
|
|
import { Toast } from "../../components/common/Toast";
|
|
import { ScriptWorkspace } from "./ScriptWorkspace";
|
|
import SchedulePage from "../schedules/SchedulePage";
|
|
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
|
|
import "../../styles/platform.css";
|
|
|
|
type NewScriptForm = {
|
|
name: string;
|
|
scriptType: ScriptType;
|
|
visibility: Visibility;
|
|
parentPath: string;
|
|
};
|
|
|
|
type ContextMenuState = {
|
|
x: number;
|
|
y: number;
|
|
kind: "root" | "directory" | "file";
|
|
path: string;
|
|
script?: ScriptItem;
|
|
};
|
|
|
|
type ToastState = {
|
|
tone: "success" | "error" | "info";
|
|
message: string;
|
|
};
|
|
|
|
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
|
|
|
function pageFromPath(pathname: string): ActivePage {
|
|
const page = pathname.replace(/^\/+|\/+$/g, "");
|
|
return ["scripts", "schedules", "system"].includes(page)
|
|
? page as ActivePage
|
|
: "home";
|
|
}
|
|
|
|
function pathForPage(page: ActivePage): string {
|
|
return page === "home" ? "/workbench" : `/${page}`;
|
|
}
|
|
|
|
const initialForm: NewScriptForm = {
|
|
name: "",
|
|
scriptType: "notebook",
|
|
visibility: "workspace",
|
|
parentPath: "",
|
|
};
|
|
|
|
export default function ModelPlatformApp() {
|
|
const { currentWorkspace } = useAuth();
|
|
if (!currentWorkspace) {
|
|
return (
|
|
<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);
|
|
|
|
// Workspace 菜单
|
|
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
|
|
|
// 上传相关
|
|
const [uploadParentPath, setUploadParentPath] = useState("");
|
|
const [uploading, setUploading] = useState(false);
|
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// Toast 通知
|
|
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]);
|
|
|
|
// Jupyter 访问票据续签
|
|
useEffect(() => {
|
|
if (!editSession?.ticket_expires_at) return;
|
|
const expiresAt = new Date(editSession.ticket_expires_at).getTime();
|
|
const renewAfter = Math.max(15_000, expiresAt - Date.now() - 60_000);
|
|
const timer = window.setTimeout(() => {
|
|
const current = editSessionRef.current;
|
|
if (!current || current.edit_session_id !== editSession.edit_session_id) {
|
|
return;
|
|
}
|
|
void api.createJupyterAccessTicket(current)
|
|
.then((ticket) => {
|
|
setEditSession((active) => active
|
|
&& active.edit_session_id === ticket.edit_session_id
|
|
? { ...active, ticket_expires_at: ticket.expires_at }
|
|
: active);
|
|
})
|
|
.catch((error) => {
|
|
setToast({
|
|
tone: "error",
|
|
message: `Jupyter 访问票据续签失败:${error instanceof Error ? error.message : "请重新打开文件"}`,
|
|
});
|
|
});
|
|
}, renewAfter);
|
|
return () => window.clearTimeout(timer);
|
|
}, [editSession?.edit_session_id, editSession?.ticket_expires_at]);
|
|
|
|
// 页面卸载时释放编辑锁
|
|
useEffect(() => {
|
|
if (!editSession) return;
|
|
const handleUnload = () => {
|
|
const current = editSessionRef.current;
|
|
if (current) api.releaseFileLockOnUnload(current);
|
|
};
|
|
window.addEventListener("beforeunload", handleUnload);
|
|
return () => window.removeEventListener("beforeunload", handleUnload);
|
|
}, [editSession?.edit_session_id]);
|
|
|
|
// 过滤脚本
|
|
const filteredScripts = useMemo(() => {
|
|
const normalized = keyword.trim().toLocaleLowerCase();
|
|
if (!normalized) return scripts;
|
|
return scripts.filter((item) =>
|
|
item.script_name.toLocaleLowerCase().includes(normalized),
|
|
);
|
|
}, [keyword, scripts]);
|
|
|
|
const selected = scripts.find((item) => item.script_id === selectedId) ?? null;
|
|
|
|
// 选择脚本
|
|
const selectScript = (scriptId: string | null) => {
|
|
setSelectedId(scriptId);
|
|
};
|
|
|
|
// 打开标签
|
|
const openTab = (scriptId: string) => {
|
|
if (selectedIdRef.current !== scriptId) {
|
|
editorOpenRequestRef.current += 1;
|
|
setEditorOpenError(null);
|
|
}
|
|
selectedIdRef.current = scriptId;
|
|
setSelectedId(scriptId);
|
|
setOpenTabIds((current) => {
|
|
if (current.includes(scriptId)) {
|
|
return current;
|
|
}
|
|
return [...current, scriptId];
|
|
});
|
|
};
|
|
|
|
// 关闭标签
|
|
const closeTab = async (scriptId: string, event?: ReactMouseEvent) => {
|
|
event?.stopPropagation();
|
|
if (editSessionRef.current?.script_id === scriptId) {
|
|
await endEditing(false, false);
|
|
}
|
|
setOpenTabIds((current) => {
|
|
const index = current.indexOf(scriptId);
|
|
if (index === -1) return current;
|
|
const newTabs = current.filter((id) => id !== scriptId);
|
|
if (selectedId === scriptId) {
|
|
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
|
|
setSelectedId(nextId);
|
|
selectedIdRef.current = nextId;
|
|
}
|
|
return newTabs;
|
|
});
|
|
};
|
|
|
|
// 切换标签
|
|
const switchTab = (scriptId: string) => {
|
|
if (selectedIdRef.current !== scriptId) {
|
|
editorOpenRequestRef.current += 1;
|
|
setEditorOpenError(null);
|
|
}
|
|
setSelectedId(scriptId);
|
|
selectedIdRef.current = scriptId;
|
|
};
|
|
|
|
// 打开脚本编辑器
|
|
const openScriptEditor = async (script: ScriptItem, showToast = true) => {
|
|
if (editorOpeningRef.current) return;
|
|
editorOpeningRef.current = true;
|
|
const requestId = editorOpenRequestRef.current + 1;
|
|
editorOpenRequestRef.current = requestId;
|
|
const requestIsCurrent = () =>
|
|
editorOpenRequestRef.current === requestId
|
|
&& selectedIdRef.current === script.script_id;
|
|
const clearSessionIfActive = (session: ActiveEditSession) => {
|
|
if (editSessionRef.current?.edit_session_id === session.edit_session_id) {
|
|
setEmbeddedJupyterUrl(null);
|
|
setEditSession(null);
|
|
editSessionRef.current = null;
|
|
}
|
|
};
|
|
|
|
setEditBusy(true);
|
|
setEditorOpenError((current) =>
|
|
current?.scriptId === script.script_id ? null : current);
|
|
let active = editSessionRef.current;
|
|
let newlyAcquired = false;
|
|
try {
|
|
if (active && active.script_id !== script.script_id) {
|
|
await api.releaseFileLock(active);
|
|
setEmbeddedJupyterUrl(null);
|
|
setEditSession(null);
|
|
editSessionRef.current = null;
|
|
active = null;
|
|
}
|
|
if (!requestIsCurrent()) return;
|
|
|
|
if (!active) {
|
|
active = await api.acquireFileLock(script);
|
|
newlyAcquired = true;
|
|
}
|
|
if (!requestIsCurrent()) {
|
|
if (active) {
|
|
await api.releaseFileLock(active);
|
|
clearSessionIfActive(active);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const ticket = await api.createJupyterAccessTicket(active);
|
|
if (!requestIsCurrent()) {
|
|
await api.releaseFileLock(active);
|
|
clearSessionIfActive(active);
|
|
return;
|
|
}
|
|
|
|
const readySession = { ...active, ticket_expires_at: ticket.expires_at };
|
|
setEditSession(readySession);
|
|
editSessionRef.current = readySession;
|
|
setEmbeddedJupyterUrl(ticket.jupyter_url);
|
|
if (showToast) {
|
|
setToast({
|
|
tone: "success",
|
|
message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (newlyAcquired && active) {
|
|
try {
|
|
await api.releaseFileLock(active);
|
|
} catch {
|
|
// The database lease is the final safety net
|
|
}
|
|
setEditSession(null);
|
|
editSessionRef.current = null;
|
|
}
|
|
setEmbeddedJupyterUrl(null);
|
|
if (requestIsCurrent()) {
|
|
const message = error instanceof Error ? error.message : "打开编辑器失败";
|
|
setEditorOpenError({ scriptId: script.script_id, message });
|
|
if (showToast) {
|
|
setToast({ tone: "error", message });
|
|
}
|
|
}
|
|
} finally {
|
|
editorOpeningRef.current = false;
|
|
setEditBusy(false);
|
|
}
|
|
};
|
|
|
|
// 自动打开 Notebook 编辑器
|
|
useEffect(() => {
|
|
if (
|
|
activePage !== "scripts"
|
|
|| !selected
|
|
|| selected.script_type !== "notebook"
|
|
|| editBusy
|
|
|| editorOpenError?.scriptId === selected.script_id
|
|
|| (editSession?.script_id === selected.script_id && embeddedJupyterUrl)
|
|
) {
|
|
return;
|
|
}
|
|
void openScriptEditor(selected, false);
|
|
}, [
|
|
activePage, editBusy, editSession?.script_id, editorOpenError?.scriptId,
|
|
embeddedJupyterUrl, selected?.script_id, selected?.script_type,
|
|
]);
|
|
|
|
// 结束编辑
|
|
const endEditing = async (closeTabFlag = true, showToast = true) => {
|
|
editorOpenRequestRef.current += 1;
|
|
setEditorOpenError(null);
|
|
const active = editSessionRef.current;
|
|
const scriptId = active?.script_id;
|
|
if (!active) {
|
|
if (closeTabFlag && scriptId) {
|
|
void closeTab(scriptId);
|
|
}
|
|
return;
|
|
}
|
|
setEditBusy(true);
|
|
try {
|
|
await api.releaseFileLock(active);
|
|
setEmbeddedJupyterUrl(null);
|
|
setEditSession(null);
|
|
editSessionRef.current = null;
|
|
if (closeTabFlag && scriptId) {
|
|
void closeTab(scriptId);
|
|
}
|
|
if (showToast) {
|
|
setToast({
|
|
tone: "success",
|
|
message: `${active.script_name} 的编辑锁已释放`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
setToast({
|
|
tone: "error",
|
|
message: error instanceof Error ? error.message : "释放编辑锁失败",
|
|
});
|
|
} finally {
|
|
setEditBusy(false);
|
|
}
|
|
};
|
|
|
|
// 切换文件时结束编辑
|
|
useEffect(() => {
|
|
const active = editSessionRef.current;
|
|
if (
|
|
!active
|
|
|| !selected
|
|
|| selected.script_type === "notebook"
|
|
|| selected.script_id === active.script_id
|
|
|| editBusy
|
|
) {
|
|
return;
|
|
}
|
|
void endEditing(false, false);
|
|
}, [editBusy, selected?.script_id, selected?.script_type]);
|
|
|
|
// 切换页面时结束编辑
|
|
useEffect(() => {
|
|
if (
|
|
activePage === "scripts"
|
|
|| editBusy
|
|
|| (!editSessionRef.current && !editorOpeningRef.current)
|
|
) {
|
|
return;
|
|
}
|
|
void endEditing(true, false);
|
|
}, [activePage, editBusy]);
|
|
|
|
// 打开发布对话框
|
|
const openPublishDialog = (script: ScriptItem) => {
|
|
setPublishTarget(script);
|
|
setReleaseNote("");
|
|
setPublishVisibility(script.visibility === "private" ? "private" : "workspace");
|
|
};
|
|
|
|
// 发布稳定版本
|
|
const submitPublish = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
if (!publishTarget) return;
|
|
setPublishing(true);
|
|
try {
|
|
const version = await api.publishScriptVersion({
|
|
script: publishTarget,
|
|
releaseNote,
|
|
visibility: publishVisibility,
|
|
});
|
|
setPublishTarget(null);
|
|
setPublishedVersion(version);
|
|
setToast({
|
|
tone: "success",
|
|
message: `${version.version_label} 稳定版本发布成功`,
|
|
});
|
|
} catch (error) {
|
|
setToast({
|
|
tone: "error",
|
|
message: error instanceof Error ? error.message : "稳定版本发布失败",
|
|
});
|
|
} finally {
|
|
setPublishing(false);
|
|
}
|
|
};
|
|
|
|
// 创建脚本
|
|
const submitCreate = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
if (!form.name.trim()) return;
|
|
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
|
|
const requestedName = form.name.trim();
|
|
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
|
|
? requestedName
|
|
: `${requestedName}${suffix}`;
|
|
const duplicate = scripts.some((script) =>
|
|
script.script_type === form.scriptType
|
|
&& script.script_name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase()
|
|
);
|
|
if (duplicate) {
|
|
setToast({ tone: "error", message: `${normalizedName} 已存在,请更换名称` });
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
try {
|
|
const created = await api.createScript(form);
|
|
setScripts((items) => [created, ...items]);
|
|
openTab(created.script_id);
|
|
setCreateOpen(false);
|
|
setForm(initialForm);
|
|
setToast({ tone: "success", message: `${created.script_name} 已创建` });
|
|
} catch (error) {
|
|
setToast({
|
|
tone: "error",
|
|
message: error instanceof Error ? error.message : "创建失败",
|
|
});
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const openCreateDialog = (parentPath = "", scriptType: ScriptType = "notebook") => {
|
|
setContextMenu(null);
|
|
setForm({ ...initialForm, parentPath, scriptType });
|
|
setCreateOpen(true);
|
|
};
|
|
|
|
const openFolderDialog = (parentPath = "") => {
|
|
setContextMenu(null);
|
|
setFolderDialog({ open: true, parentPath, name: "", busy: false });
|
|
};
|
|
|
|
const chooseUpload = (parentPath = "") => {
|
|
setContextMenu(null);
|
|
setUploadParentPath(parentPath);
|
|
uploadInputRef.current?.click();
|
|
};
|
|
|
|
// 上传文件
|
|
const handleUpload = async (event: ChangeEvent<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">
|
|
<Sidebar
|
|
activePage={activePage}
|
|
collapsed={sidebarCollapsed}
|
|
onNavigate={(page) => navigate(pathForPage(page))}
|
|
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
|
|
onEndEditing={() => void endEditing(true)}
|
|
onSelectScript={selectScript}
|
|
editSessionRef={editSessionRef}
|
|
/>
|
|
|
|
<main className="main-area">
|
|
<Topbar
|
|
activePage={activePage}
|
|
apiOnline={apiOnline}
|
|
user={user}
|
|
currentWorkspace={currentWorkspace}
|
|
workspaces={workspaces}
|
|
workspaceMenuOpen={workspaceMenuOpen}
|
|
onSetWorkspaceMenuOpen={setWorkspaceMenuOpen}
|
|
onSetCurrentWorkspace={setCurrentWorkspace}
|
|
onLogout={logout}
|
|
/>
|
|
|
|
{activePage === "scripts" ? (
|
|
<section className="workspace-layout">
|
|
<ScriptExplorer
|
|
scripts={scripts}
|
|
filteredScripts={filteredScripts}
|
|
directories={directories}
|
|
user={user}
|
|
selectedId={selectedId}
|
|
loading={loading}
|
|
refreshing={refreshing}
|
|
uploading={uploading}
|
|
keyword={keyword}
|
|
onKeywordChange={setKeyword}
|
|
onRefresh={() => void load(true)}
|
|
onUpload={() => chooseUpload("")}
|
|
onOpenCreateDialog={openCreateDialog}
|
|
onOpenFolderDialog={openFolderDialog}
|
|
onChooseUpload={chooseUpload}
|
|
onContextMenu={showContextMenu}
|
|
onSelect={openTab}
|
|
uploadInputRef={uploadInputRef}
|
|
onHandleUpload={handleUpload}
|
|
/>
|
|
|
|
<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>
|
|
|
|
<CreateScriptModal
|
|
open={createOpen}
|
|
creating={creating}
|
|
form={form}
|
|
scripts={scripts.map((s) => ({ script_type: s.script_type, script_name: s.script_name }))}
|
|
onFormChange={setForm}
|
|
onSubmit={submitCreate}
|
|
onClose={() => setCreateOpen(false)}
|
|
/>
|
|
|
|
<CreateFolderModal
|
|
open={folderDialog.open}
|
|
parentPath={folderDialog.parentPath}
|
|
name={folderDialog.name}
|
|
busy={folderDialog.busy}
|
|
onNameChange={(name) => setFolderDialog((current) => ({ ...current, name }))}
|
|
onSubmit={submitFolder}
|
|
onClose={() => setFolderDialog((current) => ({ ...current, open: false }))}
|
|
/>
|
|
|
|
<TreeContextMenu
|
|
contextMenu={contextMenu}
|
|
onOpenScript={(scriptId) => {
|
|
selectScript(scriptId);
|
|
setContextMenu(null);
|
|
}}
|
|
onRemoveScript={removeScript}
|
|
onOpenCreateDialog={openCreateDialog}
|
|
onOpenFolderDialog={openFolderDialog}
|
|
onChooseUpload={chooseUpload}
|
|
onRemoveDirectory={removeDirectory}
|
|
onClose={() => setContextMenu(null)}
|
|
/>
|
|
|
|
<PublishModal
|
|
publishTarget={publishTarget}
|
|
releaseNote={releaseNote}
|
|
publishVisibility={publishVisibility}
|
|
publishing={publishing}
|
|
onReleaseNoteChange={setReleaseNote}
|
|
onPublishVisibilityChange={setPublishVisibility}
|
|
onSubmit={submitPublish}
|
|
onClose={() => setPublishTarget(null)}
|
|
/>
|
|
|
|
<VersionReceiptModal
|
|
publishedVersion={publishedVersion}
|
|
onClose={() => setPublishedVersion(null)}
|
|
onCopy={(message) => setToast({ tone: "success", message })}
|
|
/>
|
|
|
|
<Toast toast={toast} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ownedScriptPath(item: ScriptItem) {
|
|
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
|
|
}
|