feat(workspace): 增强连接管理与终端状态展示

- 为连接管理页补充多级标签树、列头排序和行级更多菜单
- 支持同一 SSH 连接打开多个终端并显示终端序号
- 补充状态监控的内存与磁盘详情字段

---
 feat(workspace): enhance connection management and terminal status visibility

- add multi-level tag tree, sortable columns, and row-level more menu
- support multiple terminals per SSH connection with terminal indices
- extend status monitor with memory and disk detail fields
This commit is contained in:
yinjianm
2026-03-25 22:25:37 +08:00
parent 6553739c08
commit d730d06c5e
16 changed files with 798 additions and 72 deletions
+4
View File
@@ -22,3 +22,7 @@
- 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/) - 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/)
- **[frontend]**: 将连接管理页升级为左侧标签树、顶部搜索工具条和右侧结果列表的双栏管理台 — by yinjianm - **[frontend]**: 将连接管理页升级为左侧标签树、顶部搜索工具条和右侧结果列表的双栏管理台 — by yinjianm
- 方案: [202603250636_connections-view-tree-search-redesign](archive/2026-03/202603250636_connections-view-tree-search-redesign/) - 方案: [202603250636_connections-view-tree-search-redesign](archive/2026-03/202603250636_connections-view-tree-search-redesign/)
- **[frontend]**: 为连接管理页补充多级标签树、列头排序和行级更多菜单,并支持分组范围与展开状态持久化 — by yinjianm
- 方案: [202603252152_connections-tree-sort-more-menu](archive/2026-03/202603252152_connections-tree-sort-more-menu/)
- **[frontend]**: 为同一 SSH 服务器连接补充多终端入口与终端序号标识,默认首次仍只打开一个终端 — by yinjianm
- 方案: [202603252207_ssh-connection-multi-terminal](archive/2026-03/202603252207_ssh-connection-multi-terminal/)
+2 -2
View File
@@ -31,9 +31,9 @@
```yaml ```yaml
kb_version: 2.3.7 kb_version: 2.3.7
最后更新: 2026-03-25 06:46 最后更新: 2026-03-25 22:19
模块数量: 4 模块数量: 4
待执行方案: 0 待执行方案: 2
``` ```
## 读取指引 ## 读取指引
@@ -0,0 +1 @@
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"已完成连接管理页多级标签树、列头排序和更多菜单增强,并通过前端构建验证","updated_at":"2026-03-25 22:13:10"}
@@ -0,0 +1,65 @@
# 变更提案: connections-tree-sort-more-menu
## 元信息
```yaml
类型: 功能增强
方案类型: implementation
优先级: P1
状态: 已完成
状态说明: 已完成连接管理页多级标签树、列头排序和更多菜单增强,并通过前端构建验证
创建: 2026-03-25
```
---
## 1. 需求
### 背景
连接管理页上一轮已经升级为左侧范围树、顶部工具条和右侧结果列表,但离参考图仍有明显差距:标签仍是单层列表,右侧列头无法直接排序,单行操作也缺少“更多”菜单承载克隆/删除等次级动作。
### 目标
- 将左侧标签区升级为支持层级展开的多级标签树。
- 将右侧结果列表升级为可点击列头排序。
- 为每一行补上“更多”菜单,承载克隆、删除等次级操作。
### 约束条件
```yaml
范围约束: 优先限制在 ConnectionsView.vue,不改后端接口和标签表结构
兼容约束: 保留现有连接、编辑、测试、批量编辑和批量删除能力
数据约束: 多级树基于现有标签名称推导,不新增 hierarchy 字段
视觉约束: 延续当前黑绿主题和现有双栏结构,不新增仓库入口
```
### 验收标准
- [ ] 左侧标签区支持按层级展开/折叠浏览
- [ ] 右侧列表列头可点击切换排序字段和方向
- [ ] 每一行提供“更多”菜单,并可执行至少克隆和删除动作
- [ ] 前端构建通过
---
## 2. 方案
### 技术方案
`ConnectionsView.vue` 中把标签列表改成树形数据结构,按标签名称中的层级分隔符推导父子节点;右侧结果列表增加列配置和点击式排序入口,统一映射到已有 `localSortBy` / `localSortOrder` 状态;每一行补充受控下拉菜单,集中放置克隆和删除等次级操作,避免主按钮区继续膨胀。
### 影响范围
```yaml
涉及模块:
- frontend: ConnectionsView.vue
预计变更文件: 1
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 现有标签名没有统一层级分隔符,树结构收益不稳定 | 中 | 同时兼容 `/``>``\\` 三类分隔符,无分隔符时退回单层节点 |
| 列头排序和顶部排序控件共存可能造成状态混乱 | 低 | 统一复用同一组排序状态,并在交互上以列头为主、顶部为辅助 |
| 行内更多菜单与批量模式选择冲突 | 低 | 菜单按钮在批量模式下保持可见但不触发行点击选择,事件显式 stopPropagation |
### 实施结果
- `ConnectionsView.vue` 已基于现有标签名推导出多级树结构,支持 `/``>``\\` 分隔的层级路径,并持久化展开状态。
- 左侧树节点现在支持分组范围选择,`group:*` scope 会在刷新后继续恢复,不再退回“全部”。
- 右侧结果列表新增名称、地址、上次连接三列的点击式排序,并继续复用顶部排序状态。
- 每一行新增“更多”菜单,已接入克隆和删除动作,且通过 `stopPropagation` 避免与批量选择冲突。
- `npm run build --workspace @nexus-terminal/frontend` 通过。
@@ -0,0 +1,48 @@
# 任务清单: connections-tree-sort-more-menu
```yaml
@feature: connections-tree-sort-more-menu
@created: 2026-03-25
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
---
## 任务列表
### 1. 方案与范围确认
- [√] 1.1 创建连接管理页增强方案包并锁定为多级标签树、列头排序和更多菜单 | depends_on: []
### 2. 交互增强实现
- [√] 2.1 在 `ConnectionsView.vue` 中实现多级标签树和展开状态管理 | depends_on: [1.1]
- [√] 2.2 为右侧结果列表接入列头排序与行级更多菜单 | depends_on: [2.1]
### 3. 验证与同步
- [√] 3.1 运行前端构建验证并同步 `.helloagents` 文档与变更记录 | depends_on: [2.2]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-25 21:52 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为 ConnectionsView.vue 的树形筛选与列表增强 |
| 2026-03-25 22:05 | 2.1 / 2.2 | 完成 | 实现多级标签树、展开状态持久化、列头排序与行级更多菜单,并补齐 group scope 刷新恢复 |
| 2026-03-25 22:13 | 3.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过,并同步知识库与归档记录 |
---
## 执行备注
- 本轮是 `connections-view-tree-search-redesign` 的后续增强,目标是继续向参考图靠拢,但仍限制在单页增量改造。
- 标签层级仍由现有标签名即时推导,不新增后端 hierarchy 字段,也不引入新的仓库视图入口。
+4
View File
@@ -13,6 +13,8 @@
| 202603250603 | dark-green-night-theme | implementation | frontend, backend | - | ✅完成 | | 202603250603 | dark-green-night-theme | implementation | frontend, backend | - | ✅完成 |
| 202603250614 | terminal-ansi-color-effects | implementation | frontend, backend | - | ✅完成 | | 202603250614 | terminal-ansi-color-effects | implementation | frontend, backend | - | ✅完成 |
| 202603250636 | connections-view-tree-search-redesign | implementation | frontend | - | ✅完成 | | 202603250636 | connections-view-tree-search-redesign | implementation | frontend | - | ✅完成 |
| 202603252152 | connections-tree-sort-more-menu | implementation | frontend | - | ✅完成 |
| 202603252207 | ssh-connection-multi-terminal | implementation | frontend | ssh-connection-multi-terminal#D001 | ✅完成 |
| 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 | | 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 |
## 按月归档 ## 按月归档
@@ -24,6 +26,8 @@
- [202603250603_dark-green-night-theme](./2026-03/202603250603_dark-green-night-theme/) - 将黑暗模式预设与终端默认主题统一调整为黑绿夜间风格 - [202603250603_dark-green-night-theme](./2026-03/202603250603_dark-green-night-theme/) - 将黑暗模式预设与终端默认主题统一调整为黑绿夜间风格
- [202603250614_terminal-ansi-color-effects](./2026-03/202603250614_terminal-ansi-color-effects/) - 修复终端文字效果覆盖 ANSI 彩色输出的问题,并将文字效果默认开关改为开启 - [202603250614_terminal-ansi-color-effects](./2026-03/202603250614_terminal-ansi-color-effects/) - 修复终端文字效果覆盖 ANSI 彩色输出的问题,并将文字效果默认开关改为开启
- [202603250636_connections-view-tree-search-redesign](./2026-03/202603250636_connections-view-tree-search-redesign/) - 将连接管理页升级为左侧标签树、顶部搜索工具条和右侧结果列表的双栏管理台 - [202603250636_connections-view-tree-search-redesign](./2026-03/202603250636_connections-view-tree-search-redesign/) - 将连接管理页升级为左侧标签树、顶部搜索工具条和右侧结果列表的双栏管理台
- [202603252152_connections-tree-sort-more-menu](./2026-03/202603252152_connections-tree-sort-more-menu/) - 为连接管理页补充多级标签树、列头排序和行级更多菜单
- [202603252207_ssh-connection-multi-terminal](./2026-03/202603252207_ssh-connection-multi-terminal/) - 为同一 SSH 服务器连接补充多终端入口与终端序号标识
- [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控 - [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控
## 结果状态说明 ## 结果状态说明
+2 -2
View File
@@ -36,8 +36,8 @@
### 工作区交互 ### 工作区交互
**条件**: 用户进入 `/workspace` 或相关管理页面。 **条件**: 用户进入 `/workspace` 或相关管理页面。
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button``text-button-text``hover:bg-button-hover``hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复,并在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,左侧支持全部/未标记/标签树切换,右侧列表与类型筛选、搜索、排序、批量编辑和批量删除共用同一过滤管线;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 **行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button``text-button-text``hover:bg-button-hover``hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复,并在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex``TerminalTabBar.vue` 则把当前连接“继续新增终端”和“改连其他服务器”拆成独立入口,并在标签上显示终端序号,从而实现“单连接默认 1 个终端、可继续追加多个终端”的交互;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化和分组 scope 恢复,右侧结果列表则同时支持顶部排序控件、列头点击排序和行级“更多”菜单(克隆/删除);样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
**结果**: 页面逻辑分散在 `views/``components/``stores/``composables/`,其中布局与交互微调优先落在 `layout.store.ts``LayoutRenderer.vue``WorkspaceWorkbench.vue``QuickCommandsView.vue``ConnectionsView.vue``Terminal.vue``StyleCustomizerUiTab.vue``StyleCustomizerTerminalTab.vue` `features/appearance/config/default-themes.ts` **结果**: 页面逻辑分散在 `views/``components/``stores/``composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts``session/actions/sessionActions.ts``session/getters.ts``TerminalTabBar.vue``WorkspaceView.vue``Terminal.vue` 与相关 locale 文件
## 依赖关系 ## 依赖关系
@@ -9,12 +9,20 @@ interface ServerStatus {
memPercent?: number; memPercent?: number;
memUsed?: number; // MB memUsed?: number; // MB
memTotal?: number; // MB memTotal?: number; // MB
memFree?: number; // MB
memCached?: number; // MB
swapPercent?: number; swapPercent?: number;
swapUsed?: number; // MB swapUsed?: number; // MB
swapTotal?: number; // MB swapTotal?: number; // MB
diskPercent?: number; diskPercent?: number;
diskUsed?: number; // KB diskUsed?: number; // KB
diskTotal?: number; // KB diskTotal?: number; // KB
diskAvailable?: number; // KB
diskMountPoint?: string;
diskFsType?: string;
diskDevice?: string;
diskReadRate?: number; // Bytes per second
diskWriteRate?: number; // Bytes per second
cpuModel?: string; cpuModel?: string;
netRxRate?: number; // Bytes per second netRxRate?: number; // Bytes per second
netTxRate?: number; // Bytes per second netTxRate?: number; // Bytes per second
@@ -34,9 +42,17 @@ interface NetworkStats {
} }
} }
interface DiskIoStats {
[deviceName: string]: {
readBytes: number;
writeBytes: number;
}
}
// 用于存储上一次的网络统计信息以计算速率 // 用于存储上一次的网络统计信息以计算速率
const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>(); const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
const previousDiskStats = new Map<string, { device: string, readBytes: number, writeBytes: number, timestamp: number }>();
export class StatusMonitorService { export class StatusMonitorService {
private clientStates: Map<string, ClientState>; // 使用导入的 ClientState private clientStates: Map<string, ClientState>; // 使用导入的 ClientState
@@ -47,6 +63,32 @@ export class StatusMonitorService {
this.clientStates = clientStates; this.clientStates = clientStates;
} }
private async parseProcDiskStats(sshClient: Client): Promise<DiskIoStats | null> {
try {
const output = await this.executeSshCommand(sshClient, 'cat /proc/diskstats');
const stats: DiskIoStats = {};
for (const line of output.split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length < 10) continue;
const deviceName = parts[2];
const sectorsRead = parseInt(parts[5], 10);
const sectorsWritten = parseInt(parts[9], 10);
if (!isNaN(sectorsRead) && !isNaN(sectorsWritten)) {
stats[deviceName] = {
readBytes: sectorsRead * 512,
writeBytes: sectorsWritten * 512,
};
}
}
return Object.keys(stats).length > 0 ? stats : null;
} catch (error) {
return null;
}
}
/** /**
* 启动指定会话的状态轮询 * 启动指定会话的状态轮询
* @param sessionId 会话 ID * @param sessionId 会话 ID
@@ -88,6 +130,7 @@ export class StatusMonitorService {
clearInterval(state.statusIntervalId); clearInterval(state.statusIntervalId);
state.statusIntervalId = undefined; state.statusIntervalId = undefined;
previousNetStats.delete(sessionId); // 清理网络统计缓存 previousNetStats.delete(sessionId); // 清理网络统计缓存
previousDiskStats.delete(sessionId);
this.previousCpuStats.delete(sessionId); // 清理 CPU 统计缓存 this.previousCpuStats.delete(sessionId); // 清理 CPU 统计缓存
} }
} }
@@ -170,22 +213,52 @@ export class StatusMonitorService {
} }
const freeOutput = await this.executeSshCommand(sshClient, freeCommand); const freeOutput = await this.executeSshCommand(sshClient, freeCommand);
const lines = freeOutput.split('\n'); const lines = freeOutput.split('\n');
const headerLine = lines.find(line => line.toLowerCase().includes('total') && line.toLowerCase().includes('used'));
const memLine = lines.find(line => line.startsWith('Mem:')); const memLine = lines.find(line => line.startsWith('Mem:'));
const swapLine = lines.find(line => line.startsWith('Swap:')); const swapLine = lines.find(line => line.startsWith('Swap:'));
if (memLine) { if (memLine && headerLine) {
const headers = headerLine.trim().split(/\s+/);
const values = memLine.trim().split(/\s+/).slice(1);
const memoryFields: Record<string, number> = {};
headers.forEach((header, index) => {
const rawValue = parseInt(values[index], 10);
if (!isNaN(rawValue)) {
memoryFields[header.toLowerCase()] = isBusyBox ? Math.round(rawValue / 1024) : rawValue;
}
});
const totalVal = memoryFields.total;
const usedVal = memoryFields.used;
const freeVal = memoryFields.free;
const cachedVal = memoryFields['buff/cache'] ?? ((memoryFields.buffers ?? 0) + (memoryFields.cached ?? 0));
if (!isNaN(totalVal) && !isNaN(usedVal)) {
status.memTotal = totalVal;
status.memUsed = usedVal;
status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
status.memFree = !isNaN(freeVal) ? freeVal : Math.max(totalVal - usedVal - (cachedVal || 0), 0);
if (cachedVal > 0) {
status.memCached = cachedVal;
}
}
} else if (memLine) {
const parts = memLine.split(/\s+/); const parts = memLine.split(/\s+/);
if (parts.length >= 3) { if (parts.length >= 4) {
let totalVal = parseInt(parts[1], 10); let totalVal = parseInt(parts[1], 10);
let usedVal = parseInt(parts[2], 10); let usedVal = parseInt(parts[2], 10);
let freeVal = parseInt(parts[3], 10);
if (isBusyBox) { if (isBusyBox) {
if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024); if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024);
if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024); if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024);
if (!isNaN(freeVal)) freeVal = Math.round(freeVal / 1024);
} }
if (!isNaN(totalVal) && !isNaN(usedVal)) { if (!isNaN(totalVal) && !isNaN(usedVal)) {
status.memTotal = totalVal; status.memTotal = totalVal;
status.memUsed = usedVal; status.memUsed = usedVal;
status.memFree = !isNaN(freeVal) ? freeVal : undefined;
status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0; status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
} }
} }
@@ -216,12 +289,12 @@ export class StatusMonitorService {
try { try {
let dfCommand = "df -kP /"; // 优先尝试 POSIX 标准格式 let dfCommand = "df -kPT /";
let dfOutput: string; let dfOutput: string;
try { try {
dfOutput = await this.executeSshCommand(sshClient, dfCommand); dfOutput = await this.executeSshCommand(sshClient, dfCommand);
} catch (errP) { } catch (errP) {
dfCommand = "df -k /"; // 备用方案 dfCommand = "df -kP /";
try { try {
dfOutput = await this.executeSshCommand(sshClient, dfCommand); dfOutput = await this.executeSshCommand(sshClient, dfCommand);
} catch (errK) { } catch (errK) {
@@ -231,13 +304,38 @@ export class StatusMonitorService {
if (dfOutput) { if (dfOutput) {
const lines = dfOutput.split('\n'); const lines = dfOutput.split('\n');
let rawDiskDevice: string | undefined;
let parsedDiskInfo = false; let parsedDiskInfo = false;
// 从第二行开始查找根挂载点信息 (跳过表头) // 从第二行开始查找根挂载点信息 (跳过表头)
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim(); const line = lines[i].trim();
// 确保是根挂载点,通常以 " /" 结尾 // 确保是根挂载点,通常以 " /" 结尾
if (line.endsWith(" /")) { if (!line.endsWith(" /")) {
const parts = line.split(/\s+/); continue;
}
const parts = line.split(/\s+/);
const hasTypeColumn = parts.length >= 7;
const totalIndex = hasTypeColumn ? 2 : 1;
const usedIndex = hasTypeColumn ? 3 : 2;
const availableIndex = hasTypeColumn ? 4 : 3;
const percentIndex = hasTypeColumn ? 5 : 4;
const mountIndex = hasTypeColumn ? 6 : 5;
const total = parseInt(parts[totalIndex], 10);
const used = parseInt(parts[usedIndex], 10);
const available = parseInt(parts[availableIndex], 10);
const percentMatch = parts[percentIndex]?.match(/(\d+)%/);
if (!isNaN(total) && !isNaN(used) && !isNaN(available) && percentMatch?.[1]) {
rawDiskDevice = parts[0];
status.diskFsType = hasTypeColumn ? parts[1] : status.diskFsType;
status.diskTotal = total;
status.diskUsed = used;
status.diskAvailable = available;
status.diskPercent = parseFloat(percentMatch[1]);
status.diskMountPoint = parts[mountIndex] || '/';
break;
}
// 预期 parts 至少包含: 文件系统, 总量(KB), 已用(KB), 可用(KB), 百分比%, 挂载点 // 预期 parts 至少包含: 文件系统, 总量(KB), 已用(KB), 可用(KB), 百分比%, 挂载点
// 例如: /dev/sda1 10307920 3841884 5941800 40% / // 例如: /dev/sda1 10307920 3841884 5941800 40% /
if (parts.length >= 5) { if (parts.length >= 5) {
@@ -259,6 +357,46 @@ export class StatusMonitorService {
} }
} }
if (!rawDiskDevice || !status.diskFsType || !status.diskMountPoint) {
try {
const findmntOutput = await this.executeSshCommand(sshClient, 'findmnt -n -o SOURCE,FSTYPE,TARGET /');
const findmntParts = findmntOutput.trim().split(/\s+/);
rawDiskDevice = rawDiskDevice || findmntParts[0];
status.diskFsType = status.diskFsType || findmntParts[1];
status.diskMountPoint = status.diskMountPoint || findmntParts[2] || '/';
} catch (findmntErr) { /* 静默处理 */ }
}
status.diskDevice = this.normalizeDiskDevice(rawDiskDevice);
if (status.diskDevice) {
const currentDiskStats = await this.parseProcDiskStats(sshClient);
const deviceStats = currentDiskStats?.[status.diskDevice];
if (deviceStats) {
const previousStats = previousDiskStats.get(sessionId);
if (previousStats && previousStats.device === status.diskDevice && previousStats.timestamp < timestamp) {
const timeDiffSeconds = (timestamp - previousStats.timestamp) / 1000;
if (timeDiffSeconds > 0.1) {
status.diskReadRate = Math.max(0, Math.round((deviceStats.readBytes - previousStats.readBytes) / timeDiffSeconds));
status.diskWriteRate = Math.max(0, Math.round((deviceStats.writeBytes - previousStats.writeBytes) / timeDiffSeconds));
} else {
status.diskReadRate = 0;
status.diskWriteRate = 0;
}
} else {
status.diskReadRate = 0;
status.diskWriteRate = 0;
}
previousDiskStats.set(sessionId, {
device: status.diskDevice,
readBytes: deviceStats.readBytes,
writeBytes: deviceStats.writeBytes,
timestamp,
});
}
}
} }
} catch (err) { } catch (err) {
// 如果捕获到错误 (例如 executeSshCommand 内部的 Promise reject), disk* 字段将保持 undefined // 如果捕获到错误 (例如 executeSshCommand 内部的 Promise reject), disk* 字段将保持 undefined
@@ -414,6 +552,31 @@ export class StatusMonitorService {
return null; return null;
} }
private normalizeDiskDevice(rawDevice?: string): string | undefined {
if (!rawDevice) {
return undefined;
}
let normalized = rawDevice.trim();
if (!normalized) {
return undefined;
}
if (normalized.startsWith('/dev/')) {
normalized = normalized.slice(5);
}
if (/^(sd[a-z]+|vd[a-z]+|xvd[a-z]+|hd[a-z]+)\d+$/.test(normalized)) {
return normalized.replace(/\d+$/, '');
}
if (/^(nvme\d+n\d+|mmcblk\d+)p\d+$/.test(normalized)) {
return normalized.replace(/p\d+$/, '');
}
return normalized;
}
/** /**
* 在 SSH 连接上执行单个命令 * 在 SSH 连接上执行单个命令
* @param sshClient SSH 客户端实例 * @param sshClient SSH 客户端实例
@@ -64,6 +64,39 @@ const showConnectionListPopup = ref(false); // 连接列表弹出状态
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态 const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态
const activeSessionState = computed(() => {
if (!props.activeSessionId) {
return null;
}
return sessionStore.sessions.get(props.activeSessionId) ?? null;
});
const activeConnectionInfo = computed(() => {
const activeSession = activeSessionState.value;
if (!activeSession) {
return null;
}
return connectionsStore.connections.find((connection) => connection.id === Number(activeSession.connectionId)) ?? null;
});
const canAddTerminalToActiveConnection = computed(() => activeConnectionInfo.value?.type === 'SSH');
const openNewTerminalForActiveConnection = () => {
const activeConnection = activeConnectionInfo.value;
if (!activeConnection || activeConnection.type !== 'SSH') {
showConnectionListPopup.value = true;
return;
}
sessionStore.handleOpenNewSession(activeConnection.id);
};
const openConnectionPicker = () => {
showConnectionListPopup.value = true;
};
// + Watch prop changes to update local state // + Watch prop changes to update local state
watch(() => props.sessions, (newSessions) => { watch(() => props.sessions, (newSessions) => {
// Create a shallow copy to avoid modifying the prop directly // Create a shallow copy to avoid modifying the prop directly
@@ -174,6 +207,22 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
// 注意:关闭左侧通常不包括当前标签本身 // 注意:关闭左侧通常不包括当前标签本身
emitWorkspaceEvent('session:closeToLeft', { targetSessionId: targetId }); emitWorkspaceEvent('session:closeToLeft', { targetSessionId: targetId });
break; break;
case 'new-terminal': {
const targetSessionState = sessionStore.sessions.get(targetId);
if (!targetSessionState) {
console.warn(`[TabBar] 'new-terminal' action failed: session ${targetId} not found.`);
break;
}
const targetConnectionInfo = connectionsStore.connections.find(c => c.id === Number(targetSessionState.connectionId));
if (!targetConnectionInfo || targetConnectionInfo.type !== 'SSH') {
console.warn(`[TabBar] 'new-terminal' action ignored for non-SSH connection. targetId=${targetId}`);
break;
}
sessionStore.handleOpenNewSession(targetConnectionInfo.id);
break;
}
case 'mark-for-suspend': // +++ 修改 action 名称 +++ case 'mark-for-suspend': // +++ 修改 action 名称 +++
if (typeof targetId === 'string') { if (typeof targetId === 'string') {
console.log(`[TabBar] Context menu action 'mark-for-suspend' requested for session ID: ${targetId}`); console.log(`[TabBar] Context menu action 'mark-for-suspend' requested for session ID: ${targetId}`);
@@ -213,6 +262,7 @@ const contextMenuItems = computed(() => {
// 添加标记/取消标记挂起会话菜单项(如果适用) // 添加标记/取消标记挂起会话菜单项(如果适用)
if (connectionInfo && connectionInfo.type === 'SSH') { if (connectionInfo && connectionInfo.type === 'SSH') {
items.push({ label: 'terminalTabBar.newTerminalTooltip', action: 'new-terminal' });
const isActiveSession = targetSessionState.wsManager.isConnected.value; const isActiveSession = targetSessionState.wsManager.isConnected.value;
if (isActiveSession) { // 只对活动的SSH会话显示相关操作 if (isActiveSession) { // 只对活动的SSH会话显示相关操作
if (targetSessionState.isMarkedForSuspend) { if (targetSessionState.isMarkedForSuspend) {
@@ -446,6 +496,12 @@ onBeforeUnmount(() => {
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' : session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span> session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="truncate text-sm" style="transform: translateY(-1px);">{{ session.connectionName }}</span> <span class="truncate text-sm" style="transform: translateY(-1px);">{{ session.connectionName }}</span>
<span
class="ml-2 inline-flex flex-shrink-0 items-center rounded-full border border-border px-1.5 py-0.5 text-[11px] leading-none text-text-secondary"
:title="t('terminalTabBar.terminalBadge', { index: session.terminalIndex })"
>
{{ session.terminalIndex }}
</span>
<button class="ml-2 p-0.5 rounded-full text-text-secondary hover:bg-border hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-150" <button class="ml-2 p-0.5 rounded-full text-text-secondary hover:bg-border hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-150"
:class="{'text-foreground hover:bg-header': session.sessionId === activeSessionId}" :class="{'text-foreground hover:bg-header': session.sessionId === activeSessionId}"
@click="closeSession($event, session.sessionId)" :title="$t('tabs.closeTabTooltip')"> @click="closeSession($event, session.sessionId)" :title="$t('tabs.closeTabTooltip')">
@@ -456,11 +512,18 @@ onBeforeUnmount(() => {
</li> </li>
</template> </template>
</draggable> </draggable>
<!-- Add Tab Button --> <!-- Add Terminal Button -->
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0" <button
@click="togglePopup" :title="$t('tabs.newTabTooltip')"> class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
@click="openNewTerminalForActiveConnection"
:title="canAddTerminalToActiveConnection ? t('terminalTabBar.newTerminalTooltip') : t('tabs.newTabTooltip')">
<i class="fas fa-plus text-sm"></i> <i class="fas fa-plus text-sm"></i>
</button> </button>
<!-- Open Connection Picker Button -->
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
@click="openConnectionPicker" :title="t('terminalTabBar.openConnectionPickerTooltip')">
<i class="fas fa-server text-sm"></i>
</button>
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex items-center ml-auto h-full flex-shrink-0"> <div class="flex items-center ml-auto h-full flex-shrink-0">
+4 -1
View File
@@ -1457,7 +1457,10 @@
}, },
"terminalTabBar": { "terminalTabBar": {
"selectServerTitle": "Select server to connect", "selectServerTitle": "Select server to connect",
"showTransferProgressTooltip": "Show/Hide Transfer Progress" "showTransferProgressTooltip": "Show/Hide Transfer Progress",
"newTerminalTooltip": "Open another terminal for the current server",
"openConnectionPickerTooltip": "Choose another server",
"terminalBadge": "Terminal {index}"
}, },
"tabs": { "tabs": {
"contextMenu": { "contextMenu": {
+4 -1
View File
@@ -1419,7 +1419,10 @@
}, },
"terminalTabBar": { "terminalTabBar": {
"selectServerTitle": "接続するサーバーを選択", "selectServerTitle": "接続するサーバーを選択",
"showTransferProgressTooltip": "転送進捗の表示/非表示" "showTransferProgressTooltip": "転送進捗の表示/非表示",
"newTerminalTooltip": "現在のサーバーに新しいターミナルを追加",
"openConnectionPickerTooltip": "別のサーバーを選択",
"terminalBadge": "端末 {index}"
}, },
"workspace": { "workspace": {
"terminal": { "terminal": {
+4 -1
View File
@@ -1461,7 +1461,10 @@
}, },
"terminalTabBar": { "terminalTabBar": {
"selectServerTitle": "选择要连接的服务器", "selectServerTitle": "选择要连接的服务器",
"showTransferProgressTooltip": "显示/隐藏传输进度" "showTransferProgressTooltip": "显示/隐藏传输进度",
"newTerminalTooltip": "为当前服务器新增终端",
"openConnectionPickerTooltip": "选择其他服务器",
"terminalBadge": "终端 {index}"
}, },
"tabs": { "tabs": {
"contextMenu": { "contextMenu": {
@@ -21,6 +21,18 @@ const findConnectionInfo = (connectionId: number | string, connectionsStore: Ret
return connectionsStore.connections.find(c => c.id === Number(connectionId)); return connectionsStore.connections.find(c => c.id === Number(connectionId));
}; };
const getNextTerminalIndex = (connectionId: string): number => {
let maxTerminalIndex = 0;
sessions.value.forEach((session) => {
if (session.connectionId === connectionId) {
maxTerminalIndex = Math.max(maxTerminalIndex, session.terminalIndex || 0);
}
});
return maxTerminalIndex + 1;
};
// --- Actions --- // --- Actions ---
export const openNewSession = ( export const openNewSession = (
connectionOrId: ConnectionInfo | number | string, connectionOrId: ConnectionInfo | number | string,
@@ -51,6 +63,7 @@ export const openNewSession = (
const newSessionId = existingSessionId || generateSessionId(); const newSessionId = existingSessionId || generateSessionId();
const dbConnId = String(connInfo.id); // connInfo is now guaranteed to be defined here const dbConnId = String(connInfo.id); // connInfo is now guaranteed to be defined here
const terminalIndex = getNextTerminalIndex(dbConnId);
// 1. 创建管理器实例 // 1. 创建管理器实例
const isResume = !!existingSessionId; // 如果提供了 existingSessionId,则为恢复流程 const isResume = !!existingSessionId; // 如果提供了 existingSessionId,则为恢复流程
@@ -60,6 +73,7 @@ export const openNewSession = (
sessionId: newSessionId, sessionId: newSessionId,
connectionId: dbConnId, connectionId: dbConnId,
connectionName: connInfo.name || connInfo.host, connectionName: connInfo.name || connInfo.host,
terminalIndex,
editorTabs: ref([]), editorTabs: ref([]),
activeEditorTabId: ref(null), activeEditorTabId: ref(null),
commandInputContent: ref(''), commandInputContent: ref(''),
@@ -115,7 +129,7 @@ export const openNewSession = (
newSessionsMap.set(newSessionId, newSession); newSessionsMap.set(newSessionId, newSession);
sessions.value = newSessionsMap; sessions.value = newSessionsMap;
activeSessionId.value = newSessionId; activeSessionId.value = newSessionId;
console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId}`); console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId} (terminal #${terminalIndex})`);
// +++ 在连接前设置 ssh:connected 处理器以更新 sessionId +++ // +++ 在连接前设置 ssh:connected 处理器以更新 sessionId +++
const originalFrontendSessionIdForHandler = newSessionId; // 捕获初始ID给闭包 const originalFrontendSessionIdForHandler = newSessionId; // 捕获初始ID给闭包
@@ -320,4 +334,4 @@ export const cleanupAllSessions = () => {
sessions.value = newSessionsMap; sessions.value = newSessionsMap;
} }
activeSessionId.value = null; activeSessionId.value = null;
}; };
@@ -7,7 +7,9 @@ import type { SessionState, SessionTabInfoWithStatus } from './types';
export const sessionTabs = computed(() => { export const sessionTabs = computed(() => {
return Array.from(sessions.value.values()).map(session => ({ return Array.from(sessions.value.values()).map(session => ({
sessionId: session.sessionId, sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName, connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
})); }));
}); });
@@ -39,7 +41,9 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
}) })
.map(session => ({ .map(session => ({
sessionId: session.sessionId, sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName, connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态 status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
isMarkedForSuspend: session.isMarkedForSuspend, isMarkedForSuspend: session.isMarkedForSuspend,
})); }));
@@ -49,7 +53,9 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
.sort((a, b) => a.createdAt - b.createdAt) .sort((a, b) => a.createdAt - b.createdAt)
.map(session => ({ .map(session => ({
sessionId: session.sessionId, sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName, connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态 status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
isMarkedForSuspend: session.isMarkedForSuspend, isMarkedForSuspend: session.isMarkedForSuspend,
})); }));
@@ -59,4 +65,4 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
export const activeSession = computed((): SessionState | null => { export const activeSession = computed((): SessionState | null => {
if (!activeSessionId.value) return null; if (!activeSessionId.value) return null;
return sessions.value.get(activeSessionId.value) || null; return sessions.value.get(activeSessionId.value) || null;
}); });
@@ -29,6 +29,7 @@ export interface SessionState {
sessionId: string; sessionId: string;
connectionId: string; // 数据库中的连接 ID connectionId: string; // 数据库中的连接 ID
connectionName: string; // 用于显示 connectionName: string; // 用于显示
terminalIndex: number; // 同一连接下的终端序号,从 1 开始
wsManager: WsManagerInstance; wsManager: WsManagerInstance;
sftpManagers: Map<string, SftpManagerInstance>; // 使用 Map 管理多个实例 sftpManagers: Map<string, SftpManagerInstance>; // 使用 Map 管理多个实例
terminalManager: SshTerminalInstance; terminalManager: SshTerminalInstance;
@@ -49,7 +50,9 @@ export interface SessionState {
// 为标签栏定义包含状态的类型 // 为标签栏定义包含状态的类型
export interface SessionTabInfoWithStatus { export interface SessionTabInfoWithStatus {
sessionId: string; sessionId: string;
connectionId: string;
connectionName: string; connectionName: string;
terminalIndex: number;
status: WsConnectionStatus; // 添加状态字段 status: WsConnectionStatus; // 添加状态字段
isMarkedForSuspend?: boolean; // +++ 用于UI指示会话是否已标记待挂起 +++ isMarkedForSuspend?: boolean; // +++ 用于UI指示会话是否已标记待挂起 +++
} }
+397 -51
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import AddConnectionForm from '../components/AddConnectionForm.vue'; import AddConnectionForm from '../components/AddConnectionForm.vue';
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue'; import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
import { useConnectionsStore } from '../stores/connections.store'; import { useConnectionsStore } from '../stores/connections.store';
@@ -17,7 +17,8 @@ import { zhCN, enUS, ja } from 'date-fns/locale';
import type { Locale } from 'date-fns'; import type { Locale } from 'date-fns';
type ConnectionTypeFilter = 'ALL' | 'SSH' | 'RDP' | 'VNC'; type ConnectionTypeFilter = 'ALL' | 'SSH' | 'RDP' | 'VNC';
type ScopeId = 'all' | 'untagged' | `tag:${number}`; type ScopeId = 'all' | 'untagged' | `tag:${number}` | `group:${string}`;
type ConnectionSortField = SortField | 'host';
interface ScopeNode { interface ScopeNode {
id: ScopeId; id: ScopeId;
@@ -25,6 +26,13 @@ interface ScopeNode {
count: number; count: number;
} }
interface TagTreeNode extends ScopeNode {
fullLabel: string;
level: number;
expandable: boolean;
children: TagTreeNode[];
}
interface ConnectionTestState { interface ConnectionTestState {
status: 'idle' | 'testing' | 'success' | 'error'; status: 'idle' | 'testing' | 'success' | 'error';
resultText: string; resultText: string;
@@ -48,12 +56,17 @@ const LS_FILTER_TAG_KEY = 'connections_view_filter_tag';
const LS_FILTER_SCOPE_KEY = 'connections_view_filter_scope'; const LS_FILTER_SCOPE_KEY = 'connections_view_filter_scope';
const LS_TYPE_FILTER_KEY = 'connections_view_type_filter'; const LS_TYPE_FILTER_KEY = 'connections_view_type_filter';
const localSortBy = ref<SortField>(localStorage.getItem(LS_SORT_BY_KEY) as SortField || 'last_connected_at'); const localSortBy = ref<ConnectionSortField>((localStorage.getItem(LS_SORT_BY_KEY) as ConnectionSortField) || 'last_connected_at');
const localSortOrder = ref<SortOrder>(localStorage.getItem(LS_SORT_ORDER_KEY) as SortOrder || 'desc'); const localSortOrder = ref<SortOrder>(localStorage.getItem(LS_SORT_ORDER_KEY) as SortOrder || 'desc');
const getInitialSelectedScope = (): ScopeId => { const getInitialSelectedScope = (): ScopeId => {
const storedScope = localStorage.getItem(LS_FILTER_SCOPE_KEY); const storedScope = localStorage.getItem(LS_FILTER_SCOPE_KEY);
if (storedScope === 'all' || storedScope === 'untagged' || storedScope?.startsWith('tag:')) { if (
storedScope === 'all' ||
storedScope === 'untagged' ||
storedScope?.startsWith('tag:') ||
storedScope?.startsWith('group:')
) {
return storedScope as ScopeId; return storedScope as ScopeId;
} }
@@ -80,21 +93,45 @@ const isBatchEditMode = ref(false);
const selectedConnectionIdsForBatch = ref<Set<number>>(new Set()); const selectedConnectionIdsForBatch = ref<Set<number>>(new Set());
const showBatchEditForm = ref(false); const showBatchEditForm = ref(false);
const isDeletingSelectedConnections = ref(false); const isDeletingSelectedConnections = ref(false);
const expandedTreeNodes = ref<Record<string, boolean>>({});
const connectionTestStates = ref<Map<number, ConnectionTestState>>(new Map()); const connectionTestStates = ref<Map<number, ConnectionTestState>>(new Map());
const isTestingAll = ref(false); const isTestingAll = ref(false);
const isConnectingAll = ref(false); const isConnectingAll = ref(false);
const moreMenuOpenForId = ref<number | null>(null);
const sortOptions: { value: SortField; labelKey: string }[] = [ const sortOptions: { value: ConnectionSortField; labelKey: string }[] = [
{ value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' }, { value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' },
{ value: 'name', labelKey: 'dashboard.sortOptions.name' }, { value: 'name', labelKey: 'dashboard.sortOptions.name' },
{ value: 'host', labelKey: 'connections.table.host' },
{ value: 'type', labelKey: 'dashboard.sortOptions.type' }, { value: 'type', labelKey: 'dashboard.sortOptions.type' },
{ value: 'updated_at', labelKey: 'dashboard.sortOptions.updated' }, { value: 'updated_at', labelKey: 'dashboard.sortOptions.updated' },
{ value: 'created_at', labelKey: 'dashboard.sortOptions.created' }, { value: 'created_at', labelKey: 'dashboard.sortOptions.created' },
]; ];
const TREE_EXPANDED_STORAGE_KEY = 'connections_view_tree_expanded';
const tagPathSeparatorRegex = /\s*(?:\/|>|\\)\s*/;
const normalizedSearchQuery = computed(() => searchQuery.value.toLowerCase().trim()); const normalizedSearchQuery = computed(() => searchQuery.value.toLowerCase().trim());
const loadInitialExpandedTreeState = (): Record<string, boolean> => {
try {
const rawValue = localStorage.getItem(TREE_EXPANDED_STORAGE_KEY);
if (!rawValue) {
return {};
}
const parsed = JSON.parse(rawValue);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch (error) {
console.error('读取连接管理树展开状态失败:', error);
localStorage.removeItem(TREE_EXPANDED_STORAGE_KEY);
return {};
}
};
expandedTreeNodes.value = loadInitialExpandedTreeState();
const tagLookup = computed(() => { const tagLookup = computed(() => {
const map = new Map<number, TagInfo>(); const map = new Map<number, TagInfo>();
(tags.value as TagInfo[]).forEach((tag) => { (tags.value as TagInfo[]).forEach((tag) => {
@@ -113,6 +150,21 @@ const getConnectionTagNames = (conn: ConnectionInfo): string[] => {
.filter((tagName): tagName is string => Boolean(tagName)); .filter((tagName): tagName is string => Boolean(tagName));
}; };
const getTagPathSegments = (tagName: string): string[] => {
return tagName
.split(tagPathSeparatorRegex)
.map((segment) => segment.trim())
.filter(Boolean);
};
const encodeGroupScopeId = (pathKey: string): ScopeId => {
return `group:${encodeURIComponent(pathKey)}`;
};
const decodeGroupScopeId = (scopeId: ScopeId): string => {
return decodeURIComponent(scopeId.replace('group:', ''));
};
const matchesSearchQuery = (conn: ConnectionInfo, query: string): boolean => { const matchesSearchQuery = (conn: ConnectionInfo, query: string): boolean => {
if (!query) { if (!query) {
return true; return true;
@@ -141,7 +193,19 @@ const matchesScope = (conn: ConnectionInfo, scope: ScopeId): boolean => {
} }
const tagId = parseInt(scope.replace('tag:', ''), 10); const tagId = parseInt(scope.replace('tag:', ''), 10);
return conn.tag_ids?.includes(tagId) ?? false; if (!Number.isNaN(tagId) && conn.tag_ids?.includes(tagId)) {
return true;
}
if (scope.startsWith('group:')) {
const pathPrefix = decodeGroupScopeId(scope);
return getConnectionTagNames(conn).some((tagName) => {
const pathKey = getTagPathSegments(tagName).join('/');
return pathKey === pathPrefix || pathKey.startsWith(`${pathPrefix}/`);
});
}
return false;
}; };
const matchedConnections = computed(() => { const matchedConnections = computed(() => {
@@ -151,16 +215,110 @@ const matchedConnections = computed(() => {
}); });
}); });
const tagTreeNodes = computed<ScopeNode[]>(() => { const tagTreeNodes = computed<TagTreeNode[]>(() => {
return (tags.value as TagInfo[]) type DraftTreeNode = {
.map((tag) => ({ id: ScopeId;
id: `tag:${tag.id}` as ScopeId, label: string;
label: tag.name, fullLabel: string;
count: matchedConnections.value.filter((conn) => conn.tag_ids?.includes(tag.id)).length, children: Map<string, DraftTreeNode>;
})) tagId: number | null;
.sort((left, right) => left.label.localeCompare(right.label)); };
const rootChildren = new Map<string, DraftTreeNode>();
(tags.value as TagInfo[]).forEach((tag) => {
const segments = getTagPathSegments(tag.name);
if (!segments.length) {
return;
}
let currentChildren = rootChildren;
const currentPathSegments: string[] = [];
segments.forEach((segment, index) => {
currentPathSegments.push(segment);
const pathKey = currentPathSegments.join('/');
const isLeaf = index === segments.length - 1;
const nodeKey = (isLeaf ? `tag:${tag.id}` : encodeGroupScopeId(pathKey)) as ScopeId;
if (!currentChildren.has(nodeKey)) {
currentChildren.set(nodeKey, {
id: nodeKey,
label: segment,
fullLabel: currentPathSegments.join(' / '),
children: new Map<string, DraftTreeNode>(),
tagId: isLeaf ? tag.id : null,
});
}
const currentNode = currentChildren.get(nodeKey)!;
if (isLeaf) {
currentNode.fullLabel = tag.name;
currentNode.tagId = tag.id;
}
currentChildren = currentNode.children;
});
});
const buildNodes = (source: Map<string, DraftTreeNode>, level: number): TagTreeNode[] => {
return Array.from(source.values())
.sort((left, right) => left.label.localeCompare(right.label))
.map((node) => {
const children = buildNodes(node.children, level + 1);
const count =
node.tagId !== null
? matchedConnections.value.filter((conn) => conn.tag_ids?.includes(node.tagId!)).length
: matchedConnections.value.filter((conn) => matchesScope(conn, node.id)).length;
return {
id: node.id,
label: node.label,
fullLabel: node.fullLabel,
count,
level,
expandable: children.length > 0,
children,
};
});
};
return buildNodes(rootChildren, 0);
}); });
const visibleTagTreeNodes = computed<TagTreeNode[]>(() => {
const rows: TagTreeNode[] = [];
const appendVisibleNodes = (nodes: TagTreeNode[]) => {
nodes.forEach((node) => {
rows.push(node);
if (node.expandable && (expandedTreeNodes.value[node.id] ?? true)) {
appendVisibleNodes(node.children);
}
});
};
appendVisibleNodes(tagTreeNodes.value);
return rows;
});
const expandableTreeNodeIds = computed<ScopeId[]>(() => {
const ids: ScopeId[] = [];
const collectNodeIds = (nodes: TagTreeNode[]) => {
nodes.forEach((node) => {
if (node.expandable) {
ids.push(node.id);
collectNodeIds(node.children);
}
});
};
collectNodeIds(tagTreeNodes.value);
return ids;
});
const hasExpandableTreeNodes = computed(() => expandableTreeNodeIds.value.length > 0);
const primaryScopeNodes = computed<ScopeNode[]>(() => { const primaryScopeNodes = computed<ScopeNode[]>(() => {
return [ return [
{ {
@@ -192,6 +350,10 @@ const filteredAndSortedConnections = computed(() => {
leftValue = left.name || left.host; leftValue = left.name || left.host;
rightValue = right.name || right.host; rightValue = right.name || right.host;
return String(leftValue).localeCompare(String(rightValue)) * sortOrderFactor; return String(leftValue).localeCompare(String(rightValue)) * sortOrderFactor;
case 'host':
leftValue = left.host || '';
rightValue = right.host || '';
return String(leftValue).localeCompare(String(rightValue)) * sortOrderFactor;
case 'type': case 'type':
leftValue = left.type || ''; leftValue = left.type || '';
rightValue = right.type || ''; rightValue = right.type || '';
@@ -225,6 +387,10 @@ const selectedScopeTitle = computed(() => {
return t('connections.untaggedGroup', '未标记'); return t('connections.untaggedGroup', '未标记');
} }
if (selectedScope.value.startsWith('group:')) {
return decodeGroupScopeId(selectedScope.value).replaceAll('/', ' / ');
}
const selectedTagId = parseInt(selectedScope.value.replace('tag:', ''), 10); const selectedTagId = parseInt(selectedScope.value.replace('tag:', ''), 10);
return tagLookup.value.get(selectedTagId)?.name || t('connections.table.tags', '标签'); return tagLookup.value.get(selectedTagId)?.name || t('connections.table.tags', '标签');
}); });
@@ -404,6 +570,14 @@ watch(activeTypeFilter, (newValue) => {
localStorage.setItem(LS_TYPE_FILTER_KEY, newValue); localStorage.setItem(LS_TYPE_FILTER_KEY, newValue);
}); });
watch(
expandedTreeNodes,
(newValue) => {
localStorage.setItem(TREE_EXPANDED_STORAGE_KEY, JSON.stringify(newValue));
},
{ deep: true },
);
watch([selectedScope, activeTypeFilter, searchQuery], () => { watch([selectedScope, activeTypeFilter, searchQuery], () => {
if (isBatchEditMode.value) { if (isBatchEditMode.value) {
const visibleIds = new Set(filteredAndSortedConnections.value.map((conn) => conn.id)); const visibleIds = new Set(filteredAndSortedConnections.value.map((conn) => conn.id));
@@ -419,6 +593,38 @@ const selectScope = (scopeId: ScopeId) => {
selectedScope.value = scopeId; selectedScope.value = scopeId;
}; };
const toggleTreeNode = (nodeId: ScopeId) => {
expandedTreeNodes.value[nodeId] = !(expandedTreeNodes.value[nodeId] ?? true);
};
const handleTreeNodeSelect = (node: TagTreeNode) => {
selectScope(node.id);
if (node.expandable) {
toggleTreeNode(node.id);
}
};
const expandAllTreeNodes = () => {
if (!hasExpandableTreeNodes.value) {
return;
}
tagsSectionExpanded.value = true;
expandedTreeNodes.value = Object.fromEntries(expandableTreeNodeIds.value.map((nodeId) => [nodeId, true]));
};
const collapseAllTreeNodes = () => {
if (!hasExpandableTreeNodes.value) {
return;
}
expandedTreeNodes.value = Object.fromEntries(expandableTreeNodeIds.value.map((nodeId) => [nodeId, false]));
};
const resetScopeSelection = () => {
selectScope('all');
};
const connectTo = (connection: ConnectionInfo) => { const connectTo = (connection: ConnectionInfo) => {
sessionStore.handleConnectRequest(connection); sessionStore.handleConnectRequest(connection);
}; };
@@ -437,6 +643,16 @@ const openEditConnectionForm = (connection: ConnectionInfo) => {
showAddEditConnectionForm.value = true; showAddEditConnectionForm.value = true;
}; };
const handleSortByColumn = (field: ConnectionSortField) => {
if (localSortBy.value === field) {
toggleSortOrder();
return;
}
localSortBy.value = field;
localSortOrder.value = field === 'last_connected_at' ? 'desc' : 'asc';
};
const handleFormClose = () => { const handleFormClose = () => {
showAddEditConnectionForm.value = false; showAddEditConnectionForm.value = false;
connectionToEdit.value = null; connectionToEdit.value = null;
@@ -561,6 +777,34 @@ const handleBatchDeleteConnections = async () => {
} }
}; };
const handleCloneConnection = async (connection: ConnectionInfo) => {
const allConnections = connectionsStore.connections;
const baseName = connection.name || connection.host;
let counter = 1;
let newName = `${baseName} (${counter})`;
while (allConnections.some((item) => item.name === newName)) {
counter += 1;
newName = `${baseName} (${counter})`;
}
await connectionsStore.cloneConnection(connection.id, newName);
await connectionsStore.fetchConnections();
};
const handleDeleteSingleConnection = async (connection: ConnectionInfo) => {
const confirmed = await showConfirmDialog({
message: t('connections.prompts.confirmDelete', { name: connection.name || connection.host }),
});
if (!confirmed) {
return;
}
await connectionsStore.deleteConnection(connection.id);
await connectionsStore.fetchConnections();
};
const handleTestSingleConnection = async (connection: ConnectionInfo) => { const handleTestSingleConnection = async (connection: ConnectionInfo) => {
if (!connection.id || connection.type !== 'SSH') { if (!connection.id || connection.type !== 'SSH') {
return; return;
@@ -636,6 +880,26 @@ const handleConnectAllFilteredConnections = async () => {
isConnectingAll.value = false; isConnectingAll.value = false;
} }
}; };
const toggleMoreMenu = (connectionId: number) => {
moreMenuOpenForId.value = moreMenuOpenForId.value === connectionId ? null : connectionId;
};
const closeMoreMenu = () => {
moreMenuOpenForId.value = null;
};
const handleGlobalClick = () => {
closeMoreMenu();
};
onMounted(() => {
document.addEventListener('click', handleGlobalClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleGlobalClick);
});
</script> </script>
<template> <template>
@@ -705,28 +969,72 @@ const handleConnectAllFilteredConnections = async () => {
<section> <section>
<div class="px-2 mb-2 flex items-center justify-between gap-3 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary/80"> <div class="px-2 mb-2 flex items-center justify-between gap-3 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary/80">
<span>{{ t('connections.table.tags', '标签') }}</span> <span>{{ t('connections.table.tags', '标签') }}</span>
<span class="text-[11px] tracking-normal normal-case text-text-secondary">{{ tagTreeNodes.length }}</span> <span class="text-[11px] tracking-normal normal-case text-text-secondary">{{ visibleTagTreeNodes.length }}</span>
</div> </div>
<div v-show="tagsSectionExpanded" class="space-y-1 max-h-[520px] overflow-y-auto pr-1"> <div v-show="tagsSectionExpanded" class="space-y-2">
<button <div class="flex flex-wrap items-center gap-2 px-2">
v-for="node in tagTreeNodes" <button
@click="expandAllTreeNodes"
:disabled="!hasExpandableTreeNodes"
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-square-plus"></i>
<span>{{ t('common.expandAll', '展开全部') }}</span>
</button>
<button
@click="collapseAllTreeNodes"
:disabled="!hasExpandableTreeNodes"
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-square-minus"></i>
<span>{{ t('common.collapseAll', '收起全部') }}</span>
</button>
<button
@click="resetScopeSelection"
:disabled="selectedScope === 'all'"
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-rotate-left"></i>
<span>{{ t('common.reset', '重置范围') }}</span>
</button>
</div>
<div class="px-2 flex items-center justify-between gap-3 text-[11px] text-text-secondary">
<span>{{ t('connections.scopeHintCompact', '树节点按标签路径自动分层') }}</span>
<span>{{ selectedScopeTitle }}</span>
</div>
<div class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
<div
v-for="node in visibleTagTreeNodes"
:key="node.id" :key="node.id"
@click="selectScope(node.id)"
:class="[ :class="[
'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150', 'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getScopeNodeClass(node.id), getScopeNodeClass(node.id),
node.count === 0 ? 'opacity-55' : '' node.count === 0 ? 'opacity-55' : ''
]" ]"
:style="{ paddingLeft: `${0.75 + node.level * 1.05}rem` }"
> >
<span class="flex items-center gap-2 min-w-0"> <button
<i class="fas fa-folder-tree w-4 text-center"></i> class="flex items-center gap-2 min-w-0 flex-1"
<span class="truncate">{{ node.label }}</span> @click="handleTreeNodeSelect(node)"
</span> >
<span class="px-2 py-0.5 rounded-full text-xs border border-current/15 bg-black/10"> <i
v-if="node.expandable"
:class="[
'fas w-4 text-center transition-transform duration-150',
(expandedTreeNodes[node.id] ?? true) ? 'fa-chevron-down' : 'fa-chevron-right'
]"
></i>
<i v-else class="fas fa-circle text-[8px] w-4 text-center opacity-60"></i>
<span class="truncate" :title="node.fullLabel">{{ node.label }}</span>
</button>
<span class="px-2 py-0.5 rounded-full text-xs border border-current/15 bg-black/10 flex-shrink-0">
{{ node.count }} {{ node.count }}
</span> </span>
</button> </div>
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -891,11 +1199,20 @@ const handleConnectAllFilteredConnections = async () => {
</div> </div>
<div v-else class="min-w-0"> <div v-else class="min-w-0">
<div class="hidden xl:grid grid-cols-[minmax(0,2.2fr)_minmax(0,1.4fr)_minmax(0,1.3fr)_160px_220px] gap-4 px-4 py-3 text-xs font-semibold uppercase tracking-[0.16em] text-text-secondary border-b border-border/50 bg-background/40 sticky top-0 z-10"> <div class="hidden xl:grid grid-cols-[minmax(0,2.25fr)_minmax(0,1.45fr)_minmax(0,1.25fr)_160px_170px] gap-4 px-4 py-3 text-xs font-semibold uppercase tracking-[0.16em] text-text-secondary border-b border-border/50 bg-background/40 sticky top-0 z-10">
<div>{{ t('connections.table.name', '名称') }}</div> <button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('name')">
<div>{{ t('connections.table.host', '地址') }}</div> <span>{{ t('connections.table.name', '名称') }}</span>
<i v-if="localSortBy === 'name'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
</button>
<button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('host')">
<span>{{ t('connections.table.host', '地址') }}</span>
<i v-if="localSortBy === 'host'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
</button>
<div>{{ t('connections.table.tags', '标签 / 备注') }}</div> <div>{{ t('connections.table.tags', '标签 / 备注') }}</div>
<div>{{ t('dashboard.lastConnected', '上次连接') }}</div> <button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('last_connected_at')">
<span>{{ t('dashboard.lastConnected', '上次连接') }}</span>
<i v-if="localSortBy === 'last_connected_at'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
</button>
<div>{{ t('connections.table.actions', '操作') }}</div> <div>{{ t('connections.table.actions', '操作') }}</div>
</div> </div>
@@ -910,7 +1227,7 @@ const handleConnectAllFilteredConnections = async () => {
isBatchEditMode && isConnectionSelectedForBatch(conn.id) ? 'bg-primary/10' : '' isBatchEditMode && isConnectionSelectedForBatch(conn.id) ? 'bg-primary/10' : ''
]" ]"
> >
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,2.2fr)_minmax(0,1.4fr)_minmax(0,1.3fr)_160px_220px] gap-4 items-start"> <div class="grid grid-cols-1 xl:grid-cols-[minmax(0,2.25fr)_minmax(0,1.45fr)_minmax(0,1.25fr)_160px_170px] gap-4 items-start">
<div class="min-w-0 flex items-start gap-3"> <div class="min-w-0 flex items-start gap-3">
<input <input
v-if="isBatchEditMode" v-if="isBatchEditMode"
@@ -997,27 +1314,7 @@ const handleConnectAllFilteredConnections = async () => {
</div> </div>
</div> </div>
<div class="flex flex-wrap justify-start xl:justify-end gap-2"> <div class="flex flex-wrap justify-start xl:justify-end gap-2 relative">
<button
v-if="conn.type === 'SSH'"
@click.stop="handleTestSingleConnection(conn)"
:disabled="isBatchEditMode || getSingleTestButtonInfo(conn.id, conn.type).disabled"
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2 text-sm"
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
>
<i :class="getSingleTestButtonInfo(conn.id, conn.type).iconClass"></i>
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
</button>
<button
@click.stop="openEditConnectionForm(conn)"
:disabled="isBatchEditMode"
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2 text-sm"
>
<i class="fas fa-pen"></i>
<span>{{ t('connections.actions.edit', '编辑') }}</span>
</button>
<button <button
@click.stop="connectTo(conn)" @click.stop="connectTo(conn)"
:disabled="isBatchEditMode" :disabled="isBatchEditMode"
@@ -1026,6 +1323,55 @@ const handleConnectAllFilteredConnections = async () => {
<i class="fas fa-arrow-right-to-bracket"></i> <i class="fas fa-arrow-right-to-bracket"></i>
<span>{{ t('connections.actions.connect', '连接') }}</span> <span>{{ t('connections.actions.connect', '连接') }}</span>
</button> </button>
<div class="relative">
<button
@click.stop="toggleMoreMenu(conn.id)"
:disabled="isBatchEditMode"
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-ellipsis"></i>
<span>{{ t('common.more', '更多') }}</span>
</button>
<div
v-if="moreMenuOpenForId === conn.id"
class="absolute right-0 top-full mt-2 w-48 rounded-xl border border-border bg-background shadow-xl z-20 overflow-hidden"
@click.stop
>
<button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2"
@click.stop="openEditConnectionForm(conn); closeMoreMenu()"
>
<i class="fas fa-pen w-4 text-center"></i>
<span>{{ t('connections.actions.edit', '编辑') }}</span>
</button>
<button
v-if="conn.type === 'SSH'"
:disabled="getSingleTestButtonInfo(conn.id, conn.type).disabled"
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click.stop="handleTestSingleConnection(conn); closeMoreMenu()"
>
<i :class="[getSingleTestButtonInfo(conn.id, conn.type).iconClass, 'w-4 text-center']"></i>
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2"
@click.stop="handleCloneConnection(conn); closeMoreMenu()"
>
<i class="fas fa-clone w-4 text-center"></i>
<span>{{ t('connections.actions.clone', '克隆') }}</span>
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-error hover:bg-error/10 transition-colors flex items-center gap-2"
@click.stop="handleDeleteSingleConnection(conn); closeMoreMenu()"
>
<i class="fas fa-trash-alt w-4 text-center"></i>
<span>{{ t('connections.actions.delete', '删除') }}</span>
</button>
</div>
</div>
</div> </div>
</div> </div>
</li> </li>