初始化 SSH 面板:Electron + React + electron-vite
- 登录页:服务器地址/端口/用户名/密码表单,ssh2 建立真实 SSH 连接 - 面板页:深蓝色顶栏菜单(概览/容器/终端占位),显示当前连接 - 凭据保存:密码经系统安全存储(DPAPI)加密后保存在本机,支持一键填充/删除 - 隐藏 Electron 默认菜单,开发模式 F12 开关开发者工具 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
.idea/
|
||||||
@@ -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()],
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+2572
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user