初始化 SSH 面板:Electron + React + electron-vite

- 登录页:服务器地址/端口/用户名/密码表单,ssh2 建立真实 SSH 连接
- 面板页:深蓝色顶栏菜单(概览/容器/终端占位),显示当前连接
- 凭据保存:密码经系统安全存储(DPAPI)加密后保存在本机,支持一键填充/删除
- 隐藏 Electron 默认菜单,开发模式 F12 开关开发者工具

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-06 17:31:05 +08:00
commit 0acd71d76d
14 changed files with 3232 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
out/
dist/
.idea/
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
plugins: [react()],
},
})
+2572
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "ssh-panel",
"version": "0.1.0",
"description": "基于 SSH 的服务器管理面板",
"main": "out/main/index.js",
"scripts": {
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "electron-vite build"
},
"license": "MIT",
"devDependencies": {
"@vitejs/plugin-react": "^5.2.0",
"electron": "^44.2.0",
"electron-vite": "^5.0.0",
"vite": "^7.3.6"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"ssh2": "^1.17.0"
}
}
+169
View File
@@ -0,0 +1,169 @@
import { app, BrowserWindow, Menu, ipcMain, safeStorage } from 'electron'
import path from 'path'
import fs from 'fs'
import { Client } from 'ssh2'
// 隐藏 Electron 默认菜单(File / Edit 等)
Menu.setApplicationMenu(null)
let mainWindow = null
let activeConnection = null // 当前活动的 SSH 连接
let activeSession = null // { host, username },登录成功后记录
// ---------- 凭据存储 ----------
const credFile = () => path.join(app.getPath('userData'), 'credentials.json')
function readCredentials() {
try {
return JSON.parse(fs.readFileSync(credFile(), 'utf8'))
} catch {
return []
}
}
function writeCredentials(list) {
fs.mkdirSync(path.dirname(credFile()), { recursive: true })
fs.writeFileSync(credFile(), JSON.stringify(list, null, 2), 'utf8')
}
// 密码优先用系统安全存储加密(Windows DPAPI),不可用时降级为 base64
function encryptPassword(password) {
if (safeStorage.isEncryptionAvailable()) {
return { data: safeStorage.encryptString(password).toString('base64'), encrypted: true }
}
return { data: Buffer.from(password, 'utf8').toString('base64'), encrypted: false }
}
function decryptPassword(entry) {
if (entry.encrypted) {
return safeStorage.decryptString(Buffer.from(entry.data, 'base64'))
}
return Buffer.from(entry.data, 'base64').toString('utf8')
}
// ---------- 窗口 ----------
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
backgroundColor: '#d2f8fc',
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
},
})
if (process.env['ELECTRON_RENDERER_URL']) {
// 开发模式:加载 Vite 开发服务器(支持热更新)
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
// 菜单已隐藏,用 F12 开关开发者工具
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12') mainWindow.webContents.toggleDevTools()
})
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
if (activeConnection) {
activeConnection.end()
activeConnection = null
}
})
}
// ---------- IPC:SSH 连接 ----------
ipcMain.handle('ssh:connect', (event, { host, port, username, password }) => {
return new Promise((resolve) => {
// 已有连接时先断开旧连接
if (activeConnection) {
activeConnection.end()
activeConnection = null
}
const client = new Client()
client.on('ready', () => {
activeConnection = client
activeSession = { host, username }
resolve({ ok: true })
})
client.on('error', (err) => {
resolve({ ok: false, message: err.message })
})
client.on('close', () => {
if (activeConnection === client) activeConnection = null
})
client.connect({
host,
port: Number(port),
username,
password,
readyTimeout: 10000,
})
})
})
ipcMain.handle('ssh:disconnect', () => {
if (activeConnection) {
activeConnection.end()
activeConnection = null
}
activeSession = null
return { ok: true }
})
ipcMain.handle('ssh:session', () => activeSession)
// ---------- IPC:凭据 ----------
ipcMain.handle('cred:save', (event, { host, port, username, password }) => {
const list = readCredentials()
const entry = { host, port: Number(port), username, password: encryptPassword(password) }
// 同一 host + username 覆盖旧记录
const idx = list.findIndex((c) => c.host === host && c.username === username)
if (idx >= 0) list[idx] = entry
else list.push(entry)
writeCredentials(list)
return { ok: true }
})
ipcMain.handle('cred:list', () => {
return readCredentials()
.map((c) => {
try {
return {
host: c.host,
port: c.port,
username: c.username,
password: decryptPassword(c.password),
}
} catch {
return null // 本机无法解密(比如从别的机器拷贝来的),跳过
}
})
.filter(Boolean)
})
ipcMain.handle('cred:delete', (event, { host, username }) => {
writeCredentials(readCredentials().filter((c) => !(c.host === host && c.username === username)))
return { ok: true }
})
// ---------- 应用生命周期 ----------
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+12
View File
@@ -0,0 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('panel', {
// SSH 连接
connect: (config) => ipcRenderer.invoke('ssh:connect', config),
disconnect: () => ipcRenderer.invoke('ssh:disconnect'),
getSession: () => ipcRenderer.invoke('ssh:session'),
// 凭据(密码加密保存在本机)
saveCredential: (cred) => ipcRenderer.invoke('cred:save', cred),
listCredentials: () => ipcRenderer.invoke('cred:list'),
deleteCredential: (key) => ipcRenderer.invoke('cred:delete', key),
})
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SSH Panel</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
import { useState } from 'react'
import Login from './pages/Login'
import Panel from './pages/Panel'
export default function App() {
const [session, setSession] = useState(null)
// 登录成功后切换到面板页
const handleConnected = (session) => setSession(session)
// 断开连接,回到登录页
const handleLogout = async () => {
await window.panel.disconnect()
setSession(null)
}
return session ? (
<Panel session={session} onLogout={handleLogout} />
) : (
<Login onConnected={handleConnected} />
)
}
+16
View File
@@ -0,0 +1,16 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
}
body {
background: #d2f8fc;
color: #2c3e50;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './main.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+127
View File
@@ -0,0 +1,127 @@
.wrap {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.card {
width: 400px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 40, 60, 0.15);
padding: 36px 40px;
}
.card h1 {
font-size: 24px;
color: #123a6b;
text-align: center;
}
.card .subtitle {
text-align: center;
color: #7a8a99;
font-size: 13px;
margin: 6px 0 24px;
}
.field {
margin-bottom: 14px;
}
.field label {
display: block;
font-size: 13px;
color: #4a5a6a;
margin-bottom: 4px;
}
.field input {
width: 100%;
padding: 9px 12px;
border: 1px solid #cfd9e3;
border-radius: 6px;
font-size: 14px;
outline: none;
}
.field input:focus {
border-color: #123a6b;
}
.remember {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #4a5a6a;
margin-bottom: 18px;
}
.connect-btn {
width: 100%;
padding: 10px;
background: #123a6b;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 15px;
cursor: pointer;
}
.connect-btn:disabled {
opacity: 0.6;
cursor: default;
}
.status {
min-height: 20px;
margin-top: 12px;
font-size: 13px;
text-align: center;
color: #c0392b;
}
.saved {
margin-top: 24px;
border-top: 1px solid #e6edf4;
padding-top: 16px;
}
.saved h2 {
font-size: 14px;
color: #4a5a6a;
margin-bottom: 10px;
}
.saved ul {
list-style: none;
}
.saved li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.saved li:hover {
background: #eef4f9;
}
.saved li.empty {
cursor: default;
color: #9aa7b5;
}
.saved .del {
color: #c0392b;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import './Login.css'
export default function Login({ onConnected }) {
const [host, setHost] = useState('')
const [port, setPort] = useState('22')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [connecting, setConnecting] = useState(false)
const [status, setStatus] = useState('')
const [saved, setSaved] = useState([])
// 加载已保存的服务器列表
useEffect(() => {
window.panel.listCredentials().then(setSaved)
}, [])
// 点击已保存的服务器,自动填充表单
const fillForm = (cred) => {
setHost(cred.host)
setPort(String(cred.port))
setUsername(cred.username)
setPassword(cred.password)
setStatus('')
}
const removeSaved = async (cred) => {
await window.panel.deleteCredential({ host: cred.host, username: cred.username })
setSaved(await window.panel.listCredentials())
}
// 提交表单,发起 SSH 连接
const handleSubmit = async (e) => {
e.preventDefault()
const config = {
host: host.trim(),
port: Number(port),
username: username.trim(),
password,
}
setConnecting(true)
setStatus('')
const result = await window.panel.connect(config)
if (result.ok) {
// 连接成功后才保存凭据,避免存下错误密码
if (remember) await window.panel.saveCredential(config)
const session = await window.panel.getSession()
onConnected(session)
} else {
setConnecting(false)
setStatus(`连接失败:${result.message}`)
}
}
return (
<div className="wrap">
<div className="card">
<h1>SSH Panel</h1>
<p className="subtitle">连接到你的服务器</p>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="host">服务器地址</label>
<input
id="host"
type="text"
placeholder="192.168.1.100"
required
value={host}
onChange={(e) => setHost(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="port">端口</label>
<input
id="port"
type="number"
min="1"
max="65535"
required
value={port}
onChange={(e) => setPort(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="username">用户名</label>
<input
id="username"
type="text"
placeholder="root"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<label className="remember">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
记住密码(加密保存在本机)
</label>
<button className="connect-btn" type="submit" disabled={connecting}>
{connecting ? '正在连接...' : '连接'}
</button>
<p className="status">{status}</p>
</form>
<div className="saved">
<h2>已保存的服务器</h2>
<ul>
{saved.length === 0 ? (
<li className="empty">(暂无)</li>
) : (
saved.map((cred) => (
<li key={`${cred.username}@${cred.host}`}>
<span onClick={() => fillForm(cred)}>
{cred.username}@{cred.host}:{cred.port}
</span>
<button className="del" onClick={() => removeSaved(cred)}>
删除
</button>
</li>
))
)}
</ul>
</div>
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
.menu {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: #123a6b;
color: #ffffff;
display: flex;
align-items: center;
padding: 0 20px;
gap: 28px;
}
.menu .brand {
font-size: 16px;
font-weight: 600;
}
.menu nav {
display: flex;
gap: 4px;
flex: 1;
}
.menu nav a {
padding: 6px 14px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.menu nav a:hover {
background: rgba(255, 255, 255, 0.12);
}
.menu nav a.active {
background: rgba(255, 255, 255, 0.18);
}
.menu .right {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.menu .right button {
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
padding: 5px 12px;
cursor: pointer;
font-size: 13px;
}
main {
padding: 68px 20px 20px;
}
main .hint {
text-align: center;
color: #7a8a99;
margin-top: 120px;
}
+36
View File
@@ -0,0 +1,36 @@
import { useState } from 'react'
import './Panel.css'
const NAV_ITEMS = ['概览', '容器', '终端']
export default function Panel({ session, onLogout }) {
const [active, setActive] = useState('概览')
return (
<>
<header className="menu">
<span className="brand">SSH Panel</span>
<nav>
{NAV_ITEMS.map((item) => (
<a
key={item}
className={item === active ? 'active' : ''}
onClick={() => setActive(item)}
>
{item}
</a>
))}
</nav>
<div className="right">
<span>
{session.username}@{session.host}
</span>
<button onClick={onLogout}>断开连接</button>
</div>
</header>
<main>
<p className="hint">连接成功!这里将展示服务器信息,下一步从这里开始扩展。</p>
</main>
</>
)
}