feat: 实现 2x3 终端面板网格布局

- 抽取 TerminalPanel 组件实现多终端支持
- 使用 CSS Grid 实现 2x3 布局 (6个面板)
- 添加连接延迟错开同时连接 (0-1000ms)
- 后端添加 CORS 支持
- Vite 代理添加 WebSocket 支持

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:48:50 +08:00
parent 0786167c87
commit 346fa9b7e0
3 changed files with 116 additions and 107 deletions

View File

@@ -11,6 +11,17 @@ const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3002;
// CORS middleware
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept');
if (req.method === 'OPTIONS') {
return res.status(204).send();
}
next();
});
// Middleware
app.use(express.json({ limit: '50mb' }));
app.use(express.text({ type: '*/*' }));

View File

@@ -8,27 +8,26 @@ import {
closeTerminal,
} from './lib/terminalApi';
import { getDefaultTheme, type TerminalTheme } from './lib/terminalTheme';
import { useTerminalStore, type TerminalChunk } from './stores/useTerminalStore';
import { type TerminalChunk } from './stores/useTerminalStore';
const DEFAULT_CWD = '/workspace';
const DEFAULT_FONT_SIZE = 14;
const DEFAULT_FONT_FAMILY = 'IBM Plex Mono';
function App() {
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [chunks, setChunks] = useState<TerminalChunk[]>([]);
function TerminalPanel({ initialCwd, connectDelay = 0 }: { initialCwd: string; connectDelay?: number }) {
const [theme] = useState<TerminalTheme>(getDefaultTheme());
const [fontSize] = useState(DEFAULT_FONT_SIZE);
const [fontFamily] = useState(DEFAULT_FONT_FAMILY);
const [cwd, setCwd] = useState(DEFAULT_CWD);
const [inputCwd, setInputCwd] = useState(DEFAULT_CWD);
const [chunks, setChunks] = useState<TerminalChunk[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const sessionIdRef = useRef<string | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const nextChunkIdRef = useRef(1);
const isMountedRef = useRef(true);
const hasConnectedRef = useRef(false);
const handleInput = useCallback(async (data: string) => {
const sessionId = sessionIdRef.current;
@@ -52,7 +51,13 @@ function App() {
}
}, []);
const connectToTerminal = useCallback(async (targetCwd: string) => {
useEffect(() => {
// Skip if already connected (e.g., React strict mode double-mount)
if (hasConnectedRef.current) return;
isMountedRef.current = true;
const connectToTerminal = async (targetCwd: string) => {
// Cleanup previous session
if (cleanupRef.current) {
cleanupRef.current();
@@ -65,12 +70,12 @@ function App() {
} catch {
// Ignore cleanup errors
}
sessionIdRef.current = null;
}
setIsConnecting(true);
setError(null);
setChunks([]);
sessionIdRef.current = null;
nextChunkIdRef.current = 1;
try {
@@ -93,13 +98,11 @@ function App() {
setIsConnected(true);
setIsConnecting(false);
} else if (event.type === 'data' && event.data) {
setChunks((prev) => {
const newChunk: TerminalChunk = {
id: nextChunkIdRef.current++,
data: event.data!,
data: event.data,
};
return [...prev, newChunk];
});
setChunks((prev) => [...prev, newChunk]);
} else if (event.type === 'exit') {
setIsConnected(false);
if (event.exitCode !== 0) {
@@ -123,51 +126,37 @@ function App() {
setIsConnecting(false);
setError(err instanceof Error ? err.message : 'Failed to create terminal');
}
}, []);
};
const handleConnect = useCallback(() => {
if (inputCwd.trim()) {
setCwd(inputCwd.trim());
connectToTerminal(inputCwd.trim());
}
}, [inputCwd, connectToTerminal]);
const handleDisconnect = useCallback(async () => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (sessionIdRef.current) {
try {
await closeTerminal(sessionIdRef.current);
} catch {
// Ignore cleanup errors
}
sessionIdRef.current = null;
}
setIsConnected(false);
setChunks([]);
}, []);
useEffect(() => {
isMountedRef.current = true;
connectToTerminal(DEFAULT_CWD);
// Apply connection delay to stagger connections
const delay = connectDelay > 0 ? connectDelay : 0;
const timeoutId = setTimeout(() => {
hasConnectedRef.current = true;
connectToTerminal(initialCwd);
}, delay);
return () => {
clearTimeout(timeoutId);
isMountedRef.current = false;
if (cleanupRef.current) {
cleanupRef.current();
}
};
}, [connectToTerminal]);
}, [initialCwd, connectDelay]);
return (
<div className="h-screen w-screen flex flex-col bg-[#1e1e1e]">
<div className="h-full w-full flex flex-col bg-[#1e1e1e] border border-[#3c3c3c]">
{/* Panel header */}
<div className="px-2 py-1 bg-[#2d2d2d] text-white text-xs flex items-center justify-between border-b border-[#3c3c3c]">
<span className="truncate">{initialCwd}</span>
<span className={isConnected ? 'text-[#4caf50]' : 'text-[#808080]'}>
{isConnected ? 'Connected' : isConnecting ? 'Connecting...' : 'Not connected'}
</span>
</div>
{/* Error message */}
{error && (
<div className="px-4 py-2 bg-[#3c3c3c] text-[#f14c4c] text-sm">
<div className="px-2 py-1 bg-[#3c3c3c] text-[#f14c4c] text-xs">
{error}
</div>
)}
@@ -176,7 +165,7 @@ function App() {
<div className="flex-1 min-h-0">
{isConnected ? (
<TerminalViewport
sessionKey={`${cwd}-${sessionIdRef.current}`}
sessionKey={`${initialCwd}-${sessionIdRef.current}`}
chunks={chunks}
onInput={handleInput}
onResize={handleResize}
@@ -186,17 +175,24 @@ function App() {
autoFocus
/>
) : (
<div className="h-full flex items-center justify-center text-[#808080]">
{isConnecting ? 'Connecting to terminal...' : 'Click "Connect" to start a terminal session'}
<div className="h-full flex items-center justify-center text-[#808080] text-sm">
{isConnecting ? 'Connecting...' : 'Click to connect'}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-1 bg-[#007acc] text-white text-xs flex items-center justify-between">
<span>{cwd}</span>
<span>{isConnected ? 'Connected' : 'Not connected'}</span>
</div>
</div>
);
}
function App() {
return (
<div className="grid grid-cols-3 grid-rows-2 gap-1 h-screen w-screen bg-[#1e1e1e]">
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={0} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={200} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={400} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={600} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={800} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={1000} />
</div>
);
}

View File

@@ -21,6 +21,8 @@ export default defineConfig({
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
ws: true,
rewrite: (path) => path,
},
},
},