feat(frontend): 增强文件管理器上传与右键菜单
新增“上传文件夹”入口,选择目录后先在浏览器端打包为 zip, 上传完成后自动触发远端解压并尝试清理临时压缩包。 同时重排文件右键菜单,补齐终端子菜单、复制文件名与绝对路径等操作, 并扩展上传任务状态展示。 同步前后端包版本到 1.0.0,并将设置页版本显示规范为 1.0
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
- 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。
|
||||
|
||||
### 修复
|
||||
- **[frontend]**: 重排文件区右键菜单结构,补齐终端子菜单、复制文件名和复制绝对路径等动作 — by yinjianm
|
||||
- 方案: [202603260228_file-context-menu-terminal-actions](archive/2026-03/202603260228_file-context-menu-terminal-actions/)
|
||||
- **[frontend]**: 将工作台文件区继续收敛为固定 `/` 根节点的单栏资源管理器树,并在树内同时显示目录与文件 — by yinjianm
|
||||
- 方案: [202603260212_workbench-file-root-tree](archive/2026-03/202603260212_workbench-file-root-tree/)
|
||||
- **[frontend]**: 修正快捷命令右键菜单的透明背景与粘贴项语义,改为实底菜单并将回填动作统一为“粘贴到命令输入框(不发送)” — by yinjianm
|
||||
@@ -24,6 +26,12 @@
|
||||
- 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/)
|
||||
|
||||
### 快速修改
|
||||
- **[backend]**: 将后端包版本元数据同步提升到 `1.0.0`,与根工作区和其余主包保持一致 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: packages/backend/package.json, packages/backend/package-lock.json, package-lock.json
|
||||
- **[frontend]**: 将设置页本地版本显示调整为 `1.0`,并同步前端包版本元数据到 `1.0.0` — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: packages/frontend/package.json, package-lock.json, packages/frontend/src/composables/settings/useVersionCheck.ts, packages/frontend/src/composables/settings/useAboutSection.ts
|
||||
- **[workspace-root]**: 同步更新中英文 README,补充 monorepo 结构、最新功能清单与 `.helloagents/` 开发说明 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: README.md, doc/README_EN.md
|
||||
@@ -32,6 +40,8 @@
|
||||
- 文件: packages/frontend/src/components/AddEditQuickCommandForm.vue:9,184-185,242-245
|
||||
|
||||
### 新增
|
||||
- **[frontend]**: 为文件管理器补齐“上传文件夹”入口,选择目录后会先在浏览器端打包为 zip,再上传并自动触发远端解压 — by yinjianm
|
||||
- 方案: [202603260234_folder-upload-auto-zip](archive/2026-03/202603260234_folder-upload-auto-zip/)
|
||||
- **[frontend]**: 为工作台文件面板补齐左侧多根目录资源管理器,支持收藏路径与当前路径同屏作为多个根目录展开浏览 — by yinjianm
|
||||
- 方案: [202603260041_workbench-file-multi-root-explorer](archive/2026-03/202603260041_workbench-file-multi-root-explorer/)
|
||||
- **[frontend]**: 为快捷指令编辑弹窗补充动态变量清单与点击插入,并统一列表执行/弹窗执行的动态变量解析链路 — by yinjianm
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"文件右键菜单已增强并补齐终端子菜单动作","updated_at":"2026-03-26 02:39:00"}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# 变更提案: file-context-menu-terminal-actions
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 功能增强
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已完成
|
||||
状态说明: 已按参考图重排文件右键菜单并补终端子菜单动作,前端构建通过
|
||||
创建: 2026-03-26
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
当前文件区已有右键菜单基础能力,但结构、顺序和终端相关动作与参考图差距较大,尤其缺少“执行 cd 命令到终端 / 新建终端到当前目录”这类直接联动终端的入口。
|
||||
|
||||
### 目标
|
||||
- 将文件右键菜单按参考图重排为更接近资源管理器的结构。
|
||||
- 补齐终端子菜单,支持发送 `cd` 命令和新建终端到当前目录。
|
||||
- 保留现有下载、权限、复制路径、删除、上传等文件动作。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
范围约束: 优先限制在 FileManager.vue、FileManagerContextMenu.vue 和 useFileManagerContextMenu.ts
|
||||
交互约束: 菜单结构接近参考图,但仍复用现有文件动作和上下文选择逻辑
|
||||
兼容约束: 空白处右键、多选右键和单文件右键保持可用
|
||||
后端约束: 不新增后端接口
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [x] 单文件/目录右键菜单顺序与分组接近参考图
|
||||
- [x] 终端子菜单支持“执行 cd 命令到终端 / 新建终端到当前目录”
|
||||
- [x] 文件名和绝对路径可分别复制
|
||||
- [x] 前端构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `useFileManagerContextMenu.ts` 中扩展菜单项模型,支持图标、危险态和更明确的子菜单结构;重排单项右键菜单为“刷新 / 新建 / 重命名 / 下载 / 权限 / 终端 / 复制 / 删除 / 上传”顺序,并将终端子菜单动作通过 `FileManager.vue` 暴露的现有终端发送逻辑与新建会话逻辑实现。`FileManagerContextMenu.vue` 同步渲染图标、危险态和二级菜单样式。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: FileManager.vue
|
||||
- frontend: FileManagerContextMenu.vue
|
||||
- frontend: useFileManagerContextMenu.ts
|
||||
预计变更文件: 3-6
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 新建终端后过早发送 `cd` 命令可能丢失 | 中 | 在新会话收到 `ssh:connected` 后再发送一次 `cd` |
|
||||
| 单项菜单重排后与多选/空白处菜单的分组不一致 | 低 | 仅对单项菜单贴近参考图,多选和空白处菜单保留现有能力并做最小整理 |
|
||||
| locale 新增文案可能与其他并行改动冲突 | 低 | 只做精确增量补丁,避免覆盖已有键 |
|
||||
@@ -0,0 +1,42 @@
|
||||
# 任务清单: file-context-menu-terminal-actions
|
||||
|
||||
```yaml
|
||||
@feature: file-context-menu-terminal-actions
|
||||
@created: 2026-03-26
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 4 | 0 | 0 | 4 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与范围确认
|
||||
|
||||
- [√] 1.1 创建文件右键菜单增强方案包并锁定到文件菜单链路 | depends_on: []
|
||||
|
||||
### 2. 菜单交互实现
|
||||
|
||||
- [√] 2.1 扩展上下文菜单项模型与菜单渲染,支持图标和终端子菜单 | depends_on: [1.1]
|
||||
- [√] 2.2 在 FileManager 中补终端联动、复制文件名等菜单动作 | depends_on: [2.1]
|
||||
|
||||
### 3. 验证与同步
|
||||
|
||||
- [√] 3.1 运行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [2.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-26 02:28 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为文件右键菜单结构与终端子菜单增强 |
|
||||
| 2026-03-26 02:33 | 2.1 | 完成 | 扩展菜单项模型与菜单渲染,支持图标、危险态和终端/上传二级菜单 |
|
||||
| 2026-03-26 02:36 | 2.2 | 完成 | 在 FileManager 中补齐复制文件名、按目录发送 cd、以及新建终端后自动 cd 的动作 |
|
||||
| 2026-03-26 02:39 | 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 02:52:00"}
|
||||
@@ -0,0 +1,139 @@
|
||||
# 变更提案: folder-upload-auto-zip
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 功能调整
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已完成
|
||||
状态说明: 已补齐文件夹选择、浏览器端 zip 上传与远端自动解压,并通过前端构建验证
|
||||
创建: 2026-03-26
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
当前文件管理器只支持普通文件上传。虽然拖拽目录时已经能递归遍历并逐个上传文件,但面对大量小文件目录时,浏览器侧扫描、前端逐文件分块、后端逐文件创建与远端 SFTP 写入都会显著拉长整体耗时,用户体感接近“卡住”。
|
||||
|
||||
### 目标
|
||||
- 在现有文件管理器中新增明确的“上传文件夹”入口。
|
||||
- 选择文件夹后,在浏览器端先将目录树压缩为单个 zip,再复用现有上传链路。
|
||||
- 上传成功后自动调用远端解压,尽量让用户获得“像直接上传文件夹”的结果。
|
||||
- 普通文件上传保持现有行为,不影响已有入口和拖拽目录上传兼容性。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
范围约束: 优先复用现有 sftp:upload 与 sftp:decompress,不新增 REST 上传接口
|
||||
前端约束: 需要在浏览器端完成目录树收集和 zip 构建
|
||||
后端约束: 远端自动解压依赖现有服务器命令检测与解压实现
|
||||
兼容约束: 普通文件上传、现有拖拽上传和文件树刷新逻辑不能回归
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] 文件管理器出现独立的“上传文件夹”入口
|
||||
- [ ] 选择文件夹后会先压缩为 zip,再作为单次上传任务发送
|
||||
- [ ] zip 上传成功后会自动触发远端解压
|
||||
- [ ] 普通文件上传行为保持不变
|
||||
- [ ] 前后端构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `FileManager.vue` 中增加第二个隐藏目录选择 input,并将“上传文件”与“上传文件夹”拆分为两个明确入口。前端新增目录压缩逻辑:利用 `webkitdirectory` 返回的 `FileList` 生成目录树,并通过前端 zip 库构建一个临时 `File`/`Blob`。上传层继续走 `useFileUploader` 的 `sftp:upload:start/chunk` 协议,但补充目录压缩任务元数据、成功回调和自动解压回调;上传成功后复用 `useSftpActions` 现有 `decompressItem()` 能力,并在成功后清理临时 zip。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: FileManager.vue、useFileUploader.ts、upload.types.ts、locales
|
||||
- backend: 无协议重构,最多仅需适配现有上传/解压消息的边界处理
|
||||
预计变更文件: 6-10
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 浏览器端压缩大目录时主线程占用明显 | 中 | 在 UI 中明确显示“压缩中”,并只对显式文件夹入口启用 |
|
||||
| 远端缺少 unzip 或 tar 命令时自动解压失败 | 中 | 继续复用现有 `sftp:command_not_found` 提示,上传成功但解压失败时给出明确报错 |
|
||||
| 自动解压后再清理临时 zip 可能因当前目录变更导致删除目标错误 | 中 | 删除逻辑基于上传完成返回的绝对远端路径,而不是依赖当前 UI 路径 |
|
||||
| 目录名与 zip 临时文件名冲突 | 低 | 为临时 zip 文件名附加固定后缀,避免覆盖现有同名目录/文件 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[FileManager 文件夹选择] --> B[浏览器收集 FileList]
|
||||
B --> C[前端生成 zip Blob/File]
|
||||
C --> D[useFileUploader 发起 sftp:upload]
|
||||
D --> E[后端 SftpService 写入远端 zip]
|
||||
E --> F[上传成功回调]
|
||||
F --> G[useSftpActions.decompressItem]
|
||||
G --> H[远端解压并刷新文件树]
|
||||
H --> I[清理临时 zip]
|
||||
```
|
||||
|
||||
### API设计
|
||||
本轮不新增独立 HTTP API,沿用现有 WebSocket 消息:
|
||||
|
||||
- `sftp:upload:start`
|
||||
- `sftp:upload:chunk`
|
||||
- `sftp:upload:success`
|
||||
- `sftp:decompress`
|
||||
- `sftp:decompress:success`
|
||||
|
||||
前端本地新增的只是上传任务元数据,不改动后端消息协议主体。
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `mode` | `'file' \| 'folder-archive'` | 上传任务模式,区分普通文件与目录压缩上传 |
|
||||
| `archiveFileName` | `string` | 浏览器端生成的临时 zip 文件名 |
|
||||
| `remoteArchivePath` | `string` | 远端临时 zip 的绝对路径 |
|
||||
| `decompressAfterUpload` | `boolean` | 上传成功后是否自动触发解压 |
|
||||
| `cleanupArchiveAfterExtract` | `boolean` | 解压成功后是否自动删除临时 zip |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
### 场景: 上传文件夹并自动解压
|
||||
**模块**: frontend
|
||||
**条件**: 用户在文件管理器点击“上传文件夹”,并选择一个本地目录。
|
||||
**行为**: 前端将目录内容打包为 zip,上传到当前远端目录,成功后自动调用解压并清理临时 zip。
|
||||
**结果**: 远端目录出现解压后的文件夹内容,用户无需手工上传 zip 再解压。
|
||||
|
||||
### 场景: 远端缺少解压命令
|
||||
**模块**: frontend / backend
|
||||
**条件**: zip 上传成功,但服务器上没有可用解压命令。
|
||||
**行为**: 复用现有 `sftp:command_not_found` / `sftp:decompress:error` 错误反馈。
|
||||
**结果**: 用户能看到“上传成功但自动解压失败”的明确信号,并保留上传的 zip 文件用于手工处理。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### folder-upload-auto-zip#D001: 文件夹上传采用“前端压缩 + 现有上传协议 + 远端自动解压”
|
||||
**日期**: 2026-03-26
|
||||
**状态**: ✅采纳
|
||||
**背景**: 现有目录上传是逐文件递归上传,小文件多时开销大;而后端已经具备远端解压能力。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 保持逐文件目录上传 | 不引入新依赖,链路简单 | 小文件多时扫描与上传时间长,用户体感差 |
|
||||
| B: 前端压缩成 zip 后上传,再自动解压 | 最大化复用现有协议,明显减少小文件上传请求数 | 前端需要额外压缩逻辑,浏览器端会有打包耗时 |
|
||||
| C: 后端接收目录流并服务端压缩/展开 | 可把压缩开销从浏览器移走 | 需要重写上传协议与后端缓存链路,改动面过大 |
|
||||
**决策**: 选择方案 B
|
||||
**理由**: 在当前仓库里,这是性能收益最大且改动最小的路径,能复用现有 `sftp:upload` 和 `sftp:decompress`,避免引入新的后端上传接口。
|
||||
**影响**: 主要影响 `packages/frontend` 的文件管理器和上传状态管理,后端维持现有 WebSocket/SFTP 能力。
|
||||
|
||||
---
|
||||
|
||||
## 6. 成果设计
|
||||
|
||||
N/A。本轮是现有文件管理器能力增强,不引入新的视觉主题方向,仅延续当前工具栏和上传浮层风格。
|
||||
@@ -0,0 +1,56 @@
|
||||
# 任务清单: folder-upload-auto-zip
|
||||
|
||||
```yaml
|
||||
@feature: folder-upload-auto-zip
|
||||
@created: 2026-03-26
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 6 | 0 | 0 | 6 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与范围确认
|
||||
|
||||
- [√] 1.1 创建文件夹压缩上传方案包并锁定为前端主导实现 | depends_on: []
|
||||
|
||||
### 2. 上传入口与压缩任务
|
||||
|
||||
- [√] 2.1 在 `packages/frontend/src/components/FileManager.vue` 中新增“上传文件夹”入口与目录选择 input | depends_on: [1.1]
|
||||
- [√] 2.2 在前端实现目录选择后的 zip 打包流程,并生成可上传的临时归档文件 | depends_on: [2.1]
|
||||
|
||||
### 3. 上传状态与自动解压
|
||||
|
||||
- [√] 3.1 在 `packages/frontend/src/composables/useFileUploader.ts` 和相关类型中扩展压缩中/解压中任务状态与成功回调 | depends_on: [2.2]
|
||||
- [√] 3.2 上传成功后自动调用远端解压,并在成功时清理临时 zip 与刷新文件树 | depends_on: [3.1]
|
||||
|
||||
### 4. 文案与验证
|
||||
|
||||
- [√] 4.1 更新 locale 文案并验证前后端构建 | depends_on: [3.2]
|
||||
- [√] 4.2 同步 `.helloagents` 文档、模块说明与 CHANGELOG | depends_on: [4.1]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-26 02:34 | 1.1 | 完成 | 创建 implementation 方案包,并锁定为前端主导的文件夹压缩上传方案 |
|
||||
| 2026-03-26 02:41 | 2.1 | 完成 | 文件管理器新增隐藏目录选择 input、上传文件夹按钮与上下文菜单入口 |
|
||||
| 2026-03-26 02:45 | 2.2 | 完成 | 新增浏览器端目录压缩逻辑,选择文件夹后生成临时 zip 归档 |
|
||||
| 2026-03-26 02:49 | 3.1 | 完成 | 扩展上传任务状态,支持压缩中、上传中和解压中串联展示 |
|
||||
| 2026-03-26 02:50 | 3.2 | 完成 | 上传成功后自动触发远端解压,并尝试清理临时 zip |
|
||||
| 2026-03-26 02:52 | 4.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
> 当前方案已按“前端压缩 + 现有上传协议 + 远端自动解压”落地;空目录仍受浏览器 `webkitdirectory` 选择结果限制,不会被单独打包上传。
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202603260234 | folder-upload-auto-zip | implementation | frontend | folder-upload-auto-zip#D001 | ✅完成 |
|
||||
| 202603260228 | file-context-menu-terminal-actions | implementation | frontend | - | ✅完成 |
|
||||
| 202603260212 | workbench-file-root-tree | implementation | frontend | - | ✅完成 |
|
||||
| 202603260156 | quickcommands-context-menu-polish | implementation | frontend | quickcommands-context-menu-polish#D001 | ✅完成 |
|
||||
| 202603260150 | workbench-file-folder-overview | implementation | frontend | - | ✅完成 |
|
||||
@@ -35,6 +37,8 @@
|
||||
## 按月归档
|
||||
|
||||
### 2026-03
|
||||
- [202603260234_folder-upload-auto-zip](./2026-03/202603260234_folder-upload-auto-zip/) - 为文件管理器补齐上传文件夹入口,选择目录后先打包为 zip,再上传并自动触发远端解压
|
||||
- [202603260228_file-context-menu-terminal-actions](./2026-03/202603260228_file-context-menu-terminal-actions/) - 重排文件区右键菜单结构,并补齐终端子菜单、复制文件名和复制绝对路径等动作
|
||||
- [202603260212_workbench-file-root-tree](./2026-03/202603260212_workbench-file-root-tree/) - 将工作台文件区收敛为固定 / 根节点的单栏资源管理器树,并在树内同时显示目录与文件
|
||||
- [202603260156_quickcommands-context-menu-polish](./2026-03/202603260156_quickcommands-context-menu-polish/) - 修正快捷命令右键菜单透明背景,并将粘贴动作统一为“粘贴到命令输入框(不发送)”
|
||||
- [202603260150_workbench-file-folder-overview](./2026-03/202603260150_workbench-file-folder-overview/) - 将工作台文件区调整为多根目录常驻的文件夹总览,不再点击目录后切成单独文件表格
|
||||
|
||||
@@ -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` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
|
||||
**行为**: 通过组件、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` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包,从而显著降低小文件很多时的扫描与上传耗时;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
|
||||
**结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||
Generated
+72
-2
@@ -5622,6 +5622,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -5939,6 +5945,48 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
@@ -6018,6 +6066,15 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.29.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
|
||||
@@ -7160,6 +7217,12 @@
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -8100,6 +8163,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -9793,7 +9862,7 @@
|
||||
},
|
||||
"packages/backend": {
|
||||
"name": "@nexus-terminal/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.1.1",
|
||||
"@types/archiver": "^6.0.3",
|
||||
@@ -9922,7 +9991,7 @@
|
||||
},
|
||||
"packages/frontend": {
|
||||
"name": "@nexus-terminal/frontend",
|
||||
"version": "0.8.1",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-cpp": "^6.0.2",
|
||||
@@ -9954,6 +10023,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jszip": "^3.10.1",
|
||||
"mitt": "^3.0.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "^3.0.2",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@nexus-terminal/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@nexus-terminal/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.1.1",
|
||||
"@types/archiver": "^6.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nexus-terminal/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nexus-terminal/frontend",
|
||||
"version": "0.8.1",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -39,6 +39,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jszip": "^3.10.1",
|
||||
"mitt": "^3.0.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "^3.0.2",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } f
|
||||
import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection';
|
||||
import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop';
|
||||
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
|
||||
import { createFolderArchive } from '../composables/file-manager/useFolderArchiveUpload';
|
||||
import FileUploadPopup from './FileUploadPopup.vue';
|
||||
import FileManagerContextMenu from './FileManagerContextMenu.vue';
|
||||
import FileManagerActionModal from './FileManagerActionModal.vue';
|
||||
@@ -102,6 +103,9 @@ const {
|
||||
uploads,
|
||||
startFileUpload,
|
||||
cancelUpload,
|
||||
createUploadTask,
|
||||
updateUploadTask,
|
||||
cleanupUploadTask,
|
||||
} = useFileUploader(
|
||||
computed(() => props.sessionId),
|
||||
// 传递 manager 的 currentPath 和 fileList ref
|
||||
@@ -131,6 +135,7 @@ const {
|
||||
|
||||
// --- UI 状态 Refs (Remain mostly the same) ---
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const folderInputRef = ref<HTMLInputElement | null>(null);
|
||||
const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename');
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc');
|
||||
const isEditingPath = ref(false);
|
||||
@@ -142,6 +147,7 @@ const pathInputRef = ref<HTMLInputElement | null>(null);
|
||||
const editablePath = ref('');
|
||||
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 文件列表容器引用
|
||||
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
|
||||
const isFolderUploadBusy = ref(false);
|
||||
|
||||
// +++ Favorite Paths Modal State +++
|
||||
const showFavoritePathsModal = ref(false);
|
||||
@@ -869,6 +875,90 @@ const handlePaste = () => {
|
||||
|
||||
// --- 文件上传触发器 (定义在此处,供 Composable 使用) ---
|
||||
const triggerFileUpload = () => { fileInputRef.value?.click(); };
|
||||
const triggerFolderUpload = () => {
|
||||
if (isFolderUploadBusy.value) {
|
||||
return;
|
||||
}
|
||||
folderInputRef.value?.click();
|
||||
};
|
||||
|
||||
const getFolderUploadName = (files: File[]) => {
|
||||
const firstRelativePath = files[0]?.webkitRelativePath || files[0]?.name || 'folder';
|
||||
return firstRelativePath.split('/').filter(Boolean)[0] || 'folder';
|
||||
};
|
||||
|
||||
const handleFolderSelected = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
input.value = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSftpManager.value || !props.wsDeps.isConnected.value) {
|
||||
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'));
|
||||
return;
|
||||
}
|
||||
|
||||
const folderName = getFolderUploadName(files);
|
||||
const uploadId = createUploadTask(folderName, {
|
||||
status: 'compressing',
|
||||
mode: 'folder-archive',
|
||||
detail: t('fileManager.notifications.folderArchiveQueued', { count: files.length }),
|
||||
});
|
||||
|
||||
isFolderUploadBusy.value = true;
|
||||
|
||||
try {
|
||||
const { archiveFile, entryCount } = await createFolderArchive(files, (progress) => {
|
||||
updateUploadTask(uploadId, {
|
||||
status: 'compressing',
|
||||
progress,
|
||||
detail: t('fileManager.notifications.folderArchivePreparing', { count: files.length }),
|
||||
});
|
||||
});
|
||||
|
||||
updateUploadTask(uploadId, {
|
||||
progress: 100,
|
||||
detail: t('fileManager.notifications.folderArchiveReady', { count: entryCount }),
|
||||
});
|
||||
|
||||
startFileUpload(archiveFile, undefined, {
|
||||
uploadId,
|
||||
displayName: folderName,
|
||||
mode: 'folder-archive',
|
||||
detail: t('fileManager.notifications.folderArchiveUploading', { count: entryCount }),
|
||||
afterUpload: async ({ remotePath }) => {
|
||||
if (!currentSftpManager.value) {
|
||||
throw new Error(t('fileManager.errors.sftpManagerNotFound'));
|
||||
}
|
||||
|
||||
await currentSftpManager.value.decompressPath(remotePath, folderName);
|
||||
|
||||
try {
|
||||
await currentSftpManager.value.unlinkPath(remotePath);
|
||||
} catch (cleanupError: any) {
|
||||
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to cleanup uploaded archive ${remotePath}:`, cleanupError);
|
||||
uiNotificationsStore.showWarning(
|
||||
t('fileManager.errors.archiveCleanupFailed', {
|
||||
name: remotePath.split('/').pop() || remotePath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to archive folder before upload:`, error);
|
||||
updateUploadTask(uploadId, {
|
||||
status: 'error',
|
||||
error: error?.message || t('fileManager.errors.folderCompressionFailed'),
|
||||
});
|
||||
cleanupUploadTask(uploadId, 5000);
|
||||
} finally {
|
||||
isFolderUploadBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 下载触发器 (定义在此处,供 Composable 使用) ---
|
||||
const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileListItem 数组
|
||||
@@ -1038,6 +1128,90 @@ const handleCopyPath = async (item: FileListItem) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyFilename = async (item: FileListItem) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.filename);
|
||||
uiNotificationsStore.showSuccess(t('fileManager.notifications.filenameCopied', 'Filename copied to clipboard'));
|
||||
} catch (err) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to copy filename: `, err);
|
||||
uiNotificationsStore.showError(t('fileManager.errors.copyFilenameFailed', 'Failed to copy filename'));
|
||||
}
|
||||
};
|
||||
|
||||
const getTargetPathForItem = (item?: FileListItem | null): string | null => {
|
||||
if (!currentSftpManager.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPath = currentSftpManager.value.currentPath.value;
|
||||
if (!item || item.filename === '..') {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
if (item.attrs.isDirectory) {
|
||||
return currentSftpManager.value.joinPath(currentPath, item.filename);
|
||||
}
|
||||
|
||||
return currentPath;
|
||||
};
|
||||
|
||||
const sendCdCommandToPath = (targetPath: string, sessionId?: string) => {
|
||||
if (!props.wsDeps.isConnected.value) {
|
||||
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot send CD command: not connected.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const escapedPath = `"${targetPath}"`;
|
||||
const command = `cd ${escapedPath}\n`;
|
||||
|
||||
try {
|
||||
const targetSession = sessionId ? sessionStore.sessions.get(sessionId) : sessionStore.activeSession;
|
||||
if (!targetSession?.terminalManager) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command: Terminal manager not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
targetSession.terminalManager.sendData(command);
|
||||
} catch (error) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Failed to send command to terminal:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendItemPathToTerminal = (item: FileListItem) => {
|
||||
const targetPath = getTargetPathForItem(item);
|
||||
if (!targetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendCdCommandToPath(targetPath);
|
||||
};
|
||||
|
||||
const handleOpenTerminalAtItemPath = (item: FileListItem) => {
|
||||
const targetPath = getTargetPathForItem(item);
|
||||
const connectionId = Number(props.dbConnectionId);
|
||||
if (!targetPath || Number.isNaN(connectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousActiveSessionId = sessionStore.activeSessionId;
|
||||
sessionStore.handleOpenNewSession(connectionId);
|
||||
const newSessionId = sessionStore.activeSessionId;
|
||||
|
||||
if (!newSessionId || newSessionId === previousActiveSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSession = sessionStore.sessions.get(newSessionId);
|
||||
if (!newSession?.wsManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregister = newSession.wsManager.onMessage('ssh:connected', () => {
|
||||
sendCdCommandToPath(targetPath, newSessionId);
|
||||
unregister?.();
|
||||
});
|
||||
};
|
||||
|
||||
// --- 上下文菜单逻辑 (使用 Composable, 需要 Selection 和 Action Handlers) ---
|
||||
const {
|
||||
contextMenuVisible,
|
||||
@@ -1065,6 +1239,7 @@ const {
|
||||
}
|
||||
},
|
||||
onUpload: triggerFileUpload,
|
||||
onUploadFolder: triggerFolderUpload,
|
||||
onDownload: triggerDownload,
|
||||
onDelete: handleDeleteSelectedClick,
|
||||
onRename: handleRenameContextMenuClick,
|
||||
@@ -1079,6 +1254,9 @@ const {
|
||||
onCompressRequest: handleCompress,
|
||||
onDecompressRequest: handleDecompress,
|
||||
onCopyPath: handleCopyPath, // +++ 传递复制路径回调 +++
|
||||
onCopyFilename: handleCopyFilename,
|
||||
onSendCdToTerminal: handleSendItemPathToTerminal,
|
||||
onOpenTerminalAtPath: handleOpenTerminalAtItemPath,
|
||||
});
|
||||
|
||||
// --- 目录加载与导航 ---
|
||||
@@ -1970,10 +2148,11 @@ watch(
|
||||
class="left-0 right-0 top-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div> <!-- End Wrapper -->
|
||||
</div> <!-- End Wrapper -->
|
||||
<!-- Main Actions Bar -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
|
||||
<input type="file" ref="folderInputRef" @change="handleFolderSelected" webkitdirectory directory multiple class="hidden" />
|
||||
<!-- 打开编辑器按钮 -->
|
||||
<button
|
||||
v-if="showPopupFileEditorBoolean"
|
||||
@@ -1995,7 +2174,17 @@ watch(
|
||||
:class="{ 'px-1.5': props.isMobile }"
|
||||
>
|
||||
<i class="fas fa-upload text-sm"></i>
|
||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.upload') }}</span>
|
||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFile') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="triggerFolderUpload"
|
||||
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || isFolderUploadBusy"
|
||||
:title="t('fileManager.actions.uploadFolder')"
|
||||
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
|
||||
:class="{ 'px-1.5': props.isMobile }"
|
||||
>
|
||||
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin text-sm' : 'fas fa-folder-open text-sm'"></i>
|
||||
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFolder') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleNewFolderContextMenuClick"
|
||||
|
||||
@@ -133,6 +133,9 @@ onUnmounted(() => {
|
||||
const emit = defineEmits(['item-click', 'close-request']); // 添加 close-request
|
||||
|
||||
const handleItemClick = (item: ContextMenuItem) => {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
if (item.action) {
|
||||
item.action(); // 只有当 action 存在时才执行
|
||||
emit('close-request'); // <-- 发出关闭请求
|
||||
@@ -221,13 +224,13 @@ onUnmounted(() => {
|
||||
<div
|
||||
ref="contextMenuRef"
|
||||
v-if="isVisible"
|
||||
class="fixed bg-background border border-border shadow-lg rounded-md z-[1002] min-w-[150px]"
|
||||
class="fixed bg-background/98 border border-border/80 shadow-[0_18px_40px_rgba(0,0,0,0.36)] rounded-xl z-[1002] min-w-[184px] overflow-hidden backdrop-blur-sm"
|
||||
:style="{ top: `${computedRenderPosition.y}px`, left: `${computedRenderPosition.x}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<ul class="list-none p-1 m-0">
|
||||
<ul class="list-none p-1.5 m-0">
|
||||
<template v-for="(menuItem, index) in items" :key="index">
|
||||
<li v-if="menuItem.separator" class="border-t border-border/50 my-1 mx-1"></li>
|
||||
<li v-if="menuItem.separator" class="border-t border-border/60 my-1.5 mx-2"></li>
|
||||
<!-- 如果是移动设备且有子菜单,则平铺子菜单 -->
|
||||
<template v-else-if="isMobile && menuItem.submenu && menuItem.submenu.length > 0">
|
||||
<li
|
||||
@@ -235,10 +238,15 @@ onUnmounted(() => {
|
||||
:key="`${index}-${subIndex}`"
|
||||
@click.stop="handleItemClick(subItem)"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
subItem.disabled
|
||||
? 'cursor-not-allowed opacity-50 text-text-secondary'
|
||||
: subItem.danger
|
||||
? 'cursor-pointer text-error hover:bg-error/10'
|
||||
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
<i v-if="subItem.icon" :class="[subItem.icon, 'w-4 text-center flex-shrink-0']"></i>
|
||||
{{ subItem.label }}
|
||||
</li>
|
||||
<!-- 如果 menuItem (作为移动端子菜单容器) 是 "压缩", 在其子项后添加 "发送到" -->
|
||||
@@ -246,7 +254,7 @@ onUnmounted(() => {
|
||||
<li
|
||||
@click.stop="handleSendToClick"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'px-3 py-2.5 cursor-pointer text-foreground text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
@@ -259,10 +267,15 @@ onUnmounted(() => {
|
||||
v-else-if="!menuItem.submenu"
|
||||
@click.stop="handleItemClick(menuItem)"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
menuItem.disabled
|
||||
? 'cursor-not-allowed opacity-50 text-text-secondary'
|
||||
: menuItem.danger
|
||||
? 'cursor-pointer text-error hover:bg-error/10'
|
||||
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
<i v-if="menuItem.icon" :class="[menuItem.icon, 'w-4 text-center flex-shrink-0']"></i>
|
||||
{{ menuItem.label }}
|
||||
</li>
|
||||
<!-- 如果普通菜单项是 "压缩", 在其后添加 "发送到" -->
|
||||
@@ -279,15 +292,25 @@ onUnmounted(() => {
|
||||
</template>
|
||||
<li
|
||||
v-if="menuItem.submenu && !isMobile"
|
||||
class="px-4 py-1.5 text-foreground text-sm flex items-center justify-between transition-colors duration-150 rounded mx-1 hover:bg-primary/10 hover:text-primary relative"
|
||||
:class="[
|
||||
'px-3 py-2.5 text-sm flex items-center justify-between transition-colors duration-150 rounded-lg mx-0.5 relative',
|
||||
menuItem.disabled
|
||||
? 'cursor-not-allowed opacity-50 text-text-secondary'
|
||||
: menuItem.danger
|
||||
? 'cursor-pointer text-error hover:bg-error/10'
|
||||
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
@mouseenter="showSubmenu(menuItem.label)"
|
||||
@mouseleave="hideSubmenu()"
|
||||
>
|
||||
{{ menuItem.label }}
|
||||
<span class="flex items-center gap-3 min-w-0">
|
||||
<i v-if="menuItem.icon" :class="[menuItem.icon, 'w-4 text-center flex-shrink-0']"></i>
|
||||
<span class="truncate">{{ menuItem.label }}</span>
|
||||
</span>
|
||||
<span class="ml-2">›</span>
|
||||
<ul
|
||||
v-if="expandedSubmenu === menuItem.label"
|
||||
class="absolute left-full top-0 mt-0 ml-1 bg-background border border-border shadow-lg rounded-md z-[1003] min-w-[150px] list-none p-1"
|
||||
class="absolute left-full top-0 mt-0 ml-2 bg-background/98 border border-border/80 shadow-[0_18px_40px_rgba(0,0,0,0.32)] rounded-xl z-[1003] min-w-[184px] list-none p-1.5"
|
||||
@mouseenter="showSubmenu(menuItem.label)"
|
||||
@mouseleave="hideSubmenu()"
|
||||
>
|
||||
@@ -296,10 +319,15 @@ onUnmounted(() => {
|
||||
:key="subIndex"
|
||||
@click.stop="handleItemClick(subItem)"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
'px-3 py-2.5 text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
subItem.disabled
|
||||
? 'cursor-not-allowed opacity-50 text-text-secondary'
|
||||
: subItem.danger
|
||||
? 'cursor-pointer text-error hover:bg-error/10'
|
||||
: 'cursor-pointer text-foreground hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
<i v-if="subItem.icon" :class="[subItem.icon, 'w-4 text-center flex-shrink-0']"></i>
|
||||
{{ subItem.label }}
|
||||
</li>
|
||||
</ul>
|
||||
@@ -309,7 +337,7 @@ onUnmounted(() => {
|
||||
<li
|
||||
@click.stop="handleSendToClick"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1',
|
||||
'px-3 py-2.5 cursor-pointer text-foreground text-sm flex items-center gap-2.5 transition-colors duration-150 rounded-lg mx-0.5',
|
||||
'hover:bg-primary/10 hover:text-primary'
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,14 @@ const uploadList = computed(() => Object.values(props.uploads).filter(upload =>
|
||||
return !isEffectivelySuccess && upload.status !== 'cancelled';
|
||||
}));
|
||||
|
||||
const showProgressBar = (upload: UploadItem) => {
|
||||
return ['compressing', 'pending', 'uploading'].includes(upload.status);
|
||||
};
|
||||
|
||||
const showProgressValue = (upload: UploadItem) => {
|
||||
return ['compressing', 'uploading'].includes(upload.status);
|
||||
};
|
||||
|
||||
const handleCancel = (uploadId: string) => {
|
||||
emit('cancel-upload', uploadId);
|
||||
};
|
||||
@@ -39,9 +47,14 @@ const handleCancel = (uploadId: string) => {
|
||||
<ul class="list-none p-0 m-0">
|
||||
<li v-for="upload in uploadList" :key="upload.id" class="mb-1.5 text-xs flex items-center flex-wrap gap-2">
|
||||
<span class="flex-grow truncate" :title="upload.filename">{{ upload.filename }} ({{ t(`fileManager.uploadStatus.${upload.status}`) }})</span>
|
||||
<progress v-if="(upload.status === 'uploading' && upload.progress < 100) || upload.status === 'pending'" :value="upload.progress" max="100" class="w-20 h-2 flex-shrink-0 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-gray-300 [&::-webkit-progress-value]:bg-blue-600 [&::-moz-progress-bar]:bg-blue-600"></progress>
|
||||
<span v-if="upload.status === 'uploading' && upload.progress < 100" class="text-xs flex-shrink-0"> {{ upload.progress }}%</span>
|
||||
<span v-if="upload.detail" class="basis-full text-[11px] text-text-secondary">{{ upload.detail }}</span>
|
||||
<progress v-if="showProgressBar(upload)" :value="upload.progress" max="100" class="w-20 h-2 flex-shrink-0 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-gray-300 [&::-webkit-progress-value]:bg-blue-600 [&::-moz-progress-bar]:bg-blue-600"></progress>
|
||||
<span v-if="showProgressValue(upload)" class="text-xs flex-shrink-0"> {{ upload.progress }}%</span>
|
||||
<span v-if="upload.status === 'error'" class="text-red-600 basis-full text-xs"> {{ t('fileManager.errors.generic') }}: {{ upload.error }}</span>
|
||||
<span v-if="upload.status === 'decompressing'" class="text-xs text-text-secondary flex items-center gap-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
{{ t('fileManager.uploadStatus.decompressing') }}
|
||||
</span>
|
||||
<span v-if="upload.status === 'success' || (upload.status === 'uploading' && upload.progress === 100)" class="text-green-600"> ✅</span>
|
||||
<span v-if="upload.status === 'cancelled'" class="text-red-600"> ❌ {{ t('fileManager.uploadStatus.cancelled') }}</span>
|
||||
<!-- 只有在可取消状态时显示取消按钮 -->
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface ContextMenuItem {
|
||||
disabled?: boolean;
|
||||
separator?: boolean; // 添加分隔符类型
|
||||
submenu?: ContextMenuItem[]; // 添加二级菜单支持
|
||||
icon?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
// 支持的压缩格式
|
||||
@@ -35,6 +37,7 @@ export interface UseFileManagerContextMenuOptions {
|
||||
// --- 回调函数 ---
|
||||
onRefresh: () => void;
|
||||
onUpload: () => void;
|
||||
onUploadFolder: () => void;
|
||||
onDownload: (items: FileListItem[]) => void; // 文件下载回调
|
||||
onDownloadDirectory: (item: FileListItem) => void; // +++ 文件夹下载回调 +++
|
||||
onDelete: () => void; // 删除操作现在由外部处理
|
||||
@@ -49,6 +52,9 @@ export interface UseFileManagerContextMenuOptions {
|
||||
onCompressRequest: (items: FileListItem[], format: CompressFormat) => void; // +++ 压缩回调 +++
|
||||
onDecompressRequest: (item: FileListItem) => void; // +++ 解压回调 +++
|
||||
onCopyPath?: (item: FileListItem) => void; // +++ 复制路径回调 +++
|
||||
onCopyFilename?: (item: FileListItem) => void;
|
||||
onSendCdToTerminal?: (item: FileListItem) => void;
|
||||
onOpenTerminalAtPath?: (item: FileListItem) => void;
|
||||
}
|
||||
|
||||
// 辅助函数:检查文件是否为支持的压缩格式
|
||||
@@ -71,6 +77,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
t,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
onUploadFolder,
|
||||
onDownload,
|
||||
onDelete,
|
||||
onRename,
|
||||
@@ -84,6 +91,9 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
onCompressRequest, // +++ 解构压缩回调 +++
|
||||
onDecompressRequest, // +++ 解构解压回调 +++
|
||||
onCopyPath, // +++ 解构复制路径回调 +++
|
||||
onCopyFilename,
|
||||
onSendCdToTerminal,
|
||||
onOpenTerminalAtPath,
|
||||
} = options;
|
||||
|
||||
const contextMenuVisible = ref(false);
|
||||
@@ -126,8 +136,8 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
|
||||
menu = [
|
||||
// 调整顺序:剪切、复制优先
|
||||
{ label: t('fileManager.actions.cut'), action: onCut, disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.actions.copy'), action: onCopy, disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.actions.cut'), action: onCut, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-scissors' },
|
||||
{ label: t('fileManager.actions.copy'), action: onCopy, disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-copy' },
|
||||
];
|
||||
|
||||
// --- 多选下载 ---
|
||||
@@ -135,7 +145,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
// 如果需要支持多选文件夹下载或混合下载,需要更复杂的逻辑和后端支持(例如打包成 zip)
|
||||
// 目前仅在 allFilesSelected 为 true 时启用多文件下载
|
||||
if (allFilesSelected) {
|
||||
menu.push({ label: t('fileManager.actions.downloadMultiple', { count: selectionSize }), action: () => onDownload(selectedFileItems), disabled: !(isConnected.value && isSftpReady.value) });
|
||||
menu.push({ label: t('fileManager.actions.downloadMultiple', { count: selectionSize }), action: () => onDownload(selectedFileItems), disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-download' });
|
||||
}
|
||||
|
||||
|
||||
@@ -143,10 +153,11 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
menu.push({
|
||||
label: t('fileManager.contextMenu.compress'),
|
||||
submenu: [
|
||||
{ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest(selectedFileItems, 'zip'), disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest(selectedFileItems, 'targz'), disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest(selectedFileItems, 'tarbz2'), disabled: !(isConnected.value && isSftpReady.value) }
|
||||
]
|
||||
{ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest(selectedFileItems, 'zip'), disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-file-archive' },
|
||||
{ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest(selectedFileItems, 'targz'), disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-file-archive' },
|
||||
{ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest(selectedFileItems, 'tarbz2'), disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-file-archive' }
|
||||
],
|
||||
icon: 'fas fa-box-archive'
|
||||
});
|
||||
menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator
|
||||
|
||||
@@ -154,103 +165,127 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
|
||||
menu.push(
|
||||
// --- 分隔符 (视觉) ---
|
||||
{ label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: onDelete, disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: onDelete, disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-trash-alt', danger: true },
|
||||
// --- 分隔符 (视觉) ---
|
||||
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value) }
|
||||
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-rotate-right' }
|
||||
);
|
||||
} else if (targetItem && targetItem.filename !== '..') {
|
||||
// Single item (not '..') menu
|
||||
menu = [];
|
||||
|
||||
// --- 修改:区分文件和文件夹下载 ---
|
||||
const canOperate = isConnected.value && isSftpReady.value;
|
||||
const canCompress = canOperate;
|
||||
const canDecompress = canOperate && targetItem.attrs.isFile && isSupportedArchive(targetItem.filename);
|
||||
|
||||
menu.push({ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canOperate, icon: 'fas fa-rotate-right' });
|
||||
menu.push({ label: '', action: () => {}, disabled: true, separator: true });
|
||||
menu.push({ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !canOperate, icon: 'far fa-file' });
|
||||
menu.push({ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !canOperate, icon: 'far fa-folder' });
|
||||
menu.push({ label: '', action: () => {}, disabled: true, separator: true });
|
||||
menu.push({ label: t('fileManager.actions.rename'), action: () => onRename(targetItem), disabled: !canOperate, icon: 'far fa-pen-to-square' });
|
||||
|
||||
if (targetItem.attrs.isFile) {
|
||||
menu.push({ label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload([targetItem]), disabled: !(isConnected.value && isSftpReady.value) }); // 文件下载
|
||||
menu.push({ label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload([targetItem]), disabled: !canOperate, icon: 'fas fa-download' });
|
||||
} else if (targetItem.attrs.isDirectory) {
|
||||
menu.push({ label: t('fileManager.actions.downloadFolder', { name: targetItem.filename }), action: () => onDownloadDirectory(targetItem), disabled: !(isConnected.value && isSftpReady.value) }); // 文件夹下载
|
||||
menu.push({ label: t('fileManager.actions.downloadFolder', { name: targetItem.filename }), action: () => onDownloadDirectory(targetItem), disabled: !canOperate, icon: 'fas fa-download' });
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 2. 剪切、复制、粘贴 (粘贴 - 如果是文件夹)
|
||||
menu.push({ label: t('fileManager.actions.cut'), action: onCut, disabled: !(isConnected.value && isSftpReady.value) });
|
||||
menu.push({ label: t('fileManager.actions.copy'), action: onCopy, disabled: !(isConnected.value && isSftpReady.value) });
|
||||
if (targetItem.attrs.isDirectory) {
|
||||
menu.push({ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent });
|
||||
}
|
||||
// +++ 添加复制路径菜单项 +++
|
||||
if (onCopyPath) {
|
||||
menu.push({ label: t('fileManager.actions.copyPath', 'Copy Path'), action: () => onCopyPath(targetItem), disabled: !(isConnected.value && isSftpReady.value) });
|
||||
}
|
||||
|
||||
// --- 分隔符 (视觉) ---
|
||||
// The invalid object literal was here and is now removed.
|
||||
// The separator below handles the division correctly.
|
||||
|
||||
// Ensure separator is pushed separately and correctly
|
||||
menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator
|
||||
|
||||
|
||||
// 3. 删除、重命名
|
||||
menu.push({ label: t('fileManager.actions.delete'), action: onDelete, disabled: !(isConnected.value && isSftpReady.value) });
|
||||
menu.push({ label: t('fileManager.actions.rename'), action: () => onRename(targetItem), disabled: !(isConnected.value && isSftpReady.value) });
|
||||
|
||||
// --- 分隔符 (视觉) ---
|
||||
// Ensure separator is pushed separately and correctly
|
||||
menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator
|
||||
|
||||
// --- 压缩 & 解压 ---
|
||||
const canCompress = isConnected.value && isSftpReady.value;
|
||||
const canDecompress = isConnected.value && isSftpReady.value && targetItem.attrs.isFile && isSupportedArchive(targetItem.filename);
|
||||
|
||||
// 添加压缩选项作为二级菜单
|
||||
menu.push({ label: t('fileManager.actions.changePermissions'), action: () => onChangePermissions(targetItem), disabled: !canOperate, icon: 'fas fa-lock' });
|
||||
menu.push({
|
||||
label: t('fileManager.contextMenu.compress'),
|
||||
label: t('fileManager.actions.terminalMenu', '终端'),
|
||||
icon: 'fas fa-terminal',
|
||||
submenu: [
|
||||
{ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest([targetItem], 'zip'), disabled: !canCompress },
|
||||
{ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest([targetItem], 'targz'), disabled: !canCompress },
|
||||
{ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest([targetItem], 'tarbz2'), disabled: !canCompress }
|
||||
]
|
||||
{
|
||||
label: t('fileManager.actions.cdToTerminalMenu', '执行 cd 命令到终端'),
|
||||
action: () => onSendCdToTerminal?.(targetItem),
|
||||
disabled: !canOperate || !onSendCdToTerminal,
|
||||
icon: 'fas fa-terminal',
|
||||
},
|
||||
{
|
||||
label: t('fileManager.actions.newTerminalAtPath', '新建终端到当前目录'),
|
||||
action: () => onOpenTerminalAtPath?.(targetItem),
|
||||
disabled: !canOperate || !onOpenTerminalAtPath,
|
||||
icon: 'far fa-square-plus',
|
||||
},
|
||||
],
|
||||
});
|
||||
menu.push({ label: '', action: () => {}, disabled: true, separator: true });
|
||||
|
||||
if (onCopyFilename) {
|
||||
menu.push({ label: t('fileManager.actions.copyFilename', '复制文件名'), action: () => onCopyFilename(targetItem), disabled: !canOperate, icon: 'far fa-copy' });
|
||||
}
|
||||
if (onCopyPath) {
|
||||
menu.push({ label: t('fileManager.actions.copyPath', 'Copy Path'), action: () => onCopyPath(targetItem), disabled: !canOperate, icon: 'far fa-copy' });
|
||||
}
|
||||
|
||||
menu.push({ label: t('fileManager.actions.delete'), action: onDelete, disabled: !canOperate, icon: 'far fa-trash-alt', danger: true });
|
||||
menu.push({
|
||||
label: t('fileManager.actions.uploadMenu', '上传'),
|
||||
icon: 'fas fa-upload',
|
||||
submenu: [
|
||||
{
|
||||
label: t('fileManager.actions.uploadFile', '上传文件'),
|
||||
action: onUpload,
|
||||
disabled: !canOperate,
|
||||
icon: 'fas fa-upload',
|
||||
},
|
||||
{
|
||||
label: t('fileManager.actions.uploadFolder', '上传文件夹'),
|
||||
action: onUploadFolder,
|
||||
disabled: !canOperate,
|
||||
icon: 'fas fa-folder-open',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 只有在支持解压的文件上才显示解压选项
|
||||
if (canDecompress) {
|
||||
menu.push({ label: t('fileManager.contextMenu.decompress'), action: () => onDecompressRequest(targetItem) });
|
||||
if (targetItem.attrs.isDirectory) {
|
||||
menu.push({ label: t('fileManager.actions.paste'), action: onPaste, disabled: !canOperate || !hasClipboardContent, icon: 'far fa-clipboard' });
|
||||
}
|
||||
menu.push({ label: t('fileManager.actions.copy'), action: onCopy, disabled: !canOperate, icon: 'far fa-copy' });
|
||||
menu.push({ label: t('fileManager.actions.cut'), action: onCut, disabled: !canOperate, icon: 'fas fa-scissors' });
|
||||
|
||||
if (canCompress) {
|
||||
menu.push({ label: '', action: () => {}, disabled: true, separator: true });
|
||||
menu.push({
|
||||
label: t('fileManager.contextMenu.compress'),
|
||||
submenu: [
|
||||
{ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest([targetItem], 'zip'), disabled: !canCompress, icon: 'far fa-file-archive' },
|
||||
{ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest([targetItem], 'targz'), disabled: !canCompress, icon: 'far fa-file-archive' },
|
||||
{ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest([targetItem], 'tarbz2'), disabled: !canCompress, icon: 'far fa-file-archive' }
|
||||
],
|
||||
icon: 'fas fa-box-archive'
|
||||
});
|
||||
}
|
||||
|
||||
// --- 分隔符 (视觉) ---
|
||||
menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator
|
||||
|
||||
// --- 分隔符 (视觉) ---
|
||||
|
||||
// 4. 新建、上传 (这些更像空白处操作,但保留)
|
||||
menu.push({ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !(isConnected.value && isSftpReady.value) });
|
||||
menu.push({ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !(isConnected.value && isSftpReady.value) });
|
||||
menu.push({ label: t('fileManager.actions.upload'), action: onUpload, disabled: !(isConnected.value && isSftpReady.value) }); // 上传放在新建之后
|
||||
|
||||
// --- 分隔符 (视觉) ---
|
||||
|
||||
// 5. 权限、刷新
|
||||
menu.push({ label: t('fileManager.actions.changePermissions'), action: () => onChangePermissions(targetItem), disabled: !(isConnected.value && isSftpReady.value) });
|
||||
menu.push({ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value) });
|
||||
if (canDecompress) {
|
||||
menu.push({ label: t('fileManager.contextMenu.decompress'), action: () => onDecompressRequest(targetItem), icon: 'fas fa-box-open' });
|
||||
}
|
||||
} else if (!targetItem) {
|
||||
// Right-click on empty space menu
|
||||
menu = [
|
||||
// 1. 粘贴
|
||||
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent },
|
||||
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent, icon: 'far fa-clipboard' },
|
||||
// --- 分隔符 (视觉) ---
|
||||
// 2. 新建、上传
|
||||
{ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.actions.upload'), action: onUpload, disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-folder' },
|
||||
{ label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !(isConnected.value && isSftpReady.value), icon: 'far fa-file' },
|
||||
{
|
||||
label: t('fileManager.actions.uploadMenu', '上传'),
|
||||
icon: 'fas fa-upload',
|
||||
submenu: [
|
||||
{ label: t('fileManager.actions.uploadFile', '上传文件'), action: onUpload, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-upload' },
|
||||
{ label: t('fileManager.actions.uploadFolder', '上传文件夹'), action: onUploadFolder, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-folder-open' },
|
||||
],
|
||||
},
|
||||
// --- 分隔符 (视觉) ---
|
||||
// 3. 刷新
|
||||
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value) },
|
||||
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-rotate-right' },
|
||||
];
|
||||
} else { // Clicked on '..'
|
||||
menu = [
|
||||
// +++ 粘贴 (可以粘贴到上级目录) +++
|
||||
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent },
|
||||
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value) }
|
||||
{ label: t('fileManager.actions.paste'), action: onPaste, disabled: !(isConnected.value && isSftpReady.value) || !hasClipboardContent, icon: 'far fa-clipboard' },
|
||||
{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !(isConnected.value && isSftpReady.value), icon: 'fas fa-rotate-right' }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -322,4 +357,4 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
showContextMenu,
|
||||
hideContextMenu,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export interface FolderArchiveResult {
|
||||
archiveFile: File;
|
||||
folderName: string;
|
||||
entryCount: number;
|
||||
}
|
||||
|
||||
const TEMP_ARCHIVE_SUFFIX = '__nexus_upload__.zip';
|
||||
|
||||
const sanitizeFileName = (name: string): string => {
|
||||
return name
|
||||
.trim()
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'folder';
|
||||
};
|
||||
|
||||
const getFolderNameFromRelativePath = (relativePath: string): string => {
|
||||
const [folderName] = relativePath.split('/').filter(Boolean);
|
||||
return sanitizeFileName(folderName || 'folder');
|
||||
};
|
||||
|
||||
export const createFolderArchive = async (
|
||||
selectedFiles: File[] | FileList,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<FolderArchiveResult> => {
|
||||
const files = Array.from(selectedFiles).filter((file) => {
|
||||
return Boolean(file.webkitRelativePath && file.webkitRelativePath.includes('/'));
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error('No folder files were selected.');
|
||||
}
|
||||
|
||||
const folderName = getFolderNameFromRelativePath(files[0].webkitRelativePath);
|
||||
const zip = new JSZip();
|
||||
|
||||
files.forEach((file) => {
|
||||
const relativePath = file.webkitRelativePath.replace(/\\/g, '/');
|
||||
zip.file(relativePath, file);
|
||||
});
|
||||
|
||||
const archiveBlob = await zip.generateAsync(
|
||||
{
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 },
|
||||
},
|
||||
(metadata) => {
|
||||
onProgress?.(Math.max(0, Math.min(100, Math.round(metadata.percent))));
|
||||
},
|
||||
);
|
||||
|
||||
const archiveFileName = `${folderName}${TEMP_ARCHIVE_SUFFIX}`;
|
||||
const archiveFile = new File([archiveBlob], archiveFileName, {
|
||||
type: 'application/zip',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
archiveFile,
|
||||
folderName,
|
||||
entryCount: files.length,
|
||||
};
|
||||
};
|
||||
@@ -3,9 +3,14 @@ import axios from 'axios';
|
||||
import pkg from '../../../package.json'; // 路径相对于当前文件
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const normalizeVersionLabel = (version: string) => {
|
||||
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
|
||||
return cleanVersion.replace(/^(\d+\.\d+)\.0$/, '$1');
|
||||
};
|
||||
|
||||
export function useAboutSection() {
|
||||
const { t } = useI18n();
|
||||
const appVersion = ref(pkg.version);
|
||||
const appVersion = ref(normalizeVersionLabel(pkg.version));
|
||||
|
||||
// --- Version Check State ---
|
||||
const latestVersion = ref<string | null>(null);
|
||||
@@ -18,12 +23,8 @@ export function useAboutSection() {
|
||||
// appVersion.value 通常不包含 'v'
|
||||
if (!latestVersion.value) return false;
|
||||
|
||||
const cleanLatestVersion = latestVersion.value.startsWith('v')
|
||||
? latestVersion.value.substring(1)
|
||||
: latestVersion.value;
|
||||
const cleanAppVersion = appVersion.value.startsWith('v')
|
||||
? appVersion.value.substring(1)
|
||||
: appVersion.value;
|
||||
const cleanLatestVersion = normalizeVersionLabel(latestVersion.value);
|
||||
const cleanAppVersion = appVersion.value;
|
||||
|
||||
// 进行版本比较,更健壮的比较可能需要拆分版本号进行数字比较
|
||||
// 此处简单比较字符串,对于 "1.0.10" > "1.0.9" 是有效的
|
||||
@@ -75,4 +76,4 @@ export function useAboutSection() {
|
||||
isUpdateAvailable,
|
||||
checkLatestVersion, // Expose if manual refresh is needed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,24 @@ import axios from 'axios';
|
||||
import pkg from '../../../package.json'; // 调整路径以正确导入 package.json
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const normalizeVersionLabel = (version: string) => {
|
||||
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
|
||||
return cleanVersion.replace(/^(\d+\.\d+)\.0$/, '$1');
|
||||
};
|
||||
|
||||
export function useVersionCheck() {
|
||||
const { t } = useI18n();
|
||||
const appVersion = ref(pkg.version);
|
||||
const appVersion = ref(normalizeVersionLabel(pkg.version));
|
||||
const latestVersion = ref<string | null>(null);
|
||||
const isCheckingVersion = ref(false);
|
||||
const versionCheckError = ref<string | null>(null);
|
||||
|
||||
const isUpdateAvailable = computed(() => {
|
||||
// 简单的字符串比较,假设 tag 格式为 vX.Y.Z
|
||||
return latestVersion.value && latestVersion.value !== `v${appVersion.value}`;
|
||||
if (!latestVersion.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizeVersionLabel(latestVersion.value) !== appVersion.value;
|
||||
});
|
||||
|
||||
const checkLatestVersion = async () => {
|
||||
@@ -48,4 +56,4 @@ export function useVersionCheck() {
|
||||
isUpdateAvailable,
|
||||
checkLatestVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ref, reactive, nextTick, onUnmounted, readonly, type Ref, watchEffect } from 'vue';
|
||||
import { createWebSocketConnectionManager } from './useWebSocketConnection';
|
||||
import { reactive, nextTick, onUnmounted, type Ref, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { FileListItem } from '../types/sftp.types';
|
||||
import type { UploadItem } from '../types/upload.types';
|
||||
import type { UploadItem, UploadTaskMode } from '../types/upload.types';
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
|
||||
|
||||
|
||||
@@ -27,10 +26,86 @@ export function useFileUploader(
|
||||
wsDeps: Ref<WebSocketDependencies>
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
wsDeps;
|
||||
|
||||
// 对 uploads 字典使用 reactive 以获得更好的深度响应性
|
||||
const uploads = reactive<Record<string, UploadItem>>({});
|
||||
const uploadHooks = new Map<string, { afterUpload?: (context: { uploadId: string; remotePath: string; item: UploadItem; }) => Promise<void> }>();
|
||||
|
||||
const cleanupUploadTask = (uploadId: string, delayMs = 0) => {
|
||||
const removeTask = () => {
|
||||
delete uploads[uploadId];
|
||||
uploadHooks.delete(uploadId);
|
||||
};
|
||||
|
||||
if (delayMs > 0) {
|
||||
setTimeout(removeTask, delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
removeTask();
|
||||
};
|
||||
|
||||
const getErrorMessage = (payload: MessagePayload, fallback: string): string => {
|
||||
if (typeof payload === 'string') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string') {
|
||||
return payload.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const getUploadId = (payload: MessagePayload, message: WebSocketMessage): string | undefined => {
|
||||
if (message.uploadId) {
|
||||
return message.uploadId;
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object' && 'uploadId' in payload && typeof payload.uploadId === 'string') {
|
||||
return payload.uploadId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const createUploadTask = (
|
||||
filename: string,
|
||||
initial: Partial<UploadItem> = {},
|
||||
): string => {
|
||||
const uploadId = generateUploadId();
|
||||
uploads[uploadId] = {
|
||||
id: uploadId,
|
||||
file: null,
|
||||
filename,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
mode: initial.mode ?? 'file',
|
||||
...initial,
|
||||
};
|
||||
return uploadId;
|
||||
};
|
||||
|
||||
const updateUploadTask = (uploadId: string, patch: Partial<UploadItem>) => {
|
||||
const upload = uploads[uploadId];
|
||||
if (!upload) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(upload, patch);
|
||||
};
|
||||
|
||||
const buildRemotePath = (file: File, relativePath?: string) => {
|
||||
let finalRemotePath: string;
|
||||
if (relativePath) {
|
||||
const basePath = currentPathRef.value.endsWith('/') ? currentPathRef.value : `${currentPathRef.value}/`;
|
||||
let cleanRelativePath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
||||
cleanRelativePath = cleanRelativePath.endsWith('/') ? cleanRelativePath.slice(0, -1) : cleanRelativePath;
|
||||
finalRemotePath = `${basePath}${cleanRelativePath ? `${cleanRelativePath}/` : ''}${file.name}`;
|
||||
} else {
|
||||
finalRemotePath = joinPath(currentPathRef.value, file.name);
|
||||
}
|
||||
|
||||
return finalRemotePath.replace(/\/+/g, '/');
|
||||
};
|
||||
|
||||
// --- 上传逻辑 ---
|
||||
|
||||
@@ -116,44 +191,50 @@ wsDeps;
|
||||
};
|
||||
|
||||
|
||||
const startFileUpload = (file: File, relativePath?: string) => {
|
||||
// Roo: 使用 .value 访问响应式的 sessionIdForLog
|
||||
const startFileUpload = (
|
||||
file: File,
|
||||
relativePath?: string,
|
||||
options?: {
|
||||
uploadId?: string;
|
||||
displayName?: string;
|
||||
mode?: UploadTaskMode;
|
||||
detail?: string;
|
||||
afterUpload?: (context: { uploadId: string; remotePath: string; item: UploadItem; }) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
if (!wsDeps.value.isConnected.value) {
|
||||
console.warn(`[FileUploader ${sessionIdForLog.value}] Cannot start upload: WebSocket not connected.`);
|
||||
|
||||
if (options?.uploadId && uploads[options.uploadId]) {
|
||||
updateUploadTask(options.uploadId, {
|
||||
status: 'error',
|
||||
error: t('fileManager.errors.uploadFailed'),
|
||||
});
|
||||
cleanupUploadTask(options.uploadId, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadId = generateUploadId();
|
||||
|
||||
let finalRemotePath: string;
|
||||
if (relativePath) {
|
||||
|
||||
const basePath = currentPathRef.value.endsWith('/') ? currentPathRef.value : `${currentPathRef.value}/`;
|
||||
// 确保 relativePath 开头没有斜杠,末尾有斜杠 (如果非空)
|
||||
let cleanRelativePath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
||||
// 移除末尾斜杠(如果有),因为文件名会加上
|
||||
cleanRelativePath = cleanRelativePath.endsWith('/') ? cleanRelativePath.slice(0, -1) : cleanRelativePath;
|
||||
// 拼接路径,确保 cleanRelativePath 和 file.name 之间只有一个斜杠
|
||||
finalRemotePath = `${basePath}${cleanRelativePath ? cleanRelativePath + '/' : ''}${file.name}`;
|
||||
} else {
|
||||
finalRemotePath = joinPath(currentPathRef.value, file.name); // 对于非文件夹上传,保持原样
|
||||
}
|
||||
// 规范化路径,移除多余的斜杠 e.g. /root//dir -> /root/dir
|
||||
finalRemotePath = finalRemotePath.replace(/\/+/g, '/');
|
||||
const uploadId = options?.uploadId ?? generateUploadId();
|
||||
const finalRemotePath = buildRemotePath(file, relativePath);
|
||||
console.log(`[FileUploader ${sessionIdForLog.value}] Calculated finalRemotePath: ${finalRemotePath} (current: ${currentPathRef.value}, relative: ${relativePath}, filename: ${file.name}) // wsDeps.isSftpReady: ${wsDeps.value.isSftpReady.value}`);
|
||||
// --- 结束修正 ---
|
||||
|
||||
|
||||
// 添加到响应式 uploads 字典
|
||||
uploads[uploadId] = {
|
||||
id: uploadId,
|
||||
file,
|
||||
filename: file.name,
|
||||
filename: options?.displayName ?? uploads[uploadId]?.filename ?? file.name,
|
||||
progress: 0,
|
||||
status: 'pending' // 初始状态
|
||||
status: 'pending',
|
||||
mode: options?.mode ?? uploads[uploadId]?.mode ?? 'file',
|
||||
remotePath: finalRemotePath,
|
||||
detail: options?.detail,
|
||||
};
|
||||
|
||||
if (options?.afterUpload) {
|
||||
uploadHooks.set(uploadId, { afterUpload: options.afterUpload });
|
||||
} else {
|
||||
uploadHooks.delete(uploadId);
|
||||
}
|
||||
|
||||
console.log(`[FileUploader ${sessionIdForLog.value}] Starting upload ${uploadId} to ${finalRemotePath}`);
|
||||
wsDeps.value.sendMessage({
|
||||
type: 'sftp:upload:start',
|
||||
@@ -172,23 +253,18 @@ wsDeps;
|
||||
wsDeps.value.sendMessage({ type: 'sftp:upload:cancel', payload: { uploadId } });
|
||||
}
|
||||
|
||||
// 短暂延迟后从列表中移除,以显示取消状态
|
||||
setTimeout(() => {
|
||||
if (uploads[uploadId]?.status === 'cancelled') {
|
||||
delete uploads[uploadId];
|
||||
}
|
||||
}, 3000);
|
||||
cleanupUploadTask(uploadId, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 消息处理器 ---
|
||||
|
||||
const onUploadReady = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
const uploadId = getUploadId(payload, message);
|
||||
if (!uploadId) return;
|
||||
|
||||
const upload = uploads[uploadId];
|
||||
if (upload && upload.status === 'pending') {
|
||||
if (upload && upload.status === 'pending' && upload.file) {
|
||||
console.log(`[FileUploader ${sessionIdForLog.value}] Upload ${uploadId} ready, starting chunk sending.`);
|
||||
upload.status = 'uploading';
|
||||
sendFileChunks(uploadId, upload.file); // 开始发送块
|
||||
@@ -197,22 +273,32 @@ wsDeps;
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
const onUploadSuccess = async (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = getUploadId(payload, message);
|
||||
if (!uploadId) return;
|
||||
|
||||
const upload = uploads[uploadId];
|
||||
if (upload) {
|
||||
const remotePath = message.path || upload.remotePath;
|
||||
console.log(`[FileUploader ${sessionIdForLog.value}] Upload ${uploadId} successful.`);
|
||||
upload.status = 'success';
|
||||
upload.progress = 100;
|
||||
|
||||
|
||||
// 立即删除记录
|
||||
if (uploads[uploadId]) { // 确保记录仍然存在
|
||||
delete uploads[uploadId];
|
||||
const hook = uploadHooks.get(uploadId);
|
||||
if (hook?.afterUpload && remotePath) {
|
||||
upload.status = 'decompressing';
|
||||
try {
|
||||
await hook.afterUpload({ uploadId, remotePath, item: upload });
|
||||
upload.status = 'success';
|
||||
cleanupUploadTask(uploadId, 1200);
|
||||
} catch (error: any) {
|
||||
upload.status = 'error';
|
||||
upload.error = error?.message || t('fileManager.errors.decompressFailed');
|
||||
cleanupUploadTask(uploadId, 5000);
|
||||
}
|
||||
} else {
|
||||
upload.status = 'success';
|
||||
cleanupUploadTask(uploadId);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.warn(`[FileUploader ${sessionIdForLog.value}] Received upload:success for unknown upload ID: ${uploadId}`);
|
||||
}
|
||||
@@ -228,24 +314,18 @@ wsDeps;
|
||||
|
||||
const upload = uploads[uploadId];
|
||||
if (upload) {
|
||||
const errorMessage = typeof payload === 'string' ? payload : t('fileManager.errors.uploadFailed');
|
||||
const errorMessage = getErrorMessage(payload, t('fileManager.errors.uploadFailed'));
|
||||
console.error(`[FileUploader ${sessionIdForLog.value}] Upload ${uploadId} error:`, errorMessage);
|
||||
upload.status = 'error';
|
||||
upload.error = errorMessage; // 使用 payload 作为错误消息
|
||||
|
||||
// 让错误消息可见时间长一些
|
||||
setTimeout(() => {
|
||||
if (uploads[uploadId]?.status === 'error') {
|
||||
delete uploads[uploadId];
|
||||
}
|
||||
}, 5000);
|
||||
upload.error = errorMessage;
|
||||
cleanupUploadTask(uploadId, 5000);
|
||||
} else {
|
||||
console.warn(`[FileUploader ${sessionIdForLog.value}] Received upload:error for unknown upload ID: ${uploadId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadPause = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
const uploadId = getUploadId(payload, message);
|
||||
if (!uploadId) return;
|
||||
const upload = uploads[uploadId];
|
||||
if (upload && upload.status === 'uploading') {
|
||||
@@ -255,10 +335,10 @@ wsDeps;
|
||||
};
|
||||
|
||||
const onUploadResume = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
const uploadId = getUploadId(payload, message);
|
||||
if (!uploadId) return;
|
||||
const upload = uploads[uploadId];
|
||||
if (upload && upload.status === 'paused') {
|
||||
if (upload && upload.status === 'paused' && upload.file) {
|
||||
console.log(`[FileUploader ${sessionIdForLog.value}] Resuming upload ${uploadId}`);
|
||||
upload.status = 'uploading';
|
||||
sendFileChunks(uploadId, upload.file);
|
||||
@@ -266,7 +346,7 @@ wsDeps;
|
||||
};
|
||||
|
||||
const onUploadCancelled = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId;
|
||||
const uploadId = getUploadId(payload, message);
|
||||
if (!uploadId) return;
|
||||
const upload = uploads[uploadId];
|
||||
if (upload) {
|
||||
@@ -277,7 +357,7 @@ wsDeps;
|
||||
// 确保它会被移除(如果尚未计划移除)
|
||||
setTimeout(() => {
|
||||
if (uploads[uploadId]?.status === 'cancelled') {
|
||||
delete uploads[uploadId];
|
||||
cleanupUploadTask(uploadId);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
@@ -285,7 +365,7 @@ wsDeps;
|
||||
|
||||
// +++ 处理上传进度更新 +++
|
||||
const onUploadProgress = (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
const uploadId = message.uploadId || payload?.uploadId; // 从顶层获取 uploadId
|
||||
const uploadId = getUploadId(payload, message);
|
||||
if (!uploadId) {
|
||||
return;
|
||||
}
|
||||
@@ -349,5 +429,8 @@ wsDeps;
|
||||
uploads,
|
||||
startFileUpload,
|
||||
cancelUpload,
|
||||
createUploadTask,
|
||||
updateUploadTask,
|
||||
cleanupUploadTask,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface SftpManagerInstance {
|
||||
moveItems: (sourcePaths: string[], destinationDir: string) => void;
|
||||
compressItems: (items: FileListItem[], format: 'zip' | 'targz' | 'tarbz2') => Promise<void>; // Assume async
|
||||
decompressItem: (item: FileListItem) => Promise<void>; // Assume async
|
||||
decompressPath: (archivePath: string, displayName?: string) => Promise<void>;
|
||||
unlinkPath: (targetPath: string) => Promise<void>;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
setInitialLoadDone: (value: boolean) => void;
|
||||
|
||||
@@ -56,6 +58,22 @@ const joinPath = (base: string, name: string): string => {
|
||||
return base.endsWith('/') ? `${base}${name}` : `${base}/${name}`;
|
||||
};
|
||||
|
||||
const dirname = (targetPath: string): string => {
|
||||
const normalized = targetPath.replace(/\/+$/, '') || '/';
|
||||
if (normalized === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const lastSlashIndex = normalized.lastIndexOf('/');
|
||||
return lastSlashIndex <= 0 ? '/' : normalized.substring(0, lastSlashIndex);
|
||||
};
|
||||
|
||||
const basename = (targetPath: string): string => {
|
||||
const normalized = targetPath.replace(/\/+$/, '');
|
||||
const lastSlashIndex = normalized.lastIndexOf('/');
|
||||
return lastSlashIndex >= 0 ? normalized.substring(lastSlashIndex + 1) : normalized;
|
||||
};
|
||||
|
||||
// Helper function
|
||||
const sortFiles = (a: FileListItem, b: FileListItem): number => {
|
||||
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
|
||||
@@ -558,17 +576,18 @@ export function createSftpActionsManager(
|
||||
});
|
||||
};
|
||||
|
||||
const decompressItem = (item: FileListItem): Promise<void> => {
|
||||
const decompressPath = (archivePath: string, displayName?: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isSftpReady.value) {
|
||||
const errMsg = t('fileManager.errors.sftpNotReady');
|
||||
uiNotificationsStore.showError(errMsg);
|
||||
console.warn(`[SFTP ${instanceSessionId}] 尝试解压项目 ${item.filename} 但 SFTP 未就绪。`);
|
||||
console.warn(`[SFTP ${instanceSessionId}] 尝试解压项目 ${archivePath} 但 SFTP 未就绪。`);
|
||||
return reject(new Error(errMsg));
|
||||
}
|
||||
const sourcePath = joinPath(currentPathRef.value, item.filename);
|
||||
const destinationDir = currentPathRef.value; // 默认解压到当前目录
|
||||
const sourcePath = archivePath;
|
||||
const destinationDir = dirname(archivePath);
|
||||
const requestId = generateRequestId();
|
||||
const successName = displayName || basename(archivePath);
|
||||
|
||||
let unregisterSuccess: (() => void) | null = null;
|
||||
let unregisterError: (() => void) | null = null;
|
||||
@@ -586,8 +605,8 @@ export function createSftpActionsManager(
|
||||
clearTimeout(timeoutId);
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
uiNotificationsStore.showSuccess(t('fileManager.notifications.decompressSuccess', { name: item.filename })); // 使用 i18n
|
||||
loadDirectory(currentPathRef.value, true); // 强制刷新当前目录
|
||||
uiNotificationsStore.showSuccess(t('fileManager.notifications.decompressSuccess', { name: successName })); // 使用 i18n
|
||||
loadDirectory(destinationDir, true); // 强制刷新当前目录
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
@@ -613,6 +632,57 @@ export function createSftpActionsManager(
|
||||
});
|
||||
};
|
||||
|
||||
const decompressItem = (item: FileListItem): Promise<void> => {
|
||||
const sourcePath = joinPath(currentPathRef.value, item.filename);
|
||||
return decompressPath(sourcePath, item.filename);
|
||||
};
|
||||
|
||||
const unlinkPath = (targetPath: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isSftpReady.value) {
|
||||
const errMsg = t('fileManager.errors.sftpNotReady');
|
||||
uiNotificationsStore.showError(errMsg);
|
||||
console.warn(`[SFTP ${instanceSessionId}] 尝试删除路径 ${targetPath} 但 SFTP 未就绪。`);
|
||||
return reject(new Error(errMsg));
|
||||
}
|
||||
|
||||
const requestId = generateRequestId();
|
||||
let unregisterSuccess: (() => void) | null = null;
|
||||
let unregisterError: (() => void) | null = null;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
reject(new Error(t('fileManager.errors.deleteFailed')));
|
||||
}, 20000);
|
||||
|
||||
unregisterSuccess = onMessage('sftp:unlink:success', (_payload: MessagePayload, message: WebSocketMessage) => {
|
||||
if (message.requestId === requestId && message.path === targetPath) {
|
||||
clearTimeout(timeoutId);
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
unregisterError = onMessage('sftp:unlink:error', (payload: MessagePayload, message: WebSocketMessage) => {
|
||||
if (message.requestId === requestId && message.path === targetPath) {
|
||||
clearTimeout(timeoutId);
|
||||
unregisterSuccess?.();
|
||||
unregisterError?.();
|
||||
const errorMsg = (payload as string) || t('fileManager.errors.deleteFailed');
|
||||
reject(new Error(errorMsg));
|
||||
}
|
||||
});
|
||||
|
||||
sendMessage({
|
||||
type: 'sftp:unlink',
|
||||
requestId,
|
||||
payload: { path: targetPath },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// --- Message Handlers ---
|
||||
|
||||
@@ -1170,10 +1240,12 @@ export function createSftpActionsManager(
|
||||
changePermissions,
|
||||
readFile,
|
||||
writeFile,
|
||||
copyItems, // +++ 暴露 copyItems +++
|
||||
copyItems, // +++ 暴露 copyItems +++
|
||||
moveItems, // +++ 暴露 moveItems +++
|
||||
compressItems, // +++ 暴露 compressItems +++
|
||||
decompressItem, // +++ 暴露 decompressItem +++
|
||||
decompressPath,
|
||||
unlinkPath,
|
||||
joinPath, // 暴露辅助函数
|
||||
// clearSftpError, // 移除 clearSftpError
|
||||
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"refresh": "Refresh",
|
||||
"parentDirectory": "Parent Directory",
|
||||
"uploadFile": "Upload File",
|
||||
"uploadFolder": "Upload Folder",
|
||||
"upload": "Upload",
|
||||
"newFolder": "New Folder",
|
||||
"rename": "Rename",
|
||||
@@ -463,11 +464,16 @@
|
||||
"closeTab": "Close Tab",
|
||||
"closeEditor": "Close Editor",
|
||||
"cdToTerminal": "Change terminal directory to current path",
|
||||
"cdToTerminalMenu": "Run cd in terminal",
|
||||
"copy": "Copy",
|
||||
"copyFilename": "Copy Filename",
|
||||
"cut": "Cut",
|
||||
"paste": "Paste",
|
||||
"openEditor": "Open Editor",
|
||||
"copyPath": "Copy Path"
|
||||
"copyPath": "Copy Absolute Path",
|
||||
"terminalMenu": "Terminal",
|
||||
"newTerminalAtPath": "Open New Terminal Here",
|
||||
"uploadMenu": "Upload"
|
||||
},
|
||||
"contextMenu": {
|
||||
"compress": "Compress",
|
||||
@@ -485,7 +491,9 @@
|
||||
"modified": "Modified"
|
||||
},
|
||||
"uploadStatus": {
|
||||
"compressing": "Compressing",
|
||||
"cancelled": "Cancelled",
|
||||
"decompressing": "Extracting",
|
||||
"pending": "Pending",
|
||||
"uploading": "Uploading"
|
||||
},
|
||||
@@ -506,6 +514,7 @@
|
||||
"loadDirectoryFailed": "Failed to load directory",
|
||||
"copyFailed": "Copy failed",
|
||||
"moveFailed": "Move failed",
|
||||
"uploadFailed": "Upload failed",
|
||||
"sftpNotReady": "SFTP session not ready",
|
||||
"sftpManagerNotFound": "SFTP manager not found",
|
||||
"noActiveSession": "No active session found",
|
||||
@@ -522,7 +531,10 @@
|
||||
"commandNotFoundCompress": "Command '{command}' not found on server, cannot complete compression.",
|
||||
"commandNotFoundDecompress": "Command '{command}' not found on server, cannot complete decompression.",
|
||||
"genericCommandNotFound": "Command '{command}' not found on server, cannot complete '{operation}' operation.",
|
||||
"copyPathFailed": "Failed to copy path"
|
||||
"folderCompressionFailed": "Failed to compress the selected folder",
|
||||
"archiveCleanupFailed": "Failed to remove temporary archive {name} automatically. Please delete it manually.",
|
||||
"copyPathFailed": "Failed to copy path",
|
||||
"copyFilenameFailed": "Failed to copy filename"
|
||||
},
|
||||
"notifications": {
|
||||
"copySuccess": "Copy successful",
|
||||
@@ -530,7 +542,12 @@
|
||||
"cdCommandSent": "CD command sent to terminal",
|
||||
"compressSuccess": "Compressed {name} successfully",
|
||||
"decompressSuccess": "Decompressed {name} successfully",
|
||||
"pathCopied": "Path copied to clipboard"
|
||||
"folderArchiveQueued": "{count} files selected. Preparing archive upload.",
|
||||
"folderArchivePreparing": "Compressing {count} files",
|
||||
"folderArchiveReady": "Archive ready. {count} files prepared for upload.",
|
||||
"folderArchiveUploading": "Uploading archive and preparing automatic extraction",
|
||||
"pathCopied": "Path copied to clipboard",
|
||||
"filenameCopied": "Filename copied to clipboard"
|
||||
},
|
||||
"warnings": {
|
||||
"moveSameDirectory": "Cannot cut and paste in the same directory."
|
||||
|
||||
@@ -439,6 +439,7 @@
|
||||
"closeEditor": "エディターを閉じる",
|
||||
"closeTab": "タブを閉じる",
|
||||
"copy": "コピー",
|
||||
"copyFilename": "ファイル名をコピー",
|
||||
"cut": "切り取り",
|
||||
"delete": "削除",
|
||||
"deleteMultiple": "{count} 個の項目を削除",
|
||||
@@ -455,7 +456,12 @@
|
||||
"save": "保存",
|
||||
"upload": "アップロード",
|
||||
"uploadFile": "ファイルをアップロード",
|
||||
"copyPath": "パスをコピー"
|
||||
"uploadFolder": "フォルダをアップロード",
|
||||
"copyPath": "絶対パスをコピー",
|
||||
"cdToTerminalMenu": "cd コマンドをターミナルで実行",
|
||||
"terminalMenu": "ターミナル",
|
||||
"newTerminalAtPath": "このディレクトリで新しいターミナルを開く",
|
||||
"uploadMenu": "アップロード"
|
||||
},
|
||||
"contextMenu": {
|
||||
"compress": "圧縮",
|
||||
@@ -484,6 +490,7 @@
|
||||
"loadDirectoryFailed": "ディレクトリの読み込みに失敗しました",
|
||||
"missingConnectionId": "現在の接続 ID を取得できません",
|
||||
"moveFailed": "移動に失敗しました",
|
||||
"uploadFailed": "アップロードに失敗しました",
|
||||
"noActiveSession": "アクティブなセッションが見つかりません",
|
||||
"readFileError": "ファイルの読み取り中にエラーが発生しました",
|
||||
"readFileFailed": "ファイルの読み取りに失敗しました",
|
||||
@@ -503,7 +510,10 @@
|
||||
"commandNotFoundCompress": "サーバーにコマンド '{command}' が見つからないため、圧縮操作を完了できません。",
|
||||
"commandNotFoundDecompress": "サーバーにコマンド '{command}' が見つからないため、解凍操作を完了できません。",
|
||||
"genericCommandNotFound": "サーバーにコマンド '{command}' が見つからないため、'{operation}' 操作を完了できません。",
|
||||
"copyPathFailed": "パスのコピーに失敗しました"
|
||||
"folderCompressionFailed": "選択したフォルダの圧縮に失敗しました",
|
||||
"archiveCleanupFailed": "一時アーカイブ {name} の自動削除に失敗しました。手動で削除してください。",
|
||||
"copyPathFailed": "パスのコピーに失敗しました",
|
||||
"copyFilenameFailed": "ファイル名のコピーに失敗しました"
|
||||
},
|
||||
"headers": {
|
||||
"modified": "変更日",
|
||||
@@ -521,7 +531,12 @@
|
||||
"moveSuccess": "移動に成功しました",
|
||||
"compressSuccess": "{name} を正常に圧縮しました",
|
||||
"decompressSuccess": "{name} を正常に解凍しました",
|
||||
"pathCopied": "パスがクリップボードにコピーされました"
|
||||
"folderArchiveQueued": "{count} 件のファイルを選択しました。圧縮アップロードを準備しています。",
|
||||
"folderArchivePreparing": "{count} 件のファイルを圧縮中",
|
||||
"folderArchiveReady": "圧縮が完了しました。{count} 件のファイルをアップロードできます。",
|
||||
"folderArchiveUploading": "アーカイブをアップロードし、自動展開を準備しています",
|
||||
"pathCopied": "パスがクリップボードにコピーされました",
|
||||
"filenameCopied": "ファイル名がクリップボードにコピーされました"
|
||||
},
|
||||
"prompts": {
|
||||
"confirmDeleteFile": "ファイル \"{name}\" を削除しますか?この操作は元に戻せません。",
|
||||
@@ -539,7 +554,9 @@
|
||||
"searchPlaceholder": "ファイルを検索...",
|
||||
"selectFileToEdit": "ファイルマネージャーから編集するファイルを選択してください。",
|
||||
"uploadStatus": {
|
||||
"compressing": "圧縮中",
|
||||
"cancelled": "キャンセルされました",
|
||||
"decompressing": "展開中",
|
||||
"pending": "待機中",
|
||||
"uploading": "アップロード中"
|
||||
},
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"refresh": "刷新",
|
||||
"parentDirectory": "上一级",
|
||||
"uploadFile": "上传文件",
|
||||
"uploadFolder": "上传文件夹",
|
||||
"upload": "上传",
|
||||
"newFolder": "新建文件夹",
|
||||
"newFile": "新建文件",
|
||||
@@ -463,11 +464,16 @@
|
||||
"closeTab": "关闭标签页",
|
||||
"closeEditor": "关闭编辑器",
|
||||
"cdToTerminal": "将终端目录切换到当前路径",
|
||||
"cdToTerminalMenu": "执行 cd 命令到终端",
|
||||
"copy": "复制",
|
||||
"copyFilename": "复制文件名",
|
||||
"cut": "剪切",
|
||||
"paste": "粘贴",
|
||||
"openEditor": "打开编辑器",
|
||||
"copyPath": "复制路径"
|
||||
"copyPath": "复制绝对路径",
|
||||
"terminalMenu": "终端",
|
||||
"newTerminalAtPath": "新建终端到当前目录",
|
||||
"uploadMenu": "上传"
|
||||
},
|
||||
"contextMenu": {
|
||||
"compress": "压缩",
|
||||
@@ -485,7 +491,9 @@
|
||||
"modified": "修改时间"
|
||||
},
|
||||
"uploadStatus": {
|
||||
"compressing": "压缩中",
|
||||
"cancelled": "已取消",
|
||||
"decompressing": "解压中",
|
||||
"pending": "等待中",
|
||||
"uploading": "上传中"
|
||||
},
|
||||
@@ -506,6 +514,7 @@
|
||||
"loadDirectoryFailed": "加载目录失败",
|
||||
"copyFailed": "复制失败",
|
||||
"moveFailed": "移动失败",
|
||||
"uploadFailed": "上传失败",
|
||||
"sftpNotReady": "SFTP 会话未就绪",
|
||||
"sftpManagerNotFound": "SFTP 管理器未找到",
|
||||
"noActiveSession": "未找到活动会话",
|
||||
@@ -522,7 +531,10 @@
|
||||
"commandNotFoundCompress": "服务器上缺少 '{command}' 命令,无法完成压缩操作。",
|
||||
"commandNotFoundDecompress": "服务器上缺少 '{command}' 命令,无法完成解压操作。",
|
||||
"genericCommandNotFound": "服务器上缺少 '{command}' 命令,无法完成 '{operation}' 操作。",
|
||||
"copyPathFailed": "复制路径失败"
|
||||
"folderCompressionFailed": "文件夹压缩失败",
|
||||
"archiveCleanupFailed": "自动清理临时压缩包 {name} 失败,请手动删除。",
|
||||
"copyPathFailed": "复制路径失败",
|
||||
"copyFilenameFailed": "复制文件名失败"
|
||||
},
|
||||
"notifications": {
|
||||
"copySuccess": "复制成功",
|
||||
@@ -530,7 +542,12 @@
|
||||
"cdCommandSent": "CD 命令已发送到终端",
|
||||
"compressSuccess": "压缩 {name} 成功",
|
||||
"decompressSuccess": "解压 {name} 成功",
|
||||
"pathCopied": "路径已复制到剪贴板"
|
||||
"folderArchiveQueued": "已选择 {count} 个文件,准备压缩上传",
|
||||
"folderArchivePreparing": "正在压缩 {count} 个文件",
|
||||
"folderArchiveReady": "压缩完成,准备上传 {count} 个文件",
|
||||
"folderArchiveUploading": "正在上传压缩包并准备自动解压",
|
||||
"pathCopied": "路径已复制到剪贴板",
|
||||
"filenameCopied": "文件名已复制到剪贴板"
|
||||
},
|
||||
"warnings": {
|
||||
"moveSameDirectory": "不能在同一目录下剪切和粘贴。"
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
export type UploadTaskMode = 'file' | 'folder-archive';
|
||||
|
||||
export type UploadStatus =
|
||||
| 'compressing'
|
||||
| 'pending'
|
||||
| 'uploading'
|
||||
| 'decompressing'
|
||||
| 'paused'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'cancelled';
|
||||
|
||||
// 类型定义:用于文件上传任务
|
||||
export interface UploadItem {
|
||||
id: string; // 上传任务的唯一标识符
|
||||
file: File; // 要上传的文件对象
|
||||
filename: string; // 文件名
|
||||
progress: number; // 上传进度 (0-100)
|
||||
file: File | null; // 要上传的文件对象;本地预处理阶段可为空
|
||||
filename: string; // 展示给用户的名称
|
||||
progress: number; // 上传/压缩进度 (0-100)
|
||||
error?: string; // 错误信息
|
||||
status: 'pending' | 'uploading' | 'paused' | 'success' | 'error' | 'cancelled'; // 上传状态
|
||||
status: UploadStatus; // 上传状态
|
||||
mode?: UploadTaskMode;
|
||||
remotePath?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
// 可以根据需要添加其他与上传相关的类型
|
||||
|
||||
Reference in New Issue
Block a user