项目地址: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.stringifyClipboardSetText() → 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 类型层:ServerRowServerConfigConnectRequest,逐层透传:

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

架构层面的经验

  1. CSS 变量先行:UI 重构不要从组件开始,先建立完整的 CSS 变量体系。变量定义了设计规范的边界,组件只需要消费变量
  2. 子代理并行开发:UI 重构的 11 个任务并行执行,通过统一的 CSS 变量保证一致性
  3. 渐进式数据库变更:永远用 ALTER TABLE 增量修改,不要重建表。用户的已有数据比代码更珍贵
  4. 务实的技术选型:导入导出用剪贴板中转、趋势图用纯 SVG 实现、拖拽上传复用 Wails 原生事件 —— 不引入新依赖解决问题

下一步方向

v1.2 计划中:

  • SSH 跳板机(ProxyJump)支持
  • 终端录制/回放
  • SFTP 文件预览(图片、文本)
  • 多窗口支持
  • 插件系统

结语

从 v1.0 到 v1.1 的迭代让我深刻体会到:「能用」和「好用」之间的差距,往往不在于技术难度,而在于对细节的关注。

iOS 风格的圆角、毛玻璃、柔和阴影,每个单独看都是小改动,但组合在一起就形成了完全不同的产品气质。终端主题跟随、Tab 拖拽、快捷键增强,每个都是用户「期望有」的功能,但只有你真正做了,用户才会觉得「这个工具懂我」。

项目地址:https://github.com/Julyos-rgb/wsShell

如果觉得不错,欢迎 Star ⭐ 支持一下!有问题欢迎在 GitHub Issues 中讨论交流!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐