feat(frontend): 增强工作台快捷指令与仪表盘能力

补充快捷指令动态变量解析与编辑弹窗一键插入,
统一列表执行、粘贴到终端和批量发送的处理链路

扩展快捷命令右键菜单动作,并为文件面板新增
多根目录资源管理器式侧栏浏览体验

为首页 dashboard 增加当前用户与系统总览双视角的
实时会话指标展示,并同步更新相关知识库记录
This commit is contained in:
yinjianm
2026-03-26 01:39:42 +08:00
parent a2ac4047d9
commit 3f6e2bffc6
35 changed files with 2206 additions and 190 deletions
+14
View File
@@ -18,10 +18,24 @@
- 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/)
### 新增
- **[frontend]**: 为工作台文件面板补齐左侧多根目录资源管理器,支持收藏路径与当前路径同屏作为多个根目录展开浏览 — by yinjianm
- 方案: [202603260041_workbench-file-multi-root-explorer](archive/2026-03/202603260041_workbench-file-multi-root-explorer/)
- **[frontend]**: 为快捷指令编辑弹窗补充动态变量清单与点击插入,并统一列表执行/弹窗执行的动态变量解析链路 — by yinjianm
- 方案: [202603260042_quickcommands-dynamic-variables](archive/2026-03/202603260042_quickcommands-dynamic-variables/)
- **[frontend]**: 为快捷命令列表补齐图标化右键菜单,支持立即执行、粘贴到终端输入框、复制命令、粘贴到快捷输入框,并保留发送到全部服务器/编辑/删除动作 — by yinjianm
- 方案: [202603260038_quickcommands-context-menu-actions](archive/2026-03/202603260038_quickcommands-context-menu-actions/)
- **[frontend]**: 为连接管理页新增独立“登录凭证”入口,并在新增连接/批量编辑中支持选择已保存凭证或继续直填账号密码/密钥 — by yinjianm
- 方案: [202603252354_login-credential-management](plan/202603252354_login-credential-management/)
- **[backend]**: 新增 `login_credentials` 数据模型与 `/api/v1/login-credentials` 接口,并让连接创建、更新和测试支持引用已保存登录凭证 — by yinjianm
- 方案: [202603252354_login-credential-management](plan/202603252354_login-credential-management/)
- **[frontend]**: 将首页仪表盘升级为统计卡片、趋势/分布图和活跃连接排行组成的管理驾驶舱 — by yinjianm
- 方案: [202603252343_dashboard-management-cockpit](archive/2026-03/202603252343_dashboard-management-cockpit/)
- **[backend]**: 新增 `/api/v1/dashboard/summary` 聚合接口,统一输出首页所需的连接、审计和 SSH 统计摘要 — by yinjianm
- 方案: [202603252343_dashboard-management-cockpit](archive/2026-03/202603252343_dashboard-management-cockpit/)
- **[frontend]**: 为首页 dashboard 增加“我的会话 / 系统总览”双视角实时指标面板,展示在线 SSH、挂起会话和状态监控流 — by yinjianm
- 方案: [202603260043_dashboard-live-session-metrics](archive/2026-03/202603260043_dashboard-live-session-metrics/)
- **[backend]**: 扩展 `/api/v1/dashboard/summary`,组合 `clientStates``sshSuspendService` 返回当前用户和系统范围的实时会话指标 — by yinjianm
- 方案: [202603260043_dashboard-live-session-metrics](archive/2026-03/202603260043_dashboard-live-session-metrics/)
- **[frontend]**: 将底部命令输入框升级为支持多行草稿与自动增高,并把发送快捷键改为 `Ctrl+Shift+Enter` — by yinjianm
- 方案: [202603252340_command-input-multiline-shortcut](archive/2026-03/202603252340_command-input-multiline-shortcut/)
- **[frontend]**: 将服务器状态中的内存与磁盘区域升级为卡片化监控视图,补齐环形内存占比、磁盘设备信息、读写速率与挂载表格展示 — by yinjianm
+2 -2
View File
@@ -31,9 +31,9 @@
```yaml
kb_version: 2.3.7
最后更新: 2026-03-25 23:45
最后更新: 2026-03-26 00:43
模块数量: 4
待执行方案: 0
待执行方案: 4
```
## 读取指引
@@ -0,0 +1 @@
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"已完成:快捷命令右键菜单动作扩展","updated_at":"2026-03-26 00:43:00"}
@@ -0,0 +1,141 @@
# 变更提案: quickcommands-context-menu-actions
## 元信息
```yaml
类型: 优化
方案类型: implementation
优先级: P1
状态: 已完成
创建: 2026-03-26
完成: 2026-03-26
```
---
## 1. 需求
### 背景
当前快捷命令列表项虽然支持右键菜单,但菜单中只有“发送到全部会话”一个动作,和用户提供的参考图不一致。用户希望在快捷命令部分的右键菜单中补齐常用动作,包括立即执行、粘贴到终端输入框、复制命令、粘贴到快捷输入框、编辑和删除。
### 目标
- 将快捷命令右键菜单扩展为与参考图接近的多动作菜单。
- 保持“立即执行”为对当前活动 SSH 会话直接发送命令。
- 将“粘贴到终端”实现为写入底部命令输入框但不发送。
- 将“粘贴到快捷输入框”实现为写入快捷命令顶部搜索输入框,便于二次筛选或编辑。
### 约束条件
```yaml
时间约束: 本轮内完成前端菜单扩展与基础验证
性能约束: 不引入新依赖,沿用现有视图、store 和事件总线
兼容性约束: 保持现有快捷命令执行、复制、编辑、删除和“发送到全部会话”能力不回退
业务约束: “粘贴到终端”必须只写入命令输入框,不回车发送;菜单样式尽量贴近参考图
```
### 验收标准
- [ ] 快捷命令右键菜单包含“立即执行 / 粘贴到终端 / 复制命令 / 粘贴到快捷输入框 / 编辑 / 删除”
- [ ] “立即执行”继续向当前活动 SSH 会话发送处理后的命令
- [ ] “粘贴到终端”仅写入底部命令输入框,不自动发送
- [ ] “粘贴到快捷输入框”写入快捷命令顶部搜索输入框并刷新筛选
- [ ] `packages/frontend` 的构建验证通过
---
## 2. 方案
### 技术方案
继续在 `QuickCommandsView.vue` 内扩展现有右键菜单,不新拆组件。将快捷命令处理逻辑抽成“解析命令内容”和“执行菜单动作”两个层级:命令变量替换等公共处理复用给“立即执行”和“粘贴到终端”,复制/编辑/删除则复用现有函数。菜单模板改为图标 + 文案列表,并保留“发送到全部会话”作为扩展动作。文案统一写入 locale。
### 影响范围
```yaml
涉及模块:
- frontend: `QuickCommandsView.vue` 的右键菜单结构与动作逻辑
- frontend: 快捷命令 locale 文案
预计变更文件: 3-4
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 右键菜单新增动作后与现有点击、hover 按钮行为冲突 | 中 | 仅在现有 context menu 状态机上扩展,不改列表项主点击路径 |
| “粘贴到终端”与“立即执行”语义混淆,导致误发送 | 中 | 将终端输入框写入逻辑单独封装,明确不触发 `terminal:sendCommand` |
| locale 文案分散,容易遗漏多语言同步 | 低 | 同轮同步 zh-CN / en-US / ja-JP |
---
## 3. 技术设计(可选)
### 架构设计
```mermaid
flowchart LR
A[QuickCommandsView context menu] --> B[resolveProcessedCommand]
A --> C[executeCommand]
A --> D[pasteToTerminalInput]
A --> E[pasteToQuickCommandsSearch]
A --> F[copy/edit/delete]
```
### 数据模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `quickCommandContextTargetCommand` | `QuickCommandFE \| null` | 当前右键命中的快捷命令 |
| `quickCommandContextMenuVisible` | `boolean` | 右键菜单显示状态 |
| `activeSessionId` | `string \| undefined` | 当前活动 SSH 会话,用于“立即执行”和“粘贴到终端” |
---
## 4. 核心场景
### 场景: 右键快捷命令后立即执行
**模块**: frontend
**条件**: 用户在快捷命令列表中右键某条命令,且当前存在活动 SSH 会话。
**行为**: 点击“立即执行”后,对命令完成变量替换并发送到当前活动会话。
**结果**: 用户无需左键执行即可从右键菜单直接运行命令。
### 场景: 右键快捷命令后粘贴到终端输入框
**模块**: frontend
**条件**: 用户在快捷命令列表中右键某条命令,且当前存在活动 SSH 会话。
**行为**: 点击“粘贴到终端”后,将处理后的命令写入底部命令输入框,但不触发发送。
**结果**: 用户可以继续手动修改命令,再自行决定何时发送。
### 场景: 右键快捷命令后回填到快捷输入框
**模块**: frontend
**条件**: 用户在快捷命令列表中右键某条命令。
**行为**: 点击“粘贴到快捷输入框”后,将命令内容写入快捷命令顶部搜索框,并触发筛选。
**结果**: 用户可基于命令内容快速筛选、比对或继续编辑快捷命令。
---
## 5. 技术决策
### quickcommands-context-menu-actions#D001: 继续在 `QuickCommandsView.vue` 内扩展菜单逻辑,而不是新建独立上下文菜单组件
**日期**: 2026-03-26
**状态**: ✅采纳
**背景**: 当前右键菜单已经在 `QuickCommandsView.vue` 内实现了显示、定位和关闭逻辑,只是动作过少。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 直接扩展现有右键菜单 | 改动集中,复用现有状态和依赖,交付速度快 | 菜单逻辑继续留在单文件内 |
| B: 抽离独立菜单组件 | 结构更分明 | 需要额外抽 props / emits,当前收益不高 |
**决策**: 选择方案A
**理由**: 这是一次局部交互增强,现有右键菜单基础已经足够,直接扩展更符合最小改动原则。
**影响**: frontend
---
## 6. 成果设计
### 设计方向
- **美学基调**: 延续当前深色工作台菜单风格,接近参考图的紧凑右键菜单
- **记忆点**: 图标与文案并列的多动作菜单,删除项保留危险色强调
- **参考**: 用户提供的右键菜单截图
### 视觉要素
- **配色**: 常规动作使用前景色与 hover 高亮,删除项使用错误色
- **字体**: 沿用当前界面菜单字号和字体体系
- **布局**: 单列垂直菜单项,图标左对齐,文字紧凑排列
- **动效**: 保持现有 hover 颜色过渡
- **氛围**: 保持工具型菜单克制感,不过度装饰
### 技术约束
- **可访问性**: 菜单项文案明确区分“立即执行”和“粘贴到终端”
- **响应式**: 保持现有防越界定位逻辑
@@ -0,0 +1,45 @@
# 任务清单: quickcommands-context-menu-actions
```yaml
@feature: quickcommands-context-menu-actions
@created: 2026-03-26
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
---
## 任务列表
### 1. 快捷命令右键菜单扩展
- [√] 1.1 在 `packages/frontend/src/views/QuickCommandsView.vue` 中补齐右键菜单动作与样式,加入立即执行、粘贴到终端、复制命令、粘贴到快捷输入框、编辑、删除,并保留现有扩展动作 | depends_on: []
- [√] 1.2 在 `packages/frontend/src/views/QuickCommandsView.vue` 中补齐命令处理与联动逻辑,确保“粘贴到终端”只写入底部命令输入框、不发送 | depends_on: [1.1]
### 2. 文案与验证
- [√] 2.1 更新 `packages/frontend/src/locales/zh-CN.json``packages/frontend/src/locales/en-US.json``packages/frontend/src/locales/ja-JP.json` 中的快捷命令右键菜单文案和提示信息 | depends_on: [1.2]
- [√] 2.2 执行 `packages/frontend` 的构建验证,确认类型检查与打包通过 | depends_on: [2.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-26 00:38 | DESIGN | completed | 已确认右键菜单需扩展为 5 个主动作,“粘贴到终端”只写入底部命令输入框不发送 |
| 2026-03-26 00:42 | 1.1 / 1.2 | 完成 | `QuickCommandsView.vue` 已补齐多动作右键菜单,并区分立即执行、粘贴到终端输入框和粘贴到快捷输入框三种语义 |
| 2026-03-26 00:42 | 2.1 | 完成 | 已同步中文、英文、日文快捷命令右键菜单文案与提示信息 |
| 2026-03-26 00:43 | 2.2 | 完成 | `packages/frontend` 执行 `npm run build` 通过,仅保留既有 Vite 动态导入与 chunk 警告 |
---
## 执行备注
> 当前环境缺少可直接调用的 `python/py`,方案包通过模板降级方式手工创建。本轮未做浏览器内真实右键交互截图对照,运行态确认以代码审查和前端构建通过为主。
@@ -0,0 +1 @@
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"工作台文件多根目录树已完成并通过前端构建验证","updated_at":"2026-03-26 00:56:00"}
@@ -0,0 +1,58 @@
# 变更提案: workbench-file-multi-root-explorer
## 元信息
```yaml
类型: 功能增强
方案类型: implementation
优先级: P1
状态: 进行中
状态说明: 已完成工作台文件多根目录树侧栏,保留右侧现有文件表格并通过前端构建验证
创建: 2026-03-26
```
---
## 1. 需求
### 背景
当前工作台文件页仍是单一路径的文件表格,虽然可以通过路径栏和收藏路径跳转,但无法像参考图那样在同一侧栏里同时展示多个目录根并独立展开浏览。
### 目标
- 在工作台文件面板中支持同屏显示多个目录根。
- 各目录根可独立展开/折叠,交互接近资源管理器。
- 保留现有文件表格与操作能力,不回退上传、重命名、删除、编辑等已有功能。
### 约束条件
```yaml
范围约束: 优先限制在 FileManager.vue,不改后端接口和 SFTP 协议
状态约束: 优先复用现有 favoritePaths 与 SFTP fileTree,不新建第二套远端文件状态链
交互约束: 左侧树作为目录浏览入口,右侧继续保留当前目录表格
兼容约束: 现有路径输入、收藏路径弹窗、上传与上下文菜单行为保持可用
```
### 验收标准
- [x] 文件面板出现多根目录树侧栏
- [x] 收藏路径与当前路径可作为多个根目录同时展示
- [x] 点击树节点可切换右侧当前目录或打开文件
- [x] 前端构建通过
---
## 2. 方案
### 技术方案
`FileManager.vue` 内复用 `favoritePaths.store` 作为多根目录来源,并结合当前 `currentPath` 动态补入当前目录根;树数据直接从 `createSftpActionsManager` 暴露的 `fileTree` 中派生,按目录层级递归展开,左侧渲染资源管理器式树,右侧继续保留现有文件表格与所有文件操作。对尚未加载的目录,点击时复用现有 `loadDirectory(path)` 触发懒加载。
### 影响范围
```yaml
涉及模块:
- frontend: FileManager.vue
预计变更文件: 1-4
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| `loadDirectory` 会更新当前路径,树懒加载与右侧表格联动可能显得过于激进 | 中 | 将树节点点击定义为“聚焦该目录”,允许目录切换驱动右侧同步更新 |
| 收藏路径之间存在父子重叠,侧栏可能出现重复层级 | 中 | 保留多根并明确标识根目录,避免擅自折叠合并破坏用户心智 |
| `fileTree` 仅缓存已访问目录,初始树可能不完整 | 低 | 根节点默认可见,展开时按现有 SFTP 请求链路懒加载 |
@@ -0,0 +1,42 @@
# 任务清单: workbench-file-multi-root-explorer
```yaml
@feature: workbench-file-multi-root-explorer
@created: 2026-03-26
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
---
## 任务列表
### 1. 方案与范围确认
- [√] 1.1 创建工作台文件多根目录树方案包 | depends_on: []
### 2. 交互增强实现
- [√] 2.1 在 `FileManager.vue` 中实现多根目录树状态与节点交互 | depends_on: [1.1]
- [√] 2.2 将文件面板改成左侧多根目录树加右侧现有表格布局 | depends_on: [2.1]
### 3. 验证与同步
- [√] 3.1 运行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [2.2]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-26 00:41 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为工作台文件面板多根目录树增强 |
| 2026-03-26 00:52 | 2.1 | 完成 | 复用 favoritePaths 与 SFTP fileTree,为 FileManager 补齐多根目录树状态、懒加载展开和目录/文件打开逻辑 |
| 2026-03-26 00:55 | 2.2 | 完成 | 将文件面板改成左侧资源管理器式多根目录树 + 右侧现有文件表格布局 |
| 2026-03-26 00:56 | 3.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过,准备同步知识库并归档方案包 |
@@ -0,0 +1 @@
{"status":"completed","completed":6,"failed":0,"pending":0,"total":6,"done":6,"percent":100,"current":"已完成快捷指令动态变量解析、编辑弹窗插入与前端构建验证,待归档","updated_at":"2026-03-26 01:08:00"}
@@ -0,0 +1,153 @@
# 变更提案: quickcommands-dynamic-variables
## 元信息
```yaml
类型: 新功能
方案类型: implementation
优先级: P1
状态: 草稿
创建: 2026-03-26
```
---
## 1. 需求
### 背景
当前快捷指令仅支持用户手动维护的 `${变量名}` 替换,编辑弹窗左侧也只有“变量管理”区。用户希望在“编辑快捷指令”部分直接看到一组可点击插入的动态变量,例如日期、时间、UUID、随机串、剪贴板和 SSH 登录密码,并在真正执行快捷指令时自动填充这些值。
### 目标
- 在编辑快捷指令弹窗中新增“动态变量”说明与点击插入能力。
- 支持 `${{date}}``${{date:YYYYMMDD}}` 这类动态变量写法,并保留现有 `${NAME}` 自定义变量能力。
- 统一“编辑弹窗内执行”和“快捷指令列表直接执行”两条链路的变量解析逻辑,避免一处支持、一处失效。
### 约束条件
```yaml
时间约束: 本轮内完成前端实现与构建验证
性能约束: 不引入新依赖,优先复用现有 Vue/Pinia 与浏览器原生能力
兼容性约束: 现有 `${变量名}` 自定义变量保存、编辑、执行行为不得回退
业务约束:
- 动态变量点击后应插入到当前指令文本中,而不是只做展示
- 自动填充必须同时覆盖“编辑弹窗执行”和“列表直接执行”
- 剪贴板类变量取值失败时不能导致整个执行链路崩溃
```
### 验收标准
- [ ] 编辑快捷指令弹窗中出现“动态变量”区,至少覆盖日期时间、唯一标识和系统三类变量
- [ ] 点击动态变量项后,指令文本区域会插入对应占位符
- [ ] `${{date}}``${{time}}``${{timestamp}}``${{week}}``${{uuid}}``${{random}}``${{random:8}}``${{clipboard}}``${{password}}` 在执行时会被自动填充
- [ ] `${{date:YYYYMMDD}}``${{time:HHmmss}}` 这类带格式参数的变量可正确生效
- [ ] 现有 `${变量名}` 自定义变量替换继续生效
- [ ] `packages/frontend` 构建验证通过
---
## 2. 方案
### 技术方案
新增一个前端快捷指令变量解析工具,将“自定义变量替换 + 动态变量填充 + 未定义变量检查”收敛到同一处。`AddEditQuickCommandForm.vue` 负责提供动态变量清单、说明文案和点击插入;`QuickCommandsView.vue` 与表单内“执行”按钮都改为调用同一套解析函数。动态变量按运行时来源分层实现:
- 时间类变量通过前端当前时间即时生成;
- `uuid` 使用浏览器 `crypto.randomUUID()`,必要时做降级;
- `random[:len]` 由本地字符集生成;
- `clipboard` 使用浏览器 Clipboard API 读取;
- `password` 优先从当前活动会话关联的连接信息中读取保存的登录密码字段,取不到时按空串处理并给出提示。
### 影响范围
```yaml
涉及模块:
- frontend: 快捷指令编辑弹窗 UI
- frontend: 快捷指令执行链路
- frontend: locale 文案
预计变更文件: 5-6
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 两条执行链路继续复制解析逻辑,后续容易再次偏离 | 中 | 抽出共享解析工具,只保留调用层差异 |
| `clipboard` 读取受浏览器权限或交互上下文限制 | 中 | 读取失败时返回空串并给出非阻断提示 |
| `password` 来源不稳定,不同连接类型字段可能不完全一致 | 中 | 先基于当前 SSH 连接常规密码字段取值,取不到时保持空串并避免抛错 |
| locale 文案遗漏导致编辑区出现回退文案 | 低 | 同轮同步 zh-CN / en-US / ja-JP |
---
## 3. 技术设计(可选)
### 架构设计
```mermaid
flowchart LR
A[AddEditQuickCommandForm] --> B[insertDynamicVariable]
A --> C[resolveQuickCommandTemplate]
D[QuickCommandsView] --> C
C --> E[自定义变量替换]
C --> F[动态变量解析]
F --> G[time/date helpers]
F --> H[clipboard/password providers]
```
### 数据模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `QuickCommandVariableContext` | object | 执行时上下文,包含自定义变量、活动会话和连接信息 |
| `DynamicVariableDefinition` | object | 动态变量展示项定义,包含 key、label、description、example |
| `ResolveQuickCommandResult` | object | 返回处理后命令、未定义变量、动态变量告警 |
---
## 4. 核心场景
### 场景: 在编辑快捷指令时点击插入动态变量
**模块**: frontend
**条件**: 用户打开“编辑快捷指令”弹窗。
**行为**: 用户点击动态变量卡片后,将对应 `${{...}}` 占位符插入到指令文本中。
**结果**: 用户不需要手写复杂占位符格式。
### 场景: 在编辑弹窗内执行带动态变量的快捷指令
**模块**: frontend
**条件**: 当前存在活动 SSH 会话,指令中包含动态变量或自定义变量。
**行为**: 点击“执行”后统一解析模板,再发送处理后的命令。
**结果**: 编辑态预执行与保存后执行行为一致。
### 场景: 直接从快捷指令列表执行带动态变量的命令
**模块**: frontend
**条件**: 用户在工作台快捷指令列表中点击某条命令。
**行为**: 列表执行逻辑复用共享解析器,自动填充动态变量后发送。
**结果**: 保存后的快捷指令在运行态自动得到真实值。
---
## 5. 技术决策
### quickcommands-dynamic-variables#D001: 用共享解析工具统一快捷指令变量处理,而不是在两个视图内各自扩展
**日期**: 2026-03-26
**状态**: ✅采纳
**背景**: 当前 `AddEditQuickCommandForm.vue``QuickCommandsView.vue` 已各自实现一份 `${变量名}` 替换逻辑,继续在两个位置分别补动态变量会放大分叉风险。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 抽共享解析工具 | 规则唯一、两条执行链一致、后续容易扩展更多变量 | 需要多一个工具文件 |
| B: 原位各自扩展 | 改动表面更直接 | 逻辑重复,后续维护成本更高 |
**决策**: 选择方案A
**理由**: 本次需求的核心不是单纯加一块 UI,而是让“编辑态执行”和“列表态执行”共享同一套变量解析语义。
**影响**: frontend
---
## 6. 成果设计
### 设计方向
- **美学基调**: 延续当前快捷指令编辑弹窗的工具型深色界面,在左侧变量区域内加入结构清晰的动态变量分组
- **记忆点**: 每个动态变量同时展示名称、说明、示例,占位符可以一键插入
- **参考**: 用户提供的动态变量列表说明
### 视觉要素
- **配色**: 复用现有输入区与边框体系,用主色 hover 强调“可点击插入”
- **字体**: 标题沿用当前界面字体,变量占位符和示例使用等宽字体
- **布局**: 左侧分组列表纵向展开,变量项内部保持“名称 + 描述 + 示例”三段式
- **动效**: 沿用当前按钮 hover 过渡,不额外加入复杂动画
- **氛围**: 以“清楚可用”为主,避免文档墙式堆砌
### 技术约束
- **响应式**: 在当前弹窗可调整宽高前提下保持左右栏可滚动
- **可访问性**: 动态变量项需有明确 hover/click 反馈,示例文本可复制识别
@@ -0,0 +1,55 @@
# 任务清单: quickcommands-dynamic-variables
```yaml
@feature: quickcommands-dynamic-variables
@created: 2026-03-26
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 6 | 0 | 0 | 6 |
---
## 任务列表
### 1. 共享解析能力
- [√] 1.1 新增快捷指令变量解析工具,统一处理自定义变量替换、动态变量解析与告警收集 | depends_on: []
### 2. 编辑弹窗动态变量区
- [√] 2.1 在 `packages/frontend/src/components/AddEditQuickCommandForm.vue` 中加入动态变量分组展示与点击插入能力 | depends_on: [1.1]
- [√] 2.2 将编辑弹窗内“执行”逻辑改为复用共享解析工具,并处理剪贴板/密码类变量告警 | depends_on: [2.1]
### 3. 列表执行链路统一
- [√] 3.1 在 `packages/frontend/src/views/QuickCommandsView.vue` 中改为复用共享解析工具,保证列表直接执行支持动态变量 | depends_on: [1.1]
### 4. 文案与验证
- [√] 4.1 更新 `packages/frontend/src/locales/zh-CN.json``packages/frontend/src/locales/en-US.json``packages/frontend/src/locales/ja-JP.json` 的动态变量说明文案 | depends_on: [2.2, 3.1]
- [√] 4.2 执行 `packages/frontend` 构建验证,确认类型检查与打包通过 | depends_on: [4.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-26 00:42 | DESIGN | completed | 已确认动态变量需同时覆盖编辑弹窗执行与快捷指令列表直接执行 |
| 2026-03-26 01:05 | 1.1 | completed | 新增 `quickCommandTemplate.ts`,统一自定义变量与动态变量解析 |
| 2026-03-26 01:05 | 2.1/2.2 | completed | 编辑快捷指令弹窗新增动态变量区,并改为复用共享解析器 |
| 2026-03-26 01:06 | 3.1 | completed | 快捷指令列表执行、粘贴到终端和发送到全部服务器接入动态变量解析 |
| 2026-03-26 01:07 | 4.1 | completed | 已补齐 zh-CN / en-US / ja-JP 动态变量文案 |
| 2026-03-26 01:08 | 4.2 | completed | `npm run build` 通过,仅保留既有 chunk 体积告警 |
---
## 执行备注
> `create_package.py` 在当前环境执行失败且无可用输出,本方案包按模板结构手工创建。`${{password}}` 当前优先从活动 SSH 会话关联的已保存登录凭证解析;若前端运行态取不到明文密码,则回退为空串并给出警告,不阻断命令执行。
@@ -0,0 +1,10 @@
{
"status": "completed",
"completed": 5,
"failed": 0,
"pending": 0,
"total": 5,
"percent": 100,
"current": "全部任务完成,等待知识库归档",
"updated_at": "2026-03-26 00:50:00"
}
@@ -0,0 +1,170 @@
# 变更提案: dashboard-live-session-metrics
## 元信息
```yaml
类型: 新功能
方案类型: implementation
优先级: P1
状态: 草稿
创建: 2026-03-26
```
---
## 1. 需求
### 背景
首页 dashboard 已经具备稳定统计能力,但仍缺少运行态视角。用户继续要求补“在线会话数/实时指标”,并明确选择“混合视角”,意味着首页同时需要展示当前登录用户的运行态会话信息,以及整个系统范围内的总在线会话概况。
### 目标
- 在现有 dashboard summary 基础上补充运行态指标,而不是另起一套接口。
- 同时展示“当前用户视角”和“系统总览视角”的在线 SSH / 挂起会话指标。
- 补充能体现实时性的附加指标,例如被监控中的活跃状态流数量,让首页更接近真实运维总控台。
- 保持首页首屏仍为单次 summary 请求,不要求前端直接订阅 WebSocket 状态。
### 约束条件
```yaml
时间约束: 本轮内完成前后端联动与基础构建验证
性能约束: 运行态统计应在现有 dashboard summary 接口内组合返回,避免首页新增额外请求
兼容性约束: 不改变现有 workspace WebSocket 协议与挂起会话 API 语义
业务约束: 当前用户口径必须与后端登录态一致;系统口径必须来自后端共享运行态,而不是浏览器本地 session store 猜测
```
### 验收标准
- [ ] `GET /api/v1/dashboard/summary` 返回新增的 live metrics 字段
- [ ] 首页同时展示当前用户在线 SSH / 挂起会话数与系统总在线 SSH / 挂起会话数
- [ ] 首页补充至少一个与实时运行态有关的附加指标,例如当前活跃状态监控流数量
- [ ] 前端 UI 对新增实时指标完成中英日文案适配
- [ ] `packages/backend``packages/frontend` 构建通过
---
## 2. 方案
### 技术方案
延续现有 `dashboard` 业务域,但把运行态统计从纯数据库聚合扩展为“数据库统计 + 内存运行态组合”。数据库部分继续由 `dashboard.repository.ts` 提供稳定统计;运行态部分由 `dashboard.service.ts` 直接组合后端共享的 `clientStates``sshSuspendService`:前者提供系统范围和按当前 `req.session.userId` 过滤的在线 SSH 会话数量,并进一步统计处于状态监控中的会话数量;后者补充当前用户与系统范围的挂起会话数。前端沿用现有 `dashboard.store.ts`,在 `DashboardOverviewPanel.vue` 中新增“实时会话”板块,使用清晰的双列卡片同时呈现“我的会话”和“系统会话”。
### 影响范围
```yaml
涉及模块:
- backend: 扩展 dashboard summary 组合逻辑,补运行态统计
- backend: 为 sshSuspendService 暴露系统级计数方法
- frontend: 扩展 summary 类型与总览组件
- frontend: 更新 dashboard 文案
预计变更文件: 6-9
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 前端本地 session store 与后端真实在线会话不一致 | 中 | 在线/挂起口径统一以后端运行态为准,只在首页展示后端汇总结果 |
| `clientStates` 中可能包含正在恢复、挂起标记中或半断开的会话 | 中 | 统计时显式按状态字段过滤,优先统计未被服务接管的可用活跃会话 |
| `sshSuspendService` 目前只有按用户列出接口,没有系统汇总接口 | 低 | 为服务补一个只读统计方法,不改现有对外 API |
---
## 3. 技术设计
### 架构设计
```mermaid
flowchart LR
A[DashboardController] --> B[DashboardService]
B --> C[dashboard.repository.ts]
B --> D[clientStates]
B --> E[sshSuspendService]
B --> F[req.session.userId]
```
### API设计
#### GET /api/v1/dashboard/summary
- **新增响应字段**:
```json
{
"liveMetrics": {
"currentUser": {
"activeSshSessions": 0,
"suspendedSessions": 0
},
"system": {
"activeSshSessions": 0,
"suspendedSessions": 0,
"statusStreams": 0
}
}
}
```
### 数据模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `liveMetrics.currentUser.activeSshSessions` | `number` | 当前登录用户的活跃 SSH 会话数 |
| `liveMetrics.currentUser.suspendedSessions` | `number` | 当前登录用户的挂起会话数 |
| `liveMetrics.system.activeSshSessions` | `number` | 系统范围的活跃 SSH 会话数 |
| `liveMetrics.system.suspendedSessions` | `number` | 系统范围的挂起会话数 |
| `liveMetrics.system.statusStreams` | `number` | 当前处于状态轮询中的活跃会话数 |
---
## 4. 核心场景
### 场景: 当前用户查看自己的在线与挂起会话
**模块**: frontend / backend
**条件**: 用户已登录并打开首页 dashboard。
**行为**: 后端基于当前 session 的 `userId` 统计用户活跃 SSH 会话和挂起会话,并在首页实时会话面板中显示。
**结果**: 用户能快速判断“我当前开了多少会话、还有多少挂起会话待恢复”。
### 场景: 管理员查看系统整体运行态
**模块**: frontend / backend
**条件**: 首页 dashboard 已完成 summary 拉取。
**行为**: 后端同时返回系统总活跃 SSH 会话数、总挂起会话数和状态监控流数量。
**结果**: 首页能同时表达“我的视角”和“系统视角”,满足混合视角需求。
---
## 5. 技术决策
### dashboard-live-session-metrics#D001: 运行态指标继续并入 summary 接口,而不是前端再建额外实时 API
**日期**: 2026-03-26
**状态**: ✅采纳
**背景**: 现有 dashboard 已经是单接口驱动。继续拆新接口只会增加首页请求数和前端状态拼装复杂度。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 扩展现有 `/api/v1/dashboard/summary` | 首页保持单请求,口径集中,最适合当前仪表盘架构 | service 层需同时组合数据库与运行态来源 |
| B: 新增独立 `/api/v1/dashboard/live` | 可把稳定统计和实时统计分离 | 首页需要二次请求和额外错误处理,前端复杂度上升 |
**决策**: 选择方案A
**理由**: 这轮增强是现有 dashboard 的自然延展,不值得拆出第二条首页专用链路。
**影响**: backend, frontend
### dashboard-live-session-metrics#D002: 在线/挂起统计口径以后端运行态为准,不使用浏览器本地 session store 作为首页事实来源
**日期**: 2026-03-26
**状态**: ✅采纳
**背景**: 前端 session store 只能反映当前浏览器标签页,无法代表同一用户其他浏览器窗口、恢复中的会话或后端已接管的挂起状态。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 后端 `clientStates + sshSuspendService` 统一统计 | 口径统一,能同时覆盖系统和当前用户 | 需要后端 service 增加只读组合逻辑 |
| B: 首页直接读前端 `session.store` | 实现快 | 只能看到当前浏览器局部状态,无法满足混合视角 |
**决策**: 选择方案A
**理由**: 用户明确要“当前用户 + 系统总览”混合视角,只能以后端运行态作为事实源。
**影响**: backend, frontend
---
## 6. 成果设计
### 设计方向
- **美学基调**: 保持现有 dashboard 驾驶舱风格,但把运行态指标做成更“控制台式”的双层面板
- **记忆点**: 首页同时出现“我的会话 / 系统会话”两组清晰分栏,不再只有静态总量
- **参考**: 当前 dashboard 卡片体系 + 运维控制台常见的 live session summary
### 视觉要素
- **配色**: 当前用户指标偏主色和绿色,系统总览指标偏青蓝和琥珀,避免与稳定总量卡片混淆
- **字体**: 沿用现有项目后台字体体系
- **布局**: 运行态指标独立为一块 summary panel,位于统计卡片区之后、图表区之前
- **动效**: 只保留轻量 hover 和数字层级,不增加复杂动画
- **氛围**: 强调“控制面板”而非“报表页”,以密度和清晰分组为主
### 技术约束
- **可访问性**: 每个实时指标必须带文字标签,不用纯 icon 表达
- **响应式**: 窄屏下“当前用户 / 系统总览”两组指标要能自然折叠为单栏
@@ -0,0 +1,49 @@
# 任务清单: dashboard-live-session-metrics
```yaml
@feature: dashboard-live-session-metrics
@created: 2026-03-26
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 5 | 0 | 0 | 5 |
---
## 任务列表
### 1. 后端运行态统计扩展
- [√] 1.1 在 `packages/backend/src/ssh-suspend/ssh-suspend.service.ts` 中补充系统级挂起会话汇总能力,并在 `packages/backend/src/dashboard/` 中扩展 live metrics 结构与 service 组合逻辑 | depends_on: []
- [√] 1.2 调整 `packages/backend/src/dashboard/dashboard.controller.ts` / `dashboard.service.ts` / `dashboard.types.ts`,让 summary 响应同时包含当前用户和系统总览的实时指标 | depends_on: [1.1]
### 2. 前端展示扩展
- [√] 2.1 更新 `packages/frontend/src/types/server.types.ts``packages/frontend/src/components/DashboardOverviewPanel.vue`,新增“当前用户 / 系统总览”实时会话指标展示 | depends_on: [1.2]
- [√] 2.2 更新 `packages/frontend/src/locales/zh-CN.json``packages/frontend/src/locales/en-US.json``packages/frontend/src/locales/ja-JP.json` 的实时指标文案 | depends_on: [2.1]
### 3. 验证
- [√] 3.1 执行 `packages/backend``packages/frontend` 的构建校验,确认新增实时指标链路通过 | depends_on: [2.2]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-26 00:43 | DESIGN | completed | 已确定采用“数据库统计继续走 repository + 运行态统计走 clientStates/sshSuspendService 组合”的实现路径 |
| 2026-03-26 00:47 | 1.1 | completed | 为 sshSuspendService 增加系统级/当前用户挂起会话计数,并将 clientStates 运行态接入 dashboard service |
| 2026-03-26 00:49 | 2.1 | completed | 首页 summary 类型与总览组件已扩展为混合视角实时会话面板,并拆出 DashboardLiveMetricsPanel 保持单文件约束 |
| 2026-03-26 00:50 | 3.1 | completed | `packages/frontend` 构建通过;`packages/backend` 类型校验问题已修正后再次通过 |
---
## 执行备注
> `create_package.py` 在当前环境未返回有效执行报告,本方案包按模板规则手工创建。当前仓库存在其他并行中的方案包和未提交改动,本轮仅处理 dashboard 实时指标相关范围。
+8
View File
@@ -7,6 +7,9 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------|
| 202603260041 | workbench-file-multi-root-explorer | implementation | frontend | - | ✅完成 |
| 202603260042 | quickcommands-dynamic-variables | implementation | frontend | quickcommands-dynamic-variables#D001 | ✅完成 |
| 202603260038 | quickcommands-context-menu-actions | implementation | frontend | quickcommands-context-menu-actions#D001 | ✅完成 |
| 202603250317 | ghcr-docker-publish | implementation | workspace-root | ghcr-docker-publish#D001 | ✅完成 |
| 202603250532 | quickcommands-theme-alignment | implementation | frontend | - | ✅完成 |
| 202603250547 | terminal-tab-scroll-restore | implementation | frontend | - | ✅完成 |
@@ -21,6 +24,7 @@
| 202603252336 | connections-tree-hover-drag-polish | implementation | frontend | - | ✅完成 |
| 202603252340 | command-input-multiline-shortcut | implementation | frontend | command-input-multiline-shortcut#D001 | ✅完成 |
| 202603252343 | dashboard-management-cockpit | implementation | frontend, backend | dashboard-management-cockpit#D001, dashboard-management-cockpit#D002 | ✅完成 |
| 202603260043 | dashboard-live-session-metrics | implementation | frontend, backend | dashboard-live-session-metrics#D001, dashboard-live-session-metrics#D002 | ✅完成 |
| 202603252229 | terminal-tab-group-visual | implementation | frontend | terminal-tab-group-visual#D001 | ✅完成 |
| 202603252256 | workspace-monitor-terminal-polish | implementation | workspace-root | workspace-monitor-terminal-polish#D001 | ✅完成 |
| 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 |
@@ -28,6 +32,9 @@
## 按月归档
### 2026-03
- [202603260041_workbench-file-multi-root-explorer](./2026-03/202603260041_workbench-file-multi-root-explorer/) - 为工作台文件面板补齐左侧多根目录资源管理器,并允许收藏路径与当前路径同屏作为多个根目录展开浏览
- [202603260042_quickcommands-dynamic-variables](./2026-03/202603260042_quickcommands-dynamic-variables/) - 为快捷指令编辑弹窗补充动态变量清单、一键插入,并统一列表执行与弹窗执行的动态变量解析
- [202603260038_quickcommands-context-menu-actions](./2026-03/202603260038_quickcommands-context-menu-actions/) - 为快捷命令列表补齐图标化右键菜单,并区分立即执行、粘贴到终端输入框和粘贴到快捷输入框
- [202603250317_ghcr-docker-publish](./2026-03/202603250317_ghcr-docker-publish/) - 新增 GHCR 镜像发布 workflow 并切换 compose 镜像来源
- [202603250532_quickcommands-theme-alignment](./2026-03/202603250532_quickcommands-theme-alignment/) - 统一快捷指令视图按钮主题适配,移除残留硬编码 hover 色值
- [202603250547_terminal-tab-scroll-restore](./2026-03/202603250547_terminal-tab-scroll-restore/) - 修复终端标签切换后的贴底/历史滚动恢复逻辑
@@ -42,6 +49,7 @@
- [202603252336_connections-tree-hover-drag-polish](./2026-03/202603252336_connections-tree-hover-drag-polish/) - 为连接管理页补树节点 hover 工具、分隔标题行和拖拽重排占位反馈
- [202603252340_command-input-multiline-shortcut](./2026-03/202603252340_command-input-multiline-shortcut/) - 将命令输入框改为多行自动增高,并改用 Ctrl+Shift+Enter 发送
- [202603252343_dashboard-management-cockpit](./2026-03/202603252343_dashboard-management-cockpit/) - 将首页升级为统计卡片、趋势/分布图和活跃连接排行组成的 dashboard 驾驶舱,并补充 summary 聚合接口
- [202603260043_dashboard-live-session-metrics](./2026-03/202603260043_dashboard-live-session-metrics/) - 为首页 dashboard 增加当前用户与系统总览双视角的在线 SSH / 挂起会话与状态监控流指标
- [202603252229_terminal-tab-group-visual](./2026-03/202603252229_terminal-tab-group-visual/) - 将顶部终端标签栏改成更明显的服务器组头与终端子标签
- [202603252256_workspace-monitor-terminal-polish](./2026-03/202603252256_workspace-monitor-terminal-polish/) - 重新核对状态监控与终端标签剩余改动,并修正知识库归档索引与活跃方案状态
- [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控
+7 -2
View File
@@ -41,8 +41,13 @@
### 仪表盘聚合接口
**条件**: 前端首页需要一次性获取可视化仪表盘统计。
**行为**: 后端新增 `packages/backend/src/dashboard/` 业务域,当前通过 `GET /api/v1/dashboard/summary` 聚合 SQLite 中的 `connections``connection_tags``audit_logs`,输出连接总量、近 7 天活跃连接数、标签覆盖连接数、24 小时 SSH 成功/失败计数、近 7 天活动趋势、连接类型分布、事件类型分布以及活跃连接排行;其中高频连接排行会安全解析审计日志 `details` 中的 `connectionId/connectionName`,解析失败的单条日志会被忽略而不阻断整体 summary。
**结果**: 仪表盘统计口径集中在后端统一维护,首页不再依赖多接口前端拼装,后续扩展更多运营指标时可沿用同一聚合模块
**行为**: 后端新增 `packages/backend/src/dashboard/` 业务域,当前通过 `GET /api/v1/dashboard/summary` 统一组合两类数据源:数据库侧继续聚合 SQLite 中的 `connections``connection_tags``audit_logs`,输出连接总量、近 7 天活跃连接数、标签覆盖连接数、24 小时 SSH 成功/失败计数、近 7 天活动趋势、连接类型分布、事件类型分布以及活跃连接排行;运行态侧则由 `dashboard.service.ts` 组合 `clientStates``sshSuspendService`,补充当前登录用户与系统范围的在线 SSH 会话数、挂起会话数和活跃状态监控流数量。其中高频连接排行会安全解析审计日志 `details` 中的 `connectionId/connectionName`,解析失败的单条日志会被忽略而不阻断整体 summary。
**结果**: 仪表盘统计口径集中在后端统一维护,首页既能看到稳定统计,也能看到当前用户与系统总览的实时会话指标,而无需前端额外拼接第二条接口链路
### 登录凭证管理
**条件**: 用户需要把 SSH / RDP / VNC 的账号信息独立管理并复用于多台连接。
**行为**: 后端新增 `packages/backend/src/login-credentials/` 业务域和 `login_credentials` 表,通过 `/api/v1/login-credentials` 提供登录凭证列表、创建、编辑、删除和详情读取接口;`connections` 表新增 `login_credential_id` 外键,连接创建、更新和未保存测试时都可以引用已保存凭证;运行时凭证解析则优先读取 `login_credentials` 的用户名、认证方式和加密凭证,再回退到连接自身保存的直填字段,因此编辑登录凭证后,引用它的连接在测试和实际连接时会自动使用新配置。
**结果**: 连接管理支持“直填凭证”和“引用已保存凭证”双轨并存,旧连接保持兼容,后续如需扩展凭证审计、共享或筛选能力,也有了独立数据模型承接。
### 外观默认值
**条件**: 数据库初始化、外观设置重置或前后端默认主题定义调整。
+8 -3
View File
@@ -36,13 +36,18 @@
### 工作区交互
**条件**: 用户进入 `/workspace` 或相关管理页面。
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。`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 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue``QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}``${{time}}``${{timestamp}}``${{week}}``${{uuid}}``${{random:8}}``${{clipboard}}``${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到终端输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`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 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);`FileManager.vue` 当前也已升级为左侧资源管理器式多根目录树加右侧原文件表格的双栏结构,左侧根目录来源于收藏路径并自动补入当前路径,复用 `useSftpActions.ts``fileTree` 缓存和 `loadDirectory(path)` 懒加载链路来展开目录、切换右侧当前目录并直接打开文件;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
**结果**: 页面逻辑分散在 `views/``components/``stores/``composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts``session/actions/sessionActions.ts``session/getters.ts``TerminalTabBar.vue``WorkspaceView.vue``Terminal.vue` 与相关 locale 文件。
### 仪表盘总览
**条件**: 用户登录后访问 `/` 首页仪表盘。
**行为**: `DashboardView.vue` 当前会并行拉取连接列表、最近审计日志、标签列表和新的 dashboard summary 数据,并将总览区拆到 `DashboardOverviewPanel.vue`:顶部提供连接总数、近 7 天活跃连接、标签覆盖率、审计日志总量、24 小时 SSH 成功/失败等统计卡片,中部展示近 7 天活动趋势、连接类型分布和事件类型分布图表,下方展示高频连接排行;`dashboard.store.ts` 负责缓存 `/api/v1/dashboard/summary` 的聚合结果,原有连接列表和最近活动则继续复用 `connections.store.ts``audit.store.ts`
**结果**: 首页从“列表首页”升级为“总览 + 操作入口”的管理驾驶舱,用户可在同一页面完成扫描、筛选和直接连接
**行为**: `DashboardView.vue` 当前会并行拉取连接列表、最近审计日志、标签列表和新的 dashboard summary 数据,并将总览区拆到 `DashboardOverviewPanel.vue`:顶部提供连接总数、近 7 天活跃连接、标签覆盖率、审计日志总量、24 小时 SSH 成功/失败等统计卡片,中部展示近 7 天活动趋势、连接类型分布和事件类型分布图表,下方展示高频连接排行;同时又把实时会话块拆到 `DashboardLiveMetricsPanel.vue`,以“我的会话 / 系统总览”双栏形式显示当前用户在线 SSH 会话数、当前用户挂起会话数、系统总在线 SSH 会话数、系统总挂起会话数和状态监控流数量。`dashboard.store.ts` 负责缓存 `/api/v1/dashboard/summary` 的聚合结果,原有连接列表和最近活动则继续复用 `connections.store.ts``audit.store.ts`
**结果**: 首页从“列表首页”升级为“总览 + 实时运行态 + 操作入口”的管理驾驶舱,用户可在同一页面同时判断自己的会话状态和系统整体运行态
### 登录凭证工作流
**条件**: 用户在连接管理页、新增连接弹窗或批量编辑中需要复用登录配置。
**行为**: 前端新增 `loginCredentials.store.ts``LoginCredentialSelector.vue``LoginCredentialManagementModal.vue`,在 `ConnectionsView.vue` 顶部增加“登录凭证”入口;`AddConnectionFormAuth.vue` 当前把认证区拆成“直填账号密码 / 密钥”和“使用已保存凭证”两种来源,`useAddConnectionForm.ts` 在保存和测试连接时会根据 `credential_source` 自动决定提交 `login_credential_id` 还是直填字段;`BatchEditConnectionForm.vue` 也补充了批量应用已保存凭证的能力,并限制为同一种连接类型批量使用。
**结果**: 用户既可以继续沿用原来的直填方式,也可以把常用账号沉淀成独立凭证并在连接或批量编辑时快速套用,连接管理台的凭证入口和表单行为保持一致。
## 依赖关系
@@ -1 +1 @@
{"status":"in_progress","completed":0,"failed":0,"pending":9,"total":9,"done":0,"percent":0,"current":"DESIGN completed - package created for login credential management","updated_at":"2026-03-25 23:54:00"}
{"status":"completed","completed":9,"failed":0,"pending":0,"total":9,"done":9,"percent":100,"current":"Completed - login credential management implemented and verified","updated_at":"2026-03-26 00:22:57"}
@@ -3,7 +3,7 @@
```yaml
@feature: login-credential-management
@created: 2026-03-25
@status: pending
@status: completed
@mode: R3
```
@@ -11,7 +11,7 @@
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 0 | 0 | 0 | 9 |
| 9 | 0 | 0 | 9 |
---
@@ -19,28 +19,28 @@
### 1. 后端登录凭证模型与迁移
- [ ] 1.1 在 `packages/backend/src/database/schema.ts``schema.registry.ts``migrations.ts` 中新增 `login_credentials` 表定义和 `connections.login_credential_id` 迁移 | depends_on: []
- [ ] 1.2 新增 `packages/backend/src/login-credentials/` 模块,实现 repository/service/controller/routes 和类型定义,提供凭证 CRUD 接口 | depends_on: [1.1]
- [√] 1.1 在 `packages/backend/src/database/schema.ts``schema.registry.ts``migrations.ts` 中新增 `login_credentials` 表定义和 `connections.login_credential_id` 迁移 | depends_on: []
- [√] 1.2 新增 `packages/backend/src/login-credentials/` 模块,实现 repository/service/controller/routes 和类型定义,提供凭证 CRUD 接口 | depends_on: [1.1]
### 2. 连接模块凭证引用支持
- [ ] 2.1 在 `packages/backend/src/types/connection.types.ts``packages/backend/src/connections/connection.service.ts``packages/backend/src/connections/connections.controller.ts` 中新增 `login_credential_id` 与统一凭证解析逻辑,覆盖创建、更新、测试和读取回显 | depends_on: [1.2]
- [ ] 2.2 在 `packages/backend/src/index.ts` 注册登录凭证路由,并确保连接测试与实际连接链路复用新的凭证解析逻辑 | depends_on: [2.1]
- [√] 2.1 在 `packages/backend/src/types/connection.types.ts``packages/backend/src/connections/connection.service.ts``packages/backend/src/connections/connections.controller.ts` 中新增 `login_credential_id` 与统一凭证解析逻辑,覆盖创建、更新、测试和读取回显 | depends_on: [1.2]
- [√] 2.2 在 `packages/backend/src/index.ts` 注册登录凭证路由,并确保连接测试与实际连接链路复用新的凭证解析逻辑 | depends_on: [2.1]
### 3. 前端登录凭证管理与表单接入
- [ ] 3.1 新增前端登录凭证类型、store 和管理组件,提供列表、新增、编辑、删除交互入口 | depends_on: [2.2]
- [ ] 3.2 改造 `packages/frontend/src/components/AddConnectionFormAuth.vue``AddConnectionForm.vue``packages/frontend/src/composables/useAddConnectionForm.ts`,支持“直填凭证 / 已保存凭证”双模式 | depends_on: [3.1]
- [√] 3.1 新增前端登录凭证类型、store 和管理组件,提供列表、新增、编辑、删除交互入口 | depends_on: [2.2]
- [√] 3.2 改造 `packages/frontend/src/components/AddConnectionFormAuth.vue``AddConnectionForm.vue``packages/frontend/src/composables/useAddConnectionForm.ts`,支持“直填凭证 / 已保存凭证”双模式 | depends_on: [3.1]
### 4. 批量编辑与文案同步
- [ ] 4.1 改造 `packages/frontend/src/components/BatchEditConnectionForm.vue`,支持批量应用已保存登录凭证并做类型校验 | depends_on: [3.1]
- [ ] 4.2 更新连接相关 store、页面入口与 `packages/frontend/src/locales/zh-CN.json``packages/frontend/src/locales/en-US.json``packages/frontend/src/locales/ja-JP.json` 文案 | depends_on: [3.2, 4.1]
- [√] 4.1 改造 `packages/frontend/src/components/BatchEditConnectionForm.vue`,支持批量应用已保存登录凭证并做类型校验 | depends_on: [3.1]
- [√] 4.2 更新连接相关 store、页面入口与 `packages/frontend/src/locales/zh-CN.json``packages/frontend/src/locales/en-US.json``packages/frontend/src/locales/ja-JP.json` 文案 | depends_on: [3.2, 4.1]
### 5. 验证与知识同步
- [ ] 5.1 执行前后端构建或类型校验,验证登录凭证管理、连接表单和批量编辑改造可通过基础检查 | depends_on: [4.2]
- [ ] 5.2 同步 `.helloagents` 知识库与 CHANGELOG,记录本次“登录凭证管理”实现方案和落地结果 | depends_on: [5.1]
- [√] 5.1 执行前后端构建或类型校验,验证登录凭证管理、连接表单和批量编辑改造可通过基础检查 | depends_on: [4.2]
- [√] 5.2 同步 `.helloagents` 知识库与 CHANGELOG,记录本次“登录凭证管理”实现方案和落地结果 | depends_on: [5.1]
---
@@ -49,10 +49,12 @@
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-25 23:54 | DESIGN | completed | 已确认采用“独立登录凭证实体 + 连接可选引用 + 保留直填”的实现路径 |
| 2026-03-26 00:12 | 1.x-2.x | completed | 已完成后端数据表、登录凭证 CRUD 和连接引用解析逻辑 |
| 2026-03-26 00:19 | 3.x-4.x | completed | 已完成前端登录凭证管理、连接表单接入和批量应用能力 |
| 2026-03-26 00:22 | 5.x | completed | `packages/backend``packages/frontend` 构建通过,并同步知识库与变更日志 |
---
## 执行备注
> 当前环境缺少可用 Python 运行时,`create_package.py` 未能执行;本方案包已按模板规范手工创建。开发阶段需优先保证统一凭证解析逻辑,避免连接创建、测试和运行时三套行为分叉
> 当前环境缺少可用 Python 运行时,`create_package.py` 未能执行;本方案包已按模板规范手工创建。当前实现采用“连接可选引用登录凭证 + 保留直填字段”的双轨模型,运行时优先读取已保存凭证,删除凭证后会回退到连接自身镜像字段
@@ -6,7 +6,7 @@ const dashboardService = new DashboardService();
export class DashboardController {
async getSummary(req: Request, res: Response): Promise<void> {
try {
const summary = await dashboardService.getSummary();
const summary = await dashboardService.getSummary(req.session.userId);
res.status(200).json(summary);
} catch (error: any) {
console.error('[DashboardController] 获取仪表盘统计失败:', error);
@@ -3,7 +3,7 @@ import type {
DashboardActionBreakdownItem,
DashboardActivityTrendPoint,
DashboardCountByType,
DashboardSummary,
DashboardStaticSummary,
DashboardTopConnection,
} from './dashboard.types';
import type { AuditLogActionType } from '../types/audit.types';
@@ -75,7 +75,7 @@ const safeParseAuditDetails = (raw: string | null): ParsedAuditDetails | null =>
}
};
export const getDashboardSummary = async (): Promise<DashboardSummary> => {
export const getDashboardSummary = async (): Promise<DashboardStaticSummary> => {
const db = await getDbInstance();
const now = Math.floor(Date.now() / 1000);
const since7d = now - (DASHBOARD_WINDOW_DAYS - 1) * DAY_IN_SECONDS;
@@ -1,8 +1,32 @@
import { getDashboardSummary } from './dashboard.repository';
import type { DashboardSummary } from './dashboard.types';
import { clientStates } from '../websocket/state';
import { sshSuspendService } from '../ssh-suspend/ssh-suspend.service';
export class DashboardService {
async getSummary(): Promise<DashboardSummary> {
return getDashboardSummary();
async getSummary(userId?: number): Promise<DashboardSummary> {
const summary = await getDashboardSummary();
const activeStates = Array.from(clientStates.values()).filter((state) => !state.isSuspendedByService);
const systemActiveSshSessions = activeStates.length;
const systemStatusStreams = activeStates.filter((state) => !!state.statusIntervalId).length;
const currentUserActiveSshSessions = typeof userId === 'number'
? activeStates.filter((state) => state.ws.userId === userId).length
: 0;
const suspendedMetrics = sshSuspendService.getSessionMetrics(userId);
return {
...summary,
liveMetrics: {
currentUser: {
activeSshSessions: currentUserActiveSshSessions,
suspendedSessions: suspendedMetrics.currentUserSuspendedSessions,
},
system: {
activeSshSessions: systemActiveSshSessions,
suspendedSessions: suspendedMetrics.totalSuspendedSessions,
statusStreams: systemStatusStreams,
},
},
};
}
}
@@ -35,7 +35,23 @@ export interface DashboardActionBreakdownItem {
count: number;
}
export interface DashboardSummary {
export interface DashboardCurrentUserLiveMetrics {
activeSshSessions: number;
suspendedSessions: number;
}
export interface DashboardSystemLiveMetrics {
activeSshSessions: number;
suspendedSessions: number;
statusStreams: number;
}
export interface DashboardLiveMetrics {
currentUser: DashboardCurrentUserLiveMetrics;
system: DashboardSystemLiveMetrics;
}
export interface DashboardStaticSummary {
totals: DashboardTotals;
sshOutcomes24h: DashboardSshOutcomes24h;
connectionTypes: DashboardCountByType[];
@@ -43,3 +59,7 @@ export interface DashboardSummary {
activityTrend7d: DashboardActivityTrendPoint[];
topConnections: DashboardTopConnection[];
}
export interface DashboardSummary extends DashboardStaticSummary {
liveMetrics: DashboardLiveMetrics;
}
@@ -211,6 +211,28 @@ export class SshSuspendService extends EventEmitter {
return sessionsInfo;
}
getSessionMetrics(userId?: number): {
totalSuspendedSessions: number;
currentUserSuspendedSessions: number;
} {
let totalSuspendedSessions = 0;
let currentUserSuspendedSessions = 0;
for (const [ownerUserId, sessions] of this.suspendedSessions.entries()) {
const sessionCount = sessions.size;
totalSuspendedSessions += sessionCount;
if (typeof userId === 'number' && ownerUserId === userId) {
currentUserSuspendedSessions += sessionCount;
}
}
return {
totalSuspendedSessions,
currentUserSuspendedSessions,
};
}
/**
* 恢复指定的挂起会话。
* @param userId 用户ID。
@@ -42,6 +42,39 @@
<button type="button" @click="addVariable" class="mt-3 w-full py-2 px-4 border border-primary/50 text-primary text-sm rounded-md hover:bg-primary/10 transition-colors duration-150">
{{ t('quickCommands.form.addVariable', '+ 添加变量') }}
</button>
<div class="mt-5 border-t border-border/40 pt-4 space-y-4">
<div>
<h3 class="text-md font-medium text-text-secondary">{{ t('quickCommands.form.dynamicVariables.title', '动态变量') }}</h3>
<p class="mt-1 text-xs leading-5 text-text-tertiary">
{{ t('quickCommands.form.dynamicVariables.description', '点击下方变量即可插入到指令中,执行时会自动填充。') }}
</p>
</div>
<div v-for="group in dynamicVariableGroups" :key="group.key" class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-text-tertiary">
{{ t(group.titleKey, group.fallbackTitle) }}
</p>
<button
v-for="item in group.items"
:key="item.key"
type="button"
class="w-full rounded-lg border border-border/40 bg-input/20 px-3 py-2 text-left transition-colors duration-150 hover:border-primary/40 hover:bg-primary/10"
@click="insertDynamicVariable(item.insertValue)"
>
<div class="flex items-start justify-between gap-2">
<span class="text-sm font-medium text-foreground">{{ t(item.labelKey, item.key) }}</span>
<code class="rounded bg-background/80 px-1.5 py-0.5 text-[11px] text-primary">{{ item.insertValue }}</code>
</div>
<p class="mt-1 text-xs leading-5 text-text-secondary">
{{ t(item.descriptionKey, item.key) }}
</p>
<p class="mt-1 text-[11px] text-text-tertiary">
{{ t('quickCommands.form.dynamicVariables.exampleLabel', '示例') }}: <code>{{ item.example }}</code>
</p>
</button>
</div>
</div>
</div>
<!-- 右侧现有表单 -->
@@ -60,6 +93,7 @@
<div class="flex flex-col flex-grow">
<label for="qc-command" class="block mb-1.5 text-sm font-medium text-text-secondary">{{ t('quickCommands.form.command', '指令:') }} <span class="text-error">*</span></label>
<textarea
ref="commandTextareaRef"
id="qc-command"
v-model="formData.command"
required
@@ -105,17 +139,25 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue';
import { useResizable } from '../composables/useResizable';
import { useI18n } from 'vue-i18n';
import { useQuickCommandsStore, type QuickCommandFE } from '../stores/quickCommands.store';
import { useQuickCommandTagsStore } from '../stores/quickCommandTags.store';
import { useConnectionsStore } from '../stores/connections.store';
import { useLoginCredentialsStore } from '../stores/loginCredentials.store';
import { useSessionStore } from '../stores/session.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import TagInput from './TagInput.vue';
import { useConfirmDialog } from '../composables/useConfirmDialog';
import { useAlertDialog } from '../composables/useAlertDialog';
import {
DYNAMIC_VARIABLE_DEFINITIONS,
resolveQuickCommandTemplate,
type DynamicVariableDefinition,
type QuickCommandTemplateWarning,
} from '../utils/quickCommandTemplate';
const props = defineProps<{
commandToEdit?: QuickCommandFE | null; // 接收要编辑的指令对象 (应包含标签ID和变量)
@@ -128,12 +170,15 @@ const { showConfirmDialog } = useConfirmDialog();
const { showAlertDialog } = useAlertDialog();
const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore();
const connectionsStore = useConnectionsStore();
const loginCredentialsStore = useLoginCredentialsStore();
const sessionStore = useSessionStore();
const uiNotificationsStore = useUiNotificationsStore();
const emitWorkspaceEvent = useWorkspaceEventEmitter();
const isSubmitting = ref(false);
const modalContentRef = ref<HTMLElement | null>(null);
const commandTextareaRef = ref<HTMLTextAreaElement | null>(null);
const R_MIN_WIDTH = 800; // 可调整大小的最小宽度 (像素)
const R_MIN_HEIGHT = 700; // 可调整大小的最小高度 (像素)
const placeholder = t('quickCommands.form.commandPlaceholder') + 'echo "Hello,\${USERNAME}"'
@@ -155,6 +200,30 @@ const localVariables = ref<{ name: string; value: string; id: string }[]>([]);
const commandError = ref<string | null>(null);
const dynamicVariableGroups = computed(() => {
const groups: Array<{ key: string; titleKey: string; fallbackTitle: string; items: DynamicVariableDefinition[] }> = [
{
key: 'datetime',
titleKey: 'quickCommands.form.dynamicVariables.groups.datetime',
fallbackTitle: '日期时间',
items: DYNAMIC_VARIABLE_DEFINITIONS.filter((item) => item.group === 'datetime'),
},
{
key: 'identity',
titleKey: 'quickCommands.form.dynamicVariables.groups.identity',
fallbackTitle: '唯一标识',
items: DYNAMIC_VARIABLE_DEFINITIONS.filter((item) => item.group === 'identity'),
},
{
key: 'system',
titleKey: 'quickCommands.form.dynamicVariables.groups.system',
fallbackTitle: '系统',
items: DYNAMIC_VARIABLE_DEFINITIONS.filter((item) => item.group === 'system'),
},
];
return groups.filter((group) => group.items.length > 0);
});
// 监听指令内容变化,进行校验
watch(() => formData.command, (newCommand) => {
@@ -277,47 +346,80 @@ const deleteVariable = (variableId: string) => {
localVariables.value = localVariables.value.filter(v => v.id !== variableId);
};
// 使用当前变量执行命令
const handleExecute = () => {
let processedCommand = formData.command;
const currentVariables = localVariables.value.reduce((acc, curr) => {
const collectCurrentVariables = () => {
return localVariables.value.reduce((acc, curr) => {
if (curr.name.trim()) {
acc[curr.name.trim()] = curr.value;
}
return acc;
}, {} as Record<string, string>);
};
// 执行变量替换
for (const varName in currentVariables) {
const placeholder = new RegExp(`\\$\\{${varName}\\}`, 'g');
processedCommand = processedCommand.replace(placeholder, currentVariables[varName]);
}
// 检查模板中是否存在未定义的变量
const variablePlaceholders = formData.command.match(/\$\{[^\}]+\}/g) || [];
const undefinedVariables: string[] = [];
variablePlaceholders.forEach(placeholder => {
const varName = placeholder.substring(2, placeholder.length - 1);
if (!currentVariables.hasOwnProperty(varName)) {
undefinedVariables.push(varName);
}
});
const notifyTemplateWarnings = (undefinedVariables: string[], warnings: QuickCommandTemplateWarning[]) => {
if (undefinedVariables.length > 0) {
uiNotificationsStore.showWarning(
t('quickCommands.form.warningUndefinedVariables', { variables: undefinedVariables.join(', ') })
);
}
warnings.forEach((warning) => {
if (warning.code === 'clipboardUnavailable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.clipboardUnavailable', '无法读取剪贴板内容,已按空文本处理。'));
} else if (warning.code === 'passwordUnavailable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.passwordUnavailable', '当前活动连接没有可用的登录密码,已按空文本处理。'));
} else if (warning.code === 'unknownDynamicVariable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.unknownVariable', { variable: warning.variable }));
}
});
};
const getActiveSessionIdOrNotify = () => {
const activeSessionId = sessionStore.activeSessionId;
if (!activeSessionId) {
uiNotificationsStore.showError(t('quickCommands.form.errorNoActiveSession', '没有活动的SSH会话可执行指令。'));
return null;
}
return activeSessionId;
};
const insertDynamicVariable = async (placeholderValue: string) => {
const textarea = commandTextareaRef.value;
if (!textarea) {
formData.command += placeholderValue;
return;
}
console.log(`[QuickCmdForm] Executing processed command: "${processedCommand}" on session ${activeSessionId}`);
const selectionStart = textarea.selectionStart ?? formData.command.length;
const selectionEnd = textarea.selectionEnd ?? formData.command.length;
formData.command = `${formData.command.slice(0, selectionStart)}${placeholderValue}${formData.command.slice(selectionEnd)}`;
await nextTick();
textarea.focus();
const nextCursorPosition = selectionStart + placeholderValue.length;
textarea.setSelectionRange(nextCursorPosition, nextCursorPosition);
};
// 使用当前变量执行命令
const handleExecute = async () => {
const activeSessionId = getActiveSessionIdOrNotify();
if (!activeSessionId) {
return;
}
const result = await resolveQuickCommandTemplate(formData.command, {
customVariables: collectCurrentVariables(),
sessionId: activeSessionId,
sessions: sessionStore.sessions,
connections: connectionsStore.connections,
fetchLoginCredentialDetails: loginCredentialsStore.fetchLoginCredentialDetails,
});
notifyTemplateWarnings(result.undefinedVariables, result.warnings);
console.log(`[QuickCmdForm] Executing processed command: "${result.command}" on session ${activeSessionId}`);
emitWorkspaceEvent('quickCommand:executeProcessed', {
command: processedCommand,
command: result.command,
sessionId: activeSessionId
});
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { DashboardSummary } from '../types/server.types';
const props = defineProps<{
summary: DashboardSummary | null;
isLoading: boolean;
}>();
const { t, locale } = useI18n();
const summaryAvailable = computed(() => !!props.summary);
const liveMetricGroups = computed(() => {
if (!props.summary) {
return [];
}
return [
{
key: 'currentUser',
title: t('dashboard.liveMetrics.currentUser.title'),
description: t('dashboard.liveMetrics.currentUser.description'),
items: [
{
key: 'activeSshSessions',
label: t('dashboard.liveMetrics.labels.activeSshSessions'),
value: formatNumber(props.summary.liveMetrics.currentUser.activeSshSessions),
icon: 'fa-terminal',
iconClass: 'text-emerald-400',
},
{
key: 'suspendedSessions',
label: t('dashboard.liveMetrics.labels.suspendedSessions'),
value: formatNumber(props.summary.liveMetrics.currentUser.suspendedSessions),
icon: 'fa-pause-circle',
iconClass: 'text-amber-400',
},
],
},
{
key: 'system',
title: t('dashboard.liveMetrics.system.title'),
description: t('dashboard.liveMetrics.system.description'),
items: [
{
key: 'activeSshSessions',
label: t('dashboard.liveMetrics.labels.activeSshSessions'),
value: formatNumber(props.summary.liveMetrics.system.activeSshSessions),
icon: 'fa-network-wired',
iconClass: 'text-sky-400',
},
{
key: 'suspendedSessions',
label: t('dashboard.liveMetrics.labels.suspendedSessions'),
value: formatNumber(props.summary.liveMetrics.system.suspendedSessions),
icon: 'fa-layer-group',
iconClass: 'text-violet-400',
},
{
key: 'statusStreams',
label: t('dashboard.liveMetrics.labels.statusStreams'),
value: formatNumber(props.summary.liveMetrics.system.statusStreams),
icon: 'fa-heart-pulse',
iconClass: 'text-rose-400',
},
],
},
];
});
function formatNumber(value: number): string {
return new Intl.NumberFormat(locale.value).format(value);
}
</script>
<template>
<section class="rounded-xl border border-border bg-card p-4 shadow-sm">
<div class="mb-4">
<h2 class="text-lg font-medium">{{ t('dashboard.liveMetrics.title') }}</h2>
<p class="text-sm text-text-secondary">{{ t('dashboard.liveMetrics.description') }}</p>
</div>
<div v-if="isLoading && !summaryAvailable" class="text-center text-text-secondary">
{{ t('common.loading') }}
</div>
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<section
v-for="group in liveMetricGroups"
:key="group.key"
class="rounded-xl border border-border/70 bg-header/30 p-4"
>
<div class="mb-3">
<h3 class="text-base font-medium">{{ group.title }}</h3>
<p class="text-sm text-text-secondary">{{ group.description }}</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<article
v-for="item in group.items"
:key="item.key"
class="rounded-lg border border-border/70 bg-card/80 px-3 py-3"
>
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-sm text-text-secondary">{{ item.label }}</span>
<i :class="['fas', item.icon, item.iconClass]"></i>
</div>
<p class="text-2xl font-semibold leading-none">{{ item.value }}</p>
</article>
</div>
</section>
</div>
</section>
</template>
@@ -17,6 +17,7 @@ import { Bar, Doughnut, Line } from 'vue-chartjs';
import { format, formatDistanceToNow } from 'date-fns';
import { enUS, ja, zhCN } from 'date-fns/locale';
import type { Locale } from 'date-fns';
import DashboardLiveMetricsPanel from './DashboardLiveMetricsPanel.vue';
import type { ConnectionInfo } from '../stores/connections.store';
import type { DashboardSummary } from '../types/server.types';
@@ -315,6 +316,8 @@ function handleConnect(connection: ConnectionInfo | null): void {
{{ t('dashboard.summaryLoadFailed') }}: {{ error }}
</div>
<DashboardLiveMetricsPanel :summary="summary" :is-loading="isLoading" />
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
<section class="rounded-xl border border-border bg-card p-4 shadow-sm xl:col-span-2">
<div class="mb-4 flex items-start justify-between gap-3">
+352 -51
View File
@@ -3,12 +3,13 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions';
import { createSftpActionsManager, type WebSocketDependencies, type FileTreeNode } from '../composables/useSftpActions';
import { useFileUploader } from '../composables/useFileUploader';
import { useFileEditorStore, type FileInfo } from '../stores/fileEditor.store';
import { useSessionStore } from '../stores/session.store';
import { useSettingsStore } from '../stores/settings.store';
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
import { useFavoritePathsStore, type FavoritePathItem } from '../stores/favoritePaths.store';
import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } from '../composables/file-manager/useFileManagerContextMenu';
import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection';
import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop';
@@ -26,6 +27,30 @@ import { useUiNotificationsStore } from '../stores/uiNotifications.store';
type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
type ExplorerRootSource = 'favorite' | 'current';
interface ExplorerRootItem {
id: string;
path: string;
label: string;
description: string;
source: ExplorerRootSource;
}
interface ExplorerTreeRow {
id: string;
path: string;
name: string;
description?: string;
depth: number;
isDirectory: boolean;
isRoot: boolean;
loaded: boolean;
expanded: boolean;
source: ExplorerRootSource | 'tree';
item: FileListItem;
}
// --- Props ---
const props = defineProps({
@@ -58,6 +83,7 @@ const props = defineProps({
const { t } = useI18n();
const route = useRoute(); // Keep for download URL generation for now
const sessionStore = useSessionStore(); // Session Store
const favoritePathsStore = useFavoritePathsStore();
// --- SFTP ---
// 使 shallowRef 便 sessionId
@@ -113,6 +139,7 @@ const {
showPopupFileEditorBoolean, // +++ +++
fileManagerShowDeleteConfirmationBoolean, // +++ +++
} = storeToRefs(settingsStore); // 使 storeToRefs
const { favoritePaths } = storeToRefs(favoritePathsStore);
@@ -133,6 +160,7 @@ const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引
// +++ Favorite Paths Modal State +++
const showFavoritePathsModal = ref(false);
const favoritePathsButtonRef = ref<HTMLButtonElement | null>(null); // Ref for the trigger button
const explorerExpandedPaths = ref<Record<string, boolean>>({});
// +++ Path History Refs +++
const showPathHistoryDropdown = ref(false);
@@ -190,6 +218,173 @@ const formatMode = (mode: number): string => {
return str;
};
const getPathName = (path: string): string => {
if (!path || path === '/') {
return '/';
}
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
return normalized.substring(normalized.lastIndexOf('/') + 1) || normalized;
};
const sortTreeItems = (items: FileListItem[]): FileListItem[] => {
return [...items].sort((left, right) => {
if (left.attrs.isDirectory && !right.attrs.isDirectory) return -1;
if (!left.attrs.isDirectory && right.attrs.isDirectory) return 1;
return left.filename.localeCompare(right.filename);
});
};
const findTreeNodeByPath = (path: string): FileTreeNode | null => {
const root = currentSftpManager.value?.fileTree;
if (!root) {
return null;
}
if (path === '/') {
return root;
}
const segments = path.split('/').filter(Boolean);
let currentNode: FileTreeNode | null = root;
for (const segment of segments) {
if (!currentNode?.children) {
return null;
}
currentNode = currentNode.children.find((child) => child.filename === segment) ?? null;
}
return currentNode;
};
const toFileListItem = (node: FileTreeNode): FileListItem => ({
filename: node.filename,
longname: node.longname,
attrs: node.attrs,
});
const openFileInWorkspace = (filePath: string, filename: string) => {
const fileInfo: FileInfo = { name: filename, fullPath: filePath };
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(filePath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(filePath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
};
const explorerRoots = computed<ExplorerRootItem[]>(() => {
const roots = new Map<string, ExplorerRootItem>();
favoritePaths.value.forEach((favorite: FavoritePathItem) => {
const path = favorite.path?.trim();
if (!path) {
return;
}
roots.set(path, {
id: `favorite:${favorite.id}`,
path,
label: favorite.name?.trim() || getPathName(path),
description: path,
source: 'favorite',
});
});
const currentPath = currentSftpManager.value?.currentPath.value?.trim();
if (currentPath && !roots.has(currentPath)) {
roots.set(currentPath, {
id: `current:${currentPath}`,
path: currentPath,
label: getPathName(currentPath),
description: currentPath,
source: 'current',
});
}
return Array.from(roots.values());
});
const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
const rows: ExplorerTreeRow[] = [];
const appendNodeRows = (basePath: string, nodes: FileListItem[], depth: number) => {
sortTreeItems(nodes).forEach((item) => {
const itemPath = currentSftpManager.value?.joinPath(basePath, item.filename) ?? `${basePath}/${item.filename}`;
const treeNode = findTreeNodeByPath(itemPath);
const expanded = Boolean(explorerExpandedPaths.value[itemPath]);
const loaded = item.attrs.isDirectory ? Boolean(treeNode?.childrenLoaded) : true;
rows.push({
id: `tree:${itemPath}`,
path: itemPath,
name: item.filename,
depth,
isDirectory: item.attrs.isDirectory,
isRoot: false,
loaded,
expanded,
source: 'tree',
item,
});
if (item.attrs.isDirectory && expanded && treeNode?.children?.length) {
appendNodeRows(itemPath, treeNode.children.map(toFileListItem), depth + 1);
}
});
};
explorerRoots.value.forEach((root) => {
const node = findTreeNodeByPath(root.path);
const rootItem: FileListItem = node
? toFileListItem(node)
: {
filename: getPathName(root.path),
longname: root.path,
attrs: {
isDirectory: true,
isFile: false,
isSymbolicLink: false,
size: 0,
uid: 0,
gid: 0,
mode: 0,
atime: 0,
mtime: 0,
},
};
const expanded = explorerExpandedPaths.value[root.path] ?? true;
const loaded = Boolean(node?.childrenLoaded);
rows.push({
id: root.id,
path: root.path,
name: root.label,
description: root.description,
depth: 0,
isDirectory: true,
isRoot: true,
loaded,
expanded,
source: root.source,
item: rootItem,
});
if (expanded && node?.children?.length) {
appendNodeRows(root.path, node.children.map(toFileListItem), 1);
}
});
return rows;
});
const getFileIconClassBase = (filename: string): string => {
const lowerFilename = filename.toLowerCase();
let extension = '';
@@ -395,7 +590,6 @@ const handleItemAction = (item: FileListItem) => {
currentSftpManager.value.loadDirectory(realPath);
} else if (targetType === 'file') {
const targetFilename = realPath.substring(realPath.lastIndexOf('/') + 1) || originalLinkItem.filename; // Get filename from realPath
const fileInfo: FileInfo = { name: targetFilename, fullPath: realPath };
// Preserve mobile multi-select behavior for the original link item
if (props.isMobile && isMultiSelectMode.value) {
@@ -407,27 +601,12 @@ const handleItemAction = (item: FileListItem) => {
return;
}
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(realPath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(realPath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
openFileInWorkspace(realPath, targetFilename);
} else { // targetType is 'unknown' or not provided as expected
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Symlink target '${realPath}' has an unknown type from server ('${targetType}'). Defaulting to open as file.`);
// Fallback: attempt to open as file, or display an error
const targetFilename = realPath.substring(realPath.lastIndexOf('/') + 1) || originalLinkItem.filename;
const fileInfo: FileInfo = { name: targetFilename, fullPath: realPath };
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(realPath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(realPath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
openFileInWorkspace(realPath, targetFilename);
}
};
@@ -501,17 +680,7 @@ const handleItemAction = (item: FileListItem) => {
return;
}
const filePath = itemPath; // itemPath is already calculated
const fileInfo: FileInfo = { name: item.filename, fullPath: filePath };
if (settingsStore.showPopupFileEditorBoolean) {
fileEditorStore.triggerPopup(filePath, props.sessionId);
}
if (shareFileEditorTabsBoolean.value) {
fileEditorStore.openFile(filePath, props.sessionId, props.instanceId);
} else {
sessionStore.openFileInSession(props.sessionId, fileInfo);
}
openFileInWorkspace(filePath, item.filename);
}
};
@@ -1643,12 +1812,70 @@ const handleOpenEditorClick = () => {
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Toggled FavoritePathsModal. Visible: ${showFavoritePathsModal.value}`);
};
const handleNavigateToPathFromFavorites = (path: string) => {
if (currentSftpManager.value) {
const handleNavigateToPathFromFavorites = (path: string) => {
if (currentSftpManager.value) {
currentSftpManager.value.loadDirectory(path);
}
showFavoritePathsModal.value = false; // Close modal after navigation
};
explorerExpandedPaths.value[path] = true;
}
showFavoritePathsModal.value = false; // Close modal after navigation
};
const handleExplorerToggle = (row: ExplorerTreeRow) => {
if (!row.isDirectory) {
return;
}
const nextExpanded = !(explorerExpandedPaths.value[row.path] ?? row.expanded);
explorerExpandedPaths.value[row.path] = nextExpanded;
if (nextExpanded && !row.loaded && currentSftpManager.value) {
currentSftpManager.value.loadDirectory(row.path);
return;
}
if (currentSftpManager.value?.currentPath.value !== row.path) {
currentSftpManager.value?.loadDirectory(row.path);
}
};
const handleExplorerOpen = (row: ExplorerTreeRow) => {
if (row.isDirectory) {
explorerExpandedPaths.value[row.path] = true;
currentSftpManager.value?.loadDirectory(row.path);
return;
}
openFileInWorkspace(row.path, row.name);
};
const isExplorerRowActive = (row: ExplorerTreeRow) => {
return currentSftpManager.value?.currentPath.value === row.path;
};
const isExplorerRowRelated = (row: ExplorerTreeRow) => {
const currentPath = currentSftpManager.value?.currentPath.value;
if (!currentPath) {
return false;
}
if (row.path === '/') {
return true;
}
return currentPath === row.path || currentPath.startsWith(`${row.path}/`);
};
watch(
explorerRoots,
(roots) => {
roots.forEach((root) => {
if (explorerExpandedPaths.value[root.path] === undefined) {
explorerExpandedPaths.value[root.path] = true;
}
});
},
{ immediate: true },
);
</script>
<template>
@@ -1833,22 +2060,95 @@ const handleOpenEditorClick = () => {
</div>
</div>
<div class="flex flex-grow min-h-0 overflow-hidden border-t border-border/60">
<aside class="w-[260px] flex-shrink-0 border-r border-border/60 bg-header/40 flex flex-col min-h-0">
<div class="px-3 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-2">
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
<div class="mt-1 text-xs text-text-secondary">{{ explorerRoots.length }} {{ t('fileManager.explorer.rootCount', '个根目录') }}</div>
</div>
<button
@click="toggleFavoritePathsModal"
class="w-8 h-8 rounded-lg border border-border bg-background text-text-secondary hover:bg-header hover:text-foreground transition-colors"
:title="t('favoritePaths.addNew', 'Add new favorite path')"
>
<i class="fas fa-plus text-xs"></i>
</button>
</div>
</div>
<!-- File List Container -->
<div
ref="fileListContainerRef"
class="flex-grow overflow-y-auto relative outline-none"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@click="fileListContainerRef?.focus()"
@keydown="handleKeydown"
@wheel="handleWheel"
@contextmenu.prevent="showContextMenu($event)"
tabindex="0"
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
>
<div class="flex-1 min-h-0 overflow-y-auto px-2 py-2">
<div v-if="explorerRoots.length === 0" class="px-3 py-6 text-xs text-text-secondary text-center">
{{ t('fileManager.explorer.noRoots', '暂无目录根,请先添加收藏路径或连接后浏览当前目录。') }}
</div>
<div v-else class="space-y-1">
<div
v-for="row in explorerTreeRows"
:key="row.id"
:class="[
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors cursor-pointer',
isExplorerRowActive(row)
? 'bg-primary text-white border-primary shadow-sm'
: isExplorerRowRelated(row)
? 'border-primary/20 bg-primary/8 text-foreground'
: 'border-transparent text-text-secondary hover:bg-background hover:text-foreground'
]"
:style="{ paddingLeft: `${0.6 + row.depth * 0.85}rem` }"
@click="handleExplorerOpen(row)"
>
<button
v-if="row.isDirectory"
@click.stop="handleExplorerToggle(row)"
class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[10px]"
>
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</button>
<span v-else class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60">
<i class="fas fa-circle"></i>
</span>
<i
:class="[
row.isDirectory
? (row.isRoot ? 'fas fa-folder-tree' : 'fas fa-folder')
: getFileIconClassBase(row.name),
'w-4 text-center flex-shrink-0',
isExplorerRowActive(row) ? 'text-white' : (row.isDirectory ? 'text-primary' : 'text-text-secondary')
]"
></i>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium" :title="row.description || row.path">{{ row.name }}</div>
<div
v-if="row.isRoot"
class="truncate text-[10px]"
:class="isExplorerRowActive(row) ? 'text-white/75' : 'text-text-secondary/80'"
>
{{ row.description }}
</div>
</div>
</div>
</div>
</div>
</aside>
<!-- File List Container -->
<div
ref="fileListContainerRef"
class="flex-grow overflow-y-auto relative outline-none"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@click="fileListContainerRef?.focus()"
@keydown="handleKeydown"
@wheel="handleWheel"
@contextmenu.prevent="showContextMenu($event)"
tabindex="0"
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
>
<!-- 外部文件拖拽蒙版 -->
<div
v-if="showExternalDropOverlay"
@@ -2006,7 +2306,8 @@ const handleOpenEditorClick = () => {
</tbody>
</table>
<!-- Removed separate loading/empty divs -->
</div>
</div>
</div>
<!-- 使用 FileUploadPopup 组件 -->
<FileUploadPopup :uploads="uploads" @cancel-upload="cancelUpload" />
+75 -3
View File
@@ -389,8 +389,6 @@
"scopeSearchMode": "Matched paths are expanded",
"scopeTreeNoMatch": "No matching tree nodes",
"scopeDragPlaceholder": "Drag-reorder is reserved for now; only target placeholder feedback is shown.",
"scopePinAction": "Focus this scope",
"scopeDragAction": "Drag to reorder (reserved)",
"untaggedGroup": "Untagged",
"noUntaggedConnections": "No untagged connections found."
},
@@ -1379,16 +1377,73 @@
"addVariable": "+ Add Variable",
"execute": "Execute",
"warningUndefinedVariables": "Warning: Undefined variables in command template: {variables}",
"errorNoActiveSession": "No active SSH session to execute the command."
"errorNoActiveSession": "No active SSH session to execute the command.",
"dynamicVariables": {
"title": "Dynamic Variables",
"description": "Click a variable below to insert it into the command. It will be resolved automatically at execution time.",
"exampleLabel": "Example",
"groups": {
"datetime": "Date & Time",
"identity": "Identifiers",
"system": "System"
},
"items": {
"date": {
"label": "date",
"description": "Current date. Supports custom formats such as YYYY-MM-DD, YYYYMMDD, and MM/DD."
},
"time": {
"label": "time",
"description": "Current time. Supports custom formats such as HH:mm:ss, HHmmss, and HH:mm."
},
"timestamp": {
"label": "timestamp",
"description": "Unix timestamp in seconds."
},
"week": {
"label": "week",
"description": "Current ISO week number in the year."
},
"uuid": {
"label": "uuid",
"description": "Generate a unique identifier."
},
"random": {
"label": "random",
"description": "Generate a random string. Use forms like random:8 to control the length."
},
"clipboard": {
"label": "clipboard",
"description": "Read text from the current clipboard."
},
"password": {
"label": "password",
"description": "Try to read the login password for the current active SSH session."
}
},
"warnings": {
"clipboardUnavailable": "Unable to read clipboard text. An empty string was used instead.",
"passwordUnavailable": "No login password is available for the current active connection. An empty string was used instead.",
"unknownVariable": "Unrecognized dynamic variable: {variable}"
}
}
},
"untagged": "Untagged",
"tags": {
"clickToEditTag": "Click to edit tag name"
},
"actions": {
"runNow": "Run Now",
"pasteToTerminal": "Paste to Terminal",
"copyCommand": "Copy Command",
"pasteToQuickInput": "Paste to Quick Input",
"edit": "Edit",
"delete": "Delete",
"sendToAllSessions": "Send to All Servers"
},
"notifications": {
"pastedToTerminal": "Pasted into the terminal input.",
"pastedToQuickInput": "Pasted into the quick input.",
"sentToAllSessions": "Command sent to {count} servers.",
"noActiveSshSessions": "No active SSH sessions to send command to."
}
@@ -1519,6 +1574,23 @@
"sshSuccess24h": "Successful SSH connection events over the last 24 hours",
"sshFailure24h": "Failed SSH or shell-open events over the last 24 hours"
},
"liveMetrics": {
"title": "Live Session Metrics",
"description": "A mixed view of your current sessions and the whole system runtime state.",
"currentUser": {
"title": "My Sessions",
"description": "Sessions tied to the current signed-in user."
},
"system": {
"title": "System Overview",
"description": "All live session signals currently tracked by the backend."
},
"labels": {
"activeSshSessions": "Active SSH Sessions",
"suspendedSessions": "Suspended Sessions",
"statusStreams": "Status Streams"
}
},
"charts": {
"activityTrend7d": "Activity Trend (7 Days)",
"activityTrendHint": "Daily audit event volume for the latest week",
+75 -3
View File
@@ -291,8 +291,6 @@
"scopeSearchMode": "一致したパスを自動展開中",
"scopeTreeNoMatch": "一致するツリーノードはありません",
"scopeDragPlaceholder": "ドラッグ並べ替えは予約中で、現在は配置先のプレースホルダーのみ表示します。",
"scopePinAction": "この範囲にフォーカス",
"scopeDragAction": "ドラッグで並べ替え(予約)",
"table": {
"actions": "アクション",
"authMethod": "認証方法",
@@ -361,6 +359,23 @@
"sshSuccess24h": "過去 24 時間に成功した SSH 接続イベント数",
"sshFailure24h": "過去 24 時間に失敗した SSH 接続または Shell 起動イベント数"
},
"liveMetrics": {
"title": "ライブセッション指標",
"description": "現在のユーザー視点とシステム全体視点を同時に表示します。",
"currentUser": {
"title": "自分のセッション",
"description": "現在ログイン中のユーザーに紐づくオンライン / 中断セッションです。"
},
"system": {
"title": "システム概要",
"description": "バックエンドが現在追跡している全体のライブセッション信号です。"
},
"labels": {
"activeSshSessions": "稼働中の SSH セッション",
"suspendedSessions": "中断セッション",
"statusStreams": "状態監視ストリーム"
}
},
"charts": {
"activityTrend7d": "直近 7 日のアクティビティ推移",
"activityTrendHint": "直近 1 週間の監査イベント数を日別に表示",
@@ -779,7 +794,56 @@
"addVariable": "+ 変数を追加",
"execute": "実行",
"warningUndefinedVariables": "警告:コマンドテンプレートに未定義の変数があります: {variables}",
"errorNoActiveSession": "コマンドを実行するためのアクティブなSSHセッションがありません。"
"errorNoActiveSession": "コマンドを実行するためのアクティブなSSHセッションがありません。",
"dynamicVariables": {
"title": "動的変数",
"description": "下の変数をクリックするとコマンドへ挿入され、実行時に自動で展開されます。",
"exampleLabel": "例",
"groups": {
"datetime": "日時",
"identity": "識別子",
"system": "システム"
},
"items": {
"date": {
"label": "date",
"description": "現在の日付です。YYYY-MM-DD、YYYYMMDD、MM/DD などの書式に対応します。"
},
"time": {
"label": "time",
"description": "現在の時刻です。HH:mm:ss、HHmmss、HH:mm などの書式に対応します。"
},
"timestamp": {
"label": "timestamp",
"description": "Unix タイムスタンプ(秒)です。"
},
"week": {
"label": "week",
"description": "現在が年内の第何週かを返します。"
},
"uuid": {
"label": "uuid",
"description": "一意の識別子を生成します。"
},
"random": {
"label": "random",
"description": "ランダム文字列を生成します。random:8 のように長さを指定できます。"
},
"clipboard": {
"label": "clipboard",
"description": "現在のクリップボードのテキストを読み取ります。"
},
"password": {
"label": "password",
"description": "現在アクティブな SSH セッションに対応するログインパスワードの取得を試みます。"
}
},
"warnings": {
"clipboardUnavailable": "クリップボードの内容を読み取れなかったため、空文字として扱いました。",
"passwordUnavailable": "現在のアクティブ接続に利用可能なログインパスワードがないため、空文字として扱いました。",
"unknownVariable": "未対応の動的変数があります: {variable}"
}
}
},
"untagged": "タグなし",
"tags": {
@@ -790,9 +854,17 @@
"sortByUsage": "使用頻度",
"usageCount": "使用回数",
"actions": {
"runNow": "今すぐ実行",
"pasteToTerminal": "ターミナルに貼り付け",
"copyCommand": "コマンドをコピー",
"pasteToQuickInput": "クイック入力欄に貼り付け",
"edit": "編集",
"delete": "削除",
"sendToAllSessions": "すべてのサーバーに送信"
},
"notifications": {
"pastedToTerminal": "ターミナル入力欄に貼り付けました。",
"pastedToQuickInput": "クイック入力欄に貼り付けました。",
"sentToAllSessions": "コマンドは {count} 台のサーバーに送信されました。",
"noActiveSshSessions": "コマンドを送信するアクティブな SSH セッションはありません。"
}
+75 -3
View File
@@ -389,8 +389,6 @@
"scopeSearchMode": "命中路径已自动展开",
"scopeTreeNoMatch": "没有匹配的树节点",
"scopeDragPlaceholder": "拖拽排序预留中,当前仅展示目标占位反馈。",
"scopePinAction": "定位到此范围",
"scopeDragAction": "拖拽重排(预留)",
"untaggedGroup": "未标记",
"noUntaggedConnections": "没有未标记的连接。"
},
@@ -1383,16 +1381,73 @@
"addVariable": "+ 添加变量",
"execute": "执行",
"warningUndefinedVariables": "警告:指令模板中存在未定义的变量: {variables}",
"errorNoActiveSession": "没有活动的SSH会话可执行指令。"
"errorNoActiveSession": "没有活动的SSH会话可执行指令。",
"dynamicVariables": {
"title": "动态变量",
"description": "点击下方变量即可插入到指令中,执行时会自动填充。",
"exampleLabel": "示例",
"groups": {
"datetime": "日期时间",
"identity": "唯一标识",
"system": "系统"
},
"items": {
"date": {
"label": "date",
"description": "当前日期,支持自定义格式,例如 YYYY-MM-DD、YYYYMMDD、MM/DD。"
},
"time": {
"label": "time",
"description": "当前时间,支持自定义格式,例如 HH:mm:ss、HHmmss、HH:mm。"
},
"timestamp": {
"label": "timestamp",
"description": "Unix 时间戳(秒)。"
},
"week": {
"label": "week",
"description": "当前是一年中的第几周。"
},
"uuid": {
"label": "uuid",
"description": "生成唯一标识符。"
},
"random": {
"label": "random",
"description": "生成随机字符串,可通过 random:8 这类写法指定长度。"
},
"clipboard": {
"label": "clipboard",
"description": "读取当前剪贴板文本内容。"
},
"password": {
"label": "password",
"description": "尝试读取当前活动 SSH 会话对应的登录密码。"
}
},
"warnings": {
"clipboardUnavailable": "无法读取剪贴板内容,已按空文本处理。",
"passwordUnavailable": "当前活动连接没有可用的登录密码,已按空文本处理。",
"unknownVariable": "存在未识别的动态变量: {variable}"
}
}
},
"untagged": "未标记",
"tags": {
"clickToEditTag": "点击编辑标签名称"
},
"actions": {
"runNow": "立即执行",
"pasteToTerminal": "粘贴到终端",
"copyCommand": "复制命令",
"pasteToQuickInput": "粘贴到快捷输入框",
"edit": "编辑",
"delete": "删除",
"sendToAllSessions": "发送到全部服务器"
},
"notifications": {
"pastedToTerminal": "已粘贴到终端输入框。",
"pastedToQuickInput": "已粘贴到快捷输入框。",
"sentToAllSessions": "指令已发送到 {count} 台服务器。",
"noActiveSshSessions": "没有活动的 SSH 会话可发送指令。"
}
@@ -1523,6 +1578,23 @@
"sshSuccess24h": "最近 24 小时内成功建立的 SSH 连接事件",
"sshFailure24h": "最近 24 小时内 SSH 连接或 Shell 打开失败事件"
},
"liveMetrics": {
"title": "实时会话指标",
"description": "同时展示当前用户视角与系统总览视角的运行态会话信息。",
"currentUser": {
"title": "我的会话",
"description": "与当前登录用户绑定的在线和挂起会话。"
},
"system": {
"title": "系统总览",
"description": "后端当前正在追踪的全局实时会话信号。"
},
"labels": {
"activeSshSessions": "在线 SSH 会话",
"suspendedSessions": "挂起会话",
"statusStreams": "状态监控流"
}
},
"charts": {
"activityTrend7d": "近 7 天活动趋势",
"activityTrendHint": "按天统计最近一周的审计事件量",
@@ -163,6 +163,22 @@ export interface DashboardActionBreakdownItem {
count: number;
}
export interface DashboardCurrentUserLiveMetrics {
activeSshSessions: number;
suspendedSessions: number;
}
export interface DashboardSystemLiveMetrics {
activeSshSessions: number;
suspendedSessions: number;
statusStreams: number;
}
export interface DashboardLiveMetrics {
currentUser: DashboardCurrentUserLiveMetrics;
system: DashboardSystemLiveMetrics;
}
export interface DashboardSummary {
totals: DashboardTotals;
sshOutcomes24h: DashboardSshOutcomes24h;
@@ -170,4 +186,5 @@ export interface DashboardSummary {
actionBreakdown7d: DashboardActionBreakdownItem[];
activityTrend7d: DashboardActivityTrendPoint[];
topConnections: DashboardTopConnection[];
liveMetrics: DashboardLiveMetrics;
}
@@ -0,0 +1,309 @@
import { format, getISOWeek } from 'date-fns';
import type { ConnectionInfo } from '../stores/connections.store';
import type { LoginCredentialDetails } from '../stores/loginCredentials.store';
import type { SessionState } from '../stores/session/types';
export interface DynamicVariableDefinition {
key: string;
insertValue: string;
example: string;
group: 'datetime' | 'identity' | 'system';
labelKey: string;
descriptionKey: string;
}
export interface QuickCommandTemplateWarning {
code: 'clipboardUnavailable' | 'passwordUnavailable' | 'unknownDynamicVariable';
variable: string;
}
export interface ResolveQuickCommandTemplateContext {
customVariables?: Record<string, string>;
sessionId?: string | null;
sessions?: Map<string, SessionState>;
connections?: ConnectionInfo[];
fetchLoginCredentialDetails?: (id: number) => Promise<LoginCredentialDetails | null>;
}
export interface ResolveQuickCommandTemplateResult {
command: string;
undefinedVariables: string[];
warnings: QuickCommandTemplateWarning[];
}
const CUSTOM_VARIABLE_PATTERN = /\$\{(?!\{)([^}]+)\}/g;
const DYNAMIC_VARIABLE_PATTERN = /\$\{\{([^{}]+)\}\}/g;
const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';
const DEFAULT_TIME_FORMAT = 'HH:mm:ss';
const RANDOM_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
export const DYNAMIC_VARIABLE_DEFINITIONS: DynamicVariableDefinition[] = [
{
key: 'date',
insertValue: '${{date}}',
example: '${{date:YYYYMMDD}}',
group: 'datetime',
labelKey: 'quickCommands.form.dynamicVariables.items.date.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.date.description',
},
{
key: 'time',
insertValue: '${{time}}',
example: '${{time:HHmmss}}',
group: 'datetime',
labelKey: 'quickCommands.form.dynamicVariables.items.time.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.time.description',
},
{
key: 'timestamp',
insertValue: '${{timestamp}}',
example: '${{timestamp}}',
group: 'datetime',
labelKey: 'quickCommands.form.dynamicVariables.items.timestamp.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.timestamp.description',
},
{
key: 'week',
insertValue: '${{week}}',
example: '${{week}}',
group: 'datetime',
labelKey: 'quickCommands.form.dynamicVariables.items.week.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.week.description',
},
{
key: 'uuid',
insertValue: '${{uuid}}',
example: '${{uuid}}',
group: 'identity',
labelKey: 'quickCommands.form.dynamicVariables.items.uuid.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.uuid.description',
},
{
key: 'random',
insertValue: '${{random:8}}',
example: '${{random:8}}',
group: 'identity',
labelKey: 'quickCommands.form.dynamicVariables.items.random.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.random.description',
},
{
key: 'clipboard',
insertValue: '${{clipboard}}',
example: '${{clipboard}}',
group: 'system',
labelKey: 'quickCommands.form.dynamicVariables.items.clipboard.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.clipboard.description',
},
{
key: 'password',
insertValue: '${{password}}',
example: '${{password}}',
group: 'system',
labelKey: 'quickCommands.form.dynamicVariables.items.password.label',
descriptionKey: 'quickCommands.form.dynamicVariables.items.password.description',
},
];
export async function resolveQuickCommandTemplate(
template: string,
context: ResolveQuickCommandTemplateContext = {},
): Promise<ResolveQuickCommandTemplateResult> {
const customVariables = context.customVariables ?? {};
const undefinedVariables = new Set<string>();
const warnings = new Map<string, QuickCommandTemplateWarning>();
let processedCommand = template.replace(CUSTOM_VARIABLE_PATTERN, (fullMatch, rawVariableName: string) => {
const variableName = rawVariableName.trim();
if (Object.prototype.hasOwnProperty.call(customVariables, variableName)) {
return customVariables[variableName];
}
undefinedVariables.add(variableName);
return fullMatch;
});
const dynamicMatches = [...processedCommand.matchAll(DYNAMIC_VARIABLE_PATTERN)];
const now = new Date();
let cachedClipboard: string | undefined;
let clipboardLoaded = false;
let cachedPassword: string | undefined;
let passwordLoaded = false;
for (const match of dynamicMatches) {
const fullMatch = match[0];
const expression = match[1]?.trim() ?? '';
const [rawVariableName, rawArgument = ''] = expression.split(/:(.*)/s, 2);
const variableName = rawVariableName.trim().toLowerCase();
const argument = rawArgument.trim();
let replacement = fullMatch;
if (variableName === 'date') {
replacement = safeFormat(now, normalizeDateFormat(argument) || DEFAULT_DATE_FORMAT);
} else if (variableName === 'time') {
replacement = safeFormat(now, normalizeDateFormat(argument) || DEFAULT_TIME_FORMAT);
} else if (variableName === 'timestamp') {
replacement = String(Math.floor(now.getTime() / 1000));
} else if (variableName === 'week') {
replacement = String(getISOWeek(now));
} else if (variableName === 'uuid') {
replacement = generateUuid();
} else if (variableName === 'random') {
replacement = generateRandomString(parseRandomLength(argument));
} else if (variableName === 'clipboard') {
if (!clipboardLoaded) {
cachedClipboard = await readClipboardText();
clipboardLoaded = true;
}
replacement = cachedClipboard ?? '';
if (!replacement) {
warnings.set(`clipboard:${expression}`, {
code: 'clipboardUnavailable',
variable: expression,
});
}
} else if (variableName === 'password') {
if (!passwordLoaded) {
cachedPassword = await resolveSessionPassword(context);
passwordLoaded = true;
}
replacement = cachedPassword ?? '';
if (!replacement) {
warnings.set(`password:${expression}`, {
code: 'passwordUnavailable',
variable: expression,
});
}
} else {
warnings.set(`unknown:${expression}`, {
code: 'unknownDynamicVariable',
variable: expression,
});
}
processedCommand = processedCommand.replace(fullMatch, replacement);
}
return {
command: processedCommand,
undefinedVariables: [...undefinedVariables],
warnings: [...warnings.values()],
};
}
function normalizeDateFormat(input: string): string {
if (!input) {
return '';
}
return input
.replace(/YYYY/g, 'yyyy')
.replace(/YY/g, 'yy')
.replace(/DD/g, 'dd');
}
function safeFormat(date: Date, pattern: string): string {
try {
return format(date, pattern);
} catch {
return format(date, pattern.includes('H') || pattern.includes('m') || pattern.includes('s') ? DEFAULT_TIME_FORMAT : DEFAULT_DATE_FORMAT);
}
}
function parseRandomLength(rawLength: string): number {
const parsedLength = Number.parseInt(rawLength, 10);
if (Number.isFinite(parsedLength) && parsedLength > 0) {
return parsedLength;
}
return 6;
}
function generateRandomString(length: number): string {
const result: string[] = [];
const randomBuffer = new Uint32Array(length);
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
crypto.getRandomValues(randomBuffer);
for (let index = 0; index < length; index += 1) {
result.push(RANDOM_CHARSET[randomBuffer[index] % RANDOM_CHARSET.length]);
}
return result.join('');
}
for (let index = 0; index < length; index += 1) {
result.push(RANDOM_CHARSET[Math.floor(Math.random() * RANDOM_CHARSET.length)]);
}
return result.join('');
}
function generateUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
const randomBytes = new Uint8Array(16);
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
crypto.getRandomValues(randomBytes);
} else {
for (let index = 0; index < randomBytes.length; index += 1) {
randomBytes[index] = Math.floor(Math.random() * 256);
}
}
randomBytes[6] = (randomBytes[6] & 0x0f) | 0x40;
randomBytes[8] = (randomBytes[8] & 0x3f) | 0x80;
const hex = [...randomBytes].map((value) => value.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
async function readClipboardText(): Promise<string | undefined> {
if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
return undefined;
}
try {
return await navigator.clipboard.readText();
} catch {
return undefined;
}
}
async function resolveSessionPassword(
context: ResolveQuickCommandTemplateContext,
): Promise<string | undefined> {
const { sessionId, sessions, connections, fetchLoginCredentialDetails } = context;
if (!sessionId || !sessions || !connections) {
return undefined;
}
const session = sessions.get(sessionId);
if (!session) {
return undefined;
}
const connection = connections.find((item) => String(item.id) === String(session.connectionId)) as
| (ConnectionInfo & { password?: string })
| undefined;
if (!connection) {
return undefined;
}
if (typeof connection.password === 'string' && connection.password.length > 0) {
return connection.password;
}
if (connection.login_credential_id && typeof fetchLoginCredentialDetails === 'function') {
const credential = await fetchLoginCredentialDetails(connection.login_credential_id);
if (credential?.password) {
return credential.password;
}
}
return undefined;
}
+48 -51
View File
@@ -4,8 +4,10 @@ import AddConnectionForm from '../components/AddConnectionForm.vue';
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue';
import { useConnectionsStore } from '../stores/connections.store';
import { useProxiesStore } from '../stores/proxies.store';
import { useSessionStore } from '../stores/session.store';
import { useTagsStore } from '../stores/tags.store';
import { useLoginCredentialsStore } from '../stores/loginCredentials.store';
import type { TagInfo } from '../stores/tags.store';
import type { SortField, SortOrder } from '../stores/settings.store';
import { useI18n } from 'vue-i18n';
@@ -45,11 +47,15 @@ const { t, locale } = useI18n();
const { showConfirmDialog } = useConfirmDialog();
const { showAlertDialog } = useAlertDialog();
const connectionsStore = useConnectionsStore();
const proxiesStore = useProxiesStore();
const sessionStore = useSessionStore();
const tagsStore = useTagsStore();
const loginCredentialsStore = useLoginCredentialsStore();
const { connections, isLoading: isLoadingConnections } = storeToRefs(connectionsStore);
const { tags } = storeToRefs(tagsStore);
const { proxies } = storeToRefs(proxiesStore);
const { loginCredentials } = storeToRefs(loginCredentialsStore);
const LS_SORT_BY_KEY = 'connections_view_sort_by';
const LS_SORT_ORDER_KEY = 'connections_view_sort_order';
@@ -97,7 +103,6 @@ const selectedConnectionIdsForBatch = ref<Set<number>>(new Set());
const showBatchEditForm = ref(false);
const isDeletingSelectedConnections = ref(false);
const expandedTreeNodes = ref<Record<string, boolean>>({});
const hoveredTreeNodeId = ref<ScopeId | null>(null);
const draggingTreeNodeId = ref<ScopeId | null>(null);
const dropTargetTreeNodeId = ref<ScopeId | null>(null);
const treeDragNoticeVisible = ref(false);
@@ -158,6 +163,42 @@ const getConnectionTagNames = (conn: ConnectionInfo): string[] => {
.filter((tagName): tagName is string => Boolean(tagName));
};
const getConnectionCredentialDisplay = (conn: ConnectionInfo): string => {
if (conn.login_credential_id) {
const credential = loginCredentials.value.find((item) => item.id === conn.login_credential_id);
if (credential) {
return `${t('connections.form.savedLoginCredential', '登录凭证')}: ${credential.name}`;
}
return `${t('connections.form.savedLoginCredential', '登录凭证')}: #${conn.login_credential_id}`;
}
return `${conn.username} ${conn.auth_method} ${conn.port}`;
};
const getConnectionEndpointTitle = (conn: ConnectionInfo): string => {
return `${conn.username}@${conn.host}:${conn.port}`;
};
const getConnectionRouteDisplay = (conn: ConnectionInfo): string => {
if (conn.proxy_type === 'proxy' && conn.proxy_id) {
const proxy = proxies.value.find((item) => item.id === conn.proxy_id);
if (proxy) {
return `${t('connections.proxyType', '代理')}: ${proxy.name}`;
}
return `${t('connections.proxyType', '代理')}: #${conn.proxy_id}`;
}
if (conn.proxy_type === 'jump' && conn.jump_chain?.length) {
const jumpNames = conn.jump_chain.map((jumpConnectionId) => {
const jumpConnection = connections.value.find((item) => item.id === jumpConnectionId);
return jumpConnection?.name || jumpConnection?.host || `#${jumpConnectionId}`;
});
return `${t('connections.form.connectionModeJumpHost', '跳板机')}: ${jumpNames.join(' -> ')}`;
}
return t('connections.noProxy', '未使用代理');
};
const getTagPathSegments = (tagName: string): string[] => {
return tagName
.split(tagPathSeparatorRegex)
@@ -635,6 +676,8 @@ const ensureDataLoaded = async () => {
}
await tagsStore.fetchTags();
await proxiesStore.fetchProxies();
await loginCredentialsStore.fetchLoginCredentials();
};
onMounted(async () => {
@@ -720,18 +763,6 @@ const clearTreeSearch = () => {
treeSearchQuery.value = '';
};
const setHoveredTreeNode = (nodeId: ScopeId | null) => {
hoveredTreeNodeId.value = nodeId;
};
const toggleTreeNodeFromAction = (node: TagTreeNode) => {
if (!node.expandable) {
return;
}
toggleTreeNode(node.id);
};
const startTreeDrag = (node: TagTreeNode) => {
draggingTreeNodeId.value = node.id;
dropTargetTreeNodeId.value = node.id;
@@ -1193,14 +1224,12 @@ onBeforeUnmount(() => {
v-for="node in visibleTagTreeNodes"
:key="node.id"
:class="[
'group 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-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getTreeNodeRowClass(node),
node.count === 0 ? 'opacity-55' : ''
]"
:style="{ paddingLeft: `${0.75 + node.level * 1.05}rem` }"
draggable="true"
@mouseenter="setHoveredTreeNode(node.id)"
@mouseleave="setHoveredTreeNode(null)"
@dragstart="startTreeDrag(node)"
@dragenter.prevent="updateTreeDropTarget(node)"
@dragover.prevent
@@ -1229,36 +1258,6 @@ onBeforeUnmount(() => {
>
{{ node.count }}
</span>
<div
:class="[
'flex items-center gap-1 flex-shrink-0 transition-opacity duration-150',
hoveredTreeNodeId === node.id ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
]"
>
<button
v-if="node.expandable"
@click.stop="toggleTreeNodeFromAction(node)"
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center"
:title="(expandedTreeNodes[node.id] ?? true) ? t('common.collapse', '收起') : t('common.expand', '展开')"
>
<i :class="['fas text-[11px]', (expandedTreeNodes[node.id] ?? true) ? 'fa-compress-alt' : 'fa-expand-alt']"></i>
</button>
<button
@click.stop="selectScope(node.id)"
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center"
:title="t('connections.scopePinAction', '定位到此范围')"
>
<i class="fas fa-crosshairs text-[11px]"></i>
</button>
<button
@mousedown.stop
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center cursor-grab active:cursor-grabbing"
:title="t('connections.scopeDragAction', '拖拽重排(预留)')"
>
<i class="fas fa-grip-lines text-[11px]"></i>
</button>
</div>
</div>
</div>
</div>
@@ -1483,9 +1482,7 @@ onBeforeUnmount(() => {
</h3>
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-text-secondary">
<span>{{ conn.username }}</span>
<span>{{ conn.auth_method }}</span>
<span>{{ conn.port }}</span>
<span>{{ getConnectionCredentialDisplay(conn) }}</span>
<span>{{ t('connections.createdAt', '创建于') }} {{ formatRelativeTime(conn.created_at) }}</span>
</div>
</div>
@@ -1495,11 +1492,11 @@ onBeforeUnmount(() => {
<div class="text-sm font-medium text-foreground truncate" :title="conn.host">
{{ conn.host }}
</div>
<div class="mt-2 text-sm text-text-secondary truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
<div class="mt-2 text-sm text-text-secondary truncate" :title="getConnectionEndpointTitle(conn)">
{{ conn.username }}@{{ conn.host }}:{{ conn.port }}
</div>
<div class="mt-2 text-xs text-text-secondary">
{{ conn.proxy_type ? `${t('connections.proxyType', '代理')}: ${conn.proxy_type}` : t('connections.noProxy', '未使用代理') }}
{{ getConnectionRouteDisplay(conn) }}
</div>
</div>
+156 -27
View File
@@ -216,10 +216,59 @@
<ul class="list-none p-0 m-0">
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('runNow', quickCommandContextTargetCommand!)"
>
<i class="fas fa-play-circle w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.runNow', '立即执行') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('pasteToTerminal', quickCommandContextTargetCommand!)"
>
<i class="fas fa-terminal w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.pasteToTerminal', '粘贴到终端') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('copyCommand', quickCommandContextTargetCommand!)"
>
<i class="fas fa-copy w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.copyCommand', '复制命令') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('pasteToQuickInput', quickCommandContextTargetCommand!)"
>
<i class="fas fa-i-cursor w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.pasteToQuickInput', '粘贴到快捷输入框') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('sendToAllSessions', quickCommandContextTargetCommand!)"
>
<span>{{ t('quickCommands.actions.sendToAllSessions', '发送到全部会话') }}</span>
<i class="fas fa-server w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.sendToAllSessions', '发送到全部服务器') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1 mt-1 border-t border-border/50 pt-2"
@click="handleQuickCommandMenuAction('edit', quickCommandContextTargetCommand!)"
>
<i class="fas fa-pen w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('quickCommands.actions.edit', '编辑') }}</span>
</li>
<li
v-if="quickCommandContextTargetCommand"
class="group px-4 py-1.5 cursor-pointer flex items-center gap-3 text-error hover:bg-error/10 hover:text-error text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleQuickCommandMenuAction('delete', quickCommandContextTargetCommand!)"
>
<i class="fas fa-trash-alt w-4 text-center text-error"></i>
<span>{{ t('quickCommands.actions.delete', '删除') }}</span>
</li>
</ul>
</div>
@@ -241,7 +290,9 @@ import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import { useSessionStore } from '../stores/session.store';
import type { SessionState } from '../stores/session/types';
import { useConnectionsStore } from '../stores/connections.store';
import { useLoginCredentialsStore } from '../stores/loginCredentials.store';
import { getUniqueConnectedSshSessions } from '../utils/sessionSelection';
import { resolveQuickCommandTemplate, type QuickCommandTemplateWarning } from '../utils/quickCommandTemplate';
const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore();
@@ -253,6 +304,7 @@ const settingsStore = useSettingsStore();
const emitWorkspaceEvent = useWorkspaceEventEmitter();
const sessionStore = useSessionStore();
const connectionsStore = useConnectionsStore();
const loginCredentialsStore = useLoginCredentialsStore();
const hoveredItemId = ref<number | null>(null);
const isFormVisible = ref(false);
@@ -270,6 +322,14 @@ const tagInputRefs = ref(new Map<string | number, HTMLInputElement | null>());
const quickCommandContextMenuVisible = ref(false);
const quickCommandContextMenuPosition = ref({ x: 0, y: 0 });
const quickCommandContextTargetCommand = ref<QuickCommandFE | null>(null);
type QuickCommandContextAction =
| 'runNow'
| 'pasteToTerminal'
| 'copyCommand'
| 'pasteToQuickInput'
| 'edit'
| 'delete'
| 'sendToAllSessions';
// --- Store Getter ---
const searchTerm = computed(() => quickCommandsStore.searchTerm);
@@ -552,46 +612,83 @@ const copyCommand = async (command: string) => {
}
};
//
const executeCommand = (cmd: QuickCommandFE) => {
// 1. 使
quickCommandsStore.incrementUsage(cmd.id);
let processedCommand = cmd.command;
const savedVariables = cmd.variables || {}; // 使
// 2.
for (const varName in savedVariables) {
const placeholder = new RegExp(`\\$\\{${varName}\\}`, 'g');
processedCommand = processedCommand.replace(placeholder, savedVariables[varName]);
const notifyTemplateWarnings = (undefinedVariables: string[], warnings: QuickCommandTemplateWarning[]) => {
if (undefinedVariables.length > 0) {
uiNotificationsStore.showWarning(
t('quickCommands.form.warningUndefinedVariables', { variables: undefinedVariables.join(', ') })
);
}
// 3.
const variablePlaceholders = cmd.command.match(/\$\{[^\}]+\}/g) || [];
const undefinedVariables: string[] = [];
variablePlaceholders.forEach(placeholder => {
const varName = placeholder.substring(2, placeholder.length - 1);
if (!savedVariables.hasOwnProperty(varName)) {
undefinedVariables.push(varName);
warnings.forEach((warning) => {
if (warning.code === 'clipboardUnavailable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.clipboardUnavailable', '无法读取剪贴板内容,已按空文本处理。'));
} else if (warning.code === 'passwordUnavailable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.passwordUnavailable', '当前活动连接没有可用的登录密码,已按空文本处理。'));
} else if (warning.code === 'unknownDynamicVariable') {
uiNotificationsStore.showWarning(t('quickCommands.form.dynamicVariables.warnings.unknownVariable', { variable: warning.variable }));
}
});
};
const resolveProcessedCommand = async (cmd: QuickCommandFE, sessionId?: string | null) => {
const result = await resolveQuickCommandTemplate(cmd.command, {
customVariables: cmd.variables || {},
sessionId,
sessions: sessionStore.sessions,
connections: connectionsStore.connections,
fetchLoginCredentialDetails: loginCredentialsStore.fetchLoginCredentialDetails,
});
notifyTemplateWarnings(result.undefinedVariables, result.warnings);
return result.command;
};
// 4. SSH ID
const getActiveSessionIdOrNotify = () => {
const activeSessionId = sessionStore.activeSessionId;
if (!activeSessionId) {
uiNotificationsStore.showError(t('quickCommands.form.errorNoActiveSession', '没有活动的SSH会话可执行指令。'));
return null;
}
return activeSessionId;
};
//
const executeCommand = async (cmd: QuickCommandFE) => {
const activeSessionId = getActiveSessionIdOrNotify();
if (!activeSessionId) {
return;
}
// 5. quickCommand:executeProcessed
void quickCommandsStore.incrementUsage(cmd.id);
const processedCommand = await resolveProcessedCommand(cmd, activeSessionId);
emitWorkspaceEvent('quickCommand:executeProcessed', {
command: processedCommand,
sessionId: activeSessionId
});
};
const pasteCommandToTerminalInput = async (cmd: QuickCommandFE) => {
const activeSessionId = getActiveSessionIdOrNotify();
if (!activeSessionId) {
return;
}
sessionStore.updateSessionCommandInput(activeSessionId, await resolveProcessedCommand(cmd, activeSessionId));
uiNotificationsStore.showSuccess(t('quickCommands.notifications.pastedToTerminal', '已粘贴到终端输入框'));
};
const pasteCommandToQuickInput = async (cmd: QuickCommandFE) => {
const activeSessionId = sessionStore.activeSessionId;
quickCommandsStore.setSearchTerm(await resolveProcessedCommand(cmd, activeSessionId));
await nextTick();
if (searchInputRef.value) {
searchInputRef.value.focus();
searchInputRef.value.select();
}
uiNotificationsStore.showSuccess(t('quickCommands.notifications.pastedToQuickInput', '已粘贴到快捷输入框'));
};
// +++ +++
const focusSearchInput = (): boolean => {
if (searchInputRef.value) {
@@ -761,15 +858,47 @@ const closeQuickCommandContextMenu = () => {
document.removeEventListener('click', closeQuickCommandContextMenu);
};
const handleQuickCommandMenuAction = (action: 'sendToAllSessions', command: QuickCommandFE) => {
const handleQuickCommandMenuAction = async (action: QuickCommandContextAction, command: QuickCommandFE) => {
closeQuickCommandContextMenu();
if (action === 'runNow') {
await executeCommand(command);
return;
}
if (action === 'pasteToTerminal') {
await pasteCommandToTerminalInput(command);
return;
}
if (action === 'copyCommand') {
void copyCommand(command.command);
return;
}
if (action === 'pasteToQuickInput') {
await pasteCommandToQuickInput(command);
return;
}
if (action === 'edit') {
openEditForm(command);
return;
}
if (action === 'delete') {
void confirmDelete(command);
return;
}
if (action === 'sendToAllSessions') {
const activeSshSessions = getUniqueConnectedSshSessions(sessionStore.sessions, connectionsStore.connections);
if (activeSshSessions.length > 0) {
activeSshSessions.forEach((session: SessionState) => {
emitWorkspaceEvent('terminal:sendCommand', { sessionId: session.sessionId, command: command.command });
});
for (const session of activeSshSessions) {
const processedCommand = await resolveProcessedCommand(command, session.sessionId);
emitWorkspaceEvent('terminal:sendCommand', { sessionId: session.sessionId, command: processedCommand });
}
uiNotificationsStore.addNotification({
message: t('quickCommands.notifications.sentToAllSessions', { count: activeSshSessions.length }),
type: 'success',