|
| 1 | +# 3.25 SSH 终端管理页面设计文档 |
| 2 | + |
| 3 | +## 概述 |
| 4 | + |
| 5 | +SSH 终端管理页面(SSH Manager)是 RedC GUI 中独立的标签页,提供多会话终端管理、文件管理、命令片段、端口转发等功能。设计上采用白色+红色风格,与 RedC 整体设计语言统一。 |
| 6 | + |
| 7 | +## 架构设计 |
| 8 | + |
| 9 | +### 组件关系 |
| 10 | + |
| 11 | +``` |
| 12 | +App.svelte |
| 13 | +├── SSHManager.svelte ← 始终挂载,不在 {#key} 或 {#if isLoading} 内 |
| 14 | +│ ├── FileManager.svelte ← 复用 Cases/ 下的文件管理组件(模态窗口) |
| 15 | +│ └── userdataTemplates.js ← 命令片段模板加载工具 |
| 16 | +``` |
| 17 | + |
| 18 | +### 持久化关键点 |
| 19 | + |
| 20 | +SSHManager 在 `App.svelte` 中的渲染位置至关重要: |
| 21 | + |
| 22 | +```svelte |
| 23 | +<!-- App.svelte 中的渲染方式 --> |
| 24 | +<!-- 必须在 {#if isLoading} 之外,否则 refreshData() 时会被销毁 --> |
| 25 | +<div style:display={activeTab === 'sshManager' && !isLoading ? 'block' : 'none'}> |
| 26 | + <SSHManager {t} onTabChange={(tab) => activeTab = tab} /> |
| 27 | +</div> |
| 28 | +
|
| 29 | +{#if isLoading} |
| 30 | + <!-- loading spinner --> |
| 31 | +{:else} |
| 32 | + {#key activeTab} |
| 33 | + <!-- 其他页面组件 --> |
| 34 | + {/key} |
| 35 | +{/if} |
| 36 | +``` |
| 37 | + |
| 38 | +**为什么必须这样放置:** |
| 39 | +- 后端场景启动/停止时会 emit `refresh` 事件 → 触发 `refreshData()` → `isLoading = true` |
| 40 | +- 如果 SSHManager 在 `{:else}` 分支内,`isLoading=true` 会销毁整个分支 → 所有终端会话丢失 |
| 41 | +- 使用 `display:none/block` 控制可见性,组件实例永不销毁 |
| 42 | + |
| 43 | +## 后端 API |
| 44 | + |
| 45 | +### Go 文件结构 |
| 46 | + |
| 47 | +| 文件 | 功能 | |
| 48 | +|------|------| |
| 49 | +| `app_ssh.go` | SSH 终端 API(启动、写入、调整、关闭、端口转发) | |
| 50 | +| `app_deployment_extra.go` | `getSSHConfig()` 统一获取 SSH 配置(Case + 自定义部署) | |
| 51 | +| `mod/case_ssh.go` | `GetSSHConfig()` / `GetSSHConfigs()` 多实例 SSH 配置 | |
| 52 | +| `mod/case.go` | `GetInstanceInfo()` / `GetInstanceInfoList()` output 值解析 | |
| 53 | + |
| 54 | +### 多实例支持 |
| 55 | + |
| 56 | +场景(如代理池)可能部署多台机器,Terraform output 的 IP 可能是字符串或字符串数组: |
| 57 | + |
| 58 | +```json |
| 59 | +// 单实例 |
| 60 | +{ "ecs_ip": { "value": "\"1.2.3.4\"" } } |
| 61 | + |
| 62 | +// 多实例 |
| 63 | +{ "ecs_ip": { "value": "[\"1.2.3.4\", \"5.6.7.8\"]" } } |
| 64 | +``` |
| 65 | + |
| 66 | +**解析逻辑 (`GetInstanceInfoList`):** |
| 67 | +1. 先尝试 `json.Unmarshal` 为 `string` → 返回 `[]string{str}` |
| 68 | +2. 再尝试 `json.Unmarshal` 为 `[]string` → 直接返回 |
| 69 | +3. 都失败则返回错误 |
| 70 | + |
| 71 | +**SSH 配置生成 (`GetSSHConfigs`):** |
| 72 | +- 获取所有 IP(数组) |
| 73 | +- 获取所有密码(数组) |
| 74 | +- 按索引配对:`ips[i]` ↔ `pwds[i]` |
| 75 | +- 如果密码只有一个,所有实例共用该密码 |
| 76 | +- 私钥路径和用户名所有实例共用 |
| 77 | + |
| 78 | +### 核心 API |
| 79 | + |
| 80 | +```go |
| 81 | +// 启动终端(兼容旧调用) |
| 82 | +func (a *App) StartSSHTerminal(caseID string, rows, cols int) (string, error) |
| 83 | + |
| 84 | +// 启动指定实例的终端(多实例场景) |
| 85 | +func (a *App) StartSSHTerminalInstance(caseID string, instanceIndex int, rows, cols int) (string, error) |
| 86 | + |
| 87 | +// 获取单实例 SSH 信息 |
| 88 | +func (a *App) GetSSHInfoForCase(caseID string) (map[string]interface{}, error) |
| 89 | + |
| 90 | +// 获取所有实例 SSH 信息(多实例) |
| 91 | +func (a *App) GetSSHInfosForCase(caseID string) ([]map[string]interface{}, error) |
| 92 | + |
| 93 | +// 端口转发 |
| 94 | +func (a *App) StartPortForward(caseID string, localPort int, remoteHost string, remotePort int) error |
| 95 | +func (a *App) StopPortForward(forwardID string) error |
| 96 | +func (a *App) ListPortForwards() []PortForwardInfo |
| 97 | +``` |
| 98 | + |
| 99 | +### Wails 事件 |
| 100 | + |
| 101 | +| 事件名 | 方向 | 数据 | |
| 102 | +|--------|------|------| |
| 103 | +| `terminal-output-{sessionID}` | 后端 → 前端 | `string` 终端输出数据 | |
| 104 | +| `terminal-error-{sessionID}` | 后端 → 前端 | `string` 错误信息 | |
| 105 | +| `terminal-closed-{sessionID}` | 后端 → 前端 | `bool` 连接关闭 | |
| 106 | +
|
| 107 | +## 前端实现 |
| 108 | +
|
| 109 | +### 状态管理(Svelte 5 runes) |
| 110 | +
|
| 111 | +```javascript |
| 112 | +// 会话列表 |
| 113 | +let sessions = $state([]); |
| 114 | +let activeSessionIndex = $state(-1); |
| 115 | + |
| 116 | +// 每个 session 对象 |
| 117 | +{ |
| 118 | + id: crypto.randomUUID(), // 前端唯一 ID |
| 119 | + caseId, // 场景/部署 ID |
| 120 | + caseName, // 显示名称 |
| 121 | + instanceIndex, // 实例索引(多实例场景) |
| 122 | + sessionId: null, // 后端返回的 session ID(用于事件监听) |
| 123 | + terminal: null, // xterm.js Terminal 实例 |
| 124 | + fitAddon: null, // xterm FitAddon |
| 125 | + containerEl: null, // DOM 容器元素 |
| 126 | + resizeObserver: null, // ResizeObserver |
| 127 | + connected: false, |
| 128 | + connecting: true, |
| 129 | + error: '', |
| 130 | + host, user // 显示用 |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +### Svelte 5 响应式陷阱 |
| 135 | + |
| 136 | +`bind:this={session.containerEl}` 在 `{#each}` 中设置的是**响应式代理对象**上的属性,而非原始 JS 对象。初始化终端时必须从 `sessions[idx]` 读取: |
| 137 | + |
| 138 | +```javascript |
| 139 | +sessions = [...sessions, session]; |
| 140 | +const idx = sessions.length - 1; |
| 141 | +activeSessionIndex = idx; |
| 142 | +await tick(); |
| 143 | +const reactiveSession = sessions[idx]; // ← 必须从数组重新读取 |
| 144 | +initSessionTerminal(reactiveSession); |
| 145 | +``` |
| 146 | + |
| 147 | +### 新建会话流程 |
| 148 | + |
| 149 | +``` |
| 150 | +用户点击 "+" 按钮 |
| 151 | + ↓ |
| 152 | +openNewSessionDialog() |
| 153 | + ↓ |
| 154 | +调用 ListCases() → 过滤 state === 'running' |
| 155 | + ↓ |
| 156 | +显示运行中场景列表(带类型标签:预定义/自定义) |
| 157 | + ↓ |
| 158 | +用户选择场景 |
| 159 | + ↓ |
| 160 | +调用 GetSSHInfosForCase(caseId) |
| 161 | + ├─ 单实例 → 直接 createSession() |
| 162 | + └─ 多实例 → 显示实例选择器 |
| 163 | + ├─ 点击单个实例 → createSession(caseId, label, index, hostInfo) |
| 164 | + └─ 点击「全部连接」→ 循环 createSession() 为每个实例创建标签 |
| 165 | + ↓ |
| 166 | +也可点击「手动输入 ID」→ 传统的 caseId + displayName 输入表单 |
| 167 | +``` |
| 168 | + |
| 169 | +### 终端 → AI 对话功能 |
| 170 | + |
| 171 | +``` |
| 172 | +SSHManager: terminal.onSelectionChange() |
| 173 | + ↓ 用户选中文本 |
| 174 | +显示浮动「发送到 AI 分析」按钮 |
| 175 | + ↓ 用户点击 |
| 176 | +sendToAIChat(): |
| 177 | + 1. localStorage.setItem('ai-chat-pending-terminal', text) |
| 178 | + 2. onTabChange?.('aiChat') // 切换标签页 |
| 179 | + ↓ |
| 180 | +AIChat 组件重新创建(在 {#key activeTab} 内) |
| 181 | + ↓ |
| 182 | +onMount → checkPendingTerminalText(): |
| 183 | + 1. 读取 localStorage |
| 184 | + 2. 清除 localStorage |
| 185 | + 3. 切换到自由对话模式 |
| 186 | + 4. 预填 inputText: "请帮我分析以下终端输出内容:\n```\n{text}\n```" |
| 187 | +``` |
| 188 | + |
| 189 | +**注意:** `window.addEventListener('storage')` 只对跨窗口生效,同窗口的 localStorage 写入不会触发。主要机制是 AIChat 在 `{#key activeTab}` 内,切换标签时重新创建 → `onMount` 执行 → 读取 localStorage。 |
| 190 | + |
| 191 | +### 关闭所有连接 |
| 192 | + |
| 193 | +- 仅当 `sessions.length > 1` 时显示关闭按钮 |
| 194 | +- 点击后弹出确认对话框,防止误操作 |
| 195 | +- 确认后遍历所有 session 调用 `cleanupSession()` |
| 196 | + |
| 197 | +### 标签栏布局 |
| 198 | + |
| 199 | +使用 `flex-wrap` 实现满行自动换行: |
| 200 | + |
| 201 | +```html |
| 202 | +<div class="flex flex-wrap items-center min-h-[40px] py-1 gap-1"> |
| 203 | + {#each sessions as session, i} |
| 204 | + <!-- 每个标签 max-w-[200px] --> |
| 205 | + {/each} |
| 206 | +</div> |
| 207 | +``` |
| 208 | + |
| 209 | +## 右侧面板 |
| 210 | + |
| 211 | +通过 `rightPanel` 状态切换:`'none' | 'portForward' | 'userdata'` |
| 212 | + |
| 213 | +### 命令片段面板 |
| 214 | + |
| 215 | +- 调用 `loadUserdataTemplates()` 加载本地 userdata 模板 |
| 216 | +- 按分类(`userdataCategoryNames`)折叠分组显示 |
| 217 | +- 点击模板显示脚本预览 |
| 218 | +- 「上传并执行」调用 `UploadUserdataScript` Wails 绑定上传到远程 |
| 219 | +- 空状态显示引导提示 + 跳转模板仓库按钮 |
| 220 | + |
| 221 | +### 端口转发面板 |
| 222 | + |
| 223 | +- 活跃会话时自动显示当前会话信息,无需手动输入 caseId |
| 224 | +- 无活跃会话时回退到手动输入 |
| 225 | +- 远程主机默认 `127.0.0.1` |
| 226 | +- 已有转发列表支持单个停止 |
| 227 | + |
| 228 | +## 设计规范 |
| 229 | + |
| 230 | +### 配色方案 |
| 231 | + |
| 232 | +| 元素 | 样式 | |
| 233 | +|------|------| |
| 234 | +| 页面背景 | `bg-white` | |
| 235 | +| 标签栏背景 | `bg-gray-50` | |
| 236 | +| 活跃标签 | `bg-white border-gray-200 shadow-sm` | |
| 237 | +| 非活跃标签 | `text-gray-500 hover:bg-gray-100` | |
| 238 | +| 主按钮 | `bg-red-600 hover:bg-red-700 text-white` | |
| 239 | +| 工具栏激活 | `bg-red-50 text-red-600` | |
| 240 | +| 输入框焦点 | `focus:ring-red-500` | |
| 241 | +| 连接状态 | 已连接 `bg-emerald-500` / 连接中 `bg-amber-500 animate-pulse` / 断开 `bg-gray-400` | |
| 242 | +| 终端画布 | `bg-gray-900`(保持深色以确保可读性) | |
| 243 | + |
| 244 | +### Wails 绑定文件 |
| 245 | + |
| 246 | +新增绑定需同时添加到两个文件(历史原因,双层嵌套路径): |
| 247 | +- `frontend/wailsjs/go/main/App.js` |
| 248 | +- `frontend/wailsjs/wailsjs/go/main/App.js` |
| 249 | + |
| 250 | +## 维护注意事项 |
| 251 | + |
| 252 | +1. **不要将 SSHManager 移入 `{#if isLoading}` 或 `{#key activeTab}` 块中**,否则会导致页面刷新或标签切换时所有终端会话丢失 |
| 253 | +2. **新增后端 API 后需要手动添加 Wails 绑定**到两个 `App.js` 文件 |
| 254 | +3. **Svelte 5 中不能嵌套 `<button>`**,关闭按钮使用 `<span role="button">` 替代 |
| 255 | +4. **多实例场景的密码配对**:按索引对应,如果密码数量为 1 则所有实例共用 |
| 256 | +5. **`createSession` 的 `instanceIndex` 参数**:默认值为 0,确保向后兼容单实例场景 |
0 commit comments