Files
model-platform/frontend/app/features/platform/ModelPlatformApp.tsx
T
2026-08-06 16:32:39 +08:00

1060 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// 编辑会话相关 - 为每个标签维护独立 iframe 和状态
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);
// 为每个打开的标签维护独立的缓存会话(多 iframe 共存方案)
interface CachedSession {
session: ActiveEditSession;
jupyterUrl: string;
lastActiveTime: number; // 最后激活时间戳(用于清理)
}
const sessionCacheRef = useRef<Map<string, CachedSession>>(new Map());
// 版本发布相关
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) => {
const validIds = new Set(items.map(item => item.script_id));
// 当前选中的文件还在 → 保持
if (current && validIds.has(current)) {
selectedIdRef.current = current;
return current;
}
// 否则设为 null(不自动选中)
selectedIdRef.current = null;
return null;
});
// 同时更新打开的标签页:保留有效标签,移除已删除的文件
setOpenTabIds((current) => {
const validIds = new Set(items.map(item => item.script_id));
// 过滤掉已删除的文件
const preserved = current.filter(id => validIds.has(id));
// 直接返回(有就有,没有就没有,不自动补充)
return preserved;
});
} catch (error) {
setApiOnline(false);
setToast({
tone: "error",
message: error instanceof Error ? error.message : "脚本列表加载失败",
});
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void load();
}, []);
// 切换 Workspace 时重置状态并重新加载
useEffect(() => {
setScripts([]);
setDirectories([]);
setSelectedId(null);
setOpenTabIds([]);
setLatestVersion(null);
setEmbeddedJupyterUrl(null);
setEditSession(null);
editSessionRef.current = null;
selectedIdRef.current = null;
void load();
}, [currentWorkspace.workspace_id]);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 3200);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("pointerdown", close);
window.addEventListener("blur", close);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
window.addEventListener("keydown", closeOnEscape);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("blur", close);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("keydown", closeOnEscape);
};
}, [contextMenu]);
useEffect(() => {
editSessionRef.current = editSession;
}, [editSession]);
useEffect(() => {
if (!selectedId) {
setLatestVersion(null);
return;
}
let ignore = false;
setLatestVersionLoading(true);
void api.getLatestScriptVersion(selectedId)
.then((item) => {
if (!ignore) setLatestVersion(item);
})
.catch((error) => {
if (!ignore) {
setToast({
tone: "error",
message: error instanceof Error ? error.message : "最新版本加载失败",
});
}
})
.finally(() => {
if (!ignore) setLatestVersionLoading(false);
});
return () => {
ignore = true;
};
}, [selectedId]);
// 编辑锁心跳
useEffect(() => {
if (!editSession) return;
const intervalSeconds = Math.max(5, editSession.heartbeat_interval_seconds || 15);
let heartbeatRunning = false;
const timer = window.setInterval(() => {
if (heartbeatRunning) return;
const current = editSessionRef.current;
if (!current || current.edit_session_id !== editSession.edit_session_id) {
return;
}
heartbeatRunning = true;
void api.heartbeatFileLock(current)
.then((updated) => {
setEditSession((active) => active
&& active.edit_session_id === updated.edit_session_id
? {
...active,
session_status: updated.session_status,
expires_at: updated.expires_at,
}
: active);
})
.catch((error) => {
setEditSession(null);
setEmbeddedJupyterUrl(null);
setToast({
tone: "error",
message: `编辑锁心跳已中断:${error instanceof Error ? error.message : "请重新打开文件"}`,
});
})
.finally(() => {
heartbeatRunning = false;
});
}, intervalSeconds * 1000);
return () => window.clearInterval(timer);
}, [editSession?.edit_session_id, editSession?.heartbeat_interval_seconds]);
// 统一心跳管理:为所有缓存的会话维持心跳(包括隐藏的 iframe)
useEffect(() => {
const intervalSeconds = 15;
let heartbeatRunning = false;
const timer = window.setInterval(() => {
if (heartbeatRunning) return;
heartbeatRunning = true;
// 为所有缓存的会话调用心跳
const promises: Promise<void>[] = [];
for (const cached of sessionCacheRef.current.values()) {
// 只更新缓存中的 session 对象,不更新 React 状态
promises.push(
api.heartbeatFileLock(cached.session)
.then((updated) => {
cached.session.session_status = updated.session_status;
cached.session.expires_at = updated.expires_at;
})
.catch(() => {
// 不删除缓存,等待用户切回来时再处理
})
);
}
Promise.allSettled(promises).finally(() => {
heartbeatRunning = false;
});
}, intervalSeconds * 1000);
return () => window.clearInterval(timer);
}, []);
// 定时清理:10 分钟无活动的会话
useEffect(() => {
const TEN_MINUTES = 10 * 60 * 1000;
const CHECK_INTERVAL = 60 * 1000; // 每分钟检查一次
const cleanupTimer = window.setInterval(() => {
const now = Date.now();
const toCleanup: string[] = [];
// 找出需要清理的会话
for (const [scriptId, cached] of sessionCacheRef.current.entries()) {
// 只清理非激活的会话
if (scriptId !== selectedIdRef.current) {
const inactiveTime = now - cached.lastActiveTime;
if (inactiveTime > TEN_MINUTES) {
toCleanup.push(scriptId);
}
}
}
// 执行清理
if (toCleanup.length > 0) {
for (const scriptId of toCleanup) {
const cached = sessionCacheRef.current.get(scriptId);
if (cached) {
// 释放编辑锁
api.releaseFileLock(cached.session).catch(console.warn);
// 从缓存删除
sessionCacheRef.current.delete(scriptId);
}
}
// 通知用户
setToast({
tone: "info",
message: `已清理 ${toCleanup.length} 个长时间未活动的编辑会话`,
});
}
}, CHECK_INTERVAL);
return () => window.clearInterval(cleanupTimer);
}, []);
// 页面卸载时释放编辑锁
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) => {
console.log('[closeTab] 开始关闭:', scriptId, '当前 selectedId:', selectedId);
event?.stopPropagation();
if (editSessionRef.current?.script_id === scriptId) {
console.log('[closeTab] 需要结束编辑');
await endEditing(false, false);
}
// 从缓存中删除
sessionCacheRef.current.delete(scriptId);
setOpenTabIds((current) => {
console.log('[closeTab] 当前标签栏:', current);
const index = current.indexOf(scriptId);
if (index === -1) return current;
const newTabs = current.filter((id) => id !== scriptId);
console.log('[closeTab] 关闭后的标签栏:', newTabs);
if (selectedId === scriptId) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
console.log('[closeTab] 选中下一个:', nextId);
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 cached = sessionCacheRef.current.get(scriptId);
if (cached) {
// 更新最后激活时间
cached.lastActiveTime = Date.now();
setEditSession(cached.session);
editSessionRef.current = cached.session;
setEmbeddedJupyterUrl(cached.jupyterUrl);
}
};
// 打开脚本编辑器
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);
try {
// 检查是否已有缓存的编辑会话
const cached = sessionCacheRef.current.get(script.script_id);
if (cached) {
if (!requestIsCurrent()) return;
// 直接复用缓存的会话和 URL
setEditSession(cached.session);
editSessionRef.current = cached.session;
setEmbeddedJupyterUrl(cached.jupyterUrl);
if (showToast) {
setToast({
tone: "success",
message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
});
}
return;
}
// 没有缓存,需要获取新锁
let active = editSessionRef.current;
let newlyAcquired = false;
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()) {
if (newlyAcquired) {
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);
// 缓存会话(多 iframe 方案:增加 lastActiveTime
sessionCacheRef.current.set(script.script_id, {
session: readySession,
jupyterUrl: ticket.jupyter_url,
lastActiveTime: Date.now(),
});
if (showToast) {
setToast({
tone: "success",
message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
});
}
} catch (error) {
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;
}
// 检查是否有缓存的会话,有则直接复用,不触发自动打开
const cached = sessionCacheRef.current.get(selected.script_id);
if (cached) {
setEditSession(cached.session);
editSessionRef.current = cached.session;
setEmbeddedJupyterUrl(cached.jupyterUrl);
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 (scriptId) sessionCacheRef.current.delete(scriptId);
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(() => {
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) {
openTab(lastCreated.script_id);
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);
// 从标签栏删除该文件,并选中相邻的文件
setOpenTabIds((current) => {
const index = current.indexOf(script.script_id);
const newTabs = current.filter((id) => id !== script.script_id);
// 如果删除的是当前选中的文件,选中相邻的
if (selectedIdRef.current === script.script_id) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
setSelectedId(nextId);
selectedIdRef.current = nextId;
}
return newTabs;
});
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
script={selected}
sessionCache={sessionCacheRef.current}
editSession={editSession}
jupyterUrl={embeddedJupyterUrl}
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("/");
}