feat(frontend): 重构工作区终端与导航交互

将 SSH 顶部标签改为服务器级切换入口,并把同服务器下的
多终端切换、新增与关闭下沉到终端面板内部,修正服务器与
终端的视觉层级

同时将 Workbench 导航改为左侧图标栏,并为终端标签右键菜单
补充“关闭全部”动作,完善相关多语言文案与工作区事件处理
This commit is contained in:
yinjianm
2026-03-29 23:01:49 +08:00
parent 26acdba7e8
commit d3e8d598b8
20 changed files with 762 additions and 164 deletions
+3 -1
View File
@@ -8,7 +8,9 @@
- 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。 - 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。
### 修复 ### 修复
- **[frontend]**: 修复文件管理器右键菜单的回归关闭竞态,避免“终端 / 上传 / 压缩”子菜单在展开或点击前被捕获阶段监听提前关闭 — by yinjianm - **[frontend]**: `/workspace` 的 SSH 多终端展示从顶部组头胶囊改为“顶部只切服务器、终端面板内部切换同服务器多个终端”,修正服务器与终端的视觉层级 - by yinjianm
- 方案: [202603292139_terminal-server-internal-tabs](archive/2026-03/202603292139_terminal-server-internal-tabs/)
- **[frontend]**: 修复文件管理器右键菜单的回归关闭竞态,避免“终端 / 上传 / 压缩”子菜单在展开或点击前被捕获阶段监听提前关闭 - by yinjianm
- 方案: [202603260527_file-manager-context-submenu-regression](archive/2026-03/202603260527_file-manager-context-submenu-regression/) - 方案: [202603260527_file-manager-context-submenu-regression](archive/2026-03/202603260527_file-manager-context-submenu-regression/)
- **[frontend]**: 修复文件管理器右键子菜单点击无反应、拖拽上传目标不明确,以及目录删除后持续报 `No such file` 的稳定性问题 — by yinjianm - **[frontend]**: 修复文件管理器右键子菜单点击无反应、拖拽上传目标不明确,以及目录删除后持续报 `No such file` 的稳定性问题 — by yinjianm
- 方案: [202603260324_file-manager-delete-upload-stability](archive/2026-03/202603260324_file-manager-delete-upload-stability/) - 方案: [202603260324_file-manager-delete-upload-stability](archive/2026-03/202603260324_file-manager-delete-upload-stability/)
@@ -0,0 +1 @@
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"前端改造与构建验证已完成,待归档","updated_at":"2026-03-29 22:15:00"}
@@ -0,0 +1,98 @@
# 变更提案: terminal-server-internal-tabs
## 元信息
```yaml
类型: 优化
方案类型: implementation
优先级: P1
状态: 进行中
创建: 2026-03-29
```
---
## 1. 需求
### 背景
当前工作区顶部的 `TerminalTabBar` 同时承担“服务器切换”和“同服务器多终端切换”两类职责,导致展示层把一个服务器组整体渲染成顶栏胶囊。实际交互上,同一 SSH 服务器已经支持打开多个终端,但视觉层级仍然是“全局会话标签”,与用户参考图中“先切到某台服务器,再在该服务器页面内部切换多个终端”的模型不一致。
### 目标
- 顶部标签栏降级为服务器级切换入口,不再直接承载该服务器下的终端子标签。
- 当前激活服务器的终端切换、关闭和新增入口下沉到终端内容区内部。
- 保持现有 `sessionId`、WebSocket、SSH session 的一对一模型不变,仅调整展示和切换逻辑。
- 维持现有工作区布局、RDP/VNC 行为以及其他面板对 `activeSessionId` 的兼容。
### 约束条件
```yaml
范围约束: 仅修改 frontend 展示层与少量会话派生逻辑,不改 backend 协议
兼容性约束: RDP/VNC 会话继续沿用现有顶部会话切换方式
实现约束: 复用已有 session store、terminalIndex 与 activeSessionId,不引入新的全局状态库
布局约束: 桌面端和移动端都需保持可用,避免破坏现有 LayoutRenderer 结构
```
### 验收标准
- [ ] 顶部标签栏对 SSH 连接只展示服务器级入口,不再展示“服务器头 + 终端 1/终端 2”的组合胶囊。
- [ ] 在某个 SSH 服务器对应的终端面板内部可以查看、切换、新增并关闭该服务器下的多个终端。
- [ ] 当前活动服务器切换后,终端面板内部标签同步切换到该服务器的活动终端。
- [ ] `packages/frontend` 可完成至少一次构建或类型级验证,且没有新增的模板/属性错误。
---
## 2. 方案
### 技术方案
保留现有基于 `activeSessionId` 的主工作区数据流,不重构 session store。将 `TerminalTabBar.vue` 的 SSH 展示重心调整为“按连接聚合的服务器标签”,点击服务器时激活该连接当前活动终端或最后一个终端;同时在终端面板区域新增一个轻量的内部终端切换条,只展示当前活动服务器对应的 SSH 子终端,并在该区域提供新增/关闭能力。`LayoutRenderer.vue` 继续负责所有终端实例的 `keep-alive` 渲染,但在终端背景层上方新增当前服务器内部导航。
### 影响范围
```yaml
涉及模块:
- frontend: TerminalTabBar 顶部服务器切换逻辑调整
- frontend: LayoutRenderer 终端面板内部导航与当前服务器终端切换
- frontend: i18n 文案补充
预计变更文件: 3-5
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 顶部栏从会话级改成服务器级后,现有拖拽排序语义可能变弱 | 中 | SSH 仅保留服务器级聚合展示;非 SSH 仍沿用原会话级逻辑 |
| 终端内容区内切换条如果直接耦合 Terminal 实例,可能影响现有 `keep-alive` 行为 | 中 | 保持实例渲染仍在 `LayoutRenderer`,内部切换条只驱动 `activeSessionId` |
| 其他依赖 `activeSessionId` 的面板可能在服务器切换时出现会话选择不一致 | 低 | 继续以活动终端作为全局活动会话,不新增第二套“活动服务器”状态 |
---
## 3. 技术决策
### terminal-server-internal-tabs#D001: 以“活动服务器 + 活动终端”双层视图重组展示,而不重构底层会话模型
**日期**: 2026-03-29
**状态**: 采纳
**背景**: 现有问题是 UI 归属错误,不是多终端能力缺失;底层已经支持同连接多会话。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 仅继续美化顶部分组标签 | 改动最小 | 仍然违背“终端属于服务器页面内部”的交互模型 |
| B: 新增活动服务器层,顶部只切服务器,终端面板内部切换子终端 | 贴合目标,复用现有 session 模型,回滚边界清晰 | 需要同时改顶部栏和终端面板两处展示 |
| C: 重构为嵌套 session/group store | 结构概念更完整 | 改动过大,超出这次局部 UI 纠偏需求 |
**决策**: 选择方案 B。
**理由**: 用户要求的是展示逻辑回到“服务器页面内部开多个终端”,方案 B 可以在不碰后端和不重写 store 的前提下完成目标。
**影响**: 主要影响 `TerminalTabBar.vue``LayoutRenderer.vue` 以及相关文案。
---
## 4. 成果设计
### 设计方向
- **美学基调**: 延续当前深色运维工作台风格,把顶部服务器入口做得更克制,把终端面板内部导航做成更贴近“本机多标签终端”的工具感界面。
- **记忆点**: 服务器是外层入口,终端是内层工作标签,两层层级明确分离。
- **参考**: 用户提供的终端参考图,核心不是照搬皮肤,而是复用“服务器内多终端标签”的交互结构。
### 视觉要素
- **配色**: 保留当前暗色背景和绿色活跃态,但把高亮集中在当前服务器与当前终端两个层级,避免整条胶囊都发光。
- **字体**: 沿用项目现有字体体系,不做额外字体扩展。
- **布局**: 顶部横条显示服务器级入口;终端面板顶部增加内嵌次级标签条,紧贴终端区域。
- **动效**: 保持现有 hover/active 过渡即可,不新增重动画。
- **氛围**: 强化“终端工作台”而不是“胶囊式全局标签”的层次感。
### 技术约束
- **可访问性**: 内部标签与新增按钮保留 `title`/状态色提示。
- **响应式**: 移动端不强行塞入复杂双层标签,优先保持能切换和新增。
@@ -0,0 +1,54 @@
# 任务清单: terminal-server-internal-tabs
> **@status:** completed | 2026-03-29 22:59
```yaml
@feature: terminal-server-internal-tabs
@created: 2026-03-29
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
---
## 任务列表
### 1. 方案与现状确认
- [√] 1.1 复核 `TerminalTabBar.vue``LayoutRenderer.vue``sessionActions.ts` 的现状,锁定“顶部服务器切换 + 面板内终端切换”的最小实现范围 | depends_on: []
### 2. 顶部服务器级切换改造
- [√] 2.1 在 `packages/frontend/src/components/TerminalTabBar.vue` 中将 SSH 顶部标签改为服务器级入口,并保留非 SSH 会话的现有行为 | depends_on: [1.1]
### 3. 终端面板内切换改造
- [√] 3.1 在 `packages/frontend/src/components/LayoutRenderer.vue` 中新增当前服务器内部终端切换条,支持切换/新增/关闭该服务器下的终端 | depends_on: [2.1]
- [√] 3.2 补充 `packages/frontend/src/locales/zh-CN.json``packages/frontend/src/locales/en-US.json` 的相关文案 | depends_on: [3.1]
### 4. 验证与同步
- [√] 4.1 运行前端构建并同步知识库变更说明 | depends_on: [3.2]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-29 21:39 | 方案包创建 | 完成 | 已创建 `202603292139_terminal-server-internal-tabs` 并确认本轮按 R2 执行 |
| 2026-03-29 21:54 | 2.1 / 3.1 / 3.2 | 完成 | 顶部 SSH 标签改为服务器级入口,终端面板内新增当前服务器终端切换条与新增按钮 |
| 2026-03-29 22:15 | 4.1 | 完成 | `npm --prefix packages/frontend run build` 通过;仅保留既有 Vite chunk size / dynamic import 提示 |
---
## 执行备注
- 本轮只调整 SSH 多终端的展示归属,不改变后端协议、不扩展到 RDP/VNC 会话模型。
- 顶部拖拽在存在 SSH 聚合展示时已临时禁用,避免“可见服务器项”与“底层会话项”不一致造成错误拖拽。
@@ -0,0 +1 @@
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"Completed - workbench left icon rail implemented and verified","updated_at":"2026-03-29 22:53:00"}
@@ -0,0 +1,116 @@
# 变更提案: workbench-left-icon-rail
## 元信息
```yaml
类型: 优化
方案类型: implementation
优先级: P1
状态: 已完成
创建: 2026-03-29
```
---
## 1. 需求
### 背景
当前 `WorkspaceWorkbench.vue` 将“快捷指令 / 文件 / 历史命令 / 编辑器”渲染为顶部 2x2 文本按钮组。用户希望参考文件管理器截图中的窄侧边栏交互,将这组入口移动到容器最左侧,并改为小图标自上而下竖排展示,以更接近桌面资源管理器的导航感。
### 目标
在不改变现有四个工作区面板切换逻辑的前提下:
- 把 Workbench 导航从顶部网格按钮改为左侧纵向 icon rail。
- 默认仅显示图标,使用 tooltip 暴露标签文本。
- 保留当前激活态高亮,让用户能清晰识别当前面板。
- 保持右侧内容区和文件管理、历史命令、编辑器等现有功能链路不变。
### 约束条件
```yaml
时间约束: 本轮只做现有布局重排和样式优化,不扩展新的功能入口。
性能约束: 不引入额外依赖,不增加面板切换时的运行时开销。
兼容性约束: 保持现有四个 tab 的切换逻辑与现有国际化文案兼容。
业务约束: 文件、历史命令、编辑器、快捷指令的功能实现保持原样,仅调整导航表现。
```
### 验收标准
- [ ] Workbench 左侧出现固定窄栏,四个入口图标从上到下竖排展示。
- [ ] 未激活项仅显示图标,悬停可通过 `title` 查看名称;激活态可被明显识别。
- [ ] 右侧 header 和内容面板仍能正常切换,文件管理和编辑器上下文不回归。
- [ ] `npm run build --workspace=@nexus-terminal/frontend` 通过。
---
## 2. 方案
### 技术方案
仅修改 `packages/frontend/src/components/WorkspaceWorkbench.vue`
- 将当前顶部导航按钮区从 header 中拆出,改为与内容区并列的左侧 rail 容器。
- 每个 tab 按钮改成固定宽高的图标按钮,使用 `:title="tab.label"` 提供 tooltip。
- 右侧保留现有 title / session 信息区域与内容切换区域,避免影响内部组件树。
- 通过 scoped CSS 增加 rail 的背景、分隔线、激活态和 hover 态,形成更接近参考图的资源管理器式侧栏。
### 影响范围
```yaml
涉及模块:
- frontend: 工作区 Workbench 导航布局和视觉样式调整
预计变更文件: 1
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 左侧 rail 挤压内容区,导致窄屏显示不佳 | 中 | 控制 rail 宽度并保留右侧内容 `min-w-0`,以现有容器自适应布局为主 |
| 导航区 DOM 重排影响现有 header 层级或滚动区域 | 中 | 只调整 `WorkspaceWorkbench.vue` 外层结构,不触碰四个面板组件内部实现 |
| 样式修改导致激活态辨识不足 | 低 | 保留主色高亮、边框和背景差异,且 tooltip 始终可用 |
---
## 3. 技术设计(可选)
本次不涉及 API、数据模型或架构层改动,保持 N/A。
---
## 4. 核心场景
### 场景: 工作区左侧图标导航
**模块**: frontend
**条件**: 用户进入 `/workspace`Workbench 区域已渲染。
**行为**: 用户通过左侧竖排图标点击切换快捷指令、文件、历史命令和编辑器。
**结果**: 右侧内容区域切换到对应面板,左侧当前图标高亮,未激活项保持极简图标外观。
---
## 5. 技术决策
### workbench-left-icon-rail#D001: Workbench 导航改为极简左侧图标栏
**日期**: 2026-03-29
**状态**: 已采纳
**背景**: 用户明确要求参考文件管理器样式,把 Workbench 入口放到容器最左边,并选择“仅显示小图标,文字全部隐藏,仅用 tooltip 显示名称”。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 左侧极简图标栏 | 最贴近参考图,占用空间小,视觉上更像桌面导航 | 需要通过 tooltip 补足文字语义 |
| B: 左侧图标加短文字 | 可读性更高 | 占宽更大,和参考图差异更明显 |
**决策**: 选择方案 A
**理由**: 该方案完全符合用户确认的展示方式,同时对现有组件逻辑影响最小,只需调整导航容器和按钮样式即可。
**影响**: 影响 `WorkspaceWorkbench.vue` 的布局结构和样式,不改变四个子面板组件的实现。
---
## 6. 成果设计
### 设计方向
- **美学基调**: 工业化桌面工具栏,强调“窄、稳、冷静”的资源管理器式侧边导航,而不是卡片化 dashboard 按钮区。
- **记忆点**: Workbench 最左侧的细窄图标栏与右侧内容区形成明确分栏,四个入口像原生桌面工具栏一样垂直排列。
- **参考**: 用户提供的文件管理器截图;顶部文字按钮组向左侧 icon rail 收敛。
### 视觉要素
- **配色**: 继承现有 `bg-header``border-border``bg-background``primary` 主题变量,激活态用主色背景和浅阴影做聚焦。
- **字体**: 导航主交互不直接展示文字,tooltip 继续使用现有项目字体体系,无新增字体依赖。
- **布局**: 左侧固定窄栏,顶部到下方竖排排列四个图标按钮;右侧为 header + 内容面板两层结构。
- **动效**: 保留当前按钮 hover / active 的颜色过渡,避免大幅动画影响工具型界面的稳定感。
- **氛围**: 通过左侧窄栏边界和轻微层次阴影,强化“桌面工作区工具箱”观感。
### 技术约束
- **可访问性**: 每个图标按钮保留 `title` 文本,确保隐藏标签后仍可识别;激活态需要有清晰视觉差异。
- **响应式**: rail 采用固定窄宽度,右侧内容保持 `flex-1 min-w-0`,避免内容区域被挤爆。
@@ -0,0 +1,50 @@
# 任务清单: workbench-left-icon-rail
> **@status:** completed | 2026-03-29 22:55
```yaml
@feature: workbench-left-icon-rail
@created: 2026-03-29
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
---
## 任务列表
### 1. 方案设计
- [√] 1.1 完成 Workbench 左侧 icon rail 的布局与交互方案整理 | depends_on: []
- [√] 1.2 补全方案包 `proposal.md``tasks.md`,并通过包结构校验 | depends_on: [1.1]
### 2. 开发实施
- [√] 2.1 在 `packages/frontend/src/components/WorkspaceWorkbench.vue` 中将顶部 tab 按钮组改为左侧竖排图标栏 | depends_on: [1.2]
- [√] 2.2 运行前端构建验证布局改动未破坏类型检查和打包 | depends_on: [2.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-29 22:47 | 1.1 | completed | 已确认采用“仅显示小图标 + tooltip”的左侧竖排导航方案 |
| 2026-03-29 22:49 | 1.2 | completed | 已补全 proposal/tasks 并通过 validate_package.py 校验 |
| 2026-03-29 22:52 | 2.1 | completed | 已将顶部 tab 网格改为 Workbench 左侧竖排 icon rail |
| 2026-03-29 22:53 | 2.2 | completed | `npm run build --workspace=@nexus-terminal/frontend` 通过,仅保留既有 chunk warning |
---
## 执行备注
> 记录执行过程中的重要说明、决策变化、风险提示等
- 本次改动目标是最小化重排,仅调整 Workbench 导航容器,不扩散到文件管理、历史命令和编辑器子组件内部。
- 构建验证通过,Vite 仍输出仓库既有的动态导入与大 chunk 警告,但未阻断本次交付。
@@ -0,0 +1,72 @@
# 变更提案: terminal-tab-close-all
## 元信息
```yaml
类型: 修复
方案类型: implementation
优先级: P2
状态: 已完成
创建: 2026-03-29
```
---
## 1. 需求
### 背景
当前终端标签右键菜单已经支持关闭当前、关闭其他、关闭左侧和关闭右侧,但缺少“一次性关闭全部终端标签”的入口。用户希望在工作区内直接关闭所有服务器的终端标签,而不是仅针对当前服务器分组操作。
### 目标
- 在终端标签右键菜单中新增“关闭全部”。
- 点击后关闭当前工作区内全部终端标签。
- 保持现有右键菜单事件链路与会话清理逻辑,不引入新的后端依赖。
### 约束条件
```yaml
范围约束: 仅修改 frontend 菜单、工作区事件和本地化文案
行为约束: “关闭全部”作用域为当前工作区全部终端标签,不区分服务器
实现约束: 复用现有 session store 的 cleanupAllSessions 和 Workspace 事件总线
```
### 验收标准
- [x] 终端标签右键菜单在多标签场景下显示“关闭全部”。
- [x] 点击“关闭全部”后,当前工作区中的全部终端标签被关闭。
- [x] `packages/frontend` 构建通过,未引入新的类型错误。
---
## 2. 方案
### 技术方案
沿用现有 `TerminalTabBar.vue -> workspaceEvents.ts -> WorkspaceView.vue -> session.store.ts` 的前端事件链,新增 `session:closeAll` 事件。`TerminalTabBar.vue` 负责在多标签场景下渲染菜单项并发出事件,`WorkspaceView.vue` 统一接收后调用 `sessionStore.cleanupAllSessions()` 完成全部会话关闭,文案同步更新到中英文 locale 文件。
### 影响范围
```yaml
涉及模块:
- frontend: TerminalTabBar 右键菜单项
- frontend: workspaceEvents 工作区事件类型
- frontend: WorkspaceView 全部关闭处理
- frontend: zh-CN / en-US 本地化文案
预计变更文件: 5
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 关闭全部复用 `cleanupAllSessions()` 时影响现有退出清理逻辑 | 低 | 仅在用户主动点击菜单时调用,且该逻辑已用于工作区卸载清理 |
| 右键菜单新增项后多语言缺失导致展示 key | 低 | 同步补充 `zh-CN``en-US` 文案 |
---
## 3. 技术决策
### terminal-tab-close-all#D001: 复用工作区级事件总线和现有全部清理能力
**日期**: 2026-03-29
**状态**: 采纳
**背景**: 当前终端标签关闭动作已全部走 `WorkspaceView` 中转处理,`session.store` 已存在 `cleanupAllSessions()` 可安全关闭全部会话。
**备选方案**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 在 `TerminalTabBar.vue` 内循环逐个关闭 | 改动直观 | 组件直接操作关闭细节,事件层次不一致 |
| B: 新增 `session:closeAll` 事件并由 `WorkspaceView` 调用 `cleanupAllSessions()` | 与现有 close/closeOthers 链路一致,复用现有清理逻辑 | 需要补一个事件类型 |
**决策**: 选择方案 B。
**理由**: 保持终端标签菜单与工作区事件模型一致,避免在组件层直接堆叠会话清理逻辑。
@@ -0,0 +1,42 @@
# 任务清单: terminal-tab-close-all
```yaml
@feature: terminal-tab-close-all
@created: 2026-03-29
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
---
## 任务列表
### 1. 现状确认
- [x] 1.1 复核 `TerminalTabBar.vue``workspaceEvents.ts``WorkspaceView.vue` 的现有关闭链路,确认新增菜单项可复用工作区事件处理 | depends_on: []
### 2. 菜单与事件扩展
- [x] 2.1 在 `packages/frontend/src/components/TerminalTabBar.vue` 中新增“关闭全部”菜单项和 `close-all` 动作 | depends_on: [1.1]
- [x] 2.2 在 `packages/frontend/src/composables/workspaceEvents.ts` 中补充 `session:closeAll` 事件类型,并在 `packages/frontend/src/views/WorkspaceView.vue` 中接入全部关闭处理 | depends_on: [2.1]
### 3. 文案与验证
- [x] 3.1 更新 `packages/frontend/src/locales/zh-CN.json``packages/frontend/src/locales/en-US.json` 文案 | depends_on: [2.2]
- [x] 3.2 运行 `npm --prefix packages/frontend run build` 验证改动 | depends_on: [3.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-29 | 1.1 | 完成 | 确认终端关闭动作统一经 Workspace 事件总线流转 |
| 2026-03-29 | 2.1 | 完成 | 新增 `close-all` 菜单动作 |
| 2026-03-29 | 2.2 | 完成 | 新增 `session:closeAll` 事件并接入 `cleanupAllSessions()` |
| 2026-03-29 | 3.1 | 完成 | 补充中英文菜单文案 |
| 2026-03-29 | 3.2 | 完成 | 前端构建通过,仅保留既有 chunk warning |
+4
View File
@@ -7,6 +7,9 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 | | 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------| |--------|------|------|---------|------|------|
| 202603292139 | terminal-server-internal-tabs | - | - | - | ✅完成 |
| 202603292247 | workbench-left-icon-rail | - | - | - | ✅完成 |
| 202603292300 | terminal-tab-close-all | implementation | frontend | terminal-tab-close-all#D001 | ✅完成 |
| 202603260527 | file-manager-context-submenu-regression | implementation | frontend | file-manager-context-submenu-regression#D001 | ✅完成 | | 202603260527 | file-manager-context-submenu-regression | implementation | frontend | file-manager-context-submenu-regression#D001 | ✅完成 |
| 202603260324 | file-manager-delete-upload-stability | implementation | frontend, backend | file-manager-delete-upload-stability#D001 | ✅完成 | | 202603260324 | file-manager-delete-upload-stability | implementation | frontend, backend | file-manager-delete-upload-stability#D001 | ✅完成 |
| 202603260310 | file-manager-root-sibling-bootstrap | implementation | frontend | file-manager-root-sibling-bootstrap#D001 | ✅完成 | | 202603260310 | file-manager-root-sibling-bootstrap | implementation | frontend | file-manager-root-sibling-bootstrap#D001 | ✅完成 |
@@ -40,6 +43,7 @@
## 按月归档 ## 按月归档
### 2026-03 ### 2026-03
- [202603292300_terminal-tab-close-all](./2026-03/202603292300_terminal-tab-close-all/) - 为终端标签右键菜单补充“关闭全部”,并复用现有工作区会话清理链路
- [202603260527_file-manager-context-submenu-regression](./2026-03/202603260527_file-manager-context-submenu-regression/) - 修复文件管理器右键菜单回归关闭竞态,恢复终端、上传、压缩等子菜单展开与点击 - [202603260527_file-manager-context-submenu-regression](./2026-03/202603260527_file-manager-context-submenu-regression/) - 修复文件管理器右键菜单回归关闭竞态,恢复终端、上传、压缩等子菜单展开与点击
- [202603260324_file-manager-delete-upload-stability](./2026-03/202603260324_file-manager-delete-upload-stability/) - 修复文件管理器右键子菜单点击、拖拽上传目标确认、目录删除模式选择与删除后路径失效回退 - [202603260324_file-manager-delete-upload-stability](./2026-03/202603260324_file-manager-delete-upload-stability/) - 修复文件管理器右键子菜单点击、拖拽上传目标确认、目录删除模式选择与删除后路径失效回退
- [202603260234_folder-upload-auto-zip](./2026-03/202603260234_folder-upload-auto-zip/) - 为文件管理器补齐上传文件夹入口,选择目录后先打包为 zip,再上传并自动触发远端解压 - [202603260234_folder-upload-auto-zip](./2026-03/202603260234_folder-upload-auto-zip/) - 为文件管理器补齐上传文件夹入口,选择目录后先打包为 zip,再上传并自动触发远端解压
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -286,8 +286,8 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
<!-- 项目 Logo --> <!-- 项目 Logo -->
<img src="./assets/logo.png" alt="Project Logo" class="h-10 w-auto"> <!-- 移除右侧外边距使其更靠左 --> <img src="./assets/logo.png" alt="Project Logo" class="h-10 w-auto"> <!-- 移除右侧外边距使其更靠左 -->
<RouterLink to="/" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.dashboard') }}</RouterLink> <!-- 恢复仪表盘链接, 始终可见 --> <RouterLink to="/" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.dashboard') }}</RouterLink> <!-- 恢复仪表盘链接, 始终可见 -->
<RouterLink to="/workspace" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.terminal') }}</RouterLink> <!-- 保持可见 -->
<RouterLink to="/connections" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.connections') }}</RouterLink> <!-- 连接管理链接 --> <RouterLink to="/connections" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.connections') }}</RouterLink> <!-- 连接管理链接 -->
<RouterLink to="/workspace" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.terminal') }}</RouterLink> <!-- 保持可见 -->
<RouterLink to="/proxies" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.proxies') }}</RouterLink> <!-- 移动端隐藏 --> <RouterLink to="/proxies" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.proxies') }}</RouterLink> <!-- 移动端隐藏 -->
<RouterLink to="/notifications" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.notifications') }}</RouterLink> <!-- 移动端隐藏 --> <RouterLink to="/notifications" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.notifications') }}</RouterLink> <!-- 移动端隐藏 -->
<RouterLink to="/audit-logs" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.auditLogs') }}</RouterLink> <!-- 移动端隐藏 --> <RouterLink to="/audit-logs" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.auditLogs') }}</RouterLink> <!-- 移动端隐藏 -->
@@ -12,6 +12,7 @@ import { useSettingsStore } from '../stores/settings.store';
import { useAppearanceStore } from '../stores/appearance.store'; // +++ Import appearance store +++ import { useAppearanceStore } from '../stores/appearance.store'; // +++ Import appearance store +++
import { useSidebarResize } from '../composables/useSidebarResize'; import { useSidebarResize } from '../composables/useSidebarResize';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import type { SessionTabInfoWithStatus } from '../stores/session/types';
// --- Props --- // --- Props ---
@@ -66,7 +67,7 @@ const {
terminalCustomHTML, terminalCustomHTML,
} = storeToRefs(appearanceStore); } = storeToRefs(appearanceStore);
const { activeSession } = storeToRefs(sessionStore); const { activeSession, sessionTabsWithStatus } = storeToRefs(sessionStore);
const { workspaceSidebarPersistentBoolean, getSidebarPaneWidth } = storeToRefs(settingsStore); const { workspaceSidebarPersistentBoolean, getSidebarPaneWidth } = storeToRefs(settingsStore);
const { sidebarPanes } = storeToRefs(layoutStore); const { sidebarPanes } = storeToRefs(layoutStore);
const { orderedTabs: editorTabsFromStore, activeTabId: activeEditorTabIdFromStore } = storeToRefs(fileEditorStore); // <-- Get editor state const { orderedTabs: editorTabsFromStore, activeTabId: activeEditorTabIdFromStore } = storeToRefs(fileEditorStore); // <-- Get editor state
@@ -125,6 +126,47 @@ const hasSshSessions = computed(() => {
return false; return false;
}); });
const activeTerminalConnectionId = computed(() => {
if (!props.activeSessionId) {
return null;
}
const sessionState = sessionStore.sessions.get(props.activeSessionId);
if (!sessionState?.terminalManager) {
return null;
}
return sessionState.connectionId;
});
const activeTerminalSessions = computed<SessionTabInfoWithStatus[]>(() => {
if (!activeTerminalConnectionId.value) {
return [];
}
return sessionTabsWithStatus.value.filter((session) => {
const sessionState = sessionStore.sessions.get(session.sessionId);
return session.connectionId === activeTerminalConnectionId.value && Boolean(sessionState?.terminalManager);
});
});
const activeTerminalConnectionName = computed(() => activeTerminalSessions.value[0]?.connectionName ?? '');
const openTerminalSibling = () => {
if (!activeTerminalConnectionId.value) {
return;
}
sessionStore.handleOpenNewSession(activeTerminalConnectionId.value);
};
const closeTerminalSession = (sessionId: string) => {
sessionStore.closeSession(sessionId);
};
const getTerminalSessionTitle = (session: SessionTabInfoWithStatus) =>
`${session.connectionName} / ${t('terminalTabBar.terminalBadge', { index: session.terminalIndex })}`;
// 面板标签 (Similar to LayoutConfigurator) // 面板标签 (Similar to LayoutConfigurator)
const paneLabels = computed(() => ({ const paneLabels = computed(() => ({
connections: t('layout.pane.connections', '连接列表'), connections: t('layout.pane.connections', '连接列表'),
@@ -573,9 +615,67 @@ onBeforeUnmount(() => {
<!-- Terminal Pane: Render ALL SSH sessions, show only the active one --> <!-- Terminal Pane: Render ALL SSH sessions, show only the active one -->
<template v-if="layoutNode.component === 'terminal'"> <template v-if="layoutNode.component === 'terminal'">
<div <div
class="terminal-pane-container relative flex-grow overflow-hidden" class="terminal-pane-container flex h-full flex-col overflow-hidden"
:class="{ 'has-global-terminal-background': isTerminalBackgroundEnabled, 'bg-background': !isTerminalBackgroundEnabled }" :class="{ 'has-global-terminal-background': isTerminalBackgroundEnabled, 'bg-background': !isTerminalBackgroundEnabled }"
> >
<div
v-if="activeTerminalSessions.length > 0"
class="flex items-center gap-2 border-b border-border bg-header/95 px-2 py-1.5"
>
<div class="flex min-w-0 items-center gap-2 rounded-md border border-border/70 bg-background/70 px-2.5 py-1 text-xs text-text-secondary">
<i class="fas fa-server text-[10px] text-primary/80"></i>
<span class="truncate font-semibold text-foreground">{{ activeTerminalConnectionName }}</span>
<span class="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-foreground/80">
{{ t('terminalTabBar.terminalCount', { count: activeTerminalSessions.length }) }}
</span>
</div>
<div class="flex min-w-0 flex-1 items-center overflow-x-auto">
<div
v-for="session in activeTerminalSessions"
:key="session.sessionId"
role="button"
tabindex="0"
:class="[
'group flex h-8 items-center rounded-md border px-2.5 text-[11px] font-medium transition-colors duration-150',
session.sessionId === activeSessionId
? 'border-primary/60 bg-primary/10 text-foreground'
: 'border-transparent bg-background/70 text-text-secondary hover:border-border hover:bg-border hover:text-foreground',
]"
:title="getTerminalSessionTitle(session)"
@click="sessionStore.activateSession(session.sessionId)"
@keydown.enter.prevent="sessionStore.activateSession(session.sessionId)"
@keydown.space.prevent="sessionStore.activateSession(session.sessionId)"
>
<span :class="['mr-2 h-2 w-2 rounded-full',
session.isMarkedForSuspend ? 'bg-blue-500' :
session.status === 'connected' ? 'bg-green-500' :
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="whitespace-nowrap">
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }}
</span>
<button
type="button"
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-header hover:text-foreground group-hover:opacity-100"
@click.stop="closeTerminalSession(session.sessionId)"
:title="$t('tabs.closeTabTooltip')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<button
type="button"
class="flex h-8 items-center justify-center rounded-md border border-border/70 bg-background/70 px-2.5 text-text-secondary transition-colors duration-150 hover:bg-border hover:text-foreground"
:title="t('terminalTabBar.newTerminalTooltip')"
@click="openTerminalSibling"
>
<i class="fas fa-plus text-[11px]"></i>
</button>
</div>
<div class="relative flex-1 overflow-hidden">
<!-- Shared Background Layers --> <!-- Shared Background Layers -->
<div <div
v-if="isTerminalBackgroundEnabled" v-if="isTerminalBackgroundEnabled"
@@ -633,6 +733,7 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<!-- FileManager --> <!-- FileManager -->
<template v-else-if="layoutNode.component === 'fileManager'"> <template v-else-if="layoutNode.component === 'fileManager'">
@@ -72,6 +72,64 @@ const activeConnectionId = computed(() => {
return sessionStore.sessions.get(props.activeSessionId)?.connectionId ?? null; return sessionStore.sessions.get(props.activeSessionId)?.connectionId ?? null;
}); });
const getConnectionInfoById = (connectionId: string) =>
connectionsStore.connections.find((connection) => connection.id === Number(connectionId)) ?? null;
const isSshConnection = (connectionId: string) => getConnectionInfoById(connectionId)?.type === 'SSH';
const getConnectionSessions = (connectionId: string) =>
draggableSessions.value.filter((session) => session.connectionId === connectionId);
const getRepresentativeSessionId = (connectionId: string, fallbackSessionId: string) => {
if (activeConnectionId.value === connectionId && props.activeSessionId) {
return props.activeSessionId;
}
return getConnectionSessions(connectionId)[0]?.sessionId ?? fallbackSessionId;
};
const getConnectionSessionCount = (connectionId: string) => getConnectionSessions(connectionId).length;
const shouldRenderTopLevelItem = (session: SessionTabInfoWithStatus, index: number) => {
if (!isSshConnection(session.connectionId)) {
return true;
}
return isGroupStart(index);
};
const hasCollapsedSshGroups = computed(() =>
draggableSessions.value.some((session, index) => isSshConnection(session.connectionId) && !isGroupStart(index))
);
const activateTopLevelItem = (session: SessionTabInfoWithStatus) => {
if (isSshConnection(session.connectionId)) {
activateSession(getRepresentativeSessionId(session.connectionId, session.sessionId));
return;
}
activateSession(session.sessionId);
};
const showTopLevelContextMenu = (event: MouseEvent, session: SessionTabInfoWithStatus) => {
const targetSessionId = isSshConnection(session.connectionId)
? getRepresentativeSessionId(session.connectionId, session.sessionId)
: session.sessionId;
showContextMenu(event, targetSessionId);
};
const getTopLevelItemTitle = (session: SessionTabInfoWithStatus) => {
if (!isSshConnection(session.connectionId)) {
return `${session.connectionName} / ${t('terminalTabBar.terminalBadge', { index: session.terminalIndex })}`;
}
return t('terminalTabBar.serverEntryTitle', {
name: session.connectionName,
count: getConnectionSessionCount(session.connectionId),
});
};
const openConnectionPicker = () => { const openConnectionPicker = () => {
showConnectionListPopup.value = true; showConnectionListPopup.value = true;
}; };
@@ -85,27 +143,6 @@ const isGroupStart = (index: number) => {
return Boolean(currentSession && (!previousSession || previousSession.connectionId !== currentSession.connectionId)); return Boolean(currentSession && (!previousSession || previousSession.connectionId !== currentSession.connectionId));
}; };
const isGroupEnd = (index: number) => {
const currentSession = getSessionAtIndex(index);
const nextSession = getSessionAtIndex(index + 1);
return Boolean(currentSession && (!nextSession || nextSession.connectionId !== currentSession.connectionId));
};
const getConnectionInfoById = (connectionId: string) =>
connectionsStore.connections.find((connection) => connection.id === Number(connectionId)) ?? null;
const canOpenSiblingTerminal = (connectionId: string) => getConnectionInfoById(connectionId)?.type === 'SSH';
const openNewTerminalForConnection = (connectionId: string) => {
const connectionInfo = getConnectionInfoById(connectionId);
if (!connectionInfo || connectionInfo.type !== 'SSH') {
return;
}
sessionStore.handleOpenNewSession(connectionInfo.id);
};
// + Watch prop changes to update local state // + Watch prop changes to update local state
watch(() => props.sessions, (newSessions) => { watch(() => props.sessions, (newSessions) => {
// Create a shallow copy to avoid modifying the prop directly // Create a shallow copy to avoid modifying the prop directly
@@ -484,66 +521,64 @@ onBeforeUnmount(() => {
ghost-class="opacity-50" ghost-class="opacity-50"
drag-class="opacity-75" drag-class="opacity-75"
animation="150" animation="150"
:disabled="props.isMobile" :disabled="props.isMobile || hasCollapsedSshGroups"
> >
<template #item="{ element: session, index }"> <template #item="{ element: session, index }">
<li <li
v-if="shouldRenderTopLevelItem(session, index)"
:key="session.sessionId" :key="session.sessionId"
:class="['flex h-full flex-shrink-0 items-stretch py-1', isGroupStart(index) ? 'pl-1' : 'pl-0']" :class="['flex h-full flex-shrink-0 items-stretch py-1', isGroupStart(index) ? 'pl-1' : 'pl-0']"
@dragstart="handleDragStart" @dragstart="handleDragStart"
> >
<div <button
v-if="isSshConnection(session.connectionId)"
type="button"
:class="[ :class="[
'flex h-full items-stretch overflow-hidden border transition-all duration-150', 'group flex h-full items-center gap-2 rounded-md border px-3 text-left transition-all duration-150',
session.connectionId === activeConnectionId session.connectionId === activeConnectionId
? 'border-primary/60 bg-primary/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]' ? 'border-primary/60 bg-primary/10 text-foreground shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'border-border/70 bg-header/80 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]', : 'border-border/70 bg-header/80 text-text-secondary shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-border hover:text-foreground',
isGroupStart(index) && isGroupEnd(index) ]"
? 'rounded-md' :title="getTopLevelItemTitle(session)"
: isGroupStart(index) @click="activateTopLevelItem(session)"
? 'rounded-l-md rounded-r-none' @contextmenu.prevent="showTopLevelContextMenu($event, session)"
: isGroupEnd(index) >
? '-ml-px rounded-r-md rounded-l-none' <i class="fas fa-server text-[11px] text-primary/80"></i>
: '-ml-px rounded-none', <span class="max-w-[180px] truncate text-xs font-semibold tracking-wide">
{{ session.connectionName }}
</span>
<span
:class="[
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
session.connectionId === activeConnectionId
? 'bg-primary/15 text-foreground/90'
: 'bg-black/20 text-text-secondary group-hover:text-foreground',
]" ]"
> >
{{ getConnectionSessionCount(session.connectionId) }}
</span>
</button>
<div <div
v-if="isGroupStart(index)" v-else
:class="[ :class="[
'flex max-w-[160px] items-center border-r px-2.5 text-xs font-semibold tracking-wide', 'group flex h-full items-center overflow-hidden rounded-md border px-2.5 transition-all duration-150',
session.connectionId === activeConnectionId
? 'border-primary/50 bg-primary/15 text-foreground'
: 'border-border/70 bg-black/15 text-text-secondary',
]"
:title="session.connectionName"
>
<i class="fas fa-server mr-1.5 text-[10px] text-primary/80"></i>
<span class="truncate">{{ session.connectionName }}</span>
</div>
<div
:class="[
'group flex h-full items-center px-2.5 transition-colors duration-150 relative',
session.sessionId === activeSessionId session.sessionId === activeSessionId
? 'bg-background text-foreground shadow-[inset_0_1px_0_rgba(34,197,94,0.15)]' ? 'border-primary/60 bg-primary/10 text-foreground shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: session.connectionId === activeConnectionId : 'border-border/70 bg-header/80 text-text-secondary shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-border hover:text-foreground',
? 'bg-primary/5 text-foreground/85 hover:bg-primary/10'
: 'bg-header text-text-secondary hover:bg-border',
!isGroupStart(index) ? 'border-l border-border/60' : '',
]" ]"
@click="activateSession(session.sessionId)" @click="activateTopLevelItem(session)"
@contextmenu.prevent="showContextMenu($event, session.sessionId)" @contextmenu.prevent="showTopLevelContextMenu($event, session)"
@touchstart="handleTouchStart($event, session.sessionId)" @touchstart="handleTouchStart($event, session.sessionId)"
@touchend="handleTouchEnd($event)" @touchend="handleTouchEnd($event)"
:title="`${session.connectionName} / ${t('terminalTabBar.terminalBadge', { index: session.terminalIndex })}`" :title="getTopLevelItemTitle(session)"
> >
<span :class="['w-2 h-2 rounded-full mr-2 flex-shrink-0', <span :class="['w-2 h-2 rounded-full mr-2 flex-shrink-0',
session.isMarkedForSuspend ? 'bg-blue-500' : session.isMarkedForSuspend ? 'bg-blue-500' :
session.status === 'connected' ? 'bg-green-500' : session.status === 'connected' ? 'bg-green-500' :
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' : session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span> session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="whitespace-nowrap text-[11px] font-medium"> <span class="max-w-[180px] truncate text-[11px] font-medium">
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }} {{ session.connectionName }}
</span> </span>
<button <button
type="button" type="button"
@@ -557,22 +592,6 @@ onBeforeUnmount(() => {
</svg> </svg>
</button> </button>
</div> </div>
<button
v-if="isGroupEnd(index) && canOpenSiblingTerminal(session.connectionId)"
type="button"
:class="[
'flex h-full items-center border-l px-2.5 transition-colors duration-150',
session.connectionId === activeConnectionId
? 'border-primary/40 bg-primary/10 text-foreground/80 hover:bg-primary/15 hover:text-foreground'
: 'border-border/60 bg-black/10 text-text-secondary hover:bg-border hover:text-foreground',
]"
@click.stop="openNewTerminalForConnection(session.connectionId)"
:title="t('terminalTabBar.newTerminalTooltip')"
>
<i class="fas fa-plus text-[11px]"></i>
</button>
</div>
</li> </li>
</template> </template>
</draggable> </draggable>
@@ -97,14 +97,34 @@ watch(
</script> </script>
<template> <template>
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-background"> <div class="flex h-full min-h-0 overflow-hidden bg-background">
<div class="border-b border-border bg-header px-3 py-3"> <aside class="workbench-rail flex w-14 flex-shrink-0 flex-col items-center gap-2 border-r border-border px-2 py-3">
<button
v-for="tab in workbenchTabs"
:key="tab.id"
type="button"
:title="tab.label"
:aria-label="tab.label"
@click="activeWorkbenchTab = tab.id"
:class="[
'inline-flex h-10 w-10 items-center justify-center rounded-xl border text-sm transition-colors',
activeWorkbenchTab === tab.id
? 'border-primary bg-primary text-white shadow-sm'
: 'border-transparent bg-transparent text-text-secondary hover:border-primary/20 hover:bg-background hover:text-foreground'
]"
>
<i :class="tab.icon"></i>
</button>
</aside>
<div class="flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
<div class="border-b border-border bg-header px-4 py-3">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div> <div class="min-w-0">
<h3 class="text-sm font-semibold text-foreground"> <h3 class="text-sm font-semibold text-foreground">
{{ t('workspace.workbench.title', 'Workbench') }} {{ t('workspace.workbench.title', 'Workbench') }}
</h3> </h3>
<p class="mt-1 text-xs text-text-secondary"> <p class="mt-1 truncate text-xs text-text-secondary">
{{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }} {{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }}
</p> </p>
</div> </div>
@@ -112,23 +132,6 @@ watch(
{{ t('workspace.workbench.label', '工作台') }} {{ t('workspace.workbench.label', '工作台') }}
</span> </span>
</div> </div>
<div class="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
<button
v-for="tab in workbenchTabs"
:key="tab.id"
type="button"
@click="activeWorkbenchTab = tab.id"
:class="[
'inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-colors',
activeWorkbenchTab === tab.id
? 'border-primary bg-primary text-white shadow-sm'
: 'border-border bg-background text-text-secondary hover:border-primary/40 hover:text-foreground'
]"
>
<i :class="tab.icon"></i>
<span>{{ tab.label }}</span>
</button>
</div>
</div> </div>
<div class="relative flex-1 min-h-0 overflow-hidden bg-background"> <div class="relative flex-1 min-h-0 overflow-hidden bg-background">
@@ -172,9 +175,16 @@ watch(
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.workbench-rail {
background:
linear-gradient(180deg, rgba(30, 41, 59, 0.94) 0%, rgba(17, 24, 39, 0.98) 100%);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
}
.workbench-quick-commands { .workbench-quick-commands {
background: background:
linear-gradient(180deg, rgba(15, 17, 22, 0.98) 0%, rgba(12, 14, 18, 1) 100%); linear-gradient(180deg, rgba(15, 17, 22, 0.98) 0%, rgba(12, 14, 18, 1) 100%);
@@ -41,6 +41,7 @@ export type WorkspaceEventPayloads = {
// Session Management Events (主要由 TerminalTabBar 发出) // Session Management Events (主要由 TerminalTabBar 发出)
'session:activate': { sessionId: string }; 'session:activate': { sessionId: string };
'session:close': { sessionId: string }; 'session:close': { sessionId: string };
'session:closeAll': void;
'session:closeOthers': { targetSessionId: string }; 'session:closeOthers': { targetSessionId: string };
'session:closeToRight': { targetSessionId: string }; 'session:closeToRight': { targetSessionId: string };
'session:closeToLeft': { targetSessionId: string }; 'session:closeToLeft': { targetSessionId: string };
+3 -1
View File
@@ -1628,7 +1628,9 @@
"showTransferProgressTooltip": "Show/Hide Transfer Progress", "showTransferProgressTooltip": "Show/Hide Transfer Progress",
"newTerminalTooltip": "Open another terminal for the current server", "newTerminalTooltip": "Open another terminal for the current server",
"openConnectionPickerTooltip": "Choose another server", "openConnectionPickerTooltip": "Choose another server",
"terminalBadge": "Terminal {index}" "terminalBadge": "Terminal {index}",
"serverEntryTitle": "{name} · {count} terminals",
"terminalCount": "{count} terminals"
}, },
"tabs": { "tabs": {
"contextMenu": { "contextMenu": {
+13
View File
@@ -1590,6 +1590,19 @@
"openConnectionPickerTooltip": "別のサーバーを選択", "openConnectionPickerTooltip": "別のサーバーを選択",
"terminalBadge": "端末 {index}" "terminalBadge": "端末 {index}"
}, },
"tabs": {
"contextMenu": {
"close": "タブを閉じる",
"closeAll": "すべて閉じる",
"closeOthers": "他のタブを閉じる",
"closeRight": "右側のタブを閉じる",
"closeLeft": "左側のタブを閉じる",
"suspendSession": "セッションを中断",
"unmarkForSuspend": "中断マークを解除"
},
"closeTabTooltip": "タブを閉じる",
"newTabTooltip": "新しい接続タブ"
},
"workspace": { "workspace": {
"terminal": { "terminal": {
"reconnectingMsg": "再接続を試行中..." "reconnectingMsg": "再接続を試行中..."
+3 -1
View File
@@ -1632,7 +1632,9 @@
"showTransferProgressTooltip": "显示/隐藏传输进度", "showTransferProgressTooltip": "显示/隐藏传输进度",
"newTerminalTooltip": "为当前服务器新增终端", "newTerminalTooltip": "为当前服务器新增终端",
"openConnectionPickerTooltip": "选择其他服务器", "openConnectionPickerTooltip": "选择其他服务器",
"terminalBadge": "终端 {index}" "terminalBadge": "终端 {index}",
"serverEntryTitle": "{name} · {count} 个终端",
"terminalCount": "{count} 个终端"
}, },
"tabs": { "tabs": {
"contextMenu": { "contextMenu": {
@@ -169,6 +169,7 @@ onMounted(() => {
// TerminalTabBar // TerminalTabBar
subscribeToWorkspaceEvents('session:activate', (payload) => sessionStore.activateSession(payload.sessionId)); subscribeToWorkspaceEvents('session:activate', (payload) => sessionStore.activateSession(payload.sessionId));
subscribeToWorkspaceEvents('session:close', (payload) => sessionStore.closeSession(payload.sessionId)); subscribeToWorkspaceEvents('session:close', (payload) => sessionStore.closeSession(payload.sessionId));
subscribeToWorkspaceEvents('session:closeAll', handleCloseAllSessions);
subscribeToWorkspaceEvents('session:closeOthers', (payload) => handleCloseOtherSessions(payload.targetSessionId)); subscribeToWorkspaceEvents('session:closeOthers', (payload) => handleCloseOtherSessions(payload.targetSessionId));
subscribeToWorkspaceEvents('session:closeToRight', (payload) => handleCloseSessionsToRight(payload.targetSessionId)); subscribeToWorkspaceEvents('session:closeToRight', (payload) => handleCloseSessionsToRight(payload.targetSessionId));
subscribeToWorkspaceEvents('session:closeToLeft', (payload) => handleCloseSessionsToLeft(payload.targetSessionId)); subscribeToWorkspaceEvents('session:closeToLeft', (payload) => handleCloseSessionsToLeft(payload.targetSessionId));
@@ -214,6 +215,7 @@ onBeforeUnmount(() => {
unsubscribeFromWorkspaceEvents('session:activate', (payload) => sessionStore.activateSession(payload.sessionId)); unsubscribeFromWorkspaceEvents('session:activate', (payload) => sessionStore.activateSession(payload.sessionId));
unsubscribeFromWorkspaceEvents('session:close', (payload) => sessionStore.closeSession(payload.sessionId)); unsubscribeFromWorkspaceEvents('session:close', (payload) => sessionStore.closeSession(payload.sessionId));
unsubscribeFromWorkspaceEvents('session:closeAll', handleCloseAllSessions);
unsubscribeFromWorkspaceEvents('session:closeOthers', (payload) => handleCloseOtherSessions(payload.targetSessionId)); unsubscribeFromWorkspaceEvents('session:closeOthers', (payload) => handleCloseOtherSessions(payload.targetSessionId));
unsubscribeFromWorkspaceEvents('session:closeToRight', (payload) => handleCloseSessionsToRight(payload.targetSessionId)); unsubscribeFromWorkspaceEvents('session:closeToRight', (payload) => handleCloseSessionsToRight(payload.targetSessionId));
unsubscribeFromWorkspaceEvents('session:closeToLeft', (payload) => handleCloseSessionsToLeft(payload.targetSessionId)); unsubscribeFromWorkspaceEvents('session:closeToLeft', (payload) => handleCloseSessionsToLeft(payload.targetSessionId));
@@ -581,6 +583,14 @@ const toggleVirtualKeyboard = () => {
// --- --- // --- ---
const handleCloseAllSessions = () => {
if (sessionTabsWithStatus.value.length === 0) {
return;
}
sessionStore.cleanupAllSessions();
};
const handleCloseOtherSessions = (targetSessionId: string) => { const handleCloseOtherSessions = (targetSessionId: string) => {
const sessionsToClose = sessionTabsWithStatus.value const sessionsToClose = sessionTabsWithStatus.value
.filter(tab => tab.sessionId !== targetSessionId) .filter(tab => tab.sessionId !== targetSessionId)