diff --git a/.gitignore b/.gitignore index fcd75ef..bc91b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,9 @@ frontend/dist/ .DS_Store *.local .codex +/package-lock.json release/ -.opencode/package-lock.json +.opencode/ # Worktrees .worktrees/ diff --git a/.opencode/plans/2026-03-30-sftp-tab-cache-fix-plan.md b/.opencode/plans/2026-03-30-sftp-tab-cache-fix-plan.md deleted file mode 100644 index 0b6fb70..0000000 --- a/.opencode/plans/2026-03-30-sftp-tab-cache-fix-plan.md +++ /dev/null @@ -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 - - - -``` -替换为: -```vue - - - - - -``` - ---- - -### Task 3: 验证修复效果 -**Files:** -- Test: 手动验证 + 构建验证 - -- [ ] **Step 1: 运行类型检查** -Run: `cd frontend && npm run build` -Expected: 构建成功,无类型错误 - -- [ ] **Step 2: 手动测试功能** -1. 启动开发服务,登录系统 -2. 打开任意连接的SFTP标签 -3. 浏览到任意子目录 -4. 切换到其他页面(如连接列表、终端) -5. 切回SFTP标签,确认仍停留在之前浏览的子目录,状态未丢失 -6. 打开多个不同连接的SFTP标签,切换时确认各自状态独立保存 - ---- diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 056d1b7..0000000 --- a/AGENTS.md +++ /dev/null @@ -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`,与现有代码保持一致 -- 请求 / 响应边界优先使用 DTO;除非某文件本来就是如此,否则不要直接从控制器暴露实体 -- 持久化操作应保留在 service / repository 边界内,不要堆进 controller -- 对需要原子性的写操作使用 `@Transactional` -### 错误处理与安全 -- 维持现有 JSON 错误格式,使用 `message` 或 `error` 字段 -- HTTP 状态码保持一致: - - `400`:参数或输入错误 - - `401`:认证 / 授权失败 - - `500`:未预期的服务端异常 -- 不要在日志中输出明文密码、私钥、口令、JWT 或解密后的凭据 -- 加解密逻辑统一集中在 `EncryptionService` -- 修改认证逻辑时,要同时验证 HTTP 与 WebSocket token 处理 -- 注意 `ChannelSftp` 不是线程安全的,不要在并发场景共享实例 -## 9. 前端约定(`frontend/`) -### Vue 与 TypeScript 结构 -- Vue 单文件组件使用 ` - - -``` - -- [ ] **Step 2: 修改 TransfersView.vue 支持多文件源路径** - -```vue - - - -``` - -- [ ] **Step 3: 运行前端类型检查** - -```bash -cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend && npm run build -``` - -Expected: Build succeeds with no TypeScript errors in modified files. - -- [ ] **Step 4: 浏览器验证 UI 变更** - -- 手动启动前端开发服务 `npm run dev` -- 切换到 Remote -> Many 标签页 -- 点击 "浏览" 按钮,验证文件选择器支持多选(单击切换选中状态,右上角确认按钮显示选中数量) -- 选中多个文件后,验证源路径输入框显示路径列表,再次点击浏览按钮可追加选择 -- 清空按钮可清空已选择的路径 - ---- - -### Task 1: 后端新增批量任务创建 API - -**Files:** -- Modify: `backend/src/main/java/com/sshmanager/controller/SftpController.java:504-547` - -- [ ] **Step 1: 修改 SftpController.createTransferRemoteTask() 支持多源路径** - -```java -@PostMapping("/transfer-remote/batch-tasks") -public ResponseEntity> createRemoteToManyMultiTask( - @RequestParam Long sourceConnectionId, - @RequestParam String[] sourcePaths, - @RequestParam Long targetConnectionId, - @RequestParam String targetDirOrPath, - Authentication authentication) { - Long userId = getCurrentUserId(authentication); - - if (sourcePaths == null || sourcePaths.length == 0) { - Map err = new HashMap<>(); - err.put("error", "sourcePaths is required"); - return ResponseEntity.badRequest().body(err); - } - - if (sourceConnectionId == null) { - Map err = new HashMap<>(); - err.put("error", "sourceConnectionId is required"); - return ResponseEntity.badRequest().body(err); - } - - if (targetConnectionId == null) { - Map err = new HashMap<>(); - err.put("error", "targetConnectionId is required"); - return ResponseEntity.badRequest().body(err); - } - - if (targetDirOrPath == null || targetDirOrPath.trim().isEmpty()) { - Map err = new HashMap<>(); - err.put("error", "targetDirOrPath is required"); - return ResponseEntity.badRequest().body(err); - } - - List> taskResponses = new ArrayList<>(); - - for (String sourcePath : sourcePaths) { - ResponseEntity> validation = validateTransferPaths(sourcePath, targetDirOrPath); - if (validation != null) { - Map err = new HashMap<>(); - err.putAll(validation.getBody()); - err.put("sourcePath", sourcePath); - taskResponses.add(err); - continue; - } - - String sourcePathTrimmed = sourcePath.trim(); - String filename = sourcePathTrimmed.split("/").filter(p -> !p.isEmpty()).reduce((a, b) -> b).orElse(sourcePathTrimmed); - String targetPath = targetDirOrPath.endsWith("/") ? (targetDirOrPath + filename) : targetDirOrPath; - - TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, - sourcePathTrimmed, targetPath); - status.setController(this); - String taskKey = transferTaskKey(userId, status.getTaskId()); - transferTasks.put(taskKey, status); - - Future future = transferTaskExecutor.submit(() -> { - status.setStatus("running"); - try { - if (Thread.currentThread().isInterrupted()) { - status.markCancelled(); - return; - } - executeTransfer(userId, sourceConnectionId, sourcePathTrimmed, targetConnectionId, targetPath, status); - status.markSuccess(); - } catch (Exception e) { - if (e instanceof InterruptedException || Thread.currentThread().isInterrupted()) { - status.markCancelled(); - return; - } - status.markError(toSftpErrorMessage(e, sourcePathTrimmed, "transfer")); - log.warn("SFTP transfer task failed: taskId={}, sourceConnectionId={}, sourcePath={}, targetConnectionId={}, targetPath={}, error={}", - status.getTaskId(), sourceConnectionId, sourcePathTrimmed, targetConnectionId, targetPath, e.getMessage(), e); - } - }); - status.setFuture(future); - - taskResponses.add(status.toResponse()); - } - - Map result = new HashMap<>(); - result.put("tasks", taskResponses); - result.put("count", taskResponses.size()); - return ResponseEntity.ok(result); -} -``` - -- [ ] **Step 2: 修改 SftpController.createTransferRemoteTask() 保持单文件兼容性** - -```java -@PostMapping("/transfer-remote/tasks") -public ResponseEntity> createTransferRemoteTask( - @RequestParam Long sourceConnectionId, - @RequestParam String sourcePath, - @RequestParam Long targetConnectionId, - @RequestParam String targetPath, - Authentication authentication) { - Long userId = getCurrentUserId(authentication); - ResponseEntity> validation = validateTransferPaths(sourcePath, targetPath); - if (validation != null) { - Map err = new HashMap<>(); - err.putAll(validation.getBody()); - return ResponseEntity.status(validation.getStatusCode()).body(err); - } - - TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, - sourcePath.trim(), targetPath.trim()); - status.setController(this); - String taskKey = transferTaskKey(userId, status.getTaskId()); - transferTasks.put(taskKey, status); - - Future future = transferTaskExecutor.submit(() -> { - status.setStatus("running"); - try { - if (Thread.currentThread().isInterrupted()) { - status.markCancelled(); - return; - } - executeTransfer(userId, sourceConnectionId, sourcePath, targetConnectionId, targetPath, status); - status.markSuccess(); - } catch (Exception e) { - if (e instanceof InterruptedException || Thread.currentThread().isInterrupted()) { - status.markCancelled(); - return; - } - status.markError(toSftpErrorMessage(e, sourcePath, "transfer")); - log.warn("SFTP transfer task failed: taskId={}, sourceConnectionId={}, sourcePath={}, targetConnectionId={}, targetPath={}, error={}", - status.getTaskId(), sourceConnectionId, sourcePath, targetConnectionId, targetPath, e.getMessage(), e); - } - }); - status.setFuture(future); - - return ResponseEntity.ok(status.toResponse()); -} -``` - -- [ ] **Step 3: 运行后端编译测试** - -```bash -cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend && mvn clean compile -``` - -Expected: Compilation succeeds with no errors. - -- [ ] **Step 4: 启动后端并验证 API** - -```bash -cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend && mvn spring-boot:run -``` - -- 验证 `/api/sftp/transfer-remote/tasks` 接口(单文件)仍正常工作 -- 验证 `/api/sftp/transfer-remote/batch-tasks` 接口存在,可通过 curl 验证: - -```bash -curl -X POST "http://localhost:48080/api/sftp/transfer-remote/batch-tasks" \ - -H "Authorization: Bearer " \ - -d "sourceConnectionId=1&sourcePaths=/path/file1.txt&sourcePaths=/path/file2.txt&targetConnectionId=2&targetDirOrPath=/target/" -``` - -Expected: Response includes `tasks` array with individual task status. - ---- - -### Task 2: 前端 API 封装和 Store 改造 - -**Files:** -- Modify: `frontend/src/api/sftp.ts:226-230` -- Modify: `frontend/src/stores/transfers.ts:368-410` - -- [ ] **Step 1: 添加 createRemoteToManyMultiTask API** - -```typescript -export function createRemoteToManyMultiTask( - sourceConnectionId: number, - sourcePaths: string[], - targetConnectionId: number, - targetDirOrPath: string -) { - const params = new URLSearchParams() - params.append('sourceConnectionId', String(sourceConnectionId)) - sourcePaths.forEach((p) => params.append('sourcePaths', p)) - params.append('targetConnectionId', String(targetConnectionId)) - params.append('targetDirOrPath', targetDirOrPath) - - return client.post<{ tasks: RemoteTransferTask[]; count: number }>('/sftp/transfer-remote/batch-tasks', null, { - params, - }) -} -``` - -- [ ] **Step 2: 新增 startRemoteToManyMulti store method** - -```typescript -async function startRemoteToManyMulti(params: { - sourceConnectionId: number - sourcePaths: string[] - targetConnectionIds: number[] - targetDirOrPath: string - concurrency?: number -}) { - const { sourceConnectionId, sourcePaths, targetConnectionIds, targetDirOrPath } = params - const concurrency = params.concurrency ?? 3 - - if (sourceConnectionId == null) return - - const runId = uid('run') - const runItems: TransferItem[] = [] - - for (const sourcePath of sourcePaths) { - const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath - for (const targetId of targetConnectionIds) { - runItems.push({ - id: uid('item'), - label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`, - status: 'queued' as const, - progress: 0, - }) - } - } - - const run: TransferRun = { - id: runId, - mode: 'REMOTE_TO_MANY' as const, - title: `Remote ${sourcePaths.length} files -> ${targetConnectionIds.length} targets`, - createdAt: now(), - items: runItems, - status: 'queued' as const, - } - - runs.value = [run, ...runs.value] - - let cancelled = false - const unsubscribers: (() => void)[] = [] - controllers.set(runId, { - abortAll: () => { - cancelled = true - }, - unsubscribers, - }) - - const tasks: (() => Promise)[] = sourcePaths.flatMap((sourcePath) => { - const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath - return targetConnectionIds.map((targetId, index) => { - const itemIndex = runItems.findIndex((i) => - i.label.includes(filename) && i.label.includes(`#${targetId}`) - ) - if (itemIndex === -1) return null - const item = runItems[itemIndex]! - - return async () => { - if (item.status === 'cancelled' || cancelled) return - item.status = 'running' - item.progress = 0 - item.startedAt = now() - runs.value = [...runs.value] - console.log('[Remote->Many Multi] Starting transfer:', item.label, 'targetId:', targetId) - try { - const targetPath = targetDirOrPath.endsWith('/') - ? targetDirOrPath + filename - : targetDirOrPath - - const task = await createRemoteToManyMultiTask(sourceConnectionId, [sourcePath], targetId, targetPath) - const taskId = task.data.tasks[0]?.taskId - if (!taskId) { - throw new Error('Failed to create transfer task') - } - console.log('[Remote->Many Multi] Task created:', taskId) - await waitForRemoteTransfer(taskId, (progress) => { - console.log('[Remote->Many Multi] Progress update:', progress, 'item:', item.label) - item.progress = Math.max(item.progress || 0, progress) - runs.value = [...runs.value] - }, unsubscribers) - - item.status = 'success' - item.progress = 100 - item.finishedAt = now() - console.log('[Remote->Many Multi] Transfer completed:', item.label) - runs.value = [...runs.value] - } catch (e: unknown) { - const err = e as { response?: { data?: { error?: string } } } - const msg = err?.response?.data?.error || (e as Error)?.message || 'Transfer failed' - console.error('[Remote->Many Multi] Transfer failed:', item.label, 'error:', msg) - if (msg === 'Cancelled') { - item.status = 'cancelled' - item.progress = 100 - } else { - item.status = 'error' - item.progress = 100 - item.message = msg - } - item.finishedAt = now() - runs.value = [...runs.value] - } finally { - runs.value = [...runs.value] - } - } - }).filter((t): t is () => Promise => t !== null) - }) - - await runWithConcurrency(tasks, concurrency) - runs.value = [...runs.value] -} -``` - -- [ ] **Step 3: 在 transfers.ts export 中添加新方法** - -```typescript -return { - runs, - recentRuns, - controllers, - clearRuns, - cancelRun, - startLocalToMany, - startRemoteToMany, - startRemoteToManyMulti, -} -``` - -- [ ] **Step 4: 运行前端类型检查** - -```bash -cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend && npm run build -``` - -Expected: Build succeeds with no TypeScript errors. - ---- - -### Task 3: 端到端测试和验证 - -**Files:** -- Manual verification only - -- [ ] **Step 1: 清空数据库并重启后端** - -```bash -cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend -rm -rf data/sshmanager -mvn spring-boot:run -``` - -- [ ] **Step 2: 启动前端** - -```bash -cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend -npm run dev -``` - -- [ ] **Step 3: 创建测试连接** - -- 在 Connections 页面添加 2-3 个测试连接(例如本地 Docker 容器) - -- [ ] **Step 4: Remote -> Many 单文件传输(兼容性测试)** - -- 切换到 Remote -> Many 标签页 -- 选择源连接 -- 使用文件选择器选择单个文件(或手动输入路径) -- 选择一个目标连接 -- 点击 "开始转发" -- 验证任务创建成功,进度栏正常更新 - -- [ ] **Step 5: Remote -> Many 多文件传输(新增功能)** - -- 切换到 Remote -> Many 标签页 -- 点击 "浏览" 按钮 -- 在文件选择器中: - - 单击多个文件切换选中状态 - - 右上角 "确认 (N)" 按钮应显示选中数量 - - 选中 2-3 个文件后点击确认 -- 源路径输入框应显示类似 `/path/file1.txt, /path/file2.txt` -- 选择一个目标连接 -- 点击 "开始转发" -- 验证任务队列中创建了 `sourceFiles.length * targetConnections.length` 个子任务 -- 每个子任务的进度独立更新 -- 全部完成后状态应为 "Success" - -- [ ] **Step 6: 并发控制验证** - -- 设置并发为 1(串行) -- 选择 3 个源文件和 2 个目标连接(共 6 个子任务) -- 观察任务执行顺序,应为串行执行 -- 设置并发为 3,重复以上步骤,观察并发执行 - -- [ ] **Step 7: 错误处理验证** - -- 选择不存在的文件路径 -- 验证错误消息正确显示在对应子任务中 -- 其他子任务不受影响继续执行 - -- [ ] **Step 8: 取消任务验证** - -- 开始传输后立即点击 "取消任务" -- 验证所有未完成任务标记为 "Cancelled" -- 控制台日志显示取消信息 - -- [ ] **Step 9: 登录流程验证** - -- 退出登录 -- 重新登录 -- 创建新的传输任务 -- 验证认证流程正常 - ---- - -### Task 4: 文档和清理 - -**Files:** -- Create: `docs/REMOTE_MULTI_FILE_TRANSFER.md` - -- [ ] **Step 1: 创建功能文档** - -```markdown -# Remote -> Many 多文件传输功能说明 - -## 功能概述 -Remote -> Many 模式现在支持选择多个远程文件,所有文件推送到统一目标目录,使用并发传输提升效率。 - -## 使用方式 - -### 前端操作 -1. 切换到 Remote -> Many 标签页 -2. 选择源连接 -3. 点击 "浏览" 按钮打开文件选择器 -4. 在文件选择器中: - - 单击文件切换选中状态 - - 选择多个文件后点击右上角 "确认 (N)" 按钮 - - 或手动在源路径输入框输入路径(英文逗号分隔) -5. 选择一个或多个目标连接 -6. 设置并发数(默认 3) -7. 点击 "开始转发" - -### 并发控制 -- 并发数支持 1-6,建议 2-4 -- 并发越高越吃带宽与 CPU -- 后端是逐个调用 transfer-remote;并发适中即可 - -### 进度追踪 -- 每个文件-目标对独立显示进度 -- 悬停可查看详细错误信息 -- 支持取消所有未完成任务 - -## 技术细节 - -### API 端点 -- `POST /api/sftp/transfer-remote/batch-tasks` - 批量创建传输任务 - - `sourceConnectionId` - 源连接 ID - - `sourcePaths` - 源文件路径数组(可重复) - - `targetConnectionId` - 目标连接 ID - - `targetDirOrPath` - 目标目录或路径 - -- `GET /api/sftp/transfer-remote/tasks/{taskId}/progress` - SSE 进度追踪 - -### 并发控制 -- 前端使用 `runWithConcurrency` 工具函数 -- 每个文件-目标对创建独立任务 -- 任务状态独立追踪,互不影响 - -### 错误处理 -- 单个文件传输失败不影响其他文件 -- 错误信息显示在对应子任务中 -- 支持取消未完成任务 -``` - -- [ ] **Step 2: 运行最终构建验证** - -```bash -cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager && docker compose -f docker/docker-compose.yml up --build -d -``` - -Expected: Docker service starts successfully with no errors. - -- [ ] **Step 3: 查看日志** - -```bash -docker compose -f docker/docker-compose.yml logs -f -``` - -Verify: No stack traces or critical errors in application logs. - -- [ ] **Step 4: 提交代码** - -```bash -cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager -git add -A -git commit -m "feat: add multi-file support for Remote -> Many transfer mode" -``` - ---- - -## 执行摘要 - -| 阶段 | 任务 | 预计时间 | -|------|------|----------| -| 前端基础改造 | Task 0 | 15 分钟 | -| 后端 API 扩展 | Task 1 | 10 分钟 | -| Store 改造 | Task 2 | 10 分钟 | -| 端到端测试 | Task 3 | 20 分钟 | -| 文档和清理 | Task 4 | 5 分钟 | -| **总计** | | **60 分钟** | - ---- - -## 完成检查项 - -- [ ] 前端 UI 支持多文件选择(`SftpFilePickerModal.vue` 多选模式) -- [ ] `TransfersView.vue` 支持 `remoteSourcePaths: string[]` -- [ ] `transfers.ts` 新增 `startRemoteToManyMulti()` 方法 -- [ ] 后端新增 `/api/sftp/transfer-remote/batch-tasks` 接口 -- [ ] 每个文件-目标对独立任务,共享 SSE 进度追踪 -- [ ] 并发控制复用 `runWithConcurrency` 工具函数 -- [ ] 前端类型检查通过(`npm run build`) -- [ ] 后端编译通过(`mvn clean compile`) -- [ ] Docker 构建成功,服务启动正常 -- [ ] 端到端测试通过(单文件兼容性、多文件传输、并发控制、错误处理、取消任务、登录流程) -- [ ] 功能文档已创建 -- [ ] 代码已提交 diff --git a/docs/superpowers/plans/2026-03-23-remote-to-many-multi-file.md b/docs/superpowers/plans/2026-03-23-remote-to-many-multi-file.md deleted file mode 100644 index a143908..0000000 --- a/docs/superpowers/plans/2026-03-23-remote-to-many-multi-file.md +++ /dev/null @@ -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 ``. - - 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"` diff --git a/docs/superpowers/plans/2026-03-24-nav-swap.md b/docs/superpowers/plans/2026-03-24-nav-swap.md deleted file mode 100644 index 8c6e6cf..0000000 --- a/docs/superpowers/plans/2026-03-24-nav-swap.md +++ /dev/null @@ -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 - - -``` - -```vue - - -``` - -- [ ] **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" -``` diff --git a/docs/superpowers/plans/2026-03-24-sftp-tabs.md b/docs/superpowers/plans/2026-03-24-sftp-tabs.md deleted file mode 100644 index bc7df57..0000000 --- a/docs/superpowers/plans/2026-03-24-sftp-tabs.md +++ /dev/null @@ -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([]) -const activeTabId = ref(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" -``` diff --git a/docs/superpowers/plans/2026-03-26-terminal-multi-tabs.md b/docs/superpowers/plans/2026-03-26-terminal-multi-tabs.md deleted file mode 100644 index 35a905f..0000000 --- a/docs/superpowers/plans/2026-03-26-terminal-multi-tabs.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-03-23-remote-to-many-multi-file-design.md b/docs/superpowers/specs/2026-03-23-remote-to-many-multi-file-design.md deleted file mode 100644 index 2b09eb1..0000000 --- a/docs/superpowers/specs/2026-03-23-remote-to-many-multi-file-design.md +++ /dev/null @@ -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 ``), 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: `#: -> #:` -- 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). diff --git a/docs/superpowers/specs/2026-03-24-nav-swap-design.md b/docs/superpowers/specs/2026-03-24-nav-swap-design.md deleted file mode 100644 index 8e6988c..0000000 --- a/docs/superpowers/specs/2026-03-24-nav-swap-design.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-03-24-sftp-tabs-design.md b/docs/superpowers/specs/2026-03-24-sftp-tabs-design.md deleted file mode 100644 index df255c6..0000000 --- a/docs/superpowers/specs/2026-03-24-sftp-tabs-design.md +++ /dev/null @@ -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`. diff --git a/docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md b/docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md deleted file mode 100644 index 244c41c..0000000 --- a/docs/superpowers/specs/2026-03-26-terminal-multi-tabs-design.md +++ /dev/null @@ -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. diff --git a/docs/xianyu-sales-copy.md b/docs/xianyu-sales-copy.md deleted file mode 100644 index 9a26a60..0000000 --- a/docs/xianyu-sales-copy.md +++ /dev/null @@ -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. 关于与交付信息 diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json deleted file mode 100644 index a7cea0b..0000000 --- a/frontend/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["Vue.volar"] -} diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 7dd9f3a..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vue 3 + TypeScript + Vite - -本模板用于在 Vite 中基于 Vue 3 与 TypeScript 进行开发。模板使用 Vue 3 的 `