fix(frontend): 调整工作台文件总览与快捷指令菜单
将文件管理区从单目录文件表格修正为多根目录常驻的文件夹总览, 点击目录时仅展开和聚焦,不再切换为单独目录列表。 同时修复快捷指令右键菜单的透明背景与粘贴语义, 统一为“粘贴到命令输入框”且不自动发送,并同步多语言文案。 顺带收紧快捷指令编辑弹窗的最小尺寸、初始尺寸与视口上限, 降低小分辨率下的弹窗溢出概率。
This commit is contained in:
@@ -8,6 +8,10 @@
|
||||
- 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。
|
||||
|
||||
### 修复
|
||||
- **[frontend]**: 修正快捷命令右键菜单的透明背景与粘贴项语义,改为实底菜单并将回填动作统一为“粘贴到命令输入框(不发送)” — by yinjianm
|
||||
- 方案: [202603260156_quickcommands-context-menu-polish](archive/2026-03/202603260156_quickcommands-context-menu-polish/)
|
||||
- **[frontend]**: 将工作台文件区从单目录文件表格切换修正为多根目录常驻的文件夹总览视图 — by yinjianm
|
||||
- 方案: [202603260150_workbench-file-folder-overview](archive/2026-03/202603260150_workbench-file-folder-overview/)
|
||||
- **[workspace-root]**: 重新核对工作区状态监控与终端标签剩余改动,确认当前前后端构建通过,并修正知识库归档索引与活跃方案状态 — by yinjianm
|
||||
- 方案: [202603252256_workspace-monitor-terminal-polish](archive/2026-03/202603252256_workspace-monitor-terminal-polish/)
|
||||
- **[frontend]**: 统一 `QuickCommandsView.vue` 的按钮主题适配,移除残留硬编码 hover 色值并切回主题变量体系 — by yinjianm
|
||||
@@ -17,6 +21,11 @@
|
||||
- **[frontend]**: 修复终端文字效果对 ANSI 彩色输出的覆盖问题,仅让默认前景文字保留描边/阴影效果 — by yinjianm
|
||||
- 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/)
|
||||
|
||||
### 快速修改
|
||||
- **[frontend]**: 收紧快捷指令编辑弹窗的最小尺寸、初始尺寸和视口上限,降低小分辨率下的弹窗溢出概率 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: packages/frontend/src/components/AddEditQuickCommandForm.vue:9,184-185,242-245
|
||||
|
||||
### 新增
|
||||
- **[frontend]**: 为工作台文件面板补齐左侧多根目录资源管理器,支持收藏路径与当前路径同屏作为多个根目录展开浏览 — by yinjianm
|
||||
- 方案: [202603260041_workbench-file-multi-root-explorer](archive/2026-03/202603260041_workbench-file-multi-root-explorer/)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"工作台文件区已改成多根目录常驻的文件夹总览视图","updated_at":"2026-03-26 02:02:00"}
|
||||
@@ -0,0 +1,58 @@
|
||||
# 变更提案: workbench-file-folder-overview
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 功能调整
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已完成
|
||||
状态说明: 已改成多根目录常驻的文件夹总览视图,并通过前端构建验证
|
||||
创建: 2026-03-26
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
上一轮把工作台文件区改成了“左侧多根目录树 + 右侧当前目录文件表格”,但实际使用上仍然是点击目录后切成某一个目录的单独视图,不符合参考图想要的“多根目录持续保留、同时浏览多个文件夹层级”的交互。
|
||||
|
||||
### 目标
|
||||
- 保留多根目录作为长期可见的浏览主体。
|
||||
- 点击文件夹时不再把界面切成单目录文件表格。
|
||||
- 右侧改成多根目录下的文件夹总览,持续显示不同目录层级。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
范围约束: 优先只改 FileManager.vue,不改后端接口与 SFTP 协议
|
||||
状态约束: 继续复用 favoritePaths 与 useSftpActions 的 fileTree/loadDirectory
|
||||
交互约束: 文件区以目录浏览为主,不再横向展示当前目录下的具体文件列表
|
||||
兼容约束: 路径输入、收藏路径、上传和新建动作仍依赖 currentPath 保持可用
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [x] 左侧多根目录树持续可见
|
||||
- [x] 点击文件夹后右侧不再变成单目录文件表格
|
||||
- [x] 右侧改为多根目录下的文件夹总览,能同时显示不同目录层级
|
||||
- [x] 前端构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
继续以 `FileManager.vue` 为核心,保留左侧多根目录树,但树和右侧总览都只展示目录节点,不再渲染具体文件。右侧使用 `fileTree` 缓存派生出按根目录分组的文件夹总览卡片,点击目录只触发展开与目录聚焦,不再切换成单个目录的文件表格视图。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: FileManager.vue
|
||||
预计变更文件: 1-4
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| `loadDirectory(path)` 仍会更新 currentPath,导致操作目录与浏览目录耦合 | 中 | 将 currentPath 明确视为“当前操作目录”,界面主体不再依赖它切换视图 |
|
||||
| 去掉文件表格后,部分基于当前目录文件列表的交互入口暂时只剩路径输入和上传/新建 | 中 | 本轮先满足目录总览需求,不扩展新的文件级操作入口 |
|
||||
| 收藏路径存在父子重叠时,不同根目录区块会重复展示部分目录 | 低 | 保留重复,维持“每个根目录独立浏览”的用户心智 |
|
||||
@@ -0,0 +1,42 @@
|
||||
# 任务清单: workbench-file-folder-overview
|
||||
|
||||
```yaml
|
||||
@feature: workbench-file-folder-overview
|
||||
@created: 2026-03-26
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 4 | 0 | 0 | 4 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与范围确认
|
||||
|
||||
- [√] 1.1 创建工作台文件夹总览方案包并锁定范围到 `FileManager.vue` | depends_on: []
|
||||
|
||||
### 2. 交互调整实现
|
||||
|
||||
- [√] 2.1 将多根目录树调整为仅展示目录节点并保持根目录常驻 | depends_on: [1.1]
|
||||
- [√] 2.2 将右侧区域改成多根目录文件夹总览,不再渲染单目录文件表格 | depends_on: [2.1]
|
||||
|
||||
### 3. 验证与同步
|
||||
|
||||
- [√] 3.1 运行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [2.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-26 01:50 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为工作台文件区多根目录常驻与文件夹总览调整 |
|
||||
| 2026-03-26 01:57 | 2.1 | 完成 | 多根目录树改为仅展示目录节点,点击目录后不再依赖单目录文件表格切换 |
|
||||
| 2026-03-26 02:00 | 2.2 | 完成 | 右侧区域改成按根目录分组的文件夹总览卡片,并保留当前操作目录提示 |
|
||||
| 2026-03-26 02:02 | 3.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过,准备同步知识库与归档 |
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"status":"in_progress","completed":0,"failed":0,"pending":4,"total":4,"done":0,"percent":0,"current":"准备进入开发实施:修正快捷命令右键菜单透明与粘贴动作","updated_at":"2026-03-26 01:56:00"}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# 变更提案: quickcommands-context-menu-polish
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 优化
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 草稿
|
||||
创建: 2026-03-26
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
上一轮已经为快捷命令补上右键菜单,但用户进一步确认了三个运行态问题:菜单背景出现透明效果;不再需要“粘贴到终端”单独菜单项;原“粘贴到快捷输入框”应改成“粘贴到命令输入框”的语义和位置,即点击后写入底部终端命令输入框但不发送。
|
||||
|
||||
### 目标
|
||||
- 将快捷命令右键菜单改为实底不透明菜单。
|
||||
- 移除“粘贴到终端”菜单项。
|
||||
- 将原“粘贴到快捷输入框”改为“粘贴到命令输入框”,并执行写入底部命令输入框但不发送的行为。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 本轮内完成右键菜单修正与前端验证
|
||||
性能约束: 不新增依赖,仅调整现有菜单结构和前端逻辑
|
||||
兼容性约束: 保留立即执行、复制命令、发送到全部服务器、编辑、删除等动作
|
||||
业务约束: 粘贴到命令输入框必须只写入底部命令输入框,不自动发送
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] 右键菜单背景为实底不透明,不再出现透明感
|
||||
- [ ] 菜单中不再显示“粘贴到终端”
|
||||
- [ ] “粘贴到命令输入框”点击后写入底部命令输入框,但不发送
|
||||
- [ ] locale 文案同步更新
|
||||
- [ ] `packages/frontend` 构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
继续在 `QuickCommandsView.vue` 上做最小修正。菜单容器改为显式深色背景与更强边框/阴影;删除“粘贴到终端” DOM 项与对应 action;把原 `pasteToQuickInput` 动作重命名并直接复用现有“写入底部命令输入框”的逻辑。同步三套 locale 文案与成功提示。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: `QuickCommandsView.vue` 右键菜单结构、样式和动作映射
|
||||
- frontend: 快捷命令相关 locale 文案
|
||||
预计变更文件: 3-4
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 菜单背景样式修正后与现有主题变量冲突 | 低 | 使用更明确的深色背景与现有边框/阴影组合,避免透明变量色 |
|
||||
| 删除菜单项后动作顺序与用户预期不一致 | 低 | 让“粘贴到命令输入框”顶替原位置 |
|
||||
| 文案与实际行为再次不一致 | 中 | 同时修改菜单文案、动作枚举和成功提示 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计(可选)
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[QuickCommandsView menu] --> B[runNow]
|
||||
A --> C[pasteToCommandInput]
|
||||
A --> D[copy/edit/delete]
|
||||
A --> E[sendToAllSessions]
|
||||
```
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `quickCommandContextMenuVisible` | `boolean` | 右键菜单显示状态 |
|
||||
| `quickCommandContextTargetCommand` | `QuickCommandFE \| null` | 当前菜单目标命令 |
|
||||
| `activeSessionId` | `string \| undefined` | 当前活动会话,用于写入底部命令输入框 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
### 场景: 从右键菜单回填到底部命令输入框
|
||||
**模块**: frontend
|
||||
**条件**: 用户右键某条快捷命令,当前存在活动 SSH 会话。
|
||||
**行为**: 点击“粘贴到命令输入框”后,把处理后的命令写入底部终端输入框,但不触发发送。
|
||||
**结果**: 用户可以继续手改命令,再决定是否发送。
|
||||
|
||||
### 场景: 右键菜单视觉贴近普通实底菜单
|
||||
**模块**: frontend
|
||||
**条件**: 用户在快捷命令列表中打开右键菜单。
|
||||
**行为**: 菜单以实底、边框和阴影显示,不透出底层列表内容。
|
||||
**结果**: 菜单可读性和层级感更接近常规上下文菜单。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### quickcommands-context-menu-polish#D001: 让“粘贴到命令输入框”直接复用已有底部命令输入框写入逻辑
|
||||
**日期**: 2026-03-26
|
||||
**状态**: ✅采纳
|
||||
**背景**: 用户明确要求去掉“粘贴到终端”,并让替代项承担“写入底部命令输入框但不发送”的职责。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 复用现有写入底部命令输入框逻辑 | 最小改动,行为清晰,和现有命令输入条一致 | 需要同步改菜单文案 |
|
||||
| B: 保留原搜索框回填逻辑,只改文案 | 代码更少 | 行为与用户要求不符 |
|
||||
**决策**: 选择方案A
|
||||
**理由**: 用户语义已经非常明确,应以实际行为一致性优先,而不是保留旧实现。
|
||||
**影响**: frontend
|
||||
|
||||
---
|
||||
|
||||
## 6. 成果设计
|
||||
|
||||
### 设计方向
|
||||
- **美学基调**: 延续深色工具菜单,但强调实底层级感
|
||||
- **记忆点**: 菜单不再发虚透底,操作文案与行为完全一致
|
||||
- **参考**: 用户上一轮反馈的运行态问题
|
||||
|
||||
### 视觉要素
|
||||
- **配色**: 使用稳定深色背景 + 边框 + 阴影,删除项继续使用错误色
|
||||
- **字体**: 沿用现有菜单字体体系
|
||||
- **布局**: 删除一项后保持紧凑垂直列表
|
||||
- **动效**: 保留现有 hover 过渡
|
||||
- **氛围**: 以可读性和稳定感为主,不做多余装饰
|
||||
|
||||
### 技术约束
|
||||
- **可访问性**: 文案必须直接说明“命令输入框”,避免再混淆
|
||||
- **响应式**: 保持现有防越界定位逻辑
|
||||
@@ -0,0 +1,42 @@
|
||||
# 任务清单: quickcommands-context-menu-polish
|
||||
|
||||
```yaml
|
||||
@feature: quickcommands-context-menu-polish
|
||||
@created: 2026-03-26
|
||||
@status: pending
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 0 | 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 01:56 | DESIGN | completed | 已确认删除“粘贴到终端”,并将替代项改为“粘贴到命令输入框”且只回填不发送 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
> 当前环境缺少可直接调用的 `python/py`,方案包通过模板降级方式手工创建。
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202603260156 | quickcommands-context-menu-polish | implementation | frontend | quickcommands-context-menu-polish#D001 | ✅完成 |
|
||||
| 202603260150 | workbench-file-folder-overview | implementation | frontend | - | ✅完成 |
|
||||
| 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 | ✅完成 |
|
||||
@@ -32,6 +34,8 @@
|
||||
## 按月归档
|
||||
|
||||
### 2026-03
|
||||
- [202603260156_quickcommands-context-menu-polish](./2026-03/202603260156_quickcommands-context-menu-polish/) - 修正快捷命令右键菜单透明背景,并将粘贴动作统一为“粘贴到命令输入框(不发送)”
|
||||
- [202603260150_workbench-file-folder-overview](./2026-03/202603260150_workbench-file-folder-overview/) - 将工作台文件区调整为多根目录常驻的文件夹总览,不再点击目录后切成单独文件表格
|
||||
- [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/) - 为快捷命令列表补齐图标化右键菜单,并区分立即执行、粘贴到终端输入框和粘贴到快捷输入框
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
### 工作区交互
|
||||
**条件**: 用户进入 `/workspace` 或相关管理页面。
|
||||
**行为**: 通过组件、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)` 懒加载链路来展开目录、切换右侧当前目录并直接打开文件;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
|
||||
**行为**: 通过组件、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` 当前则改成“左侧多根目录树 + 右侧文件夹总览”的目录浏览模式,左侧和右侧都只展示目录节点,根目录来自收藏路径并自动补入当前路径,右侧按根目录分组持续展示不同层级的文件夹,不再因为点击目录而切成单个目录文件表格;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
|
||||
**结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
:style="{
|
||||
width: resizableWidth ? `${resizableWidth}px` : undefined,
|
||||
height: resizableHeight ? `${resizableHeight}px` : undefined,
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
maxHeight: 'calc(100vh - 2rem)',
|
||||
}"
|
||||
>
|
||||
<h2 class="m-0 mb-6 text-center text-xl font-semibold">{{ isEditing ? t('quickCommands.form.titleEdit', '编辑快捷指令') : t('quickCommands.form.titleAdd', '添加快捷指令') }}</h2>
|
||||
@@ -179,8 +181,8 @@ 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 R_MIN_WIDTH = 680; // 可调整大小的最小宽度 (像素)
|
||||
const R_MIN_HEIGHT = 520; // 可调整大小的最小高度 (像素)
|
||||
const placeholder = t('quickCommands.form.commandPlaceholder') + 'echo "Hello,\${USERNAME}"'
|
||||
|
||||
const { width: resizableWidth, height: resizableHeight } = useResizable(modalContentRef, {
|
||||
@@ -237,8 +239,8 @@ watch(() => formData.command, (newCommand) => {
|
||||
// 初始化表单数据 (如果是编辑模式)
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
let initialW = Math.min(window.innerWidth * 0.9, 1152); // 目标 90vw,最大 1152px
|
||||
let initialH = window.innerHeight * 0.85; // 目标 85vh
|
||||
let initialW = Math.min(window.innerWidth * 0.82, 960); // 目标 82vw,最大 960px
|
||||
let initialH = Math.min(window.innerHeight * 0.78, 720); // 目标 78vh,最大 720px
|
||||
|
||||
initialW = Math.max(R_MIN_WIDTH, initialW);
|
||||
initialH = Math.max(R_MIN_HEIGHT, initialH);
|
||||
|
||||
@@ -51,6 +51,28 @@ interface ExplorerTreeRow {
|
||||
item: FileListItem;
|
||||
}
|
||||
|
||||
interface ExplorerOverviewRow {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
description?: string;
|
||||
expanded: boolean;
|
||||
loaded: boolean;
|
||||
childDirectoryCount: number;
|
||||
isRootChild: boolean;
|
||||
}
|
||||
|
||||
interface ExplorerOverviewSection {
|
||||
id: string;
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
loaded: boolean;
|
||||
rowCount: number;
|
||||
rows: ExplorerOverviewRow[];
|
||||
}
|
||||
|
||||
|
||||
// --- Props ---
|
||||
const props = defineProps({
|
||||
@@ -265,6 +287,16 @@ const toFileListItem = (node: FileTreeNode): FileListItem => ({
|
||||
attrs: node.attrs,
|
||||
});
|
||||
|
||||
const getDirectoryChildren = (node: FileTreeNode | null): FileTreeNode[] => {
|
||||
if (!node?.children?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...node.children]
|
||||
.filter((child) => child.attrs.isDirectory)
|
||||
.sort((left, right) => left.filename.localeCompare(right.filename));
|
||||
};
|
||||
|
||||
const openFileInWorkspace = (filePath: string, filename: string) => {
|
||||
const fileInfo: FileInfo = { name: filename, fullPath: filePath };
|
||||
|
||||
@@ -315,18 +347,20 @@ const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
|
||||
const rows: ExplorerTreeRow[] = [];
|
||||
|
||||
const appendNodeRows = (basePath: string, nodes: FileListItem[], depth: number) => {
|
||||
sortTreeItems(nodes).forEach((item) => {
|
||||
sortTreeItems(nodes)
|
||||
.filter((item) => item.attrs.isDirectory)
|
||||
.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;
|
||||
const loaded = Boolean(treeNode?.childrenLoaded);
|
||||
|
||||
rows.push({
|
||||
id: `tree:${itemPath}`,
|
||||
path: itemPath,
|
||||
name: item.filename,
|
||||
depth,
|
||||
isDirectory: item.attrs.isDirectory,
|
||||
isDirectory: true,
|
||||
isRoot: false,
|
||||
loaded,
|
||||
expanded,
|
||||
@@ -334,10 +368,10 @@ const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
|
||||
item,
|
||||
});
|
||||
|
||||
if (item.attrs.isDirectory && expanded && treeNode?.children?.length) {
|
||||
if (expanded && treeNode?.children?.length) {
|
||||
appendNodeRows(itemPath, treeNode.children.map(toFileListItem), depth + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
explorerRoots.value.forEach((root) => {
|
||||
@@ -385,6 +419,50 @@ const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
|
||||
return rows;
|
||||
});
|
||||
|
||||
const explorerOverviewSections = computed<ExplorerOverviewSection[]>(() => {
|
||||
const buildRows = (basePath: string, nodes: FileTreeNode[], depth: number): ExplorerOverviewRow[] => {
|
||||
const rows: ExplorerOverviewRow[] = [];
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const itemPath = currentSftpManager.value?.joinPath(basePath, node.filename) ?? `${basePath}/${node.filename}`;
|
||||
const expanded = Boolean(explorerExpandedPaths.value[itemPath]);
|
||||
const childDirectories = getDirectoryChildren(node);
|
||||
|
||||
rows.push({
|
||||
id: `overview:${itemPath}`,
|
||||
path: itemPath,
|
||||
name: node.filename,
|
||||
depth,
|
||||
expanded,
|
||||
loaded: Boolean(node.childrenLoaded),
|
||||
childDirectoryCount: childDirectories.length,
|
||||
isRootChild: depth === 0,
|
||||
});
|
||||
|
||||
if (expanded && childDirectories.length) {
|
||||
rows.push(...buildRows(itemPath, childDirectories, depth + 1));
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
return explorerRoots.value.map((root) => {
|
||||
const rootNode = findTreeNodeByPath(root.path);
|
||||
const childDirectories = getDirectoryChildren(rootNode);
|
||||
|
||||
return {
|
||||
id: `section:${root.id}`,
|
||||
path: root.path,
|
||||
label: root.label,
|
||||
description: root.description,
|
||||
loaded: Boolean(rootNode?.childrenLoaded),
|
||||
rowCount: childDirectories.length,
|
||||
rows: buildRows(root.path, childDirectories, 0),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const getFileIconClassBase = (filename: string): string => {
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
let extension = '';
|
||||
@@ -1820,36 +1898,60 @@ const handleNavigateToPathFromFavorites = (path: string) => {
|
||||
showFavoritePathsModal.value = false; // Close modal after navigation
|
||||
};
|
||||
|
||||
const toggleDirectoryPath = (path: string, currentExpanded = false) => {
|
||||
const nextExpanded = !(explorerExpandedPaths.value[path] ?? currentExpanded);
|
||||
explorerExpandedPaths.value[path] = nextExpanded;
|
||||
|
||||
if (nextExpanded && currentSftpManager.value) {
|
||||
currentSftpManager.value.loadDirectory(path);
|
||||
}
|
||||
};
|
||||
|
||||
const focusDirectoryPath = (path: string) => {
|
||||
explorerExpandedPaths.value[path] = true;
|
||||
currentSftpManager.value?.loadDirectory(path);
|
||||
};
|
||||
|
||||
const isPathActive = (path: string) => {
|
||||
return currentSftpManager.value?.currentPath.value === path;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
toggleDirectoryPath(row.path, row.expanded);
|
||||
};
|
||||
|
||||
const handleExplorerOpen = (row: ExplorerTreeRow) => {
|
||||
if (row.isDirectory) {
|
||||
explorerExpandedPaths.value[row.path] = true;
|
||||
currentSftpManager.value?.loadDirectory(row.path);
|
||||
focusDirectoryPath(row.path);
|
||||
return;
|
||||
}
|
||||
|
||||
openFileInWorkspace(row.path, row.name);
|
||||
};
|
||||
|
||||
const handleOverviewSectionOpen = (section: ExplorerOverviewSection) => {
|
||||
focusDirectoryPath(section.path);
|
||||
};
|
||||
|
||||
const handleOverviewRowToggle = (row: ExplorerOverviewRow) => {
|
||||
toggleDirectoryPath(row.path, row.expanded);
|
||||
};
|
||||
|
||||
const handleOverviewRowOpen = (row: ExplorerOverviewRow) => {
|
||||
focusDirectoryPath(row.path);
|
||||
};
|
||||
|
||||
const handleOverviewRefresh = (section: ExplorerOverviewSection) => {
|
||||
explorerExpandedPaths.value[section.path] = true;
|
||||
currentSftpManager.value?.loadDirectory(section.path, true);
|
||||
};
|
||||
|
||||
const isExplorerRowActive = (row: ExplorerTreeRow) => {
|
||||
return currentSftpManager.value?.currentPath.value === row.path;
|
||||
return isPathActive(row.path);
|
||||
};
|
||||
|
||||
const isExplorerRowRelated = (row: ExplorerTreeRow) => {
|
||||
@@ -2161,151 +2263,98 @@ watch(
|
||||
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
|
||||
</div>
|
||||
|
||||
<!-- File Table -->
|
||||
<table ref="tableRef" class="w-full border-collapse table-fixed border-border rounded" :class="{'pointer-events-none': showExternalDropOverlay}" @contextmenu.prevent>
|
||||
<colgroup>
|
||||
<col :style="{ width: `${colWidths.type}px` }">
|
||||
<col :style="{ width: `${colWidths.name}px` }">
|
||||
<col :style="{ width: `${colWidths.size}px` }">
|
||||
<col :style="{ width: `${colWidths.permissions}px` }">
|
||||
<col :style="{ width: `${colWidths.modified}px` }">
|
||||
</colgroup>
|
||||
<thead class="sticky top-0 z-10 bg-header">
|
||||
<tr>
|
||||
<th
|
||||
@click="handleSort('type')"
|
||||
class="relative px-2 py-1 border-b-2 border-border text-left text-xs font-medium text-text-secondary uppercase tracking-wider cursor-pointer select-none hover:bg-black/5"
|
||||
:style="{ paddingLeft: `calc(1rem * var(--row-size-multiplier))`, paddingRight: `calc(0.5rem * var(--row-size-multiplier))` }"
|
||||
>
|
||||
{{ t('fileManager.headers.type') }}
|
||||
<span v-if="sortKey === 'type'" class="ml-1">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="absolute top-0 right-[-3px] w-1.5 h-full cursor-col-resize z-20 hover:bg-primary/20" @mousedown.prevent="startResize($event, 0)" @click.stop></span>
|
||||
</th>
|
||||
<th
|
||||
@click="handleSort('filename')"
|
||||
class="relative px-2 py-1 border-b-2 border-border text-left text-xs font-medium text-text-secondary uppercase tracking-wider cursor-pointer select-none hover:bg-black/5"
|
||||
:style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))` }"
|
||||
>
|
||||
{{ t('fileManager.headers.name') }}
|
||||
<span v-if="sortKey === 'filename'" class="ml-1">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="absolute top-0 right-[-3px] w-1.5 h-full cursor-col-resize z-20 hover:bg-primary/20" @mousedown.prevent="startResize($event, 1)" @click.stop></span>
|
||||
</th>
|
||||
<th
|
||||
@click="handleSort('size')"
|
||||
class="relative px-2 py-1 border-b-2 border-border text-left text-xs font-medium text-text-secondary uppercase tracking-wider cursor-pointer select-none hover:bg-black/5"
|
||||
:style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))` }"
|
||||
>
|
||||
{{ t('fileManager.headers.size') }}
|
||||
<span v-if="sortKey === 'size'" class="ml-1">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="absolute top-0 right-[-3px] w-1.5 h-full cursor-col-resize z-20 hover:bg-primary/20" @mousedown.prevent="startResize($event, 2)" @click.stop></span>
|
||||
</th>
|
||||
<th
|
||||
class="relative px-2 py-1 border-b-2 border-border text-left text-xs font-medium text-text-secondary uppercase tracking-wider select-none"
|
||||
:style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))` }"
|
||||
>
|
||||
{{ t('fileManager.headers.permissions') }}
|
||||
<span class="absolute top-0 right-[-3px] w-1.5 h-full cursor-col-resize z-20 hover:bg-primary/20" @mousedown.prevent="startResize($event, 3)" @click.stop></span>
|
||||
</th>
|
||||
<th
|
||||
@click="handleSort('mtime')"
|
||||
class="relative px-2 py-1 border-b-2 border-border text-left text-xs font-medium text-text-secondary uppercase tracking-wider cursor-pointer select-none hover:bg-black/5"
|
||||
:style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))` }"
|
||||
>
|
||||
{{ t('fileManager.headers.modified') }}
|
||||
<span v-if="sortKey === 'mtime'" class="ml-1">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<!-- No resizer on the last column -->
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<div class="min-h-full p-4 md:p-5 space-y-4" :class="{ 'pointer-events-none': showExternalDropOverlay }">
|
||||
<div class="rounded-2xl border border-border/60 bg-header/30 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">
|
||||
{{ t('fileManager.explorer.overviewTitle', '文件夹总览') }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-foreground">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1">
|
||||
<i class="fas fa-crosshairs text-[11px] text-primary"></i>
|
||||
<span class="truncate max-w-[420px]">{{ currentSftpManager?.currentPath?.value ?? '/' }}</span>
|
||||
</span>
|
||||
<span class="text-text-secondary text-xs">
|
||||
{{ t('fileManager.explorer.overviewHint', '点击目录只展开和聚焦,不再切成单独目录列表。') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<tbody v-if="!currentSftpManager || currentSftpManager.isLoading.value">
|
||||
<tr>
|
||||
<td :colspan="5" class="px-4 py-6 text-center text-text-secondary italic">
|
||||
{{ t('fileManager.loading') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<div v-if="!currentSftpManager || currentSftpManager.isLoading.value" class="rounded-2xl border border-border/60 bg-background px-4 py-10 text-center text-text-secondary italic">
|
||||
{{ t('fileManager.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Empty Directory State -->
|
||||
<tbody v-else-if="filteredFileList.length === 0">
|
||||
<tr>
|
||||
<td :colspan="5" class="px-4 py-6 text-center text-text-secondary italic">
|
||||
{{ searchQuery ? t('fileManager.noSearchResults') : t('fileManager.emptyDirectory') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<div v-else-if="explorerOverviewSections.length === 0" class="rounded-2xl border border-border/60 bg-background px-4 py-10 text-center text-text-secondary italic">
|
||||
{{ t('fileManager.explorer.noRoots', '暂无目录根,请先添加收藏路径或连接后浏览当前目录。') }}
|
||||
</div>
|
||||
|
||||
<!-- File List State -->
|
||||
<tbody v-else> <!-- Remove context menu handler from tbody -->
|
||||
<!-- '..' Entry -->
|
||||
<tr v-if="currentSftpManager?.currentPath.value !== '/'"
|
||||
class="transition-colors duration-150 cursor-pointer select-none"
|
||||
:class="{
|
||||
'bg-primary/10': selectedIndex === 0,
|
||||
'outline-dashed outline-2 outline-offset-[-1px] outline-primary': dragOverTarget === '..',
|
||||
'hover:bg-header/50': dragOverTarget !== '..'
|
||||
}"
|
||||
@click="handleItemClick($event, { filename: '..', longname: '..', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })"
|
||||
@contextmenu.prevent.stop="showContextMenu($event, { filename: '..', longname: '..', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })"
|
||||
@dragover.prevent="handleDragOverRow({ filename: '..', longname: '..', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } }, $event)"
|
||||
@dragleave="handleDragLeaveRow({ filename: '..', longname: '..', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })"
|
||||
@drop.prevent="handleDropOnRow({ filename: '..', longname: '..', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } }, $event)"
|
||||
:data-filename="'..'"
|
||||
<div v-else class="space-y-4">
|
||||
<section
|
||||
v-for="section in explorerOverviewSections"
|
||||
:key="section.id"
|
||||
class="rounded-2xl border border-border/60 bg-background/95 shadow-sm overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-header/35 px-4 py-3">
|
||||
<button
|
||||
class="min-w-0 flex items-center gap-3 text-left"
|
||||
@click="handleOverviewSectionOpen(section)"
|
||||
>
|
||||
<td class="text-center border-b border-border align-middle" :style="{ paddingLeft: `calc(1rem * var(--row-size-multiplier))`, paddingRight: `calc(0.5rem * var(--row-size-multiplier))` }">
|
||||
<i class="fas fa-level-up-alt text-primary" :style="{ fontSize: `calc(1.1em * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5))` }"></i>
|
||||
</td>
|
||||
<td class="border-b border-border align-middle" :style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))`, fontSize: `calc(0.8rem * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5))` }">..</td>
|
||||
<td class="border-b border-border align-middle"></td>
|
||||
<td class="border-b border-border align-middle"></td>
|
||||
<td class="border-b border-border align-middle"></td>
|
||||
</tr>
|
||||
<!-- File Entries -->
|
||||
<tr v-for="(item, index) in filteredFileList"
|
||||
:key="item.filename"
|
||||
:draggable="item.filename !== '..'" @dragstart="handleDragStart(item)" @dragend="handleDragEnd"
|
||||
@click="handleItemClick($event, item, props.isMobile && isMultiSelectMode)"
|
||||
class="transition-colors duration-150 select-none"
|
||||
:class="[
|
||||
{ 'cursor-pointer': item.attrs.isDirectory || item.attrs.isFile },
|
||||
{ 'bg-primary text-white': selectedItems.has(item.filename) || (index + (currentSftpManager?.currentPath.value !== '/' ? 1 : 0) === selectedIndex) },
|
||||
{ 'hover:bg-header/50': !(selectedItems.has(item.filename) || (index + (currentSftpManager?.currentPath.value !== '/' ? 1 : 0) === selectedIndex)) },
|
||||
{ 'outline-dashed outline-2 outline-offset-[-1px] outline-primary': item.attrs.isDirectory && dragOverTarget === item.filename }
|
||||
]"
|
||||
:data-filename="item.filename"
|
||||
@contextmenu.prevent.stop="showContextMenu($event, item)"
|
||||
@dragover.prevent="handleDragOverRow(item, $event)"
|
||||
@dragleave="handleDragLeaveRow(item)"
|
||||
@drop.prevent="handleDropOnRow(item, $event)">
|
||||
<td class="text-center border-b border-border align-middle" :style="{ paddingLeft: `calc(1rem * var(--row-size-multiplier))`, paddingRight: `calc(0.5rem * var(--row-size-multiplier))` }">
|
||||
<i :class="[
|
||||
'transition-colors duration-150',
|
||||
item.attrs.isDirectory
|
||||
? 'fas fa-folder text-primary'
|
||||
: item.attrs.isSymbolicLink
|
||||
? 'fas fa-link text-cyan-500'
|
||||
: `${getFileIconClassBase(item.filename)} text-text-secondary`,
|
||||
{
|
||||
'text-white': selectedItems.has(item.filename) || (index + (currentSftpManager?.currentPath.value !== '/' ? 1 : 0) === selectedIndex)
|
||||
}
|
||||
]"
|
||||
:style="{ fontSize: `calc(1.1em * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5))` }"></i>
|
||||
</td>
|
||||
<td class="border-b border-border truncate align-middle" :class="{'font-medium': item.attrs.isDirectory}" :style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))`, fontSize: `calc(0.8rem * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5))` }">{{ item.filename }}</td>
|
||||
<td class="border-b border-border truncate align-middle" :class="[
|
||||
selectedItems.has(item.filename) || (index + (currentSftpManager?.currentPath.value !== '/' ? 1 : 0) === selectedIndex) ? 'text-white' : 'text-text-secondary'
|
||||
]" :style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))`, fontSize: `calc(0.72rem * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5))` }">{{ item.attrs.isFile ? formatSize(item.attrs.size) : '' }}</td>
|
||||
<td class="border-b border-border truncate font-mono align-middle" :class="[
|
||||
selectedItems.has(item.filename) || (index + (currentSftpManager?.currentPath.value !== '/' ? 1 : 0) === selectedIndex) ? 'text-white' : 'text-text-secondary'
|
||||
]" :style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))`, fontSize: `calc(0.72rem * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5))` }">{{ formatMode(item.attrs.mode) }}</td>
|
||||
<td class="border-b border-border truncate align-middle" :class="[
|
||||
selectedItems.has(item.filename) || (index + (currentSftpManager?.currentPath.value !== '/' ? 1 : 0) === selectedIndex) ? 'text-white' : 'text-text-secondary'
|
||||
]" :style="{ padding: `calc(0.4rem * var(--row-size-multiplier)) calc(0.8rem * var(--row-size-multiplier))`, fontSize: `calc(0.72rem * max(0.85, var(--row-size-multiplier) * 0.5 + 0.5))` }">{{ new Date(item.attrs.mtime).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Removed separate loading/empty divs -->
|
||||
<i class="fas fa-folder-tree text-primary"></i>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold text-foreground">{{ section.label }}</span>
|
||||
<span class="block truncate text-[11px] text-text-secondary">{{ section.description }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full border border-border/60 bg-background px-2.5 py-1 text-xs text-text-secondary">
|
||||
{{ section.rowCount }} {{ t('fileManager.explorer.folderCount', '个文件夹') }}
|
||||
</span>
|
||||
<button
|
||||
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('common.refresh', '刷新')"
|
||||
@click="handleOverviewRefresh(section)"
|
||||
>
|
||||
<i class="fas fa-sync-alt text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="section.rows.length === 0" class="px-4 py-6 text-sm text-text-secondary">
|
||||
{{ t('fileManager.explorer.emptyFolders', '这个根目录下暂时没有已加载的子文件夹,展开左侧目录可继续浏览。') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="p-3 space-y-1">
|
||||
<div
|
||||
v-for="row in section.rows"
|
||||
:key="row.id"
|
||||
class="group flex items-center gap-3 rounded-xl border px-3 py-2 transition-colors"
|
||||
:class="isPathActive(row.path) ? 'border-primary bg-primary/10 text-foreground' : 'border-transparent text-text-secondary hover:border-border/60 hover:bg-header/40 hover:text-foreground'"
|
||||
:style="{ paddingLeft: `${0.9 + row.depth * 1.1}rem` }"
|
||||
>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center flex-shrink-0 text-[10px]"
|
||||
@click.stop="handleOverviewRowToggle(row)"
|
||||
>
|
||||
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
|
||||
</button>
|
||||
|
||||
<button class="min-w-0 flex items-center gap-3 flex-1 text-left" @click="handleOverviewRowOpen(row)">
|
||||
<i class="fas fa-folder w-4 text-center text-primary flex-shrink-0"></i>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium">{{ row.name }}</span>
|
||||
<span class="block truncate text-[11px]" :class="isPathActive(row.path) ? 'text-primary/80' : 'text-text-secondary/80'">
|
||||
{{ row.path }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span class="inline-flex items-center rounded-full border border-current/10 bg-black/5 px-2 py-0.5 text-[11px] flex-shrink-0">
|
||||
{{ row.childDirectoryCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1434,16 +1434,14 @@
|
||||
},
|
||||
"actions": {
|
||||
"runNow": "Run Now",
|
||||
"pasteToTerminal": "Paste to Terminal",
|
||||
"pasteToCommandInput": "Paste to Command Input",
|
||||
"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.",
|
||||
"pastedToCommandInput": "Pasted into the command input.",
|
||||
"sentToAllSessions": "Command sent to {count} servers.",
|
||||
"noActiveSshSessions": "No active SSH sessions to send command to."
|
||||
}
|
||||
|
||||
@@ -855,16 +855,14 @@
|
||||
"usageCount": "使用回数",
|
||||
"actions": {
|
||||
"runNow": "今すぐ実行",
|
||||
"pasteToTerminal": "ターミナルに貼り付け",
|
||||
"pasteToCommandInput": "コマンド入力欄に貼り付け",
|
||||
"copyCommand": "コマンドをコピー",
|
||||
"pasteToQuickInput": "クイック入力欄に貼り付け",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"sendToAllSessions": "すべてのサーバーに送信"
|
||||
},
|
||||
"notifications": {
|
||||
"pastedToTerminal": "ターミナル入力欄に貼り付けました。",
|
||||
"pastedToQuickInput": "クイック入力欄に貼り付けました。",
|
||||
"pastedToCommandInput": "コマンド入力欄に貼り付けました。",
|
||||
"sentToAllSessions": "コマンドは {count} 台のサーバーに送信されました。",
|
||||
"noActiveSshSessions": "コマンドを送信するアクティブな SSH セッションはありません。"
|
||||
}
|
||||
|
||||
@@ -1438,16 +1438,14 @@
|
||||
},
|
||||
"actions": {
|
||||
"runNow": "立即执行",
|
||||
"pasteToTerminal": "粘贴到终端",
|
||||
"pasteToCommandInput": "粘贴到命令输入框",
|
||||
"copyCommand": "复制命令",
|
||||
"pasteToQuickInput": "粘贴到快捷输入框",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"sendToAllSessions": "发送到全部服务器"
|
||||
},
|
||||
"notifications": {
|
||||
"pastedToTerminal": "已粘贴到终端输入框。",
|
||||
"pastedToQuickInput": "已粘贴到快捷输入框。",
|
||||
"pastedToCommandInput": "已粘贴到命令输入框。",
|
||||
"sentToAllSessions": "指令已发送到 {count} 台服务器。",
|
||||
"noActiveSshSessions": "没有活动的 SSH 会话可发送指令。"
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
<!-- Context Menu for Quick Commands -->
|
||||
<div
|
||||
v-if="quickCommandContextMenuVisible"
|
||||
class="fixed bg-background border border-border/50 shadow-xl rounded-lg py-1.5 z-50 min-w-[180px] quick-command-context-menu"
|
||||
class="fixed bg-card text-card-foreground border border-border shadow-xl rounded-lg py-1.5 z-50 min-w-[220px] quick-command-context-menu"
|
||||
:style="{ top: `${quickCommandContextMenuPosition.y}px`, left: `${quickCommandContextMenuPosition.x}px` }"
|
||||
@click.stop
|
||||
>
|
||||
@@ -225,10 +225,10 @@
|
||||
<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!)"
|
||||
@click="handleQuickCommandMenuAction('pasteToCommandInput', quickCommandContextTargetCommand!)"
|
||||
>
|
||||
<i class="fas fa-terminal w-4 text-center text-text-secondary group-hover:text-primary"></i>
|
||||
<span>{{ t('quickCommands.actions.pasteToTerminal', '粘贴到终端') }}</span>
|
||||
<span>{{ t('quickCommands.actions.pasteToCommandInput', '粘贴到命令输入框') }}</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="quickCommandContextTargetCommand"
|
||||
@@ -238,14 +238,6 @@
|
||||
<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"
|
||||
@@ -324,9 +316,8 @@ const quickCommandContextMenuPosition = ref({ x: 0, y: 0 });
|
||||
const quickCommandContextTargetCommand = ref<QuickCommandFE | null>(null);
|
||||
type QuickCommandContextAction =
|
||||
| 'runNow'
|
||||
| 'pasteToTerminal'
|
||||
| 'pasteToCommandInput'
|
||||
| 'copyCommand'
|
||||
| 'pasteToQuickInput'
|
||||
| 'edit'
|
||||
| 'delete'
|
||||
| 'sendToAllSessions';
|
||||
@@ -675,18 +666,7 @@ const pasteCommandToTerminalInput = async (cmd: QuickCommandFE) => {
|
||||
}
|
||||
|
||||
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', '已粘贴到快捷输入框'));
|
||||
uiNotificationsStore.showSuccess(t('quickCommands.notifications.pastedToCommandInput', '已粘贴到命令输入框'));
|
||||
};
|
||||
|
||||
// +++ 聚焦搜索框的方法 +++
|
||||
@@ -866,7 +846,7 @@ const handleQuickCommandMenuAction = async (action: QuickCommandContextAction, c
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'pasteToTerminal') {
|
||||
if (action === 'pasteToCommandInput') {
|
||||
await pasteCommandToTerminalInput(command);
|
||||
return;
|
||||
}
|
||||
@@ -876,11 +856,6 @@ const handleQuickCommandMenuAction = async (action: QuickCommandContextAction, c
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'pasteToQuickInput') {
|
||||
await pasteCommandToQuickInput(command);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'edit') {
|
||||
openEditForm(command);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user