Skip to content

Commit ce2cfe2

Browse files
committed
feat(SSH终端): 添加相关设计文档说明架构设计和实现细节
1 parent ba53d2d commit ce2cfe2

File tree

2 files changed

+258
-1
lines changed

2 files changed

+258
-1
lines changed
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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,确保向后兼容单实例场景

frontend/public/changelog.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"version": "3.0.8",
55
"date": "2026-03-08",
66
"changes": [
7-
"新增:AI 模板生成功能,支持基于 LLM 自动生成完整的场景模板(Terraform + case.json)",
7+
"新增:SSH 终端管理页面,支持多会话标签页切换,会话跨页面持久化",
8+
"新增:SSH 端口转发功能(类似 SSH LocalForward)",
89
"新增:AI Chat 多轮对话功能,支持生成模板、推荐场景、成本优化、自由对话四种模式,支持流式输出",
910
"升级:MCP 协议版本 2024-11-05 → 2025-11-25,新增 Streamable HTTP 端点 /mcp,支持版本协商与通知处理",
1011
"优化:win 下默认 redc.log 路径问题",

0 commit comments

Comments
 (0)