From cda7e0a0187517059ae80c8d9aa49d679583ed05 Mon Sep 17 00:00:00 2001 From: yinjianm Date: Thu, 26 Mar 2026 03:48:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=AE=A1=E7=90=86=E5=99=A8=E5=88=A0=E9=99=A4=E4=B8=8E?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐文件管理器右键子菜单点击展开,新增拖拽上传目标确认, 并在上传完成后自动刷新当前可见目录 目录删除改为区分仅删空目录与强制递归删除,删除后自动回退 失效路径,避免文件树持续报 No such file 同步后端 sftp:rmdir 的 recursive 分支,并将关于页与版本检查 默认仓库链接切换到 Micah123321/nexus-terminal --- .helloagents/CHANGELOG.md | 9 + .helloagents/INDEX.md | 5 +- .../.status.json | 1 + .../proposal.md | 3 +- .../tasks.md | 43 ++++ .../.status.json | 1 + .../proposal.md | 63 ++++++ .../tasks.md | 49 +++++ .helloagents/archive/_index.md | 3 + .helloagents/modules/backend.md | 7 +- .helloagents/modules/frontend.md | 5 +- .../.status.json | 1 - .../tasks.md | 41 ---- .../.status.json | 1 + .../proposal.md | 63 ++++++ .../tasks.md | 49 +++++ package.json | 6 +- packages/backend/src/sftp/sftp.service.ts | 32 ++- .../src/websocket/handlers/sftp.handler.ts | 4 +- packages/frontend/src/App.vue | 2 +- .../frontend/src/components/FileManager.vue | 188 +++++++++++++++--- .../src/components/FileManagerContextMenu.vue | 10 + .../src/components/settings/AboutSection.vue | 13 +- .../StyleCustomizerBackgroundTab.vue | 6 +- .../file-manager/useFileManagerDragAndDrop.ts | 11 +- .../composables/settings/useAboutSection.ts | 43 +++- .../composables/settings/useVersionCheck.ts | 34 +++- .../src/composables/useFileUploader.ts | 4 +- .../src/composables/useSftpActions.ts | 58 +++++- packages/frontend/src/locales/en-US.json | 7 + packages/frontend/src/locales/ja-JP.json | 7 + packages/frontend/src/locales/zh-CN.json | 7 + 32 files changed, 661 insertions(+), 115 deletions(-) create mode 100644 .helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/.status.json rename .helloagents/{plan => archive/2026-03}/202603260310_file-manager-root-sibling-bootstrap/proposal.md (99%) create mode 100644 .helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/tasks.md create mode 100644 .helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/.status.json create mode 100644 .helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/proposal.md create mode 100644 .helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/tasks.md delete mode 100644 .helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/.status.json delete mode 100644 .helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/tasks.md create mode 100644 .helloagents/plan/202603260324_file-manager-delete-upload-stability/.status.json create mode 100644 .helloagents/plan/202603260324_file-manager-delete-upload-stability/proposal.md create mode 100644 .helloagents/plan/202603260324_file-manager-delete-upload-stability/tasks.md diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 7a35b02..7d814d6 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -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 diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index 75018bc..21f9d1b 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -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/{方案包}/* ``` + diff --git a/.helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/.status.json b/.helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/.status.json new file mode 100644 index 0000000..19c93a5 --- /dev/null +++ b/.helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/.status.json @@ -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"} diff --git a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/proposal.md b/.helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/proposal.md similarity index 99% rename from .helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/proposal.md rename to .helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/proposal.md index 57803c0..ca0a4cd 100644 --- a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/proposal.md +++ b/.helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/proposal.md @@ -5,8 +5,9 @@ 类型: 修复 方案类型: implementation 优先级: P1 -状态: 草稿 +状态: 已完成 创建: 2026-03-26 +完成: 2026-03-26 ``` --- diff --git a/.helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/tasks.md b/.helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/tasks.md new file mode 100644 index 0000000..0b8edee --- /dev/null +++ b/.helloagents/archive/2026-03/202603260310_file-manager-root-sibling-bootstrap/tasks.md @@ -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`,方案包通过模板降级方式手工创建。 diff --git a/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/.status.json b/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/.status.json new file mode 100644 index 0000000..7073bbb --- /dev/null +++ b/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/.status.json @@ -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"} diff --git a/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/proposal.md b/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/proposal.md new file mode 100644 index 0000000..a8594de --- /dev/null +++ b/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/proposal.md @@ -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` 能否透传策略,不足时做最小扩展 | +| 拖拽上传增加确认可能影响原有快捷操作感 | 低 | 仅在目标不明确时或跨目录时确认,默认场景仍快速上传 | +| 删除后自动回退目录可能与用户当前聚焦目录预期不一致 | 低 | 仅在当前路径已失效时回退到父目录,优先保证不报错 | diff --git a/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/tasks.md b/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/tasks.md new file mode 100644 index 0000000..e33cabc --- /dev/null +++ b/.helloagents/archive/2026-03/202603260324_file-manager-delete-upload-stability/tasks.md @@ -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` 均通过 | diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index f87da6e..22ff03d 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -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/) - 将工作台文件区收敛为固定 / 根节点的单栏资源管理器树,并在树内同时显示目录与文件 diff --git a/.helloagents/modules/backend.md b/.helloagents/modules/backend.md index 684e893..0de87a9 100644 --- a/.helloagents/modules/backend.md +++ b/.helloagents/modules/backend.md @@ -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` 链路。 +**结果**: 前端可以把“仅删空目录”和“强制递归删除”明确区分为两类后端行为,而不是所有目录删除都默认递归。 ### 仪表盘聚合接口 **条件**: 前端首页需要一次性获取可视化仪表盘统计。 diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 2e01621..36c606d 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -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 / 网络曲线继续保留。 **结果**: 状态监控从“简单进度行”升级为“高信息密度卡片”,并直接承接后端新增的内存细分字段与磁盘元数据。 + diff --git a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/.status.json b/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/.status.json deleted file mode 100644 index 8665795..0000000 --- a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/.status.json +++ /dev/null @@ -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"} diff --git a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/tasks.md b/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/tasks.md deleted file mode 100644 index f191b4e..0000000 --- a/.helloagents/plan/202603260310_file-manager-root-sibling-bootstrap/tasks.md +++ /dev/null @@ -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`,方案包通过模板降级方式手工创建。 diff --git a/.helloagents/plan/202603260324_file-manager-delete-upload-stability/.status.json b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/.status.json new file mode 100644 index 0000000..7073bbb --- /dev/null +++ b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/.status.json @@ -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"} diff --git a/.helloagents/plan/202603260324_file-manager-delete-upload-stability/proposal.md b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/proposal.md new file mode 100644 index 0000000..a8594de --- /dev/null +++ b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/proposal.md @@ -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` 能否透传策略,不足时做最小扩展 | +| 拖拽上传增加确认可能影响原有快捷操作感 | 低 | 仅在目标不明确时或跨目录时确认,默认场景仍快速上传 | +| 删除后自动回退目录可能与用户当前聚焦目录预期不一致 | 低 | 仅在当前路径已失效时回退到父目录,优先保证不报错 | diff --git a/.helloagents/plan/202603260324_file-manager-delete-upload-stability/tasks.md b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/tasks.md new file mode 100644 index 0000000..e33cabc --- /dev/null +++ b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/tasks.md @@ -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` 均通过 | diff --git a/package.json b/package.json index 79e08eb..2fb6eaf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/backend/src/sftp/sftp.service.ts b/packages/backend/src/sftp/sftp.service.ts index 5cf7bad..c39a8a1 100644 --- a/packages/backend/src/sftp/sftp.service.ts +++ b/packages/backend/src/sftp/sftp.service.ts @@ -489,16 +489,40 @@ export class SftpService { } } - /** 删除目录 (强制递归) */ - async rmdir(sessionId: string, path: string, requestId: string): Promise { + /** 删除目录 */ + async rmdir(sessionId: string, path: string, requestId: string, recursive = true): Promise { 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 ' : ''; diff --git a/packages/backend/src/websocket/handlers/sftp.handler.ts b/packages/backend/src/websocket/handlers/sftp.handler.ts index 6025d0a..6545669 100644 --- a/packages/backend/src/websocket/handlers/sftp.handler.ts +++ b/packages/backend/src/websocket/handlers/sftp.handler.ts @@ -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); -} \ No newline at end of file +} diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index 234a836..63cae50 100644 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -296,7 +296,7 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
- + diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index b74cd1e..30edd17 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -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; @@ -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; +}; + // 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 => { + 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 => { + 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(() => { 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 }, +); \ No newline at end of file + diff --git a/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts b/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts index d61a0fb..10bd5f4 100644 --- a/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts +++ b/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts @@ -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; // 修改:触发文件上传的回调,增加相对路径 onFolderUpload: (files: FolderArchiveSource[], targetPath?: string) => void | Promise; onItemMove: (sourceItem: FileListItem, newFullPath: string) => void; // 触发文件/文件夹移动的回调 + onConfirmExternalDropTarget?: (targetPath: string, itemCount: number) => Promise; } 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]; diff --git a/packages/frontend/src/composables/settings/useAboutSection.ts b/packages/frontend/src/composables/settings/useAboutSection.ts index 65a4c8e..995059a 100644 --- a/packages/frontend/src/composables/settings/useAboutSection.ts +++ b/packages/frontend/src/composables/settings/useAboutSection.ts @@ -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(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) { diff --git a/packages/frontend/src/composables/settings/useVersionCheck.ts b/packages/frontend/src/composables/settings/useVersionCheck.ts index 9c482cb..3b59424 100644 --- a/packages/frontend/src/composables/settings/useVersionCheck.ts +++ b/packages/frontend/src/composables/settings/useVersionCheck.ts @@ -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 { diff --git a/packages/frontend/src/composables/useFileUploader.ts b/packages/frontend/src/composables/useFileUploader.ts index ed66f41..443dbde 100644 --- a/packages/frontend/src/composables/useFileUploader.ts +++ b/packages/frontend/src/composables/useFileUploader.ts @@ -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'; diff --git a/packages/frontend/src/composables/useSftpActions.ts b/packages/frontend/src/composables/useSftpActions.ts index 6e6241c..bd14581 100644 --- a/packages/frontend/src/composables/useSftpActions.ts +++ b/packages/frontend/src/composables/useSftpActions.ts @@ -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); + } }; // 处理重命名成功 diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 8d8115b..c4f4f15 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -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:" diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 5bac85f..26db306 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -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}\" の新しい名前を入力してください:", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index fff4ad8..06fc5b4 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -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": "请输入新文件的名称:"