fix(frontend): 修复文件管理器删除与上传稳定性
补齐文件管理器右键子菜单点击展开,新增拖拽上传目标确认, 并在上传完成后自动刷新当前可见目录 目录删除改为区分仅删空目录与强制递归删除,删除后自动回退 失效路径,避免文件树持续报 No such file 同步后端 sftp:rmdir 的 recursive 分支,并将关于页与版本检查 默认仓库链接切换到 Micah123321/nexus-terminal
This commit is contained in:
@@ -8,6 +8,12 @@
|
||||
- 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。
|
||||
|
||||
### 修复
|
||||
- **[frontend]**: 修复文件管理器右键子菜单点击无反应、拖拽上传目标不明确,以及目录删除后持续报 `No such file` 的稳定性问题 — by yinjianm
|
||||
- 方案: [202603260324_file-manager-delete-upload-stability](archive/2026-03/202603260324_file-manager-delete-upload-stability/)
|
||||
- **[backend]**: 为 `sftp:rmdir` 增加 `recursive` 分支,让“仅删空目录”和“强制递归删除”分别落到 SFTP 原生删除与 `rm -rf` 链路 — by yinjianm
|
||||
- 方案: [202603260324_file-manager-delete-upload-stability](archive/2026-03/202603260324_file-manager-delete-upload-stability/)
|
||||
- **[frontend]**: 修正新终端会话下文件树只保留当前路径链路的问题,改为先加载 `/` 根树再串行加载当前工作目录,确保 `/` 下同级目录可见 — by yinjianm
|
||||
- 方案: [202603260310_file-manager-root-sibling-bootstrap](archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/)
|
||||
- **[frontend]**: 重排文件区右键菜单结构,补齐终端子菜单、复制文件名和复制绝对路径等动作 — by yinjianm
|
||||
- 方案: [202603260228_file-context-menu-terminal-actions](archive/2026-03/202603260228_file-context-menu-terminal-actions/)
|
||||
- **[frontend]**: 将工作台文件区继续收敛为固定 `/` 根节点的单栏资源管理器树,并在树内同时显示目录与文件 — by yinjianm
|
||||
@@ -26,6 +32,9 @@
|
||||
- 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/)
|
||||
|
||||
### 快速修改
|
||||
- **[frontend]**: 将前端关于页、版本检查和样式仓库默认链接切换到 `Micah123321/nexus-terminal`,并移除 Ko-fi 入口 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: packages/frontend/src/composables/settings/useVersionCheck.ts, packages/frontend/src/composables/settings/useAboutSection.ts, packages/frontend/src/components/settings/AboutSection.vue, packages/frontend/src/App.vue, packages/frontend/src/components/style-customizer/StyleCustomizerBackgroundTab.vue, package.json
|
||||
- **[frontend]**: 修正外部拖拽上传的落点路径判定,拖到哪个目录就上传到哪个目录,拖拽目录仍沿用先压缩再上传 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: packages/frontend/src/components/FileManager.vue, packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts, packages/frontend/src/composables/file-manager/useFolderArchiveUpload.ts, packages/frontend/src/composables/useFileUploader.ts
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Nexus Terminal 知识库
|
||||
# Nexus Terminal 知识库
|
||||
|
||||
> 本文件是知识库的入口点
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
```yaml
|
||||
kb_version: 2.3.7
|
||||
最后更新: 2026-03-26 02:00
|
||||
最后更新: 2026-03-26 03:15
|
||||
模块数量: 4
|
||||
待执行方案: 4
|
||||
```
|
||||
@@ -52,3 +52,4 @@ kb_version: 2.3.7
|
||||
- 需要历史决策: 搜索 CHANGELOG.md → 读取对应 archive/{YYYY-MM}/{方案包}/proposal.md
|
||||
- 继续之前任务: 读取 plan/{方案包}/*
|
||||
```
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":3,"failed":0,"pending":0,"total":3,"done":3,"percent":100,"current":"已完成:修复新会话下根目录同级树补全","updated_at":"2026-03-26 03:15:00"}
|
||||
+2
-1
@@ -5,8 +5,9 @@
|
||||
类型: 修复
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 草稿
|
||||
状态: 已完成
|
||||
创建: 2026-03-26
|
||||
完成: 2026-03-26
|
||||
```
|
||||
|
||||
---
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# 任务清单: file-manager-root-sibling-bootstrap
|
||||
|
||||
```yaml
|
||||
@feature: file-manager-root-sibling-bootstrap
|
||||
@created: 2026-03-26
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 3 | 0 | 0 | 3 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 根目录同级补全修复
|
||||
|
||||
- [√] 1.1 在 `packages/frontend/src/composables/useSftpActions.ts` 中定位新会话初始化下 `/` 与当前目录并发加载的竞态点 | depends_on: []
|
||||
- [√] 1.2 在 `packages/frontend/src/composables/useSftpActions.ts` 中实现“先加载 `/`,再串行加载当前目录”的根树补全逻辑,避免 currentPath 被改回 `/` | depends_on: [1.1]
|
||||
|
||||
### 2. 验证与同步
|
||||
|
||||
- [√] 2.1 执行 `packages/frontend` 的构建验证,并同步知识库记录 | depends_on: [1.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-26 03:10 | DESIGN | completed | 已确认问题集中在 `/` 根树补全而非当前工作目录切换,修复以最小协议变更为主 |
|
||||
| 2026-03-26 03:13 | 1.1 / 1.2 | 完成 | `useSftpActions.ts` 已改为根目录优先加载,`/` 完成后再串行加载当前工作目录 |
|
||||
| 2026-03-26 03:15 | 2.1 | 完成 | `packages/frontend` 执行 `npm run build` 通过,仅保留既有 Vite 动态导入与 chunk 警告 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
> 当前环境缺少可直接调用的 `python/py`,方案包通过模板降级方式手工创建。
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"done":5,"percent":100,"current":"文件管理删除、上传和菜单稳定性修复已完成","updated_at":"2026-03-26 04:14:00"}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# 变更提案: file-manager-delete-upload-stability
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 修复增强
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已完成
|
||||
状态说明: 已完成子菜单点击展开、拖拽上传目标确认、目录删除模式区分与删除后无效路径自动回退
|
||||
创建: 2026-03-26
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
文件管理器近期完成了资源管理器树与右键菜单改造,但当前仍存在一组影响可用性的连锁问题:终端/上传/压缩子菜单点击无反应,上传后没有自动刷新且拖拽上传没有目标确认,目录删除缺少“空目录删除 / 强制递归删除”的明确选项,并且删除后树会持续触发 `读取目录失败: No such file`。
|
||||
|
||||
### 目标
|
||||
- 修复文件右键菜单的子菜单交互。
|
||||
- 上传后自动刷新,并在拖拽上传时明确目标目录。
|
||||
- 目录删除提供“仅删除空目录”和“强制递归删除”二次确认。
|
||||
- 删除后不再持续触发已不存在路径的目录加载错误。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
范围约束: 优先收敛在 FileManager.vue、FileManagerContextMenu.vue、useFileManagerContextMenu.ts 和 useSftpActions.ts
|
||||
后端约束: 可复用现有 sftp:rmdir 能力,必要时仅做最小协议扩展
|
||||
交互约束: 保持当前资源管理器树和右键菜单风格,不回退到旧文件表格
|
||||
兼容约束: 单文件/目录右键、多选和空白处右键仍需可用
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [x] 终端 / 上传 / 压缩子菜单可明确展开并可点击执行
|
||||
- [x] 上传完成后自动刷新,拖拽上传前能确认目标目录
|
||||
- [x] 删除目录时弹二次确认并区分空目录删除与强制递归删除
|
||||
- [x] 删除后不再持续报 `读取目录失败: No such file`
|
||||
- [x] 前端构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `FileManagerContextMenu.vue` 中补桌面端点击展开二级菜单与更稳定的 hover/click 保持逻辑;在 `FileManager.vue` 中为拖拽上传新增目标目录提示与确认,并在上传成功回调后触发目标目录刷新;目录删除改成显式二次确认流程,给目录提供“仅删除空目录”和“强制递归删除”两种执行路径。`useSftpActions.ts` 与删除成功后的树更新逻辑同步修正,若当前路径或挂起加载目标落在已删除目录下,则自动回退到父目录或 `/`,避免持续请求不存在路径。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: FileManager.vue
|
||||
- frontend: FileManagerContextMenu.vue
|
||||
- frontend: useFileManagerContextMenu.ts
|
||||
- frontend: useSftpActions.ts
|
||||
预计变更文件: 4-7
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 删除协议若需扩展 `recursive` 参数,前后端需同步 | 中 | 先核对现有后端 `sftp:rmdir` 能否透传策略,不足时做最小扩展 |
|
||||
| 拖拽上传增加确认可能影响原有快捷操作感 | 低 | 仅在目标不明确时或跨目录时确认,默认场景仍快速上传 |
|
||||
| 删除后自动回退目录可能与用户当前聚焦目录预期不一致 | 低 | 仅在当前路径已失效时回退到父目录,优先保证不报错 |
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# 任务清单: file-manager-delete-upload-stability
|
||||
|
||||
```yaml
|
||||
@feature: file-manager-delete-upload-stability
|
||||
@created: 2026-03-26
|
||||
@status: in_progress
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 5 | 0 | 0 | 5 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与范围确认
|
||||
|
||||
- [√] 1.1 创建文件管理稳定性修复方案包并锁定到文件管理前端链路 | depends_on: []
|
||||
|
||||
### 2. 菜单与上传交互修复
|
||||
|
||||
- [√] 2.1 修复右键子菜单展开/点击交互 | depends_on: [1.1]
|
||||
- [√] 2.2 补拖拽上传目标确认和上传完成自动刷新 | depends_on: [2.1]
|
||||
|
||||
### 3. 删除与树状态修复
|
||||
|
||||
- [√] 3.1 为目录删除增加“空目录删除 / 强制递归删除”二次确认 | depends_on: [2.2]
|
||||
- [√] 3.2 修复删除后树持续请求不存在路径的问题 | depends_on: [3.1]
|
||||
|
||||
### 4. 验证与同步
|
||||
|
||||
- [√] 4.1 运行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [3.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-26 03:24 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为文件管理删除、上传和菜单稳定性修复 |
|
||||
| 2026-03-26 04:03 | 2.1 | 完成 | `FileManagerContextMenu.vue` 桌面端子菜单补点击展开,避免“终端 / 上传 / 压缩”点击无反应 |
|
||||
| 2026-03-26 04:05 | 2.2 | 完成 | 拖拽上传前新增目标目录确认,并在当前可见目录上传完成后主动刷新 |
|
||||
| 2026-03-26 04:08 | 3.1 | 完成 | 目录删除改为“仅删空目录 / 强制递归删除”双确认,后端 `sftp:rmdir` 接收 `recursive` 标志 |
|
||||
| 2026-03-26 04:10 | 3.2 | 完成 | 删除目录后若当前/待加载路径失效,前端自动回退父目录,终止持续 `No such file` 重试 |
|
||||
| 2026-03-26 04:14 | 4.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 与 `@nexus-terminal/backend` 均通过 |
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202603260324 | file-manager-delete-upload-stability | implementation | frontend, backend | file-manager-delete-upload-stability#D001 | ✅完成 |
|
||||
| 202603260310 | file-manager-root-sibling-bootstrap | implementation | frontend | file-manager-root-sibling-bootstrap#D001 | ✅完成 |
|
||||
| 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 | - | ✅完成 |
|
||||
@@ -37,6 +39,7 @@
|
||||
## 按月归档
|
||||
|
||||
### 2026-03
|
||||
- [202603260324_file-manager-delete-upload-stability](./2026-03/202603260324_file-manager-delete-upload-stability/) - 修复文件管理器右键子菜单点击、拖拽上传目标确认、目录删除模式选择与删除后路径失效回退
|
||||
- [202603260234_folder-upload-auto-zip](./2026-03/202603260234_folder-upload-auto-zip/) - 为文件管理器补齐上传文件夹入口,选择目录后先打包为 zip,再上传并自动触发远端解压
|
||||
- [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/) - 将工作台文件区收敛为固定 / 根节点的单栏资源管理器树,并在树内同时显示目录与文件
|
||||
|
||||
@@ -37,7 +37,12 @@
|
||||
### 业务分层
|
||||
**条件**: 维护业务域代码。
|
||||
**行为**: 按 `controller/service/repository/routes` 的分层模式组织连接、通知、设置、快速命令、主题等功能。
|
||||
**结果**: 新增后端能力时应优先延续现有业务域目录结构,而不是在入口文件堆叠逻辑。
|
||||
**结果**: 新增后端能力时应优先延续现有业务域目录结构,而不是在入口文件堆叠逻辑。
|
||||
|
||||
### SFTP 目录删除
|
||||
**条件**: 前端文件管理器请求删除目录。
|
||||
**行为**: `websocket/handlers/sftp.handler.ts` 当前允许 `sftp:rmdir` 接收可选 `payload.recursive`;`sftp.service.ts` 在 `recursive=false` 时走原生 `sftp.rmdir` 仅删除空目录,在 `recursive=true` 时继续沿用现有 `rm -rf` / `sudo rm -rf` 链路。
|
||||
**结果**: 前端可以把“仅删空目录”和“强制递归删除”明确区分为两类后端行为,而不是所有目录删除都默认递归。
|
||||
|
||||
### 仪表盘聚合接口
|
||||
**条件**: 前端首页需要一次性获取可视化仪表盘统计。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# frontend
|
||||
# frontend
|
||||
|
||||
## 职责
|
||||
|
||||
@@ -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` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
|
||||
**行为**: 通过组件、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` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;本轮又补上了拖拽上传前的目标路径确认、桌面端右键子菜单点击展开,以及目录删除时“仅删空目录 / 强制递归删除”的显式二选一;同时 `useSftpActions.ts` 会在删除目录后自动回退当前或待加载的失效路径,避免文件树持续对已删除目录刷出 `No such file`。样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
|
||||
**结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。
|
||||
|
||||
### 仪表盘总览
|
||||
@@ -60,3 +60,4 @@
|
||||
**条件**: 用户在 `/workspace` 右侧状态监控面板查看服务器资源状态。
|
||||
**行为**: `StatusMonitor.vue` 当前将内存与磁盘区域升级为卡片化监控视图:内存卡片展示总量、已用、缓存、空闲和环形占比,磁盘卡片展示设备名、文件系统类型、读写速率以及挂载点/大小/可用/已用率表格;CPU、Swap、网络速率和 `StatusCharts.vue` 的 CPU / 网络曲线继续保留。
|
||||
**结果**: 状态监控从“简单进度行”升级为“高信息密度卡片”,并直接承接后端新增的内存细分字段与磁盘元数据。
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"status":"in_progress","completed":0,"failed":0,"pending":3,"total":3,"done":0,"percent":0,"current":"准备进入开发实施:修复新会话下根目录同级树补全","updated_at":"2026-03-26 03:10:00"}
|
||||
@@ -1,41 +0,0 @@
|
||||
# 任务清单: file-manager-root-sibling-bootstrap
|
||||
|
||||
```yaml
|
||||
@feature: file-manager-root-sibling-bootstrap
|
||||
@created: 2026-03-26
|
||||
@status: pending
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 0 | 0 | 0 | 3 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 根目录同级补全修复
|
||||
|
||||
- [ ] 1.1 在 `packages/frontend/src/composables/useSftpActions.ts` 中定位新会话初始化下 `/` 与当前目录并发加载的竞态点 | depends_on: []
|
||||
- [ ] 1.2 在 `packages/frontend/src/composables/useSftpActions.ts` 中实现 stale `/` 根目录响应的安全树补全逻辑,避免 currentPath 被改回 `/` | depends_on: [1.1]
|
||||
|
||||
### 2. 验证与同步
|
||||
|
||||
- [ ] 2.1 执行 `packages/frontend` 的构建验证,并同步知识库记录 | depends_on: [1.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-26 03:10 | DESIGN | completed | 已确认问题集中在 `/` 根树补全而非当前工作目录切换,修复以最小协议变更为主 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
> 当前环境缺少可直接调用的 `python/py`,方案包通过模板降级方式手工创建。
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"done":5,"percent":100,"current":"文件管理删除、上传和菜单稳定性修复已完成","updated_at":"2026-03-26 04:14:00"}
|
||||
@@ -0,0 +1,63 @@
|
||||
# 变更提案: file-manager-delete-upload-stability
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 修复增强
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已完成
|
||||
状态说明: 已完成子菜单点击展开、拖拽上传目标确认、目录删除模式区分与删除后无效路径自动回退
|
||||
创建: 2026-03-26
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
文件管理器近期完成了资源管理器树与右键菜单改造,但当前仍存在一组影响可用性的连锁问题:终端/上传/压缩子菜单点击无反应,上传后没有自动刷新且拖拽上传没有目标确认,目录删除缺少“空目录删除 / 强制递归删除”的明确选项,并且删除后树会持续触发 `读取目录失败: No such file`。
|
||||
|
||||
### 目标
|
||||
- 修复文件右键菜单的子菜单交互。
|
||||
- 上传后自动刷新,并在拖拽上传时明确目标目录。
|
||||
- 目录删除提供“仅删除空目录”和“强制递归删除”二次确认。
|
||||
- 删除后不再持续触发已不存在路径的目录加载错误。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
范围约束: 优先收敛在 FileManager.vue、FileManagerContextMenu.vue、useFileManagerContextMenu.ts 和 useSftpActions.ts
|
||||
后端约束: 可复用现有 sftp:rmdir 能力,必要时仅做最小协议扩展
|
||||
交互约束: 保持当前资源管理器树和右键菜单风格,不回退到旧文件表格
|
||||
兼容约束: 单文件/目录右键、多选和空白处右键仍需可用
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [x] 终端 / 上传 / 压缩子菜单可明确展开并可点击执行
|
||||
- [x] 上传完成后自动刷新,拖拽上传前能确认目标目录
|
||||
- [x] 删除目录时弹二次确认并区分空目录删除与强制递归删除
|
||||
- [x] 删除后不再持续报 `读取目录失败: No such file`
|
||||
- [x] 前端构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `FileManagerContextMenu.vue` 中补桌面端点击展开二级菜单与更稳定的 hover/click 保持逻辑;在 `FileManager.vue` 中为拖拽上传新增目标目录提示与确认,并在上传成功回调后触发目标目录刷新;目录删除改成显式二次确认流程,给目录提供“仅删除空目录”和“强制递归删除”两种执行路径。`useSftpActions.ts` 与删除成功后的树更新逻辑同步修正,若当前路径或挂起加载目标落在已删除目录下,则自动回退到父目录或 `/`,避免持续请求不存在路径。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: FileManager.vue
|
||||
- frontend: FileManagerContextMenu.vue
|
||||
- frontend: useFileManagerContextMenu.ts
|
||||
- frontend: useSftpActions.ts
|
||||
预计变更文件: 4-7
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 删除协议若需扩展 `recursive` 参数,前后端需同步 | 中 | 先核对现有后端 `sftp:rmdir` 能否透传策略,不足时做最小扩展 |
|
||||
| 拖拽上传增加确认可能影响原有快捷操作感 | 低 | 仅在目标不明确时或跨目录时确认,默认场景仍快速上传 |
|
||||
| 删除后自动回退目录可能与用户当前聚焦目录预期不一致 | 低 | 仅在当前路径已失效时回退到父目录,优先保证不报错 |
|
||||
@@ -0,0 +1,49 @@
|
||||
# 任务清单: file-manager-delete-upload-stability
|
||||
|
||||
```yaml
|
||||
@feature: file-manager-delete-upload-stability
|
||||
@created: 2026-03-26
|
||||
@status: in_progress
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 5 | 0 | 0 | 5 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与范围确认
|
||||
|
||||
- [√] 1.1 创建文件管理稳定性修复方案包并锁定到文件管理前端链路 | depends_on: []
|
||||
|
||||
### 2. 菜单与上传交互修复
|
||||
|
||||
- [√] 2.1 修复右键子菜单展开/点击交互 | depends_on: [1.1]
|
||||
- [√] 2.2 补拖拽上传目标确认和上传完成自动刷新 | depends_on: [2.1]
|
||||
|
||||
### 3. 删除与树状态修复
|
||||
|
||||
- [√] 3.1 为目录删除增加“空目录删除 / 强制递归删除”二次确认 | depends_on: [2.2]
|
||||
- [√] 3.2 修复删除后树持续请求不存在路径的问题 | depends_on: [3.1]
|
||||
|
||||
### 4. 验证与同步
|
||||
|
||||
- [√] 4.1 运行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [3.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-26 03:24 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为文件管理删除、上传和菜单稳定性修复 |
|
||||
| 2026-03-26 04:03 | 2.1 | 完成 | `FileManagerContextMenu.vue` 桌面端子菜单补点击展开,避免“终端 / 上传 / 压缩”点击无反应 |
|
||||
| 2026-03-26 04:05 | 2.2 | 完成 | 拖拽上传前新增目标目录确认,并在当前可见目录上传完成后主动刷新 |
|
||||
| 2026-03-26 04:08 | 3.1 | 完成 | 目录删除改为“仅删空目录 / 强制递归删除”双确认,后端 `sftp:rmdir` 接收 `recursive` 标志 |
|
||||
| 2026-03-26 04:10 | 3.2 | 完成 | 删除目录后若当前/待加载路径失效,前端自动回退父目录,终止持续 `No such file` 重试 |
|
||||
| 2026-03-26 04:14 | 4.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 与 `@nexus-terminal/backend` 均通过 |
|
||||
+3
-3
@@ -15,15 +15,15 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Heavrnl/nexus-terminal.git"
|
||||
"url": "git+https://github.com/Micah123321/nexus-terminal.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Heavrnl/nexus-terminal/issues"
|
||||
"url": "https://github.com/Micah123321/nexus-terminal/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Heavrnl/nexus-terminal#readme",
|
||||
"homepage": "https://github.com/Micah123321/nexus-terminal#readme",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"archiver-zip-encrypted": "^2.0.0",
|
||||
|
||||
@@ -489,16 +489,40 @@ export class SftpService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除目录 (强制递归) */
|
||||
async rmdir(sessionId: string, path: string, requestId: string): Promise<void> {
|
||||
/** 删除目录 */
|
||||
async rmdir(sessionId: string, path: string, requestId: string, recursive = true): Promise<void> {
|
||||
const state = this.clientStates.get(sessionId);
|
||||
if (!state || !state.sshClient) {
|
||||
console.warn(`[SSH Exec] SSH 客户端未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`);
|
||||
if (!state || (!state.sshClient && recursive) || (!state.sftp && !recursive)) {
|
||||
console.warn(`[SSH Exec] 会话未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`);
|
||||
state?.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SSH 会话未就绪', requestId: requestId }));
|
||||
return;
|
||||
}
|
||||
console.debug(`[SSH Exec ${sessionId}] Received rmdir request for ${path} (ID: ${requestId})`);
|
||||
|
||||
if (!recursive) {
|
||||
const sftp = state.sftp;
|
||||
if (!sftp) {
|
||||
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sftp.rmdir(path, (err) => {
|
||||
if (err) {
|
||||
console.error(`[SFTP ${sessionId}] rmdir ${path} failed (ID: ${requestId}):`, err);
|
||||
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${err.message}`, requestId: requestId }));
|
||||
} else {
|
||||
console.log(`[SFTP ${sessionId}] rmdir ${path} success (ID: ${requestId})`);
|
||||
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:success', path: path, requestId: requestId }));
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`[SFTP ${sessionId}] rmdir ${path} caught unexpected error (ID: ${requestId}):`, error);
|
||||
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${error.message}`, requestId: requestId }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 第一种方案:尝试 rm -rf 命令
|
||||
const tryRmRfCommand = async (isSudo: boolean) => {
|
||||
const commandPrefix = isSudo ? 'sudo ' : '';
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function handleSftpOperation(
|
||||
else throw new Error("Missing 'path' in payload for mkdir");
|
||||
break;
|
||||
case 'sftp:rmdir':
|
||||
if (payload?.path) sftpService.rmdir(sessionId, payload.path, requestId);
|
||||
if (payload?.path) sftpService.rmdir(sessionId, payload.path, requestId, payload?.recursive !== false);
|
||||
else throw new Error("Missing 'path' in payload for rmdir");
|
||||
break;
|
||||
case 'sftp:unlink':
|
||||
@@ -169,4 +169,4 @@ export function handleSftpUploadCancel(ws: AuthenticatedWebSocket, payload: any)
|
||||
return;
|
||||
}
|
||||
sftpService.cancelUpload(sessionId, payload.uploadId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
|
||||
<!-- Right navigation links with Tailwind classes using theme variables -->
|
||||
<div class="flex items-center space-x-1">
|
||||
<!-- GitHub Icon (Hide on mobile) -->
|
||||
<a v-if="!isMobile" href="https://github.com/Heavrnl/nexus-terminal" target="_blank" rel="noopener noreferrer" title="Heavrnl/nexus-terminal" class="px-2 py-2 rounded-md text-lg text-icon hover:text-icon-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out">
|
||||
<a v-if="!isMobile" href="https://github.com/Micah123321/nexus-terminal" target="_blank" rel="noopener noreferrer" title="Micah123321/nexus-terminal" class="px-2 py-2 rounded-md text-lg text-icon hover:text-icon-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
|
||||
</svg>
|
||||
|
||||
@@ -23,6 +23,7 @@ import PathHistoryDropdown from './PathHistoryDropdown.vue';
|
||||
import { usePathHistoryStore } from '../stores/pathHistory.store';
|
||||
import FavoritePathsModal from './FavoritePathsModal.vue';
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
import { useConfirmDialog } from '../composables/useConfirmDialog';
|
||||
|
||||
|
||||
type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
|
||||
@@ -121,6 +122,7 @@ const settingsStore = useSettingsStore(); // +++ 实例化 Settings Store +++
|
||||
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
|
||||
const pathHistoryStore = usePathHistoryStore(); // +++ 实例化 PathHistoryStore +++
|
||||
const uiNotificationsStore = useUiNotificationsStore(); // +++ 实例化通知 store +++
|
||||
const { showConfirmDialog } = useConfirmDialog();
|
||||
|
||||
// 从 Settings Store 获取共享设置
|
||||
const {
|
||||
@@ -194,6 +196,15 @@ const startWidth = ref(0);
|
||||
// --- 辅助函数 ---
|
||||
const generateRequestId = (): string => `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
type ManagedUploadOptions = {
|
||||
uploadId?: string;
|
||||
displayName?: string;
|
||||
mode?: 'file' | 'folder-archive';
|
||||
detail?: string;
|
||||
targetPath?: string;
|
||||
afterUpload?: (context: { uploadId: string; remotePath: string; item: any }) => Promise<void>;
|
||||
};
|
||||
|
||||
|
||||
// UI 格式化函数保持不变
|
||||
const formatSize = (size: number): string => {
|
||||
@@ -309,6 +320,110 @@ const createContextMenuItemFromTreeRow = (row: ExplorerTreeRow): FileListItem =>
|
||||
attrs: row.item.attrs,
|
||||
});
|
||||
|
||||
const refreshDirectory = (targetPath?: string) => {
|
||||
if (!currentSftpManager.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSftpManager.value.loadDirectory(targetPath || currentSftpManager.value.currentPath.value, true);
|
||||
};
|
||||
|
||||
const startManagedFileUpload = (
|
||||
file: File,
|
||||
relativePath?: string,
|
||||
options?: ManagedUploadOptions,
|
||||
) => {
|
||||
const resolvedTargetPath = options?.targetPath || currentSftpManager.value?.currentPath.value || '/';
|
||||
const visiblePathAtStart = currentSftpManager.value?.currentPath.value || '/';
|
||||
const shouldRefreshVisibleDirectory = resolvedTargetPath === visiblePathAtStart;
|
||||
|
||||
startFileUpload(file, relativePath, {
|
||||
...options,
|
||||
targetPath: resolvedTargetPath,
|
||||
afterUpload: async (context) => {
|
||||
try {
|
||||
await options?.afterUpload?.(context);
|
||||
} finally {
|
||||
if (
|
||||
shouldRefreshVisibleDirectory &&
|
||||
currentSftpManager.value?.currentPath.value === visiblePathAtStart &&
|
||||
getParentPath(context.remotePath) === visiblePathAtStart
|
||||
) {
|
||||
refreshDirectory(visiblePathAtStart);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const sendDeleteRequests = (items: FileListItem[], directoryRecursive = true) => {
|
||||
if (!props.wsDeps.isConnected.value || !currentSftpManager.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item) => {
|
||||
const targetPath = getItemAbsolutePath(item);
|
||||
props.wsDeps.sendMessage({
|
||||
type: item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink',
|
||||
requestId: generateRequestId(),
|
||||
payload: item.attrs.isDirectory
|
||||
? { path: targetPath, recursive: directoryRecursive }
|
||||
: { path: targetPath },
|
||||
});
|
||||
});
|
||||
|
||||
selectedItems.value.clear();
|
||||
};
|
||||
|
||||
const confirmDirectoryDeleteMode = async (items: FileListItem[]): Promise<boolean | null> => {
|
||||
const directoryItems = items.filter((item) => item.attrs.isDirectory);
|
||||
const fileItems = items.filter((item) => !item.attrs.isDirectory);
|
||||
const directoryNames = directoryItems.map((item) => item.filename).join('、');
|
||||
const fileSummary = fileItems.length > 0
|
||||
? t('fileManager.prompts.deleteIncludedFiles', { count: fileItems.length })
|
||||
: '';
|
||||
|
||||
const deleteEmptyOnly = await showConfirmDialog({
|
||||
title: t('common.confirmationTitle', '请确认'),
|
||||
message: t('fileManager.prompts.confirmDeleteDirectoryEmptyOnly', {
|
||||
count: directoryItems.length,
|
||||
names: directoryNames,
|
||||
fileSummary,
|
||||
}),
|
||||
confirmText: t('fileManager.prompts.deleteEmptyOnly', '仅删除空目录'),
|
||||
cancelText: t('fileManager.prompts.moreDeleteOptions', '更多选项'),
|
||||
});
|
||||
|
||||
if (deleteEmptyOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const forceRecursiveDelete = await showConfirmDialog({
|
||||
title: t('common.confirmationTitle', '请确认'),
|
||||
message: t('fileManager.prompts.confirmDeleteDirectoryRecursive', {
|
||||
count: directoryItems.length,
|
||||
names: directoryNames,
|
||||
fileSummary,
|
||||
}),
|
||||
confirmText: t('fileManager.prompts.forceRecursiveDelete', '强制递归删除'),
|
||||
cancelText: t('fileManager.actions.cancel', '取消'),
|
||||
});
|
||||
|
||||
return forceRecursiveDelete ? true : null;
|
||||
};
|
||||
|
||||
const confirmExternalDropTarget = async (targetPath: string, itemCount: number): Promise<boolean> => {
|
||||
return showConfirmDialog({
|
||||
title: t('fileManager.actions.uploadMenu', '上传'),
|
||||
message: t('fileManager.prompts.confirmUploadToPath', {
|
||||
count: itemCount,
|
||||
path: targetPath,
|
||||
}),
|
||||
confirmText: t('fileManager.actions.upload', '上传'),
|
||||
cancelText: t('fileManager.actions.cancel', '取消'),
|
||||
});
|
||||
};
|
||||
|
||||
const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
|
||||
const rows: ExplorerTreeRow[] = [];
|
||||
const rootNode = findTreeNodeByPath('/');
|
||||
@@ -750,15 +865,7 @@ const handleModalConfirm = (value?: string) => {
|
||||
switch (currentActionType.value) {
|
||||
case 'delete':
|
||||
if (actionItems.value.length > 0) {
|
||||
actionItems.value.forEach((item) => {
|
||||
const targetPath = getItemAbsolutePath(item);
|
||||
props.wsDeps.sendMessage({
|
||||
type: item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink',
|
||||
requestId: generateRequestId(),
|
||||
payload: { path: targetPath },
|
||||
});
|
||||
});
|
||||
selectedItems.value.clear(); // Clear selection after delete
|
||||
sendDeleteRequests(actionItems.value);
|
||||
}
|
||||
break;
|
||||
case 'rename':
|
||||
@@ -812,7 +919,7 @@ const handleModalConfirm = (value?: string) => {
|
||||
|
||||
|
||||
// --- SFTP 操作处理函数 (定义在此处,供 Composable 使用) ---
|
||||
const handleDeleteSelectedClick = () => {
|
||||
const handleDeleteSelectedClick = async () => {
|
||||
// 修改:检查 currentSftpManager 是否存在
|
||||
if (!currentSftpManager.value) return;
|
||||
// 使用 props.wsDeps 和 currentSftpManager.value.fileList
|
||||
@@ -820,28 +927,30 @@ const handleDeleteSelectedClick = () => {
|
||||
const selectedListItems = Array.from(selectedItems.value)
|
||||
.map(filename => currentSftpManager.value?.fileList.value.find((f: FileListItem) => f.filename === filename))
|
||||
.filter((item): item is FileListItem => item !== undefined);
|
||||
const itemsToDelete =
|
||||
const itemsToDelete =
|
||||
selectedListItems.length > 0
|
||||
? selectedListItems
|
||||
: (contextTargetItem.value ? [contextTargetItem.value] : []);
|
||||
if (itemsToDelete.length === 0) return;
|
||||
|
||||
const hasDirectory = itemsToDelete.some((item) => item.attrs.isDirectory);
|
||||
|
||||
if (hasDirectory) {
|
||||
const recursiveDelete = await confirmDirectoryDeleteMode(itemsToDelete);
|
||||
if (recursiveDelete === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendDeleteRequests(itemsToDelete, recursiveDelete);
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据设置决定是否显示确认模态框
|
||||
if (settingsStore.fileManagerShowDeleteConfirmationBoolean) {
|
||||
openActionModal('delete', null, itemsToDelete);
|
||||
} else {
|
||||
// 直接执行删除
|
||||
if (currentSftpManager.value) {
|
||||
itemsToDelete.forEach((item) => {
|
||||
const targetPath = getItemAbsolutePath(item);
|
||||
props.wsDeps.sendMessage({
|
||||
type: item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink',
|
||||
requestId: generateRequestId(),
|
||||
payload: { path: targetPath },
|
||||
});
|
||||
});
|
||||
selectedItems.value.clear(); // Clear selection after delete
|
||||
}
|
||||
sendDeleteRequests(itemsToDelete);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -985,7 +1094,7 @@ const startFolderArchiveUpload = async (
|
||||
detail: t('fileManager.notifications.folderArchiveReady', { count: entryCount }),
|
||||
});
|
||||
|
||||
startFileUpload(archiveFile, undefined, {
|
||||
startManagedFileUpload(archiveFile, undefined, {
|
||||
uploadId,
|
||||
displayName: folderName,
|
||||
mode: 'folder-archive',
|
||||
@@ -1362,8 +1471,9 @@ const {
|
||||
joinPath: (base: string, target: string): string => {
|
||||
return currentSftpManager.value?.joinPath(base, target) ?? `${base}/${target}`.replace(/\/+/g, '/'); // 提供简单的默认实现
|
||||
},
|
||||
onFileUpload: (file, relativePath, targetPath) => startFileUpload(file, relativePath, { targetPath }),
|
||||
onFileUpload: (file, relativePath, targetPath) => startManagedFileUpload(file, relativePath, { targetPath }),
|
||||
onFolderUpload: startFolderArchiveUpload,
|
||||
onConfirmExternalDropTarget: confirmExternalDropTarget,
|
||||
// 修改:确保在调用前检查 currentSftpManager.value
|
||||
onItemMove: (item, newName) => {
|
||||
currentSftpManager.value?.renameItem(item, newName);
|
||||
@@ -1380,7 +1490,7 @@ const handleFileSelected = (event: Event) => {
|
||||
// 恢复使用 props.wsDeps.isConnected
|
||||
if (!input.files || !props.wsDeps.isConnected.value) return;
|
||||
// --- 修正:使用匿名函数包装 startFileUpload 调用 ---
|
||||
Array.from(input.files).forEach(file => startFileUpload(file)); // 只传递 file 参数
|
||||
Array.from(input.files).forEach(file => startManagedFileUpload(file)); // 只传递 file 参数
|
||||
// --- 结束修正 ---
|
||||
input.value = '';
|
||||
};
|
||||
@@ -2033,6 +2143,22 @@ const handleNavigateToPathFromFavorites = (path: string) => {
|
||||
showFavoritePathsModal.value = false; // Close modal after navigation
|
||||
};
|
||||
|
||||
const expandExplorerPathChain = (path: string | null | undefined) => {
|
||||
explorerExpandedPaths.value['/'] = true;
|
||||
|
||||
if (!path || path === '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
let currentPath = '';
|
||||
|
||||
segments.forEach((segment) => {
|
||||
currentPath += `/${segment}`;
|
||||
explorerExpandedPaths.value[currentPath] = true;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDirectoryPath = (path: string, currentExpanded = false) => {
|
||||
const nextExpanded = !(explorerExpandedPaths.value[path] ?? currentExpanded);
|
||||
explorerExpandedPaths.value[path] = nextExpanded;
|
||||
@@ -2103,9 +2229,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
if (explorerExpandedPaths.value['/'] === undefined) {
|
||||
explorerExpandedPaths.value['/'] = true;
|
||||
}
|
||||
expandExplorerPathChain(manager.currentPath.value);
|
||||
|
||||
if (!manager.fileTree.childrenLoaded || manager.currentPath.value !== '/') {
|
||||
manager.loadDirectory('/');
|
||||
@@ -2113,6 +2237,14 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => currentSftpManager.value?.currentPath.value,
|
||||
(path) => {
|
||||
expandExplorerPathChain(path);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -206,6 +206,15 @@ const showSubmenu = (label: string) => {
|
||||
expandedSubmenu.value = label;
|
||||
};
|
||||
|
||||
const toggleSubmenu = (label: string) => {
|
||||
if (expandedSubmenu.value === label) {
|
||||
expandedSubmenu.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
showSubmenu(label);
|
||||
};
|
||||
|
||||
const hideSubmenu = () => {
|
||||
closeTimeout = setTimeout(() => {
|
||||
expandedSubmenu.value = null;
|
||||
@@ -302,6 +311,7 @@ onUnmounted(() => {
|
||||
]"
|
||||
@mouseenter="showSubmenu(menuItem.label)"
|
||||
@mouseleave="hideSubmenu()"
|
||||
@click.stop="!menuItem.disabled && toggleSubmenu(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>
|
||||
|
||||
@@ -15,21 +15,16 @@
|
||||
{{ $t('settings.about.latestVersion') }}
|
||||
</span>
|
||||
<a v-else-if="isUpdateAvailable && latestVersion"
|
||||
:href="`https://github.com/Heavrnl/nexus-terminal/releases/tag/${latestVersion}`"
|
||||
:href="`https://github.com/Micah123321/nexus-terminal/releases/tag/${latestVersion}`"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center text-xs ml-2 px-2 py-0.5 rounded-full bg-warning text-white hover:bg-warning/80">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1 h-3 w-3"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
|
||||
{{ $t('settings.about.updateAvailable', { version: latestVersion }) }}
|
||||
</a>
|
||||
<span class="opacity-50">|</span>
|
||||
<a href="https://github.com/Heavrnl/nexus-terminal" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline inline-flex items-center">
|
||||
<a href="https://github.com/Micah123321/nexus-terminal" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline inline-flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="mr-1" viewBox="0 0 16 16"> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/> </svg>
|
||||
Heavrnl/nexus-terminal
|
||||
</a>
|
||||
<span class="opacity-50">|</span>
|
||||
<a href="https://ko-fi.com/0heavrnl" target="_blank" rel="noopener noreferrer" title="Support me on Ko-fi" class="text-primary hover:underline inline-flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" class="mr-1"> <path d="M20.33 6.08c-.28-.4-.7-.68-1.18-.82-.48-.14-.98-.14-1.47-.02-.48.12-.9.38-1.22.75-.32.37-.5.83-.5 1.32 0 .48.18.93.5 1.3.32.37.75.63 1.22.75.48.12.98.12 1.47 0 .48-.12.9-.38 1.18-.75.28-.37.45-.82.45-1.3 0-.48-.17-.95-.45-1.32zm-2.75 1.5c-.14.17-.33.25-.53.25s-.38-.08-.53-.25c-.14-.17-.22-.38-.22-.6s.08-.43.22-.6c.14-.17.33-.25.53-.25s.38.08.53.25c.14.17.22.38.22.6s-.08.43-.22.6zM18 10H6c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2zm-6 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/> </svg>
|
||||
Ko-fi
|
||||
Micah123321/nexus-terminal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,4 +54,4 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
/* Styles specific to AboutSection if any */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -73,7 +73,7 @@ const initializeEditableState = () => {
|
||||
// localTerminalCustomHTML.value = terminalCustomHTML.value || ''; // Replaced
|
||||
uploadError.value = null;
|
||||
currentActiveTab.value = activeHtmlPresetTab.value; // Sync with store state
|
||||
localRemoteHtmlPresetsRepositoryUrl.value = remoteHtmlPresetsRepositoryUrl.value || 'https://github.com/Heavrnl/nexus-terminal/tree/main/doc/custom_html_theme';
|
||||
localRemoteHtmlPresetsRepositoryUrl.value = remoteHtmlPresetsRepositoryUrl.value || 'https://github.com/Micah123321/nexus-terminal/tree/main/doc/custom_html_theme';
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -592,7 +592,7 @@ const filteredRemoteHtmlPresets = computed(() => {
|
||||
id="remoteRepoUrl"
|
||||
v-model="localRemoteHtmlPresetsRepositoryUrl"
|
||||
class="flex-grow p-2 border border-border rounded bg-input text-foreground focus:ring-primary focus:border-primary"
|
||||
:placeholder="t('styleCustomizer.remoteRepoUrlPlaceholder', 'https://github.com/Heavrnl/nexus-terminal/tree/main/doc/custom_html_theme')"
|
||||
:placeholder="t('styleCustomizer.remoteRepoUrlPlaceholder', 'https://github.com/Micah123321/nexus-terminal/tree/main/doc/custom_html_theme')"
|
||||
/>
|
||||
<button @click="handleSaveRemoteRepositoryUrl" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap flex-shrink-0">
|
||||
{{ t('common.save') }}
|
||||
@@ -677,4 +677,4 @@ const filteredRemoteHtmlPresets = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -13,9 +13,10 @@ export interface UseFileManagerDragAndDropOptions {
|
||||
|
||||
// 函数依赖
|
||||
joinPath: (base: string, target: string) => string; // 路径拼接函数
|
||||
onFileUpload: (file: File, relativePath?: string, targetPath?: string) => void; // 修改:触发文件上传的回调,增加相对路径
|
||||
onFileUpload: (file: File, relativePath?: string, targetPath?: string) => void | Promise<void>; // 修改:触发文件上传的回调,增加相对路径
|
||||
onFolderUpload: (files: FolderArchiveSource[], targetPath?: string) => void | Promise<void>;
|
||||
onItemMove: (sourceItem: FileListItem, newFullPath: string) => void; // 触发文件/文件夹移动的回调
|
||||
onConfirmExternalDropTarget?: (targetPath: string, itemCount: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOptions) {
|
||||
@@ -27,6 +28,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
onFileUpload,
|
||||
onFolderUpload,
|
||||
onItemMove,
|
||||
onConfirmExternalDropTarget,
|
||||
selectedItems, // 获取传入的 selectedItems
|
||||
fileList, // 获取传入的 fileList
|
||||
} = options;
|
||||
@@ -243,6 +245,13 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
return;
|
||||
}
|
||||
|
||||
if (onConfirmExternalDropTarget) {
|
||||
const confirmed = await onConfirmExternalDropTarget(targetPath, items.length);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DragDrop] Processing ${items.length} dropped items for target path ${targetPath}.`);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
@@ -3,11 +3,41 @@ import axios from 'axios';
|
||||
import pkg from '../../../package.json'; // 路径相对于当前文件
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const GITHUB_REPO_PATH = 'Micah123321/nexus-terminal';
|
||||
|
||||
const normalizeVersionLabel = (version: string) => {
|
||||
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
|
||||
return cleanVersion.replace(/^(\d+\.\d+)\.0$/, '$1');
|
||||
};
|
||||
|
||||
const parseVersion = (version: string) => {
|
||||
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
|
||||
const [major = '0', minor = '0', patch = '0'] = cleanVersion.split('.');
|
||||
|
||||
return [
|
||||
Number.parseInt(major, 10) || 0,
|
||||
Number.parseInt(minor, 10) || 0,
|
||||
Number.parseInt(patch, 10) || 0,
|
||||
] as const;
|
||||
};
|
||||
|
||||
const compareVersions = (left: string, right: string) => {
|
||||
const leftParts = parseVersion(left);
|
||||
const rightParts = parseVersion(right);
|
||||
|
||||
for (let index = 0; index < leftParts.length; index += 1) {
|
||||
if (leftParts[index] > rightParts[index]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (leftParts[index] < rightParts[index]) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
export function useAboutSection() {
|
||||
const { t } = useI18n();
|
||||
const appVersion = ref(normalizeVersionLabel(pkg.version));
|
||||
@@ -18,18 +48,9 @@ export function useAboutSection() {
|
||||
const versionCheckError = ref<string | null>(null);
|
||||
|
||||
const isUpdateAvailable = computed(() => {
|
||||
// 简单的字符串比较,假设 tag 格式为 vX.Y.Z 或 X.Y.Z
|
||||
// 后端返回的 tag_name 可能包含 'v' 前缀,也可能不包含
|
||||
// appVersion.value 通常不包含 'v'
|
||||
if (!latestVersion.value) return false;
|
||||
|
||||
const cleanLatestVersion = normalizeVersionLabel(latestVersion.value);
|
||||
const cleanAppVersion = appVersion.value;
|
||||
|
||||
// 进行版本比较,更健壮的比较可能需要拆分版本号进行数字比较
|
||||
// 此处简单比较字符串,对于 "1.0.10" > "1.0.9" 是有效的
|
||||
// 但对于 "1.0.9" > "1.0.10" 可能会出错,如果需要更精确,可以引入 semver 库或手动比较
|
||||
return cleanLatestVersion !== cleanAppVersion && cleanLatestVersion > cleanAppVersion;
|
||||
return compareVersions(latestVersion.value, appVersion.value) > 0;
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +59,7 @@ export function useAboutSection() {
|
||||
versionCheckError.value = null;
|
||||
latestVersion.value = null; // Reset before check
|
||||
try {
|
||||
const response = await axios.get('https://api.github.com/repos/Heavrnl/nexus-terminal/releases/latest', {
|
||||
const response = await axios.get(`https://api.github.com/repos/${GITHUB_REPO_PATH}/releases/latest`, {
|
||||
// 移除 headers 以尝试解决潜在的CORS或请求问题,GitHub API 通常不需要特定 headers 进行公共读取
|
||||
});
|
||||
if (response.data && response.data.tag_name) {
|
||||
|
||||
@@ -3,11 +3,41 @@ import axios from 'axios';
|
||||
import pkg from '../../../package.json'; // 调整路径以正确导入 package.json
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const GITHUB_REPO_PATH = 'Micah123321/nexus-terminal';
|
||||
|
||||
const normalizeVersionLabel = (version: string) => {
|
||||
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
|
||||
return cleanVersion.replace(/^(\d+\.\d+)\.0$/, '$1');
|
||||
};
|
||||
|
||||
const parseVersion = (version: string) => {
|
||||
const cleanVersion = version.startsWith('v') ? version.slice(1) : version;
|
||||
const [major = '0', minor = '0', patch = '0'] = cleanVersion.split('.');
|
||||
|
||||
return [
|
||||
Number.parseInt(major, 10) || 0,
|
||||
Number.parseInt(minor, 10) || 0,
|
||||
Number.parseInt(patch, 10) || 0,
|
||||
] as const;
|
||||
};
|
||||
|
||||
const compareVersions = (left: string, right: string) => {
|
||||
const leftParts = parseVersion(left);
|
||||
const rightParts = parseVersion(right);
|
||||
|
||||
for (let index = 0; index < leftParts.length; index += 1) {
|
||||
if (leftParts[index] > rightParts[index]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (leftParts[index] < rightParts[index]) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
export function useVersionCheck() {
|
||||
const { t } = useI18n();
|
||||
const appVersion = ref(normalizeVersionLabel(pkg.version));
|
||||
@@ -20,7 +50,7 @@ export function useVersionCheck() {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizeVersionLabel(latestVersion.value) !== appVersion.value;
|
||||
return compareVersions(latestVersion.value, appVersion.value) > 0;
|
||||
});
|
||||
|
||||
const checkLatestVersion = async () => {
|
||||
@@ -28,7 +58,7 @@ export function useVersionCheck() {
|
||||
versionCheckError.value = null;
|
||||
latestVersion.value = null;
|
||||
try {
|
||||
const response = await axios.get('https://api.github.com/repos/Heavrnl/nexus-terminal/releases/latest');
|
||||
const response = await axios.get(`https://api.github.com/repos/${GITHUB_REPO_PATH}/releases/latest`);
|
||||
if (response.data && response.data.tag_name) {
|
||||
latestVersion.value = response.data.tag_name;
|
||||
} else {
|
||||
|
||||
@@ -287,7 +287,9 @@ export function useFileUploader(
|
||||
|
||||
const hook = uploadHooks.get(uploadId);
|
||||
if (hook?.afterUpload && remotePath) {
|
||||
upload.status = 'decompressing';
|
||||
if (upload.mode === 'folder-archive') {
|
||||
upload.status = 'decompressing';
|
||||
}
|
||||
try {
|
||||
await hook.afterUpload({ uploadId, remotePath, item: upload });
|
||||
upload.status = 'success';
|
||||
|
||||
@@ -74,6 +74,18 @@ const basename = (targetPath: string): string => {
|
||||
return lastSlashIndex >= 0 ? normalized.substring(lastSlashIndex + 1) : normalized;
|
||||
};
|
||||
|
||||
const isSameOrDescendantPath = (candidatePath: string | null | undefined, targetPath: string | null | undefined): boolean => {
|
||||
if (!candidatePath || !targetPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (targetPath === '/') {
|
||||
return candidatePath.startsWith('/');
|
||||
}
|
||||
|
||||
return candidatePath === targetPath || candidatePath.startsWith(`${targetPath}/`);
|
||||
};
|
||||
|
||||
// Helper function
|
||||
const sortFiles = (a: FileListItem, b: FileListItem): number => {
|
||||
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
|
||||
@@ -149,6 +161,24 @@ export function createSftpActionsManager(
|
||||
unregisterCallbacks.length = 0; // 清空数组
|
||||
};
|
||||
|
||||
const recoverFromInvalidPath = (invalidPath: string | null | undefined): string => {
|
||||
const fallbackPath = dirname(invalidPath || currentPathRef.value || '/');
|
||||
|
||||
if (isSameOrDescendantPath(currentPathRef.value, invalidPath)) {
|
||||
currentPathRef.value = fallbackPath;
|
||||
}
|
||||
|
||||
if (isSameOrDescendantPath(loadingPath.value, invalidPath)) {
|
||||
loadingPath.value = null;
|
||||
}
|
||||
|
||||
if (isSameOrDescendantPath(pendingPathAfterRootBootstrap.value, invalidPath)) {
|
||||
pendingPathAfterRootBootstrap.value = null;
|
||||
}
|
||||
|
||||
return fallbackPath;
|
||||
};
|
||||
|
||||
// 不再需要 clearSftpError 函数
|
||||
// const clearSftpError = () => { ... };
|
||||
|
||||
@@ -824,6 +854,13 @@ export function createSftpActionsManager(
|
||||
// 类型断言,因为我们知道 readdir:error 的 payload 是 string
|
||||
const errorPayload = payload as string;
|
||||
const errorPath = message.path;
|
||||
const missingPath = errorPath || loadingPath.value || currentPathRef.value;
|
||||
const isMissingPathError = /No such file/i.test(errorPayload || '');
|
||||
const shouldRecoverMissingPath = isMissingPathError && (
|
||||
isSameOrDescendantPath(currentPathRef.value, missingPath) ||
|
||||
isSameOrDescendantPath(loadingPath.value, missingPath) ||
|
||||
isSameOrDescendantPath(pendingPathAfterRootBootstrap.value, missingPath)
|
||||
);
|
||||
|
||||
// 检查请求 ID 是否匹配当前加载请求
|
||||
if (message.requestId !== loadingRequestId.value) {
|
||||
@@ -832,14 +869,22 @@ export function createSftpActionsManager(
|
||||
}
|
||||
|
||||
console.error(`[SFTP ${instanceSessionId}] 加载目录 ${errorPath} 出错:`, errorPayload); // 日志改为中文
|
||||
// error.value = errorPayload; // 使用通知
|
||||
uiNotificationsStore.showError(`${t('fileManager.errors.loadDirectoryFailed')}: ${errorPayload}`);
|
||||
|
||||
// 重置加载状态,因为这是匹配的响应
|
||||
isLoading.value = false;
|
||||
loadingRequestId.value = null;
|
||||
loadingPath.value = null;
|
||||
console.log(`[SFTP ${instanceSessionId}] isLoading reset after failed readdir for ${errorPath}.`);
|
||||
|
||||
if (shouldRecoverMissingPath) {
|
||||
const fallbackPath = recoverFromInvalidPath(missingPath);
|
||||
console.warn(`[SFTP ${instanceSessionId}] 路径 ${missingPath} 不存在,自动回退到 ${fallbackPath}。`);
|
||||
loadDirectory(fallbackPath, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// error.value = errorPayload; // 使用通知
|
||||
uiNotificationsStore.showError(`${t('fileManager.errors.loadDirectoryFailed')}: ${errorPayload}`);
|
||||
};
|
||||
|
||||
// 移除通用的 onActionSuccessRefresh
|
||||
@@ -948,6 +993,9 @@ export function createSftpActionsManager(
|
||||
const removedPath = message.path;
|
||||
const parentPath = removedPath?.substring(0, removedPath.lastIndexOf('/')) || '/';
|
||||
const removedFilename = removedPath?.substring(removedPath.lastIndexOf('/') + 1);
|
||||
const shouldRecoverPath = isSameOrDescendantPath(currentPathRef.value, removedPath) ||
|
||||
isSameOrDescendantPath(loadingPath.value, removedPath) ||
|
||||
isSameOrDescendantPath(pendingPathAfterRootBootstrap.value, removedPath);
|
||||
|
||||
console.log(`[SFTP ${instanceSessionId}] 删除成功: ${removedPath}`);
|
||||
// *** 修改:直接修改文件树 ***
|
||||
@@ -958,6 +1006,12 @@ export function createSftpActionsManager(
|
||||
// 理论上 removeNodeFromTree 已经移除了它,这里可以加日志或额外清理
|
||||
console.log(`[SFTP ${instanceSessionId}] 目录 ${removedPath} 已从树中移除`);
|
||||
}
|
||||
|
||||
if (shouldRecoverPath) {
|
||||
const fallbackPath = recoverFromInvalidPath(removedPath);
|
||||
console.log(`[SFTP ${instanceSessionId}] 当前或待加载路径已被删除,自动回退到 ${fallbackPath}`);
|
||||
loadDirectory(fallbackPath, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重命名成功
|
||||
|
||||
@@ -558,6 +558,13 @@
|
||||
"confirmDeleteMultiple": "Are you sure you want to delete the selected {count} items? This cannot be undone.",
|
||||
"confirmDeleteFolder": "Are you sure you want to delete the directory \"{name}\" and all its contents? This cannot be undone.",
|
||||
"confirmDeleteFile": "Are you sure you want to delete the file \"{name}\"? This cannot be undone.",
|
||||
"confirmUploadToPath": "Upload {count} dropped item(s) to \"{path}\"?",
|
||||
"deleteIncludedFiles": ", plus {count} file(s)",
|
||||
"confirmDeleteDirectoryEmptyOnly": "This will delete {count} directory(s) ({names}){fileSummary}.\n\nIt will only remove empty directories. Non-empty directories will fail.",
|
||||
"confirmDeleteDirectoryRecursive": "This will recursively delete {count} directory(s) ({names}){fileSummary}.\n\nThis action cannot be undone.",
|
||||
"deleteEmptyOnly": "Delete Empty Only",
|
||||
"moreDeleteOptions": "More Options",
|
||||
"forceRecursiveDelete": "Force Recursive Delete",
|
||||
"enterNewName": "Enter the new name for \"{oldName}\":",
|
||||
"enterNewPermissions": "Enter new permissions for \"{name}\" (octal, e.g., 755):",
|
||||
"enterFileName": "Enter the name for the new file:"
|
||||
|
||||
@@ -543,6 +543,13 @@
|
||||
"confirmDeleteFolder": "フォルダー \"{name}\" とそのすべての内容を削除しますか?この操作は元に戻せません。",
|
||||
"confirmDeleteMultiple": "選択した {count} 個の項目を削除しますか?この操作は元に戻せません。",
|
||||
"confirmOverwrite": "ファイル \"{name}\" はすでに存在します。上書きしますか?",
|
||||
"confirmUploadToPath": "ドラッグした {count} 件の項目を \"{path}\" にアップロードしますか?",
|
||||
"deleteIncludedFiles": "、および {count} 件のファイル",
|
||||
"confirmDeleteDirectoryEmptyOnly": "{count} 個のディレクトリ({names}){fileSummary} を削除します。\n\nまず空ディレクトリのみ削除します。中身があるディレクトリは失敗します。",
|
||||
"confirmDeleteDirectoryRecursive": "{count} 個のディレクトリ({names}){fileSummary} を再帰的に削除します。\n\nこの操作は元に戻せません。",
|
||||
"deleteEmptyOnly": "空ディレクトリのみ削除",
|
||||
"moreDeleteOptions": "他の選択肢",
|
||||
"forceRecursiveDelete": "再帰削除を強制",
|
||||
"enterFileName": "新しいファイルの名前を入力してください:",
|
||||
"enterFolderName": "新しいフォルダーの名前を入力してください:",
|
||||
"enterNewName": "\"{oldName}\" の新しい名前を入力してください:",
|
||||
|
||||
@@ -558,6 +558,13 @@
|
||||
"confirmDeleteMultiple": "确定要删除选定的 {count} 个项目吗?此操作不可撤销。",
|
||||
"confirmDeleteFolder": "确定要删除目录 \"{name}\" 及其所有内容吗?此操作不可撤销。",
|
||||
"confirmDeleteFile": "确定要删除文件 \"{name}\" 吗?此操作不可撤销。",
|
||||
"confirmUploadToPath": "确认将 {count} 个拖入项目上传到 \"{path}\" 吗?",
|
||||
"deleteIncludedFiles": ",以及 {count} 个文件",
|
||||
"confirmDeleteDirectoryEmptyOnly": "将删除 {count} 个目录({names}){fileSummary}。\n\n先尝试仅删除空目录;非空目录会直接失败。",
|
||||
"confirmDeleteDirectoryRecursive": "将强制递归删除 {count} 个目录({names}){fileSummary}。\n\n此操作不可撤销。",
|
||||
"deleteEmptyOnly": "仅删除空目录",
|
||||
"moreDeleteOptions": "更多选项",
|
||||
"forceRecursiveDelete": "强制递归删除",
|
||||
"enterNewName": "请输入 \"{oldName}\" 的新名称:",
|
||||
"enterNewPermissions": "请输入 \"{name}\" 的新权限 (八进制, 例如 755):",
|
||||
"enterFileName": "请输入新文件的名称:"
|
||||
|
||||
Reference in New Issue
Block a user