wsShell v1.1 优化实战 —— 从「能用」到「好用」的迭代之路
在第一篇教程中,我们完成了 wsShell 的核心功能:SSH 终端、SFTP 文件管理、VNC 远程桌面和服务器管理。功能有了,但说实话,用起来还差点意思。配色是 Catppuccin 风格,暗沉不精致,深浅色切换后终端还是黑底服务器列表是扁平的,没有分组卡片,看着像开发调试界面右键菜单、对话框、通知都是简陋的直角方块终端 Tab 不能拖拽排序,快捷键也不够用监控面板只有数字,看不出趋势文件管理
项目地址:https://github.com/Julyos-rgb/wsShell
本文是「用 Go + React 构建桌面 SSH 客户端」系列的第二篇,聚焦于 v1.0 发布后的全面优化
适合人群:看过第一篇教程,或对桌面应用 UI 设计、功能迭代感兴趣的同学
前言
在第一篇教程中,我们完成了 wsShell 的核心功能:SSH 终端、SFTP 文件管理、VNC 远程桌面和服务器管理。功能有了,但说实话,用起来还差点意思。
v1.0 的问题很明显:
- 配色是 Catppuccin 风格,暗沉不精致,深浅色切换后终端还是黑底
- 服务器列表是扁平的,没有分组卡片,看着像开发调试界面
- 右键菜单、对话框、通知都是简陋的直角方块
- 终端 Tab 不能拖拽排序,快捷键也不够用
- 监控面板只有数字,看不出趋势
- 文件管理器不能直接拖拽上传
- 连接超时写死了 10 秒,慢服务器连不上
所以 v1.1 做了两件事:iOS 风格 UI 全面重构 + 8 项核心功能增强。
这篇文章就来聊聊,这些优化是怎么做的,踩了哪些坑。
一、iOS 风格 UI 重构 —— 从 CSS 变量开始
1.1 设计语言选型
为什么选 iOS 风格?因为它是最成熟的桌面/移动端设计语言之一:
- 大圆角(12px~16px)
- 毛玻璃效果(backdrop-filter)
- 多层柔和阴影
- 分段控件(Segmented Control)
- 分组列表卡片
这不是照搬 iOS,而是借鉴其设计原则:清晰、遵从、深度。
1.2 CSS 变量体系重构
第一步不是改组件,而是重建配色系统。原来的 CSS 变量是 Catppuccin 风格(薰衣草色系),直接改成 iOS 色彩体系:
:root {
/* iOS 浅色主题 */
--c-primary-500: 0 122 255; /* #007AFF iOS 蓝 */
--c-surface-400: 255 255 255; /* 纯白背景 */
--c-surface-50: 242 242 247; /* #F2F2F7 分组背景 */
--c-text: 28 28 30; /* #1C1C1E 纯黑文字 */
--c-border: 209 209 214; /* 柔和边框 */
/* 多层柔和阴影 */
--shadow-glass: 0 2px 8px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.08);
/* 蓝色聚焦光环 */
--shadow-glow-sm: 0 0 0 3px rgba(0, 122, 255, 0.12);
}
[data-theme="dark"] {
/* iOS 深色主题 */
--c-primary-500: 10 132 255; /* #0A84FF 亮蓝 */
--c-surface-400: 28 28 30; /* #1C1C1E 深黑 */
--c-surface-50: 44 44 46; /* #2C2C2E 卡片 */
--c-text: 242 242 247; /* 白色文字 */
/* 深色模式阴影更强 */
--shadow-glass: 0 2px 8px rgba(0,0,0,0.2), 0 8px 24px rgba(0,0,0,0.35);
}
设计要点:
| iOS 设计原则 | CSS 实现 |
|---|---|
| 大圆角 | rounded-xl(12px) 用于卡片,rounded-2xl(16px) 用于弹窗 |
| 毛玻璃 | backdrop-filter: blur(40px) saturate(180%) |
| 柔和阴影 | 双层阴影:近层小范围 + 远层大范围 |
| 分组背景 | surface-50 作为卡片/输入框的浅灰底色 |
| 蓝色聚焦环 | --shadow-glow-sm 实现 iOS 风格的 focus ring |
1.3 组件类重构
基于新的配色变量,重新定义了全局组件类:
.glass-panel {
backdrop-filter: blur(40px) saturate(180%);
border: 1px solid rgb(var(--c-border) / 0.4);
background-color: rgb(var(--c-surface-400) / 0.72);
}
.btn-primary {
background-color: rgb(var(--c-primary-500)); /* iOS 蓝 */
color: white;
border-radius: 12px;
}
.input-field {
background-color: rgb(var(--c-surface-50)); /* 灰色底,无边框 */
border: none;
border-radius: 12px;
}
.input-field:focus {
outline: none;
box-shadow: var(--shadow-glow-sm); /* 蓝色聚焦光环 */
}
1.4 iOS Segmented Control
顶部终端/VNC 切换从简单的文字按钮改成了 iOS 分段控件:
<div className="flex items-center bg-surface-50/80 rounded-xl p-0.5">
<button className={`px-5 py-1 text-xs rounded-lg transition-all duration-200
${isActive
? 'bg-surface-400 text-primary-500 font-semibold shadow-sm'
: 'text-text-dim'
}`}>
终端
</button>
<button>...</button>
</div>
效果是:灰色背景槽 + 白色滑块指示器,点击切换时有平滑过渡。
1.5 iOS Alert 风格对话框
原来的对话框按钮是右对齐排列,改成了 iOS Alert 的水平分隔风格:
<div className="flex border-t border-border/20">
<button className="flex-1 py-2.5 text-text-muted">取消</button>
<button className="flex-1 py-2.5 text-primary-500 font-semibold border-l border-border/20">
确认
</button>
</div>
用 border-l 做细分隔线,确认按钮用蓝色加粗,取消按钮用灰色 —— 完全复刻 iOS Alert 的视觉。
1.6 iOS 通知横幅
Toast 通知从右下角移到了顶部,增加了左侧彩色竖条:
<div className="backdrop-blur-2xl bg-surface-400/80 rounded-2xl shadow-glass">
<div className="w-1 self-stretch rounded-full"
style={{ backgroundColor: barColors[type] }} />
<Icon />
<Text />
</div>
不同类型用不同颜色的竖条:info 蓝色、success 绿色、error 红色、warning 黄色。
二、终端主题跟随深浅色
2.1 问题
第一版终端 xterm.js 的主题是硬编码的 Catppuccin 深色:
const xtermTheme = {
background: '#11111b',
foreground: '#cdd6f4',
// ...
}
用户切换到浅色主题后,整个 app 变白了,但终端还是黑底 —— 非常违和。
2.2 解决方案
定义两套主题,根据 Zustand store 的 theme 动态切换:
const xtermDarkTheme = {
background: '#11111b',
foreground: '#cdd6f4',
cursor: '#818cf8',
selectionBackground: '#45475a',
// ...
}
const xtermLightTheme = {
background: '#fafafa', // 浅灰底
foreground: '#1c1c1e', // 深色文字
cursor: '#007AFF', // iOS 蓝
selectionBackground: '#007AFF33',
// ...
}
关键点在于主题切换时的实时更新。xterm.js 创建后,主题不是不能改的 —— 通过 term.options.theme 即可动态更新:
useEffect(() => {
if (!termRef.current) return
const newTheme = theme === 'dark' ? xtermDarkTheme : xtermLightTheme
termRef.current.options.theme = newTheme
}, [theme])
同时终端容器的背景色也要跟着变:
<div style={{ backgroundColor: theme === 'dark' ? '#11111b' : '#fafafa' }}>
这样就实现了主题切换时终端的无缝过渡。
三、终端 Tab 拖拽排序
3.1 需求场景
多标签场景下,用户经常需要调整 Tab 顺序 —— 比如把常用的服务器放在左边。浏览器支持拖拽 Tab 排序,SSH 客户端也应该支持。
3.2 实现
后端 Store: 在 Zustand 的 TerminalTabState 中添加 reorderTabs 方法:
reorderTabs: (fromIndex, toIndex) =>
set((state) => {
const tabs = [...state.terminalTabs]
const [moved] = tabs.splice(fromIndex, 1)
tabs.splice(toIndex, 0, moved)
return { terminalTabs: tabs }
}),
核心就是数组的 splice 操作:先取出被拖拽的元素,再插入到目标位置。
前端 Tab 组件: 使用 HTML5 原生 Drag and Drop API:
<div
draggable
onDragStart={() => setDragIndex(index)}
onDragOver={(e) => { e.preventDefault(); setDragOverIndex(index) }}
onDrop={() => {
if (dragIndex !== null && dragIndex !== dragOverIndex) {
reorderTabs(dragIndex, dragOverIndex)
}
}}
onDragEnd={() => { setDragIndex(null); setDragOverIndex(null) }}
className={dragIndex === index ? 'opacity-50' : ''}
>
<span>{tab.label}</span>
</div>
拖拽时的视觉反馈:
- 被拖拽的 Tab 降低透明度(
opacity-50) - 目标位置显示蓝色竖线指示器
蓝色竖线的实现:在 Tab 之间条件渲染一个 div:
{dragOverIndex === index && dragIndex !== null && (
<div className="w-0.5 h-4 bg-primary-500 rounded-full mx-0.5" />
)}
四、键盘快捷键增强
4.1 新增快捷键
| 快捷键 | 功能 | 说明 |
|---|---|---|
Ctrl+1~9 |
切换终端 Tab | 类似浏览器 Tab 切换 |
Ctrl+W |
关闭当前 Tab | 类似浏览器关闭标签页 |
Ctrl+B |
切换侧栏 | 已有 |
Ctrl+Shift+P |
命令面板 | 已有 |
4.2 实现细节
在 App.tsx 的全局键盘事件中统一处理:
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+1~9 切换 Tab
if (e.ctrlKey && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key)
if (num >= 1 && num <= 9) {
e.preventDefault()
const tabs = useTerminalTabStore.getState().terminalTabs
if (num - 1 < tabs.length) {
useTerminalTabStore.getState().setActiveTerminalTab(tabs[num - 1].id)
}
return
}
}
// Ctrl+W 关闭当前 Tab
if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key === 'w') {
e.preventDefault()
const tabStore = useTerminalTabStore.getState()
if (tabStore.activeTerminalTabId) {
const tab = tabStore.terminalTabs.find(t => t.id === tabStore.activeTerminalTabId)
if (tab && !tab.id.endsWith('-main')) {
SSHDisconnect(tab.sessionId) // 非 main Tab 需要断开 SSH
}
tabStore.removeTerminalTab(tabStore.activeTerminalTabId)
}
return
}
}
注意点: Ctrl+W 关闭 Tab 时,如果关闭的不是主连接标签(-main 后缀),需要额外调用 SSHDisconnect 断开对应的 SSH session,避免资源泄漏。
五、命令面板扩展
5.1 新增命令
原来命令面板只有 5 个命令,扩展后增加了:
| 命令 | 快捷键 | 说明 |
|---|---|---|
| 断开所有连接 | Ctrl+Shift+D |
一键断开所有 SSH 会话 |
| 清屏当前终端 | - | 提示使用 clear 命令 |
| 切换文件面板 | Ctrl+` |
显示/隐藏文件管理器 |
5.2 断开所有连接的实现
这个功能在管理多台服务器时特别有用:
{
id: 'disconnect-all',
label: '断开所有连接',
shortcut: 'Ctrl+Shift+D',
category: '操作',
execute: () => {
const connStore = useConnectionStore.getState()
Object.keys(connStore.connections).forEach(serverId => {
connStore.removeConnection(serverId)
})
useUIStore.getState().setStatusMessage('已断开所有连接')
}
}
直接通过 Zustand Store 操作,遍历所有连接并逐个断开。
六、监控面板趋势图
6.1 问题
原来的监控面板只有实时数值条(CPU 30%、内存 45%),看不出变化趋势。用户无法判断「CPU 是刚升上去的,还是一直很高」。
6.2 SparkLine SVG 折线图
不引入任何图表库,用纯 SVG 实现迷你趋势图:
const SparkLine: React.FC<{ data: number[]; color: string; height?: number }> = ({ data, color, height = 28 }) => {
if (data.length < 2) return null
const max = 100, w = 100
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = height - (Math.min(v, max) / max) * height
return `${x},${y}`
}).join(' ')
return (
<svg viewBox={`0 0 ${w} ${height}`} className="w-full" style={{ height: `${height}px` }}>
<polyline fill="none" stroke={color} strokeWidth="1.5"
points={points} vectorEffect="non-scaling-stroke" />
</svg>
)
}
关键设计:
viewBox="0 0 100 28"固定坐标系,className="w-full"让 SVG 自适应容器宽度vectorEffect="non-scaling-stroke"保证线条粗细不随缩放变化- 最多保存 60 个数据点(约 2 分钟 @ 2s 间隔)
数据收集在监控事件回调中:
const usageHistoryRef = useRef<Array<{ cpu: number; mem: number }>>([])
const handleUsage = (data: any) => {
const u = data as ResourceUsage
setUsage(u)
const h = usageHistoryRef.current
h.push({ cpu: u.cpuPercent, mem: u.memPercent })
if (h.length > 60) h.shift() // 超过 60 条就移除最早的
}
切换连接时清空历史:
const resetData = useCallback(() => {
setSysInfo(null)
setUsage(null)
usageHistoryRef.current = [] // 清空历史
}, [])
七、服务器配置导入/导出
7.1 场景
用户有多台电脑(公司、家里),需要同步服务器配置。或者团队共享服务器列表。
7.2 后端实现
在 ConfigManager 中添加两个方法:
type ExportServersResponse struct {
Servers []ServerConfig `json:"servers"`
}
func (c *ConfigManager) ExportServers() (ExportServersResponse, error) {
rows, _ := c.repo.GetAll()
servers := make([]ServerConfig, len(rows))
for i, row := range rows {
servers[i] = rowToDecryptedConfig(row) // 解密后导出
}
return ExportServersResponse{Servers: servers}, nil
}
导入时跳过已存在的 ID,避免重复:
type ImportServersRequest struct {
Servers []ServerConfig `json:"servers"`
}
func (c *ConfigManager) ImportServers(req ImportServersRequest) (ImportServersResponse, error) {
imported := 0
for _, s := range req.Servers {
existing, _ := c.repo.GetByID(s.ID)
if existing != nil {
continue // 跳过已存在的
}
encryptSensitiveFields(&s) // 加密后存储
c.repo.Save(configToRow(s))
imported++
}
return ImportServersResponse{Success: true, Count: imported}, nil
}
7.3 前端交互
由于 Wails 绑定需要 wails dev 重新生成,而我们在没有运行 dev 模式时,采用了一个巧妙的方案:通过剪贴板中转。
导出:GetServers() → 清除敏感字段 → JSON.stringify → ClipboardSetText() → Toast 提示
导入:ClipboardGetText() → JSON.parse → 确认对话框 → 逐条 AddServer()
这样不需要新增后端方法就能完成导入导出,是一个务实的工程决策。
八、文件管理器拖拽上传
8.1 技术挑战
Web 环境中,浏览器的 File API 出于安全考虑,无法获取拖入文件的完整本地路径(只有文件名和 File 对象)。这对需要完整路径的 SFTP 上传来说是个问题。
8.2 解决方案
Wails 框架提供了 OnFileDrop 原生事件,可以获取到真实的文件路径。我们用 HTML5 拖拽事件负责视觉反馈,Wails 的 OnFileDrop 负责实际文件路径获取:
const [isDragOver, setIsDragOver] = useState(false)
const dragCounterRef = useRef(0)
// HTML5 拖拽事件:只负责视觉反馈
onDragEnter={(e) => {
e.preventDefault()
dragCounterRef.current++
setIsDragOver(true)
}}
onDragLeave={(e) => {
e.preventDefault()
dragCounterRef.current--
if (dragCounterRef.current === 0) setIsDragOver(false)
}}
// Wails 原生文件拖放:获取真实路径
OnFileDrop(func(paths []string) {
for _, localPath := range paths {
UploadFile({ sessionId, localPath, remotePath })
}
refreshRemoteFiles()
})
拖拽时的视觉反馈:蓝色半透明覆盖层 + “释放文件到此处上传” 提示文字。
dragCounterRef 的作用: 处理嵌套 DOM 元素的 dragEnter/dragLeave 闪烁问题。鼠标在子元素之间移动时,会触发父元素的 leave + 子元素的 enter,导致状态闪烁。用计数器 dragCounterRef 可以正确判断"真正离开"还是"在子元素间移动"。
九、连接超时可配置
9.1 问题
SSH 连接超时硬编码为 10 秒:
config := &sshcrypto.ClientConfig{
Timeout: 10 * time.Second, // 写死了
}
对于跨国服务器或网络不好的环境,10 秒可能不够。
9.2 全链路改造
数据库层: ALTER TABLE 添加字段
db.Exec("ALTER TABLE servers ADD COLUMN connect_timeout INTEGER DEFAULT 0")
用 ALTER TABLE 而不是修改建表 SQL,因为用户可能已有数据。
Go 类型层: 从 ServerRow 到 ServerConfig 到 ConnectRequest,逐层透传:
type ConnectRequest struct {
// ...
ConnectTimeout int `json:"connectTimeout"`
}
func (s *SSHService) Connect(req ConnectRequest) (ConnectResponse, error) {
timeout := 10 * time.Second
if req.ConnectTimeout > 0 {
timeout = time.Duration(req.ConnectTimeout) * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout + 5*time.Second)
config := &sshcrypto.ClientConfig{
Timeout: timeout,
}
// ...
}
context.WithTimeout 设为 timeout + 5s(留余量给 SSH 握手),config.Timeout 设为 timeout(TCP 连接超时)。
前端 UI: 在添加服务器对话框中增加超时输入框:
<div className="flex items-center gap-2">
<label className="text-xs text-text-dim w-16">连接超时</label>
<input
type="number"
className="input-field flex-1"
placeholder="秒(默认10)"
value={formData.connectTimeout || ''}
onChange={(e) => setFormData({
...formData,
connectTimeout: parseInt(e.target.value) || 0
})}
min={0} max={120}
/>
<span className="text-[10px] text-text-dim">秒</span>
</div>
十、优化总结
改动统计
| 类别 | 修改文件数 | 说明 |
|---|---|---|
| UI 重构 | 17 个前端文件 | CSS 变量、Tailwind 配置、全部组件样式 |
| 功能增强 | 10 个前后端文件 | 终端、监控、文件管理器、配置管理等 |
| 数据库 | 1 个迁移 | 新增 connect_timeout 列 |
| 总计 | 27 个文件 | 涉及 Go、TypeScript、CSS |
架构层面的经验
- CSS 变量先行:UI 重构不要从组件开始,先建立完整的 CSS 变量体系。变量定义了设计规范的边界,组件只需要消费变量
- 子代理并行开发:UI 重构的 11 个任务并行执行,通过统一的 CSS 变量保证一致性
- 渐进式数据库变更:永远用
ALTER TABLE增量修改,不要重建表。用户的已有数据比代码更珍贵 - 务实的技术选型:导入导出用剪贴板中转、趋势图用纯 SVG 实现、拖拽上传复用 Wails 原生事件 —— 不引入新依赖解决问题
下一步方向
v1.2 计划中:
- SSH 跳板机(ProxyJump)支持
- 终端录制/回放
- SFTP 文件预览(图片、文本)
- 多窗口支持
- 插件系统
结语
从 v1.0 到 v1.1 的迭代让我深刻体会到:「能用」和「好用」之间的差距,往往不在于技术难度,而在于对细节的关注。
iOS 风格的圆角、毛玻璃、柔和阴影,每个单独看都是小改动,但组合在一起就形成了完全不同的产品气质。终端主题跟随、Tab 拖拽、快捷键增强,每个都是用户「期望有」的功能,但只有你真正做了,用户才会觉得「这个工具懂我」。
项目地址:https://github.com/Julyos-rgb/wsShell
如果觉得不错,欢迎 Star ⭐ 支持一下!有问题欢迎在 GitHub Issues 中讨论交流!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)