diff --git a/src/App.tsx b/src/App.tsx index ed959efec..2071477e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,21 +52,22 @@ const MainContent = styled.main` min-height: 0; `; -const PageWrapper = styled.div` +const PageWrapper = styled.div<{ $isActive: boolean }>` flex: 1; padding: 24px; overflow: auto; + display: ${(props) => (props.$isActive ? "block" : "none")}; `; /** * 全屏页面容器(无 padding) * 用于终端等需要全屏显示的插件 */ -const FullscreenWrapper = styled.div` +const FullscreenWrapper = styled.div<{ $isActive: boolean }>` flex: 1; min-height: 0; overflow: hidden; - display: flex; + display: ${(props) => (props.$isActive ? "flex" : "none")}; flex-direction: column; position: relative; `; @@ -124,106 +125,109 @@ function AppContent() { }, []); /** - * 渲染当前页面 + * 渲染所有页面(保持挂载状态) * - * 根据 currentPage 状态渲染对应的页面组件 - * - 静态页面: 直接渲染对应组件 - * - 动态插件页面: 使用 PluginUIRenderer 渲染 + * 所有页面组件都会被渲染,但只有当前页面可见 + * 这样可以保持页面状态,避免切换时重置 * * _需求: 2.2, 3.2_ */ - const renderPage = () => { - // 检查是否为动态插件页面 (plugin:xxx 格式) - if (currentPage.startsWith("plugin:")) { - const pluginId = currentPage.slice(7); // 移除 "plugin:" 前缀 - - // 需要全屏显示的插件列表(终端已内置,不再需要插件) - const fullscreenPlugins: string[] = []; - const isFullscreen = fullscreenPlugins.includes(pluginId); - - if (isFullscreen) { - return ( - - - - ); - } - - return ( - - + const renderAllPages = () => { + return ( + <> + {/* Provider Pool 页面 */} + + - ); - } - // 静态页面路由 - switch (currentPage) { - case "provider-pool": - return ( - - - - ); - case "api-server": - return ( - - - - ); - case "agent": - // Agent 页面有自己的布局,不需要 PageWrapper - return ( + {/* API Server 页面 */} + + + + + {/* Agent 页面 - 使用 div 包装以支持显示/隐藏 */} +
setCurrentPage(page as Page)} /> - ); - case "terminal": - // 终端工作区 - 分块布局(对齐 Waveterm TileLayout) - return ; - case "sysinfo": - // 系统监控页面 - return ( - - - - ); - case "files": - // 文件浏览器页面 - return ( - - - - ); - case "web": - // 内嵌浏览器页面 - return ( - - - - ); - case "tools": - return ( - - - - ); - case "plugins": - return ( - - - - ); - case "settings": - return ( - - - - ); - default: - return ( - - - - ); - } +
+ + {/* 终端工作区 - 使用 div 包装以支持显示/隐藏 */} +
+ +
+ + {/* 系统监控页面 */} + + + + + {/* 文件浏览器页面 */} + + + + + {/* 内嵌浏览器页面 */} + + + + + {/* Tools 页面 */} + + + + + {/* Plugins 页面 */} + + + + + {/* Settings 页面 */} + + + + + {/* 动态插件页面 */} + {currentPage.startsWith("plugin:") && + (() => { + const pluginId = currentPage.slice(7); + const fullscreenPlugins: string[] = []; + const isFullscreen = fullscreenPlugins.includes(pluginId); + + if (isFullscreen) { + return ( + + + + ); + } + + return ( + + + + ); + })()} + + ); }; // 引导完成回调 @@ -251,7 +255,7 @@ function AppContent() { - {renderPage()} + {renderAllPages()} {/* ProxyCast Connect 确认弹窗 */} {/* _Requirements: 5.2_ */} (loadFontSizePreference()); const tabIdCounter = useRef(0); - // 单一终端容器引用(对齐 waveterm 的 connectElemRef) - const connectElemRef = useRef(null); - // 当前 TermWrap 实例引用(对齐 waveterm 的 model.termRef) - const termWrapRef = useRef(null); + // 终端容器的父容器引用 + const terminalContainerRef = useRef(null); // 获取当前活动的标签页 const activeTab = tabs.find((t) => t.id === activeTabId); // 获取当前活动的 TermWrap const getActiveTermWrap = useCallback(() => { - return termWrapRef.current; - }, []); + return activeTab?.termWrap || null; + }, [activeTab]); // 创建新终端 const handleNewTerminal = useCallback(async () => { @@ -453,6 +452,11 @@ export function TerminalPage() { const tab = tabs.find((t) => t.id === tabId); if (tab) { try { + // 销毁 TermWrap + if (tab.termWrap) { + tab.termWrap.dispose(); + } + // 关闭后端会话 await closeTerminal(tab.sessionId); } catch (err) { console.error("[TerminalPage] 关闭终端会话失败:", err); @@ -477,10 +481,15 @@ export function TerminalPage() { const handleThemeChange = useCallback((theme: ThemeName) => { setCurrentTheme(theme); saveThemePreference(theme); - // 更新当前终端的主题 - if (termWrapRef.current) { - termWrapRef.current.setTheme(theme); - } + // 更新所有终端的主题 + setTabs((prev) => + prev.map((tab) => { + if (tab.termWrap) { + tab.termWrap.setTheme(theme); + } + return tab; + }), + ); }, []); // 字体大小变化 @@ -488,10 +497,15 @@ export function TerminalPage() { const handleFontSizeChange = useCallback((size: number) => { setFontSize(size); saveFontSizePreference(size); - // 更新当前终端的字体大小 - if (termWrapRef.current) { - termWrapRef.current.setFontSize(size); - } + // 更新所有终端的字体大小 + setTabs((prev) => + prev.map((tab) => { + if (tab.termWrap) { + tab.termWrap.setFontSize(size); + } + return tab; + }), + ); }, []); // 搜索功能 @@ -578,162 +592,181 @@ export function TerminalPage() { }, []); // ============================================================================ - // 核心:当活动标签页变化时,重新创建 TermWrap(对齐 waveterm) + // 核心:为每个标签页创建独立的终端容器和 TermWrap // ============================================================================ - // 键盘事件处理器(对齐 waveterm 的 handleTerminalKeydown) + // 键盘事件处理器 // 返回 true = 允许事件传递到终端 // 返回 false = 阻止事件传递到终端(已处理) - const handleTerminalKeydown = useCallback((e: KeyboardEvent): boolean => { - // 只处理 keydown 事件 - if (e.type !== "keydown") { - return true; - } - - const termWrap = termWrapRef.current; - if (!termWrap) return true; - - const isMac = /mac/i.test(navigator.userAgent); - - // Shift+End - 滚动到底部 - if ( - e.shiftKey && - !e.ctrlKey && - !e.altKey && - !e.metaKey && - e.key === "End" - ) { - termWrap.terminal.scrollToBottom(); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - // Shift+Home - 滚动到顶部 - if ( - e.shiftKey && - !e.ctrlKey && - !e.altKey && - !e.metaKey && - e.key === "Home" - ) { - termWrap.terminal.scrollToLine(0); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - // Cmd+End (macOS) - 滚动到底部 - if ( - isMac && - e.metaKey && - !e.ctrlKey && - !e.altKey && - !e.shiftKey && - e.key === "End" - ) { - termWrap.terminal.scrollToBottom(); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - // Cmd+Home (macOS) - 滚动到顶部 - if ( - isMac && - e.metaKey && - !e.ctrlKey && - !e.altKey && - !e.shiftKey && - e.key === "Home" - ) { - termWrap.terminal.scrollToLine(0); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - // Shift+PageDown - 向下滚动一页 - if ( - e.shiftKey && - !e.ctrlKey && - !e.altKey && - !e.metaKey && - e.key === "PageDown" - ) { - termWrap.terminal.scrollPages(1); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - // Shift+PageUp - 向上滚动一页 - if ( - e.shiftKey && - !e.ctrlKey && - !e.altKey && - !e.metaKey && - e.key === "PageUp" - ) { - termWrap.terminal.scrollPages(-1); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - // 未处理的事件,允许传递到终端 - return true; - }, []); - - useEffect(() => { - const container = connectElemRef.current; - if (!container || !activeTab) { - // 没有活动标签页,清理旧的 TermWrap - if (termWrapRef.current) { - termWrapRef.current.dispose(); - termWrapRef.current = null; + const handleTerminalKeydown = useCallback( + (e: KeyboardEvent): boolean => { + // 只处理 keydown 事件 + if (e.type !== "keydown") { + return true; } - return; - } - // 销毁旧的 TermWrap - if (termWrapRef.current) { - termWrapRef.current.dispose(); - termWrapRef.current = null; - } + const termWrap = getActiveTermWrap(); + if (!termWrap) return true; - // 清空容器 - container.innerHTML = ""; + const isMac = /mac/i.test(navigator.userAgent); - // 创建新的 TermWrap(对齐 waveterm) - const termWrap = new TermWrap(activeTab.sessionId, container, { - onStatusChange: (status) => handleStatusChange(activeTab.id, status), - themeName: currentTheme, - fontSize: fontSize, - keydownHandler: handleTerminalKeydown, + // Shift+End - 滚动到底部 + if ( + e.shiftKey && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + e.key === "End" + ) { + termWrap.terminal.scrollToBottom(); + e.preventDefault(); + e.stopPropagation(); + return false; + } + + // Shift+Home - 滚动到顶部 + if ( + e.shiftKey && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + e.key === "Home" + ) { + termWrap.terminal.scrollToLine(0); + e.preventDefault(); + e.stopPropagation(); + return false; + } + + // Cmd+End (macOS) - 滚动到底部 + if ( + isMac && + e.metaKey && + !e.ctrlKey && + !e.altKey && + !e.shiftKey && + e.key === "End" + ) { + termWrap.terminal.scrollToBottom(); + e.preventDefault(); + e.stopPropagation(); + return false; + } + + // Cmd+Home (macOS) - 滚动到顶部 + if ( + isMac && + e.metaKey && + !e.ctrlKey && + !e.altKey && + !e.shiftKey && + e.key === "Home" + ) { + termWrap.terminal.scrollToLine(0); + e.preventDefault(); + e.stopPropagation(); + return false; + } + + // Shift+PageDown - 向下滚动一页 + if ( + e.shiftKey && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + e.key === "PageDown" + ) { + termWrap.terminal.scrollPages(1); + e.preventDefault(); + e.stopPropagation(); + return false; + } + + // Shift+PageUp - 向上滚动一页 + if ( + e.shiftKey && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + e.key === "PageUp" + ) { + termWrap.terminal.scrollPages(-1); + e.preventDefault(); + e.stopPropagation(); + return false; + } + + // 未处理的事件,允许传递到终端 + return true; + }, + [getActiveTermWrap], + ); + + // 为新标签页创建终端 + useEffect(() => { + const container = terminalContainerRef.current; + if (!container) return; + + // 为每个没有 termWrap 的标签页创建终端 + tabs.forEach((tab) => { + if (tab.termWrap) return; // 已有 TermWrap,跳过 + + // 创建独立的容器 + const termContainer = document.createElement("div"); + termContainer.className = "term-connectelem"; + termContainer.dataset.tabId = tab.id; + termContainer.style.display = tab.id === activeTabId ? "block" : "none"; + container.appendChild(termContainer); + + // 创建 TermWrap + const termWrap = new TermWrap(tab.sessionId, termContainer, { + onStatusChange: (status) => handleStatusChange(tab.id, status), + themeName: currentTheme, + fontSize: fontSize, + keydownHandler: handleTerminalKeydown, + }); + + // 保存到标签页 + tab.termWrap = termWrap; + + // 设置 ResizeObserver + const rszObs = new ResizeObserver(() => { + termWrap.handleResize_debounced(); + }); + rszObs.observe(termContainer); + + // 异步初始化终端 + termWrap.initTerminal().catch(console.error); + + // 如果是当前活动标签,自动聚焦 + if (tab.id === activeTabId) { + setTimeout(() => termWrap.focus(), 10); + } + + // 清理函数会在标签页关闭时由 handleCloseTab 调用 }); - termWrapRef.current = termWrap; - - // 设置 ResizeObserver(对齐 waveterm) - const rszObs = new ResizeObserver(() => { - termWrap.handleResize_debounced(); + // 更新容器显示状态 + const allContainers = container.querySelectorAll(".term-connectelem"); + allContainers.forEach((elem) => { + const htmlElem = elem as HTMLElement; + const tabId = htmlElem.dataset.tabId; + htmlElem.style.display = tabId === activeTabId ? "block" : "none"; }); - rszObs.observe(container); - // 异步初始化终端(对齐 waveterm 的 fireAndForget) - termWrap.initTerminal().catch(console.error); - - // 自动聚焦 - setTimeout(() => termWrap.focus(), 10); - - return () => { - termWrap.dispose(); - rszObs.disconnect(); - }; - // 注意:handleTerminalKeydown 使用 useCallback 且无依赖,不会导致重新创建 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab?.sessionId, currentTheme, fontSize, handleStatusChange]); + // 聚焦当前活动终端 + if (activeTab?.termWrap) { + setTimeout(() => activeTab.termWrap?.focus(), 10); + } + }, [ + tabs, + activeTabId, + currentTheme, + fontSize, + handleStatusChange, + handleTerminalKeydown, + activeTab, + ]); // 没有标签页时显示空状态 if (tabs.length === 0) { @@ -794,11 +827,11 @@ export function TerminalPage() { onClearSearch={handleClearSearch} /> - {/* 单一终端容器(对齐 waveterm 的 term-connectelem) */} + {/* 多终端容器(每个标签页一个容器) */}
termWrapRef.current?.focus()} + ref={terminalContainerRef} + className="term-container-wrapper" + onClick={() => activeTab?.termWrap?.focus()} /> {/* 错误提示 */} diff --git a/src/components/terminal/terminal.css b/src/components/terminal/terminal.css index e1a8e1637..33e1a0bb0 100644 --- a/src/components/terminal/terminal.css +++ b/src/components/terminal/terminal.css @@ -49,6 +49,25 @@ margin-left: 4px; } +/* 多终端容器包装器 */ +.term-container-wrapper { + flex-grow: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +/* 每个终端容器独立定位 */ +.term-container-wrapper .term-connectelem { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: 5px; + margin-left: 4px; +} + /* 关键:xterm 容器必须填满父元素 */ .term-connectelem .xterm { height: 100%;