chore: trim repo for customer delivery
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,8 +13,9 @@ frontend/dist/
|
||||
.DS_Store
|
||||
*.local
|
||||
.codex
|
||||
/package-lock.json
|
||||
release/
|
||||
.opencode/package-lock.json
|
||||
.opencode/
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# SFTP标签页状态保持修复实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复SFTP标签页离开后再返回会刷新页面、丢失浏览状态的问题
|
||||
|
||||
**Architecture:** 为SftpView组件添加keep-alive缓存,仅缓存SFTP相关页面,最大缓存10个实例避免内存占用过高,每个路由实例通过fullPath作为唯一key区分
|
||||
|
||||
**Tech Stack:** Vue 3、Vue Router 4、Pinia
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 为SftpView组件添加名称标识
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/SftpView.vue`
|
||||
|
||||
- [ ] **Step 1: 添加组件名称**
|
||||
在script setup开头添加:
|
||||
```typescript
|
||||
defineOptions({ name: 'SftpView' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修改MainLayout添加keep-alive缓存
|
||||
**Files:**
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue:193-195`
|
||||
|
||||
- [ ] **Step 1: 替换原RouterView代码**
|
||||
原代码:
|
||||
```vue
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" v-if="!showTerminalWorkspace" />
|
||||
</RouterView>
|
||||
```
|
||||
替换为:
|
||||
```vue
|
||||
<RouterView v-slot="{ Component }">
|
||||
<keep-alive :include="['SftpView']" :max="10">
|
||||
<component :is="Component" v-if="!showTerminalWorkspace" :key="$route.fullPath" />
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 验证修复效果
|
||||
**Files:**
|
||||
- Test: 手动验证 + 构建验证
|
||||
|
||||
- [ ] **Step 1: 运行类型检查**
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: 构建成功,无类型错误
|
||||
|
||||
- [ ] **Step 2: 手动测试功能**
|
||||
1. 启动开发服务,登录系统
|
||||
2. 打开任意连接的SFTP标签
|
||||
3. 浏览到任意子目录
|
||||
4. 切换到其他页面(如连接列表、终端)
|
||||
5. 切回SFTP标签,确认仍停留在之前浏览的子目录,状态未丢失
|
||||
6. 打开多个不同连接的SFTP标签,切换时确认各自状态独立保存
|
||||
|
||||
---
|
||||
152
AGENTS.md
152
AGENTS.md
@@ -1,152 +0,0 @@
|
||||
# `ssh-manager` AGENTS 指南
|
||||
本文件用于指导在本仓库中执行任务的 agentic coding assistants。
|
||||
目标是在保证安全、可验证和风格统一的前提下,以尽量小的意外完成聚焦改动。
|
||||
## 1. 仓库结构
|
||||
- Monorepo,包含:
|
||||
- `backend/`:Spring Boot 2.7、Java 8、Maven、H2、WebSocket、JWT
|
||||
- `frontend/`:Vue 3、TypeScript、Vite、Pinia、Tailwind、Axios
|
||||
- 后端默认端口:`48080`
|
||||
- 前端开发服务端口:`5173`
|
||||
- 前端开发时会将 `/api` 和 `/ws` 代理到后端
|
||||
- 数据库:文件型 H2,路径为 `./data/sshmanager`
|
||||
- 认证方式:HTTP 使用 Bearer JWT,终端相关 WebSocket 流程在 query 中携带 token
|
||||
## 2. 需优先检查的规则文件
|
||||
- 主要仓库规范来源:本 `AGENTS.md`
|
||||
- Cursor 规则:未发现 `.cursor/rules/` 目录,也未发现 `.cursorrules`
|
||||
- Copilot 规则:未发现 `.github/copilot-instructions.md`
|
||||
- 如果未来新增这些文件,编辑前应先读取,并将其视为高优先级约束
|
||||
## 3. 工具链
|
||||
- JDK:`8+`
|
||||
- Maven:`3.6+`
|
||||
- Node.js:`18+`
|
||||
- npm:使用与 `frontend/package-lock.json` 兼容的版本
|
||||
- Docker Compose:通过 `docker compose` 使用
|
||||
## 4. 构建 / 运行 / 测试命令
|
||||
### 后端(`backend/`)
|
||||
- 启动开发服务:`mvn spring-boot:run`
|
||||
- 打包应用:`mvn package`
|
||||
- 跳过测试打包:`mvn -DskipTests package`
|
||||
- 运行全部测试:`mvn test`
|
||||
- 运行单个测试类:`mvn -Dtest=ConnectionServiceTest test`
|
||||
- 运行单个测试方法:`mvn -Dtest=ConnectionServiceTest#shouldCreateConnection test`
|
||||
- 运行同一类中的多个方法:`mvn -Dtest=ConnectionServiceTest#testA,testB test`
|
||||
### 前端(`frontend/`)
|
||||
- 安装依赖:`npm install`
|
||||
- 启动开发服务:`npm run dev`
|
||||
- 生产构建:`npm run build`
|
||||
- 预览构建产物:`npm run preview`
|
||||
- 当前没有独立的 `lint` script
|
||||
- 当前没有独立的前端测试 script
|
||||
- `npm run build` 会先执行 `vue-tsc -b`,因此它也是主要的前端类型检查入口
|
||||
### Docker / 根目录(`./`)
|
||||
- 构建镜像:`docker compose -f docker/docker-compose.yml build`
|
||||
- 前台启动:`docker compose -f docker/docker-compose.yml up`
|
||||
- 后台启动:`docker compose -f docker/docker-compose.yml up -d`
|
||||
- Make 快捷命令:
|
||||
- `make build`
|
||||
- `make up`
|
||||
- `make down`
|
||||
- `make restart`
|
||||
- `make logs`
|
||||
- `make ps`
|
||||
## 5. 测试策略
|
||||
- 优先选择能覆盖改动的最小测试命令
|
||||
- 对后端改动,先运行受影响的单个测试,再按需要扩大范围
|
||||
- 现有后端测试位于 `backend/src/test/java`
|
||||
- 当前后端测试类包括控制器和服务测试,如 `ConnectionControllerTest`、`SftpControllerTest`、`ConnectionServiceTest`、`SftpServiceTest`
|
||||
- 如果改动涉及 Spring MVC、安全、WebSocket、SSH 或 SFTP 流程,优先补充或运行定向回归测试,而不只是编译通过
|
||||
- 前端当前没有提交测试套件;对前端改动,至少运行 `npm run build`
|
||||
## 6. 改动范围规则
|
||||
- 保持改动小且聚焦任务本身
|
||||
- 实现需求时不要顺手重构无关文件
|
||||
- 除非任务明确要求,否则不要静默改变 API 语义
|
||||
- 如果后端契约发生变化,需要在同一改动中同步更新前端 API / 类型
|
||||
- 严禁提交 secrets、密码、token、私钥或带环境属性的敏感凭据
|
||||
## 7. Git 与提交规范
|
||||
- 开始修改前先查看工作区状态,识别是否存在与当前任务无关的脏变更
|
||||
- 不要回退、覆盖或格式化并非由当前任务引入的用户改动
|
||||
- 除非用户明确要求,否则不要主动创建 commit、tag、分支或 PR
|
||||
- 避免使用 `git reset --hard`、`git checkout --`、强制推送等破坏性命令
|
||||
- 若需要提交,提交内容应仅包含与当前任务直接相关的文件
|
||||
- 提交说明建议简洁描述“为什么改”,而不只是罗列“改了什么”
|
||||
- 若因 hooks 或生成步骤导致文件变动,应先确认内容合理,再决定是否纳入同一提交
|
||||
## 8. 后端约定(`backend/`)
|
||||
### import 与文件结构
|
||||
- 包名前缀固定为 `com.sshmanager`
|
||||
- 保持显式 import,不使用通配符 import
|
||||
- 遵循仓库现有 import 分组风格:
|
||||
- 先项目内 import:`com.sshmanager...`
|
||||
- 再框架 / 第三方:`org...`、`javax...` 等
|
||||
- 最后 JDK:`java...`
|
||||
- 控制器放在 `controller`,服务放在 `service`,仓库层放在 `repository`,DTO 放在 `dto`,实体放在 `entity`,安全相关代码放在 `security`
|
||||
### 格式与命名
|
||||
- 使用 4 空格缩进
|
||||
- 左花括号与声明同行
|
||||
- 类名使用 `PascalCase`
|
||||
- 方法名和字段名使用 `camelCase`
|
||||
- 常量使用 `UPPER_SNAKE_CASE`
|
||||
- Spring 类型命名后缀保持一致:`*Controller`、`*Service`、`*Repository`
|
||||
### 类型与 Spring 用法
|
||||
- 保持 Java 8 兼容,不要引入更高版本语言特性
|
||||
- 优先使用构造器注入
|
||||
- 控制器返回 `ResponseEntity<?>` 或 `ResponseEntity<T>`,与现有代码保持一致
|
||||
- 请求 / 响应边界优先使用 DTO;除非某文件本来就是如此,否则不要直接从控制器暴露实体
|
||||
- 持久化操作应保留在 service / repository 边界内,不要堆进 controller
|
||||
- 对需要原子性的写操作使用 `@Transactional`
|
||||
### 错误处理与安全
|
||||
- 维持现有 JSON 错误格式,使用 `message` 或 `error` 字段
|
||||
- HTTP 状态码保持一致:
|
||||
- `400`:参数或输入错误
|
||||
- `401`:认证 / 授权失败
|
||||
- `500`:未预期的服务端异常
|
||||
- 不要在日志中输出明文密码、私钥、口令、JWT 或解密后的凭据
|
||||
- 加解密逻辑统一集中在 `EncryptionService`
|
||||
- 修改认证逻辑时,要同时验证 HTTP 与 WebSocket token 处理
|
||||
- 注意 `ChannelSftp` 不是线程安全的,不要在并发场景共享实例
|
||||
## 9. 前端约定(`frontend/`)
|
||||
### Vue 与 TypeScript 结构
|
||||
- Vue 单文件组件使用 `<script setup lang="ts">`
|
||||
- 新增前端逻辑时优先使用 TypeScript
|
||||
- ref、路由参数、API payload 尽量强类型化,不要随手使用 `any`
|
||||
- 先新增或更新 API 层类型,再接入 UI 行为
|
||||
- 有状态的数据流应保留在 Pinia store 或 API 模块中,不要在多个视图里重复维护
|
||||
### import 与命名
|
||||
- 遵循当前观察到的 import 顺序:
|
||||
- Vue 核心包
|
||||
- router / store
|
||||
- 本地 API / 类型 / 组件
|
||||
- 未与核心库归组时的第三方 UI / 图标库
|
||||
- 组件文件名使用 `PascalCase.vue`
|
||||
- Store 命名使用 `useXxxStore`
|
||||
- 变量 / 函数名使用 `camelCase`
|
||||
- 常量使用 `UPPER_SNAKE_CASE`
|
||||
### 格式与界面模式
|
||||
- 使用 2 空格缩进
|
||||
- TS / Vue script 块保持现有的无分号、单引号风格
|
||||
- 优先使用 Tailwind 工具类,不引入新的样式体系
|
||||
- 除非任务明确要求改版,否则保留当前 `slate` / `cyan` 视觉语言
|
||||
- 保持基础可访问性:表单标签、按钮状态、必要的 `aria-*` 属性,以及键盘可操作性
|
||||
### 错误处理与性能
|
||||
- 重要操作失败时要给用户可见反馈,不要静默失败
|
||||
- 除非明确要改认证流程,否则保留当前 Axios 拦截器中的登录失效处理逻辑
|
||||
- 对终端和 SFTP 视图要特别注意,避免不必要重渲染和高成本响应式循环
|
||||
- 对可选后端数据优先做空值安全处理,如 `string | null` 与可选响应字段
|
||||
## 10. 完成前验证
|
||||
- 仅后端改动:至少运行最相关的 `mvn -Dtest=... test` 或 `mvn test`
|
||||
- 仅前端改动:运行 `npm run build`
|
||||
- 全栈改动:运行最窄范围的后端测试,再运行 `npm run build`
|
||||
- 如果运行时行为发生变化,在可能时手工检查受影响流程,如登录、连接 CRUD、终端或 SFTP
|
||||
- 最终说明中应明确写出已验证内容与未验证内容
|
||||
## 11. 仓库特定说明
|
||||
- 后端配置文件位于 `backend/src/main/resources/application.yml`
|
||||
- 与安全相关的环境变量包括 `SSHMANAGER_JWT_SECRET` 和 `SSHMANAGER_ENCRYPTION_KEY`
|
||||
- 生产部署必须提供真实有效的这些密钥
|
||||
- 前端认证 token 当前存储在 `localStorage`
|
||||
- `frontend/src/api/client.ts` 在收到 `401` 响应时会跳转到 `/login`
|
||||
## 12. 在本仓库中的良好 agent 行为
|
||||
- 先阅读现有代码,再决定采用什么模式
|
||||
- 匹配周边代码风格,不要强行套用新偏好
|
||||
- 优先做手术式修改,而不是大范围重写
|
||||
- 当改动会让相邻文档或类型失真时,一并更新它们
|
||||
- 只在任务范围内把工作区变得更整洁
|
||||
- 输出结果时优先说明实际改动、验证情况以及仍未覆盖的风险点
|
||||
@@ -1,49 +0,0 @@
|
||||
# Moba Workspace 实施状态
|
||||
|
||||
## 当前结论
|
||||
- `/moba` 是当前唯一主工作区入口。
|
||||
- 历史路径 `/connections`、`/terminal` 仅保留兼容跳转;`/terminal/:id`、`/sftp/:id` 会打开对应工作区后进入 `/moba`,不再维护旧布局并行能力。
|
||||
- 本轮已补齐多实例工作区、嵌入式 SFTP 主要能力、移动端侧边栏抽屉和顶部面板控制。
|
||||
|
||||
## 已落地能力
|
||||
|
||||
### 工作区
|
||||
- 支持同一连接打开多个独立工作区实例
|
||||
- 顶部标签按实例显示,并支持关闭当前 / 关闭其他 / 关闭右侧 / 全部关闭
|
||||
- 支持终端面板显隐、SFTP 面板显隐、分屏比例重置
|
||||
- 分屏比例、活动工作区、SFTP 路径仍会持久化到本地
|
||||
|
||||
### 会话树
|
||||
- 创建文件夹、重命名、删除、拖拽排序
|
||||
- 搜索、展开/折叠全部、右键菜单
|
||||
- 支持手动排序和名称排序切换
|
||||
- 会话树变更会同步到服务端,失败时显示可重试提示
|
||||
|
||||
### 嵌入式 SFTP
|
||||
- 文件浏览、上传、下载、删除、创建目录
|
||||
- 搜索、隐藏文件切换、路径直达
|
||||
- 上传进度面板
|
||||
- 远程传输弹窗、进度轮询、取消传输
|
||||
- 删除和新建目录已改为弹窗交互,不再使用浏览器阻塞式对话框
|
||||
|
||||
### 响应式
|
||||
- 小屏下会话树改为抽屉
|
||||
- 顶部工具栏和工作区按钮支持折行
|
||||
- Transfers 弹层和工作区布局支持窄屏访问
|
||||
|
||||
## 仍建议继续优化
|
||||
- 会话树大数据量场景的计算优化,目前虚拟滚动已做,但 flatten/sort 仍是计算热点
|
||||
- `/terminal/:id` 与 `/sftp/:id` 仍保留轻量兼容入口,用于承接旧深链接
|
||||
- Transfers 页面本身仍偏桌面布局,可继续细化移动端交互
|
||||
|
||||
## 验证方式
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
更完整的手工回归步骤见:
|
||||
- `docs/moba-regression-checklist.md`
|
||||
|
||||
## 最近一次更新
|
||||
- 2026-04-14
|
||||
123
README.md
123
README.md
@@ -1,47 +1,16 @@
|
||||
# SSH 管理器
|
||||
# SSH 管理器部署说明
|
||||
|
||||
这是一个适合按“源码 + Docker 部署”方式售卖的 SSH / SFTP 管理项目。
|
||||
这是一份直接给客户使用的部署文档。
|
||||
|
||||
买家拿到后,不需要装 Windows 客户端,只要会用 Docker,就能按说明启动。后面如果要二开,也可以直接在源码上改。
|
||||
你只需要按下面步骤启动,不需要看开发文档。
|
||||
|
||||
## 这项目适合谁
|
||||
|
||||
- 经常连服务器的开发者
|
||||
- 小团队运维
|
||||
- NAS / 云主机用户
|
||||
- 想找 FinalShell / MobaXterm 替代方案的人
|
||||
|
||||
## 这项目能做什么
|
||||
|
||||
- SSH 终端
|
||||
- SFTP 文件管理
|
||||
- 批量命令执行
|
||||
- 连接和会话树备份恢复
|
||||
- 历史日志与传输记录
|
||||
- 默认管理员首次登录强制改密
|
||||
|
||||
## 建议怎么卖
|
||||
|
||||
建议你对外只卖这一种:
|
||||
|
||||
`源码交付 + Docker 部署版`
|
||||
|
||||
建议交付给买家的内容只有这几样:
|
||||
|
||||
1. 当前仓库源码
|
||||
2. 这份 `README.md`
|
||||
3. 默认账号说明
|
||||
4. Docker 部署说明
|
||||
|
||||
不再提供 Windows 安装包,也不再提供双击启动脚本。
|
||||
|
||||
## 最简单的启动方式
|
||||
|
||||
先准备:
|
||||
## 先准备
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
## 启动
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
@@ -52,13 +21,21 @@ docker compose -f docker/docker-compose.yml up -d --build
|
||||
|
||||
`http://localhost:48080`
|
||||
|
||||
默认登录账号:
|
||||
## 默认登录账号
|
||||
|
||||
- 用户名:`admin`
|
||||
- 密码:`admin123`
|
||||
|
||||
首次登录后请先修改密码。
|
||||
|
||||
## 这个版本能做什么
|
||||
|
||||
- SSH 终端
|
||||
- SFTP 文件管理
|
||||
- 批量命令执行
|
||||
- 连接和会话树备份恢复
|
||||
- 历史日志与传输记录
|
||||
|
||||
## 常用命令
|
||||
|
||||
启动:
|
||||
@@ -85,7 +62,7 @@ docker compose -f docker/docker-compose.yml down
|
||||
docker compose -f docker/docker-compose.yml ps
|
||||
```
|
||||
|
||||
如果你习惯 `make`,也可以直接用:
|
||||
如果你的环境已经安装了 `make`,也可以选用:
|
||||
|
||||
```bash
|
||||
make up
|
||||
@@ -93,11 +70,11 @@ make logs
|
||||
make down
|
||||
```
|
||||
|
||||
## 数据会放在哪里
|
||||
## 数据说明
|
||||
|
||||
Docker 默认把数据放在命名卷 `app-data` 里。
|
||||
数据默认保存在 Docker 命名卷 `app-data` 里。
|
||||
|
||||
日常停止服务用:
|
||||
平时停止服务请使用:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
@@ -109,58 +86,20 @@ docker compose -f docker/docker-compose.yml down
|
||||
docker compose -f docker/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
因为这会把数据一起删掉。
|
||||
因为这会把数据一起删除。
|
||||
|
||||
## 给买家时可以直接这样说
|
||||
## 遇到问题先检查
|
||||
|
||||
```text
|
||||
这是源码交付 + Docker 部署版,不是练手 demo。
|
||||
买回去后按说明执行一条 docker compose 命令就能跑起来。
|
||||
支持 SSH、SFTP、批量命令、备份恢复,适合开发者、运维和 NAS 用户。
|
||||
```
|
||||
1. Docker 是否正常启动
|
||||
2. 端口 `48080` 是否被占用
|
||||
3. 执行 `docker compose -f docker/docker-compose.yml logs -f` 查看报错
|
||||
4. 确认浏览器访问的是 `http://localhost:48080`
|
||||
|
||||
闲鱼商品文案见:
|
||||
## 建议启动后检查一遍
|
||||
|
||||
- `docs/xianyu-sales-copy.md`
|
||||
|
||||
## 如果你自己要开发
|
||||
|
||||
后端:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端开发地址:
|
||||
|
||||
`http://localhost:5173`
|
||||
|
||||
后端开发地址:
|
||||
|
||||
`http://localhost:48080`
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `SSHMANAGER_ENCRYPTION_KEY`:连接密码加密密钥
|
||||
- `SSHMANAGER_JWT_SECRET`:JWT 密钥
|
||||
- `DATA_DIR`:数据目录
|
||||
|
||||
Docker 默认已经在 `docker/docker-compose.yml` 里给了可运行示例。正式卖给客户时,建议你改成自己的密钥再交付。
|
||||
|
||||
## 发货前自己至少检查一遍
|
||||
|
||||
1. Docker 版能正常启动
|
||||
2. 能正常登录并修改密码
|
||||
3. 能创建一条连接
|
||||
4. 能打开终端
|
||||
5. 能打开 SFTP
|
||||
1. 能正常登录并修改密码
|
||||
2. 能创建一条连接
|
||||
3. 能打开终端
|
||||
4. 能打开 SFTP
|
||||
5. 能执行一次批量命令
|
||||
6. 能导出一次备份
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Docker 单容器部署
|
||||
|
||||
前端打包后放入 Spring Boot `static`,与 Java 一起在同一个容器内启动,不使用 Nginx。
|
||||
|
||||
## 国内源
|
||||
|
||||
- **npm**:`docker/.npmrc` 使用 npmmirror(淘宝镜像)
|
||||
- **Maven**:`docker/maven-settings.xml` 使用阿里云仓库
|
||||
|
||||
## 构建与运行
|
||||
|
||||
在**项目根目录**执行:
|
||||
|
||||
```bash
|
||||
# 一键(推荐)
|
||||
make up
|
||||
|
||||
# 构建镜像
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
|
||||
# 前台运行
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
|
||||
# 后台运行
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
make logs # 查看日志
|
||||
make ps # 查看状态
|
||||
make down # 停止并移除容器
|
||||
```
|
||||
|
||||
访问:http://localhost:48080
|
||||
|
||||
## 环境变量(可选)
|
||||
|
||||
- `SSHMANAGER_ENCRYPTION_KEY`:连接密码加密密钥(生产务必修改)
|
||||
- `SSHMANAGER_JWT_SECRET`:JWT 密钥(生产务必修改)
|
||||
- `TZ`:时区,默认 `Asia/Shanghai`
|
||||
|
||||
## 数据持久化
|
||||
|
||||
H2 数据目录通过 volume `app-data` 挂载到 `/app/data`,重启容器数据保留。
|
||||
@@ -1,43 +0,0 @@
|
||||
# SSH Manager Transfer Console - Design System (Master)
|
||||
|
||||
Goal: a fast, reliable, ops-style UI for moving data across many hosts.
|
||||
|
||||
Design principles
|
||||
- Transfer-first: primary surface is "plans / queue / progress"; connections are supporting data.
|
||||
- Dense but calm: show more information without visual noise; consistent rhythm and spacing.
|
||||
- Failure is actionable: errors are specific, local to the job, and keep context.
|
||||
- Keyboard-friendly: visible focus rings, logical tab order, no hover-only actions.
|
||||
|
||||
Color and surfaces (dark-first)
|
||||
- Background: deep slate with subtle gradient + faint grid/noise.
|
||||
- Surfaces: layered cards (solid + slight transparency) with visible borders.
|
||||
- Accent: cyan for primary actions and progress.
|
||||
- Status:
|
||||
- Success: green
|
||||
- Warning: amber
|
||||
- Danger: red
|
||||
|
||||
Typography
|
||||
- Headings: IBM Plex Sans (600-700)
|
||||
- Body: IBM Plex Sans (400-500)
|
||||
- Mono (paths, hostnames, commands): IBM Plex Mono
|
||||
|
||||
Spacing and layout
|
||||
- App shell: left rail (nav) + main content; content uses max width on desktop.
|
||||
- Cards: 12-16px padding on mobile, 16-20px on desktop.
|
||||
- Touch targets: >= 44px for buttons / list rows.
|
||||
|
||||
Interaction
|
||||
- Buttons: disable during async; show inline spinner + label change ("Starting…").
|
||||
- Loading: skeleton for lists; avoid layout jump.
|
||||
- Motion: 150-250ms transitions; respect prefers-reduced-motion.
|
||||
|
||||
Accessibility
|
||||
- Contrast: normal text >= 4.5:1.
|
||||
- Focus: always visible focus ring on interactive elements.
|
||||
- Icon-only buttons must have aria-label.
|
||||
|
||||
Transfer UX patterns
|
||||
- "Plan" = input + targets + options; "Run" produces jobs in a queue.
|
||||
- Queue rows show: source, targets count, status, progress, started/finished, retry.
|
||||
- Progress: per-target progress when available (XHR upload), otherwise discrete states.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,197 +0,0 @@
|
||||
# Remote -> Many Multi-File (Files Only) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Allow Transfers -> `Remote -> Many` to select multiple source files (files only) and transfer them to many targets.
|
||||
|
||||
**Architecture:** Frontend-only change. Expand `sourcePaths x targetConnectionIds` into a queue of items; each item uses existing backend `/sftp/transfer-remote/tasks` and SSE progress tracking.
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript (Vite), Pinia, Spring Boot backend unchanged.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
Modify:
|
||||
- `frontend/src/views/TransfersView.vue` (Remote -> Many UI + state)
|
||||
- `frontend/src/components/SftpFilePickerModal.vue` (add multi-select mode)
|
||||
- `frontend/src/stores/transfers.ts` (support `sourcePaths[]`, `targetMode`, improve SSE unsubscription)
|
||||
|
||||
No backend changes required.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add multi-select mode to SftpFilePickerModal
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/SftpFilePickerModal.vue`
|
||||
|
||||
- [ ] **Step 1: Update component API (props + emits)**
|
||||
- Add optional prop: `multiple?: boolean` (default `false`).
|
||||
- Keep existing event: `select` for single selection.
|
||||
- Add new event: `select-many` for multi selection (payload: `string[]`).
|
||||
|
||||
- [ ] **Step 2: Add selection state and deterministic ordering**
|
||||
- Track selected paths in selection-time order (array) + a set for O(1) membership.
|
||||
- File click behavior:
|
||||
- Directory: navigate into directory.
|
||||
- File:
|
||||
- if `multiple`: toggle selection.
|
||||
- else: emit `select(path)` and close (keep existing).
|
||||
- Persist selection across directory navigation within the modal session.
|
||||
- If a file is toggled off and later toggled on again, re-add it at the end of the array (selection-time order).
|
||||
|
||||
- [ ] **Step 2.1: Add a visible selection indicator**
|
||||
- When `multiple`, render a checkbox in each file row (checked = selected).
|
||||
- Directory rows never render a checkbox.
|
||||
|
||||
- [ ] **Step 3: Add modal footer controls**
|
||||
- Add footer actions when `multiple`:
|
||||
- `Confirm (N)` (disabled when `N=0`) emits `select-many(paths)` then closes.
|
||||
- `Cancel` closes without emitting.
|
||||
- Add a "Selected (N)" compact summary area with remove (`x`) for each selected file.
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
- Run: `npm --prefix frontend run build`
|
||||
- Expected: build succeeds.
|
||||
- Manual:
|
||||
- Open picker, select multiple files across different directories, ensure selections persist.
|
||||
- Remove items in the "Selected" summary.
|
||||
- Confirm emits all paths in selection-time order.
|
||||
- Verify search still filters entries correctly.
|
||||
- Verify hidden-files toggle still works as before.
|
||||
|
||||
- [ ] **Step 5: Commit (optional)**
|
||||
- If doing commits:
|
||||
- Run: `git add frontend/src/components/SftpFilePickerModal.vue`
|
||||
- Run: `git commit -m "feat: add multi-select mode to SFTP file picker"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update TransfersView Remote -> Many UI for multi-file selection
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/TransfersView.vue`
|
||||
|
||||
- [ ] **Step 1: Update remote source model**
|
||||
- Replace `remoteSourcePath: string` with `remoteSourcePaths: string[]`.
|
||||
- UI:
|
||||
- Show count + list of selected paths (basename + full path tooltip).
|
||||
- Provide remove per item + clear all.
|
||||
|
||||
- [ ] **Step 1.1: Add client-side validation for typed paths**
|
||||
- Before starting Remote -> Many:
|
||||
- validate each `remoteSourcePaths[i]` is non-empty
|
||||
- validate it does not end with `/` (heuristic directory indicator)
|
||||
- If invalid: show inline error and block Start.
|
||||
|
||||
- [ ] **Step 2: Wire picker in multi mode**
|
||||
- Call `<SftpFilePickerModal :multiple="true" ... />`.
|
||||
- Listen for `select-many` and merge paths into `remoteSourcePaths`:
|
||||
- append + de-duplicate by full path while preserving first-seen order.
|
||||
|
||||
- [ ] **Step 3: Add target path mode toggle**
|
||||
- Add UI control: `Directory` (default) vs `Exact Path`.
|
||||
- Behavior:
|
||||
- `Directory` treats input as directory; normalize to end with `/`; append source filename.
|
||||
- `Exact Path` uses raw input as full file path (only allowed when exactly one source file is selected).
|
||||
- Disable `Exact Path` when `remoteSourcePaths.length > 1` (force directory mode).
|
||||
|
||||
- [ ] **Step 3.1: Update Start button enablement**
|
||||
- Require `remoteSourceConnectionId != null`.
|
||||
- Require `remoteSourcePaths.length > 0`.
|
||||
- Require `remoteSelectedTargets.length > 0`.
|
||||
- Also require validation in Step 1.1 passes.
|
||||
|
||||
- [ ] **Step 4: Add basename collision warning**
|
||||
- When `Directory` mode and `remoteSourcePaths` contains duplicate basenames:
|
||||
- show a warning dialog/banner before starting (acknowledge to proceed).
|
||||
- message mentions overwrite will happen on each selected target connection.
|
||||
|
||||
- [ ] **Step 5: Source connection change behavior**
|
||||
- When `remoteSourceConnectionId` changes:
|
||||
- clear `remoteSourcePaths`
|
||||
- remove source id from `remoteSelectedTargets` if present
|
||||
|
||||
- [ ] **Step 6: Manual verification**
|
||||
- Run: `npm --prefix frontend run build`
|
||||
- Manual:
|
||||
- Pick 2 files, choose 2 targets -> queue shows 4 items.
|
||||
- Toggle Directory/Exact Path:
|
||||
- multi-file forces Directory
|
||||
- single-file allows Exact Path
|
||||
- Change source connection -> selected files cleared.
|
||||
|
||||
- [ ] **Step 7: Commit (optional)**
|
||||
- If doing commits:
|
||||
- Run: `git add frontend/src/views/TransfersView.vue frontend/src/components/SftpFilePickerModal.vue`
|
||||
- Run: `git commit -m "feat: allow selecting multiple remote source files"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update transfers store to support multi-file Remote -> Many + safe SSE unsubscription
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/stores/transfers.ts`
|
||||
|
||||
- [ ] **Step 1: Update store API and types**
|
||||
- Change `startRemoteToMany` params:
|
||||
- from: `sourcePath: string`
|
||||
- to: `sourcePaths: string[]`
|
||||
- Add param: `targetMode: 'dir' | 'path'`.
|
||||
|
||||
- [ ] **Step 2: Implement target path resolution in store**
|
||||
- For each `sourcePath`:
|
||||
- compute `filename` as basename.
|
||||
- if `targetMode === 'dir'`: normalize `targetDirOrPath` to end with `/` and append filename.
|
||||
- if `targetMode === 'path'`: require `sourcePaths.length === 1` and use raw `targetDirOrPath`.
|
||||
|
||||
- [ ] **Step 3: Expand items as `sourcePaths x targets`**
|
||||
- Create a `TransferItem` per pair.
|
||||
- Label includes both source path and target connection.
|
||||
- Concurrency limiter applies across the full list.
|
||||
|
||||
- [ ] **Step 4: Fix SSE subscription cleanup**
|
||||
- In `waitForRemoteTransfer` and upload-wait logic:
|
||||
- ensure the returned unsubscribe is called on terminal states (success/error/cancel).
|
||||
- still store unsubscribe in the run controller so `cancelRun` can close any active subscriptions.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
- Run: `npm --prefix frontend run build`
|
||||
- Manual:
|
||||
- Start a small run and ensure completion does not leave SSE connections open (observe via browser network if needed).
|
||||
- Cancel a run and confirm subscriptions close promptly.
|
||||
|
||||
- [ ] **Step 6: Commit (optional)**
|
||||
- If doing commits:
|
||||
- Run: `git add frontend/src/stores/transfers.ts`
|
||||
- Run: `git commit -m "fix: close SSE subscriptions for transfer tasks"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: End-to-end verification
|
||||
|
||||
**Files:**
|
||||
- Verify: `frontend/src/views/TransfersView.vue`
|
||||
- Verify: `frontend/src/components/SftpFilePickerModal.vue`
|
||||
- Verify: `frontend/src/stores/transfers.ts`
|
||||
|
||||
- [ ] **Step 1: Frontend build**
|
||||
- Run: `npm --prefix frontend run build`
|
||||
- Expected: success.
|
||||
|
||||
- [ ] **Step 2: Smoke test (manual)**
|
||||
- Start app (if needed):
|
||||
- Backend: `mvn -f backend/pom.xml spring-boot:run`
|
||||
- Frontend: `npm --prefix frontend run dev`
|
||||
- If deps aren't installed:
|
||||
- Frontend: `npm --prefix frontend install`
|
||||
- Use Transfers:
|
||||
- Remote -> Many: pick 2-3 files, 2 targets, Directory mode.
|
||||
- Verify files arrive at target directory with correct names.
|
||||
- Verify errors are per-item, run continues for other items.
|
||||
|
||||
- [ ] **Step 3: Commit (optional)**
|
||||
- If doing a final commit:
|
||||
- Run: `git add frontend/src/views/TransfersView.vue frontend/src/components/SftpFilePickerModal.vue frontend/src/stores/transfers.ts`
|
||||
- Run: `git commit -m "feat: support multi-file Remote -> Many transfers"`
|
||||
@@ -1,105 +0,0 @@
|
||||
# Nav Swap Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Swap the sidebar order so `连接列表` appears above `传输` without changing routing or behavior.
|
||||
|
||||
**Architecture:** This is a template-only frontend change in the shared main layout. The implementation keeps the existing `RouterLink` blocks, active-state logic, click handlers, icons, and labels intact, and only reorders the two navigation entries in `frontend/src/layouts/MainLayout.vue`.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Vite, Tailwind CSS, vue-tsc
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Contains the sidebar navigation template.
|
||||
- Only the relative position of the `/connections` and `/transfers` `RouterLink` blocks should change.
|
||||
- Verify: `frontend/src/router/index.ts`
|
||||
- Confirms the default redirect remains `/connections` and does not need modification.
|
||||
- Verify: `docs/superpowers/specs/2026-03-24-nav-swap-design.md`
|
||||
- Source of truth for this tiny UI change.
|
||||
|
||||
### Task 1: Reorder Sidebar Navigation Entries
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Verify: `frontend/src/router/index.ts`
|
||||
- Verify: `docs/superpowers/specs/2026-03-24-nav-swap-design.md`
|
||||
|
||||
- [ ] **Step 1: Check workspace state before editing**
|
||||
|
||||
Review `git status` and confirm there are no unrelated user changes in `frontend/src/layouts/MainLayout.vue` that must be preserved. Do not revert unrelated modifications.
|
||||
|
||||
- [ ] **Step 2: Inspect the existing sidebar navigation blocks**
|
||||
|
||||
Confirm that `frontend/src/layouts/MainLayout.vue` currently renders the `RouterLink` for `/transfers` before the `RouterLink` for `/connections`, and verify that both links already contain the correct label, icon, `aria-label`, active-state class, and `@click="closeSidebar"` behavior.
|
||||
|
||||
- [ ] **Step 3: Write the minimal implementation by swapping the two blocks**
|
||||
|
||||
Move the entire `/connections` `RouterLink` block so it appears before the `/transfers` `RouterLink` block.
|
||||
|
||||
Keep all existing content unchanged:
|
||||
|
||||
```vue
|
||||
<RouterLink
|
||||
to="/connections"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
||||
aria-label="连接列表"
|
||||
>
|
||||
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>连接列表</span>
|
||||
</RouterLink>
|
||||
```
|
||||
|
||||
```vue
|
||||
<RouterLink
|
||||
to="/transfers"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/transfers' }"
|
||||
aria-label="传输"
|
||||
>
|
||||
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>传输</span>
|
||||
</RouterLink>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify no route behavior changed**
|
||||
|
||||
Read `frontend/src/router/index.ts` and confirm these lines are still untouched:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
redirect: '/connections',
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the frontend verification build**
|
||||
|
||||
Run: `npm run build` in `frontend/`
|
||||
|
||||
Expected:
|
||||
- `vue-tsc -b` passes
|
||||
- Vite production build completes successfully
|
||||
|
||||
- [ ] **Step 6: Manually verify the UI behavior**
|
||||
|
||||
Check the sidebar in the app and confirm:
|
||||
- `连接列表` is above `传输`
|
||||
- Clicking each item still opens the same page
|
||||
- Active highlighting still matches the current route
|
||||
- Mobile sidebar uses the same order
|
||||
|
||||
- [ ] **Step 7: Commit the focused change if requested**
|
||||
|
||||
Only if the user asks for a commit:
|
||||
|
||||
```bash
|
||||
git add frontend/src/layouts/MainLayout.vue
|
||||
git commit -m "fix: reorder sidebar navigation items"
|
||||
```
|
||||
@@ -1,201 +0,0 @@
|
||||
# SFTP Sidebar Tabs Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add non-duplicated, session-scoped SFTP tabs in the sidebar so users can return to file sessions without losing tab entries.
|
||||
|
||||
**Architecture:** Introduce a dedicated Pinia store (`sftpTabs`) parallel to `terminalTabs`, then wire it into `ConnectionsView`, `MainLayout`, and `SftpView`. Keep `/sftp/:id` as the single SFTP route and use route-driven synchronization so tab switching updates the view correctly without stale state.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Pinia, Vue Router, Vite, vue-tsc
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `frontend/src/stores/sftpTabs.ts`
|
||||
- Owns SFTP tab state and actions (`openOrFocus`, `activate`, `close`).
|
||||
- Modify: `frontend/src/views/ConnectionsView.vue`
|
||||
- Registers/focuses SFTP tab before routing to `/sftp/:id`.
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Renders SFTP sidebar tabs, handles click/close navigation behavior.
|
||||
- Modify: `frontend/src/views/SftpView.vue`
|
||||
- Keeps route param and SFTP tab state in sync via watcher.
|
||||
- Verify: `frontend/src/router/index.ts`
|
||||
- Route shape remains unchanged (`/sftp/:id`).
|
||||
- Verify: `docs/superpowers/specs/2026-03-24-sftp-tabs-design.md`
|
||||
- Source of truth for requirements and acceptance criteria.
|
||||
|
||||
### Task 1: Add Dedicated SFTP Tabs Store
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/stores/sftpTabs.ts`
|
||||
- Reference: `frontend/src/stores/terminalTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Check current store pattern and workspace status**
|
||||
|
||||
Run `git status --short` and read `frontend/src/stores/terminalTabs.ts` to mirror established naming/style patterns.
|
||||
|
||||
- [ ] **Step 2: Add `SftpTab` model and store state**
|
||||
|
||||
Create `sftpTabs` store with:
|
||||
|
||||
```ts
|
||||
export interface SftpTab {
|
||||
id: string
|
||||
connectionId: number
|
||||
title: string
|
||||
active: boolean
|
||||
}
|
||||
```
|
||||
|
||||
State and computed:
|
||||
|
||||
```ts
|
||||
const tabs = ref<SftpTab[]>([])
|
||||
const activeTabId = ref<string | null>(null)
|
||||
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement open/activate/close logic with dedup**
|
||||
|
||||
Implement actions analogous to terminal tabs:
|
||||
|
||||
- `openOrFocus(connection)` reuses existing tab by `connectionId`
|
||||
- `activate(tabId)` flips active flags and updates `activeTabId`
|
||||
- `close(tabId)` removes tab and activates neighbor when needed
|
||||
|
||||
- [ ] **Step 4: Build-check after new store**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: build succeeds and no type errors in new store.
|
||||
|
||||
### Task 2: Register SFTP Tabs from Connections Entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/ConnectionsView.vue`
|
||||
- Use: `frontend/src/stores/sftpTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Add store import and instance**
|
||||
|
||||
Import `useSftpTabsStore` and initialize `const sftpTabsStore = useSftpTabsStore()`.
|
||||
|
||||
- [ ] **Step 2: Update `openSftp(conn)` behavior**
|
||||
|
||||
Before routing, call:
|
||||
|
||||
```ts
|
||||
sftpTabsStore.openOrFocus(conn)
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build-check for integration safety**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: build succeeds and `ConnectionsView` typing remains valid.
|
||||
|
||||
### Task 3: Render and Control SFTP Tabs in Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Use: `frontend/src/stores/sftpTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Add SFTP store wiring and computed values**
|
||||
|
||||
Add:
|
||||
|
||||
- `const sftpTabsStore = useSftpTabsStore()`
|
||||
- `const sftpTabs = computed(() => sftpTabsStore.tabs)`
|
||||
- route helper for SFTP context (for active styling and close-navigation logic)
|
||||
|
||||
- [ ] **Step 2: Add SFTP tab click and close handlers**
|
||||
|
||||
Implement:
|
||||
|
||||
- click handler: activate tab + `router.push(`/sftp/${tab.connectionId}`)` + close sidebar
|
||||
- close handler:
|
||||
- always close in store
|
||||
- only navigate when current route is the closed tab's route
|
||||
- if tabs remain, navigate to active tab route
|
||||
- if no tabs remain, navigate `/connections`
|
||||
|
||||
- [ ] **Step 3: Render `文件` sidebar section near terminal section**
|
||||
|
||||
Add a section matching current visual style conventions:
|
||||
|
||||
- title row with `FolderOpen` icon and text `文件`
|
||||
- list items for `sftpTabs`
|
||||
- close button per item
|
||||
- active class aligned with current route context
|
||||
|
||||
- [ ] **Step 4: Build-check and inspect no terminal regressions**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: build succeeds; no type/template errors in `MainLayout`.
|
||||
|
||||
### Task 4: Route-Driven Sync in SFTP View
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/SftpView.vue`
|
||||
- Use: `frontend/src/stores/sftpTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Add SFTP tabs store and route-param watcher**
|
||||
|
||||
Add watcher on `route.params.id` with `{ immediate: true }` so first load and tab switching share one code path.
|
||||
|
||||
- [ ] **Step 2: Consolidate initialization into watcher path**
|
||||
|
||||
Use watcher flow:
|
||||
|
||||
- parse and validate id; if invalid, show user-visible error feedback, do not create/open any SFTP tab, and navigate to `/connections`
|
||||
- ensure connections loaded (fetch when needed)
|
||||
- resolve connection; if missing, show user-visible error and route back to `/connections`
|
||||
- call `sftpTabsStore.openOrFocus(connection)`
|
||||
- refresh connection-bound SFTP view state for current route id
|
||||
|
||||
Avoid duplicate request/init logic split across both watcher and old mount flow.
|
||||
|
||||
- [ ] **Step 3: Keep existing file-management behavior unchanged**
|
||||
|
||||
Do not alter existing upload/download/delete/core SFTP operations; only route/tab synchronization behavior should change.
|
||||
|
||||
- [ ] **Step 4: Build-check after route-sync changes**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: build succeeds and SftpView compiles cleanly.
|
||||
|
||||
### Task 5: End-to-End Verification
|
||||
|
||||
**Files:**
|
||||
- Verify runtime behavior in app UI
|
||||
- Verify `frontend/src/router/index.ts` unchanged for route shape
|
||||
|
||||
- [ ] **Step 1: Run final build verification**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: `vue-tsc -b` and Vite build both pass.
|
||||
|
||||
- [ ] **Step 2: Manual behavior checks**
|
||||
|
||||
Verify all acceptance criteria:
|
||||
|
||||
1. Same-connection `文件` clicks do not create duplicate tabs.
|
||||
2. Multiple SFTP tabs can be opened and switched.
|
||||
3. Navigating away (for example back to connections) keeps SFTP tabs in sidebar during current session.
|
||||
4. Closing active/non-active tabs follows designed route behavior.
|
||||
5. Switching `/sftp/:id` between tabs updates header/file list without stale previous-connection state.
|
||||
6. Invalid/nonexistent `/sftp/:id` creates no tab, shows visible feedback, and routes back to `/connections`.
|
||||
7. Terminal tabs continue to open/switch/close normally.
|
||||
|
||||
- [ ] **Step 3: Commit if user requests**
|
||||
|
||||
If the user asks to commit, keep commit focused:
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/sftpTabs.ts frontend/src/views/ConnectionsView.vue frontend/src/layouts/MainLayout.vue frontend/src/views/SftpView.vue
|
||||
git commit -m "feat: add sidebar tabs for sftp sessions"
|
||||
```
|
||||
@@ -1,101 +0,0 @@
|
||||
# Terminal Multi Tabs Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Allow the connection list to open multiple terminal tabs for the same connection, with each click creating a fresh terminal session.
|
||||
|
||||
**Architecture:** Keep the existing `/terminal` workspace and Pinia-driven tab model, but change terminal tab creation from connection-deduplicated to always-new. Add lightweight title numbering so repeated tabs for the same connection remain distinguishable without changing routing or terminal widget lifecycle.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Pinia, Vue Router, xterm.js, Vite, vue-tsc
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `frontend/src/stores/terminalTabs.ts`
|
||||
- Change tab creation semantics and add repeated-title numbering.
|
||||
- Modify: `frontend/src/views/ConnectionsView.vue`
|
||||
- Use the new always-open terminal action from the connection list.
|
||||
- Verify: `frontend/src/views/TerminalWorkspaceView.vue`
|
||||
- Confirm current per-tab widget mounting already supports independent sessions.
|
||||
- Verify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Confirm terminal sidebar tab rendering needs no structure change beyond new titles.
|
||||
- Reference: `docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md`
|
||||
- Source of truth for behavior and acceptance criteria.
|
||||
|
||||
### Task 1: Change Terminal Tabs Store to Always Create New Tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/stores/terminalTabs.ts`
|
||||
- Reference: `frontend/src/stores/sftpTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Inspect current tab store behavior**
|
||||
|
||||
Read `frontend/src/stores/terminalTabs.ts` and confirm the existing dedup-by-connection behavior.
|
||||
|
||||
- [ ] **Step 2: Add repeated-title generation**
|
||||
|
||||
Add a helper that counts currently open tabs for the same `connectionId` and returns:
|
||||
|
||||
```ts
|
||||
function getTabTitle(connection: Connection) {
|
||||
const sameConnectionCount = tabs.value.filter(t => t.connectionId === connection.id).length
|
||||
|
||||
if (sameConnectionCount === 0) {
|
||||
return connection.name
|
||||
}
|
||||
|
||||
return `${connection.name} (${sameConnectionCount + 1})`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace dedup open logic with always-new creation**
|
||||
|
||||
Change the open action so it always creates a new `TerminalTab` and activates it immediately.
|
||||
|
||||
- [ ] **Step 4: Keep activate/close behavior unchanged**
|
||||
|
||||
Do not change tab activation and close-neighbor behavior except what is necessary to support the new open logic.
|
||||
|
||||
### Task 2: Wire the Connection List to the New Behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/ConnectionsView.vue`
|
||||
- Use: `frontend/src/stores/terminalTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Rename the method usage to match semantics**
|
||||
|
||||
Update `openTerminal(conn)` to call the new always-open store action.
|
||||
|
||||
- [ ] **Step 2: Preserve routing behavior**
|
||||
|
||||
Keep `router.push('/terminal')` unchanged after opening the tab.
|
||||
|
||||
### Task 3: Verify Integration and Build
|
||||
|
||||
**Files:**
|
||||
- Verify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Verify: `frontend/src/views/TerminalWorkspaceView.vue`
|
||||
|
||||
- [ ] **Step 1: Confirm sidebar rendering already keys by `tab.id`**
|
||||
|
||||
No structural code changes should be required if repeated tabs render correctly with distinct titles.
|
||||
|
||||
- [ ] **Step 2: Run final frontend build**
|
||||
|
||||
Run in `frontend/`:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `vue-tsc -b` and `vite build` both pass.
|
||||
|
||||
- [ ] **Step 3: Manual runtime verification**
|
||||
|
||||
Check:
|
||||
|
||||
1. Same connection opens multiple terminal tabs.
|
||||
2. Repeated tabs are titled distinctly.
|
||||
3. Each tab remains an independent terminal session.
|
||||
4. Close/switch behavior still works.
|
||||
@@ -1,226 +0,0 @@
|
||||
# Remote -> Many: Multi-File Source Selection (Files Only)
|
||||
|
||||
Date: 2026-03-23
|
||||
Status: Draft
|
||||
|
||||
## Context
|
||||
|
||||
Transfers page currently supports two modes:
|
||||
|
||||
- Local -> Many: user selects local files (supports multiple via `<input type="file" multiple>`), then uploads to many target connections.
|
||||
- Remote -> Many: user provides a single remote `sourcePath` on a source connection, then transfers that one file to many target connections.
|
||||
|
||||
Current remote transfer backend only supports single-file transfer. If `sourcePath` is a directory, backend throws an error ("only single file transfer is supported").
|
||||
|
||||
## Goal
|
||||
|
||||
Enable Remote -> Many to select and transfer multiple source files in one run.
|
||||
|
||||
Scope constraints:
|
||||
|
||||
- Files only (no directories).
|
||||
- Keep existing backend APIs unchanged.
|
||||
- Preserve current concurrency semantics and progress UI style.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Directory transfer (recursive) or directory selection.
|
||||
- Server-side batch API (single request for multiple source paths).
|
||||
- Change Local -> Many behavior.
|
||||
|
||||
## Current Implementation (Relevant)
|
||||
|
||||
- UI: `frontend/src/views/TransfersView.vue`
|
||||
- Remote -> Many uses `remoteSourceConnectionId` + `remoteSourcePath` + `remoteTargetDirOrPath`.
|
||||
- Uses `SftpFilePickerModal` to pick a single remote path.
|
||||
|
||||
- Store: `frontend/src/stores/transfers.ts`
|
||||
- `startRemoteToMany({ sourceConnectionId, sourcePath, targetConnectionIds, targetDirOrPath, concurrency })`
|
||||
- For each target connection creates a backend task via `createRemoteTransferTask(...)` and waits via SSE (`subscribeRemoteTransferProgress`).
|
||||
|
||||
- Backend: `backend/src/main/java/com/sshmanager/controller/SftpController.java`
|
||||
- `/sftp/transfer-remote/tasks` creates a single transfer task.
|
||||
|
||||
- Backend service: `backend/src/main/java/com/sshmanager/service/SftpService.java`
|
||||
- `transferRemote(...)` validates `sourcePath` is not a directory.
|
||||
|
||||
## Proposed Approach (Recommended)
|
||||
|
||||
Front-end only changes:
|
||||
|
||||
1) Remote -> Many supports selecting multiple `sourcePath` values.
|
||||
2) For each `(sourcePath, targetConnectionId)` pair, create one backend transfer task using existing `/sftp/transfer-remote/tasks`.
|
||||
3) Reuse current concurrency limiter (`runWithConcurrency`) across all items.
|
||||
|
||||
This is consistent with how Local -> Many expands `files x targets` into a queue of transfer items.
|
||||
|
||||
## UX / UI Changes
|
||||
|
||||
### TransfersView Remote -> Many
|
||||
|
||||
- Replace single path field with a multi-selection model:
|
||||
- From: `remoteSourcePath: string`
|
||||
- To: `remoteSourcePaths: string[]`
|
||||
|
||||
- Display:
|
||||
- A compact list of selected files (basename + full path tooltip) with remove buttons.
|
||||
- "Clear" action to empty the list.
|
||||
- Primary picker button opens the remote picker.
|
||||
|
||||
- Add target path mode toggle (default preserves current behavior):
|
||||
- `Directory` (default): treat input as directory; normalize to end with `/`; always append source filename.
|
||||
- `Exact Path` (single-file only): treat input as full remote file path; do not append filename.
|
||||
|
||||
- Start button enablement:
|
||||
- `remoteSourceConnectionId != null`
|
||||
- `remoteSourcePaths.length > 0`
|
||||
- `remoteSelectedTargets.length > 0`
|
||||
|
||||
### SftpFilePickerModal
|
||||
|
||||
Add a "multi-select files" mode:
|
||||
|
||||
- Directories are navigable only (click to enter), never selectable.
|
||||
- Files are toggle-selectable.
|
||||
- Add footer actions:
|
||||
- `Confirm (N)` emits selected file paths
|
||||
- `Cancel`
|
||||
- Maintain current search + hidden toggle behavior.
|
||||
|
||||
Modal selection UX:
|
||||
|
||||
- Show a small "Selected (N)" summary area in the modal (e.g. in footer) with the ability to remove a selected file by clicking an `x`.
|
||||
- Disable `Confirm` when `N = 0`.
|
||||
|
||||
Deterministic output order:
|
||||
|
||||
- `select-many` emits paths in selection-time order (first selected first). If a file is unselected and re-selected, it is appended to the end.
|
||||
|
||||
Visual selection indicator:
|
||||
|
||||
- File rows show a checkbox (checked when selected). Directory rows never show a checkbox.
|
||||
|
||||
Selection behavior:
|
||||
|
||||
- Selections persist while navigating folders within the modal (select in dir A, navigate to dir B, select more).
|
||||
- `select-many` returns the set selected in the modal session.
|
||||
- `TransfersView` merges returned paths into `remoteSourcePaths` (append + de-duplicate), so users can open the picker multiple times to add files.
|
||||
|
||||
API surface change:
|
||||
|
||||
- Option A (preferred): extend props and emits without breaking existing callers:
|
||||
- Add optional prop: `multiple?: boolean` (default false)
|
||||
- If `multiple` is false: keep existing `select` signature `(path: string)`.
|
||||
- If `multiple` is true: emit a new event name `select-many` with `(paths: string[])`.
|
||||
|
||||
Rationale: avoids touching other potential call sites and keeps types explicit.
|
||||
|
||||
## Data Model / State
|
||||
|
||||
- Keep `remoteTargetDirOrPath` as the raw input string.
|
||||
- Add `remoteTargetMode: 'dir' | 'path'`.
|
||||
- On selection:
|
||||
- De-duplicate paths.
|
||||
- Preserve ordering: de-duplicate by full path while keeping first-seen order; new selections append in the order confirmed in the picker.
|
||||
|
||||
- Source connection is part of the meaning of a path:
|
||||
- When `remoteSourceConnectionId` changes, clear `remoteSourcePaths` (require re-pick).
|
||||
- Also remove the source connection from `remoteSelectedTargets` if present.
|
||||
|
||||
## Execution Model
|
||||
|
||||
### Transfer items
|
||||
|
||||
Each item represents one file transfer from source to one target:
|
||||
|
||||
- Label: `#<sourceId>:<sourcePath> -> #<targetId>:<targetDirOrPath>`
|
||||
- Uses existing backend task creation and SSE progress.
|
||||
|
||||
Store API change:
|
||||
|
||||
- Update the existing store method `startRemoteToMany` to accept `sourcePaths: string[]` instead of a single `sourcePath`.
|
||||
- `TransfersView` is the only caller; update types and call site accordingly.
|
||||
|
||||
Where target-path resolution lives:
|
||||
|
||||
- Keep the resolution logic in the store (consistent with current `buildRemoteTransferPath` helper).
|
||||
- Extend `startRemoteToMany` params to include `targetMode: 'dir' | 'path'`.
|
||||
- Store computes `targetPath` per item using `{ targetMode, targetDirOrPath, filename }`.
|
||||
|
||||
### Target path resolution
|
||||
|
||||
For each `sourcePath`, compute `filename` as basename.
|
||||
|
||||
Rules are driven by the explicit `remoteTargetMode`:
|
||||
|
||||
- Mode `dir` (default):
|
||||
- Treat `remoteTargetDirOrPath` as a directory.
|
||||
- Normalize to end with `/`.
|
||||
- Target path = normalizedDir + filename.
|
||||
|
||||
- Mode `path` (single-file only):
|
||||
- Treat `remoteTargetDirOrPath` as an exact remote file path.
|
||||
- Target path = `remoteTargetDirOrPath`.
|
||||
|
||||
Multi-file constraint:
|
||||
|
||||
- When `remoteSourcePaths.length > 1`, force mode `dir` (disable `Exact Path`).
|
||||
|
||||
Compatibility note:
|
||||
|
||||
- Keep current behavior as the default: users who previously entered a directory-like value (with or without trailing `/`) will still get "append filename" semantics.
|
||||
- `Exact Path` is an explicit opt-in for single-file runs.
|
||||
|
||||
Examples:
|
||||
|
||||
- source `"/var/log/a.txt"`, mode `dir`, target input `"/tmp"` => target path `"/tmp/a.txt"`
|
||||
- source `"/var/log/a.txt"`, mode `dir`, target input `"/tmp/"` => target path `"/tmp/a.txt"`
|
||||
- source `"/var/log/a.txt"`, mode `path`, target input `"/tmp/renamed.txt"` => target path `"/tmp/renamed.txt"`
|
||||
|
||||
## Validation & Errors
|
||||
|
||||
- UI prevents selecting directories in picker.
|
||||
- If users type paths manually, guard client-side before starting:
|
||||
- Each `sourcePath` must be non-empty.
|
||||
- Each `sourcePath` must not end with `/` (heuristic directory indicator).
|
||||
- Still handle backend errors (permission denied, no such file) per item.
|
||||
- For very large runs (many items), UI should remain responsive:
|
||||
- Avoid excessive reactive updates; batch updates where feasible.
|
||||
|
||||
- Basename collision:
|
||||
- Backend uses overwrite semantics; selecting two different files with the same basename into one target directory will overwrite.
|
||||
- If multiple selected files share the same basename and `remoteTargetMode === 'dir'`, show a warning before starting.
|
||||
- Warning is non-blocking but requires explicit acknowledgement ("Continue" / "Cancel").
|
||||
|
||||
## Observability
|
||||
|
||||
- Keep existing console logs for remote transfers (optional to reduce noise later).
|
||||
|
||||
## Rollout / Compatibility
|
||||
|
||||
- Backend unchanged.
|
||||
- Existing Remote -> Many single-file flow remains possible (select one file).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Frontend:
|
||||
- Type-check build: `npm run build` (includes `vue-tsc`).
|
||||
- Manual test:
|
||||
- Select 2-3 files via picker; verify selected list and remove/clear.
|
||||
- Start transfer to 2 targets; confirm item count = files x targets.
|
||||
- Verify target path concatenation behavior for directory-like target.
|
||||
- Error handling: select a file without permission to confirm per-item error is surfaced.
|
||||
|
||||
- Backend:
|
||||
- No change required.
|
||||
|
||||
## Risks
|
||||
|
||||
- Large `files x targets` increases total tasks/events and can load the client.
|
||||
- Simultaneous SSE connections should be bounded by the concurrency limiter.
|
||||
- Must explicitly close each SSE subscription when the task reaches a terminal state (success/error/cancelled) to avoid connection buildup.
|
||||
- Mitigation: cap UI selection count in future, or add a backend batch task later.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None (scope fixed to files-only, front-end only).
|
||||
@@ -1,60 +0,0 @@
|
||||
# 2026-03-24 Nav Swap: Transfers and Connections
|
||||
|
||||
## Background
|
||||
|
||||
The user wants to swap the visual order of the `传输` and `连接列表` entries in the left sidebar navigation.
|
||||
|
||||
## Goal
|
||||
|
||||
- Show `连接列表` above `传输` in the sidebar.
|
||||
- Keep the order consistent on desktop and mobile sidebar layouts.
|
||||
- Preserve existing navigation behavior, active-state highlighting, labels, and icons.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change routing structure or auth behavior.
|
||||
- Do not change the default redirect to `/connections`.
|
||||
- Do not refactor the sidebar into config-driven navigation.
|
||||
- Do not make unrelated UI or style changes.
|
||||
|
||||
## Current State
|
||||
|
||||
In `frontend/src/layouts/MainLayout.vue`, the sidebar navigation currently renders:
|
||||
|
||||
1. `RouterLink` to `/transfers`
|
||||
2. `RouterLink` to `/connections`
|
||||
|
||||
The default app entry remains `/connections` via `frontend/src/router/index.ts`.
|
||||
|
||||
## Selected Approach
|
||||
|
||||
Use the smallest possible template-only change:
|
||||
|
||||
- In `frontend/src/layouts/MainLayout.vue`, move the `RouterLink` block for `/connections` so it appears before the `RouterLink` block for `/transfers`.
|
||||
|
||||
This is preferred because it:
|
||||
|
||||
- solves the request directly,
|
||||
- avoids unnecessary abstraction,
|
||||
- keeps behavior unchanged,
|
||||
- minimizes regression risk.
|
||||
|
||||
## Behavior Kept Unchanged
|
||||
|
||||
- `closeSidebar` still runs on click.
|
||||
- Active-state classes based on `route.path` stay the same.
|
||||
- `aria-label` values stay the same.
|
||||
- Existing icons (`Server`, `ArrowLeftRight`) stay paired with the same entries.
|
||||
- Terminal tab section remains below the main navigation items.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- The sidebar shows `连接列表` first and `传输` second.
|
||||
- Clicking either item still navigates to the same route.
|
||||
- Active highlighting still works for both routes.
|
||||
- Mobile sidebar uses the same order.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `npm run build` in `frontend/`.
|
||||
- Manually confirm the sidebar order and route highlighting.
|
||||
@@ -1,126 +0,0 @@
|
||||
# 2026-03-24 SFTP Tabs Design
|
||||
|
||||
## Background
|
||||
|
||||
The current connection list provides both `终端` and `文件` actions. `终端` has persistent sidebar tabs, but `文件` (SFTP) opens a route directly and has no sidebar tab lifecycle. Users lose the quick return path after navigating away, which feels inconsistent.
|
||||
|
||||
## Goal
|
||||
|
||||
- Add sidebar tabs for SFTP sessions near the existing terminal tabs.
|
||||
- Ensure SFTP tabs are unique per connection (no duplicates).
|
||||
- Keep tab state only for the current in-memory session.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No persistence across full page refresh (no localStorage/sessionStorage).
|
||||
- No refactor that merges terminal and SFTP tab models into one generic tab system.
|
||||
- No route restructuring; keep `/sftp/:id` as the SFTP route entry.
|
||||
|
||||
## Current State
|
||||
|
||||
- Terminal tabs are managed by `frontend/src/stores/terminalTabs.ts` and rendered in `frontend/src/layouts/MainLayout.vue`.
|
||||
- `ConnectionsView` opens terminal via `terminalTabs.openOrFocus(conn)` and then routes to `/terminal`.
|
||||
- `ConnectionsView` opens SFTP by routing directly to `/sftp/:id`, with no tab store.
|
||||
- `SftpView` currently has no tab registration step.
|
||||
|
||||
## Selected Approach
|
||||
|
||||
Introduce a dedicated SFTP tab store and render a new sidebar tab section in `MainLayout`, following the same interaction model as terminal tabs while keeping route behavior centered on `/sftp/:id`.
|
||||
|
||||
### 1. New store: `sftpTabs`
|
||||
|
||||
Create `frontend/src/stores/sftpTabs.ts` with a model parallel to terminal tabs:
|
||||
|
||||
- `tabs: SftpTab[]`
|
||||
- `activeTabId: string | null`
|
||||
- `activeTab` computed
|
||||
- `openOrFocus(connection)`
|
||||
- `activate(tabId)`
|
||||
- `close(tabId)`
|
||||
|
||||
Each tab includes:
|
||||
|
||||
- `id: string`
|
||||
- `connectionId: number`
|
||||
- `title: string`
|
||||
- `active: boolean`
|
||||
|
||||
Dedup rule:
|
||||
|
||||
- `openOrFocus(connection)` must reuse an existing tab when `connectionId` matches.
|
||||
|
||||
### 2. Connection list behavior
|
||||
|
||||
Update `frontend/src/views/ConnectionsView.vue`:
|
||||
|
||||
- In `openSftp(conn)`, call `sftpTabs.openOrFocus(conn)` before `router.push(`/sftp/${conn.id}`)`.
|
||||
|
||||
Result:
|
||||
|
||||
- Clicking `文件` on the same connection repeatedly does not create duplicate tabs.
|
||||
|
||||
### 3. Sidebar rendering and controls
|
||||
|
||||
Update `frontend/src/layouts/MainLayout.vue`:
|
||||
|
||||
- Import and use `useSftpTabsStore`.
|
||||
- Add computed `sftpTabs`.
|
||||
- Add a new sidebar section (near terminal tabs) titled `文件`.
|
||||
- Render each tab as a clickable item with close button.
|
||||
|
||||
Interactions:
|
||||
|
||||
- Click tab: activate and navigate to `/sftp/:connectionId`.
|
||||
- Close tab:
|
||||
- Always remove the tab from store.
|
||||
- Only trigger route navigation when the current route is the closed tab's route (`/sftp/:connectionId`):
|
||||
- If another SFTP tab remains, navigate to `/sftp/:newActiveConnectionId`.
|
||||
- If no SFTP tabs remain, navigate to `/connections`.
|
||||
- If closing a non-active tab, or closing from a non-SFTP route, remove only (no route change).
|
||||
|
||||
Highlighting:
|
||||
|
||||
- Keep active style tied to both tab active state and current SFTP route context.
|
||||
|
||||
### 4. Route-entry consistency
|
||||
|
||||
Update `frontend/src/views/SftpView.vue`:
|
||||
|
||||
- Import and use `useSftpTabsStore`.
|
||||
- Watch `route.params.id` with `{ immediate: true }` so logic runs on first load and on `/sftp/:id` param changes (tab-to-tab switching).
|
||||
- On each `id` change:
|
||||
- Parse `id` to number; if invalid, navigate to `/connections`.
|
||||
- Ensure connections are loaded (fetch when needed).
|
||||
- Resolve the matching connection; if not found, show error feedback and navigate to `/connections`.
|
||||
- Call `sftpTabs.openOrFocus(connection)` and then refresh SFTP view state for that connection.
|
||||
- Consolidate route-driven initialization into this watcher (avoid a separate `onMounted` path-init flow for the same concern) so first load and param switching use one code path and do not trigger duplicate requests.
|
||||
|
||||
This keeps behavior consistent for direct navigation and sidebar tab switching, without duplicate tabs.
|
||||
|
||||
## Behavior Kept Unchanged
|
||||
|
||||
- Existing terminal tab logic and terminal workspace lifecycle.
|
||||
- Existing SFTP route path (`/sftp/:id`) and core file-management interactions remain unchanged, except for tab registration/sync and invalid-or-missing connection route handling described above.
|
||||
- Authentication and router guards.
|
||||
- UI visual language (slate/cyan styling).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `文件` opens/activates an SFTP tab in sidebar.
|
||||
- Repeatedly opening `文件` for the same connection does not create duplicate tabs.
|
||||
- SFTP tabs remain available when navigating back to connections or other pages within the same session.
|
||||
- Closing active/non-active SFTP tabs follows the navigation rules above.
|
||||
- Terminal tabs continue working exactly as before.
|
||||
- Switching between existing SFTP tabs (for example `/sftp/1` and `/sftp/2`) updates connection header and file list correctly, without stale data from the previous connection.
|
||||
- Direct navigation to an invalid or nonexistent `/sftp/:id` does not create a tab and returns the user to `/connections` with visible feedback.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `npm run build` in `frontend/`.
|
||||
- Manual verification:
|
||||
1. Open SFTP for one connection multiple times and confirm single tab.
|
||||
2. Open SFTP for multiple connections and switch between tabs.
|
||||
3. Navigate away and return via sidebar SFTP tabs.
|
||||
4. Close active and inactive SFTP tabs and verify resulting route behavior.
|
||||
5. Re-check terminal tab open/switch/close behavior for regressions.
|
||||
6. Open an invalid/nonexistent `/sftp/:id` and verify no tab is created, visible error feedback appears, and navigation returns to `/connections`.
|
||||
@@ -1,97 +0,0 @@
|
||||
# 2026-03-26 Terminal Multi Tabs Design
|
||||
|
||||
## Background
|
||||
|
||||
The connection list already opens terminal sessions into the shared terminal workspace at `/terminal`, and the sidebar already renders terminal tabs. However, the current store deduplicates tabs by `connectionId`, so clicking `终端` for the same connection only focuses the existing tab instead of opening a new shell session.
|
||||
|
||||
## Goal
|
||||
|
||||
- Allow users to open multiple terminal tabs from the connection list for the same connection.
|
||||
- Keep each terminal tab as an independent shell session.
|
||||
- Preserve the current terminal workspace route and sidebar interaction model.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No tab persistence across refresh.
|
||||
- No route change from `/terminal` to per-tab URLs.
|
||||
- No generic tab-system refactor shared with SFTP.
|
||||
- No terminal session restore or reconnect workflow.
|
||||
|
||||
## Current State
|
||||
|
||||
- `frontend/src/stores/terminalTabs.ts` manages terminal tabs.
|
||||
- `openOrFocus(connection)` reuses an existing tab when `connectionId` matches.
|
||||
- `frontend/src/views/ConnectionsView.vue` uses that method before routing to `/terminal`.
|
||||
- `frontend/src/views/TerminalWorkspaceView.vue` already mounts one `TerminalWidget` per tab keyed by `tab.id`, so multiple tabs can coexist as long as the store allows them.
|
||||
|
||||
## Selected Approach
|
||||
|
||||
Keep the existing terminal workspace architecture and change only the terminal tab creation semantics: clicking `终端` always creates a new tab, even when the same connection already has open tabs.
|
||||
|
||||
### 1. Terminal tab creation becomes always-new
|
||||
|
||||
Update `frontend/src/stores/terminalTabs.ts` so the terminal action always creates a new tab entry:
|
||||
|
||||
- remove the `connectionId` dedup behavior from the open action
|
||||
- generate a fresh `tab.id` every time
|
||||
- activate the newly created tab immediately
|
||||
|
||||
This preserves the current in-memory tab lifecycle while enabling multiple concurrent sessions to the same host.
|
||||
|
||||
### 2. Distinguishable tab titles for repeated connections
|
||||
|
||||
When the same connection is opened multiple times, sidebar labels must remain distinguishable.
|
||||
|
||||
Recommended title strategy:
|
||||
|
||||
- first tab: `Connection Name`
|
||||
- second tab: `Connection Name (2)`
|
||||
- third tab: `Connection Name (3)`
|
||||
|
||||
The sequence is computed from currently open tabs for the same `connectionId`. No persistence is needed.
|
||||
|
||||
### 3. Connections entry behavior
|
||||
|
||||
Update `frontend/src/views/ConnectionsView.vue` so clicking `终端` calls the always-new tab action and routes to `/terminal`.
|
||||
|
||||
Result:
|
||||
|
||||
- each click from the connection list opens a fresh shell session
|
||||
- users can intentionally keep several terminals open for the same host
|
||||
|
||||
### 4. Terminal workspace behavior stays unchanged
|
||||
|
||||
`frontend/src/views/TerminalWorkspaceView.vue` and `frontend/src/components/TerminalWidget.vue` do not need architectural changes:
|
||||
|
||||
- the workspace already loops through tabs by `tab.id`
|
||||
- each active tab renders its own `TerminalWidget`
|
||||
- each widget maintains an independent xterm instance and WebSocket connection
|
||||
|
||||
This matches the desired UX: multiple tabs for the same connection are separate terminal sessions rather than alternate views of one shared session.
|
||||
|
||||
## Behavior Kept Unchanged
|
||||
|
||||
- Terminal route remains `/terminal`.
|
||||
- Sidebar terminal tab section remains the primary tab switcher.
|
||||
- Closing and activating adjacent terminal tabs continues to follow the current logic.
|
||||
- SFTP tab behavior remains unchanged.
|
||||
- Existing slate/cyan visual language remains unchanged.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Clicking `终端` for the same connection multiple times creates multiple terminal tabs.
|
||||
- Each new terminal tab becomes the active tab.
|
||||
- Repeated tabs for the same connection are visually distinguishable in the sidebar.
|
||||
- Switching between tabs preserves each tab's own terminal session output.
|
||||
- Closing a terminal tab keeps current close/activate behavior intact.
|
||||
- Opening tabs for different connections continues to work.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `npm run build` in `frontend/`.
|
||||
- Manual verification:
|
||||
1. Click `终端` on the same connection three times and confirm three tabs appear.
|
||||
2. Confirm the sidebar shows distinguishable titles for repeated tabs.
|
||||
3. Type different commands in different tabs for the same connection and confirm each tab keeps its own output.
|
||||
4. Close active and inactive terminal tabs and confirm focus changes remain correct.
|
||||
5. Re-check that opening terminal tabs for different connections still works.
|
||||
@@ -1,94 +0,0 @@
|
||||
# 闲鱼商品文案
|
||||
|
||||
这份文案只卖一个方向:
|
||||
|
||||
`源码交付 + Docker 部署`
|
||||
|
||||
不卖 Windows 安装包,不卖双击版。
|
||||
|
||||
## 标题
|
||||
|
||||
直接用下面任意一个:
|
||||
|
||||
- SSH/SFTP 管理器 源码交付 Docker部署 批量命令 备份恢复
|
||||
- SSH 管理项目 源码版 Docker一键启动 支持SFTP 批量运维
|
||||
- SSH 运维工具 源码交付 支持Docker部署 SFTP 批量命令
|
||||
|
||||
## 前 3 行卖点
|
||||
|
||||
```text
|
||||
这是一套可以直接部署使用的 SSH / SFTP 管理项目,不是练手 demo。
|
||||
支持终端、SFTP、批量命令、备份恢复,买回去后按说明执行 docker compose 就能启动。
|
||||
适合开发者、小团队运维、NAS / 云主机用户,也适合继续二开。
|
||||
```
|
||||
|
||||
## 详情页正文
|
||||
|
||||
```text
|
||||
这套项目适合卖给有服务器管理需求、又想自己掌控数据和部署环境的人。
|
||||
|
||||
核心功能:
|
||||
1. SSH 终端
|
||||
2. SFTP 文件管理
|
||||
3. 批量命令执行
|
||||
4. 连接和会话树备份恢复
|
||||
5. 历史日志与传输记录
|
||||
6. 首次登录强制改密
|
||||
|
||||
适合人群:
|
||||
- 经常 SSH 管服务器的开发者
|
||||
- 小团队运维
|
||||
- NAS / 云主机用户
|
||||
- 想找 FinalShell / MobaXterm 替代方案的人
|
||||
|
||||
交付方式:
|
||||
- 仓库源码
|
||||
- Docker 部署说明
|
||||
- 默认账号和初始化说明
|
||||
|
||||
售后范围:
|
||||
- 基础部署指导
|
||||
- 基础启动排查
|
||||
- 不包含远程代部署
|
||||
```
|
||||
|
||||
## 常见问答
|
||||
|
||||
### 1. 这是源码还是成品?
|
||||
|
||||
```text
|
||||
这是源码交付版,主打 Docker 部署。
|
||||
买家拿到源码和说明后,可以自己部署,也可以继续二开。
|
||||
```
|
||||
|
||||
### 2. 怎么启动?
|
||||
|
||||
```text
|
||||
按文档执行 docker compose 命令就能启动。
|
||||
不需要安装 Windows 客户端,也不是双击安装包那种交付方式。
|
||||
```
|
||||
|
||||
### 3. 需要联网吗?
|
||||
|
||||
```text
|
||||
部署完成后,日常使用不依赖外部云服务。
|
||||
数据保存在你自己的 Docker 环境里。
|
||||
```
|
||||
|
||||
### 4. 适合什么人买?
|
||||
|
||||
```text
|
||||
适合会用 Docker、会自己管理服务器或 NAS 的用户。
|
||||
如果你要的是纯小白双击安装版,这个版本不适合。
|
||||
```
|
||||
|
||||
## 建议截图
|
||||
|
||||
保留这 6 张就够了:
|
||||
|
||||
1. 登录页
|
||||
2. 工作区主界面
|
||||
3. 终端 + SFTP 分屏
|
||||
4. 批量命令结果
|
||||
5. 历史日志与传输记录
|
||||
6. 关于与交付信息
|
||||
3
frontend/.vscode/extensions.json
vendored
3
frontend/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
本模板用于在 Vite 中基于 Vue 3 与 TypeScript 进行开发。模板使用 Vue 3 的 `<script setup>` 单文件组件,可参阅 [script setup 文档](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) 了解更多。
|
||||
|
||||
推荐的项目配置与 IDE 支持请参考 [Vue 文档 TypeScript 指南](https://vuejs.org/guide/typescript/overview.html#project-setup)。
|
||||
@@ -1,17 +1,103 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="SSH Manager">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="SSH Manager">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f172a" />
|
||||
<stop offset="100%" stop-color="#164e63" />
|
||||
<stop offset="0%" stop-color="#07152f" />
|
||||
<stop offset="100%" stop-color="#0a1d3f" />
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#22d3ee" />
|
||||
<stop offset="100%" stop-color="#67e8f9" />
|
||||
<linearGradient id="neon" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3cc8ff" />
|
||||
<stop offset="55%" stop-color="#6ce7ff" />
|
||||
<stop offset="100%" stop-color="#7fffd4" />
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#17345f" />
|
||||
<stop offset="100%" stop-color="#112748" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="softGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style>
|
||||
.trace { fill: none; stroke: #143b6f; stroke-linecap: round; stroke-width: 2.2; opacity: 0.75; }
|
||||
.trace2 { fill: none; stroke: #1a4f88; stroke-linecap: round; stroke-width: 1.6; opacity: 0.6; }
|
||||
.dot { fill: #1f78b4; opacity: 0.85; }
|
||||
.rack { fill: #17345f; stroke: url(#neon); stroke-width: 2.2; }
|
||||
.lightG { fill: #88ff96; }
|
||||
.lightY { fill: #ffd96b; }
|
||||
.lightR { fill: #ff7686; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="28" fill="url(#bg)" />
|
||||
<rect x="18" y="22" width="92" height="84" rx="14" fill="#020617" stroke="#155e75" stroke-width="4" />
|
||||
<path d="M36 48 54 64 36 80" fill="none" stroke="url(#accent)" stroke-linecap="round" stroke-linejoin="round" stroke-width="10" />
|
||||
<path d="M64 82h28" fill="none" stroke="url(#accent)" stroke-linecap="round" stroke-width="10" />
|
||||
<circle cx="96" cy="36" r="8" fill="#22d3ee" opacity="0.9" />
|
||||
|
||||
<rect x="18" y="18" width="220" height="220" rx="34" fill="url(#bg)" />
|
||||
<rect x="18" y="18" width="220" height="220" rx="34" fill="none" stroke="#3cc8ff" stroke-width="4" filter="url(#glow)" />
|
||||
|
||||
<path class="trace" d="M36 62h28v-18h18v34h20" />
|
||||
<path class="trace" d="M36 96h42v18h22v-16h24" />
|
||||
<path class="trace" d="M36 160h24v22h34v-18h18" />
|
||||
<path class="trace" d="M36 196h54v-26h20" />
|
||||
<path class="trace" d="M220 58h-24v-14h-22v36h-14" />
|
||||
<path class="trace" d="M220 98h-38v14h-20v-18h-18" />
|
||||
<path class="trace" d="M220 156h-28v24h-36v-20h-16" />
|
||||
<path class="trace" d="M220 194h-42v-18h-24" />
|
||||
<circle class="dot" cx="64" cy="44" r="3" />
|
||||
<circle class="dot" cx="78" cy="114" r="3" />
|
||||
<circle class="dot" cx="60" cy="182" r="3" />
|
||||
<circle class="dot" cx="196" cy="44" r="3" />
|
||||
<circle class="dot" cx="182" cy="112" r="3" />
|
||||
<circle class="dot" cx="194" cy="180" r="3" />
|
||||
|
||||
<g filter="url(#softGlow)">
|
||||
<rect x="72" y="74" width="114" height="96" rx="12" fill="url(#panel)" stroke="#8af3ff" stroke-width="3" />
|
||||
<rect x="82" y="86" width="94" height="72" rx="10" fill="#101c35" stroke="#53d9ff" stroke-opacity="0.7" />
|
||||
<circle cx="90" cy="80" r="3.4" class="lightR" />
|
||||
<circle cx="98" cy="80" r="3.4" class="lightY" />
|
||||
<circle cx="106" cy="80" r="3.4" class="lightG" />
|
||||
<text x="94" y="103" font-size="8.5" fill="#baf7ff" font-family="IBM Plex Mono, monospace">$ ssh -i keys/mgmt.pem</text>
|
||||
<text x="94" y="114" font-size="7.8" fill="#8bcfe2" font-family="IBM Plex Mono, monospace">admin@prod-1.net</text>
|
||||
<text x="92" y="132" font-size="34" fill="#b4ffff" font-family="IBM Plex Sans, sans-serif" font-weight="700">$</text>
|
||||
<rect x="108" y="125" width="24" height="6" rx="3" fill="url(#neon)" />
|
||||
</g>
|
||||
|
||||
<g transform="translate(127 148)" filter="url(#glow)">
|
||||
<circle r="47" fill="rgba(10,29,63,0.5)" stroke="#5ce8ff" stroke-width="1.8" />
|
||||
<circle r="36" fill="none" stroke="#2fc7ff" stroke-width="1.4" stroke-opacity="0.5" />
|
||||
<circle cx="-45" cy="0" r="3.4" fill="#55efff" />
|
||||
<circle cx="45" cy="0" r="3.4" fill="#55efff" />
|
||||
<circle cx="0" cy="-45" r="3.4" fill="#55efff" />
|
||||
<circle cx="0" cy="45" r="3.4" fill="#55efff" />
|
||||
|
||||
<rect class="rack" x="-27" y="-24" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="-27" y="-10" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="-27" y="4" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="-27" y="18" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="5" y="-24" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="5" y="-10" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="5" y="4" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="5" y="18" width="22" height="10" rx="3" />
|
||||
|
||||
<circle cx="-10" cy="-19" r="1.6" class="lightG" />
|
||||
<circle cx="-14" cy="-5" r="1.6" class="lightY" />
|
||||
<circle cx="-8" cy="9" r="1.6" class="lightG" />
|
||||
<circle cx="-12" cy="23" r="1.6" class="lightR" />
|
||||
<circle cx="22" cy="-19" r="1.6" class="lightY" />
|
||||
<circle cx="18" cy="-5" r="1.6" class="lightG" />
|
||||
<circle cx="24" cy="9" r="1.6" class="lightY" />
|
||||
<circle cx="20" cy="23" r="1.6" class="lightG" />
|
||||
|
||||
<path class="trace2" d="M-16 -14v-10c0-7 5-12 12-12h8c7 0 12 5 12 12v10" />
|
||||
<path class="trace2" d="M-16 18v10c0 7 5 12 12 12h8c7 0 12-5 12-12v-10" />
|
||||
<path class="trace2" d="M-5 -14v28" />
|
||||
<path class="trace2" d="M5 -14v28" />
|
||||
<rect x="-6" y="32" width="12" height="8" rx="2.2" fill="#17345f" stroke="#79f7ff" stroke-width="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 961 B After Width: | Height: | Size: 5.1 KiB |
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { BadgeInfo, Copy, FileCode2, ShieldCheck, X } from 'lucide-vue-next'
|
||||
import { BadgeInfo, Copy, ShieldCheck, X } from 'lucide-vue-next'
|
||||
import { useProductStatusStore } from '../stores/productStatus'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useActivityLogStore } from '../stores/activityLog'
|
||||
@@ -26,8 +26,7 @@ const appVersion = computed(() => import.meta.env.VITE_APP_VERSION || '2026.04 S
|
||||
const diagnostics = computed(() => {
|
||||
return [
|
||||
`Version: ${appVersion.value}`,
|
||||
`Delivery Mode: ${productStatusStore.licenseStatusText}`,
|
||||
`Environment Fingerprint: ${productStatusStore.machineFingerprint}`,
|
||||
`First Launch: ${formatTime(productStatusStore.firstLaunchedAt)}`,
|
||||
`Connections: ${connectionsStore.connections.length}`,
|
||||
`Transfer Runs: ${transfersStore.runs.length}`,
|
||||
`Activity Logs: ${activityLogStore.entries.length}`,
|
||||
@@ -57,16 +56,16 @@ function formatTime(ts: number) {
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-3 sm:p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="关于与交付信息"
|
||||
aria-label="关于与诊断"
|
||||
>
|
||||
<div class="w-full max-w-3xl overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
|
||||
<div class="max-h-[90vh] w-full max-w-3xl overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
|
||||
<header class="flex items-center justify-between border-b border-slate-700 px-4 py-3">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<BadgeInfo class="h-4 w-4 text-cyan-300" />
|
||||
<h2 class="text-sm font-semibold text-slate-100">关于与交付信息</h2>
|
||||
<h2 class="text-sm font-semibold text-slate-100">关于与诊断</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -78,102 +77,51 @@ function formatTime(ts: number) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-4 p-4 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-cyan-300/80">Product</p>
|
||||
<h3 class="mt-2 text-xl font-semibold text-slate-50">SSH Manager</h3>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
当前是源码交付版,适合按源码、部署脚本和文档一起打包出售,重点保留工作区、备份恢复、批量命令、日志和诊断能力。
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
||||
<p class="text-xs text-slate-500">版本</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ appVersion }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
||||
<p class="text-xs text-slate-500">版本定位</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">Source Delivery Edition</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 overflow-y-auto p-4">
|
||||
<section class="rounded-xl border border-slate-800 bg-slate-950/60 p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheck class="h-4 w-4 text-cyan-300" />
|
||||
<h3 class="text-base font-semibold text-slate-50">SSH Manager</h3>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-slate-400">
|
||||
当前版本按“源码交付 + Docker 部署”方式使用。出问题时,把下面的诊断信息复制出来,发给卖家或自己排查即可。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheck class="h-4 w-4 text-emerald-300" />
|
||||
<h3 class="text-sm font-semibold text-slate-100">交付状态</h3>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
||||
<p class="text-xs text-slate-500">当前状态</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ productStatusStore.licenseStatusText }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
||||
<p class="text-xs text-slate-500">首次启动</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ formatTime(productStatusStore.firstLaunchedAt) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3 sm:col-span-2">
|
||||
<p class="text-xs text-slate-500">环境指纹</p>
|
||||
<p class="mt-1 break-all font-mono text-sm text-slate-100">{{ productStatusStore.machineFingerprint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-slate-500">
|
||||
当前交付模式不依赖授权码。你可以把源码、Docker 文件、部署文档和售后说明一起交付给客户,自行二开或部署。
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">版本</p>
|
||||
<p class="mt-1 break-all text-sm font-medium text-slate-100">{{ appVersion }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<p class="text-xs text-slate-500">首次启动</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ formatTime(productStatusStore.firstLaunchedAt) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<p class="text-xs text-slate-500">连接数</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ connectionsStore.connections.length }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<p class="text-xs text-slate-500">传输记录</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ transfersStore.runs.length }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<FileCode2 class="h-4 w-4 text-cyan-300" />
|
||||
<h3 class="text-sm font-semibold text-slate-100">源码交付建议</h3>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
对外售卖源码版时,建议把仓库代码、初始化账号说明、环境变量模板、部署脚本和备份示例一起交付,减少来回答疑。
|
||||
</p>
|
||||
<ul class="mt-4 space-y-2 text-sm text-slate-400">
|
||||
<li>交付仓库源码与 README/部署文档</li>
|
||||
<li>附带默认账号、环境变量和数据目录说明</li>
|
||||
<li>说明哪些功能可直接用,哪些是二开骨架</li>
|
||||
<li>保留诊断信息入口,方便客户反馈问题时定位环境</li>
|
||||
</ul>
|
||||
</div>
|
||||
<section class="rounded-xl border border-slate-800 bg-slate-950/60 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-semibold text-slate-100">诊断信息</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-400">
|
||||
如果页面异常、连接失败或者功能表现不对,先复制这段信息,再配合截图或报错内容一起发出去。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 inline-flex min-h-[44px] w-full items-center justify-center gap-1.5 rounded-md border border-slate-700 px-3 py-2 text-sm text-slate-200 transition-colors hover:bg-slate-800 sm:w-auto"
|
||||
@click="copyDiagnostics"
|
||||
>
|
||||
<Copy class="h-4 w-4" />
|
||||
<span>复制诊断信息</span>
|
||||
</button>
|
||||
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-100">运行摘要</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
||||
<p class="text-xs text-slate-500">连接数</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ connectionsStore.connections.length }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
||||
<p class="text-xs text-slate-500">传输历史数</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ transfersStore.runs.length }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
||||
<p class="text-xs text-slate-500">操作日志数</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ activityLogStore.entries.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-100">售后诊断</h3>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
客户反馈问题时,可以先复制这份诊断摘要给你,用来快速判断版本、交付模式和本地数据规模。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 inline-flex items-center gap-1.5 rounded-md border border-slate-700 px-3 py-2 text-sm text-slate-200 transition-colors hover:bg-slate-800"
|
||||
@click="copyDiagnostics"
|
||||
>
|
||||
<Copy class="h-4 w-4" />
|
||||
<span>复制诊断信息</span>
|
||||
</button>
|
||||
|
||||
<pre class="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-950 px-3 py-3 text-xs text-slate-300"><code>{{ diagnostics }}</code></pre>
|
||||
</div>
|
||||
<pre class="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-950 px-3 py-3 text-xs leading-6 text-slate-300"><code>{{ diagnostics }}</code></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Rocket, FolderInput, ShieldCheck, X } from 'lucide-vue-next'
|
||||
import { useProductStatusStore } from '../stores/productStatus'
|
||||
import { Rocket, ShieldCheck, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
@@ -13,8 +12,6 @@ const emit = defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const productStatusStore = useProductStatusStore()
|
||||
|
||||
function dismissGuide() {
|
||||
localStorage.setItem('ssh-manager.first-run-dismissed', 'true')
|
||||
emit('dismiss')
|
||||
@@ -33,41 +30,40 @@ function openAbout() {
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-3 sm:p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="首次启动引导"
|
||||
>
|
||||
<div class="w-full max-w-4xl overflow-hidden rounded-2xl border border-cyan-500/20 bg-slate-900 shadow-2xl">
|
||||
<div class="grid gap-0 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section class="border-b border-slate-800 p-6 lg:border-b-0 lg:border-r lg:p-8">
|
||||
<div class="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-2xl border border-cyan-500/20 bg-slate-900 shadow-2xl">
|
||||
<div class="p-5 sm:p-6 lg:p-8">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-cyan-500/20 bg-cyan-500/10 px-3 py-1 text-xs text-cyan-200">
|
||||
<Rocket class="h-3.5 w-3.5" />
|
||||
<span>Source Delivery Edition</span>
|
||||
<span>首次使用</span>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-4 text-2xl font-semibold tracking-tight text-slate-50">
|
||||
{{ props.displayName || '欢迎使用 SSH Manager' }}
|
||||
</h2>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-400">
|
||||
这是首次启动引导。你现在拿到的是源码交付版,已经具备工作区、SFTP、备份、批量命令、历史日志和环境诊断入口,适合作为源码项目继续交付或二开。
|
||||
第一次进入时,先完成下面 3 件事。做完以后,就可以正常开始使用。
|
||||
</p>
|
||||
|
||||
<div class="mt-6 grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.14em] text-cyan-300/80">Step 1</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-100">创建第一条连接</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">填写主机、端口和认证方式后,就能直接进入终端与文件工作区。</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">填写主机、端口和认证方式后,就能直接进入终端和 SFTP 工作区。</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.14em] text-cyan-300/80">Step 2</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-100">如果有旧数据</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">顶部工具栏已经支持导入备份,可直接恢复连接和会话树,不需要手工重建。</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-100">有旧数据就导入备份</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">顶部工具栏支持导入备份,可以直接恢复连接和会话树,不需要手工重建。</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.14em] text-cyan-300/80">Step 3</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-100">查看交付信息</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">“关于与交付信息”里保留了版本、环境指纹和诊断摘要,方便你整理给客户的交付说明。</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-100">需要排查时看诊断</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">工具栏里的“关于与诊断”可以复制当前版本和运行摘要,方便发给卖家或自己排查。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +82,7 @@ function openAbout() {
|
||||
@click="openAbout"
|
||||
>
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
<span>查看交付信息</span>
|
||||
<span>查看关于与诊断</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -97,38 +93,6 @@ function openAbout() {
|
||||
<span>稍后处理</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="p-6 lg:p-8">
|
||||
<div class="rounded-2xl border border-slate-800 bg-slate-950/70 p-5">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<ShieldCheck class="h-4 w-4 text-emerald-300" />
|
||||
<h3 class="text-sm font-semibold text-slate-100">源码交付摘要</h3>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<p class="text-xs text-slate-500">当前状态</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-100">{{ productStatusStore.licenseStatusText }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<p class="text-xs text-slate-500">环境指纹</p>
|
||||
<p class="mt-1 break-all font-mono text-xs text-slate-200">{{ productStatusStore.machineFingerprint }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<FolderInput class="h-4 w-4 text-cyan-300" />
|
||||
<p class="text-sm font-medium text-slate-100">交付建议</p>
|
||||
</div>
|
||||
<ul class="mt-3 space-y-2 text-xs leading-5 text-slate-400">
|
||||
<li>顶部工具栏可导入备份,适合恢复客户环境</li>
|
||||
<li>历史日志里可以查看最近传输和关键操作</li>
|
||||
<li>关于页里的诊断信息可直接发给你排查部署问题</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FolderPlus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
CopyPlus,
|
||||
Search,
|
||||
X,
|
||||
ChevronDown,
|
||||
@@ -171,19 +172,30 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
const items: ContextMenuItem[] = [
|
||||
...(node.type === 'connection' && node.connectionId
|
||||
? [
|
||||
{
|
||||
label: '新开标签',
|
||||
icon: CopyPlus,
|
||||
action: () => workspaceStore.openWorkspace(node.connectionId!),
|
||||
} as ContextMenuItem,
|
||||
{
|
||||
label: '编辑连接',
|
||||
icon: Edit2,
|
||||
action: () => openEditConnection(node.connectionId!),
|
||||
} as ContextMenuItem,
|
||||
{ divider: true } as ContextMenuItem,
|
||||
{
|
||||
label: '重命名',
|
||||
icon: Edit2,
|
||||
action: () => startRename(node.id),
|
||||
} as ContextMenuItem,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: '重命名',
|
||||
icon: Edit2,
|
||||
action: () => startRename(node.id),
|
||||
},
|
||||
: [
|
||||
{
|
||||
label: '重命名',
|
||||
icon: Edit2,
|
||||
action: () => startRename(node.id),
|
||||
},
|
||||
]),
|
||||
]
|
||||
|
||||
if (node.type === 'folder') {
|
||||
@@ -257,7 +269,7 @@ function handleNodeClick(nodeId: string) {
|
||||
if (node.type === 'folder') {
|
||||
treeStore.toggleExpanded(nodeId)
|
||||
} else if (node.type === 'connection' && node.connectionId) {
|
||||
workspaceStore.openWorkspace(node.connectionId)
|
||||
workspaceStore.openOrActivateWorkspace(node.connectionId)
|
||||
}
|
||||
|
||||
treeStore.selectNode(nodeId)
|
||||
|
||||
@@ -21,7 +21,6 @@ const activityLogStore = useActivityLogStore()
|
||||
const draft = reactive<AppSettingsState>({
|
||||
terminalFontFamily: settingsStore.terminalFontFamily,
|
||||
terminalFontSize: settingsStore.terminalFontSize,
|
||||
defaultSplitRatio: settingsStore.defaultSplitRatio,
|
||||
uploadConflictStrategy: settingsStore.uploadConflictStrategy,
|
||||
downloadNamingStrategy: settingsStore.downloadNamingStrategy,
|
||||
})
|
||||
@@ -29,7 +28,6 @@ const draft = reactive<AppSettingsState>({
|
||||
function syncDraft() {
|
||||
draft.terminalFontFamily = settingsStore.terminalFontFamily
|
||||
draft.terminalFontSize = settingsStore.terminalFontSize
|
||||
draft.defaultSplitRatio = settingsStore.defaultSplitRatio
|
||||
draft.uploadConflictStrategy = settingsStore.uploadConflictStrategy
|
||||
draft.downloadNamingStrategy = settingsStore.downloadNamingStrategy
|
||||
}
|
||||
@@ -50,7 +48,7 @@ function submit() {
|
||||
category: 'settings',
|
||||
level: 'success',
|
||||
title: '设置已更新',
|
||||
detail: `终端字号 ${draft.terminalFontSize},默认分屏 ${Math.round(draft.defaultSplitRatio * 100)}%`,
|
||||
detail: `终端字号 ${draft.terminalFontSize}`,
|
||||
})
|
||||
toast.success('设置已保存')
|
||||
closeModal()
|
||||
@@ -120,25 +118,6 @@ function resetSettings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-100">工作区</h3>
|
||||
<p class="mt-1 text-xs text-slate-400">用于新建工作区和重置分屏时的默认值。</p>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm text-slate-300">默认分屏比例</span>
|
||||
<input
|
||||
v-model.number="draft.defaultSplitRatio"
|
||||
type="range"
|
||||
min="0.2"
|
||||
max="0.8"
|
||||
step="0.05"
|
||||
class="w-full accent-cyan-500"
|
||||
>
|
||||
<span class="mt-1 block text-xs text-slate-400">终端 {{ Math.round(draft.defaultSplitRatio * 100) }}% / 文件 {{ 100 - Math.round(draft.defaultSplitRatio * 100) }}%</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-100">文件传输</h3>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Bell,
|
||||
MonitorCog,
|
||||
ShieldCheck,
|
||||
X,
|
||||
ArrowLeftRight,
|
||||
@@ -269,210 +268,212 @@ const tabContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-slate-700 bg-slate-900 px-3 py-2 sm:px-4">
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-[40px] min-w-[40px] items-center justify-center rounded-lg border border-slate-700 bg-slate-800 text-slate-300 transition-colors hover:border-slate-600 hover:text-slate-100 lg:hidden"
|
||||
:aria-label="props.sidebarOpen ? '收起会话树' : '打开会话树'"
|
||||
@click="emit('toggleSidebar')"
|
||||
>
|
||||
<PanelLeftClose v-if="props.sidebarOpen" class="h-4 w-4" />
|
||||
<PanelLeftOpen v-else class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded bg-gradient-to-br from-cyan-500 to-blue-600">
|
||||
<MonitorCog class="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-slate-100">SSH Manager</p>
|
||||
<p class="hidden text-[11px] text-slate-500 sm:block">Moba Workspace</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-3 flex w-full flex-wrap items-center gap-2 text-xs text-slate-300 lg:order-none lg:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
@click="openBatchCommand"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100"
|
||||
aria-label="批量命令执行"
|
||||
>
|
||||
<TerminalSquare class="h-3.5 w-3.5" />
|
||||
<span>批量命令</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openOperationsHistory"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100"
|
||||
aria-label="传输历史与操作日志"
|
||||
>
|
||||
<ClipboardList class="h-3.5 w-3.5" />
|
||||
<span>历史日志</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleExportBackup"
|
||||
:disabled="backupBusy"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="导出备份"
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
<span>导出备份</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="triggerImportBackup"
|
||||
:disabled="backupBusy"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="导入备份"
|
||||
>
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
<span>导入备份</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openTransfers"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors"
|
||||
:class="workspaceStore.transfersModalOpen
|
||||
? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 hover:border-slate-600 hover:text-slate-100'"
|
||||
aria-label="打开传输页面"
|
||||
>
|
||||
<ArrowLeftRight class="h-3.5 w-3.5" />
|
||||
<span>Transfers</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openCreateSession"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100"
|
||||
aria-label="新增会话"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
<span>新增连接</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateActiveWorkspace"
|
||||
:disabled="!activeWorkspace"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="复制当前工作区"
|
||||
>
|
||||
<CopyPlus class="h-3.5 w-3.5" />
|
||||
<span>复制会话</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleTerminal"
|
||||
:disabled="!activeWorkspace"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="activeWorkspace?.terminalVisible
|
||||
? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 hover:border-slate-600 hover:text-slate-100'"
|
||||
aria-label="切换终端面板"
|
||||
>
|
||||
<SquareTerminal class="h-3.5 w-3.5" />
|
||||
<span>终端</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleSftp"
|
||||
:disabled="!activeWorkspace"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="activeWorkspace?.sftpVisible
|
||||
? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 hover:border-slate-600 hover:text-slate-100'"
|
||||
aria-label="切换 SFTP 面板"
|
||||
>
|
||||
<FolderTree class="h-3.5 w-3.5" />
|
||||
<span>文件</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="resetSplit"
|
||||
:disabled="!activeWorkspace"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="重置分屏比例"
|
||||
>
|
||||
<Columns2 class="h-3.5 w-3.5" />
|
||||
<span>重置分屏</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1 sm:gap-2">
|
||||
<button
|
||||
class="inline-flex min-h-[40px] min-w-[40px] items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
title="通知"
|
||||
type="button"
|
||||
>
|
||||
<Bell class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="openAbout"
|
||||
class="hidden min-h-[40px] min-w-[40px] items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200 sm:inline-flex"
|
||||
title="关于与交付信息"
|
||||
type="button"
|
||||
>
|
||||
<HelpCircle class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="openSettings"
|
||||
class="inline-flex min-h-[40px] min-w-[40px] items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
title="设置中心"
|
||||
aria-label="设置中心"
|
||||
type="button"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="openChangePassword"
|
||||
class="inline-flex min-h-[40px] min-w-[40px] items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
title="修改密码"
|
||||
aria-label="修改密码"
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg px-2.5 py-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
title="退出登录"
|
||||
aria-label="退出登录"
|
||||
type="button"
|
||||
>
|
||||
<LogOut class="h-4 w-4" />
|
||||
<span class="hidden text-xs sm:inline">退出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 min-w-0">
|
||||
<div v-if="workspaceTabs.length > 0" class="flex items-center gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||
<div
|
||||
v-for="tab in workspaceTabs"
|
||||
:key="tab.workspaceId"
|
||||
class="group flex min-h-[36px] max-w-[280px] shrink-0 items-center gap-1 rounded-lg border px-1.5 text-xs transition-colors cursor-pointer"
|
||||
:class="tab.active
|
||||
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600 hover:text-slate-100'"
|
||||
@click="activateTab(tab.workspaceId)"
|
||||
@contextmenu="(e) => openTabContextMenu(tab.workspaceId, e)"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-500'" />
|
||||
<span class="truncate max-w-[220px]">{{ tab.title }}</span>
|
||||
<div class="border-b border-slate-700 bg-slate-900">
|
||||
<div class="border-b border-slate-800/80 px-3 py-2 sm:px-4">
|
||||
<div class="flex flex-wrap items-center gap-2 lg:flex-nowrap lg:gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 text-slate-500 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200"
|
||||
@click="(e) => closeTab(tab.workspaceId, e)"
|
||||
:aria-label="`关闭会话 ${tab.title}`"
|
||||
class="inline-flex min-h-[40px] min-w-[40px] shrink-0 items-center justify-center rounded-lg border border-slate-700 bg-slate-800 text-slate-300 transition-colors hover:border-slate-600 hover:text-slate-100 lg:hidden"
|
||||
:aria-label="props.sidebarOpen ? '收起会话树' : '打开会话树'"
|
||||
@click="emit('toggleSidebar')"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
<PanelLeftClose v-if="props.sidebarOpen" class="h-4 w-4" />
|
||||
<PanelLeftOpen v-else class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-x-auto pb-1 lg:pb-0 scrollbar-thin">
|
||||
<div class="flex min-w-max items-center gap-2 text-xs text-slate-300">
|
||||
<button
|
||||
type="button"
|
||||
@click="openBatchCommand"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100"
|
||||
aria-label="批量命令执行"
|
||||
>
|
||||
<TerminalSquare class="h-3.5 w-3.5" />
|
||||
<span>批量命令</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openOperationsHistory"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100"
|
||||
aria-label="传输历史与操作日志"
|
||||
>
|
||||
<ClipboardList class="h-3.5 w-3.5" />
|
||||
<span>历史日志</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleExportBackup"
|
||||
:disabled="backupBusy"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="导出备份"
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
<span>导出备份</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="triggerImportBackup"
|
||||
:disabled="backupBusy"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="导入备份"
|
||||
>
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
<span>导入备份</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openTransfers"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors"
|
||||
:class="workspaceStore.transfersModalOpen
|
||||
? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 hover:border-slate-600 hover:text-slate-100'"
|
||||
aria-label="打开传输页面"
|
||||
>
|
||||
<ArrowLeftRight class="h-3.5 w-3.5" />
|
||||
<span>Transfers</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openCreateSession"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100"
|
||||
aria-label="新增会话"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
<span>新增连接</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateActiveWorkspace"
|
||||
:disabled="!activeWorkspace"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="复制当前工作区"
|
||||
>
|
||||
<CopyPlus class="h-3.5 w-3.5" />
|
||||
<span>复制会话</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleTerminal"
|
||||
:disabled="!activeWorkspace"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="activeWorkspace?.terminalVisible
|
||||
? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 hover:border-slate-600 hover:text-slate-100'"
|
||||
aria-label="切换终端面板"
|
||||
>
|
||||
<SquareTerminal class="h-3.5 w-3.5" />
|
||||
<span>终端</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleSftp"
|
||||
:disabled="!activeWorkspace"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="activeWorkspace?.sftpVisible
|
||||
? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 hover:border-slate-600 hover:text-slate-100'"
|
||||
aria-label="切换 SFTP 面板"
|
||||
>
|
||||
<FolderTree class="h-3.5 w-3.5" />
|
||||
<span>文件</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="resetSplit"
|
||||
:disabled="!activeWorkspace"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 transition-colors hover:border-slate-600 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="重置分屏比例"
|
||||
>
|
||||
<Columns2 class="h-3.5 w-3.5" />
|
||||
<span>重置分屏</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<button
|
||||
class="inline-flex min-h-[40px] min-w-[40px] items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
title="通知"
|
||||
type="button"
|
||||
>
|
||||
<Bell class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="openAbout"
|
||||
class="hidden min-h-[40px] min-w-[40px] items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200 sm:inline-flex"
|
||||
title="关于与诊断"
|
||||
type="button"
|
||||
>
|
||||
<HelpCircle class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="openSettings"
|
||||
class="inline-flex min-h-[40px] min-w-[40px] items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
title="设置中心"
|
||||
aria-label="设置中心"
|
||||
type="button"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="openChangePassword"
|
||||
class="inline-flex min-h-[40px] min-w-[40px] items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
title="修改密码"
|
||||
aria-label="修改密码"
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="inline-flex min-h-[40px] items-center gap-1.5 rounded-lg px-2.5 py-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200"
|
||||
title="退出登录"
|
||||
aria-label="退出登录"
|
||||
type="button"
|
||||
>
|
||||
<LogOut class="h-4 w-4" />
|
||||
<span class="hidden text-xs sm:inline">退出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-500">未打开工作区。点击左侧连接可创建新实例。</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-950/35 px-3 py-2 sm:px-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="hidden shrink-0 pt-2 sm:block">
|
||||
<p class="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-500">会话</p>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div v-if="workspaceTabs.length > 0" class="flex items-center gap-1 overflow-x-auto pb-1 scrollbar-thin">
|
||||
<div
|
||||
v-for="tab in workspaceTabs"
|
||||
:key="tab.workspaceId"
|
||||
class="group -mb-px flex min-h-[38px] max-w-[280px] shrink-0 items-center gap-2 rounded-t-lg border border-b-0 px-3 text-xs transition-colors cursor-pointer"
|
||||
:class="tab.active
|
||||
? 'border-cyan-400/35 bg-slate-900 text-cyan-100 shadow-[0_-1px_0_rgba(34,211,238,0.16)]'
|
||||
: 'border-slate-700/80 bg-slate-800/65 text-slate-400 hover:bg-slate-800 hover:text-slate-100'"
|
||||
@click="activateTab(tab.workspaceId)"
|
||||
@contextmenu="(e) => openTabContextMenu(tab.workspaceId, e)"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-500'" />
|
||||
<span class="truncate max-w-[220px]">{{ tab.title }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 text-slate-500 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200"
|
||||
@click="(e) => closeTab(tab.workspaceId, e)"
|
||||
:aria-label="`关闭会话 ${tab.title}`"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-2 text-xs text-slate-500">未打开工作区。点击左侧连接可创建新实例。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ watch(
|
||||
>
|
||||
<div v-if="workspace.terminalVisible || workspace.sftpVisible" class="h-full">
|
||||
<SplitPane
|
||||
direction="vertical"
|
||||
direction="horizontal"
|
||||
:initial-ratio="workspace.splitRatio"
|
||||
:show-first="workspace.terminalVisible"
|
||||
:show-second="workspace.sftpVisible"
|
||||
|
||||
@@ -46,10 +46,12 @@ const showBatchCommandModal = ref(false)
|
||||
const showOperationsHistoryModal = ref(false)
|
||||
const showAboutModal = ref(false)
|
||||
const sidebarOpen = computed(() => workspaceStore.sidebarOpen)
|
||||
const sidebarWidth = computed(() => workspaceStore.sidebarWidth)
|
||||
const transfersModalOpen = computed(() => workspaceStore.transfersModalOpen)
|
||||
const sessionModalOpen = computed(() => workspaceStore.sessionModalOpen)
|
||||
const sessionModalMode = computed(() => workspaceStore.sessionModalMode)
|
||||
const forcePasswordChange = computed(() => authStore.passwordChangeRequired)
|
||||
const isSidebarResizing = ref(false)
|
||||
const currentEditingConnection = computed(() => {
|
||||
if (sessionModalMode.value !== 'edit' || workspaceStore.editingConnectionId == null) {
|
||||
return null
|
||||
@@ -57,6 +59,10 @@ const currentEditingConnection = computed(() => {
|
||||
return connectionsStore.getConnection(workspaceStore.editingConnectionId) || null
|
||||
})
|
||||
|
||||
const desktopSidebarStyle = computed(() => ({
|
||||
width: `${sidebarWidth.value}px`,
|
||||
}))
|
||||
|
||||
// Enable bidirectional sync
|
||||
useConnectionSync()
|
||||
|
||||
@@ -158,6 +164,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSidebarResize()
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
@@ -191,6 +198,36 @@ function closeSidebar() {
|
||||
workspaceStore.closeSidebar()
|
||||
}
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.max(256, Math.min(420, Math.round(width)))
|
||||
}
|
||||
|
||||
function handleSidebarResize(event: MouseEvent) {
|
||||
if (!isSidebarResizing.value) return
|
||||
workspaceStore.updateSidebarWidth(clampSidebarWidth(event.clientX))
|
||||
}
|
||||
|
||||
function stopSidebarResize() {
|
||||
if (!isSidebarResizing.value) return
|
||||
|
||||
isSidebarResizing.value = false
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
window.removeEventListener('mousemove', handleSidebarResize)
|
||||
window.removeEventListener('mouseup', stopSidebarResize)
|
||||
}
|
||||
|
||||
function startSidebarResize(event: MouseEvent) {
|
||||
if (window.innerWidth < 1024) return
|
||||
|
||||
event.preventDefault()
|
||||
isSidebarResizing.value = true
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
window.addEventListener('mousemove', handleSidebarResize)
|
||||
window.addEventListener('mouseup', stopSidebarResize)
|
||||
}
|
||||
|
||||
function closeSessionModal() {
|
||||
workspaceStore.closeSessionModal()
|
||||
}
|
||||
@@ -304,13 +341,31 @@ function resolveErrorMessage(error: unknown, fallback: string) {
|
||||
/>
|
||||
|
||||
<div
|
||||
class="fixed inset-y-0 left-0 z-40 w-[18rem] max-w-[calc(100vw-2rem)] -translate-x-full border-r border-slate-700 bg-slate-900 transition-transform duration-200 lg:static lg:z-auto lg:w-72 lg:max-w-none lg:translate-x-0"
|
||||
class="fixed inset-y-0 left-0 z-40 w-[18rem] max-w-[calc(100vw-2rem)] -translate-x-full border-r border-slate-700 bg-slate-900 transition-transform duration-200 lg:hidden"
|
||||
:class="sidebarOpen ? 'translate-x-0' : ''"
|
||||
>
|
||||
<SessionTree />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 lg:pl-0">
|
||||
<div class="hidden h-full shrink-0 lg:block" :style="desktopSidebarStyle">
|
||||
<SessionTree />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="group relative hidden h-full w-3 shrink-0 cursor-col-resize lg:block"
|
||||
:class="isSidebarResizing ? 'bg-cyan-500/10' : ''"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整左侧会话树宽度"
|
||||
@mousedown="startSidebarResize"
|
||||
>
|
||||
<div
|
||||
class="mx-auto h-full w-px bg-slate-700 transition-colors"
|
||||
:class="isSidebarResizing ? 'bg-cyan-400' : 'group-hover:bg-cyan-500'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<WorkspacePanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'ssh-manager.product-status'
|
||||
|
||||
@@ -7,26 +7,8 @@ type ProductStatusSnapshot = {
|
||||
firstLaunchedAt: number
|
||||
}
|
||||
|
||||
function hashString(input: string) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
export const useProductStatusStore = defineStore('productStatus', () => {
|
||||
const firstLaunchedAt = ref<number>(Date.now())
|
||||
const licenseStatusText = computed(() => '源码交付版(无激活限制)')
|
||||
const machineFingerprint = computed(() => {
|
||||
const source = [
|
||||
window.location.host,
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
navigator.platform,
|
||||
].join('|')
|
||||
return hashString(source).toUpperCase()
|
||||
})
|
||||
|
||||
function restore() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
@@ -59,8 +41,6 @@ export const useProductStatusStore = defineStore('productStatus', () => {
|
||||
|
||||
return {
|
||||
firstLaunchedAt,
|
||||
licenseStatusText,
|
||||
machineFingerprint,
|
||||
restore,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ const STORAGE_KEY = 'ssh-manager.settings'
|
||||
const DEFAULT_SETTINGS: AppSettingsState = {
|
||||
terminalFontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
terminalFontSize: 14,
|
||||
defaultSplitRatio: 0.5,
|
||||
uploadConflictStrategy: 'ask',
|
||||
downloadNamingStrategy: 'original',
|
||||
}
|
||||
@@ -16,11 +15,6 @@ function normalizeFontSize(value: unknown) {
|
||||
return Math.max(12, Math.min(24, size))
|
||||
}
|
||||
|
||||
function normalizeSplitRatio(value: unknown) {
|
||||
const ratio = typeof value === 'number' ? value : DEFAULT_SETTINGS.defaultSplitRatio
|
||||
return Math.max(0.2, Math.min(0.8, ratio))
|
||||
}
|
||||
|
||||
function normalizeUploadConflictStrategy(value: unknown): UploadConflictStrategy {
|
||||
return value === 'overwrite' || value === 'skip' ? value : 'ask'
|
||||
}
|
||||
@@ -45,7 +39,6 @@ export const useSettingsStore = defineStore('settings', {
|
||||
? parsed.terminalFontFamily
|
||||
: DEFAULT_SETTINGS.terminalFontFamily
|
||||
this.terminalFontSize = normalizeFontSize(parsed.terminalFontSize)
|
||||
this.defaultSplitRatio = normalizeSplitRatio(parsed.defaultSplitRatio)
|
||||
this.uploadConflictStrategy = normalizeUploadConflictStrategy(parsed.uploadConflictStrategy)
|
||||
this.downloadNamingStrategy = normalizeDownloadNamingStrategy(parsed.downloadNamingStrategy)
|
||||
} catch (error) {
|
||||
@@ -60,9 +53,6 @@ export const useSettingsStore = defineStore('settings', {
|
||||
if (next.terminalFontSize != null) {
|
||||
this.terminalFontSize = normalizeFontSize(next.terminalFontSize)
|
||||
}
|
||||
if (next.defaultSplitRatio != null) {
|
||||
this.defaultSplitRatio = normalizeSplitRatio(next.defaultSplitRatio)
|
||||
}
|
||||
if (next.uploadConflictStrategy != null) {
|
||||
this.uploadConflictStrategy = normalizeUploadConflictStrategy(next.uploadConflictStrategy)
|
||||
}
|
||||
@@ -81,7 +71,6 @@ export const useSettingsStore = defineStore('settings', {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
terminalFontFamily: this.terminalFontFamily,
|
||||
terminalFontSize: this.terminalFontSize,
|
||||
defaultSplitRatio: this.defaultSplitRatio,
|
||||
uploadConflictStrategy: this.uploadConflictStrategy,
|
||||
downloadNamingStrategy: this.downloadNamingStrategy,
|
||||
}))
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { WorkspaceInstanceState, WorkspaceState } from '../types/workspace'
|
||||
import { useSettingsStore } from './settings'
|
||||
|
||||
const STORAGE_KEY = 'ssh-manager.workspace'
|
||||
const DEFAULT_SIDEBAR_WIDTH = 288
|
||||
const MIN_SIDEBAR_WIDTH = 256
|
||||
const MAX_SIDEBAR_WIDTH = 420
|
||||
const MIN_SFTP_SPLIT_RATIO = 0.8
|
||||
|
||||
type LegacyPanelState = {
|
||||
connectionId: number
|
||||
@@ -41,10 +44,15 @@ function createWorkspace(connectionId: number, instanceNumber: number, splitRati
|
||||
}
|
||||
|
||||
function normalizeSplitRatio(value: unknown) {
|
||||
const ratio = typeof value === 'number' ? value : 0.5
|
||||
const ratio = typeof value === 'number' ? value : MIN_SFTP_SPLIT_RATIO
|
||||
return Math.max(0.2, Math.min(0.8, ratio))
|
||||
}
|
||||
|
||||
function normalizeSidebarWidth(value: unknown) {
|
||||
const width = typeof value === 'number' ? value : DEFAULT_SIDEBAR_WIDTH
|
||||
return Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, Math.round(width)))
|
||||
}
|
||||
|
||||
function normalizeWorkspace(candidate: Partial<WorkspaceInstanceState>, fallbackId: string): WorkspaceInstanceState | null {
|
||||
if (typeof candidate.connectionId !== 'number' || candidate.connectionId <= 0) {
|
||||
return null
|
||||
@@ -73,15 +81,24 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
sessionModalMode: 'create',
|
||||
editingConnectionId: null,
|
||||
sidebarOpen: false,
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
activeWorkspace: (state): WorkspaceInstanceState | null => {
|
||||
return state.activeWorkspaceId ? state.workspaces[state.activeWorkspaceId] || null : null
|
||||
},
|
||||
|
||||
firstWorkspaceIdByConnection: (state) => (connectionId: number): string | null => {
|
||||
return state.workspaceOrder.find((workspaceId) => state.workspaces[workspaceId]?.connectionId === connectionId) ?? null
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
applyNewWorkspaceLayoutDefaults() {
|
||||
this.sidebarWidth = MIN_SIDEBAR_WIDTH
|
||||
},
|
||||
|
||||
nextInstanceNumber(connectionId: number) {
|
||||
return Object.values(this.workspaces)
|
||||
.filter((workspace) => workspace.connectionId === connectionId)
|
||||
@@ -89,8 +106,8 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
},
|
||||
|
||||
openWorkspace(connectionId: number) {
|
||||
const settingsStore = useSettingsStore()
|
||||
const workspace = createWorkspace(connectionId, this.nextInstanceNumber(connectionId), settingsStore.defaultSplitRatio)
|
||||
const workspace = createWorkspace(connectionId, this.nextInstanceNumber(connectionId), MIN_SFTP_SPLIT_RATIO)
|
||||
this.applyNewWorkspaceLayoutDefaults()
|
||||
this.workspaces[workspace.id] = workspace
|
||||
this.workspaceOrder.push(workspace.id)
|
||||
this.activeWorkspaceId = workspace.id
|
||||
@@ -98,17 +115,27 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
return workspace.id
|
||||
},
|
||||
|
||||
openOrActivateWorkspace(connectionId: number) {
|
||||
const existingWorkspaceId = this.firstWorkspaceIdByConnection(connectionId)
|
||||
if (existingWorkspaceId) {
|
||||
this.activateWorkspace(existingWorkspaceId)
|
||||
return existingWorkspaceId
|
||||
}
|
||||
|
||||
return this.openWorkspace(connectionId)
|
||||
},
|
||||
|
||||
duplicateWorkspace(workspaceId: string) {
|
||||
const source = this.workspaces[workspaceId]
|
||||
if (!source) return null
|
||||
|
||||
const duplicate = createWorkspace(source.connectionId, this.nextInstanceNumber(source.connectionId), source.splitRatio)
|
||||
duplicate.splitRatio = source.splitRatio
|
||||
const duplicate = createWorkspace(source.connectionId, this.nextInstanceNumber(source.connectionId), MIN_SFTP_SPLIT_RATIO)
|
||||
duplicate.terminalVisible = source.terminalVisible
|
||||
duplicate.sftpVisible = source.sftpVisible
|
||||
duplicate.currentPath = source.currentPath
|
||||
duplicate.selectedFiles = [...source.selectedFiles]
|
||||
|
||||
this.applyNewWorkspaceLayoutDefaults()
|
||||
this.workspaces[duplicate.id] = duplicate
|
||||
this.workspaceOrder.push(duplicate.id)
|
||||
this.activeWorkspaceId = duplicate.id
|
||||
@@ -234,8 +261,7 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
resetSplitRatio(workspaceId: string) {
|
||||
const workspace = this.workspaces[workspaceId]
|
||||
if (!workspace) return
|
||||
const settingsStore = useSettingsStore()
|
||||
workspace.splitRatio = settingsStore.defaultSplitRatio
|
||||
workspace.splitRatio = MIN_SFTP_SPLIT_RATIO
|
||||
this.persist()
|
||||
},
|
||||
|
||||
@@ -279,6 +305,11 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
this.sidebarOpen = !this.sidebarOpen
|
||||
},
|
||||
|
||||
updateSidebarWidth(width: number) {
|
||||
this.sidebarWidth = normalizeSidebarWidth(width)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
persist() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.$state))
|
||||
},
|
||||
@@ -368,6 +399,7 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
this.sessionModalMode = data.sessionModalMode === 'edit' ? 'edit' : 'create'
|
||||
this.editingConnectionId = typeof data.editingConnectionId === 'number' ? data.editingConnectionId : null
|
||||
this.sidebarOpen = false
|
||||
this.sidebarWidth = normalizeSidebarWidth(data.sidebarWidth)
|
||||
|
||||
this.transfersModalOpen = false
|
||||
this.sessionModalOpen = false
|
||||
|
||||
@@ -19,6 +19,39 @@
|
||||
linear-gradient(180deg, var(--app-bg-0), var(--app-bg-1));
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(103, 232, 249, 0.38) rgba(15, 23, 42, 0.78);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(103, 232, 249, 0.38) rgba(15, 23, 42, 0.42);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
border: 2px solid rgba(15, 23, 42, 0.4);
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(180deg, rgba(103, 232, 249, 0.34), rgba(34, 211, 238, 0.22));
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(125, 211, 252, 0.58), rgba(34, 211, 238, 0.4));
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
|
||||
@@ -4,7 +4,6 @@ export type DownloadNamingStrategy = 'original' | 'connectionPrefix'
|
||||
export interface AppSettingsState {
|
||||
terminalFontFamily: string
|
||||
terminalFontSize: number
|
||||
defaultSplitRatio: number
|
||||
uploadConflictStrategy: UploadConflictStrategy
|
||||
downloadNamingStrategy: DownloadNamingStrategy
|
||||
}
|
||||
|
||||
@@ -19,4 +19,5 @@ export interface WorkspaceState {
|
||||
sessionModalMode: 'create' | 'edit'
|
||||
editingConnectionId: number | null
|
||||
sidebarOpen: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
@@ -25,18 +25,18 @@ const loading = ref(false)
|
||||
const highlights = [
|
||||
{
|
||||
icon: SquareTerminal,
|
||||
title: 'Moba 工作区',
|
||||
description: '多实例标签、终端与 SFTP 分屏,直接面对日常 SSH 运维场景。',
|
||||
title: 'SSH / SFTP 工作区',
|
||||
description: '一个界面里直接打开终端和 SFTP,适合日常服务器管理。',
|
||||
},
|
||||
{
|
||||
icon: FolderInput,
|
||||
title: '完整备份恢复',
|
||||
description: '支持连接和会话树整体导入导出,迁移客户环境更省事。',
|
||||
title: '备份恢复',
|
||||
description: '支持连接和会话树整体导入导出,迁移环境更省事。',
|
||||
},
|
||||
{
|
||||
icon: History,
|
||||
title: '历史与日志',
|
||||
description: '传输历史、操作日志、诊断信息都能留住,售后定位更快。',
|
||||
title: '日志排查',
|
||||
description: '传输历史、操作日志和诊断信息都保留,出问题更容易定位。',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -74,16 +74,16 @@ async function handleSubmit() {
|
||||
<div class="relative">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-cyan-500/20 bg-cyan-500/10 px-3 py-1 text-xs tracking-[0.18em] text-cyan-200">
|
||||
<MonitorCog class="h-3.5 w-3.5" />
|
||||
<span>SOURCE DELIVERY EDITION</span>
|
||||
<span>源码交付 + Docker 部署</span>
|
||||
</div>
|
||||
|
||||
<h1 class="mt-5 max-w-3xl text-4xl font-semibold tracking-tight text-slate-50 lg:text-5xl">
|
||||
面向源码交付与二开的
|
||||
面向源码交付的
|
||||
<span class="text-cyan-300"> SSH / SFTP </span>
|
||||
工作区项目
|
||||
</h1>
|
||||
<p class="mt-5 max-w-2xl text-base leading-7 text-slate-300">
|
||||
把终端、文件传输、批量命令、备份恢复和诊断入口整合进一个统一工作区。适合源码交付、私有部署和后续二开。
|
||||
适合按源码 + Docker 方式交付。终端、SFTP、批量命令、备份恢复都放在一个统一工作区里。
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-4 md:grid-cols-3">
|
||||
@@ -105,9 +105,9 @@ async function handleSubmit() {
|
||||
<h2 class="text-sm font-semibold text-slate-100">交付方式</h2>
|
||||
</div>
|
||||
<ul class="mt-4 space-y-2 text-sm text-slate-400">
|
||||
<li>源码仓库 + 部署说明文档</li>
|
||||
<li>Docker 版一键启动</li>
|
||||
<li>首次启动引导、关于与交付信息、诊断摘要</li>
|
||||
<li>仓库源码</li>
|
||||
<li>README 一份主文档</li>
|
||||
<li>Docker 启动方式</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -117,9 +117,9 @@ async function handleSubmit() {
|
||||
<h2 class="text-sm font-semibold text-slate-100">当前版本能力</h2>
|
||||
</div>
|
||||
<ul class="mt-4 space-y-2 text-sm text-slate-400">
|
||||
<li>源码交付说明与环境诊断入口</li>
|
||||
<li>终端自动重连、批量命令执行</li>
|
||||
<li>传输历史、操作日志、备份恢复</li>
|
||||
<li>SSH 终端 + SFTP 文件管理</li>
|
||||
<li>批量命令 + 历史日志</li>
|
||||
<li>备份恢复 + 基础诊断</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ async function handleSubmit() {
|
||||
<LogIn class="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Workspace Access</p>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Workspace Login</p>
|
||||
<h2 class="mt-1 text-2xl font-semibold text-slate-100">登录 SSH 管理器</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
title: 'Moba 工作区',
|
||||
description: '统一入口打开终端、SFTP、传输和批量命令,支持多标签、多实例和分屏布局。',
|
||||
title: 'SSH / SFTP 工作区',
|
||||
description: '统一入口打开终端、SFTP、传输和批量命令,适合日常服务器管理。',
|
||||
icon: MonitorCog,
|
||||
},
|
||||
{
|
||||
@@ -25,12 +25,12 @@ const featureCards = [
|
||||
},
|
||||
{
|
||||
title: '备份恢复',
|
||||
description: '连接和会话树整体导入导出,适合迁移环境、售后恢复和客户交付。',
|
||||
description: '连接和会话树整体导入导出,适合迁移环境和售后恢复。',
|
||||
icon: FolderInput,
|
||||
},
|
||||
{
|
||||
title: '历史与日志',
|
||||
description: '保留传输历史、关键操作和诊断信息,方便售后排障和留痕。',
|
||||
description: '保留传输历史、关键操作和诊断信息,方便排障和留痕。',
|
||||
icon: History,
|
||||
},
|
||||
]
|
||||
@@ -41,7 +41,7 @@ const screenshots = [
|
||||
'终端与 SFTP 分屏',
|
||||
'批量命令执行结果',
|
||||
'传输历史与日志',
|
||||
'关于与交付信息',
|
||||
'关于与诊断',
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -54,7 +54,7 @@ const screenshots = [
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-cyan-500/20 bg-cyan-500/10 px-3 py-1 text-xs tracking-[0.2em] text-cyan-200">
|
||||
<BadgeCheck class="h-3.5 w-3.5" />
|
||||
<span>PRODUCT SHOWCASE</span>
|
||||
<span>商品展示页</span>
|
||||
</div>
|
||||
<h1 class="mt-5 max-w-3xl text-4xl font-semibold tracking-tight text-slate-50 lg:text-6xl">
|
||||
可直接截图和录屏的
|
||||
@@ -62,7 +62,7 @@ const screenshots = [
|
||||
演示页
|
||||
</h1>
|
||||
<p class="mt-5 max-w-2xl text-base leading-7 text-slate-300">
|
||||
用于源码商品图、演示视频和介绍页展示。把“可交付、可部署、可二开”的项目感直接摆到买家眼前,不需要先登录再解释这是什么。
|
||||
用于源码商品主图、录屏和详情页展示。重点突出“源码交付 + Docker 部署”,不用再把它包装成 Windows 安装包。
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap items-center gap-3">
|
||||
<RouterLink
|
||||
@@ -89,7 +89,7 @@ const screenshots = [
|
||||
<h2 class="text-sm font-semibold text-slate-100">适合谁买</h2>
|
||||
</div>
|
||||
<ul class="mt-4 space-y-2 text-sm text-slate-400">
|
||||
<li>开发者与小团队运维</li>
|
||||
<li>会 Docker 的开发者与小团队运维</li>
|
||||
<li>NAS / 软路由 / 云主机用户</li>
|
||||
<li>想找 MobaXterm / FinalShell 替代品的人</li>
|
||||
</ul>
|
||||
@@ -100,8 +100,8 @@ const screenshots = [
|
||||
<h2 class="text-sm font-semibold text-slate-100">当前可卖能力</h2>
|
||||
</div>
|
||||
<ul class="mt-4 space-y-2 text-sm text-slate-400">
|
||||
<li>源码交付说明与环境诊断</li>
|
||||
<li>Docker / 部署脚本</li>
|
||||
<li>SSH 终端 + SFTP 文件管理</li>
|
||||
<li>Docker 部署 + README 说明</li>
|
||||
<li>批量命令、日志、备份恢复</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "fix-transfer-and-multi-terminal",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user