重写ssh前端组件,通用化

This commit is contained in:
dengqichen 2025-12-06 23:10:37 +08:00
parent 83c36866cf
commit e768188ca5
7 changed files with 106 additions and 19 deletions

View File

@ -22,6 +22,7 @@ export const Terminal: React.FC<TerminalProps> = ({
audit, audit,
toolbar, toolbar,
isActive, isActive,
compact = false,
onStatusChange, onStatusChange,
onCloseReady, onCloseReady,
onError, onError,
@ -276,6 +277,7 @@ export const Terminal: React.FC<TerminalProps> = ({
fontSize={fontSize} fontSize={fontSize}
currentTheme={currentTheme} currentTheme={currentTheme}
themes={TERMINAL_THEMES} themes={TERMINAL_THEMES}
compact={compact}
onSearch={handleSearch} onSearch={handleSearch}
onClear={handleClear} onClear={handleClear}
onCopy={handleCopy} onCopy={handleCopy}

View File

@ -7,7 +7,7 @@ import type { LayoutOrientation } from './types';
interface TerminalSplitDividerProps { interface TerminalSplitDividerProps {
orientation: LayoutOrientation; orientation: LayoutOrientation;
onResize: (delta: number) => void; onResize: (delta: number, containerSize?: number) => void;
} }
/** /**
@ -18,21 +18,52 @@ interface TerminalSplitDividerProps {
export const TerminalSplitDivider: React.FC<TerminalSplitDividerProps> = ({ orientation, onResize }) => { export const TerminalSplitDivider: React.FC<TerminalSplitDividerProps> = ({ orientation, onResize }) => {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const startPosRef = useRef(0); const startPosRef = useRef(0);
const dividerRef = useRef<HTMLDivElement>(null);
const lastUpdateRef = useRef(0);
const lastMousePosRef = useRef(0); // 记录上一次鼠标位置
useEffect(() => { useEffect(() => {
if (!isDragging) return; if (!isDragging) return;
// 设置拖动时的鼠标样式
const cursor = orientation === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.cursor = cursor;
document.body.style.userSelect = 'none';
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const delta = orientation === 'horizontal' e.preventDefault();
? e.clientX - startPosRef.current
: e.clientY - startPosRef.current;
onResize(delta); const currentMousePos = orientation === 'horizontal' ? e.clientX : e.clientY;
startPosRef.current = orientation === 'horizontal' ? e.clientX : e.clientY;
// 节流限制更新频率为16ms约60fps
const now = Date.now();
if (now - lastUpdateRef.current < 16) {
return;
}
lastUpdateRef.current = now;
// 计算相对于上一帧的增量
const delta = currentMousePos - lastMousePosRef.current;
// 如果没有移动,跳过
if (delta === 0) {
return;
}
// 获取父容器的尺寸
const container = dividerRef.current?.parentElement;
const containerSize = container
? (orientation === 'horizontal' ? container.clientWidth : container.clientHeight)
: undefined;
onResize(delta, containerSize);
lastMousePosRef.current = currentMousePos;
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
setIsDragging(false); setIsDragging(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}; };
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
@ -41,33 +72,46 @@ export const TerminalSplitDivider: React.FC<TerminalSplitDividerProps> = ({ orie
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}; };
}, [isDragging, orientation, onResize]); }, [isDragging, orientation, onResize]);
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const initialPos = orientation === 'horizontal' ? e.clientX : e.clientY;
startPosRef.current = initialPos;
lastMousePosRef.current = initialPos;
setIsDragging(true); setIsDragging(true);
startPosRef.current = orientation === 'horizontal' ? e.clientX : e.clientY;
}; };
return ( return (
<div <div
ref={dividerRef}
className={` className={`
${orientation === 'horizontal' ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'} ${orientation === 'horizontal' ? 'w-[2px] cursor-col-resize' : 'h-[2px] cursor-row-resize'}
${isDragging ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-700 hover:bg-blue-400'} ${isDragging ? 'bg-blue-500' : 'bg-gray-400 dark:bg-gray-600 hover:bg-blue-500'}
transition-colors transition-all duration-150
relative relative
group group
flex-shrink-0
`} `}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
{/* 中心可见线 */}
<div className={`
absolute inset-0
${orientation === 'horizontal' ? 'w-[1px] left-1/2 -translate-x-1/2' : 'h-[1px] top-1/2 -translate-y-1/2'}
${isDragging ? 'bg-blue-600' : 'bg-gray-500 dark:bg-gray-500'}
`} />
{/* 拖动热区 - 增加可拖动区域 */} {/* 拖动热区 - 增加可拖动区域 */}
<div <div
className={` className={`
absolute absolute
${orientation === 'horizontal' ${orientation === 'horizontal'
? '-left-1 -right-1 top-0 bottom-0' ? 'w-3 -left-1.5 top-0 bottom-0'
: 'left-0 right-0 -top-1 -bottom-1' : 'h-3 left-0 right-0 -top-1.5'
} }
`} `}
/> />

View File

@ -22,10 +22,11 @@ export interface TerminalSplitNodeProps {
onSplitLeft: () => void; onSplitLeft: () => void;
onSplitRight: () => void; onSplitRight: () => void;
onFocus: (groupId: string) => void; onFocus: (groupId: string) => void;
onResize: (nodeId: string, delta: number) => void; onResize: (nodeId: string, delta: number, containerSize?: number) => void;
getConnectionConfig: (tab: TerminalTab) => TerminalConnectionConfig; getConnectionConfig: (tab: TerminalTab) => TerminalConnectionConfig;
getAuditConfig: () => TerminalAuditConfig; getAuditConfig: () => TerminalAuditConfig;
getToolbarConfig: () => TerminalToolbarConfig; getToolbarConfig: () => TerminalToolbarConfig;
hasMultipleGroups?: boolean; // 是否有多个分屏组
} }
/** /**
@ -48,6 +49,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
getConnectionConfig, getConnectionConfig,
getAuditConfig, getAuditConfig,
getToolbarConfig, getToolbarConfig,
hasMultipleGroups = false,
}) => { }) => {
// 渲染 Group 节点(包含 Tabs 和 Terminal // 渲染 Group 节点(包含 Tabs 和 Terminal
if (node.type === 'group') { if (node.type === 'group') {
@ -126,6 +128,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
audit={auditConfig} audit={auditConfig}
toolbar={toolbarConfig} toolbar={toolbarConfig}
isActive={tab.isActive} isActive={tab.isActive}
compact={hasMultipleGroups}
onSplitUp={onSplitUp} onSplitUp={onSplitUp}
onSplitDown={onSplitDown} onSplitDown={onSplitDown}
onSplitLeft={onSplitLeft} onSplitLeft={onSplitLeft}
@ -168,6 +171,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
getConnectionConfig={getConnectionConfig} getConnectionConfig={getConnectionConfig}
getAuditConfig={getAuditConfig} getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig} getToolbarConfig={getToolbarConfig}
hasMultipleGroups={hasMultipleGroups}
/> />
</div> </div>
@ -175,7 +179,7 @@ const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
{index < node.children.length - 1 && ( {index < node.children.length - 1 && (
<TerminalSplitDivider <TerminalSplitDivider
orientation={node.orientation} orientation={node.orientation}
onResize={(delta) => onResize(child.id, delta)} onResize={(delta, containerSize) => onResize(child.id, delta, containerSize)}
/> />
)} )}
</React.Fragment> </React.Fragment>

View File

@ -5,7 +5,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useSplitView } from './useSplitView'; import { useSplitView } from './useSplitView';
import { TerminalSplitNode } from './TerminalSplitNode'; import { TerminalSplitNode } from './TerminalSplitNode';
import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig } from './types'; import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig, SplitNode } from './types';
// 对外暴露的主组件Props // 对外暴露的主组件Props
export interface TerminalSplitViewProps { export interface TerminalSplitViewProps {
@ -85,6 +85,13 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [splitUp, splitDown, splitLeft, splitRight]); }, [splitUp, splitDown, splitLeft, splitRight]);
// 计算是否有多个分屏组
const countGroups = (node: SplitNode): number => {
if (node.type === 'group') return 1;
return node.children.reduce((sum, child) => sum + countGroups(child), 0);
};
const hasMultipleGroups = countGroups(layout.root) > 1;
return ( return (
<div className="terminal-split-container h-full w-full"> <div className="terminal-split-container h-full w-full">
<TerminalSplitNode <TerminalSplitNode
@ -102,6 +109,7 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
getConnectionConfig={getConnectionConfig} getConnectionConfig={getConnectionConfig}
getAuditConfig={getAuditConfig} getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig} getToolbarConfig={getToolbarConfig}
hasMultipleGroups={hasMultipleGroups}
/> />
</div> </div>
); );

View File

@ -23,6 +23,7 @@ interface TerminalToolbarProps {
fontSize: number; fontSize: number;
currentTheme?: string; currentTheme?: string;
themes?: TerminalTheme[]; themes?: TerminalTheme[];
compact?: boolean; // 紧凑模式
onSearch?: () => void; onSearch?: () => void;
onClear?: () => void; onClear?: () => void;
onCopy?: () => void; onCopy?: () => void;
@ -44,6 +45,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
fontSize, fontSize,
currentTheme, currentTheme,
themes = [], themes = [],
compact = false,
onSearch, onSearch,
onClear, onClear,
onCopy, onCopy,
@ -60,6 +62,23 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
if (!config.show) return null; if (!config.show) return null;
const getStatusBadge = () => { const getStatusBadge = () => {
// 紧凑模式:只显示状态圆点
if (compact) {
switch (connectionStatus) {
case 'connecting':
return <div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" title="连接中" />;
case 'connected':
return <div className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" title="已连接" />;
case 'reconnecting':
return <div className="h-2 w-2 rounded-full bg-orange-500 animate-pulse" title="重连中" />;
case 'error':
return <div className="h-2 w-2 rounded-full bg-red-500" title="连接失败" />;
case 'disconnected':
return <div className="h-2 w-2 rounded-full bg-gray-400" title="已断开" />;
}
}
// 正常模式显示带文字的Badge
switch (connectionStatus) { switch (connectionStatus) {
case 'connecting': case 'connecting':
return <Badge variant="outline" className="bg-yellow-100 text-yellow-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>; return <Badge variant="outline" className="bg-yellow-100 text-yellow-700"><Loader2 className="mr-1 h-3 w-3 animate-spin" /></Badge>;
@ -80,8 +99,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<div className={styles.left}> <div className={styles.left}>
{config.showStatus && getStatusBadge()} {config.showStatus && getStatusBadge()}
{config.showFontSizeLabel && ( {config.showFontSizeLabel && (
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className={compact ? "text-[12px] text-gray-500 dark:text-gray-400" : "text-xs text-gray-500 dark:text-gray-400"}>
{fontSize}px {compact ? `${fontSize}px` : `字体 ${fontSize}px`}
</span> </span>
)} )}
</div> </div>

View File

@ -217,6 +217,8 @@ export interface TerminalProps {
toolbar?: TerminalToolbarConfig; toolbar?: TerminalToolbarConfig;
/** 是否为激活状态用于Tab切换时重新focus */ /** 是否为激活状态用于Tab切换时重新focus */
isActive?: boolean; isActive?: boolean;
/** 紧凑模式(多分屏时自动启用) */
compact?: boolean;
/** 连接状态变化回调 */ /** 连接状态变化回调 */
onStatusChange?: (status: ConnectionStatus) => void; onStatusChange?: (status: ConnectionStatus) => void;
/** 关闭就绪回调 */ /** 关闭就绪回调 */

View File

@ -369,7 +369,7 @@ export const useSplitView = ({ initialTab, onWindowClose }: UseSplitViewOptions)
}; };
// 调整分屏大小 // 调整分屏大小
const resizeGroups = useCallback((nodeId: string, delta: number) => { const resizeGroups = useCallback((nodeId: string, delta: number, containerSize?: number) => {
setLayout(prev => { setLayout(prev => {
const result = findParent(prev.root, nodeId); const result = findParent(prev.root, nodeId);
if (!result || !result.parent) return prev; if (!result || !result.parent) return prev;
@ -381,7 +381,15 @@ export const useSplitView = ({ initialTab, onWindowClose }: UseSplitViewOptions)
const next = parent.children[index + 1]; const next = parent.children[index + 1];
const totalSize = current.size + next.size; const totalSize = current.size + next.size;
const newCurrentSize = Math.max(20, Math.min(totalSize - 20, current.size + delta)); // 将像素delta转换为百分比delta
// delta是像素值需要转换为百分比(delta / containerSize) * totalSize
const deltaPercent = containerSize ? (delta / containerSize) * totalSize : delta;
// 计算最小尺寸百分比520px / 容器尺寸 * 100
// 如果没有容器尺寸使用默认20%作为兜底
const minSizePercent = containerSize ? Math.min(50, (520 / containerSize) * 100) : 20;
const newCurrentSize = Math.max(minSizePercent, Math.min(totalSize - minSizePercent, current.size + deltaPercent));
const newNextSize = totalSize - newCurrentSize; const newNextSize = totalSize - newCurrentSize;
const newChildren = parent.children.map((child, i) => { const newChildren = parent.children.map((child, i) => {