feat(frontend): 将文件管理器改为固定根树视图
文件区改为固定 `/` 根节点的单栏资源管理器树, 在同一树中同时展示目录和文件,并移除文件夹总览区块 同时收紧快捷指令编辑弹窗尺寸并优化窄屏为上下布局, 降低小分辨率下的溢出概率,并同步更新中英文 README 及 `.helloagents` 方案记录
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
- 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。
|
||||
|
||||
### 修复
|
||||
- **[frontend]**: 将工作台文件区继续收敛为固定 `/` 根节点的单栏资源管理器树,并在树内同时显示目录与文件 — by yinjianm
|
||||
- 方案: [202603260212_workbench-file-root-tree](archive/2026-03/202603260212_workbench-file-root-tree/)
|
||||
- **[frontend]**: 修正快捷命令右键菜单的透明背景与粘贴项语义,改为实底菜单并将回填动作统一为“粘贴到命令输入框(不发送)” — by yinjianm
|
||||
- 方案: [202603260156_quickcommands-context-menu-polish](archive/2026-03/202603260156_quickcommands-context-menu-polish/)
|
||||
- **[frontend]**: 将工作台文件区从单目录文件表格切换修正为多根目录常驻的文件夹总览视图 — by yinjianm
|
||||
@@ -22,7 +24,10 @@
|
||||
- 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/)
|
||||
|
||||
### 快速修改
|
||||
- **[frontend]**: 收紧快捷指令编辑弹窗的最小尺寸、初始尺寸和视口上限,降低小分辨率下的弹窗溢出概率 — by yinjianm
|
||||
- **[workspace-root]**: 同步更新中英文 README,补充 monorepo 结构、最新功能清单与 `.helloagents/` 开发说明 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: README.md, doc/README_EN.md
|
||||
- **[frontend]**: 收紧快捷指令编辑弹窗的最小尺寸、初始尺寸和视口上限,并在窄屏下切换为上下布局,降低小分辨率下的弹窗溢出概率 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: packages/frontend/src/components/AddEditQuickCommandForm.vue:9,184-185,242-245
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
```yaml
|
||||
kb_version: 2.3.7
|
||||
最后更新: 2026-03-26 00:43
|
||||
最后更新: 2026-03-26 02:00
|
||||
模块数量: 4
|
||||
待执行方案: 4
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"FileManager 已改为固定 / 根节点并显示目录和文件的单栏树","updated_at":"2026-03-26 02:21:00"}
|
||||
@@ -0,0 +1,58 @@
|
||||
# 变更提案: workbench-file-root-tree
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 功能调整
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已完成
|
||||
状态说明: 已改为固定 / 根节点的单栏资源管理器树,并通过前端构建验证
|
||||
创建: 2026-03-26
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
上一轮虽然去掉了单目录文件表格,但仍保留了“文件夹总览”区块,而且根目录会随当前路径变化,不符合实际想要的资源管理器体验。
|
||||
|
||||
### 目标
|
||||
- 文件区只保留一个资源管理器树。
|
||||
- 根节点始终固定显示 `/`。
|
||||
- 目录展开时同时显示子目录和文件,文件作为叶子节点显示。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
范围约束: 优先只改 FileManager.vue,不改后端接口与 SFTP 协议
|
||||
状态约束: 继续复用 useSftpActions 的 fileTree 和 loadDirectory(path)
|
||||
交互约束: 点击目录只展开和聚焦,点击文件按现有逻辑打开
|
||||
兼容约束: 顶部工具栏、路径栏、上传与新建动作保持可用
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [x] 文件区只保留单栏资源管理器树
|
||||
- [x] 根节点始终固定显示 `/`
|
||||
- [x] 目录下同时展示子目录和文件
|
||||
- [x] 前端构建通过
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `FileManager.vue` 中去掉多根目录和文件夹总览派生结构,改为以 `/` 为唯一 explorer root,树节点直接基于 `fileTree` 递归渲染所有已加载的目录和文件,按“目录在前、文件在后”排序。主内容区改成单栏树视图,点击目录只负责展开与调用 `loadDirectory(path)` 以懒加载子节点,点击文件则继续复用现有 `openFileInWorkspace`。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- frontend: FileManager.vue
|
||||
预计变更文件: 1-4
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| `/` 下初次只会显示已加载到 `fileTree` 的节点 | 中 | 在组件中确保优先加载 `/`,展开目录时继续复用现有懒加载 |
|
||||
| 右侧总览移除后,拖拽与右键的作用区域缩小 | 低 | 保留主容器和现有上下文菜单、上传入口,不改后端动作 |
|
||||
| 树节点数量增多后单栏滚动深度增加 | 低 | 保留当前滚动容器与层级缩进,先满足准确交互 |
|
||||
@@ -0,0 +1,42 @@
|
||||
# 任务清单: workbench-file-root-tree
|
||||
|
||||
```yaml
|
||||
@feature: workbench-file-root-tree
|
||||
@created: 2026-03-26
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 4 | 0 | 0 | 4 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 方案与范围确认
|
||||
|
||||
- [√] 1.1 创建固定 `/` 根节点资源管理器方案包并锁定范围到 `FileManager.vue` | depends_on: []
|
||||
|
||||
### 2. 交互调整实现
|
||||
|
||||
- [√] 2.1 将资源管理器改为固定 `/` 唯一根节点 | depends_on: [1.1]
|
||||
- [√] 2.2 在树中同时渲染子目录和文件,并移除文件夹总览区块 | depends_on: [2.1]
|
||||
|
||||
### 3. 验证与同步
|
||||
|
||||
- [√] 3.1 运行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [2.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-03-26 02:12 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为 FileManager 固定 / 根节点单栏资源管理器改造 |
|
||||
| 2026-03-26 02:17 | 2.1 | 完成 | 文件区收敛为固定 / 根节点单栏树,并确保组件加载时优先请求 / 目录 |
|
||||
| 2026-03-26 02:19 | 2.2 | 完成 | 树中同时渲染目录和文件节点,并移除文件夹总览面板 |
|
||||
| 2026-03-26 02:21 | 3.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过,准备同步知识库与归档 |
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202603260212 | workbench-file-root-tree | implementation | frontend | - | ✅完成 |
|
||||
| 202603260156 | quickcommands-context-menu-polish | implementation | frontend | quickcommands-context-menu-polish#D001 | ✅完成 |
|
||||
| 202603260150 | workbench-file-folder-overview | implementation | frontend | - | ✅完成 |
|
||||
| 202603260041 | workbench-file-multi-root-explorer | implementation | frontend | - | ✅完成 |
|
||||
@@ -34,6 +35,7 @@
|
||||
## 按月归档
|
||||
|
||||
### 2026-03
|
||||
- [202603260212_workbench-file-root-tree](./2026-03/202603260212_workbench-file-root-tree/) - 将工作台文件区收敛为固定 / 根节点的单栏资源管理器树,并在树内同时显示目录与文件
|
||||
- [202603260156_quickcommands-context-menu-polish](./2026-03/202603260156_quickcommands-context-menu-polish/) - 修正快捷命令右键菜单透明背景,并将粘贴动作统一为“粘贴到命令输入框(不发送)”
|
||||
- [202603260150_workbench-file-folder-overview](./2026-03/202603260150_workbench-file-folder-overview/) - 将工作台文件区调整为多根目录常驻的文件夹总览,不再点击目录后切成单独文件表格
|
||||
- [202603260041_workbench-file-multi-root-explorer](./2026-03/202603260041_workbench-file-multi-root-explorer/) - 为工作台文件面板补齐左侧多根目录资源管理器,并允许收藏路径与当前路径同屏作为多个根目录展开浏览
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
### 工作区交互
|
||||
**条件**: 用户进入 `/workspace` 或相关管理页面。
|
||||
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue`、`QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}`、`${{time}}`、`${{timestamp}}`、`${{week}}`、`${{uuid}}`、`${{random:8}}`、`${{clipboard}}`、`${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复,并在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`,`TerminalTabBar.vue` 则进一步把连续同连接会话渲染成“服务器组头 + 终端子标签 + 组尾新增按钮”,全局 `+` 只负责选择其他服务器,从而让“单连接默认 1 个终端、可继续追加多个终端”的关系在顶部标签栏里更接近参考图;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);`FileManager.vue` 当前则改成“左侧多根目录树 + 右侧文件夹总览”的目录浏览模式,左侧和右侧都只展示目录节点,根目录来自收藏路径并自动补入当前路径,右侧按根目录分组持续展示不同层级的文件夹,不再因为点击目录而切成单个目录文件表格;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
|
||||
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue`、`QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}`、`${{time}}`、`${{timestamp}}`、`${{week}}`、`${{uuid}}`、`${{random:8}}`、`${{clipboard}}`、`${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复,并在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`,`TerminalTabBar.vue` 则进一步把连续同连接会话渲染成“服务器组头 + 终端子标签 + 组尾新增按钮”,全局 `+` 只负责选择其他服务器,从而让“单连接默认 1 个终端、可继续追加多个终端”的关系在顶部标签栏里更接近参考图;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。
|
||||
**结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||
@@ -16,18 +16,27 @@
|
||||
|
||||
**星枢终端(Nexus Terminal)** 是一款现代化、功能丰富的 Web SSH / RDP / VNC 客户端,致力于提供高度可定制的远程连接体验。提供独立的本地桌面端。
|
||||
|
||||
## 🧱 项目结构
|
||||
|
||||
本仓库采用 `npm workspaces` 的 monorepo 结构:
|
||||
|
||||
* `packages/frontend`:基于 Vue 3、Vite、Pinia、xterm.js 与 Monaco Editor,负责 Web 工作区、PWA、设置面板与文件编辑体验
|
||||
* `packages/backend`:基于 Express、SQLite、WebSocket、SSH/SFTP,负责认证、连接管理、通知与审计日志
|
||||
* `packages/remote-gateway`:负责 RDP / VNC 远程桌面令牌生成以及与 `guacd` 的桥接
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
* 多标签页管理 SSH 与 SFTP 连接
|
||||
* 支持 RDP/VNC 协议
|
||||
* 支持 PWA
|
||||
* 支持 PWA 与独立桌面端
|
||||
* 采用 Monaco Editor,支持在线编辑文件
|
||||
* 集成多重登录安全机制,包括人机验证(hCaptcha、Google reCAPTCHA)与双因素认证(2FA)
|
||||
* 支持 SSH 会话挂起与恢复,长任务不中断
|
||||
* 集成多重登录安全机制,包括人机验证(hCaptcha、Google reCAPTCHA)、双因素认证(2FA)与 Passkey
|
||||
* 高度可定制的界面主题与布局风格
|
||||
* 提供 Focus Switcher,可自定义页面输入组件切换顺序与快捷键
|
||||
* 内置简易 Docker 容器管理面板,便于容器运维
|
||||
* 支持 IP 白名单与黑名单,异常访问自动封禁
|
||||
* 通知系统(如登录提醒、异常告警)
|
||||
* 审计日志,全面记录用户行为与系统变更
|
||||
* 通知系统与审计日志,全面记录登录、凭据与系统变更
|
||||
* 基于 Node.js 的轻量级后端,资源占用低
|
||||
* 内置心跳保活机制,确保连接稳定
|
||||
|
||||
@@ -151,11 +160,12 @@ docker compose up -d
|
||||
|
||||
### 文件管理器组件
|
||||
|
||||
1. **文件快速选择**:在文件搜索框获得焦点时,可以使用 `↑/↓` 键快速选择文件。
|
||||
2. **拖拽上传**:支持从浏览器外部拖拽文件或文件夹进行上传。**注意:** 上传大量文件或深层文件夹时,建议先进行打包压缩,以避免浏览器卡死。
|
||||
3. **内部拖拽**:可以直接在文件管理器内部拖动文件或文件夹以进行移动。
|
||||
4. **多选操作**:按住 `Ctrl` 或 `Shift` 键可以选择多个文件或文件夹。
|
||||
5. **右键菜单**:提供复制、粘贴、剪切、删除、重命名、修改权限等常用文件操作。
|
||||
1. **固定根节点资源管理器**:文件区采用固定 `/` 根节点的单栏资源管理器树,目录展开后会在同一棵树里同时显示子目录和文件。
|
||||
2. **文件快速选择**:在文件搜索框获得焦点时,可以使用 `↑/↓` 键快速选择文件。
|
||||
3. **拖拽上传**:支持从浏览器外部拖拽文件或文件夹进行上传。**注意:** 上传大量文件或深层文件夹时,建议先进行打包压缩,以避免浏览器卡死。
|
||||
4. **内部拖拽**:可以直接在文件管理器内部拖动文件或文件夹以进行移动。
|
||||
5. **多选操作**:按住 `Ctrl` 或 `Shift` 键可以选择多个文件或文件夹。
|
||||
6. **右键菜单**:提供复制、粘贴、剪切、删除、重命名、修改权限等常用文件操作。
|
||||
|
||||
### 终端组件
|
||||
1. Ctrl + Shift + C 复制,Ctrl + Shift + V 粘贴
|
||||
@@ -186,6 +196,24 @@ docker compose up -d
|
||||
4. 关于数据备份,请自行备份目录下的 data 文件夹,本项目不提供相关备份功能。
|
||||
5. 由于浏览器限制,非https或者localhost无法复制终端内容,请使用https访问
|
||||
|
||||
## 🛠️ 开发说明
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev --workspace=@nexus-terminal/frontend
|
||||
npm run dev --workspace=@nexus-terminal/backend
|
||||
npm run dev --workspace=@nexus-terminal/remote-gateway
|
||||
npm run build --workspace=@nexus-terminal/frontend
|
||||
npm run build --workspace=@nexus-terminal/backend
|
||||
npm run build --workspace=@nexus-terminal/remote-gateway
|
||||
```
|
||||
|
||||
### 本地知识库
|
||||
|
||||
仓库中的 `.helloagents/` 目录用于维护本地知识库、模块索引和方案包,方便协作与后续变更追踪。它不是运行时依赖,但当项目结构、功能说明或协作约定发生变化时,建议同步更新对应内容。
|
||||
|
||||
|
||||
## 💐 致谢
|
||||
|
||||
|
||||
+40
-10
@@ -5,6 +5,8 @@
|
||||
<div align="center">
|
||||
|
||||
[][docker-url] [](https://github.com/Heavrnl/nexus-terminal/blob/main/LICENSE)
|
||||
<br>
|
||||
[中文](../README.md) | [English](./README_EN.md)
|
||||
|
||||
[docker-url]: https://hub.docker.com/r/heavrnl/nexus-terminal-frontend
|
||||
|
||||
@@ -14,22 +16,31 @@
|
||||
|
||||
## 📖 Overview
|
||||
|
||||
**Nexus Terminal** is a modern, feature-rich web-based SSH / RDP / VNC client dedicated to providing a highly customizable remote connection experience.
|
||||
**Nexus Terminal** is a modern, feature-rich web-based SSH / RDP / VNC client dedicated to providing a highly customizable remote connection experience. A standalone desktop client is also available.
|
||||
|
||||
## 🧱 Project Structure
|
||||
|
||||
This repository uses an `npm workspaces` monorepo layout:
|
||||
|
||||
* `packages/frontend`: built with Vue 3, Vite, Pinia, xterm.js, and Monaco Editor; responsible for the web workspace, PWA, settings, and file editing experience
|
||||
* `packages/backend`: built with Express, SQLite, WebSocket, and SSH/SFTP; responsible for authentication, connection management, notifications, and audit logs
|
||||
* `packages/remote-gateway`: responsible for issuing RDP / VNC remote desktop tokens and bridging requests to `guacd`
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Manage SSH and SFTP connections with multiple tabs
|
||||
* Support remote access to desktops via RDP/VNC protocol
|
||||
* Support PWA and a standalone desktop client
|
||||
* Utilizes Monaco Editor for online file editing
|
||||
* Integrated multi-factor login security mechanisms, including human verification (hCaptcha, Google reCAPTCHA) and two-factor authentication (2FA)
|
||||
* Support suspending and resuming SSH sessions so long-running tasks stay alive
|
||||
* Integrated multi-factor login security mechanisms, including human verification (hCaptcha, Google reCAPTCHA), two-factor authentication (2FA), and Passkey
|
||||
* Highly customizable interface themes and layout styles
|
||||
* Focus Switcher for moving between input components with configurable order and hotkeys
|
||||
* Built-in simple Docker container management panel for easy container operations
|
||||
* Supports IP whitelisting and blacklisting, with automatic banning for abnormal access
|
||||
* Notification system (e.g., login reminders, anomaly alerts)
|
||||
* Audit logs for comprehensive recording of user behavior and system changes
|
||||
* Notification system and audit logs for login, credential, and system events
|
||||
* Lightweight Node.js-based backend with low resource consumption
|
||||
* Built-in heartbeat keep-alive mechanism to ensure stable connections
|
||||
* Focus Switcher: Allows switching between input components on the page, supporting customizable switching order and hotkeys.
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
@@ -151,11 +162,12 @@ You can right-click in the SSH tab to select "Suspend Session" (long-press on mo
|
||||
|
||||
### File Manager Component
|
||||
|
||||
1. **Quick File Selection**: When the file search box has focus, you can use the `↑/↓` keys to quickly select files.
|
||||
2. **Drag and Drop Upload**: Supports dragging files or folders from outside the browser for uploading. **Note:** When uploading a large number of files or deeply nested folders, it is recommended to compress them first to avoid browser freezes.
|
||||
3. **Internal Drag and Drop**: You can directly drag and drop files or folders within the file manager to move them.
|
||||
4. **Multiple Selection**: Hold down the `Ctrl` or `Shift` key to select multiple files or folders.
|
||||
5. **Context Menu**: Provides common file operations such as copy, paste, cut, delete, rename, and modify permissions.
|
||||
1. **Fixed Root Explorer**: The file area now uses a single explorer tree rooted at `/`. Expanding a directory shows both child folders and files in the same tree.
|
||||
2. **Quick File Selection**: When the file search box has focus, you can use the `↑/↓` keys to quickly select files.
|
||||
3. **Drag and Drop Upload**: Supports dragging files or folders from outside the browser for uploading. **Note:** When uploading a large number of files or deeply nested folders, it is recommended to compress them first to avoid browser freezes.
|
||||
4. **Internal Drag and Drop**: You can directly drag and drop files or folders within the file manager to move them.
|
||||
5. **Multiple Selection**: Hold down the `Ctrl` or `Shift` key to select multiple files or folders.
|
||||
6. **Context Menu**: Provides common file operations such as copy, paste, cut, delete, rename, and modify permissions.
|
||||
|
||||
### Command History Component
|
||||
|
||||
@@ -191,6 +203,24 @@ Since Apache Guacamole does not provide an ARMv7-compatible image for `guacd`, t
|
||||
4. Since I don't have an ARM machine on hand, I haven't conducted actual testing, so unexpected bugs may occur during runtime.
|
||||
5. For data backup, please back up the **data** folder in the directory yourself. This project does not provide any backup functionality.
|
||||
|
||||
## 🛠️ Development Notes
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev --workspace=@nexus-terminal/frontend
|
||||
npm run dev --workspace=@nexus-terminal/backend
|
||||
npm run dev --workspace=@nexus-terminal/remote-gateway
|
||||
npm run build --workspace=@nexus-terminal/frontend
|
||||
npm run build --workspace=@nexus-terminal/backend
|
||||
npm run build --workspace=@nexus-terminal/remote-gateway
|
||||
```
|
||||
|
||||
### Local Knowledge Base
|
||||
|
||||
The `.helloagents/` directory stores the local knowledge base, module index, and plan packages used for collaboration and change tracking. It is not a runtime dependency, but when the project structure, feature set, or collaboration conventions change, the related entries should be kept in sync.
|
||||
|
||||
|
||||
## 💐 Acknowledgements
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
}"
|
||||
>
|
||||
<h2 class="m-0 mb-6 text-center text-xl font-semibold">{{ isEditing ? t('quickCommands.form.titleEdit', '编辑快捷指令') : t('quickCommands.form.titleAdd', '添加快捷指令') }}</h2>
|
||||
<div class="flex-grow flex space-x-6 min-h-0">
|
||||
<div class="flex-grow flex min-h-0 flex-col gap-4 lg:flex-row lg:gap-6">
|
||||
<!-- 左侧:变量管理 -->
|
||||
<div class="w-1/3 border-r border-border/30 pr-6 flex flex-col overflow-y-auto">
|
||||
<div class="flex max-h-[38vh] w-full flex-col overflow-y-auto border-b border-border/30 pb-4 lg:max-h-none lg:w-1/3 lg:border-b-0 lg:border-r lg:pb-0 lg:pr-6">
|
||||
<h3 class="text-md font-medium mb-3 text-text-secondary">{{ t('quickCommands.form.variablesTitle', '变量管理') }}</h3>
|
||||
<div class="space-y-3 overflow-y-auto flex-grow pr-1 pb-2">
|
||||
<div v-if="localVariables.length === 0" class="text-sm text-text-tertiary p-2 border border-dashed border-border/30 rounded-md">
|
||||
@@ -80,7 +80,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 右侧:现有表单 -->
|
||||
<form @submit.prevent="handleSubmit" class="w-2/3 space-y-5 flex flex-col">
|
||||
<form @submit.prevent="handleSubmit" class="flex w-full flex-col space-y-5 lg:w-2/3">
|
||||
<div class="flex-grow space-y-5 pr-1 flex flex-col">
|
||||
<div>
|
||||
<label for="qc-name" class="block mb-1.5 text-sm font-medium text-text-secondary">{{ t('quickCommands.form.name', '名称:') }}</label>
|
||||
@@ -181,8 +181,8 @@ const isSubmitting = ref(false);
|
||||
|
||||
const modalContentRef = ref<HTMLElement | null>(null);
|
||||
const commandTextareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const R_MIN_WIDTH = 680; // 可调整大小的最小宽度 (像素)
|
||||
const R_MIN_HEIGHT = 520; // 可调整大小的最小高度 (像素)
|
||||
const R_MIN_WIDTH = 580; // 可调整大小的最小宽度 (像素)
|
||||
const R_MIN_HEIGHT = 440; // 可调整大小的最小高度 (像素)
|
||||
const placeholder = t('quickCommands.form.commandPlaceholder') + 'echo "Hello,\${USERNAME}"'
|
||||
|
||||
const { width: resizableWidth, height: resizableHeight } = useResizable(modalContentRef, {
|
||||
@@ -239,8 +239,8 @@ watch(() => formData.command, (newCommand) => {
|
||||
// 初始化表单数据 (如果是编辑模式)
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
let initialW = Math.min(window.innerWidth * 0.82, 960); // 目标 82vw,最大 960px
|
||||
let initialH = Math.min(window.innerHeight * 0.78, 720); // 目标 78vh,最大 720px
|
||||
let initialW = Math.min(window.innerWidth * 0.74, 860); // 目标 74vw,最大 860px
|
||||
let initialH = Math.min(window.innerHeight * 0.68, 600); // 目标 68vh,最大 600px
|
||||
|
||||
initialW = Math.max(R_MIN_WIDTH, initialW);
|
||||
initialH = Math.max(R_MIN_HEIGHT, initialH);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useFileEditorStore, type FileInfo } from '../stores/fileEditor.store';
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
|
||||
import { useFavoritePathsStore, type FavoritePathItem } from '../stores/favoritePaths.store';
|
||||
import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } from '../composables/file-manager/useFileManagerContextMenu';
|
||||
import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection';
|
||||
import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop';
|
||||
@@ -27,16 +26,6 @@ import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
|
||||
type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
|
||||
|
||||
type ExplorerRootSource = 'favorite' | 'current';
|
||||
|
||||
interface ExplorerRootItem {
|
||||
id: string;
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
source: ExplorerRootSource;
|
||||
}
|
||||
|
||||
interface ExplorerTreeRow {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -47,32 +36,9 @@ interface ExplorerTreeRow {
|
||||
isRoot: boolean;
|
||||
loaded: boolean;
|
||||
expanded: boolean;
|
||||
source: ExplorerRootSource | 'tree';
|
||||
item: FileListItem;
|
||||
}
|
||||
|
||||
interface ExplorerOverviewRow {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
description?: string;
|
||||
expanded: boolean;
|
||||
loaded: boolean;
|
||||
childDirectoryCount: number;
|
||||
isRootChild: boolean;
|
||||
}
|
||||
|
||||
interface ExplorerOverviewSection {
|
||||
id: string;
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
loaded: boolean;
|
||||
rowCount: number;
|
||||
rows: ExplorerOverviewRow[];
|
||||
}
|
||||
|
||||
|
||||
// --- Props ---
|
||||
const props = defineProps({
|
||||
@@ -105,7 +71,6 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
const route = useRoute(); // Keep for download URL generation for now
|
||||
const sessionStore = useSessionStore(); // 实例化 Session Store
|
||||
const favoritePathsStore = useFavoritePathsStore();
|
||||
|
||||
// --- 获取并存储 SFTP 管理器实例 ---
|
||||
// 使用 shallowRef 存储管理器实例,以便在 sessionId 变化时切换
|
||||
@@ -161,7 +126,6 @@ const {
|
||||
showPopupFileEditorBoolean, // +++ 获取弹窗设置状态 +++
|
||||
fileManagerShowDeleteConfirmationBoolean, // +++ 获取删除确认设置状态 +++
|
||||
} = storeToRefs(settingsStore); // 使用 storeToRefs 保持响应性
|
||||
const { favoritePaths } = storeToRefs(favoritePathsStore);
|
||||
|
||||
|
||||
|
||||
@@ -287,14 +251,17 @@ const toFileListItem = (node: FileTreeNode): FileListItem => ({
|
||||
attrs: node.attrs,
|
||||
});
|
||||
|
||||
const getDirectoryChildren = (node: FileTreeNode | null): FileTreeNode[] => {
|
||||
const getTreeChildren = (node: FileTreeNode | null): FileTreeNode[] => {
|
||||
if (!node?.children?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...node.children]
|
||||
.filter((child) => child.attrs.isDirectory)
|
||||
.sort((left, right) => left.filename.localeCompare(right.filename));
|
||||
.sort((left, right) => {
|
||||
if (left.attrs.isDirectory && !right.attrs.isDirectory) return -1;
|
||||
if (!left.attrs.isDirectory && right.attrs.isDirectory) return 1;
|
||||
return left.filename.localeCompare(right.filename);
|
||||
});
|
||||
};
|
||||
|
||||
const openFileInWorkspace = (filePath: string, filename: string) => {
|
||||
@@ -311,158 +278,73 @@ const openFileInWorkspace = (filePath: string, filename: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const explorerRoots = computed<ExplorerRootItem[]>(() => {
|
||||
const roots = new Map<string, ExplorerRootItem>();
|
||||
|
||||
favoritePaths.value.forEach((favorite: FavoritePathItem) => {
|
||||
const path = favorite.path?.trim();
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
roots.set(path, {
|
||||
id: `favorite:${favorite.id}`,
|
||||
path,
|
||||
label: favorite.name?.trim() || getPathName(path),
|
||||
description: path,
|
||||
source: 'favorite',
|
||||
});
|
||||
});
|
||||
|
||||
const currentPath = currentSftpManager.value?.currentPath.value?.trim();
|
||||
if (currentPath && !roots.has(currentPath)) {
|
||||
roots.set(currentPath, {
|
||||
id: `current:${currentPath}`,
|
||||
path: currentPath,
|
||||
label: getPathName(currentPath),
|
||||
description: currentPath,
|
||||
source: 'current',
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(roots.values());
|
||||
});
|
||||
|
||||
const explorerTreeRows = computed<ExplorerTreeRow[]>(() => {
|
||||
const rows: ExplorerTreeRow[] = [];
|
||||
const rootNode = findTreeNodeByPath('/');
|
||||
const rootItem: FileListItem = rootNode
|
||||
? toFileListItem(rootNode)
|
||||
: {
|
||||
filename: '/',
|
||||
longname: '/',
|
||||
attrs: {
|
||||
isDirectory: true,
|
||||
isFile: false,
|
||||
isSymbolicLink: false,
|
||||
size: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
mode: 0,
|
||||
atime: 0,
|
||||
mtime: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const appendNodeRows = (basePath: string, nodes: FileListItem[], depth: number) => {
|
||||
sortTreeItems(nodes)
|
||||
.filter((item) => item.attrs.isDirectory)
|
||||
.forEach((item) => {
|
||||
sortTreeItems(nodes).forEach((item) => {
|
||||
const itemPath = currentSftpManager.value?.joinPath(basePath, item.filename) ?? `${basePath}/${item.filename}`;
|
||||
const treeNode = findTreeNodeByPath(itemPath);
|
||||
const expanded = Boolean(explorerExpandedPaths.value[itemPath]);
|
||||
const loaded = Boolean(treeNode?.childrenLoaded);
|
||||
const treeNode = item.attrs.isDirectory ? findTreeNodeByPath(itemPath) : null;
|
||||
const expanded = item.attrs.isDirectory ? Boolean(explorerExpandedPaths.value[itemPath]) : false;
|
||||
const loaded = item.attrs.isDirectory ? Boolean(treeNode?.childrenLoaded) : true;
|
||||
|
||||
rows.push({
|
||||
id: `tree:${itemPath}`,
|
||||
path: itemPath,
|
||||
name: item.filename,
|
||||
depth,
|
||||
isDirectory: true,
|
||||
isDirectory: item.attrs.isDirectory,
|
||||
isRoot: false,
|
||||
loaded,
|
||||
expanded,
|
||||
source: 'tree',
|
||||
item,
|
||||
});
|
||||
|
||||
if (expanded && treeNode?.children?.length) {
|
||||
if (item.attrs.isDirectory && expanded && treeNode?.children?.length) {
|
||||
appendNodeRows(itemPath, treeNode.children.map(toFileListItem), depth + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
explorerRoots.value.forEach((root) => {
|
||||
const node = findTreeNodeByPath(root.path);
|
||||
const rootItem: FileListItem = node
|
||||
? toFileListItem(node)
|
||||
: {
|
||||
filename: getPathName(root.path),
|
||||
longname: root.path,
|
||||
attrs: {
|
||||
isDirectory: true,
|
||||
isFile: false,
|
||||
isSymbolicLink: false,
|
||||
size: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
mode: 0,
|
||||
atime: 0,
|
||||
mtime: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const expanded = explorerExpandedPaths.value[root.path] ?? true;
|
||||
const loaded = Boolean(node?.childrenLoaded);
|
||||
|
||||
rows.push({
|
||||
id: root.id,
|
||||
path: root.path,
|
||||
name: root.label,
|
||||
description: root.description,
|
||||
depth: 0,
|
||||
isDirectory: true,
|
||||
isRoot: true,
|
||||
loaded,
|
||||
expanded,
|
||||
source: root.source,
|
||||
item: rootItem,
|
||||
});
|
||||
|
||||
if (expanded && node?.children?.length) {
|
||||
appendNodeRows(root.path, node.children.map(toFileListItem), 1);
|
||||
}
|
||||
const expanded = explorerExpandedPaths.value['/'] ?? true;
|
||||
rows.push({
|
||||
id: 'root:/',
|
||||
path: '/',
|
||||
name: '/',
|
||||
description: '/',
|
||||
depth: 0,
|
||||
isDirectory: true,
|
||||
isRoot: true,
|
||||
loaded: Boolean(rootNode?.childrenLoaded),
|
||||
expanded,
|
||||
item: rootItem,
|
||||
});
|
||||
|
||||
if (expanded && rootNode?.children?.length) {
|
||||
appendNodeRows('/', rootNode.children.map(toFileListItem), 1);
|
||||
}
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
const explorerOverviewSections = computed<ExplorerOverviewSection[]>(() => {
|
||||
const buildRows = (basePath: string, nodes: FileTreeNode[], depth: number): ExplorerOverviewRow[] => {
|
||||
const rows: ExplorerOverviewRow[] = [];
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const itemPath = currentSftpManager.value?.joinPath(basePath, node.filename) ?? `${basePath}/${node.filename}`;
|
||||
const expanded = Boolean(explorerExpandedPaths.value[itemPath]);
|
||||
const childDirectories = getDirectoryChildren(node);
|
||||
|
||||
rows.push({
|
||||
id: `overview:${itemPath}`,
|
||||
path: itemPath,
|
||||
name: node.filename,
|
||||
depth,
|
||||
expanded,
|
||||
loaded: Boolean(node.childrenLoaded),
|
||||
childDirectoryCount: childDirectories.length,
|
||||
isRootChild: depth === 0,
|
||||
});
|
||||
|
||||
if (expanded && childDirectories.length) {
|
||||
rows.push(...buildRows(itemPath, childDirectories, depth + 1));
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
return explorerRoots.value.map((root) => {
|
||||
const rootNode = findTreeNodeByPath(root.path);
|
||||
const childDirectories = getDirectoryChildren(rootNode);
|
||||
|
||||
return {
|
||||
id: `section:${root.id}`,
|
||||
path: root.path,
|
||||
label: root.label,
|
||||
description: root.description,
|
||||
loaded: Boolean(rootNode?.childrenLoaded),
|
||||
rowCount: childDirectories.length,
|
||||
rows: buildRows(root.path, childDirectories, 0),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const getFileIconClassBase = (filename: string): string => {
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
let extension = '';
|
||||
@@ -1933,23 +1815,6 @@ const handleExplorerOpen = (row: ExplorerTreeRow) => {
|
||||
openFileInWorkspace(row.path, row.name);
|
||||
};
|
||||
|
||||
const handleOverviewSectionOpen = (section: ExplorerOverviewSection) => {
|
||||
focusDirectoryPath(section.path);
|
||||
};
|
||||
|
||||
const handleOverviewRowToggle = (row: ExplorerOverviewRow) => {
|
||||
toggleDirectoryPath(row.path, row.expanded);
|
||||
};
|
||||
|
||||
const handleOverviewRowOpen = (row: ExplorerOverviewRow) => {
|
||||
focusDirectoryPath(row.path);
|
||||
};
|
||||
|
||||
const handleOverviewRefresh = (section: ExplorerOverviewSection) => {
|
||||
explorerExpandedPaths.value[section.path] = true;
|
||||
currentSftpManager.value?.loadDirectory(section.path, true);
|
||||
};
|
||||
|
||||
const isExplorerRowActive = (row: ExplorerTreeRow) => {
|
||||
return isPathActive(row.path);
|
||||
};
|
||||
@@ -1968,13 +1833,19 @@ const isExplorerRowRelated = (row: ExplorerTreeRow) => {
|
||||
};
|
||||
|
||||
watch(
|
||||
explorerRoots,
|
||||
(roots) => {
|
||||
roots.forEach((root) => {
|
||||
if (explorerExpandedPaths.value[root.path] === undefined) {
|
||||
explorerExpandedPaths.value[root.path] = true;
|
||||
}
|
||||
});
|
||||
currentSftpManager,
|
||||
(manager) => {
|
||||
if (!manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (explorerExpandedPaths.value['/'] === undefined) {
|
||||
explorerExpandedPaths.value['/'] = true;
|
||||
}
|
||||
|
||||
if (!manager.fileTree.childrenLoaded || manager.currentPath.value !== '/') {
|
||||
manager.loadDirectory('/');
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -2163,29 +2034,42 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="flex flex-grow min-h-0 overflow-hidden border-t border-border/60">
|
||||
<aside class="w-[260px] flex-shrink-0 border-r border-border/60 bg-header/40 flex flex-col min-h-0">
|
||||
<div class="flex-1 bg-header/20 flex flex-col min-h-0">
|
||||
<div class="px-3 py-3 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
|
||||
<div class="mt-1 text-xs text-text-secondary">{{ explorerRoots.length }} {{ t('fileManager.explorer.rootCount', '个根目录') }}</div>
|
||||
<div class="mt-1 text-xs text-text-secondary">1 {{ t('fileManager.explorer.rootCount', '个根目录') }}</div>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleFavoritePathsModal"
|
||||
class="w-8 h-8 rounded-lg border border-border bg-background text-text-secondary hover:bg-header hover:text-foreground transition-colors"
|
||||
:title="t('favoritePaths.addNew', 'Add new favorite path')"
|
||||
>
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-2 py-2">
|
||||
<div v-if="explorerRoots.length === 0" class="px-3 py-6 text-xs text-text-secondary text-center">
|
||||
{{ t('fileManager.explorer.noRoots', '暂无目录根,请先添加收藏路径或连接后浏览当前目录。') }}
|
||||
<div
|
||||
ref="fileListContainerRef"
|
||||
class="flex-1 min-h-0 overflow-y-auto relative outline-none"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="fileListContainerRef?.focus()"
|
||||
@keydown="handleKeydown"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu.prevent="showContextMenu($event)"
|
||||
tabindex="0"
|
||||
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
|
||||
>
|
||||
<div
|
||||
v-if="showExternalDropOverlay"
|
||||
ref="dropOverlayRef"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/70 text-white text-xl font-semibold rounded z-50 pointer-events-auto"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleOverlayDrop"
|
||||
>
|
||||
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<div class="p-2 space-y-1" :class="{ 'pointer-events-none': showExternalDropOverlay }">
|
||||
<div
|
||||
v-for="row in explorerTreeRows"
|
||||
:key="row.id"
|
||||
@@ -2208,7 +2092,7 @@ watch(
|
||||
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
|
||||
</button>
|
||||
<span v-else class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60">
|
||||
<i class="fas fa-circle"></i>
|
||||
<i class="fas fa-minus"></i>
|
||||
</span>
|
||||
|
||||
<i
|
||||
@@ -2224,137 +2108,16 @@ watch(
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium" :title="row.description || row.path">{{ row.name }}</div>
|
||||
<div
|
||||
v-if="row.isRoot"
|
||||
v-if="row.isRoot || !row.isDirectory"
|
||||
class="truncate text-[10px]"
|
||||
:class="isExplorerRowActive(row) ? 'text-white/75' : 'text-text-secondary/80'"
|
||||
>
|
||||
{{ row.description }}
|
||||
{{ row.path }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- File List Container -->
|
||||
<div
|
||||
ref="fileListContainerRef"
|
||||
class="flex-grow overflow-y-auto relative outline-none"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="fileListContainerRef?.focus()"
|
||||
@keydown="handleKeydown"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu.prevent="showContextMenu($event)"
|
||||
tabindex="0"
|
||||
:style="{ '--row-size-multiplier': rowSizeMultiplier }"
|
||||
>
|
||||
<!-- 外部文件拖拽蒙版 -->
|
||||
<div
|
||||
v-if="showExternalDropOverlay"
|
||||
ref="dropOverlayRef"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/70 text-white text-xl font-semibold rounded z-50 pointer-events-auto"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleOverlayDrop"
|
||||
>
|
||||
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
|
||||
</div>
|
||||
|
||||
<div class="min-h-full p-4 md:p-5 space-y-4" :class="{ 'pointer-events-none': showExternalDropOverlay }">
|
||||
<div class="rounded-2xl border border-border/60 bg-header/30 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">
|
||||
{{ t('fileManager.explorer.overviewTitle', '文件夹总览') }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-foreground">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1">
|
||||
<i class="fas fa-crosshairs text-[11px] text-primary"></i>
|
||||
<span class="truncate max-w-[420px]">{{ currentSftpManager?.currentPath?.value ?? '/' }}</span>
|
||||
</span>
|
||||
<span class="text-text-secondary text-xs">
|
||||
{{ t('fileManager.explorer.overviewHint', '点击目录只展开和聚焦,不再切成单独目录列表。') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!currentSftpManager || currentSftpManager.isLoading.value" class="rounded-2xl border border-border/60 bg-background px-4 py-10 text-center text-text-secondary italic">
|
||||
{{ t('fileManager.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="explorerOverviewSections.length === 0" class="rounded-2xl border border-border/60 bg-background px-4 py-10 text-center text-text-secondary italic">
|
||||
{{ t('fileManager.explorer.noRoots', '暂无目录根,请先添加收藏路径或连接后浏览当前目录。') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<section
|
||||
v-for="section in explorerOverviewSections"
|
||||
:key="section.id"
|
||||
class="rounded-2xl border border-border/60 bg-background/95 shadow-sm overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-header/35 px-4 py-3">
|
||||
<button
|
||||
class="min-w-0 flex items-center gap-3 text-left"
|
||||
@click="handleOverviewSectionOpen(section)"
|
||||
>
|
||||
<i class="fas fa-folder-tree text-primary"></i>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold text-foreground">{{ section.label }}</span>
|
||||
<span class="block truncate text-[11px] text-text-secondary">{{ section.description }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full border border-border/60 bg-background px-2.5 py-1 text-xs text-text-secondary">
|
||||
{{ section.rowCount }} {{ t('fileManager.explorer.folderCount', '个文件夹') }}
|
||||
</span>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg border border-border bg-background text-text-secondary hover:bg-header hover:text-foreground transition-colors"
|
||||
:title="t('common.refresh', '刷新')"
|
||||
@click="handleOverviewRefresh(section)"
|
||||
>
|
||||
<i class="fas fa-sync-alt text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="section.rows.length === 0" class="px-4 py-6 text-sm text-text-secondary">
|
||||
{{ t('fileManager.explorer.emptyFolders', '这个根目录下暂时没有已加载的子文件夹,展开左侧目录可继续浏览。') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="p-3 space-y-1">
|
||||
<div
|
||||
v-for="row in section.rows"
|
||||
:key="row.id"
|
||||
class="group flex items-center gap-3 rounded-xl border px-3 py-2 transition-colors"
|
||||
:class="isPathActive(row.path) ? 'border-primary bg-primary/10 text-foreground' : 'border-transparent text-text-secondary hover:border-border/60 hover:bg-header/40 hover:text-foreground'"
|
||||
:style="{ paddingLeft: `${0.9 + row.depth * 1.1}rem` }"
|
||||
>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center flex-shrink-0 text-[10px]"
|
||||
@click.stop="handleOverviewRowToggle(row)"
|
||||
>
|
||||
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
|
||||
</button>
|
||||
|
||||
<button class="min-w-0 flex items-center gap-3 flex-1 text-left" @click="handleOverviewRowOpen(row)">
|
||||
<i class="fas fa-folder w-4 text-center text-primary flex-shrink-0"></i>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium">{{ row.name }}</span>
|
||||
<span class="block truncate text-[11px]" :class="isPathActive(row.path) ? 'text-primary/80' : 'text-text-secondary/80'">
|
||||
{{ row.path }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span class="inline-flex items-center rounded-full border border-current/10 bg-black/5 px-2 py-0.5 text-[11px] flex-shrink-0">
|
||||
{{ row.childDirectoryCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user