feat(frontend): 增强文件管理器上传与右键菜单

新增“上传文件夹”入口,选择目录后先在浏览器端打包为 zip,
上传完成后自动触发远端解压并尝试清理临时压缩包。
同时重排文件右键菜单,补齐终端子菜单、复制文件名与绝对路径等操作,
并扩展上传任务状态展示。

同步前后端包版本到 1.0.0,并将设置页版本显示规范为 1.0
This commit is contained in:
yinjianm
2026-03-26 02:56:19 +08:00
parent dcdc8deab8
commit 3d26bffc99
26 changed files with 1142 additions and 198 deletions
+10
View File
@@ -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
@@ -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"}
@@ -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` 选择结果限制,不会被单独打包上传。
+4
View File
@@ -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/) - 将工作台文件区调整为多根目录常驻的文件夹总览,不再点击目录后切成单独文件表格
+1 -1
View File
@@ -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 文件。
### 仪表盘总览
+72 -2
View File
@@ -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",
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nexus-terminal/backend",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"scripts": {
+2 -1
View File
@@ -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
+20 -3
View File
@@ -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."
+20 -3
View File
@@ -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": "アップロード中"
},
+20 -3
View File
@@ -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": "不能在同一目录下剪切和粘贴。"
+19 -6
View File
@@ -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;
}
// 可以根据需要添加其他与上传相关的类型