From 37dc4d821633cd805d72035d7789b4d7ae05602e Mon Sep 17 00:00:00 2001 From: liumangmang Date: Thu, 16 Apr 2026 23:28:26 +0800 Subject: [PATCH] feat: prepare sellable source delivery edition --- MOBA_IMPLEMENTATION_STATUS.md | 227 +-- Makefile | 1 + README.md | 173 ++- backend/pom.xml | 37 +- .../config/ConfigurationValidator.java | 19 +- .../sshmanager/controller/AuthController.java | 165 +- .../controller/ConnectionController.java | 76 +- .../sshmanager/dto/BackupConnectionDto.java | 18 + .../dto/BackupImportResponseDto.java | 12 + .../com/sshmanager/dto/BackupPackageDto.java | 18 + .../sshmanager/dto/BatchCommandRequest.java | 13 + .../dto/BatchCommandResponseDto.java | 16 + .../sshmanager/dto/BatchCommandResultDto.java | 16 + .../sshmanager/dto/ChangePasswordRequest.java | 22 + .../com/sshmanager/dto/LoginResponse.java | 11 +- .../sshmanager/dto/SessionTreeLayoutDto.java | 8 +- .../com/sshmanager/service/BackupService.java | 148 ++ .../service/BatchCommandService.java | 81 + .../service/SessionTreeLayoutService.java | 15 + backend/src/main/resources/application.yml | 2 +- .../controller/AuthControllerTest.java | 161 ++ .../controller/ConnectionControllerTest.java | 51 + .../sshmanager/service/BackupServiceTest.java | 106 ++ .../service/BatchCommandServiceTest.java | 71 + .../service/SessionTreeLayoutServiceTest.java | 6 + docs/delivery-guide.md | 115 ++ docs/moba-regression-checklist.md | 156 ++ docs/release-checklist.md | 227 +++ docs/sellable-product-plan.md | 107 ++ docs/windows-after-sales-faq.md | 128 ++ docs/windows-buyer-guide.md | 101 ++ docs/xianyu-sales-copy.md | 87 ++ frontend/index.html | 4 +- frontend/public/ssh-manager.svg | 17 + frontend/src/api/auth.ts | 26 +- frontend/src/api/backup.ts | 35 + frontend/src/api/batchCommand.ts | 27 + frontend/src/api/sessionTree.ts | 2 + frontend/src/api/sftp.ts | 4 +- frontend/src/components/AboutLicenseModal.vue | 181 +++ frontend/src/components/BatchCommandModal.vue | 275 ++++ .../src/components/ChangePasswordModal.vue | 159 ++ .../src/components/FirstRunGuideModal.vue | 135 ++ .../src/components/OperationsHistoryModal.vue | 192 +++ frontend/src/components/SessionTree.vue | 171 ++- frontend/src/components/SettingsModal.vue | 201 +++ .../src/components/SftpFilePickerModal.vue | 24 +- frontend/src/components/SftpPanel.vue | 1322 ++++++++++++++--- frontend/src/components/SplitPane.vue | 32 +- frontend/src/components/TerminalWidget.vue | 482 ++++-- frontend/src/components/TopToolbar.vue | 461 +++++- frontend/src/components/WorkspacePanel.vue | 109 +- frontend/src/composables/useConnectionSync.ts | 8 +- .../composables/useLegacyWorkspaceRedirect.ts | 35 + frontend/src/layouts/MainLayout.vue | 94 -- frontend/src/layouts/MobaLayout.vue | 204 ++- frontend/src/router/index.ts | 26 + frontend/src/stores/activityLog.ts | 62 + frontend/src/stores/auth.ts | 66 +- frontend/src/stores/productStatus.ts | 66 + frontend/src/stores/sessionTree.ts | 101 +- frontend/src/stores/settings.ts | 90 ++ frontend/src/stores/sftpTabs.ts | 107 -- frontend/src/stores/terminalTabs.ts | 76 - frontend/src/stores/transfers.ts | 84 +- frontend/src/stores/workspace.ts | 401 +++-- frontend/src/types/activity.ts | 11 + frontend/src/types/sessionTree.ts | 4 +- frontend/src/types/settings.ts | 10 + frontend/src/types/workspace.ts | 11 +- frontend/src/utils/sftpPath.ts | 59 + frontend/src/views/ConnectionsView.vue | 419 ------ frontend/src/views/LoginView.vue | 248 +++- frontend/src/views/SftpView.vue | 1030 +------------ frontend/src/views/ShowcaseView.vue | 165 ++ frontend/src/views/TerminalView.vue | 48 +- frontend/src/views/TerminalWorkspaceView.vue | 166 --- frontend/src/views/TransfersView.vue | 66 +- scripts/installer/assets/ssh-manager.ico | Bin 0 -> 4286 bytes scripts/installer/ssh-manager.iss | 72 + scripts/release/build-local-package.bat | 64 + scripts/release/build-local-package.sh | 29 + scripts/release/build-windows-installer.bat | 146 ++ scripts/release/check-windows-release.bat | 149 ++ scripts/release/get-app-version.ps1 | 14 + scripts/windows/start-docker.bat | 28 + scripts/windows/start-installed.cmd | 13 + scripts/windows/start-installed.ps1 | 186 +++ scripts/windows/start-installed.vbs | 3 + scripts/windows/start-local.bat | 56 + scripts/windows/stop-docker.bat | 11 + scripts/windows/stop-installed.cmd | 14 + scripts/windows/stop-installed.ps1 | 50 + 93 files changed, 7649 insertions(+), 3096 deletions(-) create mode 100644 backend/src/main/java/com/sshmanager/dto/BackupConnectionDto.java create mode 100644 backend/src/main/java/com/sshmanager/dto/BackupImportResponseDto.java create mode 100644 backend/src/main/java/com/sshmanager/dto/BackupPackageDto.java create mode 100644 backend/src/main/java/com/sshmanager/dto/BatchCommandRequest.java create mode 100644 backend/src/main/java/com/sshmanager/dto/BatchCommandResponseDto.java create mode 100644 backend/src/main/java/com/sshmanager/dto/BatchCommandResultDto.java create mode 100644 backend/src/main/java/com/sshmanager/dto/ChangePasswordRequest.java create mode 100644 backend/src/main/java/com/sshmanager/service/BackupService.java create mode 100644 backend/src/main/java/com/sshmanager/service/BatchCommandService.java create mode 100644 backend/src/test/java/com/sshmanager/controller/AuthControllerTest.java create mode 100644 backend/src/test/java/com/sshmanager/service/BackupServiceTest.java create mode 100644 backend/src/test/java/com/sshmanager/service/BatchCommandServiceTest.java create mode 100644 docs/delivery-guide.md create mode 100644 docs/moba-regression-checklist.md create mode 100644 docs/release-checklist.md create mode 100644 docs/sellable-product-plan.md create mode 100644 docs/windows-after-sales-faq.md create mode 100644 docs/windows-buyer-guide.md create mode 100644 docs/xianyu-sales-copy.md create mode 100644 frontend/public/ssh-manager.svg create mode 100644 frontend/src/api/backup.ts create mode 100644 frontend/src/api/batchCommand.ts create mode 100644 frontend/src/components/AboutLicenseModal.vue create mode 100644 frontend/src/components/BatchCommandModal.vue create mode 100644 frontend/src/components/ChangePasswordModal.vue create mode 100644 frontend/src/components/FirstRunGuideModal.vue create mode 100644 frontend/src/components/OperationsHistoryModal.vue create mode 100644 frontend/src/components/SettingsModal.vue create mode 100644 frontend/src/composables/useLegacyWorkspaceRedirect.ts delete mode 100644 frontend/src/layouts/MainLayout.vue create mode 100644 frontend/src/stores/activityLog.ts create mode 100644 frontend/src/stores/productStatus.ts create mode 100644 frontend/src/stores/settings.ts delete mode 100644 frontend/src/stores/sftpTabs.ts delete mode 100644 frontend/src/stores/terminalTabs.ts create mode 100644 frontend/src/types/activity.ts create mode 100644 frontend/src/types/settings.ts create mode 100644 frontend/src/utils/sftpPath.ts delete mode 100644 frontend/src/views/ConnectionsView.vue create mode 100644 frontend/src/views/ShowcaseView.vue delete mode 100644 frontend/src/views/TerminalWorkspaceView.vue create mode 100644 scripts/installer/assets/ssh-manager.ico create mode 100644 scripts/installer/ssh-manager.iss create mode 100644 scripts/release/build-local-package.bat create mode 100644 scripts/release/build-local-package.sh create mode 100644 scripts/release/build-windows-installer.bat create mode 100644 scripts/release/check-windows-release.bat create mode 100644 scripts/release/get-app-version.ps1 create mode 100644 scripts/windows/start-docker.bat create mode 100644 scripts/windows/start-installed.cmd create mode 100644 scripts/windows/start-installed.ps1 create mode 100644 scripts/windows/start-installed.vbs create mode 100644 scripts/windows/start-local.bat create mode 100644 scripts/windows/stop-docker.bat create mode 100644 scripts/windows/stop-installed.cmd create mode 100644 scripts/windows/stop-installed.ps1 diff --git a/MOBA_IMPLEMENTATION_STATUS.md b/MOBA_IMPLEMENTATION_STATUS.md index 215c906..8172a8e 100644 --- a/MOBA_IMPLEMENTATION_STATUS.md +++ b/MOBA_IMPLEMENTATION_STATUS.md @@ -1,204 +1,49 @@ -# MobaXterm 风格重构实施状态 +# Moba Workspace 实施状态 -## 已完成 ✅ +## 当前结论 +- `/moba` 是当前唯一主工作区入口。 +- 历史路径 `/connections`、`/terminal` 仅保留兼容跳转;`/terminal/:id`、`/sftp/:id` 会打开对应工作区后进入 `/moba`,不再维护旧布局并行能力。 +- 本轮已补齐多实例工作区、嵌入式 SFTP 主要能力、移动端侧边栏抽屉和顶部面板控制。 -### Phase 1: 基础架构 (100%) -- ✅ 安装依赖 @vueuse/core -- ✅ 创建类型定义 - - `frontend/src/types/sessionTree.ts` - - `frontend/src/types/workspace.ts` -- ✅ 实现 Pinia stores - - `frontend/src/stores/sessionTree.ts` - - `frontend/src/stores/workspace.ts` +## 已落地能力 -### Phase 2: 核心组件开发 (100%) -- ✅ `SplitPane.vue` - 可拖拽分割面板 -- ✅ `TopToolbar.vue` - 顶部工具栏(样式占位) -- ✅ `SessionTreeNode.vue` - 递归树节点组件 -- ✅ `SessionTree.vue` - 会话树主组件 -- ✅ `SftpPanel.vue` - SFTP 面板(props 驱动) -- ✅ `WorkspacePanel.vue` - 工作区面板 +### 工作区 +- 支持同一连接打开多个独立工作区实例 +- 顶部标签按实例显示,并支持关闭当前 / 关闭其他 / 关闭右侧 / 全部关闭 +- 支持终端面板显隐、SFTP 面板显隐、分屏比例重置 +- 分屏比例、活动工作区、SFTP 路径仍会持久化到本地 -### Phase 3: 拖拽功能 (100%) -- ✅ `useTreeDragDrop` composable 实现 -- ✅ SessionTreeNode 集成拖拽事件 -- ✅ 拖拽约束(不能拖到自己/子节点) -- ✅ 拖拽视觉反馈(半透明、指示线、高亮) -- ✅ 支持 before/after/inside 放置位置 +### 会话树 +- 创建文件夹、重命名、删除、拖拽排序 +- 搜索、展开/折叠全部、右键菜单 +- 支持手动排序和名称排序切换 +- 会话树变更会同步到服务端,失败时显示可重试提示 -### Phase 4: 布局集成 (100%) -- ✅ `MobaLayout.vue` - 主布局 -- ✅ 路由配置更新 - - 新增 `/moba` 路由 - - 默认首页重定向到 `/moba` -- ✅ 构建测试通过 +### 嵌入式 SFTP +- 文件浏览、上传、下载、删除、创建目录 +- 搜索、隐藏文件切换、路径直达 +- 上传进度面板 +- 远程传输弹窗、进度轮询、取消传输 +- 删除和新建目录已改为弹窗交互,不再使用浏览器阻塞式对话框 -### Phase 5: 数据迁移和同步 (100%) -- ✅ `MigrationPrompt.vue` - 迁移提示组件 -- ✅ `useConnectionSync` composable - 双向同步 -- ✅ syncNewConnections/syncDeletedConnections 方法 -- ✅ syncConnectionName 方法 -- ✅ 首次使用迁移提示 -- ✅ 自动同步新增/删除的连接 +### 响应式 +- 小屏下会话树改为抽屉 +- 顶部工具栏和工作区按钮支持折行 +- Transfers 弹层和工作区布局支持窄屏访问 -### Phase 6: 优化完善 (100%) -- ✅ `useKeyboardShortcuts` composable -- ✅ 键盘快捷键: F2(重命名), Delete(删除), Ctrl+N(新建文件夹), Ctrl+F(搜索) -- ✅ `ContextMenu.vue` - 右键菜单组件 -- ✅ 右键菜单: 重命名、删除、新建子文件夹 -- ✅ 重命名对话框 -- ✅ 删除确认提示 -- ✅ `useTreeSearch` - 搜索功能 -- ✅ 搜索高亮和结果过滤 -- ✅ 展开/折叠全部功能 -- ⏳ 响应式设计(移动端适配) -- ⏳ 性能优化(虚拟滚动) +## 仍建议继续优化 +- 会话树大数据量场景的计算优化,目前虚拟滚动已做,但 flatten/sort 仍是计算热点 +- `/terminal/:id` 与 `/sftp/:id` 仍保留轻量兼容入口,用于承接旧深链接 +- Transfers 页面本身仍偏桌面布局,可继续细化移动端交互 -## 当前状态 - -### 可用功能 -1. **会话树管理** - - 创建文件夹(按钮 + Ctrl+N) - - 展开/折叠文件夹 - - 展开/折叠全部(工具栏按钮) - - 拖拽节点重新排序 - - 拖拽节点到文件夹 - - 搜索会话(Ctrl+F) - - 搜索结果高亮 - - 右键菜单操作 - - F2 重命名节点 - - Delete 删除节点 - - localStorage 持久化 - -2. **工作区面板** - - 垂直分屏(终端 + SFTP) - - 可拖拽调整分割比例 - - 分割比例持久化 - - 每个连接独立工作区 - -3. **终端集成** - - 复用现有 TerminalWidget - - 实时监控面板 - - WebSocket 连接 - -4. **SFTP 功能** - - 文件浏览 - - 上传/下载 - - 创建文件夹 - - 删除文件 - -5. **数据迁移** - - 首次使用迁移提示 - - 自动同步连接变更 - - 新旧布局共存 - -### 访问方式 -- 新布局: http://localhost:5173/moba -- 旧布局: http://localhost:5173/connections (保留兼容) - -## 测试步骤 - -1. 启动后端服务 -```bash -cd backend -go run main.go -``` - -2. 启动前端服务 +## 验证方式 ```bash cd frontend -npm run dev +npm run build ``` -3. 访问新布局 -``` -http://localhost:5173/moba -``` +更完整的手工回归步骤见: +- `docs/moba-regression-checklist.md` -4. 测试功能 - - 点击"文件夹"按钮或按 Ctrl+N 创建文件夹 - - 使用工具栏按钮展开/折叠全部文件夹 - - 按 Ctrl+F 或点击搜索框搜索会话 - - 拖拽节点重新排序或移动到文件夹 - - 右键点击节点查看菜单 - - 按 F2 重命名选中节点 - - 按 Delete 删除选中节点 - - 点击连接节点打开工作区 - - 拖拽分割条调整终端/SFTP 比例 - - 刷新页面验证状态持久化 - -## 已知问题 - -1. **SFTP 功能简化** - - 移除了搜索和隐藏文件功能 - - 移除了远程传输功能 - - 上传进度显示简化 - -2. **待优化项** - - 响应式设计(移动端适配) - - 大量节点时的虚拟滚动优化 - -## 下一步计划 - -1. **短期优化** - - 响应式设计(移动端/平板适配) - - 性能优化(虚拟滚动,大量节点场景) - - 添加更多键盘快捷键(Ctrl+C复制,Ctrl+V粘贴等) - -2. **长期扩展** - - 多工作区支持(标签页) - - 会话模板功能 - - 云端同步 - - 导入/导出配置 - - 会话分组颜色标记 - - 连接状态实时显示 - -## 技术栈 - -- Vue 3.5.24 (Composition API) -- Pinia 3.0.4 (状态管理) -- Vue Router 5.0.2 (路由) -- Tailwind CSS 3.4.14 (样式) -- @vueuse/core (工具库) -- xterm.js 5.3.0 (终端) -- lucide-vue-next (图标) - -## 文件结构 - -``` -frontend/src/ -├── types/ -│ ├── sessionTree.ts # 会话树类型定义 -│ └── workspace.ts # 工作区类型定义 -├── stores/ -│ ├── sessionTree.ts # 会话树状态管理 -│ └── workspace.ts # 工作区状态管理 -├── composables/ -│ ├── useTreeDragDrop.ts # 拖拽逻辑 -│ ├── useConnectionSync.ts # 连接同步 -│ ├── useKeyboardShortcuts.ts # 键盘快捷键 -│ └── useTreeSearch.ts # 会话树搜索 -├── components/ -│ ├── SessionTree.vue # 会话树主组件 -│ ├── SessionTreeNode.vue # 树节点组件(递归) -│ ├── WorkspacePanel.vue # 工作区面板 -│ ├── SplitPane.vue # 分割面板 -│ ├── SftpPanel.vue # SFTP 面板 -│ ├── TopToolbar.vue # 顶部工具栏 -│ ├── ContextMenu.vue # 右键菜单 -│ └── MigrationPrompt.vue # 迁移提示 -└── layouts/ - └── MobaLayout.vue # MobaXterm 风格主布局 -``` - ---- - -**最后更新**: 2026-04-03 -**实施进度**: Phase 1-6 全部完成 -**Git 提交**: 6 个提交 - - feat: implement MobaXterm-style layout (Phase 1-2-4) - - feat: implement drag-drop and data migration (Phase 3 & 5) - - feat: add keyboard shortcuts and context menu (Phase 6) - - feat: add session tree search functionality - - feat: add expand/collapse all functionality - - docs: update implementation status - all phases complete +## 最近一次更新 +- 2026-04-14 diff --git a/Makefile b/Makefile index e4d18f9..569dae0 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ help: @printf " make restart Restart services\n" @printf " make logs Follow service logs\n" @printf " make ps Show service status\n" + @printf " Note: do not use 'docker compose down -v' in daily usage (it removes persistent volumes)\n" build: $(COMPOSE) build diff --git a/README.md b/README.md index 0744de7..6eed210 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,19 @@ -# SSH 管理器 - -基于 Web 的 SSH 连接管理工具,支持终端与 SFTP 文件传输。技术栈:Vue 3、Spring Boot(JDK 8)、JSch、xterm.js。 - -## 功能 - -- **认证**:本地用户登录(JWT) -- **连接管理**:SSH 连接的增删改查(密码或私钥) -- **Web 终端**:通过 WebSocket + xterm.js 实现实时 SSH 终端 -- **SFTP**:文件浏览,支持上传、下载、删除、创建目录 - -## 环境要求 +# SSH 管理器 + +基于 Web 的 SSH / SFTP 工作区项目,适合源码交付、私有部署和二次开发。技术栈:Vue 3、Spring Boot(JDK 8)、JSch、xterm.js。 + +## 功能 + +- **认证**:本地用户登录(JWT) +- **账户安全**:默认管理员登录后会提示修改密码 +- **连接管理**:SSH 连接的增删改查(密码或私钥) +- **Moba 工作区**:统一入口 `/moba`,支持多工作区实例、多标签和终端/SFTP 分屏 +- **Web 终端**:通过 WebSocket + xterm.js 实现实时 SSH 终端 +- **SFTP**:文件浏览、搜索、隐藏文件切换、上传/下载、远程传输、删除、创建目录 +- **批量能力**:批量命令执行、操作日志、传输历史 +- **交付辅助**:首次启动引导、关于与交付信息、诊断摘要、备份恢复 + +## 环境要求 - JDK 8+ - Node.js 18+ @@ -36,19 +40,123 @@ npm install npm run dev ``` -前端运行在 http://localhost:5173(API 与 WebSocket 会代理到后端) +前端运行在 http://localhost:5173(API 与 WebSocket 会代理到后端) + +默认登录后进入 `/moba` 工作区。历史路径 `/connections`、`/terminal` 会跳转到 `/moba`;带连接 ID 的旧深链接 `/terminal/:id`、`/sftp/:id` 会先打开对应工作区,再进入 `/moba`。 +在 `/moba` 中,点击左侧连接会创建新的工作区实例;同一连接可重复打开多个实例。 + +## 交付形态 + +当前仓库支持两种交付方向: + +- 源码仓库版:适合会自己部署、要二开的买家 +- Windows 安装版:适合普通买家,安装后双击快捷方式即可使用 + +## 源码交付建议 + +如果你按源码版本售卖,建议交付时至少包含: + +- 当前仓库源码 +- `README.md` 或独立部署文档 +- Docker 启动方式 +- 默认账号与修改密码说明 +- 环境变量说明 +- 备份导入导出示例 +- 售后排查时用到的诊断摘要入口说明 + +源码版当前不依赖授权码,买家拿到后可自行部署、二开和迁移环境。 + +## Windows 安装版 + +Windows 成品版仍然是本地 Web 应用,但可封装为安装包: + +- 安装程序:`Setup.exe` +- 安装目录:`C:\Program Files\SSH Manager\` +- 数据目录:`%LOCALAPPDATA%\SSHManager\data` +- 日志目录:`%LOCALAPPDATA%\SSHManager\logs` + +安装后双击快捷方式会: + +- 自动检测程序是否已启动 +- 首次生成本地密钥 +- 后台启动内置 jar +- 自动打开浏览器到 `http://127.0.0.1:48080` + +## 启动方式 + +### Windows 本地验证 + +- 先准备 Java 8+ +- 构建后可直接运行: + - `scripts/windows/start-local.bat` +- 本地打包验证: + - `scripts/release/build-local-package.bat` + +脚本会自动: +- 创建 `runtime/` 和 `data/` +- 首次生成本地密钥 +- 启动后端 jar + +### Windows 安装版构建 + +先准备: + +- Node.js 18+ +- Maven 3.6+ +- Inno Setup +- 一个已解压的 Windows JRE 目录,并设置环境变量 `SSH_MANAGER_WINDOWS_JRE_DIR` +- 发布版本号取自 `backend/pom.xml` 的 `` + +建议先执行预检: + +```bat +scripts\release\check-windows-release.bat +``` + +执行: + +```bat +scripts\release\build-windows-installer.bat +``` + +脚本会自动: + +- 构建前端 +- 以 `embed-frontend-dist` profile 打包后端 +- 组装 `release/windows-app/` +- 如检测到 `ISCC.exe`,生成 `release/windows-installer/SSHManager-Setup-*.exe` +- 自动带上安装器图标和买家 / 售后说明文档 + +### Docker 版 + +- Windows 一键启动验证: + - `scripts/windows/start-docker.bat` +- 停止: + - `scripts/windows/stop-docker.bat` + +更完整的交付说明见: +- `docs/delivery-guide.md` -### 生产构建 - -```bash -# 后端 -cd backend && mvn package - -# 前端 -cd frontend && npm run build -``` - -将 `frontend/dist` 目录内容复制到后端的静态资源目录,或单独部署前端。 +### 生产构建 + +```bash +# 前端 +cd frontend && npm run build + +# 后端 +cd backend && mvn -Pembed-frontend-dist package +``` + +## 验证建议 + +- 前端改动后至少运行: +```bash +cd frontend +npm run build +``` + +- Moba 工作区相关回归项见: + - `docs/moba-regression-checklist.md` ## 项目结构 @@ -76,12 +184,19 @@ ssh-manager/ - `sshmanager.encryption-key`:用于加密连接密码的 Base64 32 字节密钥 - `sshmanager.jwt-secret`:JWT 签名密钥 -- `spring.datasource.url`:H2 数据库路径(默认:`./data/sshmanager`) +- `spring.datasource.url`:H2 数据库路径(默认:`${DATA_DIR:/app/data}/sshmanager`) -### 环境变量 - -- `SSHMANAGER_ENCRYPTION_KEY`:覆盖加密密钥 -- `SSHMANAGER_JWT_SECRET`:覆盖 JWT 密钥 +### 环境变量 + +- `SSHMANAGER_ENCRYPTION_KEY`:覆盖加密密钥 +- `SSHMANAGER_JWT_SECRET`:覆盖 JWT 密钥 +- `DATA_DIR`:数据目录(默认 `/app/data`,Docker 下应保持卷挂载) + +### Docker 持久化说明 + +- 默认通过 `make up` / `make restart` 运行,数据存放在 `app-data` 命名卷。 +- `make down` 仅停止并移除容器,不删除卷数据。 +- 不要使用 `docker compose ... down -v`,该命令会删除卷并导致数据丢失。 ## 安全说明 diff --git a/backend/pom.xml b/backend/pom.xml index 446c8df..7fb0e71 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -82,12 +82,31 @@ - - - - org.springframework.boot - spring-boot-maven-plugin - - - - + + + + src/main/resources + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + embed-frontend-dist + + + + ${project.basedir}/../frontend/dist + static + + + + + + diff --git a/backend/src/main/java/com/sshmanager/config/ConfigurationValidator.java b/backend/src/main/java/com/sshmanager/config/ConfigurationValidator.java index a6f79f2..a023ad5 100644 --- a/backend/src/main/java/com/sshmanager/config/ConfigurationValidator.java +++ b/backend/src/main/java/com/sshmanager/config/ConfigurationValidator.java @@ -6,7 +6,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Configuration; -import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -15,34 +14,38 @@ public class ConfigurationValidator implements CommandLineRunner { private static final Logger log = LoggerFactory.getLogger(ConfigurationValidator.class); - @Value("${SSHMANAGER_ENCRYPTION_KEY:}") + @Value("${sshmanager.encryption-key:}") private String encryptionKey; - @Value("${SSHMANAGER_JWT_SECRET:}") + @Value("${sshmanager.jwt-secret:}") private String jwtSecret; + @Value("${DATA_DIR:/app/data}") + private String dataDir; + @Override public void run(String... args) { + log.info("Data directory resolved to: {}", dataDir); Set missingConfigs = new HashSet<>(); if (encryptionKey == null || encryptionKey.trim().isEmpty()) { - missingConfigs.add("SSHMANAGER_ENCRYPTION_KEY"); + missingConfigs.add("sshmanager.encryption-key"); } if (jwtSecret == null || jwtSecret.trim().isEmpty()) { - missingConfigs.add("SSHMANAGER_JWT_SECRET"); + missingConfigs.add("sshmanager.jwt-secret"); } if (!missingConfigs.isEmpty()) { String missing = String.join(", ", missingConfigs); - log.error("Missing required environment variables: {}", missing); - log.error("Please set the following environment variables:"); + log.error("Missing required configuration values: {}", missing); + log.error("Please provide them via Spring properties, environment variables or JVM -D arguments."); missingConfigs.forEach(key -> log.error(" - {} (required)", key)); log.error("Application will not start without these configurations."); System.exit(1); } if ("ssh-manager-jwt-secret-change-in-production".equals(jwtSecret)) { - log.error("Default JWT secret detected. Please set SSHMANAGER_JWT_SECRET to a secure random value."); + log.error("Default JWT secret detected. Please set sshmanager.jwt-secret to a secure random value."); System.exit(1); } diff --git a/backend/src/main/java/com/sshmanager/controller/AuthController.java b/backend/src/main/java/com/sshmanager/controller/AuthController.java index ccf66fe..9193c19 100644 --- a/backend/src/main/java/com/sshmanager/controller/AuthController.java +++ b/backend/src/main/java/com/sshmanager/controller/AuthController.java @@ -1,69 +1,140 @@ package com.sshmanager.controller; -import com.sshmanager.dto.LoginRequest; -import com.sshmanager.dto.LoginResponse; -import com.sshmanager.entity.User; -import com.sshmanager.repository.UserRepository; -import com.sshmanager.security.JwtTokenProvider; -import org.springframework.http.ResponseEntity; +import com.sshmanager.dto.LoginRequest; +import com.sshmanager.dto.LoginResponse; +import com.sshmanager.dto.ChangePasswordRequest; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.security.JwtTokenProvider; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.Map; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; @RestController @RequestMapping("/api/auth") public class AuthController { - private final AuthenticationManager authenticationManager; - private final JwtTokenProvider tokenProvider; - private final UserRepository userRepository; + private final AuthenticationManager authenticationManager; + private final JwtTokenProvider tokenProvider; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public AuthController(AuthenticationManager authenticationManager, + JwtTokenProvider tokenProvider, + UserRepository userRepository, + PasswordEncoder passwordEncoder) { + this.authenticationManager = authenticationManager; + this.tokenProvider = tokenProvider; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } - public AuthController(AuthenticationManager authenticationManager, - JwtTokenProvider tokenProvider, - UserRepository userRepository) { - this.authenticationManager = authenticationManager; - this.tokenProvider = tokenProvider; - this.userRepository = userRepository; - } - - @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { try { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); - SecurityContextHolder.getContext().setAuthentication(authentication); - String token = tokenProvider.generateToken(authentication); - - User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found")); - LoginResponse response = new LoginResponse(token, user.getUsername(), - user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()); - - return ResponseEntity.ok(response); - } catch (BadCredentialsException e) { + SecurityContextHolder.getContext().setAuthentication(authentication); + String token = tokenProvider.generateToken(authentication); + + User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found")); + LoginResponse response = new LoginResponse(token, user.getUsername(), + user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), + isPasswordChangeRequired(user)); + + return ResponseEntity.ok(response); + } catch (BadCredentialsException e) { Map error = new HashMap<>(); error.put("message", "Invalid username or password"); return ResponseEntity.status(401).body(error); - } - } - - @GetMapping("/me") - public ResponseEntity me(Authentication authentication) { + } + } + + @GetMapping("/health") + public ResponseEntity health() { + Map data = new HashMap<>(); + data.put("app", "ssh-manager"); + data.put("status", "ok"); + data.put("timestamp", Instant.now().toEpochMilli()); + return ResponseEntity.ok(data); + } + + @GetMapping("/me") + public ResponseEntity me(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) { Map error = new HashMap<>(); error.put("error", "Unauthorized"); return ResponseEntity.status(401).body(error); } User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found")); - Map data = new HashMap<>(); - data.put("username", user.getUsername()); - data.put("displayName", user.getDisplayName()); - return ResponseEntity.ok(data); - } -} + Map data = new HashMap<>(); + data.put("username", user.getUsername()); + data.put("displayName", user.getDisplayName()); + data.put("passwordChangeRequired", isPasswordChangeRequired(user)); + return ResponseEntity.ok(data); + } + + @PostMapping("/change-password") + public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request, Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + Map error = new HashMap<>(); + error.put("error", "Unauthorized"); + return ResponseEntity.status(401).body(error); + } + + String currentPassword = request.getCurrentPassword() == null ? "" : request.getCurrentPassword().trim(); + String newPassword = request.getNewPassword() == null ? "" : request.getNewPassword().trim(); + + if (currentPassword.isEmpty() || newPassword.isEmpty()) { + Map error = new HashMap<>(); + error.put("message", "Current password and new password are required"); + return ResponseEntity.badRequest().body(error); + } + + if (newPassword.length() < 8) { + Map error = new HashMap<>(); + error.put("message", "New password must be at least 8 characters"); + return ResponseEntity.badRequest().body(error); + } + + User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found")); + if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) { + Map error = new HashMap<>(); + error.put("message", "Current password is incorrect"); + return ResponseEntity.badRequest().body(error); + } + + if (passwordEncoder.matches(newPassword, user.getPasswordHash())) { + Map error = new HashMap<>(); + error.put("message", "New password must be different from current password"); + return ResponseEntity.badRequest().body(error); + } + + user.setPasswordHash(passwordEncoder.encode(newPassword)); + user.setPasswordChangedAt(Instant.now()); + userRepository.save(user); + + Map data = new HashMap<>(); + data.put("message", "Password updated"); + data.put("passwordChangeRequired", false); + return ResponseEntity.ok(data); + } + + private boolean isPasswordChangeRequired(User user) { + return "admin".equals(user.getUsername()) && passwordEncoder.matches("admin123", user.getPasswordHash()); + } +} diff --git a/backend/src/main/java/com/sshmanager/controller/ConnectionController.java b/backend/src/main/java/com/sshmanager/controller/ConnectionController.java index ba3935d..e59763e 100644 --- a/backend/src/main/java/com/sshmanager/controller/ConnectionController.java +++ b/backend/src/main/java/com/sshmanager/controller/ConnectionController.java @@ -1,11 +1,17 @@ package com.sshmanager.controller; -import com.sshmanager.dto.ConnectionCreateRequest; -import com.sshmanager.dto.ConnectionDto; -import com.sshmanager.entity.Connection; -import com.sshmanager.entity.User; -import com.sshmanager.repository.UserRepository; -import com.sshmanager.service.ConnectionService; +import com.sshmanager.dto.ConnectionCreateRequest; +import com.sshmanager.dto.ConnectionDto; +import com.sshmanager.dto.BackupImportResponseDto; +import com.sshmanager.dto.BackupPackageDto; +import com.sshmanager.dto.BatchCommandRequest; +import com.sshmanager.dto.BatchCommandResponseDto; +import com.sshmanager.entity.Connection; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.BackupService; +import com.sshmanager.service.BatchCommandService; +import com.sshmanager.service.ConnectionService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -18,14 +24,20 @@ import java.util.Map; @RequestMapping("/api/connections") public class ConnectionController { - private final ConnectionService connectionService; - private final UserRepository userRepository; - - public ConnectionController(ConnectionService connectionService, - UserRepository userRepository) { - this.connectionService = connectionService; - this.userRepository = userRepository; - } + private final ConnectionService connectionService; + private final BackupService backupService; + private final BatchCommandService batchCommandService; + private final UserRepository userRepository; + + public ConnectionController(ConnectionService connectionService, + BackupService backupService, + BatchCommandService batchCommandService, + UserRepository userRepository) { + this.connectionService = connectionService; + this.backupService = backupService; + this.batchCommandService = batchCommandService; + this.userRepository = userRepository; + } private Long getCurrentUserId(Authentication auth) { User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found")); @@ -59,17 +71,37 @@ public class ConnectionController { return ResponseEntity.ok(connectionService.update(id, request, userId)); } - @DeleteMapping("/{id}") - public ResponseEntity> delete(@PathVariable Long id, - Authentication authentication) { + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id, + Authentication authentication) { Long userId = getCurrentUserId(authentication); connectionService.delete(id, userId); Map result = new HashMap<>(); - result.put("message", "Deleted"); - return ResponseEntity.ok(result); - } - - @PostMapping("/test") + result.put("message", "Deleted"); + return ResponseEntity.ok(result); + } + + @GetMapping("/backup/export") + public ResponseEntity exportBackup(Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(backupService.exportBackup(userId)); + } + + @PostMapping("/backup/import") + public ResponseEntity importBackup(@RequestBody BackupPackageDto request, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(backupService.importBackup(userId, request)); + } + + @PostMapping("/batch-command") + public ResponseEntity executeBatchCommand(@RequestBody BatchCommandRequest request, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(batchCommandService.execute(userId, request)); + } + + @PostMapping("/test") public ResponseEntity> connectivity(@RequestBody Connection connection, Authentication authentication) { try { diff --git a/backend/src/main/java/com/sshmanager/dto/BackupConnectionDto.java b/backend/src/main/java/com/sshmanager/dto/BackupConnectionDto.java new file mode 100644 index 0000000..6f29079 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/BackupConnectionDto.java @@ -0,0 +1,18 @@ +package com.sshmanager.dto; + +import com.sshmanager.entity.Connection; +import lombok.Data; + +@Data +public class BackupConnectionDto { + + private Long sourceId; + private String name; + private String host; + private Integer port; + private String username; + private Connection.AuthType authType; + private String password; + private String privateKey; + private String passphrase; +} diff --git a/backend/src/main/java/com/sshmanager/dto/BackupImportResponseDto.java b/backend/src/main/java/com/sshmanager/dto/BackupImportResponseDto.java new file mode 100644 index 0000000..3fa4909 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/BackupImportResponseDto.java @@ -0,0 +1,12 @@ +package com.sshmanager.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class BackupImportResponseDto { + + private int importedConnections; + private int importedTreeNodes; +} diff --git a/backend/src/main/java/com/sshmanager/dto/BackupPackageDto.java b/backend/src/main/java/com/sshmanager/dto/BackupPackageDto.java new file mode 100644 index 0000000..53a6e0e --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/BackupPackageDto.java @@ -0,0 +1,18 @@ +package com.sshmanager.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +public class BackupPackageDto { + + private Integer version = 1; + private Instant exportedAt = Instant.now(); + private List connections = new ArrayList(); + private SessionTreeLayoutDto sessionTree = new SessionTreeLayoutDto(); +} diff --git a/backend/src/main/java/com/sshmanager/dto/BatchCommandRequest.java b/backend/src/main/java/com/sshmanager/dto/BatchCommandRequest.java new file mode 100644 index 0000000..545f46d --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/BatchCommandRequest.java @@ -0,0 +1,13 @@ +package com.sshmanager.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class BatchCommandRequest { + + private List connectionIds = new ArrayList(); + private String command; +} diff --git a/backend/src/main/java/com/sshmanager/dto/BatchCommandResponseDto.java b/backend/src/main/java/com/sshmanager/dto/BatchCommandResponseDto.java new file mode 100644 index 0000000..bcded89 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/BatchCommandResponseDto.java @@ -0,0 +1,16 @@ +package com.sshmanager.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class BatchCommandResponseDto { + + private String command; + private int total; + private int successCount; + private int failureCount; + private List results = new ArrayList(); +} diff --git a/backend/src/main/java/com/sshmanager/dto/BatchCommandResultDto.java b/backend/src/main/java/com/sshmanager/dto/BatchCommandResultDto.java new file mode 100644 index 0000000..a78f75d --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/BatchCommandResultDto.java @@ -0,0 +1,16 @@ +package com.sshmanager.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class BatchCommandResultDto { + + private Long connectionId; + private String connectionName; + private boolean success; + private String output; + private String error; + private long durationMs; +} diff --git a/backend/src/main/java/com/sshmanager/dto/ChangePasswordRequest.java b/backend/src/main/java/com/sshmanager/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..1b92a28 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/ChangePasswordRequest.java @@ -0,0 +1,22 @@ +package com.sshmanager.dto; + +public class ChangePasswordRequest { + private String currentPassword; + private String newPassword; + + public String getCurrentPassword() { + return currentPassword; + } + + public void setCurrentPassword(String currentPassword) { + this.currentPassword = currentPassword; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/backend/src/main/java/com/sshmanager/dto/LoginResponse.java b/backend/src/main/java/com/sshmanager/dto/LoginResponse.java index c77b9dd..a12e7a1 100644 --- a/backend/src/main/java/com/sshmanager/dto/LoginResponse.java +++ b/backend/src/main/java/com/sshmanager/dto/LoginResponse.java @@ -7,8 +7,9 @@ import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor -public class LoginResponse { - private String token; - private String username; - private String displayName; -} +public class LoginResponse { + private String token; + private String username; + private String displayName; + private boolean passwordChangeRequired; +} diff --git a/backend/src/main/java/com/sshmanager/dto/SessionTreeLayoutDto.java b/backend/src/main/java/com/sshmanager/dto/SessionTreeLayoutDto.java index 58a89fe..666e97d 100644 --- a/backend/src/main/java/com/sshmanager/dto/SessionTreeLayoutDto.java +++ b/backend/src/main/java/com/sshmanager/dto/SessionTreeLayoutDto.java @@ -1,6 +1,5 @@ package com.sshmanager.dto; -import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -9,8 +8,13 @@ import java.util.List; @Data @NoArgsConstructor -@AllArgsConstructor public class SessionTreeLayoutDto { private List nodes = new ArrayList(); + private String sortMode = "manual"; + + public SessionTreeLayoutDto(List nodes) { + this.nodes = nodes; + this.sortMode = "manual"; + } } diff --git a/backend/src/main/java/com/sshmanager/service/BackupService.java b/backend/src/main/java/com/sshmanager/service/BackupService.java new file mode 100644 index 0000000..32bf64c --- /dev/null +++ b/backend/src/main/java/com/sshmanager/service/BackupService.java @@ -0,0 +1,148 @@ +package com.sshmanager.service; + +import com.sshmanager.dto.BackupConnectionDto; +import com.sshmanager.dto.BackupImportResponseDto; +import com.sshmanager.dto.BackupPackageDto; +import com.sshmanager.dto.ConnectionCreateRequest; +import com.sshmanager.dto.ConnectionDto; +import com.sshmanager.dto.SessionTreeLayoutDto; +import com.sshmanager.dto.SessionTreeNodeDto; +import com.sshmanager.entity.Connection; +import com.sshmanager.repository.ConnectionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class BackupService { + + private final ConnectionRepository connectionRepository; + private final ConnectionService connectionService; + private final SessionTreeLayoutService sessionTreeLayoutService; + + public BackupService(ConnectionRepository connectionRepository, + ConnectionService connectionService, + SessionTreeLayoutService sessionTreeLayoutService) { + this.connectionRepository = connectionRepository; + this.connectionService = connectionService; + this.sessionTreeLayoutService = sessionTreeLayoutService; + } + + @Transactional(readOnly = true) + public BackupPackageDto exportBackup(Long userId) { + BackupPackageDto backup = new BackupPackageDto(); + backup.setExportedAt(Instant.now()); + + List exportedConnections = new ArrayList(); + for (Connection connection : connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId)) { + BackupConnectionDto item = new BackupConnectionDto(); + item.setSourceId(connection.getId()); + item.setName(connection.getName()); + item.setHost(connection.getHost()); + item.setPort(connection.getPort()); + item.setUsername(connection.getUsername()); + item.setAuthType(connection.getAuthType()); + item.setPassword(connectionService.getDecryptedPassword(connection)); + item.setPrivateKey(connectionService.getDecryptedPrivateKey(connection)); + item.setPassphrase(connectionService.getDecryptedPassphrase(connection)); + exportedConnections.add(item); + } + + backup.setConnections(exportedConnections); + backup.setSessionTree(sessionTreeLayoutService.getLayout(userId)); + return backup; + } + + @Transactional + public BackupImportResponseDto importBackup(Long userId, BackupPackageDto backupPackage) { + if (backupPackage == null) { + throw new IllegalArgumentException("Backup package is required"); + } + + List connections = backupPackage.getConnections() == null + ? new ArrayList() + : backupPackage.getConnections(); + SessionTreeLayoutDto sessionTree = backupPackage.getSessionTree() == null + ? new SessionTreeLayoutDto() + : backupPackage.getSessionTree(); + + connectionRepository.deleteAll(connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId)); + + Map connectionIdMapping = new HashMap(); + for (BackupConnectionDto item : connections) { + ConnectionCreateRequest request = new ConnectionCreateRequest(); + request.setName(item.getName()); + request.setHost(item.getHost()); + request.setPort(item.getPort()); + request.setUsername(item.getUsername()); + request.setAuthType(item.getAuthType()); + request.setPassword(item.getPassword()); + request.setPrivateKey(item.getPrivateKey()); + request.setPassphrase(item.getPassphrase()); + + ConnectionDto created = connectionService.create(request, userId); + if (item.getSourceId() != null) { + connectionIdMapping.put(item.getSourceId(), created.getId()); + } + } + + SessionTreeLayoutDto remappedLayout = remapSessionTree(sessionTree, connectionIdMapping); + sessionTreeLayoutService.saveLayout(userId, remappedLayout); + + int nodeCount = remappedLayout.getNodes() == null ? 0 : remappedLayout.getNodes().size(); + return new BackupImportResponseDto(connections.size(), nodeCount); + } + + private SessionTreeLayoutDto remapSessionTree(SessionTreeLayoutDto source, + Map connectionIdMapping) { + SessionTreeLayoutDto target = new SessionTreeLayoutDto(); + target.setSortMode(source.getSortMode()); + + List remappedNodes = new ArrayList(); + if (source.getNodes() != null) { + for (SessionTreeNodeDto node : source.getNodes()) { + if (node == null) { + continue; + } + if ("connection".equals(node.getType())) { + Long mappedConnectionId = connectionIdMapping.get(node.getConnectionId()); + if (mappedConnectionId == null) { + continue; + } + remappedNodes.add(new SessionTreeNodeDto( + node.getId(), + node.getType(), + node.getName(), + node.getParentId(), + node.getOrder(), + mappedConnectionId, + node.getExpanded(), + node.getCreatedAt(), + node.getUpdatedAt() + )); + continue; + } + + remappedNodes.add(new SessionTreeNodeDto( + node.getId(), + node.getType(), + node.getName(), + node.getParentId(), + node.getOrder(), + null, + node.getExpanded(), + node.getCreatedAt(), + node.getUpdatedAt() + )); + } + } + + target.setNodes(remappedNodes); + return target; + } +} diff --git a/backend/src/main/java/com/sshmanager/service/BatchCommandService.java b/backend/src/main/java/com/sshmanager/service/BatchCommandService.java new file mode 100644 index 0000000..2a6f203 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/service/BatchCommandService.java @@ -0,0 +1,81 @@ +package com.sshmanager.service; + +import com.sshmanager.dto.BatchCommandRequest; +import com.sshmanager.dto.BatchCommandResponseDto; +import com.sshmanager.dto.BatchCommandResultDto; +import com.sshmanager.entity.Connection; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class BatchCommandService { + + private final ConnectionService connectionService; + private final SshService sshService; + + public BatchCommandService(ConnectionService connectionService, + SshService sshService) { + this.connectionService = connectionService; + this.sshService = sshService; + } + + public BatchCommandResponseDto execute(Long userId, BatchCommandRequest request) { + if (request == null || request.getConnectionIds() == null || request.getConnectionIds().isEmpty()) { + throw new IllegalArgumentException("At least one connection is required"); + } + + String command = request.getCommand() == null ? "" : request.getCommand().trim(); + if (command.isEmpty()) { + throw new IllegalArgumentException("Command is required"); + } + + List results = new ArrayList(); + int successCount = 0; + int failureCount = 0; + + for (Long connectionId : request.getConnectionIds()) { + Connection connection = connectionService.getConnectionForSsh(connectionId, userId); + long startedAt = System.currentTimeMillis(); + try { + String output = sshService.executeCommand( + connection, + connectionService.getDecryptedPassword(connection), + connectionService.getDecryptedPrivateKey(connection), + connectionService.getDecryptedPassphrase(connection), + command + ); + long durationMs = System.currentTimeMillis() - startedAt; + results.add(new BatchCommandResultDto( + connection.getId(), + connection.getName(), + true, + output, + null, + durationMs + )); + successCount += 1; + } catch (Exception error) { + long durationMs = System.currentTimeMillis() - startedAt; + results.add(new BatchCommandResultDto( + connection.getId(), + connection.getName(), + false, + null, + error.getMessage(), + durationMs + )); + failureCount += 1; + } + } + + BatchCommandResponseDto response = new BatchCommandResponseDto(); + response.setCommand(command); + response.setTotal(results.size()); + response.setSuccessCount(successCount); + response.setFailureCount(failureCount); + response.setResults(results); + return response; + } +} diff --git a/backend/src/main/java/com/sshmanager/service/SessionTreeLayoutService.java b/backend/src/main/java/com/sshmanager/service/SessionTreeLayoutService.java index 44aa16b..cde2767 100644 --- a/backend/src/main/java/com/sshmanager/service/SessionTreeLayoutService.java +++ b/backend/src/main/java/com/sshmanager/service/SessionTreeLayoutService.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sshmanager.dto.SessionTreeLayoutDto; import com.sshmanager.entity.SessionTreeLayout; import com.sshmanager.repository.SessionTreeLayoutRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,6 +15,8 @@ import java.util.ArrayList; @Service public class SessionTreeLayoutService { + private static final Logger log = LoggerFactory.getLogger(SessionTreeLayoutService.class); + private final SessionTreeLayoutRepository sessionTreeLayoutRepository; private final ObjectMapper objectMapper; @@ -34,8 +38,10 @@ public class SessionTreeLayoutService { if (parsed.getNodes() == null) { parsed.setNodes(new ArrayList<>()); } + parsed.setSortMode(normalizeSortMode(parsed.getSortMode())); return parsed; } catch (Exception e) { + log.warn("Failed to parse session tree layout for userId={}, returning empty layout", userId, e); return createEmptyLayout(); } } @@ -46,11 +52,13 @@ public class SessionTreeLayoutService { if (payload.getNodes() == null) { payload.setNodes(new ArrayList<>()); } + payload.setSortMode(normalizeSortMode(payload.getSortMode())); final String layoutJson; try { layoutJson = objectMapper.writeValueAsString(payload); } catch (Exception e) { + log.error("Failed to serialize session tree layout for userId={}", userId, e); throw new RuntimeException("Failed to serialize session tree layout", e); } @@ -66,4 +74,11 @@ public class SessionTreeLayoutService { private SessionTreeLayoutDto createEmptyLayout() { return new SessionTreeLayoutDto(new ArrayList<>()); } + + private String normalizeSortMode(String sortMode) { + if ("nameAsc".equals(sortMode)) { + return "nameAsc"; + } + return "manual"; + } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index f1c97c8..816cc74 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -12,7 +12,7 @@ spring: location: ${DATA_DIR:/app/data}/upload-temp # 使用容器数据目录,避免被解析为 Tomcat 工作目录 file-size-threshold: 0 # 立即写入磁盘,不使用内存缓冲 datasource: - url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1 + url: jdbc:h2:file:${DATA_DIR:/app/data}/sshmanager;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver username: sa password: diff --git a/backend/src/test/java/com/sshmanager/controller/AuthControllerTest.java b/backend/src/test/java/com/sshmanager/controller/AuthControllerTest.java new file mode 100644 index 0000000..13ec83e --- /dev/null +++ b/backend/src/test/java/com/sshmanager/controller/AuthControllerTest.java @@ -0,0 +1,161 @@ +package com.sshmanager.controller; + +import com.sshmanager.dto.ChangePasswordRequest; +import com.sshmanager.dto.LoginRequest; +import com.sshmanager.dto.LoginResponse; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.security.JwtTokenProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthControllerTest { + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private JwtTokenProvider tokenProvider; + + @Mock + private UserRepository userRepository; + + private PasswordEncoder passwordEncoder; + private AuthController authController; + + @BeforeEach + void setUp() { + passwordEncoder = new BCryptPasswordEncoder(); + authController = new AuthController(authenticationManager, tokenProvider, userRepository, passwordEncoder); + } + + @Test + void loginMarksDefaultAdminPasswordAsRequiredToChange() { + LoginRequest request = new LoginRequest(); + request.setUsername("admin"); + request.setPassword("admin123"); + + Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "admin123"); + when(authenticationManager.authenticate(any(Authentication.class))).thenReturn(authentication); + when(tokenProvider.generateToken(authentication)).thenReturn("token"); + + User user = new User(); + user.setUsername("admin"); + user.setPasswordHash(passwordEncoder.encode("admin123")); + user.setDisplayName("Administrator"); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user)); + + ResponseEntity response = authController.login(request); + assertEquals(200, response.getStatusCodeValue()); + LoginResponse body = (LoginResponse) response.getBody(); + assertNotNull(body); + assertTrue(body.isPasswordChangeRequired()); + } + + @Test + void healthReturnsApplicationMarker() { + ResponseEntity response = authController.health(); + assertEquals(200, response.getStatusCodeValue()); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals("ssh-manager", body.get("app")); + assertEquals("ok", body.get("status")); + assertTrue(((Number) body.get("timestamp")).longValue() > 0); + } + + @Test + void changePasswordUpdatesHashAndTimestamp() { + ChangePasswordRequest request = new ChangePasswordRequest(); + request.setCurrentPassword("admin123"); + request.setNewPassword("newPassword123"); + + Authentication authentication = mock(Authentication.class); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("admin"); + + User user = new User(); + user.setUsername("admin"); + user.setPasswordHash(passwordEncoder.encode("admin123")); + user.setPasswordChangedAt(Instant.now().minusSeconds(3600)); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user)); + + ResponseEntity response = authController.changePassword(request, authentication); + assertEquals(200, response.getStatusCodeValue()); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals("Password updated", body.get("message")); + assertEquals(false, body.get("passwordChangeRequired")); + assertTrue(passwordEncoder.matches("newPassword123", user.getPasswordHash())); + verify(userRepository).save(user); + } + + @Test + void changePasswordRejectsWrongCurrentPassword() { + ChangePasswordRequest request = new ChangePasswordRequest(); + request.setCurrentPassword("wrong"); + request.setNewPassword("newPassword123"); + + Authentication authentication = mock(Authentication.class); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("admin"); + + User user = new User(); + user.setUsername("admin"); + user.setPasswordHash(passwordEncoder.encode("admin123")); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user)); + + ResponseEntity response = authController.changePassword(request, authentication); + assertEquals(400, response.getStatusCodeValue()); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals("Current password is incorrect", body.get("message")); + } + + @Test + void meReturnsPasswordChangeRequiredFlag() { + Authentication authentication = mock(Authentication.class); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("admin"); + + User user = new User(); + user.setUsername("admin"); + user.setPasswordHash(passwordEncoder.encode("admin123")); + user.setDisplayName("Administrator"); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user)); + + ResponseEntity response = authController.me(authentication); + assertEquals(200, response.getStatusCodeValue()); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertNotNull(body); + assertTrue((Boolean) body.get("passwordChangeRequired")); + assertFalse(((String) body.get("username")).isEmpty()); + } + +} diff --git a/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java b/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java index 22631b8..fee8bbb 100644 --- a/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java +++ b/backend/src/test/java/com/sshmanager/controller/ConnectionControllerTest.java @@ -2,7 +2,13 @@ package com.sshmanager.controller; import com.sshmanager.entity.Connection; import com.sshmanager.entity.User; +import com.sshmanager.dto.BackupImportResponseDto; +import com.sshmanager.dto.BackupPackageDto; +import com.sshmanager.dto.BatchCommandRequest; +import com.sshmanager.dto.BatchCommandResponseDto; import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.BackupService; +import com.sshmanager.service.BatchCommandService; import com.sshmanager.service.ConnectionService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,6 +41,12 @@ class ConnectionControllerTest { @Mock private ConnectionService connectionService; + @Mock + private BackupService backupService; + + @Mock + private BatchCommandService batchCommandService; + @Mock private UserRepository userRepository; @@ -98,4 +110,43 @@ class ConnectionControllerTest { assertFalse((Boolean) body.get("success")); assertTrue(((String) body.get("message")).contains("Connection failed")); } + + @Test + void exportBackupUsesCurrentUserId() { + BackupPackageDto expected = new BackupPackageDto(); + when(backupService.exportBackup(1L)).thenReturn(expected); + + ResponseEntity response = connectionController.exportBackup(authentication); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(expected, response.getBody()); + verify(backupService).exportBackup(1L); + } + + @Test + void importBackupUsesCurrentUserId() { + BackupPackageDto request = new BackupPackageDto(); + BackupImportResponseDto expected = new BackupImportResponseDto(2, 5); + when(backupService.importBackup(1L, request)).thenReturn(expected); + + ResponseEntity response = connectionController.importBackup(request, authentication); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(expected, response.getBody()); + verify(backupService).importBackup(1L, request); + } + + @Test + void executeBatchCommandUsesCurrentUserId() { + BatchCommandRequest request = new BatchCommandRequest(); + BatchCommandResponseDto expected = new BatchCommandResponseDto(); + expected.setTotal(1); + when(batchCommandService.execute(1L, request)).thenReturn(expected); + + ResponseEntity response = connectionController.executeBatchCommand(request, authentication); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(expected, response.getBody()); + verify(batchCommandService).execute(1L, request); + } } diff --git a/backend/src/test/java/com/sshmanager/service/BackupServiceTest.java b/backend/src/test/java/com/sshmanager/service/BackupServiceTest.java new file mode 100644 index 0000000..16f0063 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/service/BackupServiceTest.java @@ -0,0 +1,106 @@ +package com.sshmanager.service; + +import com.sshmanager.dto.BackupConnectionDto; +import com.sshmanager.dto.BackupImportResponseDto; +import com.sshmanager.dto.BackupPackageDto; +import com.sshmanager.dto.ConnectionDto; +import com.sshmanager.dto.SessionTreeLayoutDto; +import com.sshmanager.dto.SessionTreeNodeDto; +import com.sshmanager.entity.Connection; +import com.sshmanager.repository.ConnectionRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BackupServiceTest { + + @Mock + private ConnectionRepository connectionRepository; + + @Mock + private ConnectionService connectionService; + + @Mock + private SessionTreeLayoutService sessionTreeLayoutService; + + @InjectMocks + private BackupService backupService; + + @Test + void exportBackupIncludesConnectionsAndSessionTree() { + Connection connection = new Connection(); + connection.setId(11L); + connection.setName("prod"); + connection.setHost("127.0.0.1"); + connection.setPort(22); + connection.setUsername("root"); + connection.setAuthType(Connection.AuthType.PASSWORD); + + SessionTreeLayoutDto layout = new SessionTreeLayoutDto(Arrays.asList( + new SessionTreeNodeDto("node-1", "connection", "prod", null, 0, 11L, null, 1L, 2L) + )); + + when(connectionRepository.findByUserIdOrderByUpdatedAtDesc(1L)).thenReturn(Collections.singletonList(connection)); + when(connectionService.getDecryptedPassword(connection)).thenReturn("secret"); + when(sessionTreeLayoutService.getLayout(1L)).thenReturn(layout); + + BackupPackageDto result = backupService.exportBackup(1L); + + assertNotNull(result); + assertEquals(1, result.getConnections().size()); + assertEquals(11L, result.getConnections().get(0).getSourceId().longValue()); + assertEquals("secret", result.getConnections().get(0).getPassword()); + assertEquals(layout, result.getSessionTree()); + } + + @Test + void importBackupReplacesConnectionsAndRemapsTreeIds() { + BackupConnectionDto backupConnection = new BackupConnectionDto(); + backupConnection.setSourceId(7L); + backupConnection.setName("prod"); + backupConnection.setHost("10.0.0.10"); + backupConnection.setPort(22); + backupConnection.setUsername("root"); + backupConnection.setAuthType(Connection.AuthType.PASSWORD); + backupConnection.setPassword("secret"); + + BackupPackageDto backupPackage = new BackupPackageDto(); + backupPackage.setConnections(Collections.singletonList(backupConnection)); + backupPackage.setSessionTree(new SessionTreeLayoutDto(Arrays.asList( + new SessionTreeNodeDto("folder-1", "folder", "生产", null, 0, null, true, 1L, 2L), + new SessionTreeNodeDto("conn-1", "connection", "prod", "folder-1", 1, 7L, null, 1L, 2L) + ))); + + ConnectionDto created = new ConnectionDto(); + created.setId(101L); + + when(connectionRepository.findByUserIdOrderByUpdatedAtDesc(1L)).thenReturn(Collections.emptyList()); + when(connectionService.create(any(), eq(1L))).thenReturn(created); + + BackupImportResponseDto result = backupService.importBackup(1L, backupPackage); + + assertEquals(1, result.getImportedConnections()); + assertEquals(2, result.getImportedTreeNodes()); + verify(connectionRepository).deleteAll(Collections.emptyList()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SessionTreeLayoutDto.class); + verify(sessionTreeLayoutService).saveLayout(eq(1L), captor.capture()); + SessionTreeLayoutDto savedLayout = captor.getValue(); + assertEquals(2, savedLayout.getNodes().size()); + assertEquals(101L, savedLayout.getNodes().get(1).getConnectionId().longValue()); + } +} diff --git a/backend/src/test/java/com/sshmanager/service/BatchCommandServiceTest.java b/backend/src/test/java/com/sshmanager/service/BatchCommandServiceTest.java new file mode 100644 index 0000000..ec7c114 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/service/BatchCommandServiceTest.java @@ -0,0 +1,71 @@ +package com.sshmanager.service; + +import com.sshmanager.dto.BatchCommandRequest; +import com.sshmanager.dto.BatchCommandResponseDto; +import com.sshmanager.entity.Connection; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BatchCommandServiceTest { + + @Mock + private ConnectionService connectionService; + + @Mock + private SshService sshService; + + @InjectMocks + private BatchCommandService batchCommandService; + + @Test + void executeAggregatesSuccessAndFailure() throws Exception { + Connection successConnection = new Connection(); + successConnection.setId(1L); + successConnection.setUserId(99L); + successConnection.setName("prod"); + + Connection failedConnection = new Connection(); + failedConnection.setId(2L); + failedConnection.setUserId(99L); + failedConnection.setName("test"); + + BatchCommandRequest request = new BatchCommandRequest(); + request.setConnectionIds(Arrays.asList(1L, 2L)); + request.setCommand("uptime"); + + when(connectionService.getConnectionForSsh(1L, 99L)).thenReturn(successConnection); + when(connectionService.getConnectionForSsh(2L, 99L)).thenReturn(failedConnection); + when(sshService.executeCommand(eq(successConnection), eq(null), eq(null), eq(null), eq("uptime"))).thenReturn("ok"); + when(sshService.executeCommand(eq(failedConnection), eq(null), eq(null), eq(null), eq("uptime"))).thenThrow(new RuntimeException("boom")); + + BatchCommandResponseDto response = batchCommandService.execute(99L, request); + + assertEquals(2, response.getTotal()); + assertEquals(1, response.getSuccessCount()); + assertEquals(1, response.getFailureCount()); + assertEquals("prod", response.getResults().get(0).getConnectionName()); + assertEquals("ok", response.getResults().get(0).getOutput()); + assertEquals("boom", response.getResults().get(1).getError()); + } + + @Test + void executeRejectsEmptyCommand() { + BatchCommandRequest request = new BatchCommandRequest(); + request.setConnectionIds(Arrays.asList(1L)); + request.setCommand(" "); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> batchCommandService.execute(1L, request)); + assertEquals("Command is required", error.getMessage()); + } +} diff --git a/backend/src/test/java/com/sshmanager/service/SessionTreeLayoutServiceTest.java b/backend/src/test/java/com/sshmanager/service/SessionTreeLayoutServiceTest.java index f5ac3a5..3d9d610 100644 --- a/backend/src/test/java/com/sshmanager/service/SessionTreeLayoutServiceTest.java +++ b/backend/src/test/java/com/sshmanager/service/SessionTreeLayoutServiceTest.java @@ -41,6 +41,7 @@ class SessionTreeLayoutServiceTest { SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L); assertTrue(result.getNodes().isEmpty()); + assertEquals("manual", result.getSortMode()); } @Test @@ -52,6 +53,7 @@ class SessionTreeLayoutServiceTest { SessionTreeLayoutDto parsed = new SessionTreeLayoutDto(Arrays.asList( new SessionTreeNodeDto("n1", "folder", "我的连接", null, 0, null, true, 1L, 1L) )); + parsed.setSortMode(null); when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.of(saved)); when(objectMapper.readValue(saved.getLayoutJson(), SessionTreeLayoutDto.class)).thenReturn(parsed); @@ -60,12 +62,14 @@ class SessionTreeLayoutServiceTest { assertEquals(1, result.getNodes().size()); assertEquals("n1", result.getNodes().get(0).getId()); + assertEquals("manual", result.getSortMode()); } @Test void saveLayoutNormalizesNullNodes() throws Exception { SessionTreeLayoutDto request = new SessionTreeLayoutDto(); request.setNodes(null); + request.setSortMode("unknown"); when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty()); when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}"); @@ -73,6 +77,7 @@ class SessionTreeLayoutServiceTest { SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, request); assertTrue(result.getNodes().isEmpty()); + assertEquals("manual", result.getSortMode()); ArgumentCaptor captor = ArgumentCaptor.forClass(SessionTreeLayout.class); verify(sessionTreeLayoutRepository).save(captor.capture()); assertEquals(1L, captor.getValue().getUserId().longValue()); @@ -87,5 +92,6 @@ class SessionTreeLayoutServiceTest { SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, null); assertEquals(Collections.emptyList(), result.getNodes()); + assertEquals("manual", result.getSortMode()); } } diff --git a/docs/delivery-guide.md b/docs/delivery-guide.md new file mode 100644 index 0000000..85a10a8 --- /dev/null +++ b/docs/delivery-guide.md @@ -0,0 +1,115 @@ +# SSH Manager 交付指南 + +更新时间:2026-04-14 + +## 目标 + +把当前仓库整理成三种可直接交付给买家的形态: + +- 源码仓库版 +- Docker 一键版 +- Windows 安装版 + +## 源码仓库版 + +适合会自己部署、需要二开或想长期自主管理的买家。 + +### 交付内容 + +- 当前仓库源码 +- `README.md` 或独立部署文档 +- 环境变量说明 +- 初始化账号说明 +- 备份示例或演示数据说明 + +### 推荐说明 + +- 默认提供 Docker 启动方式 +- 明确 Java / Node / Maven 版本要求 +- 明确默认账号、密码修改和数据目录位置 +- 保留“关于与交付信息”页面给买家查看诊断摘要 + +## Docker 一键版 + +适合想快速运行源码版的买家。 + +### 直接启动 + +Windows: + +- `scripts/windows/start-docker.bat` + +停止: + +- `scripts/windows/stop-docker.bat` + +### 说明 + +- 数据默认保存在 Docker volume `app-data` +- 不要执行 `docker compose down -v` + +## Windows 安装版 + +适合完全不想部署环境的买家。 + +### 构建前准备 + +- 安装 Node.js 18+ +- 安装 Maven 3.6+ +- 安装 Inno Setup +- 准备一个已解压的 Windows JRE,并设置环境变量: + - `SSH_MANAGER_WINDOWS_JRE_DIR=D:\runtime\jdk-17-jre` +- 安装包版本号默认读取 `backend/pom.xml` 中的 `` + +### 发布前预检 + +建议先执行: + +- `scripts\release\check-windows-release.bat` + +作用: + +- 检查 Node.js / Maven / PowerShell / JRE / Inno Setup +- 检查图标、安装器脚本、买家说明、售后 FAQ 是否齐全 +- 构建前端 +- 以内嵌前端静态资源的方式打包后端 + +### 构建命令 + +Windows: + +- `scripts\release\build-windows-installer.bat` + +### 产物 + +- `release/windows-app/` + - 安装前的应用目录 + - 包含 jar、启动器脚本、安装器图标、说明文档和内置 JRE +- `release/windows-installer/` + - 若安装了 Inno Setup,则输出 `SSHManager-Setup-*.exe` + +### 安装后行为 + +- 程序文件默认安装到 `C:\Program Files\SSH Manager\` +- 用户数据默认保存在 `%LOCALAPPDATA%\SSHManager\` +- 首次启动自动生成本地密钥 +- 双击快捷方式会后台启动服务并自动打开浏览器 +- 开始菜单会同时提供“买家使用说明”和“售后排查 FAQ” + +## 本地打包 + +已提供本地打包脚本,可用于你自己验证交付前构建是否完整: + +- `scripts/release/build-local-package.sh` +- `scripts/release/build-local-package.bat` + +作用: + +- 构建前端 +- 以内嵌前端静态资源的方式打包后端 +- 输出到 `release/local-package/` + +## 后续建议 + +- 再补一个“首次启动引导页” +- 再补一个“关于 / 交付信息 / 诊断摘要”页面 diff --git a/docs/moba-regression-checklist.md b/docs/moba-regression-checklist.md new file mode 100644 index 0000000..7b50823 --- /dev/null +++ b/docs/moba-regression-checklist.md @@ -0,0 +1,156 @@ +# Moba Workspace 回归清单 + +适用范围: +- `frontend/src/layouts/MobaLayout.vue` +- `frontend/src/components/SessionTree.vue` +- `frontend/src/components/WorkspacePanel.vue` +- `frontend/src/components/SftpPanel.vue` +- `frontend/src/views/TransfersView.vue` +- 旧深链接兼容入口 `/terminal/:id`、`/sftp/:id` + +## 基础验证 + +### 构建 +```bash +cd frontend +npm run build +``` + +### 启动 +```bash +cd backend +mvn spring-boot:run + +cd frontend +npm run dev +``` + +访问: +- `http://localhost:5173/login` +- 登录后应进入 `/moba` + +## 登录与入口 + +### 登录 +- 使用有效账号登录 +- 首次管理员登录时,若触发强制改密,应能弹出改密弹窗 +- 登录后默认进入 `/moba` + +### 兼容入口 +- 访问 `/connections` 应进入 `/moba` +- 访问 `/terminal` 应进入 `/moba` +- 访问 `/transfers` 应进入 `/moba?tool=transfers` +- 访问 `/terminal/:id` 应打开对应连接的终端工作区后进入 `/moba` +- 访问 `/sftp/:id` 应打开对应连接的文件工作区后进入 `/moba` + +## 工作区 + +### 多实例 +- 在左侧连续点击同一连接两次,应创建两个独立工作区实例 +- 两个实例的顶部标签应能区分序号 +- 关闭其中一个实例,不应影响另一个实例 + +### 标签行为 +- 点击标签可切换活动工作区 +- 右键菜单中的“关闭当前 / 关闭其他 / 关闭右侧 / 关闭全部”行为正确 +- 顶部“复制会话”可基于当前实例创建一个新实例 + +### 面板控制 +- 顶部“终端”按钮可显隐终端面板 +- 顶部“文件”按钮可显隐 SFTP 面板 +- 两个面板都隐藏时,工作区应显示空态提示 +- “重置分屏”应把终端/SFTP 比例恢复为默认值 + +## 会话树 + +### 基础操作 +- 创建根文件夹 +- 创建子文件夹 +- 重命名文件夹 +- 删除文件夹 +- 删除连接节点 + +### 排序与拖拽 +- 手动排序模式下,节点可拖拽排序 +- 名称排序模式下,拖拽应禁用 +- 切换为名称排序后,文件夹应排在连接前面 + +### 搜索 +- 输入关键字后,应只显示匹配结果和必要层级 +- 匹配节点应显示高亮标记 +- 清空搜索后,应恢复完整树 + +### 同步 +- 修改连接名称后,会话树中的连接节点名称应同步更新 +- 删除连接后,会话树中的对应节点应被移除 + +## SFTP + +### 基础功能 +- 打开文件面板后应能列出目录 +- 可进入子目录 +- 可返回上级目录 +- 可直接输入路径并跳转 +- 可切换显示/隐藏隐藏文件 +- 可搜索当前目录文件 + +### 文件操作 +- 上传单个文件 +- 上传多个文件 +- 查看上传进度 +- 下载文件 +- 创建目录 +- 删除文件 +- 删除目录 + +### 远程传输 +- 从当前连接选择文件并打开远程传输弹窗 +- 选择目标连接和目标路径后可开始传输 +- 传输过程中应显示进度 +- 取消传输后应显示取消状态或取消提示 + +## Transfers + +### Local -> Many +- 选择本机文件 +- 选择目标目录 +- 选择多个目标连接 +- 调整并发 +- 开始后应生成队列任务 + +### Remote -> Many +- 选择源连接 +- 手输源文件路径并添加 +- 使用远程文件浏览器选择源文件 +- 选择目标目录或精确路径 +- 选择多个目标连接 +- 开始后应生成队列任务 + +### 队列 +- 队列应显示运行状态、进度、明细项 +- 运行中任务应可取消 +- 已完成/失败任务应保留在最近任务列表 +- 点击“清空队列”后,最近任务列表应被清空 + +## 响应式 + +### 窄屏 +- 小屏下左侧会话树应通过顶部按钮展开/收起 +- 打开工作区后,小屏侧栏应自动收起 +- Transfers 区域在小屏下不应出现明显横向溢出 +- 目标连接选择区、队列卡片和源文件按钮区应可正常折行 + +## 持久化 + +### 刷新恢复 +- 刷新页面后,工作区标签顺序应保留 +- 活动工作区应保留 +- SFTP 当前路径应保留 +- 分屏比例应保留 +- 会话树排序模式应保留 + +## 风险关注点 +- 同一连接多实例下,不应误复用旧工作区 +- 会话树在大量节点下不应出现明显卡顿或错位渲染 +- `/terminal/:id` 与 `/sftp/:id` 兼容入口不应丢失目标连接语义 +- SFTP 远程传输与 Transfers 页面应避免状态来源不一致 diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..392e9cb --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,227 @@ +# SSH Manager 发布前检查表 + +更新时间:2026-04-15 + +## 发布目标 + +用于发布可销售版本前的最终检查,覆盖: + +- 构建与测试 +- 核心功能回归 +- 源码交付说明验证 +- 交付包生成 +- 销售素材准备 + +## 一、构建与测试 + +### 前端 + +```bash +cd frontend +npm run build +``` + +### 后端 + +```bash +cd backend +mvn -Dtest=AuthControllerTest,BatchCommandServiceTest,ConnectionControllerTest test +``` + +### 脚本语法检查 + +```bash +bash -n scripts/release/build-local-package.sh +``` + +## 二、核心功能回归 + +发布前至少手工走一遍下面流程: + +### 登录与基础入口 + +- 打开 `/login` +- 检查商品化登录首页展示是否正常 +- 使用默认账号登录 +- 检查首次启动引导是否正常显示 + +### 工作区 + +- 创建第一条连接 +- 左侧树点击连接,右侧终端 / SFTP 正常刷新 +- 打开多个工作区实例 +- 关闭当前 / 关闭其他 / 关闭右侧 / 关闭全部 +- 重置分屏比例 + +### 终端 + +- 打开终端 +- 执行基本命令 +- 模拟断开后验证自动重连 +- 修改终端字体和字号后检查是否生效 + +### SFTP + +- 浏览目录 +- 上传文件 +- 下载文件 +- 删除文件 +- 新建目录 +- 测试上传同名文件策略 + +### 批量能力 + +- 打开批量命令弹窗 +- 选择多台机器 +- 执行命令并检查结果汇总 +- 复制输出内容 + +### 备份恢复 + +- 导出备份 +- 清空或切换到空环境 +- 导入备份 +- 检查连接和会话树是否恢复 + +### 历史与日志 + +- 打开历史日志弹窗 +- 检查传输历史是否保留 +- 检查操作日志是否保留 +- 检查诊断信息是否可复制 + +## 三、源码交付与诊断 + +### 关于与交付信息 + +- 打开“关于与交付信息” +- 检查版本、环境指纹、交付状态显示 +- 复制诊断信息 + +### 源码交付说明 + +- README、部署文档、环境变量说明齐全 +- 默认账号说明齐全 +- Docker 启动方式可跑通 +- 诊断摘要可用于售后排查 + +## 四、交付包生成 + +### Windows 本地版 + +```bat +scripts\release\build-local-package.bat +``` + +检查: + +- `release/local-package/` 是否生成 +- 是否包含后端 jar +- 是否包含 `start-local.bat` +- 是否包含 `README.txt` + +### Windows 本地启动 + +- 双击 `start-local.bat` +- 检查是否自动生成 `runtime/` 和 `data/` +- 检查是否能访问 `http://localhost:48080` + +### Windows 安装版 + +建议先运行: + +```bat +scripts\release\check-windows-release.bat +``` + +再执行: + +```bat +scripts\release\build-windows-installer.bat +``` + +检查: + +- 当前版本是否已在 `backend\pom.xml` 中更新 +- 已设置 `SSH_MANAGER_WINDOWS_JRE_DIR` +- `release/windows-app/` 是否生成 +- 是否包含 `ssh-manager.jar` +- 是否包含 `jre\bin\javaw.exe` +- 是否包含 `start-installed.vbs` +- 是否包含 `ssh-manager.ico` +- 是否包含 `BUYER-GUIDE.txt` +- 是否包含 `AFTER-SALES-FAQ.txt` +- 如果本机安装了 Inno Setup,`release/windows-installer/` 下是否生成 `SSHManager-Setup-*.exe` + +### Windows 安装回归 + +- 安装 `SSHManager-Setup-*.exe` +- 通过桌面或开始菜单快捷方式启动 +- 检查浏览器是否自动打开 +- 检查 `%LOCALAPPDATA%\SSHManager\data` 是否生成 +- 检查 `%LOCALAPPDATA%\SSHManager\logs\backend.log` 是否生成 +- 再次点击快捷方式时,不应重复拉起多个实例 +- 检查开始菜单里的“买家使用说明”和“售后排查 FAQ”是否可打开 + +### Docker 版 + +- 运行 `scripts/windows/start-docker.bat` 或 `make up` +- 检查容器是否正常启动 +- 检查页面是否可访问 +- 检查数据卷是否正常持久化 + +## 五、销售素材准备 + +### 商品截图 + +建议至少准备这 7 张: + +1. 登录首页 +2. `/showcase` 头图 +3. Moba 工作区主界面 +4. 终端 + SFTP 分屏 +5. 批量命令执行结果 +6. 历史日志与传输记录 +7. 关于与交付信息 + +### 商品文案 + +参考文档: + +- `docs/xianyu-sales-copy.md` + +至少准备: + +- 标题 +- 前 3 行卖点 +- 详情页正文 +- 常见问答 + +## 六、发货前最终确认 + +- JWT 与加密密钥生成方式已确认 +- 默认账号说明已写清楚 +- 交付包里没有私钥、token、测试数据或敏感配置 +- 你自己完整演练过一次“买家购买后流程” + +## 七、买家购买后流程 + +建议你自己按这个顺序演练一次: + +1. 生成本地版交付包 +2. 打包源码、部署文档和环境变量模板 +3. 启动本地版或 Docker 版 +4. 登录默认账号 +5. 修改密码 +6. 创建连接 +7. 打开终端和 SFTP +8. 导出一次备份 + +## 发布结论 + +只有在下面 4 条都满足时,才建议上架: + +- 构建通过 +- 核心功能回归通过 +- 源码交付说明验证通过 +- 交付包和商品素材都已准备完成 diff --git a/docs/sellable-product-plan.md b/docs/sellable-product-plan.md new file mode 100644 index 0000000..036521f --- /dev/null +++ b/docs/sellable-product-plan.md @@ -0,0 +1,107 @@ +# SSH Manager 可卖版改造计划 + +更新时间:2026-04-14 + +## 目标定位 + +把当前项目从“可用的自用工具”推进到“可销售、可交付、可售后”的轻量 SSH/SFTP 产品。 + +建议定位: +- 国产轻量版 MobaXterm / FinalShell 替代品 +- 面向开发者、小团队运维、NAS 与云主机用户 +- 强调中文体验、易安装、批量运维、数据安全 + +## 分阶段路线 + +### 第一阶段:先做到能卖 + +目标:降低安装门槛,补齐用户信任感和售后必需能力。 + +1. 交付形态 +- Windows 一键启动版 +- Docker 一键部署版 +- 首次启动引导 +- 默认账号安全引导 + +2. 数据安全 +- 连接与会话树完整备份导出 +- 连接与会话树完整恢复导入 +- 自动备份策略 +- 恢复前风险提示 + +3. 稳定性 +- 终端断线重连 +- SFTP 超时与失败重试 +- 连接失败原因细化 +- 导入恢复后的工作区状态清理 + +4. 设置中心 +- 终端字体、字号、主题 +- 默认下载目录 +- 上传冲突策略 +- 分屏默认配置 + +### 第二阶段:做出付费理由 + +目标:让用户愿意为效率买单。 + +1. 批量运维 +- 批量执行命令 +- 批量打开会话 +- 批量上传与分发 +- 结果汇总视图 + +2. 连接管理增强 +- 标签、收藏、最近使用 +- 环境分类 +- 备注与颜色标识 +- 高级搜索 + +3. 高级传输 +- 拖拽上传 +- 传输队列 +- 断点续传 +- 冲突处理 +- 历史记录 + +### 第三阶段:提高客单价 + +目标:支持团队和更高单价销售。 + +1. 多用户与权限 +- 角色管理 +- 连接可见范围 +- 只读与可编辑权限 + +2. 审计与日志 +- 登录日志 +- 连接与传输日志 +- 文件操作日志 +- 命令执行留痕 + +3. 团队共享 +- 共享连接组 +- 共享模板 +- 共享标签 + +## 当前开发顺序 + +### P0 +- 完整备份导出 / 导入 +- 导入后的界面与工作区刷新 +- 文档化产品改造路线 + +### P1 +- 设置中心 +- 终端重连与错误诊断 +- Windows / Docker 交付脚本 + +### P2 +- 批量命令执行 +- 批量文件分发 +- 传输历史 + +## 已开始落地 + +- 已新增可卖版产品路线文档 +- 已开始实现“完整备份导出 / 导入”,覆盖连接与会话树布局 diff --git a/docs/windows-after-sales-faq.md b/docs/windows-after-sales-faq.md new file mode 100644 index 0000000..7d71731 --- /dev/null +++ b/docs/windows-after-sales-faq.md @@ -0,0 +1,128 @@ +# SSH Manager Windows 版售后 FAQ + +更新时间:2026-04-16 + +## 1. 安装包双击没反应怎么办 + +- 确认系统为 Windows 10 / 11 +- 右键安装包,选择“以管理员身份运行” +- 确认没有被安全软件直接拦截 +- 如果提示“未知发布者”,属于未签名安装包的常见提示,可继续安装 + +## 2. 安装完成后,点快捷方式没有打开页面怎么办 + +先等 5 到 15 秒,因为程序会先启动本地服务,再打开浏览器。 + +如果还是没打开: + +- 手动访问 `http://127.0.0.1:48080` +- 再看日志文件: + - `%LOCALAPPDATA%\SSHManager\logs\backend.log` + +## 3. 浏览器提示无法访问 `127.0.0.1:48080` 怎么办 + +通常是本地服务没有成功启动,重点检查: + +- `%LOCALAPPDATA%\SSHManager\logs\backend.log` +- `%LOCALAPPDATA%\SSHManager\runtime\` + +常见原因: + +- 杀毒软件拦截了 `javaw.exe` +- 本机 48080 端口被别的软件占用 +- 上次异常退出,服务还没完全结束 + +建议处理: + +- 先从开始菜单执行“停止 SSH Manager” +- 再重新打开桌面快捷方式 + +## 4. 如果 48080 端口被占用了怎么办 + +当前安装版默认使用 `48080` 端口。 + +如果端口冲突: + +- 先关闭占用该端口的软件 +- 或联系卖家,给你提供改端口后的新包 + +如果你自己会改: + +- 修改启动脚本中的启动参数 +- 同时把访问地址改成新的本地端口 + +## 5. 关闭浏览器后程序还在吗 + +可能还在。 + +- 浏览器只是界面 +- 本地服务仍可能在后台运行 + +再次点击快捷方式时: + +- 如果服务已在运行,会直接重新打开页面 +- 不会重复启动多份实例 + +## 6. 如何彻底退出程序 + +任选一种方式: + +- 从开始菜单执行 `停止 SSH Manager` +- 运行安装目录下的 `stop-installed.cmd` + +## 7. 升级新版本会不会丢连接数据 + +正常覆盖安装一般不会丢。 + +因为数据默认保存在: + +- `%LOCALAPPDATA%\SSHManager\data` + +不是保存在安装目录里。 + +## 8. 换电脑怎么迁移数据 + +把下面整个目录备份出来,再复制到新电脑同位置即可: + +- `%LOCALAPPDATA%\SSHManager\data` + +如果希望更稳妥,建议同时备份: + +- `%LOCALAPPDATA%\SSHManager\runtime` +- `%LOCALAPPDATA%\SSHManager\logs` + +## 9. 卸载软件会不会删掉我的数据 + +默认不会。 + +卸载主要删除的是安装目录和快捷方式,用户数据目录默认保留,避免误删连接信息。 + +## 10. 启动失败时,联系卖家要提供什么 + +建议一次性提供下面这些信息: + +- 当前软件版本 +- 复现步骤 +- 是否首次安装 +- `%LOCALAPPDATA%\SSHManager\logs\backend.log` +- 错误截图 +- 是否能打开 `http://127.0.0.1:48080` + +## 11. 我想备份连接配置,最简单的方法是什么 + +程序里可直接使用“导出备份”。 + +另外也建议定期备份: + +- `%LOCALAPPDATA%\SSHManager\data` + +## 12. 为什么这不是传统桌面窗口程序 + +这是“本地安装 + 浏览器界面”的交付方式。 + +优点是: + +- 安装成本低 +- 不依赖外网 +- 升级快 +- 保留现有 Web 管理界面的完整能力 diff --git a/docs/windows-buyer-guide.md b/docs/windows-buyer-guide.md new file mode 100644 index 0000000..bf1023d --- /dev/null +++ b/docs/windows-buyer-guide.md @@ -0,0 +1,101 @@ +# SSH Manager Windows 成品版使用说明 + +更新时间:2026-04-16 + +## 适用对象 + +适合不想自己部署环境、希望下载安装后直接使用的买家。 + +## 安装步骤 + +1. 双击 `SSHManager-Setup-*.exe` +2. 按提示完成安装 +3. 可选择创建桌面快捷方式 +4. 安装完成后点击“立即启动 SSH Manager” + +## 首次启动会发生什么 + +首次启动时,程序会自动: + +- 初始化本地运行目录 +- 生成本地密钥 +- 启动内置服务 +- 自动打开浏览器到 `http://127.0.0.1:48080` + +如果浏览器没有自动打开,也可以手动访问: + +- `http://127.0.0.1:48080` + +## 默认账号 + +- 用户名:`admin` +- 密码:`admin123` + +首次登录后建议立刻修改密码。 + +## 数据保存位置 + +程序不会把你的连接数据放到安装目录,默认保存在当前 Windows 用户目录下: + +- 数据目录:`%LOCALAPPDATA%\SSHManager\data` +- 运行时配置:`%LOCALAPPDATA%\SSHManager\runtime` +- 日志目录:`%LOCALAPPDATA%\SSHManager\logs` + +这意味着: + +- 升级安装一般不会丢数据 +- 卸载程序默认也不会删除你的数据目录 + +## 常见操作 + +### 启动程序 + +- 双击桌面快捷方式 `SSH Manager` +- 或从开始菜单打开 `SSH Manager` + +### 停止程序 + +- 从开始菜单执行 `停止 SSH Manager` +- 或运行安装目录下的 `stop-installed.cmd` + +### 看说明 + +安装目录会自带两份说明文本: + +- `BUYER-GUIDE.txt` +- `AFTER-SALES-FAQ.txt` + +如果遇到启动失败、浏览器没弹出、端口冲突、换机迁移等问题,优先先看 `AFTER-SALES-FAQ.txt`。 + +### 看日志 + +如果程序启动失败,请查看: + +- `%LOCALAPPDATA%\SSHManager\logs\backend.log` + +## 常见问题 + +### 1. 为什么打开的是浏览器,不是传统桌面窗口? + +这是本地安装版,不依赖外网,程序核心运行在你自己的电脑上。浏览器只是显示界面,数据仍然保存在本机。 + +### 2. 关闭浏览器后,程序还在吗? + +如果本地服务仍在运行,再次点击快捷方式会直接重新打开页面,不会重复启动多个实例。 + +### 3. 升级新版本会清空连接吗? + +正常覆盖安装不会清空数据,因为数据默认保存在 `%LOCALAPPDATA%\SSHManager\data`。 + +### 4. 卸载后数据会一起删除吗? + +默认不会自动删除数据目录,避免误删。 + +## 售后排查建议 + +联系卖家时建议同时提供: + +- 当前软件版本 +- 复现步骤 +- `%LOCALAPPDATA%\SSHManager\logs\backend.log` +- 页面中的“关于与交付信息”诊断摘要 diff --git a/docs/xianyu-sales-copy.md b/docs/xianyu-sales-copy.md new file mode 100644 index 0000000..31718b5 --- /dev/null +++ b/docs/xianyu-sales-copy.md @@ -0,0 +1,87 @@ +# 闲鱼商品文案模板 + +更新时间:2026-04-15 + +## 标题模板 + +可直接选一个改: + +- SSH 管理器 本地版 MobaXterm 替代 中文 SSH+SFTP+批量命令 +- SSH/SFTP 运维工具 本地部署版 批量命令 备份恢复 自动重连 +- 国产轻量 SSH 管理器 Windows 本地版 支持 SFTP 批量运维 + +## 主卖点短文案 + +适合放在商品前 3 行: + +```text +这不是源码练手项目,而是一套已经做成产品化体验的 SSH/SFTP 管理工具。 +支持 Moba 风格工作区、SFTP 文件传输、批量命令、备份恢复、历史日志、源码交付与部署说明。 +适合开发者、小团队运维、NAS/云主机用户,支持 Windows 本地版和 Docker 版交付。 +``` + +## 详情页文案 + +```text +核心功能: +1. Moba 风格工作区,支持多标签、多实例、终端/SFTP 分屏 +2. SFTP 文件管理,支持上传、下载、远程传输、隐藏文件、路径直达 +3. 批量命令执行,可同时对多台机器执行命令并汇总结果 +4. 完整备份恢复,支持连接和会话树整体导入导出 +5. 历史日志与诊断信息,方便售后排查 +6. 终端自动重连、设置中心、首次启动引导 + +适合人群: +- 经常 SSH 管服务器的开发者 +- 小团队运维 +- NAS / 软路由 / 云主机用户 +- 想找 FinalShell / MobaXterm 替代品的人 + +交付方式: +- Windows 本地版 +- Docker 一键版 + +售后说明: +- 提供基础使用指导 +- 提供源码、部署说明和初始化文档 +- 提供版本更新支持(可按你的实际策略改) +``` + +## 标准答疑话术 + +### 1. 这是源码还是成品? + +```text +主推的是 Windows 可安装成品版,买家下载安装后直接用。 +如果需要二开,也可以额外提供源码版。 +``` + +### 2. 怎么交付? + +```text +默认发 Windows 安装包,安装后双击快捷方式即可使用。 +如购买源码版,再额外交付仓库代码、部署文档和初始化说明。 +``` + +### 3. 需要联网吗? + +```text +日常使用不依赖外网。Windows 安装版也是本地运行,本机保存数据,不走云端。 +``` + +### 4. 支持什么系统? + +```text +当前主推 Windows 本地版,也支持 Docker 版部署。 +``` + +## 截图建议 + +建议至少准备这 6 张图: + +1. 登录页商品化首页 +2. Moba 工作区主界面 +3. 终端 + SFTP 分屏 +4. 批量命令执行结果 +5. 历史日志与传输记录 +6. 关于与交付信息 diff --git a/frontend/index.html b/frontend/index.html index 7ba9157..c70d2a2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + @@ -10,7 +10,7 @@ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" /> - SSH 传输控制台 + SSH Manager
diff --git a/frontend/public/ssh-manager.svg b/frontend/public/ssh-manager.svg new file mode 100644 index 0000000..92e857e --- /dev/null +++ b/frontend/public/ssh-manager.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index d226b05..1aaf440 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -5,16 +5,26 @@ export interface LoginRequest { password: string } -export interface LoginResponse { - token: string - username: string - displayName: string -} +export interface LoginResponse { + token: string + username: string + displayName: string + passwordChangeRequired: boolean +} export function login(data: LoginRequest) { return client.post('/auth/login', data) } -export function getMe() { - return client.get<{ username: string; displayName: string }>('/auth/me') -} +export function getMe() { + return client.get<{ username: string; displayName: string; passwordChangeRequired?: boolean }>('/auth/me') +} + +export interface ChangePasswordRequest { + currentPassword: string + newPassword: string +} + +export function changePassword(data: ChangePasswordRequest) { + return client.post<{ message: string; passwordChangeRequired: boolean }>('/auth/change-password', data) +} diff --git a/frontend/src/api/backup.ts b/frontend/src/api/backup.ts new file mode 100644 index 0000000..18a5aeb --- /dev/null +++ b/frontend/src/api/backup.ts @@ -0,0 +1,35 @@ +import client from './client' +import type { AuthType } from './connections' +import type { SessionTreeLayoutPayload } from './sessionTree' + +export interface BackupConnectionPayload { + sourceId?: number + name: string + host: string + port: number + username: string + authType: AuthType + password?: string + privateKey?: string + passphrase?: string +} + +export interface BackupPackagePayload { + version: number + exportedAt: string + connections: BackupConnectionPayload[] + sessionTree: SessionTreeLayoutPayload +} + +export interface BackupImportResponse { + importedConnections: number + importedTreeNodes: number +} + +export function exportBackup() { + return client.get('/connections/backup/export') +} + +export function importBackup(payload: BackupPackagePayload) { + return client.post('/connections/backup/import', payload) +} diff --git a/frontend/src/api/batchCommand.ts b/frontend/src/api/batchCommand.ts new file mode 100644 index 0000000..438fcab --- /dev/null +++ b/frontend/src/api/batchCommand.ts @@ -0,0 +1,27 @@ +import client from './client' + +export interface BatchCommandRequest { + connectionIds: number[] + command: string +} + +export interface BatchCommandResult { + connectionId: number + connectionName: string + success: boolean + output: string | null + error: string | null + durationMs: number +} + +export interface BatchCommandResponse { + command: string + total: number + successCount: number + failureCount: number + results: BatchCommandResult[] +} + +export function executeBatchCommand(payload: BatchCommandRequest) { + return client.post('/connections/batch-command', payload) +} diff --git a/frontend/src/api/sessionTree.ts b/frontend/src/api/sessionTree.ts index c0aa0c1..38fcfbe 100644 --- a/frontend/src/api/sessionTree.ts +++ b/frontend/src/api/sessionTree.ts @@ -1,6 +1,7 @@ import client from './client' export type SessionTreeNodeType = 'folder' | 'connection' +export type SessionTreeSortMode = 'manual' | 'nameAsc' export interface SessionTreeNodePayload { id: string @@ -16,6 +17,7 @@ export interface SessionTreeNodePayload { export interface SessionTreeLayoutPayload { nodes: SessionTreeNodePayload[] + sortMode?: SessionTreeSortMode } export function getSessionTree() { diff --git a/frontend/src/api/sftp.ts b/frontend/src/api/sftp.ts index 3f68fed..e9c5415 100644 --- a/frontend/src/api/sftp.ts +++ b/frontend/src/api/sftp.ts @@ -31,7 +31,7 @@ export function getPwd(connectionId: number) { }) } -export async function downloadFile(connectionId: number, path: string) { +export async function downloadFile(connectionId: number, path: string, downloadName?: string) { const token = localStorage.getItem('token') const params = new URLSearchParams({ connectionId: String(connectionId), path }) const res = await fetch(`/api/sftp/download?${params}`, { @@ -42,7 +42,7 @@ export async function downloadFile(connectionId: number, path: string) { const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = path.split('/').pop() || 'download' + link.download = downloadName || path.split('/').pop() || 'download' document.body.appendChild(link) link.click() document.body.removeChild(link) diff --git a/frontend/src/components/AboutLicenseModal.vue b/frontend/src/components/AboutLicenseModal.vue new file mode 100644 index 0000000..8cd21f7 --- /dev/null +++ b/frontend/src/components/AboutLicenseModal.vue @@ -0,0 +1,181 @@ + + + diff --git a/frontend/src/components/BatchCommandModal.vue b/frontend/src/components/BatchCommandModal.vue new file mode 100644 index 0000000..64b623f --- /dev/null +++ b/frontend/src/components/BatchCommandModal.vue @@ -0,0 +1,275 @@ + + +