1058 lines
35 KiB
TypeScript
1058 lines
35 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);
|
||
|
||
// 编辑会话相关 - 为每个标签维护独立 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);
|
||
selectedIdRef.current = 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);
|
||
}
|
||
// 从缓存中删除
|
||
sessionCacheRef.current.delete(scriptId);
|
||
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 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 (!script) return;
|
||
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);
|
||
}
|
||
// 和新建文件逻辑一致:直接更新 scripts,不调用 load()
|
||
setScripts((items) => [lastCreated!, ...items]);
|
||
// 只打开最后一个文件
|
||
if (lastCreated) {
|
||
openTab(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("/");
|
||
}
|