diff --git a/frontend/app/features/platform/ModelPlatformApp.tsx b/frontend/app/features/platform/ModelPlatformApp.tsx index 32eb641..b123fbb 100644 --- a/frontend/app/features/platform/ModelPlatformApp.tsx +++ b/frontend/app/features/platform/ModelPlatformApp.tsx @@ -131,6 +131,7 @@ function AuthenticatedModelPlatformApp() { const [scripts, setScripts] = useState([]); const [directories, setDirectories] = useState([]); const [selectedId, setSelectedId] = useState(null); + const [openTabIds, setOpenTabIds] = useState([]); const [keyword, setKeyword] = useState(""); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -377,12 +378,52 @@ function AuthenticatedModelPlatformApp() { 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 ( @@ -503,12 +544,15 @@ function AuthenticatedModelPlatformApp() { selected?.script_type, ]); - const endEditing = async (closeTab = false, showToast = true) => { + const endEditing = async (closeTabFlag = true, showToast = true) => { editorOpenRequestRef.current += 1; setEditorOpenError(null); const active = editSessionRef.current; + const scriptId = active?.script_id; if (!active) { - if (closeTab) selectScript(null); + if (closeTabFlag && scriptId) { + void closeTab(scriptId); + } return; } setEditBusy(true); @@ -517,7 +561,9 @@ function AuthenticatedModelPlatformApp() { setEmbeddedJupyterUrl(null); setEditSession(null); editSessionRef.current = null; - if (closeTab) selectScript(null); + if (closeTabFlag && scriptId) { + void closeTab(scriptId); + } if (showToast) { setToast({ tone: "success", @@ -613,7 +659,7 @@ function AuthenticatedModelPlatformApp() { try { const created = await api.createScript(form); setScripts((items) => [created, ...items]); - selectScript(created.script_id); + openTab(created.script_id); setCreateOpen(false); setForm(initialForm); setToast({ @@ -958,7 +1004,7 @@ function AuthenticatedModelPlatformApp() { scripts={group.scripts} directories={group.directories} selectedId={selectedId} - onSelect={selectScript} + onSelect={openTab} onContextMenu={ group.user?.user_id === user?.user_id ? showContextMenu @@ -1014,17 +1060,19 @@ function AuthenticatedModelPlatformApp() { } 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={() => { - if ( - editSessionRef.current?.script_id === selected.script_id - ) { - void endEditing(true); - } else { - selectScript(null); - } - }} + onClose={(scriptId, event) => void closeTab(scriptId, event)} + onSwitchTab={switchTab} + onNewTab={() => openCreateDialog("")} onPublish={() => openPublishDialog(selected)} onInfo={setToast} /> diff --git a/frontend/app/features/platform/ScriptWorkspace.tsx b/frontend/app/features/platform/ScriptWorkspace.tsx index d73bb9e..b5150bb 100644 --- a/frontend/app/features/platform/ScriptWorkspace.tsx +++ b/frontend/app/features/platform/ScriptWorkspace.tsx @@ -3,8 +3,11 @@ import type { ActiveEditSession, LatestVersion, ScriptItem, + ScriptType, } from "../../services/api"; import { scriptIcon } from "./WorkspaceTree"; +import type { MouseEvent as ReactMouseEvent } from "react"; +import { useRef, useEffect } from "react"; type ToastState = { tone: "success" | "error" | "info"; @@ -19,9 +22,12 @@ type ScriptWorkspaceProps = { openError: string | null; latestVersion: LatestVersion | null; versionsLoading: boolean; + openTabs: Array<{ scriptId: string; scriptName: string; scriptType: ScriptType }>; onOpenEditor: () => void; onEndEditing: () => void; - onClose: () => void; + onClose: (scriptId: string, event?: ReactMouseEvent) => void; + onSwitchTab: (scriptId: string) => void; + onNewTab: () => void; onPublish: () => void; onInfo: (toast: ToastState) => void; }; @@ -91,32 +97,88 @@ export function ScriptWorkspace({ openError, latestVersion, versionsLoading, + openTabs, onOpenEditor, onEndEditing, onClose, + onSwitchTab, + onNewTab, onPublish, onInfo, }: ScriptWorkspaceProps) { + const tabbarRef = useRef(null); const isNotebook = script.script_type === "notebook"; const isEditing = editSession?.session_status === "active"; + + const scroll = (direction: "left" | "right") => { + const tabbar = tabbarRef.current; + if (!tabbar) return; + const scrollAmount = 200; + tabbar.scrollBy({ + left: direction === "left" ? -scrollAmount : scrollAmount, + behavior: "smooth", + }); + }; + + useEffect(() => { + const tabbar = tabbarRef.current; + if (!tabbar) return; + const activeTab = tabbar.querySelector(".editor-tab--active") as HTMLElement | null; + if (activeTab) { + const tabbarRect = tabbar.getBoundingClientRect(); + const tabRect = activeTab.getBoundingClientRect(); + if (tabRect.right > tabbarRect.right || tabRect.left < tabbarRect.left) { + activeTab.scrollIntoView({ behavior: "smooth", inline: "center" }); + } + } + }, [script.script_id]); + return ( <>
-
- - - - {script.script_name} - +
+ {openTabs.map((tab) => ( +
onSwitchTab(tab.scriptId)} + > + + + + {tab.scriptName} + +
+ ))} +
diff --git a/frontend/app/styles/platform.css b/frontend/app/styles/platform.css index 4181e9b..be1f662 100644 --- a/frontend/app/styles/platform.css +++ b/frontend/app/styles/platform.css @@ -822,20 +822,81 @@ button { align-items: stretch; border-bottom: 1px solid #e5ebf1; background: #f7f9fb; + overflow: hidden; + position: relative; +} + +.tabbar-scroll-content { + display: flex; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; +} + +.tabbar-scroll-content::-webkit-scrollbar { + height: 6px; +} + +.tabbar-scroll-content::-webkit-scrollbar-track { + background: #f1f3f5; +} + +.tabbar-scroll-content::-webkit-scrollbar-thumb { + background: #c9d6e4; + border-radius: 3px; +} + +.tabbar-scroll-content::-webkit-scrollbar-thumb:hover { + background: #a8b9ca; +} + +.tabbar-scroll-btn { + display: grid; + width: 28px; + flex: 0 0 auto; + place-items: center; + border: 0; + border-right: 1px solid #e4eaf0; + color: #6b7c8f; + background: #f7f9fb; + cursor: pointer; + z-index: 1; + transition: background 0.15s ease; +} + +.tabbar-scroll-btn--right { + border-right: 0; + border-left: 1px solid #e4eaf0; +} + +.tabbar-scroll-btn:hover { + background: #edf1f5; + color: #3d4c5c; +} + +.tabbar-scroll-btn--left { + transform: rotate(180deg); } .editor-tab { position: relative; display: flex; - min-width: 190px; + min-width: 100px; max-width: 290px; align-items: center; - gap: 8px; - padding: 0 11px; + gap: 6px; + padding: 0 6px; border-right: 1px solid #e4eaf0; color: #4b5c70; background: #fff; + cursor: pointer; font-size: 12px; + flex-shrink: 0; +} + +.editor-tab:hover { + background: #f0f4f8; } .editor-tab--active::before {