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:
Your Name
2026-06-02 06:44:56 +08:00
parent 181560eca2
commit 68fdcbae0a
16 changed files with 2351 additions and 1062 deletions
@@ -0,0 +1,73 @@
# 2026-06-02 — 终端页重构 T1T5
## 摘要
`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/ResizeObserveruptime/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` | 无需变更 |
---
## 实施步骤
### T1markRaw 修复 + Uptime 计时器内嵌(Critical 修复)
**目标**:解决 C1Vue 代理 xterm/WebSocket)和 C3Uptime 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 对象
---
### T2Per-Session OverlayCritical 修复)
**目标**:解决 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">`
- 删除模板中的重复列表代码
---
### T6Minor 体验修复
**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 | 手动断网,切换 Taboverlay 正确显示 |
| 最后 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` 回滚到重构前。
---
## 执行顺序建议
```
T1markRaw + timer, 1h → T2per-session overlay, 1h
→ T6minor fixes, 0.5h
→ T3composables, 2h)→ T4components, 2h)→ T5dom + 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 overlayC2+ 修复 E1/E2 | 中,需重构状态管理 |
| **T3** | 提取 Composables(4 个)| 中,逻辑搬移,行为不变 |
| **T4** | 拆分 Components6 个)| 低,模板拆分 |
| **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,
}
}
File diff suppressed because it is too large Load Diff