refactor(terminal): split TerminalPage into composables and components (T1-T6)
Extract session/WS/xterm logic with markRaw native store, per-session disconnect overlay, shared server picker, and cmd bar via ShellHighlightField expose. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# 2026-06-02 — 终端页重构 T1–T5
|
||||
|
||||
## 摘要
|
||||
|
||||
将 `TerminalPage.vue`(约 1244 行单文件)拆分为 Composable + 6 个子组件,修复 markRaw、per-session overlay、计时器泄漏风险,并去除命令栏 DOM 查询与重复服务器选择器。
|
||||
|
||||
## 动机
|
||||
|
||||
- xterm/WebSocket/ResizeObserver 被 Vue 深度代理导致潜在渲染异常与内存问题(C1)
|
||||
- 断开 overlay 为全局状态,切换 Tab 时非活动会话断开无提示(C2)
|
||||
- Uptime 计时器存于外部 Map,清理路径不一致(C3)
|
||||
- 单文件不可维护,命令栏依赖 CSS 类名查询 DOM(M1/M2)
|
||||
- 空状态与对话框重复实现服务器列表(M3)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
### 新建
|
||||
|
||||
- `frontend/src/composables/terminal/types.ts`
|
||||
- `frontend/src/composables/terminal/termNativeStore.ts`
|
||||
- `frontend/src/composables/terminal/useTerminalSettings.ts`
|
||||
- `frontend/src/composables/terminal/useTerminalCmdBar.ts`
|
||||
- `frontend/src/composables/terminal/useTerminalSessions.ts`
|
||||
- `frontend/src/components/terminal/TerminalTabBar.vue`
|
||||
- `frontend/src/components/terminal/TerminalToolbar.vue`
|
||||
- `frontend/src/components/terminal/TerminalArea.vue`
|
||||
- `frontend/src/components/terminal/TerminalCmdBar.vue`
|
||||
- `frontend/src/components/terminal/TerminalQuickBar.vue`
|
||||
- `frontend/src/components/terminal/TerminalServerPicker.vue`
|
||||
|
||||
### 修改
|
||||
|
||||
- `frontend/src/pages/TerminalPage.vue` — 组合层(约 280 行,含样式)
|
||||
- `frontend/src/components/ShellHighlightField.vue` — `defineExpose` 供命令栏复制/粘贴
|
||||
|
||||
### 设计文档
|
||||
|
||||
- `docs/design/specs/2026-06-02-terminal-refactor-design.md`
|
||||
- `docs/design/plans/2026-06-02-terminal-refactor.md`
|
||||
|
||||
## 关键实现
|
||||
|
||||
| 阶段 | 内容 |
|
||||
|------|------|
|
||||
| T1 | `termNativeStore` + `markRaw` 存放 xterm/FitAddon/ResizeObserver;uptime/ping/reconnect 计时器归入 native |
|
||||
| T2 | 每会话 `showOverlay` / `overlayMsg` / `reconnectCountdown`;关闭末 Tab 显示空状态而非 overlay |
|
||||
| T3 | `useTerminalSettings`、`useTerminalCmdBar`、`useTerminalSessions` |
|
||||
| T4 | 6 个 `components/terminal/*` 子组件 |
|
||||
| T5 | `TerminalCmdBar` 经 `ShellHighlightField` expose 操作输入;`TerminalServerPicker` 统一 inline/dialog |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 仅前端:需 `npm run build` 并部署 `web/app/` 静态资源
|
||||
- 后端无变更,无需重启 `nexus`
|
||||
|
||||
## T6 补充(2026-06-02)
|
||||
|
||||
- 快捷命令 API 失败时 `snackbar` 提示后降级内置命令
|
||||
- 全局快捷键 `Ctrl++`/`Ctrl+=` 合并为单分支并 `return`,避免重复触发
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build-only
|
||||
# 浏览器:/app/terminal — 新建会话、多 Tab、断网重连 overlay、命令栏复制粘贴、空状态选服务器
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
```bash
|
||||
git checkout HEAD~1 -- frontend/src/pages/TerminalPage.vue frontend/src/components/ShellHighlightField.vue
|
||||
# 删除 frontend/src/composables/terminal/ 与 frontend/src/components/terminal/
|
||||
```
|
||||
@@ -0,0 +1,218 @@
|
||||
# Terminal 页重构技术计划
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**对应设计文档**: `docs/design/specs/2026-06-02-terminal-refactor-design.md`
|
||||
**阶段编号**: T1 ~ T6
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件清单
|
||||
|
||||
### 新建文件
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/src/composables/terminal/useTerminalSession.ts` | 单会话 xterm + WS 生命周期 |
|
||||
| `frontend/src/composables/terminal/useTerminalSessions.ts` | 多会话 Tab 管理 |
|
||||
| `frontend/src/composables/terminal/useTerminalSettings.ts` | fontSize/scrollback localStorage 持久化 |
|
||||
| `frontend/src/composables/terminal/useTerminalCmdBar.ts` | 命令输入 + 历史记录 |
|
||||
| `frontend/src/components/terminal/TerminalTabBar.vue` | Tab 条 |
|
||||
| `frontend/src/components/terminal/TerminalToolbar.vue` | 工具栏(状态/字号/断开) |
|
||||
| `frontend/src/components/terminal/TerminalArea.vue` | xterm 挂载区 + per-session overlay |
|
||||
| `frontend/src/components/terminal/TerminalCmdBar.vue` | 命令栏 + 快捷键面板 |
|
||||
| `frontend/src/components/terminal/TerminalQuickBar.vue` | 快捷命令栏 |
|
||||
| `frontend/src/components/terminal/TerminalServerPicker.vue` | 服务器选择(inline + dialog 两种模式) |
|
||||
|
||||
### 修改文件
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `frontend/src/pages/TerminalPage.vue` | 瘦身至 ~130 行,仅组合上述组件 |
|
||||
|
||||
### 不改动文件
|
||||
| 文件 | 原因 |
|
||||
|------|------|
|
||||
| `server/api/webssh.py` | 后端逻辑已完善,协议不变 |
|
||||
| `frontend/src/composables/useTerminalQuickCommands.ts` | 已结构良好,T6 仅小调整 |
|
||||
| `frontend/src/components/ShellHighlightField.vue` | 无需变更 |
|
||||
| `frontend/src/utils/wsUrl.ts` | 无需变更 |
|
||||
|
||||
---
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### T1:markRaw 修复 + Uptime 计时器内嵌(Critical 修复)
|
||||
|
||||
**目标**:解决 C1(Vue 代理 xterm/WebSocket)和 C3(Uptime timer 外部 Map)
|
||||
|
||||
**步骤**:
|
||||
1. 在 `TerminalPage.vue` 中,将 `TermSession` interface 拆分:
|
||||
- 新增 `TermSessionNative` type,字段:`ws / term / fitAddon / resizeObserver / reconnectTimer / pingTimer / uptimeTimer`
|
||||
- 新增 module-level `const nativeMap = new Map<string, TermSessionNative>()`
|
||||
- 从 `TermSession` interface 中删除上述字段
|
||||
2. 所有原生对象创建时包裹 `markRaw()`:
|
||||
- `term = markRaw(new Terminal(...))`
|
||||
- `fitAddon = markRaw(new FitAddon())`
|
||||
- `resizeObserver = markRaw(new ResizeObserver(...))`
|
||||
- WebSocket 无需 markRaw(已在 connectSession 内部使用,不进 reactive)
|
||||
3. 将 `_uptimeTimers` Map 删除,uptimeTimer 改存入 `nativeMap[sessionId].uptimeTimer`
|
||||
4. 更新 `closeTab` 和 `onBeforeUnmount` 中的清理逻辑,从 `nativeMap` 获取 native 对象后清理
|
||||
|
||||
**验收**:Vue DevTools → Components 面板,`sessions` ref 中不出现 Terminal/WebSocket/FitAddon 对象
|
||||
|
||||
---
|
||||
|
||||
### T2:Per-Session Overlay(Critical 修复)
|
||||
|
||||
**目标**:解决 C2(全局 overlay 状态)
|
||||
|
||||
**步骤**:
|
||||
1. 在 `TermSession` interface 中新增字段:
|
||||
```typescript
|
||||
showOverlay: boolean // 替代全局 showDisconnectOverlay
|
||||
overlayMsg: string // 替代全局 disconnectMsg
|
||||
reconnectCountdown: string // 移入 session
|
||||
```
|
||||
2. 删除全局 `showDisconnectOverlay`、`disconnectMsg`、`reconnectCountdown` refs
|
||||
3. 在 `connectSession`、`startReconnect`、`disconnect`、`closeTab` 中,将原来操作全局 ref 的代码改为操作 `s.showOverlay / s.overlayMsg / s.reconnectCountdown`
|
||||
4. 模板中的 `<v-overlay>` 绑定改为 `activeSession?.showOverlay`
|
||||
5. 关闭最后一个 Tab 时,不显示 overlay,让空状态区域自然显示(删除 `showDisconnectOverlay = true` 的 closeTab 逻辑)
|
||||
|
||||
**验收**:
|
||||
- 打开 2 个会话,让会话 2 断开,切换到会话 2,overlay 显示
|
||||
- 关闭最后一个会话,出现空状态服务器选择器,不出现 overlay
|
||||
|
||||
---
|
||||
|
||||
### T3:提取 Composables
|
||||
|
||||
**目标**:解决 M1(单文件过大),提取 4 个 Composable
|
||||
|
||||
#### T3-A:`useTerminalSettings`
|
||||
|
||||
```typescript
|
||||
// 仅管理 fontSize / scrollback 的 localStorage 持久化
|
||||
export function useTerminalSettings() {
|
||||
const fontSize = ref(...)
|
||||
const scrollback = ref(...)
|
||||
function changeFontSize(delta: number): void
|
||||
function changeScrollback(val: number): void
|
||||
return { fontSize, scrollback, scrollbackOptions, changeFontSize, changeScrollback }
|
||||
}
|
||||
```
|
||||
|
||||
#### T3-B:`useTerminalCmdBar`
|
||||
|
||||
```typescript
|
||||
// 管理命令输入、历史记录
|
||||
export function useTerminalCmdBar(getActiveSession: () => TermSession | null) {
|
||||
const cmdInput = ref('')
|
||||
function sendCmd(): void
|
||||
function cmdHistoryUp(): void
|
||||
function cmdHistoryDown(): void
|
||||
function sendCtrl(key: string): void
|
||||
function sendKey(key: string): void
|
||||
return { cmdInput, sendCmd, cmdHistoryUp, cmdHistoryDown, sendCtrl, sendKey }
|
||||
}
|
||||
```
|
||||
|
||||
#### T3-C:`useTerminalSessions`
|
||||
|
||||
核心:多 Tab 管理,包含 `sessions / activeSessionId / activeSession / newSession / closeTab / switchTab / applyRouteQuery`
|
||||
|
||||
#### T3-D:`useTerminalSession`
|
||||
|
||||
核心:单会话连接 + 重连 + ping + uptime,参数为 `sessionData` ref 和 `nativeMap`
|
||||
|
||||
**注意**:T3-C/D 是最大改动,需完整测试后再提交
|
||||
|
||||
---
|
||||
|
||||
### T4:拆分 Components
|
||||
|
||||
**目标**:消除模板耦合,每个组件职责单一
|
||||
|
||||
| 组件 | Props | Emits |
|
||||
|------|-------|-------|
|
||||
| `TerminalTabBar` | `sessions, activeSessionId` | `switch, close, new` |
|
||||
| `TerminalToolbar` | `session, fontSize, scrollback` | `font-change, scrollback-change, disconnect, fullscreen` |
|
||||
| `TerminalArea` | `sessions, activeSessionId` | `context-menu` |
|
||||
| `TerminalCmdBar` | `disabled` | `send, ctrl, key` |
|
||||
| `TerminalQuickBar` | `commands` | `exec, manage` |
|
||||
| `TerminalServerPicker` | `servers, mode` | `select` |
|
||||
|
||||
`TerminalPage.vue` 仅负责组合,约 80 行模板 + 30 行 setup。
|
||||
|
||||
---
|
||||
|
||||
### T5:修复 M2(DOM 查询) + M3(服务器选择器去重)
|
||||
|
||||
**M2 修复**:
|
||||
- `TerminalCmdBar.vue` 组件内部声明 `const inputRef = ref<HTMLTextAreaElement | null>(null)`
|
||||
- 将 `<ShellHighlightField>` 替换为绑定 ref 的原生 textarea(或 ShellHighlightField 补充 expose)
|
||||
- 删除 `getCmdInputEl()` 函数
|
||||
|
||||
**M3 修复**:
|
||||
- 新建 `TerminalServerPicker.vue`,props: `mode: 'inline' | 'dialog'`
|
||||
- 空状态区域使用 `<TerminalServerPicker mode="inline">`
|
||||
- 新建会话对话框内容使用 `<TerminalServerPicker mode="dialog">`
|
||||
- 删除模板中的重复列表代码
|
||||
|
||||
---
|
||||
|
||||
### T6:Minor 体验修复
|
||||
|
||||
**E1 修复**(向同一服务器新建会话):
|
||||
- `newSession` 中检测到同 serverId 时,弹出 snackbar:「会话已存在,已切换过去。如需新建请关闭原会话。」
|
||||
- 提供一个可选 `opts.forceNew: boolean` 参数供未来扩展
|
||||
|
||||
**E2 修复**(初始 cd 指令时序):
|
||||
- 将 `sendCdToSession` 从 `setTimeout(200)` 改为在 `TERMINAL_INIT` 消息处理中直接发送,不依赖超时
|
||||
|
||||
**E3 修复**(重复快捷键):
|
||||
- 删除 `onKeydown` 中重复的 `e.key === '='` 分支,只保留 `e.key === '+'`(标准 Chromium 行为)
|
||||
|
||||
**E4 修复**(快捷命令加载失败提示):
|
||||
- `loadQuickCmds` catch 块改为:`snackbar('快捷命令加载失败,使用内置命令', 'warning')` 后再赋值内置
|
||||
|
||||
---
|
||||
|
||||
## 依赖与配置变更
|
||||
|
||||
- 无新 npm 包依赖
|
||||
- 无 Vue Router / Pinia 结构变更
|
||||
- xterm 版本不变(@xterm/xterm)
|
||||
|
||||
---
|
||||
|
||||
## 测试要点
|
||||
|
||||
| 场景 | 验证方式 |
|
||||
|------|----------|
|
||||
| markRaw 生效 | Vue DevTools sessions ref 无 Terminal 实例 |
|
||||
| 多 Tab 断开 overlay | 手动断网,切换 Tab,overlay 正确显示 |
|
||||
| 最后 Tab 关闭 | 显示空状态选择器,不显示 overlay |
|
||||
| xterm resize | 拖拽侧栏/改字号,xterm 正确填充容器 |
|
||||
| reconnect 指数退避 | 断连后等待,验证 1s/2s/4s 重连间隔 |
|
||||
| 命令历史 | 发送命令,↑/↓ 正确回溯 |
|
||||
| 快捷命令 | 点击执行,命令发送到活动终端 |
|
||||
| pendingPath 跳转 | `/terminal?server_id=1&path=/var/log` 打开后自动 cd |
|
||||
| T1-T6 全部功能回归 | 手动端到端测试所有功能 |
|
||||
|
||||
---
|
||||
|
||||
## 回滚方式
|
||||
|
||||
全程在功能分支 `refactor/terminal` 开发,main 分支不受影响。
|
||||
任何阶段出现严重问题,直接 `git checkout main -- frontend/src/pages/TerminalPage.vue` 回滚到重构前。
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
T1(markRaw + timer, 1h) → T2(per-session overlay, 1h)
|
||||
→ T6(minor fixes, 0.5h)
|
||||
→ T3(composables, 2h)→ T4(components, 2h)→ T5(dom + picker, 0.5h)
|
||||
→ 全量测试 → changelog → 部署
|
||||
```
|
||||
|
||||
建议 T1+T2+T6 作为第一批次(安全稳定性),T3+T4+T5 作为第二批次(架构重构)分两次 PR 合并。
|
||||
@@ -0,0 +1,220 @@
|
||||
# Terminal 页重构设计文档
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**版本**: v1.0
|
||||
**状态**: 待审批
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 现状问题
|
||||
|
||||
`frontend/src/pages/TerminalPage.vue` 是一个 **1244 行的单文件组件**,包含:会话管理、WebSocket 生命周期、xterm.js 实例管理、命令栏、快捷命令、上下文菜单、键盘快捷键、服务器列表、Uptime 计时、Ping 检测等所有逻辑。
|
||||
|
||||
经过逐行审计,发现以下 **分级问题**:
|
||||
|
||||
#### 🔴 Critical(可能导致 Bug 或内存泄漏)
|
||||
|
||||
| # | 问题 | 位置 | 影响 |
|
||||
|---|------|------|------|
|
||||
| C1 | **`markRaw` 缺失** | `TermSession.term/fitAddon/ws/resizeObserver` | Vue 对 xterm Terminal、WebSocket、ResizeObserver 进行深度代理,导致 xterm 内部状态被破坏,可能出现渲染异常、内存泄漏 |
|
||||
| C2 | **全局 overlay 状态** | `showDisconnectOverlay`、`reconnectCountdown` | 这两个 ref 是全局单例,仅当 *当前活动会话* 断开时才更新。切换到断开的非活动会话时,不显示 overlay,用户以为连接正常 |
|
||||
| C3 | **Uptime 计时器独立 Map** | `_uptimeTimers` | 模块级 Map 与 session 对象分离,`closeTab` 和 `onBeforeUnmount` 都需要单独清理;若路径不一致会造成 timer 泄漏 |
|
||||
|
||||
#### 🟡 Medium(架构质量问题)
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| M1 | **单文件 1244 行** | TerminalPage.vue | 不可维护,无法单独测试 |
|
||||
| M2 | **脆弱 DOM 查询** | `getCmdInputEl()` | 依赖 CSS class `.cmd-bar--tall .sh-field__input--multi`,class 名变动即失效 |
|
||||
| M3 | **重复服务器选择器 UI** | 空状态内嵌列表 + `showServerDialog` 对话框 | 同一套 UI 实现了两遍,联动逻辑分散 |
|
||||
| M4 | **会话 interface 类型混乱** | `TermSession` | 数据字段(serverId, cwd)与 UI 状态(status, uptime, pingMs)与原生对象(ws, term, fitAddon)混在一个 interface |
|
||||
|
||||
#### 🟢 Minor(体验问题)
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| E1 | 向同一服务器新建会话时直接 `switchTab`,没有给用户选择 |
|
||||
| E2 | `sendCdToSession` 使用 200ms `setTimeout` 等 TERMINAL_INIT,时序不可靠 |
|
||||
| E3 | Ctrl+= 和 Ctrl++ 两个快捷键重复注册 |
|
||||
| E4 | 快捷命令加载失败时静默降级为内置命令,不通知用户 |
|
||||
|
||||
### 目标
|
||||
|
||||
1. 消除 C1-C3 三个 Critical 问题(安全稳定性保障)
|
||||
2. 将 TerminalPage.vue 拆解为可独立维护的组件树
|
||||
3. 提取可复用 Composable,可单独 Vitest 测试
|
||||
4. 修复 M2/M3 架构问题
|
||||
5. 修复 E1-E4 体验问题
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案选型
|
||||
|
||||
### 方案 A:局部修补(不推荐)
|
||||
|
||||
只修 C1-C3,不重构架构。
|
||||
**缺点**:1244 行单文件继续增长,下次迭代仍然面对同样复杂度。
|
||||
|
||||
### 方案 B:完全拆分 + Composable + Pinia(推荐)
|
||||
|
||||
按职责提取 Composable,`TerminalPage.vue` 只负责组合,保留约 80 行模板 + 30 行脚本。
|
||||
|
||||
---
|
||||
|
||||
## 3. 选定方案:方案 B
|
||||
|
||||
### 3.1 目标文件树
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── pages/
|
||||
│ └── TerminalPage.vue (~80 行,组合层)
|
||||
├── composables/terminal/
|
||||
│ ├── useTerminalSession.ts (单会话 xterm+WS 生命周期)
|
||||
│ ├── useTerminalSessions.ts (多会话 Tab 管理)
|
||||
│ ├── useTerminalSettings.ts (fontSize/scrollback localStorage)
|
||||
│ └── useTerminalCmdBar.ts (命令输入 + 历史)
|
||||
└── components/terminal/
|
||||
├── TerminalTabBar.vue (Tab 条)
|
||||
├── TerminalToolbar.vue (工具栏: 状态/字号/断开)
|
||||
├── TerminalArea.vue (xterm 挂载区 + overlay)
|
||||
├── TerminalCmdBar.vue (命令栏 + 快捷键面板)
|
||||
├── TerminalQuickBar.vue (快捷命令栏)
|
||||
└── TerminalServerPicker.vue (服务器选择 — 复用于空状态和新建对话框)
|
||||
```
|
||||
|
||||
### 3.2 核心数据模型重设计
|
||||
|
||||
```typescript
|
||||
// 分离「纯数据」和「原生对象」
|
||||
interface TermSessionData {
|
||||
id: string
|
||||
serverId: number
|
||||
serverName: string
|
||||
cwd: string
|
||||
cmdHistory: string[]
|
||||
cmdIdx: number
|
||||
reconnectAttempt: number
|
||||
manualDisconnect: boolean
|
||||
status: 'idle' | 'connecting' | 'connected' | 'error'
|
||||
// Per-session UI state (replaces global overlay)
|
||||
showOverlay: boolean
|
||||
overlayMsg: string
|
||||
reconnectCountdown: string
|
||||
uptime: string
|
||||
pingMs: number | null
|
||||
connectionStartTime: number
|
||||
pendingPath: string
|
||||
initialCdSent: boolean
|
||||
}
|
||||
|
||||
interface TermSessionNative {
|
||||
id: string
|
||||
ws: WebSocket | null
|
||||
term: Terminal | null // markRaw
|
||||
fitAddon: FitAddon | null // markRaw
|
||||
resizeObserver: ResizeObserver | null // markRaw
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||
pingTimer: ReturnType<typeof setInterval> | null
|
||||
uptimeTimer: ReturnType<typeof setInterval> | null // 移入 session,不再用外部 Map
|
||||
}
|
||||
```
|
||||
|
||||
> **关键**:`TermSessionNative` 中所有字段使用 `markRaw()`,不放入 Vue reactive 系统。
|
||||
> `TermSessionData` 放入 `ref<TermSessionData[]>`,驱动模板响应式。
|
||||
|
||||
### 3.3 Per-Session Overlay(修复 C2)
|
||||
|
||||
每个会话自带 `showOverlay / overlayMsg / reconnectCountdown`,`TerminalArea.vue` 根据 `activeSession.showOverlay` 渲染 overlay,切换 Tab 时自然显示/隐藏对应状态。
|
||||
|
||||
### 3.4 markRaw 应用(修复 C1)
|
||||
|
||||
```typescript
|
||||
// useTerminalSession.ts
|
||||
const native = {
|
||||
ws: null as WebSocket | null,
|
||||
term: markRaw(new Terminal(options)),
|
||||
fitAddon: markRaw(new FitAddon()),
|
||||
resizeObserver: markRaw(new ResizeObserver(...)),
|
||||
}
|
||||
```
|
||||
|
||||
原生对象不进入任何 `ref`/`reactive`,仅通过 session ID 从一个 `Map<string, TermSessionNative>` 查找。
|
||||
|
||||
### 3.5 服务器选择器去重(修复 M3)
|
||||
|
||||
`TerminalServerPicker.vue` 接受 `prop: mode = 'inline' | 'dialog'`,内部渲染列表,空状态和新建对话框均复用,消除重复代码。
|
||||
|
||||
### 3.6 getCmdInputEl 重构(修复 M2)
|
||||
|
||||
`TerminalCmdBar.vue` 直接持有 `textarea` ref,通过 `defineExpose({ getInputEl })` 或直接在组件内处理复制/粘贴,不再依赖全局 DOM 查询。
|
||||
|
||||
---
|
||||
|
||||
## 4. 拆分阶段
|
||||
|
||||
| 阶段 | 内容 | 风险 |
|
||||
|------|------|------|
|
||||
| **T1** | markRaw 修复(C1)+ Uptime 计时器内嵌(C3) | 低,仅内部实现变化 |
|
||||
| **T2** | Per-session overlay(C2)+ 修复 E1/E2 | 中,需重构状态管理 |
|
||||
| **T3** | 提取 Composables(4 个)| 中,逻辑搬移,行为不变 |
|
||||
| **T4** | 拆分 Components(6 个)| 低,模板拆分 |
|
||||
| **T5** | 修复 M2/M3(DOM查询+服务器选择器去重)| 低 |
|
||||
| **T6** | 修复 E3/E4(快捷键去重+快捷命令错误提示)| 低 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 接口设计(关键部分)
|
||||
|
||||
### `useTerminalSessions`
|
||||
|
||||
```typescript
|
||||
function useTerminalSessions() {
|
||||
const sessions: Ref<TermSessionData[]>
|
||||
const activeSessionId: Ref<string>
|
||||
const activeSession: ComputedRef<TermSessionData | null>
|
||||
|
||||
function newSession(serverId: number, opts?: { path?: string }): Promise<void>
|
||||
function closeTab(sessionId: string): Promise<void>
|
||||
function switchTab(sessionId: string): Promise<void>
|
||||
|
||||
return { sessions, activeSessionId, activeSession, newSession, closeTab, switchTab }
|
||||
}
|
||||
```
|
||||
|
||||
### `useTerminalSession`
|
||||
|
||||
```typescript
|
||||
function useTerminalSession(sessionData: TermSessionData, container: Ref<HTMLElement | null>) {
|
||||
function connect(): Promise<void>
|
||||
function disconnect(manual?: boolean): void
|
||||
function reconnect(): void
|
||||
function sendData(data: string): void
|
||||
function sendResize(cols: number, rows: number): void
|
||||
function fit(): void
|
||||
function dispose(): void
|
||||
|
||||
return { connect, disconnect, reconnect, sendData, sendResize, fit, dispose }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全与性能约束
|
||||
|
||||
- 不改动 `server/api/webssh.py`(后端已符合要求)
|
||||
- `markRaw` 修复后 xterm 对象不再被 Vue 深度观测,内存占用降低
|
||||
- 多 Tab 场景下每个 Tab 的 Ping 计时器独立,不互相干扰
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] Vue DevTools 中 xterm Terminal / WebSocket 对象不可见(markRaw 生效)
|
||||
- [ ] 多 Tab 场景:切换到断开的 Tab 时显示 overlay
|
||||
- [ ] 关闭最后一个 Tab:显示空状态服务器选择器(非断开 overlay)
|
||||
- [ ] `TerminalPage.vue` 模板 ≤ 100 行,script ≤ 50 行
|
||||
- [ ] Vitest 可对 `useTerminalSettings`、`useTerminalCmdBar` 单独测试
|
||||
- [ ] 页面功能无回归:xterm 正常渲染、WS 连接、resize、reconnect、快捷命令
|
||||
@@ -16,6 +16,7 @@
|
||||
/>
|
||||
<component
|
||||
:is="multiline ? 'textarea' : 'input'"
|
||||
ref="inputRef"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@@ -31,9 +32,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import { highlightShellToHtml } from '@/utils/shellHighlight'
|
||||
|
||||
const inputRef = ref<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
@@ -67,6 +70,48 @@ function onInput(e: Event) {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
emit('keydown', e)
|
||||
}
|
||||
|
||||
function getInputEl(): HTMLInputElement | HTMLTextAreaElement | null {
|
||||
return inputRef.value
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
function getSelectionText(): string {
|
||||
const el = inputRef.value
|
||||
if (!el) return ''
|
||||
return el.value.substring(el.selectionStart ?? 0, el.selectionEnd ?? 0)
|
||||
}
|
||||
|
||||
function selectAllInput() {
|
||||
const el = inputRef.value
|
||||
if (!el) return
|
||||
el.focus()
|
||||
el.select()
|
||||
}
|
||||
|
||||
async function insertAtSelection(text: string) {
|
||||
const el = inputRef.value
|
||||
if (!el) return
|
||||
const start = el.selectionStart ?? el.value.length
|
||||
const end = el.selectionEnd ?? start
|
||||
const next = el.value.slice(0, start) + text + el.value.slice(end)
|
||||
emit('update:modelValue', next)
|
||||
await nextTick()
|
||||
el.focus()
|
||||
const pos = start + text.length
|
||||
el.setSelectionRange(pos, pos)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getInputEl,
|
||||
focusInput,
|
||||
getSelectionText,
|
||||
selectAllInput,
|
||||
insertAtSelection,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="flex-grow-1 terminal-area"
|
||||
@contextmenu="$emit('context-menu', $event)"
|
||||
>
|
||||
<div
|
||||
v-for="tab in sessions"
|
||||
:key="tab.id"
|
||||
:ref="(el) => onPaneRef(el, tab.id)"
|
||||
class="terminal-area__pane"
|
||||
:style="paneStyle(tab.id)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showEmpty"
|
||||
class="d-flex flex-column align-center justify-center fill-height pa-6"
|
||||
>
|
||||
<v-icon size="56" color="primary" class="mb-3">mdi-console-network</v-icon>
|
||||
<div class="text-h6 mb-1 text-center">WebSSH 终端</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4 text-center">
|
||||
选择一台服务器开始会话,或从服务器页点击「终端」
|
||||
</div>
|
||||
<slot name="empty-picker" />
|
||||
</div>
|
||||
|
||||
<v-overlay
|
||||
v-if="activeSession?.showOverlay"
|
||||
:model-value="true"
|
||||
class="d-flex align-center justify-center"
|
||||
persistent
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-h6 mb-2">{{ activeSession.overlayMsg }}</div>
|
||||
<div v-if="activeSession.reconnectCountdown" class="text-body-2 text-medium-emphasis mb-4">
|
||||
{{ activeSession.reconnectCountdown }}
|
||||
</div>
|
||||
<div class="d-flex ga-3 justify-center">
|
||||
<v-btn color="primary" variant="flat" @click="$emit('reconnect')">重新连接</v-btn>
|
||||
<v-btn variant="outlined" @click="$emit('go-servers')">返回列表</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-overlay>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { TermSessionData } from '@/composables/terminal/types'
|
||||
|
||||
const props = defineProps<{
|
||||
sessions: TermSessionData[]
|
||||
activeSessionId: string
|
||||
activeSession: TermSessionData | null
|
||||
showEmpty: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'context-menu': [event: MouseEvent]
|
||||
reconnect: []
|
||||
'go-servers': []
|
||||
'pane-ref': [el: unknown, sessionId: string]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function paneStyle(sessionId: string): Record<string, string | number> {
|
||||
const active = sessionId === props.activeSessionId
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
zIndex: active ? 1 : 0,
|
||||
pointerEvents: active ? 'auto' : 'none',
|
||||
opacity: active ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function onPaneRef(el: unknown, sessionId: string) {
|
||||
emit('pane-ref', el, sessionId)
|
||||
}
|
||||
|
||||
defineExpose({ containerRef })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-area {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
.terminal-area__pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.fill-height {
|
||||
flex: 1 1 auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div
|
||||
class="d-flex align-start ga-2 px-3 py-2 shrink-0 cmd-bar cmd-bar--tall"
|
||||
:class="{ 'opacity-50 pointer-events-none': disabled }"
|
||||
@contextmenu="$emit('context-menu', $event)"
|
||||
>
|
||||
<span class="cmd-bar__prompt font-mono text-primary shrink-0">❯</span>
|
||||
<div class="flex-grow-1 cmd-bar__input-wrap">
|
||||
<ShellHighlightField
|
||||
ref="fieldRef"
|
||||
:model-value="modelValue"
|
||||
multiline
|
||||
:rows="3"
|
||||
size="large"
|
||||
placeholder="输入命令,Enter 发送,Shift+Enter 换行"
|
||||
:disabled="disabled"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
/>
|
||||
</div>
|
||||
<v-btn
|
||||
size="default"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="mt-1"
|
||||
:disabled="disabled"
|
||||
@click="$emit('send')"
|
||||
>
|
||||
发送
|
||||
</v-btn>
|
||||
<div class="terminal-hotkeys shrink-0 pl-3">
|
||||
<div class="text-body-2 font-weight-medium text-medium-emphasis mb-2">快捷键</div>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<v-btn size="default" variant="outlined" block @click="$emit('ctrl', 'c')">中断 (Ctrl+C)</v-btn>
|
||||
<v-btn size="default" variant="outlined" block @click="$emit('ctrl', 'd')">退出 (Ctrl+D)</v-btn>
|
||||
<v-btn size="default" variant="outlined" block @click="$emit('key', '\t')">补全 (Tab)</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ShellHighlightField from '@/components/ShellHighlightField.vue'
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
keydown: [event: KeyboardEvent]
|
||||
send: []
|
||||
ctrl: [key: string]
|
||||
key: [key: string]
|
||||
'context-menu': [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const fieldRef = ref<InstanceType<typeof ShellHighlightField> | null>(null)
|
||||
|
||||
function getInputEl() {
|
||||
return fieldRef.value?.getInputEl() ?? null
|
||||
}
|
||||
|
||||
function getSelectionText() {
|
||||
return fieldRef.value?.getSelectionText() ?? ''
|
||||
}
|
||||
|
||||
function selectAllInput() {
|
||||
fieldRef.value?.selectAllInput()
|
||||
}
|
||||
|
||||
async function insertAtSelection(text: string) {
|
||||
await fieldRef.value?.insertAtSelection(text)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getInputEl,
|
||||
getSelectionText,
|
||||
selectAllInput,
|
||||
insertAtSelection,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cmd-bar {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
.cmd-bar--tall {
|
||||
min-height: 120px;
|
||||
}
|
||||
.cmd-bar__prompt {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.55;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.cmd-bar__input-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
.terminal-hotkeys {
|
||||
border-left: 1px solid rgb(var(--v-theme-surface-variant));
|
||||
min-width: 9.5rem;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="terminal-quick-bar shrink-0">
|
||||
<span class="terminal-quick-bar__label">快捷命令</span>
|
||||
<div class="d-flex align-center ga-1 flex-grow-1 overflow-x-auto">
|
||||
<v-chip
|
||||
v-for="cmd in commands"
|
||||
:key="cmd.id"
|
||||
size="small"
|
||||
:variant="cmd.is_builtin ? 'outlined' : 'flat'"
|
||||
:color="cmd.is_builtin ? 'default' : 'primary'"
|
||||
label
|
||||
class="cursor-pointer"
|
||||
@click="$emit('exec', cmd.cmd)"
|
||||
>
|
||||
{{ cmd.name }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
prepend-icon="mdi-cog-outline"
|
||||
:to="{ path: '/settings', hash: '#terminal-quick-commands' }"
|
||||
>
|
||||
管理
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { QuickCmdItem } from '@/composables/useTerminalQuickCommands'
|
||||
|
||||
defineProps<{
|
||||
commands: QuickCmdItem[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
exec: [cmd: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-quick-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
.terminal-quick-bar__label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<v-card v-if="mode === 'inline'" border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
|
||||
<v-card-text class="pa-3">
|
||||
<v-text-field
|
||||
:model-value="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="搜索服务器…"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
@update:model-value="$emit('update:search', $event)"
|
||||
/>
|
||||
<v-list density="compact" class="empty-server-picker__list" nav>
|
||||
<v-list-item
|
||||
v-for="s in servers"
|
||||
:key="s.id"
|
||||
:title="s.name"
|
||||
:subtitle="s.domain"
|
||||
rounded="lg"
|
||||
@click="$emit('select', s.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
|
||||
暂无匹配的服务器
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<template v-else>
|
||||
<v-text-field
|
||||
:model-value="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="搜索…"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
@update:model-value="$emit('update:search', $event)"
|
||||
/>
|
||||
<v-list density="compact" max-height="300" style="overflow-y: auto">
|
||||
<v-list-item
|
||||
v-for="s in servers"
|
||||
:key="s.id"
|
||||
:title="s.name"
|
||||
:subtitle="s.domain"
|
||||
@click="$emit('select', s.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
|
||||
暂无匹配的服务器
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TerminalServerItem } from '@/composables/terminal/types'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
servers: TerminalServerItem[]
|
||||
search: string
|
||||
mode?: 'inline' | 'dialog'
|
||||
}>(),
|
||||
{ mode: 'inline' },
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
'update:search': [value: string]
|
||||
select: [serverId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-server-picker__list {
|
||||
max-height: min(42vh, 320px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div
|
||||
class="d-flex align-center px-2 py-1 shrink-0 terminal-tab-bar"
|
||||
>
|
||||
<div class="d-flex align-center ga-1 overflow-x-auto flex-grow-1">
|
||||
<v-chip
|
||||
v-for="tab in sessions"
|
||||
:key="tab.id"
|
||||
:variant="tab.id === activeSessionId ? 'flat' : 'outlined'"
|
||||
:color="tab.id === activeSessionId ? 'primary' : 'default'"
|
||||
size="small"
|
||||
label
|
||||
class="cursor-pointer"
|
||||
@click="$emit('switch', tab.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
size="8"
|
||||
:color="
|
||||
tab.status === 'connected'
|
||||
? 'success'
|
||||
: tab.status === 'connecting'
|
||||
? 'warning'
|
||||
: 'grey'
|
||||
"
|
||||
>
|
||||
mdi-circle
|
||||
</v-icon>
|
||||
</template>
|
||||
<span class="text-truncate terminal-tab-bar__title">{{ tab.serverName }}</span>
|
||||
<v-icon end size="12" class="ml-1" @click.stop="$emit('close', tab.id)">mdi-close</v-icon>
|
||||
</v-chip>
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="$emit('new')">
|
||||
新建会话
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TermSessionData } from '@/composables/terminal/types'
|
||||
|
||||
defineProps<{
|
||||
sessions: TermSessionData[]
|
||||
activeSessionId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
switch: [sessionId: string]
|
||||
close: [sessionId: string]
|
||||
new: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-tab-bar {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
.terminal-tab-bar__title {
|
||||
max-width: 100px;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="d-flex align-center justify-space-between px-4 py-1 shrink-0 terminal-toolbar">
|
||||
<div class="d-flex align-center ga-3">
|
||||
<span class="text-body-2 font-weight-medium">{{ session?.serverName || '...' }}</span>
|
||||
<span
|
||||
v-if="session?.cwd"
|
||||
class="text-caption text-medium-emphasis font-mono terminal-toolbar__cwd"
|
||||
>
|
||||
{{ session.cwd }}
|
||||
</span>
|
||||
<v-chip v-if="session" :color="statusColor" variant="tonal" size="x-small" label>
|
||||
<template #prepend>
|
||||
<v-icon start size="8">mdi-circle</v-icon>
|
||||
</template>
|
||||
{{ statusLabel }}
|
||||
</v-chip>
|
||||
<span v-if="session?.uptime" class="text-caption text-medium-emphasis">{{ session.uptime }}</span>
|
||||
<span
|
||||
v-if="session?.pingMs !== null && session?.pingMs !== undefined"
|
||||
class="text-caption text-medium-emphasis"
|
||||
>
|
||||
{{ session.pingMs }}ms
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-2 terminal-toolbar-actions">
|
||||
<span class="text-body-2 text-medium-emphasis shrink-0">字号</span>
|
||||
<v-btn variant="tonal" size="small" @click="$emit('font-change', -1)">缩小</v-btn>
|
||||
<span class="text-body-2 font-weight-medium terminal-toolbar__font-size">{{ fontSize }}</span>
|
||||
<v-btn variant="tonal" size="small" @click="$emit('font-change', 1)">放大</v-btn>
|
||||
<v-select
|
||||
:model-value="scrollback"
|
||||
:items="scrollbackOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="回滚行数"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
class="terminal-toolbar-scrollback"
|
||||
@update:model-value="$emit('scrollback-change', $event)"
|
||||
/>
|
||||
<v-btn variant="tonal" size="small" prepend-icon="mdi-fullscreen" @click="$emit('fullscreen')">
|
||||
全屏
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
color="error"
|
||||
prepend-icon="mdi-link-off"
|
||||
@click="$emit('disconnect')"
|
||||
>
|
||||
断开
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
sessionStatusColor,
|
||||
sessionStatusLabel,
|
||||
type TermSessionData,
|
||||
} from '@/composables/terminal/types'
|
||||
import { scrollbackOptions } from '@/composables/terminal/useTerminalSettings'
|
||||
|
||||
const props = defineProps<{
|
||||
session: TermSessionData | null
|
||||
fontSize: number
|
||||
scrollback: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'font-change': [delta: number]
|
||||
'scrollback-change': [value: number]
|
||||
fullscreen: []
|
||||
disconnect: []
|
||||
}>()
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
props.session ? sessionStatusLabel(props.session) : '',
|
||||
)
|
||||
const statusColor = computed(() =>
|
||||
props.session ? sessionStatusColor(props.session) : 'grey',
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-toolbar {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
|
||||
min-height: 32px;
|
||||
}
|
||||
.terminal-toolbar__cwd {
|
||||
max-width: 200px;
|
||||
}
|
||||
.terminal-toolbar__font-size {
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.terminal-toolbar-scrollback {
|
||||
min-width: 8.5rem;
|
||||
max-width: 10rem;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
import { markRaw } from 'vue'
|
||||
import type { FitAddon } from '@xterm/addon-fit'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import type { TermSessionNative } from './types'
|
||||
|
||||
const nativeMap = new Map<string, TermSessionNative>()
|
||||
|
||||
function emptyNative(): TermSessionNative {
|
||||
return {
|
||||
ws: null,
|
||||
term: null,
|
||||
fitAddon: null,
|
||||
resizeObserver: null,
|
||||
reconnectTimer: null,
|
||||
pingTimer: null,
|
||||
uptimeTimer: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function createNative(sessionId: string): TermSessionNative {
|
||||
const native = emptyNative()
|
||||
nativeMap.set(sessionId, native)
|
||||
return native
|
||||
}
|
||||
|
||||
export function getNative(sessionId: string): TermSessionNative | undefined {
|
||||
return nativeMap.get(sessionId)
|
||||
}
|
||||
|
||||
export function deleteNative(sessionId: string): void {
|
||||
nativeMap.delete(sessionId)
|
||||
}
|
||||
|
||||
export function setNativeTerm(sessionId: string, term: Terminal): void {
|
||||
const n = getNative(sessionId)
|
||||
if (n) n.term = markRaw(term)
|
||||
}
|
||||
|
||||
export function setNativeFitAddon(sessionId: string, fitAddon: FitAddon): void {
|
||||
const n = getNative(sessionId)
|
||||
if (n) n.fitAddon = markRaw(fitAddon)
|
||||
}
|
||||
|
||||
export function setNativeResizeObserver(sessionId: string, ro: ResizeObserver): void {
|
||||
const n = getNative(sessionId)
|
||||
if (n) n.resizeObserver = markRaw(ro)
|
||||
}
|
||||
|
||||
export function isSessionWsOpen(sessionId: string): boolean {
|
||||
const ws = getNative(sessionId)?.ws
|
||||
return ws?.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
export function forEachNative(
|
||||
fn: (sessionId: string, native: TermSessionNative) => void,
|
||||
): void {
|
||||
nativeMap.forEach((native, sessionId) => fn(sessionId, native))
|
||||
}
|
||||
|
||||
export function clearAllNatives(): void {
|
||||
nativeMap.clear()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
export interface TermSessionData {
|
||||
id: string
|
||||
serverId: number
|
||||
serverName: string
|
||||
cwd: string
|
||||
cmdHistory: string[]
|
||||
cmdIdx: number
|
||||
reconnectAttempt: number
|
||||
manualDisconnect: boolean
|
||||
status: SessionStatus
|
||||
showOverlay: boolean
|
||||
overlayMsg: string
|
||||
reconnectCountdown: string
|
||||
uptime: string
|
||||
pingMs: number | null
|
||||
connectionStartTime: number
|
||||
pendingPath: string
|
||||
initialCdSent: boolean
|
||||
}
|
||||
|
||||
export interface TermSessionNative {
|
||||
ws: WebSocket | null
|
||||
term: import('@xterm/xterm').Terminal | null
|
||||
fitAddon: import('@xterm/addon-fit').FitAddon | null
|
||||
resizeObserver: ResizeObserver | null
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||
pingTimer: ReturnType<typeof setInterval> | null
|
||||
uptimeTimer: ReturnType<typeof setInterval> | null
|
||||
}
|
||||
|
||||
export interface TerminalServerItem {
|
||||
id: number
|
||||
name: string
|
||||
domain: string
|
||||
is_online: boolean
|
||||
}
|
||||
|
||||
export function sanitizeRemotePath(raw: string): string {
|
||||
const p = (raw || '').trim()
|
||||
return p && /^[^\x00-\x1f\x7f"'`\\]+$/.test(p) ? p : ''
|
||||
}
|
||||
|
||||
export function sessionStatusLabel(s: TermSessionData): string {
|
||||
switch (s.status) {
|
||||
case 'connecting':
|
||||
return '连接中…'
|
||||
case 'connected':
|
||||
return '已连接'
|
||||
case 'error':
|
||||
return '连接失败'
|
||||
default:
|
||||
return '未连接'
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionStatusColor(s: TermSessionData): string {
|
||||
switch (s.status) {
|
||||
case 'connecting':
|
||||
return 'warning'
|
||||
case 'connected':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return 'grey'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ref } from 'vue'
|
||||
import type { TermSessionData } from './types'
|
||||
import { isSessionWsOpen } from './termNativeStore'
|
||||
|
||||
const K_HIST = 'nexus_term_history'
|
||||
|
||||
function loadGlobalHistory(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(K_HIST) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function useTerminalCmdBar(
|
||||
getActiveSession: () => TermSessionData | null,
|
||||
sendWsData: (sessionId: string, data: string) => boolean,
|
||||
) {
|
||||
const cmdInput = ref('')
|
||||
|
||||
function sendCmd() {
|
||||
const s = getActiveSession()
|
||||
if (!s || !isSessionWsOpen(s.id)) return
|
||||
const cmd = cmdInput.value
|
||||
if (!cmd) return
|
||||
s.cmdHistory.push(cmd)
|
||||
s.cmdIdx = -1
|
||||
if (!sendWsData(s.id, cmd + '\r')) return
|
||||
cmdInput.value = ''
|
||||
let hist: string[] = loadGlobalHistory()
|
||||
if (hist.length >= 200) hist.shift()
|
||||
hist.push(cmd)
|
||||
localStorage.setItem(K_HIST, JSON.stringify(hist))
|
||||
}
|
||||
|
||||
function onCmdKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendCmd()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
cmdHistoryUp()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
cmdHistoryDown()
|
||||
}
|
||||
}
|
||||
|
||||
function cmdHistoryUp() {
|
||||
const s = getActiveSession()
|
||||
if (!s) return
|
||||
if (s.cmdIdx < s.cmdHistory.length - 1) {
|
||||
s.cmdIdx++
|
||||
cmdInput.value = s.cmdHistory[s.cmdHistory.length - 1 - s.cmdIdx]
|
||||
}
|
||||
}
|
||||
|
||||
function cmdHistoryDown() {
|
||||
const s = getActiveSession()
|
||||
if (!s) return
|
||||
if (s.cmdIdx > 0) {
|
||||
s.cmdIdx--
|
||||
cmdInput.value = s.cmdHistory[s.cmdHistory.length - 1 - s.cmdIdx]
|
||||
} else {
|
||||
s.cmdIdx = -1
|
||||
cmdInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function sendCtrl(key: string) {
|
||||
const s = getActiveSession()
|
||||
if (!s || !isSessionWsOpen(s.id)) return
|
||||
sendWsData(s.id, String.fromCharCode(key.toLowerCase().charCodeAt(0) - 96))
|
||||
}
|
||||
|
||||
function sendKey(key: string) {
|
||||
const s = getActiveSession()
|
||||
if (!s || !isSessionWsOpen(s.id)) return
|
||||
sendWsData(s.id, key)
|
||||
}
|
||||
|
||||
function loadHistoryForNewSession(): string[] {
|
||||
return [...loadGlobalHistory()]
|
||||
}
|
||||
|
||||
return {
|
||||
cmdInput,
|
||||
sendCmd,
|
||||
onCmdKeydown,
|
||||
cmdHistoryUp,
|
||||
cmdHistoryDown,
|
||||
sendCtrl,
|
||||
sendKey,
|
||||
loadHistoryForNewSession,
|
||||
}
|
||||
}
|
||||
|
||||
export type CmdInputExpose = {
|
||||
getInputEl: () => HTMLTextAreaElement | HTMLInputElement | null
|
||||
focusInput: () => void
|
||||
insertAtSelection: (text: string) => void
|
||||
getSelectionText: () => string
|
||||
selectAllInput: () => void
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
import { ref, computed, watch, nextTick, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { sortQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
|
||||
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||
import {
|
||||
createNative,
|
||||
deleteNative,
|
||||
getNative,
|
||||
setNativeFitAddon,
|
||||
setNativeResizeObserver,
|
||||
setNativeTerm,
|
||||
isSessionWsOpen,
|
||||
forEachNative,
|
||||
} from './termNativeStore'
|
||||
import {
|
||||
sanitizeRemotePath,
|
||||
type TermSessionData,
|
||||
type TerminalServerItem,
|
||||
} from './types'
|
||||
import { useTerminalSettings } from './useTerminalSettings'
|
||||
import { useTerminalCmdBar } from './useTerminalCmdBar'
|
||||
|
||||
const MAX_TABS = 10
|
||||
|
||||
export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const theme = useTheme()
|
||||
const snackbar = useSnackbar()
|
||||
const settings = useTerminalSettings()
|
||||
|
||||
const sessions = ref<TermSessionData[]>([])
|
||||
const activeSessionId = ref('')
|
||||
const termContainer = ref<HTMLElement | null>(null)
|
||||
const termRefs = new Map<string, HTMLElement>()
|
||||
const showServerDialog = ref(false)
|
||||
const servers = ref<TerminalServerItem[]>([])
|
||||
const serverSearch = ref('')
|
||||
const quickCmds = ref<QuickCmdItem[]>([])
|
||||
|
||||
const activeSession = computed(
|
||||
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
||||
)
|
||||
|
||||
const activeSessionConnected = computed(() => {
|
||||
const id = activeSessionId.value
|
||||
return id ? isSessionWsOpen(id) : false
|
||||
})
|
||||
|
||||
const filteredServers = computed(() => {
|
||||
const q = serverSearch.value.toLowerCase().trim()
|
||||
return servers.value.filter(
|
||||
(s) => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
const showEmptyPicker = computed(
|
||||
() => sessions.value.length === 0 && !showServerDialog.value,
|
||||
)
|
||||
|
||||
function getSession(sessionId: string): TermSessionData | undefined {
|
||||
return sessions.value.find((t) => t.id === sessionId)
|
||||
}
|
||||
|
||||
function sendWsData(sessionId: string, data: string): boolean {
|
||||
const ws = getNative(sessionId)?.ws
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return false
|
||||
ws.send(JSON.stringify({ type: 'DATA', data }))
|
||||
return true
|
||||
}
|
||||
|
||||
const cmdBar = useTerminalCmdBar(() => activeSession.value, sendWsData)
|
||||
|
||||
function refitAllTerminals() {
|
||||
forEachNative((_id, native) => {
|
||||
try {
|
||||
native.fitAddon?.fit()
|
||||
} catch {
|
||||
/* container size 0 */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(nexusDrawer, () => {
|
||||
nextTick(() => {
|
||||
refitAllTerminals()
|
||||
setTimeout(refitAllTerminals, 220)
|
||||
})
|
||||
})
|
||||
|
||||
function setTermRef(el: unknown, sessionId: string) {
|
||||
if (el) termRefs.set(sessionId, el as HTMLElement)
|
||||
}
|
||||
|
||||
function createTerminal(): Terminal {
|
||||
const isDark = theme.global.current.value.dark
|
||||
return new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
fontSize: settings.fontSize.value,
|
||||
scrollback: settings.scrollback.value,
|
||||
fontFamily: '"Cascadia Code","Fira Code","JetBrains Mono",Menlo,monospace',
|
||||
theme: isDark
|
||||
? {
|
||||
background: '#0b1120',
|
||||
foreground: '#e2e8f0',
|
||||
cursor: '#7c8bf4',
|
||||
selectionBackground: '#334155',
|
||||
black: '#1e293b',
|
||||
red: '#f87171',
|
||||
green: '#4ade80',
|
||||
yellow: '#fbbf24',
|
||||
blue: '#60a5fa',
|
||||
magenta: '#c084fc',
|
||||
cyan: '#22d3ee',
|
||||
white: '#e2e8f0',
|
||||
brightBlack: '#64748b',
|
||||
brightRed: '#fca5a5',
|
||||
brightGreen: '#86efac',
|
||||
brightYellow: '#fde047',
|
||||
brightBlue: '#93c5fd',
|
||||
brightMagenta: '#d8b4fe',
|
||||
brightCyan: '#67e8f9',
|
||||
brightWhite: '#f8fafc',
|
||||
}
|
||||
: {
|
||||
background: '#f8fafc',
|
||||
foreground: '#0f172a',
|
||||
cursor: '#4f46e5',
|
||||
selectionBackground: '#bfdbfe',
|
||||
black: '#1e293b',
|
||||
red: '#dc2626',
|
||||
green: '#16a34a',
|
||||
yellow: '#ca8a04',
|
||||
blue: '#2563eb',
|
||||
magenta: '#9333ea',
|
||||
cyan: '#0891b2',
|
||||
white: '#f1f5f9',
|
||||
brightBlack: '#64748b',
|
||||
brightRed: '#ef4444',
|
||||
brightGreen: '#22c55e',
|
||||
brightYellow: '#eab308',
|
||||
brightBlue: '#3b82f6',
|
||||
brightMagenta: '#a855f7',
|
||||
brightCyan: '#06b6d4',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
drawBoldTextInBrightColors: true,
|
||||
})
|
||||
}
|
||||
|
||||
function sendCdToSession(s: TermSessionData, path: string) {
|
||||
const dir = sanitizeRemotePath(path)
|
||||
if (!dir || !isSessionWsOpen(s.id)) return
|
||||
sendWsData(s.id, `cd ${dir}\r`)
|
||||
s.initialCdSent = true
|
||||
}
|
||||
|
||||
function stopUptimeCounter(s: TermSessionData) {
|
||||
const native = getNative(s.id)
|
||||
if (native?.uptimeTimer) {
|
||||
clearInterval(native.uptimeTimer)
|
||||
native.uptimeTimer = null
|
||||
}
|
||||
s.uptime = ''
|
||||
}
|
||||
|
||||
function updateUptime(s: TermSessionData) {
|
||||
if (!s.connectionStartTime) {
|
||||
s.uptime = ''
|
||||
return
|
||||
}
|
||||
const sec = Math.floor((Date.now() - s.connectionStartTime) / 1000)
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
const ss = sec % 60
|
||||
s.uptime = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function startUptimeCounter(s: TermSessionData) {
|
||||
s.connectionStartTime = Date.now()
|
||||
stopUptimeCounter(s)
|
||||
updateUptime(s)
|
||||
const native = getNative(s.id)
|
||||
if (!native) return
|
||||
native.uptimeTimer = setInterval(() => updateUptime(s), 1000)
|
||||
}
|
||||
|
||||
function stopPing(s: TermSessionData) {
|
||||
const native = getNative(s.id)
|
||||
if (native?.pingTimer) {
|
||||
clearInterval(native.pingTimer)
|
||||
native.pingTimer = null
|
||||
}
|
||||
s.pingMs = null
|
||||
}
|
||||
|
||||
function startPing(s: TermSessionData) {
|
||||
stopPing(s)
|
||||
s.pingMs = null
|
||||
const native = getNative(s.id)
|
||||
if (!native) return
|
||||
native.pingTimer = setInterval(() => {
|
||||
if (isSessionWsOpen(s.id)) {
|
||||
getNative(s.id)?.ws?.send(JSON.stringify({ type: 'PING', ts: Date.now() }))
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function disposeSessionNative(sessionId: string) {
|
||||
const native = getNative(sessionId)
|
||||
if (!native) return
|
||||
if (native.reconnectTimer) {
|
||||
clearTimeout(native.reconnectTimer)
|
||||
native.reconnectTimer = null
|
||||
}
|
||||
if (native.pingTimer) {
|
||||
clearInterval(native.pingTimer)
|
||||
native.pingTimer = null
|
||||
}
|
||||
if (native.uptimeTimer) {
|
||||
clearInterval(native.uptimeTimer)
|
||||
native.uptimeTimer = null
|
||||
}
|
||||
if (native.ws) {
|
||||
try {
|
||||
native.ws.close(1000)
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
native.ws = null
|
||||
}
|
||||
if (native.term) {
|
||||
try {
|
||||
native.term.dispose()
|
||||
} catch {
|
||||
/* already disposed */
|
||||
}
|
||||
native.term = null
|
||||
}
|
||||
if (native.resizeObserver) {
|
||||
try {
|
||||
native.resizeObserver.disconnect()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
native.resizeObserver = null
|
||||
}
|
||||
native.fitAddon = null
|
||||
deleteNative(sessionId)
|
||||
}
|
||||
|
||||
async function connectSession(sessionId: string) {
|
||||
const s = getSession(sessionId)
|
||||
if (!s) return
|
||||
const native = getNative(sessionId)
|
||||
if (!native) return
|
||||
|
||||
if (native.ws) {
|
||||
try {
|
||||
native.ws.close(1000)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
native.ws = null
|
||||
}
|
||||
|
||||
s.status = 'connecting'
|
||||
|
||||
let websshToken: string
|
||||
try {
|
||||
const res = await http.post<{ webssh_token: string }>('/auth/webssh-token', {
|
||||
server_id: s.serverId,
|
||||
})
|
||||
websshToken = res.webssh_token
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '无法获取终端令牌'
|
||||
s.status = 'error'
|
||||
native.term?.writeln(`\r\n\x1b[31m⚠ ${msg}\x1b[0m`)
|
||||
s.showOverlay = true
|
||||
s.overlayMsg = msg
|
||||
s.reconnectCountdown = ''
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = buildWebSocketUrl(`/ws/terminal/${s.serverId}`, { token: websshToken })
|
||||
const ws = new WebSocket(wsUrl)
|
||||
native.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
s.status = 'connected'
|
||||
s.manualDisconnect = false
|
||||
s.reconnectAttempt = 0
|
||||
if (native.reconnectTimer) {
|
||||
clearTimeout(native.reconnectTimer)
|
||||
native.reconnectTimer = null
|
||||
}
|
||||
s.showOverlay = false
|
||||
s.overlayMsg = ''
|
||||
s.reconnectCountdown = ''
|
||||
startUptimeCounter(s)
|
||||
startPing(s)
|
||||
native.term?.focus()
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data as string)
|
||||
switch (msg.type) {
|
||||
case 'TERMINAL_INIT':
|
||||
s.serverName = msg.server_name || s.serverName
|
||||
{
|
||||
const dims = native.fitAddon?.proposeDimensions()
|
||||
if (dims && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'RESIZE', cols: dims.cols, rows: dims.rows }))
|
||||
}
|
||||
}
|
||||
if (s.pendingPath && !s.initialCdSent && ws.readyState === WebSocket.OPEN) {
|
||||
sendCdToSession(s, s.pendingPath)
|
||||
}
|
||||
break
|
||||
case 'DATA':
|
||||
native.term?.write(msg.data)
|
||||
break
|
||||
case 'ERROR':
|
||||
native.term?.write(`\r\n\x1b[31m⚠ ${msg.message}\x1b[0m\r\n`)
|
||||
break
|
||||
case 'PONG':
|
||||
s.pingMs = Date.now() - (msg.ts || 0)
|
||||
break
|
||||
case 'CLOSE':
|
||||
native.term?.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n')
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
native.term?.write(e.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
stopPing(s)
|
||||
|
||||
if (ev.code === 4001) {
|
||||
native.term?.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n')
|
||||
setTimeout(() => router.push('/login'), 2000)
|
||||
return
|
||||
}
|
||||
if (ev.code === 4003) {
|
||||
s.status = 'error'
|
||||
native.term?.write(
|
||||
`\r\n\x1b[31m⚠ 授权失败: ${ev.reason || 'SSH凭据未配置'}\x1b[0m\r\n`,
|
||||
)
|
||||
s.showOverlay = true
|
||||
s.overlayMsg = ev.reason || 'SSH 凭据未配置'
|
||||
s.reconnectCountdown = ''
|
||||
return
|
||||
}
|
||||
|
||||
s.status = 'error'
|
||||
native.ws = null
|
||||
|
||||
if (!s.manualDisconnect && ev.code !== 1000 && ev.code !== 4001 && ev.code !== 4003) {
|
||||
startReconnect(s)
|
||||
} else {
|
||||
s.showOverlay = true
|
||||
s.overlayMsg = '连接已断开'
|
||||
s.reconnectCountdown = ''
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
/* onclose handles UI */
|
||||
}
|
||||
}
|
||||
|
||||
function startReconnect(s: TermSessionData) {
|
||||
const native = getNative(s.id)
|
||||
if (!native) return
|
||||
s.reconnectAttempt++
|
||||
const delay = Math.min(1000 * 2 ** (s.reconnectAttempt - 1), 30000)
|
||||
s.showOverlay = true
|
||||
s.overlayMsg = '连接已断开'
|
||||
s.reconnectCountdown = `${Math.ceil(delay / 1000)} 秒后自动重连…`
|
||||
native.reconnectTimer = setTimeout(() => {
|
||||
void doReconnect(s)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
async function doReconnect(s: TermSessionData) {
|
||||
const native = getNative(s.id)
|
||||
if (!native) return
|
||||
s.showOverlay = false
|
||||
s.reconnectCountdown = ''
|
||||
s.manualDisconnect = false
|
||||
s.reconnectAttempt = 0
|
||||
native.term?.clear()
|
||||
await connectSession(s.id)
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
const s = activeSession.value
|
||||
if (!s) return
|
||||
const native = getNative(s.id)
|
||||
if (native?.reconnectTimer) {
|
||||
clearTimeout(native.reconnectTimer)
|
||||
native.reconnectTimer = null
|
||||
}
|
||||
s.reconnectAttempt = 0
|
||||
void doReconnect(s)
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
const s = activeSession.value
|
||||
if (!s) return
|
||||
const native = getNative(s.id)
|
||||
if (!native) return
|
||||
s.manualDisconnect = true
|
||||
if (native.reconnectTimer) {
|
||||
clearTimeout(native.reconnectTimer)
|
||||
native.reconnectTimer = null
|
||||
}
|
||||
if (native.ws) {
|
||||
if (native.ws.readyState === WebSocket.OPEN) {
|
||||
native.ws.send(JSON.stringify({ type: 'CLOSE' }))
|
||||
native.ws.close(1000, 'User disconnect')
|
||||
}
|
||||
native.ws = null
|
||||
}
|
||||
stopPing(s)
|
||||
stopUptimeCounter(s)
|
||||
s.showOverlay = true
|
||||
s.overlayMsg = '连接已断开'
|
||||
s.reconnectCountdown = ''
|
||||
}
|
||||
|
||||
async function switchTab(sessionId: string) {
|
||||
activeSessionId.value = sessionId
|
||||
await nextTick()
|
||||
const native = getNative(sessionId)
|
||||
if (native?.fitAddon) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
native.fitAddon!.fit()
|
||||
} catch {
|
||||
/* size 0 */
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
native?.term?.focus()
|
||||
}
|
||||
|
||||
async function closeTab(sessionId: string) {
|
||||
const idx = sessions.value.findIndex((s) => s.id === sessionId)
|
||||
if (idx < 0) return
|
||||
|
||||
disposeSessionNative(sessionId)
|
||||
termRefs.delete(sessionId)
|
||||
sessions.value.splice(idx, 1)
|
||||
|
||||
if (sessions.value.length === 0) {
|
||||
activeSessionId.value = ''
|
||||
return
|
||||
}
|
||||
const newIdx = Math.min(idx, sessions.value.length - 1)
|
||||
await switchTab(sessions.value[newIdx].id)
|
||||
}
|
||||
|
||||
async function newSession(serverId?: number, opts?: { path?: string }) {
|
||||
if (sessions.value.length >= MAX_TABS) {
|
||||
snackbar('最多10个标签', 'warning')
|
||||
return
|
||||
}
|
||||
if (!serverId) {
|
||||
showServerDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const existing = sessions.value.find((s) => s.serverId === serverId)
|
||||
if (existing) {
|
||||
snackbar('该服务器已有会话,已切换过去', 'info')
|
||||
await switchTab(existing.id)
|
||||
if (opts?.path) sendCdToSession(existing, opts.path)
|
||||
return
|
||||
}
|
||||
|
||||
const server = servers.value.find((s) => s.id === serverId)
|
||||
const name = server ? server.name : `#${serverId}`
|
||||
const sid = `term_${Date.now()}`
|
||||
|
||||
const session: TermSessionData = {
|
||||
id: sid,
|
||||
serverId,
|
||||
serverName: name,
|
||||
cwd: '',
|
||||
cmdHistory: cmdBar.loadHistoryForNewSession(),
|
||||
cmdIdx: -1,
|
||||
reconnectAttempt: 0,
|
||||
manualDisconnect: false,
|
||||
status: 'idle',
|
||||
showOverlay: false,
|
||||
overlayMsg: '',
|
||||
reconnectCountdown: '',
|
||||
uptime: '',
|
||||
pingMs: null,
|
||||
connectionStartTime: 0,
|
||||
pendingPath: sanitizeRemotePath(opts?.path || ''),
|
||||
initialCdSent: false,
|
||||
}
|
||||
|
||||
createNative(sid)
|
||||
sessions.value.push(session)
|
||||
|
||||
await switchTab(sid)
|
||||
await nextTick()
|
||||
|
||||
const container = termRefs.get(sid)
|
||||
if (!container) return
|
||||
|
||||
const term = createTerminal()
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(new WebLinksAddon())
|
||||
term.open(container)
|
||||
|
||||
setNativeTerm(sid, term)
|
||||
setNativeFitAddon(sid, fitAddon)
|
||||
|
||||
term.onData((data) => {
|
||||
if (isSessionWsOpen(sid)) sendWsData(sid, data)
|
||||
})
|
||||
|
||||
term.onResize(({ cols, rows }) => {
|
||||
const ws = getNative(sid)?.ws
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'RESIZE', cols, rows }))
|
||||
}
|
||||
})
|
||||
|
||||
term.onTitleChange((title) => {
|
||||
const sess = getSession(sid)
|
||||
if (sess) {
|
||||
const m = title.match(/^(\/.+)/)
|
||||
if (m) sess.cwd = m[1]
|
||||
}
|
||||
})
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddon.fit()
|
||||
} catch {
|
||||
/* size 0 */
|
||||
}
|
||||
})
|
||||
ro.observe(container)
|
||||
setNativeResizeObserver(sid, ro)
|
||||
|
||||
await connectSession(sid)
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fitAddon.fit()
|
||||
} catch {
|
||||
/* size 0 */
|
||||
}
|
||||
}, 100)
|
||||
term.focus()
|
||||
}
|
||||
|
||||
async function applyRouteQuery() {
|
||||
const id = Number(route.query.server_id) || 0
|
||||
const path = sanitizeRemotePath(String(route.query.path || ''))
|
||||
const forceNew = route.query.new_tab === '1' || route.query.new_tab === 'true'
|
||||
|
||||
if (!id) return
|
||||
|
||||
if (!forceNew) {
|
||||
const existing = sessions.value.find((s) => s.serverId === id)
|
||||
if (existing) {
|
||||
await switchTab(existing.id)
|
||||
if (path) sendCdToSession(existing, path)
|
||||
return
|
||||
}
|
||||
}
|
||||
await newSession(id, { path })
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
termContainer.value?.requestFullscreen?.()
|
||||
} else {
|
||||
document.exitFullscreen?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQuickCmds() {
|
||||
try {
|
||||
const res = await http.get<QuickCmdItem[]>('/terminal/quick-commands')
|
||||
quickCmds.value = sortQuickCommands(res || [])
|
||||
} catch {
|
||||
snackbar('快捷命令加载失败,已使用内置命令', 'warning')
|
||||
quickCmds.value = sortQuickCommands([
|
||||
{
|
||||
id: 1,
|
||||
name: '系统状态',
|
||||
cmd: 'systemctl status --no-pager 2>&1 | head -20\r',
|
||||
is_builtin: true,
|
||||
sort_order: 1001,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '系统日志',
|
||||
cmd: 'journalctl -n 30 --no-pager 2>&1\r',
|
||||
is_builtin: true,
|
||||
sort_order: 1002,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '磁盘使用',
|
||||
cmd: 'df -h\r',
|
||||
is_builtin: true,
|
||||
sort_order: 1003,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '内存使用',
|
||||
cmd: 'free -h\r',
|
||||
is_builtin: true,
|
||||
sort_order: 1004,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '进程列表',
|
||||
cmd: 'ps aux --sort=-%mem 2>&1 | head -15\r',
|
||||
is_builtin: true,
|
||||
sort_order: 1005,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function execQuickCmd(cmd: string) {
|
||||
const s = activeSession.value
|
||||
if (!s || !isSessionWsOpen(s.id)) {
|
||||
snackbar('终端未连接', 'warning')
|
||||
return
|
||||
}
|
||||
sendWsData(s.id, cmd)
|
||||
getNative(s.id)?.term?.focus()
|
||||
}
|
||||
|
||||
async function loadServers() {
|
||||
try {
|
||||
const res = await http.get<{ items: TerminalServerItem[] }>('/servers/', {
|
||||
per_page: 200,
|
||||
})
|
||||
servers.value = res.items || []
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.key === 'l') {
|
||||
e.preventDefault()
|
||||
const s = activeSession.value
|
||||
const native = s ? getNative(s.id) : undefined
|
||||
if (native?.term) {
|
||||
native.term.clear()
|
||||
if (isSessionWsOpen(s!.id)) sendWsData(s!.id, '\x0c')
|
||||
}
|
||||
return
|
||||
}
|
||||
// US 键盘 Ctrl+加号 可能上报 '+' 或 '='(同一物理键),只处理一次
|
||||
if (e.ctrlKey && (e.key === '+' || e.key === '=')) {
|
||||
e.preventDefault()
|
||||
settings.changeFontSize(1)
|
||||
return
|
||||
}
|
||||
if (e.ctrlKey && e.key === '-') {
|
||||
e.preventDefault()
|
||||
settings.changeFontSize(-1)
|
||||
}
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
||||
e.preventDefault()
|
||||
toggleFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
function disposeAllSessions() {
|
||||
const ids = sessions.value.map((s) => s.id)
|
||||
ids.forEach(disposeSessionNative)
|
||||
termRefs.clear()
|
||||
sessions.value = []
|
||||
activeSessionId.value = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.server_id, route.query.path, route.query.new_tab],
|
||||
() => {
|
||||
void applyRouteQuery()
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
activeSessionConnected,
|
||||
termContainer,
|
||||
showServerDialog,
|
||||
servers,
|
||||
serverSearch,
|
||||
filteredServers,
|
||||
showEmptyPicker,
|
||||
quickCmds,
|
||||
fontSize: settings.fontSize,
|
||||
scrollback: settings.scrollback,
|
||||
scrollbackOptions: settings.scrollbackOptions,
|
||||
changeFontSize: settings.changeFontSize,
|
||||
changeScrollback: settings.changeScrollback,
|
||||
cmdInput: cmdBar.cmdInput,
|
||||
sendCmd: cmdBar.sendCmd,
|
||||
onCmdKeydown: cmdBar.onCmdKeydown,
|
||||
sendCtrl: cmdBar.sendCtrl,
|
||||
sendKey: cmdBar.sendKey,
|
||||
setTermRef,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newSession,
|
||||
disconnect,
|
||||
reconnect,
|
||||
toggleFullscreen,
|
||||
loadQuickCmds,
|
||||
execQuickCmd,
|
||||
loadServers,
|
||||
applyRouteQuery,
|
||||
onGlobalKeydown,
|
||||
disposeAllSessions,
|
||||
getNative,
|
||||
sendWsData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ref } from 'vue'
|
||||
import { setNativeFitAddon, forEachNative, getNative } from './termNativeStore'
|
||||
|
||||
const K_FONT = 'nexus_term_fontSize'
|
||||
const K_SCROLL = 'nexus_term_scrollback'
|
||||
|
||||
export const scrollbackOptions = [
|
||||
{ title: '1 千行', value: 1000 },
|
||||
{ title: '5 千行', value: 5000 },
|
||||
{ title: '1 万行', value: 10000 },
|
||||
{ title: '5 万行', value: 50000 },
|
||||
] as const
|
||||
|
||||
export function useTerminalSettings() {
|
||||
const fontSize = ref(parseInt(localStorage.getItem(K_FONT) || '14', 10))
|
||||
const scrollback = ref(parseInt(localStorage.getItem(K_SCROLL) || '1000', 10))
|
||||
|
||||
function changeFontSize(delta: number) {
|
||||
fontSize.value = Math.max(10, Math.min(24, fontSize.value + delta))
|
||||
localStorage.setItem(K_FONT, String(fontSize.value))
|
||||
forEachNative((_id, native) => {
|
||||
if (native.term) {
|
||||
native.term.options.fontSize = fontSize.value
|
||||
try {
|
||||
native.fitAddon?.fit()
|
||||
} catch {
|
||||
/* container size 0 */
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function changeScrollback(val: number) {
|
||||
scrollback.value = val
|
||||
localStorage.setItem(K_SCROLL, String(val))
|
||||
forEachNative((_id, native) => {
|
||||
if (native.term) native.term.options.scrollback = val
|
||||
})
|
||||
}
|
||||
|
||||
function applyFontToSession(sessionId: string) {
|
||||
const term = getNative(sessionId)?.term
|
||||
if (term) term.options.fontSize = fontSize.value
|
||||
}
|
||||
|
||||
return {
|
||||
fontSize,
|
||||
scrollback,
|
||||
scrollbackOptions,
|
||||
changeFontSize,
|
||||
changeScrollback,
|
||||
applyFontToSession,
|
||||
}
|
||||
}
|
||||
+223
-1061
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user