chore: trim repo for customer delivery

This commit is contained in:
liumangmang
2026-04-17 17:53:06 +08:00
parent fa1b6707f7
commit 00ccd33961
36 changed files with 565 additions and 3519 deletions

3
.gitignore vendored
View File

@@ -13,8 +13,9 @@ frontend/dist/
.DS_Store
*.local
.codex
/package-lock.json
release/
.opencode/package-lock.json
.opencode/
# Worktrees
.worktrees/

View File

@@ -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
View File

@@ -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 行为
- 先阅读现有代码,再决定采用什么模式
- 匹配周边代码风格,不要强行套用新偏好
- 优先做手术式修改,而不是大范围重写
- 当改动会让相邻文档或类型失真时,一并更新它们
- 只在任务范围内把工作区变得更整洁
- 输出结果时优先说明实际改动、验证情况以及仍未覆盖的风险点

View File

@@ -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
View File

@@ -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. 能导出一次备份

View File

@@ -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`,重启容器数据保留。

View File

@@ -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

View File

@@ -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"`

View File

@@ -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"
```

View File

@@ -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"
```

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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. 关于与交付信息

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -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)。

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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,
}
})

View File

@@ -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,
}))

View File

@@ -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

View File

@@ -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,

View File

@@ -4,7 +4,6 @@ export type DownloadNamingStrategy = 'original' | 'connectionPrefix'
export interface AppSettingsState {
terminalFontFamily: string
terminalFontSize: number
defaultSplitRatio: number
uploadConflictStrategy: UploadConflictStrategy
downloadNamingStrategy: DownloadNamingStrategy
}

View File

@@ -19,4 +19,5 @@ export interface WorkspaceState {
sessionModalMode: 'create' | 'edit'
editingConnectionId: number | null
sidebarOpen: boolean
sidebarWidth: number
}

View File

@@ -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>

View File

@@ -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
View File

@@ -1,6 +0,0 @@
{
"name": "fix-transfer-and-multi-terminal",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}