From 14289beb66316fd338c8b83f209d843864c4638a Mon Sep 17 00:00:00 2001 From: liu <362165265@qq.com> Date: Tue, 3 Feb 2026 10:10:11 +0800 Subject: [PATCH] Initial commit Co-authored-by: Cursor --- .dockerignore | 10 + .gitignore | 42 + Dockerfile | 12 + README.md | 87 + deploy.sh | 70 + docker-compose.yml | 12 + docs/01-项目初始化与基础配置.md | 356 +++ docs/02-数据模型设计.md | 327 +++ docs/03-连接管理功能.md | 578 +++++ docs/04-文件浏览功能.md | 632 +++++ docs/05-文件上传下载功能.md | 591 +++++ docs/06-文件删除功能.md | 532 ++++ docs/07-文件重命名功能.md | 606 +++++ docs/08-新建文件夹功能.md | 686 +++++ docs/09-双面板UI界面设计.md | 899 +++++++ docs/10-模式切换功能.md | 714 ++++++ docs/11-API接口设计规范.md | 965 +++++++ docs/12-错误处理与日志.md | 831 ++++++ docs/13-部署与测试.md | 788 ++++++ docs/UI设计系统.md | 566 ++++ docs/开发文档.md | 2269 +++++++++++++++++ pom.xml | 102 + .../sftp/manager/SftpManagerApplication.java | 14 + .../com/sftp/manager/config/WebConfig.java | 26 + .../controller/ConnectionController.java | 98 + .../manager/controller/FileController.java | 361 +++ .../com/sftp/manager/dto/ApiResponse.java | 41 + .../sftp/manager/dto/BatchDeleteResult.java | 13 + .../sftp/manager/dto/ConnectionRequest.java | 16 + .../com/sftp/manager/dto/FileListRequest.java | 10 + .../manager/dto/FileOperationRequest.java | 12 + .../com/sftp/manager/dto/TransferRequest.java | 11 + .../com/sftp/manager/model/Connection.java | 59 + .../java/com/sftp/manager/model/FileInfo.java | 18 + .../repository/ConnectionRepository.java | 12 + .../manager/service/ConnectionService.java | 156 ++ .../manager/service/LocalFileService.java | 358 +++ .../sftp/manager/service/SessionManager.java | 56 + .../com/sftp/manager/service/SftpService.java | 384 +++ src/main/resources/application-prod.yml | 50 + src/main/resources/application.yml | 56 + src/main/resources/static/css/style.css | 373 +++ src/main/resources/static/index.html | 176 ++ src/main/resources/static/js/app.js | 1340 ++++++++++ src/main/resources/templates/index.html | 164 ++ 45 files changed, 15479 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 deploy.sh create mode 100644 docker-compose.yml create mode 100644 docs/01-项目初始化与基础配置.md create mode 100644 docs/02-数据模型设计.md create mode 100644 docs/03-连接管理功能.md create mode 100644 docs/04-文件浏览功能.md create mode 100644 docs/05-文件上传下载功能.md create mode 100644 docs/06-文件删除功能.md create mode 100644 docs/07-文件重命名功能.md create mode 100644 docs/08-新建文件夹功能.md create mode 100644 docs/09-双面板UI界面设计.md create mode 100644 docs/10-模式切换功能.md create mode 100644 docs/11-API接口设计规范.md create mode 100644 docs/12-错误处理与日志.md create mode 100644 docs/13-部署与测试.md create mode 100644 docs/UI设计系统.md create mode 100644 docs/开发文档.md create mode 100644 pom.xml create mode 100644 src/main/java/com/sftp/manager/SftpManagerApplication.java create mode 100644 src/main/java/com/sftp/manager/config/WebConfig.java create mode 100644 src/main/java/com/sftp/manager/controller/ConnectionController.java create mode 100644 src/main/java/com/sftp/manager/controller/FileController.java create mode 100644 src/main/java/com/sftp/manager/dto/ApiResponse.java create mode 100644 src/main/java/com/sftp/manager/dto/BatchDeleteResult.java create mode 100644 src/main/java/com/sftp/manager/dto/ConnectionRequest.java create mode 100644 src/main/java/com/sftp/manager/dto/FileListRequest.java create mode 100644 src/main/java/com/sftp/manager/dto/FileOperationRequest.java create mode 100644 src/main/java/com/sftp/manager/dto/TransferRequest.java create mode 100644 src/main/java/com/sftp/manager/model/Connection.java create mode 100644 src/main/java/com/sftp/manager/model/FileInfo.java create mode 100644 src/main/java/com/sftp/manager/repository/ConnectionRepository.java create mode 100644 src/main/java/com/sftp/manager/service/ConnectionService.java create mode 100644 src/main/java/com/sftp/manager/service/LocalFileService.java create mode 100644 src/main/java/com/sftp/manager/service/SessionManager.java create mode 100644 src/main/java/com/sftp/manager/service/SftpService.java create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/static/css/style.css create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/static/js/app.js create mode 100644 src/main/resources/templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..efae8ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# 构建前请先执行: mvn clean package +.git +.gitignore +*.md +*.iml +.idea +.vscode +data/ +logs/ +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46f1347 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.vscode/ + +# Java +*.class +*.jar +*.war +*.ear +*.log +hs_err_pid* + +# H2 / 本地数据库(可选:若需提交空库可去掉 data/) +data/*.db +data/*.trace.db + +# OS +.DS_Store +Thumbs.db + +# 环境与密钥(勿提交) +.env +*.pem +*.key diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2fd4a33 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# 构建阶段:先执行 mvn clean package 生成 target/sftp-manager-1.0.0.jar +FROM eclipse-temurin:8-jre-alpine + +VOLUME /tmp +ARG JAR_FILE=target/sftp-manager-1.0.0.jar + +COPY ${JAR_FILE} app.jar + +# 数据与日志目录 +RUN mkdir -p /app/data /app/logs + +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..91392cf --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# SFTP Manager + +SFTP文件管理系统 - 基于Spring Boot和JSch的双面板文件管理工具 + +## 项目简介 + +SFTP Manager是一个现代化的SFTP文件管理系统,提供直观的双面板界面,支持本地和远程文件的浏览、上传、下载、删除、重命名等操作。 + +## 技术栈 + +- **后端框架**: Spring Boot 2.7.18 +- **SFTP客户端**: JSch 0.1.55 +- **数据库**: H2 Database (嵌入式) +- **ORM**: Spring Data JPA +- **前端**: 原生HTML/CSS/JavaScript +- **构建工具**: Maven + +## 功能特性 + +- ✅ 连接管理(保存、编辑、删除SFTP连接配置) +- ✅ 文件浏览(本地和远程文件列表) +- ✅ 文件上传/下载 +- ✅ 文件删除 +- ✅ 文件重命名 +- ✅ 新建文件夹 +- ✅ 双面板UI界面 +- ✅ 模式切换(本地/远程) + +## 快速开始 + +### 环境要求 + +- JDK 1.8+ +- Maven 3.6+ + +### 运行项目 + +```bash +# 编译项目 +mvn clean compile + +# 运行项目 +mvn spring-boot:run +``` + +项目启动后,访问:`http://localhost:8080/sftp-manager` + +### H2数据库控制台 + +访问:`http://localhost:8080/sftp-manager/h2-console` + +- JDBC URL: `jdbc:h2:file:./data/sftp-manager` +- 用户名: `sa` +- 密码: (空) + +## 项目结构 + +``` +sftp-manager/ +├── src/ +│ ├── main/ +│ │ ├── java/com/sftp/manager/ +│ │ │ ├── SftpManagerApplication.java +│ │ │ ├── config/ # 配置类 +│ │ │ ├── controller/ # REST API控制器 +│ │ │ ├── service/ # 业务逻辑服务 +│ │ │ ├── model/ # 实体类 +│ │ │ └── dto/ # 数据传输对象 +│ │ └── resources/ +│ │ ├── application.yml # 应用配置 +│ │ ├── static/ # 静态资源 +│ │ └── templates/ # 模板文件 +│ └── test/ # 测试代码 +└── pom.xml +``` + +## 开发文档 + +详细的开发文档请参考项目根目录下的Markdown文件: + +- `01-项目初始化与基础配置.md` - 项目初始化指南 +- `02-数据模型设计.md` - 数据模型设计 +- `UI设计系统.md` - UI设计规范 + +## 许可证 + +MIT License diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..563b9b6 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# SFTP Manager 部署脚本 (Linux/Mac) +# 用法: ./deploy.sh {start|stop|restart} + +APP_NAME="sftp-manager" +JAR_FILE="${APP_NAME}-1.0.0.jar" +PID_FILE="${APP_NAME}.pid" +LOG_FILE="${APP_NAME}.log" + +# 停止服务 +stop() { + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + kill "$PID" + echo "正在停止服务 (PID: $PID)..." + for _ in $(seq 1 10); do + sleep 1 + kill -0 "$PID" 2>/dev/null || break + done + kill -9 "$PID" 2>/dev/null + fi + rm -f "$PID_FILE" + echo "服务已停止" + else + echo "未找到 PID 文件,服务可能未运行" + fi +} + +# 启动服务 +start() { + if [ ! -f "$JAR_FILE" ]; then + echo "错误: 未找到 $JAR_FILE,请先执行 mvn clean package" + exit 1 + fi + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + echo "服务已在运行 (PID: $PID)" + exit 0 + fi + fi + nohup java -jar "$JAR_FILE" --spring.profiles.active=prod > "$LOG_FILE" 2>&1 & + echo $! > "$PID_FILE" + echo "服务已启动 (PID: $(cat $PID_FILE))" +} + +# 重启服务 +restart() { + stop + sleep 2 + start +} + +case "$1" in + start) + start + ;; + stop) + stop + ;; + restart) + restart + ;; + *) + echo "用法: $0 {start|stop|restart}" + exit 1 +esac + +exit 0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ee4465 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3' +services: + sftp-manager: + build: . + ports: + - "8080:8080" + volumes: + - ./data:/app/data + - ./logs:/app/logs + environment: + - SPRING_PROFILES_ACTIVE=prod + restart: always diff --git a/docs/01-项目初始化与基础配置.md b/docs/01-项目初始化与基础配置.md new file mode 100644 index 0000000..bc107a2 --- /dev/null +++ b/docs/01-项目初始化与基础配置.md @@ -0,0 +1,356 @@ +# 模块01:项目初始化与基础配置 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 1.1 项目结构创建 + +``` +sftp-manager/ +├── README.md # 项目说明文档 +├── pom.xml # Maven配置文件 +└── src/ + ├── main/ + │ ├── java/com/sftp/manager/ + │ │ ├── SftpManagerApplication.java # Spring Boot主类 + │ │ ├── config/ + │ │ │ └── WebConfig.java # Web配置(CORS等) + │ │ ├── controller/ + │ │ │ ├── FileController.java # 文件操作API + │ │ │ └── ConnectionController.java # 连接管理API + │ │ ├── service/ + │ │ │ ├── SftpService.java # SFTP操作服务 + │ │ │ ├── LocalFileService.java # 本地文件操作服务 + │ │ │ └── ConnectionService.java # 连接配置服务 + │ │ ├── model/ + │ │ │ ├── Connection.java # 连接实体类 + │ │ │ └── FileInfo.java # 文件信息实体类 + │ │ └── dto/ + │ │ ├── ConnectionRequest.java # 连接请求DTO + │ │ ├── FileOperationRequest.java # 文件操作请求DTO + │ │ └── ApiResponse.java # API统一响应DTO + │ └── resources/ + │ ├── application.yml # 应用配置文件 + │ ├── static/ + │ │ ├── css/ + │ │ │ └── style.css # 自定义样式 + │ │ └── js/ + │ │ └── app.js # 前端业务逻辑 + │ └── templates/ + │ └── index.html # 主页面(双面板UI) + └── test/ # 测试代码 +``` + +## 1.2 Maven依赖配置(pom.xml) + +### 核心依赖 + +```xml + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.sftp + sftp-manager + 1.0.0 + SFTP Manager + SFTP文件管理系统 + + + 1.8 + 0.1.55 + 2.1.214 + 1.18.30 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.jcraft + jsch + ${jsch.version} + + + + + com.h2database + h2 + ${h2.version} + runtime + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.projectlombok + lombok + ${lombok.version} + true + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + +``` + +## 1.3 应用配置文件(application.yml) + +### 配置项说明 + +```yaml +server: + port: 8080 # 服务端口 + servlet: + context-path: /sftp-manager # 应用上下文路径 + +spring: + application: + name: sftp-manager + + # H2数据库配置 + h2: + console: + enabled: true # 启用H2控制台 + path: /h2-console # 控制台访问路径 + datasource: + url: jdbc:h2:file:./data/sftp-manager # 数据库文件路径 + driver-class-name: org.h2.Driver + username: sa + password: + + # JPA配置 + jpa: + hibernate: + ddl-auto: update # 自动更新表结构 + show-sql: true # 显示SQL语句 + properties: + hibernate: + format_sql: true # 格式化SQL输出 + + # 文件上传配置 + servlet: + multipart: + enabled: true + max-file-size: 100MB # 单文件最大100MB + max-request-size: 500MB # 总请求最大500MB + +# 自定义配置 +app: + sftp: + session-timeout: 30000 # SFTP会话超时时间(ms) + connection-timeout: 10000 # 连接超时时间(ms) + max-retries: 3 # 连接失败重试次数 + +logging: + level: + com.sftp.manager: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE +``` + +## 1.4 主类配置(SftpManagerApplication.java) + +### 配置要点 + +```java +package com.sftp.manager; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication +@EnableJpaRepositories +public class SftpManagerApplication { + + public static void main(String[] args) { + SpringApplication.run(SftpManagerApplication.class, args); + } +} +``` + +**说明:** +- `@SpringBootApplication`:包含自动配置、组件扫描、配置类定义 +- `@EnableJpaRepositories`:启用JPA仓库支持 + +## 1.5 Web配置(WebConfig.java) + +### 配置内容 + +```java +package com.sftp.manager.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .maxAge(3600); + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/"); + } +} +``` + +**配置说明:** +1. **CORS跨域配置**:允许前端访问API + - 允许所有源(*) + - 允许常用HTTP方法 + - 允许所有请求头 + - 预检请求缓存3600秒 + +2. **静态资源映射**:配置静态资源访问路径 + - 映射 `/**` 到 `classpath:/static/` + - 支持HTML、CSS、JS等静态文件 + +3. **响应编码**:Spring Boot默认使用UTF-8,无需额外配置 + +## 实施步骤 + +1. **创建项目目录结构** + ``` + mkdir -p sftp-manager/src/main/java/com/sftp/manager/{config,controller,service,model,dto} + mkdir -p sftp-manager/src/main/resources/{static/{css,js},templates} + ``` + +2. **创建pom.xml文件**:复制上面的依赖配置 + +3. **创建application.yml文件**:复制上面的配置内容 + +4. **创建SftpManagerApplication.java**:在 `src/main/java/com/sftp/manager/` 目录下 + +5. **创建WebConfig.java**:在 `src/main/java/com/sftp/manager/config/` 目录下 + +6. **验证项目结构** + ``` + tree sftp-manager + ``` + +7. **测试启动** + ``` + mvn spring-boot:run + ``` + +## 注意事项 + +1. **JDK版本**:确保使用JDK 8或更高版本 +2. **Maven版本**:建议使用Maven 3.6+ +3. **端口占用**:确认8080端口未被占用,可在application.yml中修改 +4. **数据库文件**:H2数据库文件会创建在项目根目录的data文件夹下 +5. **IDE配置**:如果使用IDEA,需启用Lombok插件 + +## 下一步 + +完成模块01后,继续模块02:数据模型设计 diff --git a/docs/02-数据模型设计.md b/docs/02-数据模型设计.md new file mode 100644 index 0000000..26752ac --- /dev/null +++ b/docs/02-数据模型设计.md @@ -0,0 +1,327 @@ +# 模块02:数据模型设计 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 2.1 连接实体(Connection.java) + +### 字段说明 + +```java +package com.sftp.manager.model; + +import lombok.Data; +import javax.persistence.*; +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "connections") +public class Connection { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // 连接ID(主键) + + @Column(nullable = false) + private String name; // 连接名称(用户自定义) + + @Column(nullable = false) + private String host; // SFTP服务器地址 + + private Integer port; // SFTP端口(默认22) + + @Column(nullable = false) + private String username; // 用户名 + + @Column(columnDefinition = "TEXT") + private String password; // 密码(加密存储) + + private String privateKeyPath; // 私钥路径(可选) + + private String passPhrase; // 私钥密码(可选) + + private Integer connectTimeout; // 连接超时时间 + + private String rootPath; // 默认登录后路径 + + @Column(name = "created_at") + private LocalDateTime createdAt; // 创建时间 + + @Column(name = "updated_at") + private LocalDateTime updatedAt; // 更新时间 + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + if (port == null) { + port = 22; + } + if (connectTimeout == null) { + connectTimeout = 10000; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} +``` + +### 设计要点 + +1. **密码字段加密**:建议使用AES或Base64加密存储 +2. **双重认证支持**:支持密码认证和密钥认证两种方式 +3. **时间自动记录**:使用`@PrePersist`和`@PreUpdate`自动设置创建和更新时间 +4. **默认值设置**:端口默认22,超时默认10秒 + +## 2.2 文件信息实体(FileInfo.java) + +### 字段说明 + +```java +package com.sftp.manager.model; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class FileInfo { + private String name; // 文件名 + private String path; // 完整路径 + private long size; // 文件大小(字节) + private boolean isDirectory; // 是否为目录 + private LocalDateTime modifiedTime; // 修改时间 + private String permissions; // 文件权限(如:-rw-r--r--) +} +``` + +### 设计要点 + +1. **DTO模式**:不持久化到数据库,仅用于数据传输 +2. **统一表示**:统一本地文件和SFTP文件的表示方式 +3. **完整元数据**:包含文件名、路径、大小、类型、修改时间、权限等 + +## 2.3 通用响应对象(ApiResponse.java) + +### 设计说明 + +```java +package com.sftp.manager.dto; + +import lombok.Data; + +@Data +public class ApiResponse { + private boolean success; // 操作是否成功 + private String message; // 响应消息 + private T data; // 响应数据 + private String error; // 错误信息 + + public static ApiResponse success(T data) { + ApiResponse response = new ApiResponse<>(); + response.setSuccess(true); + response.setData(data); + return response; + } + + public static ApiResponse success(String message, T data) { + ApiResponse response = new ApiResponse<>(); + response.setSuccess(true); + response.setMessage(message); + response.setData(data); + return response; + } + + public static ApiResponse error(String message) { + ApiResponse response = new ApiResponse<>(); + response.setSuccess(false); + response.setMessage(message); + return response; + } + + public static ApiResponse error(String message, String error) { + ApiResponse response = new ApiResponse<>(); + response.setSuccess(false); + response.setMessage(message); + response.setError(error); + return response; + } +} +``` + +### 使用场景 + +1. **统一响应格式**:所有API返回统一的结构 +2. **便于前端处理**:前端可以根据success字段判断操作结果 +3. **泛型支持**:灵活适配不同数据类型 +4. **静态工厂方法**:简化对象创建 + +## 2.4 数据传输对象(DTO) + +### 2.4.1 连接请求DTO(ConnectionRequest.java) + +```java +package com.sftp.manager.dto; + +import lombok.Data; + +@Data +public class ConnectionRequest { + private Long id; // 连接ID(用于保存配置) + private String name; // 连接名称 + private String host; // 主机地址 + private Integer port; // 端口 + private String username; // 用户名 + private String password; // 密码 + private String privateKeyPath; // 私钥路径 + private String passPhrase; // 私钥密码 +} +``` + +### 2.4.2 文件操作请求DTO(FileOperationRequest.java) + +```java +package com.sftp.manager.dto; + +import lombok.Data; + +@Data +public class FileOperationRequest { + private String sessionId; // 会话ID(标识是本地还是哪个SFTP连接) + private String path; // 操作的文件路径 + private String newName; // 新文件名(重命名使用) + private String targetPath; // 目标路径(移动/复制使用) + private String targetSessionId; // 目标会话ID(跨服务器传输使用) +} +``` + +### 2.4.3 文件列表请求DTO(FileListRequest.java) + +```java +package com.sftp.manager.dto; + +import lombok.Data; + +@Data +public class FileListRequest { + private String sessionId; // 会话ID("local"表示本地,否则为SFTP连接ID) + private String path; // 要浏览的目录路径 +} +``` + +## 2.5 文件传输请求DTO(TransferRequest.java) + +```java +package com.sftp.manager.dto; + +import lombok.Data; + +@Data +public class TransferRequest { + private String sourceSessionId; // 源会话ID + private String sourcePath; // 源文件路径 + private String targetSessionId; // 目标会话ID + private String targetPath; // 目标路径 +} +``` + +## 实施步骤 + +1. **创建实体类** + ``` + # Connection.java + touch src/main/java/com/sftp/manager/model/Connection.java + + # FileInfo.java + touch src/main/java/com/sftp/manager/model/FileInfo.java + ``` + +2. **创建DTO类** + ``` + # ApiResponse.java + touch src/main/java/com/sftp/manager/dto/ApiResponse.java + + # ConnectionRequest.java + touch src/main/java/com/sftp/manager/dto/ConnectionRequest.java + + # FileOperationRequest.java + touch src/main/java/com/sftp/manager/dto/FileOperationRequest.java + + # FileListRequest.java + touch src/main/java/com/sftp/manager/dto/FileListRequest.java + + # TransferRequest.java + touch src/main/java/com/sftp/manager/dto/TransferRequest.java + ``` + +3. **验证文件创建** + ``` + ls -la src/main/java/com/sftp/manager/model/ + ls -la src/main/java/com/sftp/manager/dto/ + ``` + +4. **编译测试** + ``` + mvn clean compile + ``` + +## 注意事项 + +1. **Lombok注解**:`@Data`自动生成getter/setter/toString等方法 +2. **JPA注解**:`@Entity`、`@Table`、`@Id`等用于数据库映射 +3. **DTO vs Entity**:DTO用于数据传输,Entity用于数据库持久化 +4. **序列化**:所有DTO都需要可序列化(JSON) + +## 下一步 + +完成模块02后,继续模块03:连接管理功能 diff --git a/docs/03-连接管理功能.md b/docs/03-连接管理功能.md new file mode 100644 index 0000000..14275b6 --- /dev/null +++ b/docs/03-连接管理功能.md @@ -0,0 +1,578 @@ +# 模块03:连接管理功能 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 3.1 功能概述 +实现SFTP连接的建立、断开、保存、加载和删除功能,支持多连接同时管理。 + +## 3.2 后端设计 + +### 3.2.1 ConnectionRepository接口 + +```java +package com.sftp.manager.repository; + +import com.sftp.manager.model.Connection; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ConnectionRepository extends JpaRepository { + List findByOrderByCreatedAtDesc(); // 按创建时间倒序查询 + Optional findByName(String name); // 按名称查询 +} +``` + +### 3.2.2 SessionManager会话管理 + +```java +package com.sftp.manager.service; + +import com.jcraft.jsch.ChannelSftp; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class SessionManager { + + private final Map activeSessions = new ConcurrentHashMap<>(); + private final Map sessionConnections = new ConcurrentHashMap<>(); + + public String addSession(ChannelSftp channel, Connection connection) { + String sessionId = "sftp-" + UUID.randomUUID().toString(); + activeSessions.put(sessionId, channel); + sessionConnections.put(sessionId, connection); + return sessionId; + } + + public ChannelSftp getSession(String sessionId) { + return activeSessions.get(sessionId); + } + + public Connection getConnection(String sessionId) { + return sessionConnections.get(sessionId); + } + + public void removeSession(String sessionId) { + ChannelSftp channel = activeSessions.get(sessionId); + if (channel != null) { + try { + channel.disconnect(); + } catch (Exception e) { + // 忽略关闭异常 + } + } + activeSessions.remove(sessionId); + sessionConnections.remove(sessionId); + } + + public boolean isActive(String sessionId) { + return activeSessions.containsKey(sessionId); + } + + public Map getAllActiveConnections() { + return new ConcurrentHashMap<>(sessionConnections); + } + + public int getActiveSessionCount() { + return activeSessions.size(); + } +} +``` + +### 3.2.3 ConnectionService连接服务 + +```java +package com.sftp.manager.service; + +import com.jcraft.jsch.Channel; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import com.sftp.manager.dto.ConnectionRequest; +import com.sftp.manager.model.Connection; +import com.sftp.manager.repository.ConnectionRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +@Service +public class ConnectionService { + + @Autowired + private ConnectionRepository connectionRepository; + + @Autowired + private SessionManager sessionManager; + + @Value("${app.sftp.connection-timeout:10000}") + private int connectionTimeout; + + @Value("${app.sftp.max-retries:3}") + private int maxRetries; + + public String connect(ConnectionRequest request) throws Exception { + JSch jsch = new JSch(); + Session session = null; + Channel channel = null; + com.jcraft.jsch.ChannelSftp sftpChannel = null; + + int retryCount = 0; + while (retryCount < maxRetries) { + try { + // 配置私钥(如果提供) + if (request.getPrivateKeyPath() != null && !request.getPrivateKeyPath().isEmpty()) { + jsch.addIdentity(request.getPrivateKeyPath(), + request.getPassPhrase() != null ? request.getPassPhrase() : ""); + } + + // 创建会话 + session = jsch.getSession(request.getUsername(), + request.getHost(), + request.getPort() != null ? request.getPort() : 22); + + // 配置密码(如果使用密码认证) + if (request.getPassword() != null && !request.getPassword().isEmpty()) { + session.setPassword(request.getPassword()); + } + + // 跳过主机密钥验证 + java.util.Properties config = new java.util.Properties(); + config.put("StrictHostKeyChecking", "no"); + session.setConfig(config); + + // 设置超时 + session.setTimeout(connectionTimeout); + + // 连接 + session.connect(); + channel = session.openChannel("sftp"); + channel.connect(); + sftpChannel = (com.jcraft.jsch.ChannelSftp) channel; + + // 如果指定了默认路径,切换到该路径 + if (request.getRootPath() != null && !request.getRootPath().isEmpty()) { + try { + sftpChannel.cd(request.getRootPath()); + } catch (Exception e) { + // 路径不存在,使用默认路径 + } + } + + // 创建连接对象(用于保存配置) + Connection connection = new Connection(); + connection.setName(request.getName()); + connection.setHost(request.getHost()); + connection.setPort(request.getPort() != null ? request.getPort() : 22); + connection.setUsername(request.getUsername()); + connection.setPassword(request.getPassword()); + connection.setPrivateKeyPath(request.getPrivateKeyPath()); + connection.setPassPhrase(request.getPassPhrase()); + connection.setRootPath(request.getRootPath()); + connection.setConnectTimeout(connectionTimeout); + + // 添加到会话管理器 + return sessionManager.addSession(sftpChannel, connection); + + } catch (Exception e) { + retryCount++; + if (retryCount >= maxRetries) { + throw new Exception("连接失败: " + e.getMessage(), e); + } + Thread.sleep(1000); // 等待1秒后重试 + } + } + + throw new Exception("连接失败"); + } + + public void disconnect(String sessionId) { + sessionManager.removeSession(sessionId); + } + + public Connection saveConnection(Connection connection) { + return connectionRepository.save(connection); + } + + public List listConnections() { + return connectionRepository.findByOrderByCreatedAtDesc(); + } + + public Connection getConnectionById(Long id) { + return connectionRepository.findById(id).orElse(null); + } + + public void deleteConnection(Long id) { + connectionRepository.deleteById(id); + } +} +``` + +### 3.2.4 ConnectionController连接控制器 + +```java +package com.sftp.manager.controller; + +import com.sftp.manager.dto.ApiResponse; +import com.sftp.manager.dto.ConnectionRequest; +import com.sftp.manager.model.Connection; +import com.sftp.manager.service.ConnectionService; +import com.sftp.manager.service.SessionManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/connection") +@CrossOrigin(origins = "*") +public class ConnectionController { + + @Autowired + private ConnectionService connectionService; + + @Autowired + private SessionManager sessionManager; + + @PostMapping("/connect") + public ApiResponse connect(@RequestBody ConnectionRequest request) { + try { + String sessionId = connectionService.connect(request); + return ApiResponse.success("连接成功", sessionId); + } catch (Exception e) { + return ApiResponse.error("连接失败: " + e.getMessage()); + } + } + + @PostMapping("/disconnect") + public ApiResponse disconnect(@RequestBody Map request) { + try { + String sessionId = request.get("sessionId"); + connectionService.disconnect(sessionId); + return ApiResponse.success("断开成功", null); + } catch (Exception e) { + return ApiResponse.error("断开失败: " + e.getMessage()); + } + } + + @PostMapping("/save") + public ApiResponse saveConnection(@RequestBody Connection connection) { + try { + Connection saved = connectionService.saveConnection(connection); + return ApiResponse.success("保存成功", saved); + } catch (Exception e) { + return ApiResponse.error("保存失败: " + e.getMessage()); + } + } + + @GetMapping("/list") + public ApiResponse> listConnections() { + try { + List connections = connectionService.listConnections(); + return ApiResponse.success("查询成功", connections); + } catch (Exception e) { + return ApiResponse.error("查询失败: " + e.getMessage()); + } + } + + @GetMapping("/{id}") + public ApiResponse getConnection(@PathVariable Long id) { + try { + Connection connection = connectionService.getConnectionById(id); + if (connection != null) { + return ApiResponse.success("查询成功", connection); + } else { + return ApiResponse.error("连接不存在"); + } + } catch (Exception e) { + return ApiResponse.error("查询失败: " + e.getMessage()); + } + } + + @DeleteMapping("/{id}") + public ApiResponse deleteConnection(@PathVariable Long id) { + try { + connectionService.deleteConnection(id); + return ApiResponse.success("删除成功", null); + } catch (Exception e) { + return ApiResponse.error("删除失败: " + e.getMessage()); + } + } + + @GetMapping("/active") + public ApiResponse> getActiveConnections() { + try { + return ApiResponse.success("查询成功", sessionManager.getAllActiveConnections()); + } catch (Exception e) { + return ApiResponse.error("查询失败: " + e.getMessage()); + } + } +} +``` + +## 3.3 API接口说明 + +### 接口列表 + +| 方法 | 路径 | 说明 | 请求参数 | +|------|------|------|----------| +| POST | /api/connection/connect | 建立SFTP连接 | ConnectionRequest | +| POST | /api/connection/disconnect | 断开连接 | sessionId | +| POST | /api/connection/save | 保存连接配置 | Connection | +| GET | /api/connection/list | 获取所有保存的连接 | - | +| GET | /api/connection/{id} | 获取指定连接 | 路径参数id | +| DELETE | /api/connection/{id} | 删除连接配置 | 路径参数id | +| GET | /api/connection/active | 获取所有活跃连接 | - | + +### 请求/响应示例 + +#### 1. 建立连接 + +**请求:** +```json +POST /api/connection/connect +{ + "name": "测试服务器", + "host": "192.168.1.100", + "port": 22, + "username": "root", + "password": "123456" +} +``` + +**响应:** +```json +{ + "success": true, + "message": "连接成功", + "data": "sftp-12345678-1234-1234-1234-123456789abc" +} +``` + +#### 2. 保存连接配置 + +**请求:** +```json +POST /api/connection/save +{ + "name": "测试服务器", + "host": "192.168.1.100", + "port": 22, + "username": "root", + "password": "encrypted_password" +} +``` + +**响应:** +```json +{ + "success": true, + "message": "保存成功", + "data": { + "id": 1, + "name": "测试服务器", + "host": "192.168.1.100", + "port": 22, + "username": "root", + "createdAt": "2024-02-02T10:00:00", + "updatedAt": "2024-02-02T10:00:00" + } +} +``` + +#### 3. 获取连接列表 + +**请求:** +``` +GET /api/connection/list +``` + +**响应:** +```json +{ + "success": true, + "message": "查询成功", + "data": [ + { + "id": 1, + "name": "测试服务器", + "host": "192.168.1.100", + "port": 22, + "username": "root", + "createdAt": "2024-02-02T10:00:00", + "updatedAt": "2024-02-02T10:00:00" + } + ] +} +``` + +#### 4. 获取活跃连接 + +**请求:** +``` +GET /api/connection/active +``` + +**响应:** +```json +{ + "success": true, + "message": "查询成功", + "data": { + "sftp-12345678-1234-1234-1234-123456789abc": { + "name": "测试服务器", + "host": "192.168.1.100", + "port": 22, + "username": "root" + } + } +} +``` + +## 3.4 关键技术点 + +### 3.4.1 JSch连接配置 + +- **StrictHostKeyChecking=no**:跳过主机密钥验证(仅用于开发环境) +- **连接超时**:配置合理的超时时间 +- **双重认证**:支持密码和私钥两种认证方式 + +### 3.4.2 会话管理机制 + +**使用ConcurrentHashMap存储活跃会话:** +- `activeSessions`:存储sessionId到ChannelSftp的映射 +- `sessionConnections`:存储sessionId到Connection的映射 +- 线程安全:使用ConcurrentHashMap确保多线程安全 + +### 3.4.3 会话ID规则 + +- "local":表示本地文件系统 +- "sftp-{uuid}":表示SFTP连接会话 + +### 3.4.4 错误处理 + +- **连接超时**:自动重试(最多3次) +- **认证失败**:返回明确错误信息 +- **网络异常**:捕获并友好提示 + +## 实施步骤 + +1. **创建Repository接口** + ``` + touch src/main/java/com/sftp/manager/repository/ConnectionRepository.java + ``` + +2. **创建SessionManager服务** + ``` + touch src/main/java/com/sftp/manager/service/SessionManager.java + ``` + +3. **创建ConnectionService服务** + ``` + touch src/main/java/com/sftp/manager/service/ConnectionService.java + ``` + +4. **创建ConnectionController控制器** + ``` + touch src/main/java/com/sftp/manager/controller/ConnectionController.java + ``` + +5. **编译测试** + ``` + mvn clean compile + ``` + +6. **启动服务并测试** + ``` + mvn spring-boot:run + ``` + +## 测试验证 + +使用Postman或curl测试以下API: + +1. **保存连接配置** + ``` + curl -X POST http://localhost:8080/sftp-manager/api/connection/save \ + -H "Content-Type: application/json" \ + -d '{"name":"测试","host":"192.168.1.100","port":22,"username":"root","password":"123456"}' + ``` + +2. **获取连接列表** + ``` + curl http://localhost:8080/sftp-manager/api/connection/list + ``` + +3. **建立连接** + ``` + curl -X POST http://localhost:8080/sftp-manager/api/connection/connect \ + -H "Content-Type: application/json" \ + -d '{"name":"测试","host":"192.168.1.100","port":22,"username":"root","password":"123456"}' + ``` + +## 注意事项 + +1. **密码安全**:生产环境应加密存储密码 +2. **连接池**:可考虑使用连接池管理SFTP连接 +3. **心跳检测**:定期检测连接状态,自动重连 +4. **资源清理**:确保连接断开时正确释放资源 + +## 下一步 + +完成模块03后,继续模块04:文件浏览功能 diff --git a/docs/04-文件浏览功能.md b/docs/04-文件浏览功能.md new file mode 100644 index 0000000..8e0fc9c --- /dev/null +++ b/docs/04-文件浏览功能.md @@ -0,0 +1,632 @@ +# 模块04:文件浏览功能 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 4.1 功能概述 +实现本地文件系统和SFTP服务器文件系统的浏览功能,支持目录导航、文件列表展示。 + +## 4.2 后端设计 + +### 4.2.1 LocalFileService本地文件服务 + +```java +package com.sftp.manager.service; + +import com.sftp.manager.model.FileInfo; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +@Service +public class LocalFileService { + + public List listFiles(String path) throws Exception { + List files = new ArrayList<>(); + File directory = new File(path); + + if (!directory.exists() || !directory.isDirectory()) { + throw new Exception("目录不存在: " + path); + } + + File[] fileArray = directory.listFiles(); + if (fileArray != null) { + for (File file : fileArray) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setName(file.getName()); + fileInfo.setPath(file.getAbsolutePath()); + fileInfo.setSize(file.length()); + fileInfo.setIsDirectory(file.isDirectory()); + + // 获取修改时间 + BasicFileAttributes attrs = Files.readAttributes( + file.toPath(), BasicFileAttributes.class); + fileInfo.setModifiedTime(LocalDateTime.ofInstant( + attrs.lastModifiedTime().toInstant(), + ZoneId.systemDefault())); + + // 设置权限(仅Windows) + if (file.canRead() && file.canWrite()) { + fileInfo.setPermissions("-rw-r--r--"); + } else if (file.canRead()) { + fileInfo.setPermissions("-r--r--r--"); + } + + files.add(fileInfo); + } + } + + return files; + } + + public boolean fileExists(String path) { + return new File(path).exists(); + } + + public FileInfo getFileInfo(String path) throws Exception { + File file = new File(path); + if (!file.exists()) { + throw new Exception("文件不存在: " + path); + } + + FileInfo fileInfo = new FileInfo(); + fileInfo.setName(file.getName()); + fileInfo.setPath(file.getAbsolutePath()); + fileInfo.setSize(file.length()); + fileInfo.setIsDirectory(file.isDirectory()); + + BasicFileAttributes attrs = Files.readAttributes( + file.toPath(), BasicFileAttributes.class); + fileInfo.setModifiedTime(LocalDateTime.ofInstant( + attrs.lastModifiedTime().toInstant(), + ZoneId.systemDefault())); + + return fileInfo; + } + + public String getParentPath(String path) { + File file = new File(path); + return file.getParent(); + } +} +``` + +### 4.2.2 SftpService SFTP文件服务 + +```java +package com.sftp.manager.service; + +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.SftpException; +import com.sftp.manager.model.FileInfo; +import com.sftp.manager.service.SessionManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Vector; + +@Service +public class SftpService { + + @Autowired + private SessionManager sessionManager; + + public List listFiles(String sessionId, String path) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + List files = new ArrayList<>(); + Vector entries = channel.ls(path); + + for (ChannelSftp.LsEntry entry : entries) { + String fileName = entry.getFilename(); + + // 跳过.和.. + if (".".equals(fileName) || "..".equals(fileName)) { + continue; + } + + FileInfo fileInfo = new FileInfo(); + fileInfo.setName(fileName); + fileInfo.setPath(path.endsWith("/") ? path + fileName : path + "/" + fileName); + + ChannelSftp.LsEntry attrs = entry; + fileInfo.setSize(attrs.getSize()); + fileInfo.setIsDirectory(attrs.getAttrs().isDir()); + + // 获取修改时间 + int mtime = attrs.getAttrs().getMTime(); + fileInfo.setModifiedTime(LocalDateTime.ofInstant( + Instant.ofEpochSecond(mtime), + ZoneId.systemDefault())); + + // 获取权限 + fileInfo.setPermissions(attrs.getAttrs().getPermissionsString()); + + files.add(fileInfo); + } + + return files; + } catch (SftpException e) { + throw new Exception("列出文件失败: " + e.getMessage(), e); + } + } + + public String pwd(String sessionId) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + return channel.pwd(); + } catch (SftpException e) { + throw new Exception("获取当前路径失败: " + e.getMessage(), e); + } + } + + public void cd(String sessionId, String path) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + channel.cd(path); + } catch (SftpException e) { + throw new Exception("切换目录失败: " + e.getMessage(), e); + } + } + + public FileInfo getFileInfo(String sessionId, String path) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + ChannelSftp.LsEntry entry = channel.stat(path); + FileInfo fileInfo = new FileInfo(); + fileInfo.setName(entry.getFilename()); + fileInfo.setPath(path); + fileInfo.setSize(entry.getSize()); + fileInfo.setIsDirectory(entry.getAttrs().isDir()); + + int mtime = entry.getAttrs().getMTime(); + fileInfo.setModifiedTime(LocalDateTime.ofInstant( + Instant.ofEpochSecond(mtime), + ZoneId.systemDefault())); + + fileInfo.setPermissions(entry.getAttrs().getPermissionsString()); + + return fileInfo; + } catch (SftpException e) { + throw new Exception("获取文件信息失败: " + e.getMessage(), e); + } + } +} +``` + +### 4.2.3 FileController文件控制器 + +```java +package com.sftp.manager.controller; + +import com.sftp.manager.dto.ApiResponse; +import com.sftp.manager.dto.FileListRequest; +import com.sftp.manager.dto.FileOperationRequest; +import com.sftp.manager.model.FileInfo; +import com.sftp.manager.service.LocalFileService; +import com.sftp.manager.service.SftpService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/files") +@CrossOrigin(origins = "*") +public class FileController { + + @Autowired + private LocalFileService localFileService; + + @Autowired + private SftpService sftpService; + + @PostMapping("/list") + public ApiResponse> listFiles(@RequestBody FileListRequest request) { + try { + String sessionId = request.getSessionId(); + String path = request.getPath(); + + List files; + if ("local".equals(sessionId)) { + files = localFileService.listFiles(path); + } else { + files = sftpService.listFiles(sessionId, path); + } + + return ApiResponse.success("查询成功", files); + } catch (Exception e) { + return ApiResponse.error("列出文件失败: " + e.getMessage()); + } + } + + @PostMapping("/info") + public ApiResponse getFileInfo(@RequestBody FileOperationRequest request) { + try { + String sessionId = request.getSessionId(); + String path = request.getPath(); + + FileInfo fileInfo; + if ("local".equals(sessionId)) { + fileInfo = localFileService.getFileInfo(path); + } else { + fileInfo = sftpService.getFileInfo(sessionId, path); + } + + return ApiResponse.success("查询成功", fileInfo); + } catch (Exception e) { + return ApiResponse.error("获取文件信息失败: " + e.getMessage()); + } + } + + @GetMapping("/path") + public ApiResponse> getCurrentPath(@RequestParam String sessionId) { + try { + Map result = new java.util.HashMap<>(); + if ("local".equals(sessionId)) { + result.put("path", System.getProperty("user.home")); + } else { + String path = sftpService.pwd(sessionId); + result.put("path", path); + } + return ApiResponse.success("查询成功", result); + } catch (Exception e) { + return ApiResponse.error("获取路径失败: " + e.getMessage()); + } + } +} +``` + +## 4.3 前端设计 + +### 4.3.1 文件列表展示 + +```html +
+
+ + +
+
+ + +
+
+ +
+
+``` + +### 4.3.2 CSS样式 + +```css +.file-list { + flex: 1; + overflow-y: auto; + background-color: #fff; +} + +.file-item { + padding: 8px 10px; + cursor: pointer; + display: flex; + align-items: center; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s; +} + +.file-item:hover { + background-color: #f8f9fa; +} + +.file-item.selected { + background-color: #007bff; + color: white; +} + +.file-icon { + margin-right: 10px; + width: 20px; + text-align: center; +} + +.file-name { + flex: 1; +} + +.file-size { + margin-left: 10px; + font-size: 12px; + color: #666; +} + +.file-date { + margin-left: 10px; + font-size: 12px; + color: #666; +} +``` + +### 4.3.3 JavaScript实现 + +```javascript +// 文件列表加载 +function loadFiles(panelId) { + const sessionId = panelState[panelId].sessionId; + const path = panelState[panelId].currentPath; + + $.ajax({ + url: '/api/files/list', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sessionId: sessionId, + path: path + }), + success: function(response) { + if (response.success) { + renderFileList(panelId, response.data); + updatePathInput(panelId, path); + } else { + alert(response.message); + } + }, + error: handleError + }); +} + +// 渲染文件列表 +function renderFileList(panelId, files) { + const fileList = $(`#${panelId}-file-list`); + fileList.empty(); + + files.forEach(file => { + const icon = file.isDirectory ? '📁' : '📄'; + const size = file.isDirectory ? '' : formatFileSize(file.size); + const date = formatDate(file.modifiedTime); + + const item = $(` +
+ ${icon} + ${file.name} + ${size} + ${date} +
+ `); + + item.on('click', function() { + selectFile(panelId, $(this)); + }); + + item.on('dblclick', function() { + if (file.isDirectory) { + enterDirectory(panelId, file.path); + } + }); + + fileList.append(item); + }); +} + +// 进入目录 +function enterDirectory(panelId, path) { + panelState[panelId].currentPath = path; + loadFiles(panelId); +} + +// 返回上级目录 +function goUp(panelId) { + const currentPath = panelState[panelId].currentPath; + const parentPath = getParentPath(currentPath); + + if (parentPath && parentPath !== currentPath) { + panelState[panelId].currentPath = parentPath; + loadFiles(panelId); + } +} + +// 选择文件 +function selectFile(panelId, element) { + $(`#${panelId}-file-list .file-item`).removeClass('selected'); + element.addClass('selected'); + + const path = element.data('path'); + panelState[panelId].selectedFiles = [path]; +} + +// 格式化文件大小 +function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +} + +// 格式化日期 +function formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleString('zh-CN'); +} + +// 获取父路径 +function getParentPath(path) { + if (path === '/' || path.indexOf('/') === -1) { + return path; + } + return path.substring(0, path.lastIndexOf('/')); +} + +// 更新路径输入框 +function updatePathInput(panelId, path) { + $(`#${panelId}-path`).val(path); +} +``` + +## 4.4 特殊处理 + +### 4.4.1 路径分隔符处理 + +- Windows系统:使用反斜杠(\) +- Linux/SFTP系统:使用正斜杠(/) +- 统一显示格式,底层自动转换 + +### 4.4.2 隐藏文件处理 + +- 本地文件:可选择过滤以.开头的文件(Linux/Mac) +- SFTP文件:由服务器返回 + +### 4.4.3 文件图标 + +根据文件类型显示不同图标: +- 目录:📁 +- 普通文件:📄 +- 图片:🖼️ +- 视频文件:🎬 +- 音频文件:🎵 +- 压缩文件:📦 +- 代码文件:💻 + +## 实施步骤 + +1. **创建LocalFileService服务** + ``` + touch src/main/java/com/sftp/manager/service/LocalFileService.java + ``` + +2. **创建SftpService服务** + ``` + touch src/main/java/com/sftp/manager/service/SftpService.java + ``` + +3. **创建FileController控制器** + ``` + touch src/main/java/com/sftp/manager/controller/FileController.java + ``` + +4. **编译测试** + ``` + mvn clean compile + ``` + +5. **启动服务** + ``` + mvn spring-boot:run + ``` + +## 测试验证 + +### 1. 测试本地文件列表 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/list \ + -H "Content-Type: application/json" \ + -d '{"sessionId":"local","path":"C:/Users"}' +``` + +### 2. 测试SFTP文件列表 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/list \ + -H "Content-Type: application/json" \ + -d '{"sessionId":"sftp-uuid","path":"/home/user"}' +``` + +### 3. 获取当前路径 + +```bash +curl "http://localhost:8080/sftp-manager/api/files/path?sessionId=local" +``` + +## 注意事项 + +1. **权限检查**:确保有权限访问指定路径 +2. **路径安全**:防止路径遍历攻击 +3. **性能优化**:大目录可考虑分页加载 +4. **错误处理**:友好提示文件不存在或无权限 + +## 下一步 + +完成模块04后,继续模块05:文件上传下载功能 diff --git a/docs/05-文件上传下载功能.md b/docs/05-文件上传下载功能.md new file mode 100644 index 0000000..59a4cb7 --- /dev/null +++ b/docs/05-文件上传下载功能.md @@ -0,0 +1,591 @@ +# 模块05:文件上传下载功能 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 5.1 功能概述 +实现本地与SFTP服务器之间、以及两个SFTP服务器之间的文件上传和下载功能。 + +## 5.2 后端设计 + +### 5.2.1 SftpService扩展方法 + +```java +// 上传文件到SFTP +public void uploadFile(String sessionId, InputStream inputStream, + String remotePath, long fileSize) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + channel.put(inputStream, remotePath); + } catch (SftpException e) { + throw new Exception("上传失败: " + e.getMessage(), e); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (Exception e) { + // 忽略关闭异常 + } + } + } +} + +// 从SFTP下载文件 +public void downloadFile(String sessionId, String remotePath, + OutputStream outputStream) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + channel.get(remotePath, outputStream); + } catch (SftpException e) { + throw new Exception("下载失败: " + e.getMessage(), e); + } finally { + if (outputStream != null) { + try { + outputStream.close(); + } catch (Exception e) { + // 忽略关闭异常 + } + } + } +} + +// SFTP间传输 +public void transferBetweenSftp(String sourceSessionId, String sourcePath, + String targetSessionId, String targetPath) throws Exception { + // 创建临时文件 + String tempDir = System.getProperty("java.io.tmpdir"); + String tempFile = tempDir + File.separator + UUID.randomUUID().toString(); + + try { + // 从源SFTP下载到临时文件 + ChannelSftp sourceChannel = sessionManager.getSession(sourceSessionId); + if (sourceChannel == null) { + throw new Exception("源会话不存在或已断开"); + } + + sourceChannel.get(sourcePath, tempFile); + + // 上传临时文件到目标SFTP + ChannelSftp targetChannel = sessionManager.getSession(targetSessionId); + if (targetChannel == null) { + throw new Exception("目标会话不存在或已断开"); + } + + targetChannel.put(tempFile, targetPath); + + } finally { + // 删除临时文件 + File file = new File(tempFile); + if (file.exists()) { + file.delete(); + } + } +} +``` + +### 5.2.2 LocalFileService扩展方法 + +```java +// 上传本地文件到SFTP +public void uploadToSftp(String localPath, String sessionId, + String remotePath, SftpService sftpService) throws Exception { + File file = new File(localPath); + if (!file.exists()) { + throw new Exception("本地文件不存在: " + localPath); + } + + try (InputStream inputStream = new FileInputStream(file)) { + sftpService.uploadFile(sessionId, inputStream, remotePath, file.length()); + } +} + +// 从SFTP下载到本地 +public void downloadFromSftp(String sessionId, String remotePath, + String localPath, SftpService sftpService) throws Exception { + File file = new File(localPath); + File parentDir = file.getParentFile(); + + // 确保父目录存在 + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + + try (OutputStream outputStream = new FileOutputStream(file)) { + sftpService.downloadFile(sessionId, remotePath, outputStream); + } +} +``` + +### 5.2.3 FileController扩展接口 + +```java +@Autowired +private LocalFileService localFileService; + +// 上传文件 +@PostMapping("/upload") +public ApiResponse uploadFile(@RequestParam("file") MultipartFile file, + @RequestParam("targetSessionId") String targetSessionId, + @RequestParam("targetPath") String targetPath) { + try { + if ("local".equals(targetSessionId)) { + // 上传到本地 + File destFile = new File(targetPath, file.getOriginalFilename()); + file.transferTo(destFile); + } else { + // 上传到SFTP + try (InputStream inputStream = file.getInputStream()) { + String remotePath = targetPath.endsWith("/") ? + targetPath + file.getOriginalFilename() : + targetPath + "/" + file.getOriginalFilename(); + sftpService.uploadFile(targetSessionId, inputStream, remotePath, file.getSize()); + } + } + return ApiResponse.success("上传成功", null); + } catch (Exception e) { + return ApiResponse.error("上传失败: " + e.getMessage()); + } +} + +// 下载文件 +@GetMapping("/download") +public void downloadFile(@RequestParam String sessionId, + @RequestParam String path, + HttpServletResponse response) { + try { + if ("local".equals(sessionId)) { + // 下载本地文件 + File file = new File(path); + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", + "attachment; filename=" + URLEncoder.encode(file.getName(), "UTF-8")); + response.setContentLengthLong(file.length()); + + try (InputStream inputStream = new FileInputStream(file); + OutputStream outputStream = response.getOutputStream()) { + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + } + } else { + // 下载SFTP文件 + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + FileInfo fileInfo = sftpService.getFileInfo(sessionId, path); + + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", + "attachment; filename=" + URLEncoder.encode(fileInfo.getName(), "UTF-8")); + response.setContentLengthLong(fileInfo.getSize()); + + try (OutputStream outputStream = response.getOutputStream()) { + sftpService.downloadFile(sessionId, path, outputStream); + outputStream.flush(); + } + } + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + try { + response.getWriter().write("下载失败: " + e.getMessage()); + } catch (Exception ex) { + // 忽略 + } + } +} + +// 服务器间传输 +@PostMapping("/transfer") +public ApiResponse transferFiles(@RequestBody TransferRequest request) { + try { + String sourceSessionId = request.getSourceSessionId(); + String sourcePath = request.getSourcePath(); + String targetSessionId = request.getTargetSessionId(); + String targetPath = request.getTargetPath(); + + // 获取源文件名 + String fileName; + if ("local".equals(sourceSessionId)) { + File file = new File(sourcePath); + fileName = file.getName(); + } else { + FileInfo fileInfo = sftpService.getFileInfo(sourceSessionId, sourcePath); + fileName = fileInfo.getName(); + } + + // 构建目标路径 + String finalTargetPath = targetPath.endsWith("/") ? + targetPath + fileName : + targetPath + "/" + fileName; + + if ("local".equals(sourceSessionId) && "local".equals(targetSessionId)) { + // 本地到本地 + Files.copy(new File(sourcePath).toPath(), new File(finalTargetPath).toPath()); + } else if ("local".equals(sourceSessionId)) { + // 本地到SFTP + localFileService.uploadToSftp(sourcePath, targetSessionId, finalTargetPath, sftpService); + } else if ("local".equals(targetSessionId)) { + // SFTP到本地 + localFileService.downloadFromSftp(sourceSessionId, sourcePath, finalTargetPath, sftpService); + } else { + // SFTP到SFTP + sftpService.transferBetweenSftp(sourceSessionId, sourcePath, + targetSessionId, finalTargetPath); + } + + return ApiResponse.success("传输成功", null); + } catch (Exception e) { + return ApiResponse.error("传输失败: " + e.getMessage()); + } +} +``` + +## 5.3 前端设计 + +### 5.3.1 上传界面 + +```html +
+ + +
+

拖拽文件到此处或点击选择文件

+
+ +
+``` + +### 5.3.2 上传实现 + +```javascript +// 文件选择 +document.getElementById('file-input').addEventListener('change', function(e) { + const files = e.target.files; + uploadFiles(files); +}); + +// 拖拽上传 +const dropZone = document.getElementById('drop-zone'); + +dropZone.addEventListener('dragover', function(e) { + e.preventDefault(); + dropZone.style.backgroundColor = '#f0f0f0'; +}); + +dropZone.addEventListener('dragleave', function(e) { + e.preventDefault(); + dropZone.style.backgroundColor = ''; +}); + +dropZone.addEventListener('drop', function(e) { + e.preventDefault(); + dropZone.style.backgroundColor = ''; + + const files = e.dataTransfer.files; + uploadFiles(files); +}); + +// 上传文件 +function uploadFiles(files) { + const targetPanelId = getTargetPanelId(); // 获取目标面板ID + const targetSessionId = panelState[targetPanelId].sessionId; + const targetPath = panelState[targetPanelId].currentPath; + + Array.from(files).forEach(file => { + uploadSingleFile(file, targetSessionId, targetPath); + }); +} + +// 上传单个文件 +function uploadSingleFile(file, targetSessionId, targetPath) { + let formData = new FormData(); + formData.append('file', file); + formData.append('targetSessionId', targetSessionId); + formData.append('targetPath', targetPath); + + showUploadProgress(true); + + $.ajax({ + url: '/api/files/upload', + method: 'POST', + data: formData, + processData: false, + contentType: false, + xhr: function() { + let xhr = new window.XMLHttpRequest(); + xhr.upload.addEventListener('progress', function(e) { + if (e.lengthComputable) { + let percent = Math.round((e.loaded / e.total) * 100); + updateUploadProgress(percent); + } + }); + return xhr; + }, + success: function(response) { + if (response.success) { + alert(file.name + ' 上传成功'); + loadFiles(targetPanelId); + } else { + alert('上传失败: ' + response.message); + } + }, + error: function(xhr, status, error) { + alert('上传失败: ' + error); + }, + complete: function() { + showUploadProgress(false); + } + }); +} + +// 显示上传进度 +function showUploadProgress(show) { + const progressDiv = document.getElementById('upload-progress'); + progressDiv.style.display = show ? 'block' : 'none'; +} + +// 更新上传进度 +function updateUploadProgress(percent) { + const progressBar = document.querySelector('#upload-progress .progress-bar'); + progressBar.style.width = percent + '%'; + progressBar.textContent = percent + '%'; +} +``` + +### 5.3.3 下载实现 + +```javascript +// 下载文件 +function downloadFile(sessionId, path) { + window.location.href = '/api/files/download?sessionId=' + + encodeURIComponent(sessionId) + + '&path=' + encodeURIComponent(path); +} + +// 批量下载 +function downloadSelectedFiles(panelId) { + const selectedFiles = panelState[panelId].selectedFiles; + selectedFiles.forEach(path => { + downloadFile(panelState[panelId].sessionId, path); + }); +} +``` + +### 5.3.4 跨服务器传输 + +```javascript +// 传输到对面面板 +function transferToOppositePanel() { + const sourcePanelId = getSourcePanelId(); + const targetPanelId = getTargetPanelId(); + + const sourceSessionId = panelState[sourcePanelId].sessionId; + const targetSessionId = panelState[targetPanelId].sessionId; + const targetPath = panelState[targetPanelId].currentPath; + + const selectedFiles = panelState[sourcePanelId].selectedFiles; + + if (selectedFiles.length === 0) { + alert('请先选择要传输的文件'); + return; + } + + selectedFiles.forEach(sourcePath => { + $.ajax({ + url: '/api/files/transfer', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sourceSessionId: sourceSessionId, + sourcePath: sourcePath, + targetSessionId: targetSessionId, + targetPath: targetPath + }), + success: function(response) { + if (response.success) { + alert('传输成功'); + loadFiles(targetPanelId); + } else { + alert('传输失败: ' + response.message); + } + }, + error: function(xhr, status, error) { + alert('传输失败: ' + error); + } + }); + }); +} +``` + +## 5.4 性能优化 + +### 5.4.1 大文件处理 + +- 使用流式传输避免内存溢出 +- 设置合理的超时时间 +- 显示实时进度 +- 支持断点续传(可选) + +### 5.4.2 断点续传(高级功能) + +```java +// 支持断点的上传 +public void uploadFileWithResume(String sessionId, InputStream inputStream, + String remotePath, long fileSize, + long resumeFrom) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + if (resumeFrom > 0) { + // 断点续传,跳过已传输的字节 + inputStream.skip(resumeFrom); + channel.put(inputStream, remotePath, ChannelSftp.RESUME); + } else { + channel.put(inputStream, remotePath); + } + } catch (SftpException e) { + throw new Exception("上传失败: " + e.getMessage(), e); + } +} +``` + +### 5.4.3 并发控制 + +- 限制同时上传/下载数量 +- 队列管理机制 +- 任务取消功能 + +### 5.4.4 临时文件清理 + +- SFTP间传输后删除临时文件 +- 定期清理超时临时文件 +- 使用系统临时目录 + +## 实施步骤 + +1. **更新SftpService**:添加上传、下载、传输方法 + +2. **更新LocalFileService**:添加与SFTP交互的方法 + +3. **更新FileController**:添加上传、下载、传输接口 + +4. **编译测试** + ``` + mvn clean compile + ``` + +5. **启动服务** + ``` + mvn spring-boot:run + ``` + +## 测试验证 + +### 1. 上传文件 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/upload \ + -F "file=@test.txt" \ + -F "targetSessionId=local" \ + -F "targetPath=C:/test" +``` + +### 2. 下载文件 + +```bash +curl "http://localhost:8080/sftp-manager/api/files/download?sessionId=local&path=C:/test/test.txt" \ + --output downloaded.txt +``` + +### 3. 服务器间传输 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/transfer \ + -H "Content-Type: application/json" \ + -d '{ + "sourceSessionId": "sftp-uuid1", + "sourcePath": "/home/source.txt", + "targetSessionId": "sftp-uuid2", + "targetPath": "/home/target/" + }' +``` + +## 注意事项 + +1. **文件大小限制**:application.yml中配置最大文件大小 +2. **超时设置**:大文件传输需要增加超时时间 +3. **磁盘空间**:确保目标位置有足够空间 +4. **权限检查**:上传/下载前检查文件权限 +5. **临时文件**:及时清理临时文件 + +## 下一步 + +完成模块05后,继续模块06:文件删除功能 diff --git a/docs/06-文件删除功能.md b/docs/06-文件删除功能.md new file mode 100644 index 0000000..280637e --- /dev/null +++ b/docs/06-文件删除功能.md @@ -0,0 +1,532 @@ +# 模块06:文件删除功能 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 6.1 功能概述 +实现删除本地文件和SFTP服务器上文件的功能,支持单个文件删除和批量删除,包含删除确认机制。 + +## 6.2 后端设计 + +### 6.2.1 LocalFileService删除方法 + +```java +// 删除单个文件或目录 +public boolean deleteFile(String path) throws Exception { + File file = new File(path); + if (!file.exists()) { + throw new Exception("文件不存在: " + path); + } + + if (file.isDirectory()) { + return deleteDirectory(file); + } else { + return file.delete(); + } +} + +// 递归删除目录 +private boolean deleteDirectory(File directory) throws Exception { + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectory(file); + } else { + if (!file.delete()) { + throw new Exception("删除文件失败: " + file.getPath()); + } + } + } + } + return directory.delete(); +} + +// 批量删除 +public BatchDeleteResult batchDelete(List paths) { + BatchDeleteResult result = new BatchDeleteResult(); + int successCount = 0; + int failCount = 0; + List failedFiles = new ArrayList<>(); + + for (String path : paths) { + try { + deleteFile(path); + successCount++; + } catch (Exception e) { + failCount++; + failedFiles.add(path + " - " + e.getMessage()); + } + } + + result.setSuccessCount(successCount); + result.setFailCount(failCount); + result.setFailedFiles(failedFiles); + return result; +} + +// 批量删除结果类 +public static class BatchDeleteResult { + private int successCount; + private int failCount; + private List failedFiles; + + // getters and setters +} +``` + +### 6.2.2 SftpService删除方法 + +```java +// 删除单个文件或目录 +public boolean deleteFile(String sessionId, String path) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + FileInfo fileInfo = getFileInfo(sessionId, path); + if (fileInfo.isDirectory()) { + deleteDirectoryRecursive(sessionId, path); + } else { + channel.rm(path); + } + return true; + } catch (SftpException e) { + throw new Exception("删除失败: " + e.getMessage(), e); + } +} + +// 递归删除SFTP目录 +private void deleteDirectoryRecursive(String sessionId, String path) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + // 列出目录内容 + Vector entries = channel.ls(path); + + for (ChannelSftp.LsEntry entry : entries) { + String fileName = entry.getFilename(); + if (".".equals(fileName) || "..".equals(fileName)) { + continue; + } + + String fullPath = path.endsWith("/") ? + path + fileName : + path + "/" + fileName; + + if (entry.getAttrs().isDir()) { + // 递归删除子目录 + deleteDirectoryRecursive(sessionId, fullPath); + } else { + // 删除文件 + channel.rm(fullPath); + } + } + + // 删除空目录 + channel.rmdir(path); + } catch (SftpException e) { + throw new Exception("删除目录失败: " + e.getMessage(), e); + } +} + +// 批量删除 +public BatchDeleteResult batchDelete(String sessionId, List paths) { + BatchDeleteResult result = new BatchDeleteResult(); + int successCount = 0; + int failCount = 0; + List failedFiles = new ArrayList<>(); + + for (String path : paths) { + try { + deleteFile(sessionId, path); + successCount++; + } catch (Exception e) { + failCount++; + failedFiles.add(path + " - " + e.getMessage()); + } + } + + result.setSuccessCount(successCount); + result.setFailCount(failCount); + result.setFailedFiles(failedFiles); + return result; +} +``` + +### 6.2.3 FileController删除接口 + +```java +// 删除单个文件 +@DeleteMapping("/delete") +public ApiResponse deleteFile(@RequestParam String sessionId, + @RequestParam String path) { + try { + if ("local".equals(sessionId)) { + localFileService.deleteFile(path); + } else { + sftpService.deleteFile(sessionId, path); + } + return ApiResponse.success("删除成功", null); + } catch (Exception e) { + return ApiResponse.error("删除失败: " + e.getMessage()); + } +} + +// 批量删除 +@PostMapping("/batch-delete") +public ApiResponse batchDelete(@RequestBody Map request) { + try { + String sessionId = (String) request.get("sessionId"); + @SuppressWarnings("unchecked") + List paths = (List) request.get("paths"); + + LocalFileService.BatchDeleteResult result; + if ("local".equals(sessionId)) { + result = localFileService.batchDelete(paths); + } else { + result = sftpService.batchDelete(sessionId, paths); + } + + return ApiResponse.success("删除完成", result); + } catch (Exception e) { + return ApiResponse.error("批量删除失败: " + e.getMessage()); + } +} +``` + +## 6.3 前端设计 + +### 6.3.1 删除交互 + +```javascript +// 删除选中的文件 +function deleteSelectedFiles(panelId) { + const selectedFiles = panelState[panelId].selectedFiles; + + if (selectedFiles.length === 0) { + alert('请先选择要删除的文件'); + return; + } + + const sessionId = panelState[panelId].sessionId; + + // 确认对话框 + let message; + if (selectedFiles.length === 1) { + const fileName = getFileNameFromPath(selectedFiles[0]); + message = `确定要删除 "${fileName}" 吗?`; + } else { + message = `确定要删除选中的 ${selectedFiles.length} 个文件吗?`; + } + + if (confirm(message)) { + deleteFiles(sessionId, selectedFiles); + } +} + +// 删除文件 +function deleteFiles(sessionId, paths) { + if (paths.length === 1) { + // 单个删除 + $.ajax({ + url: '/api/files/delete', + method: 'DELETE', + data: { + sessionId: sessionId, + path: paths[0] + }, + success: function(response) { + if (response.success) { + alert('删除成功'); + refreshCurrentPanel(); + } else { + alert('删除失败: ' + response.message); + } + }, + error: handleError + }); + } else { + // 批量删除 + $.ajax({ + url: '/api/files/batch-delete', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sessionId: sessionId, + paths: paths + }), + success: function(response) { + if (response.success) { + const result = response.data; + let message = `成功删除 ${result.successCount} 个文件`; + if (result.failCount > 0) { + message += `,失败 ${result.failCount} 个\n`; + message += '失败详情:\n' + result.failedFiles.join('\n'); + } + alert(message); + refreshCurrentPanel(); + } else { + alert('批量删除失败: ' + response.message); + } + }, + error: handleError + }); + } +} + +// 刷新当前面板 +function refreshCurrentPanel() { + const activePanelId = getActivePanelId(); + loadFiles(activePanelId); +} + +// 从路径获取文件名 +function getFileNameFromPath(path) { + const index = path.lastIndexOf('/'); + if (index === -1) { + return path; + } + return path.substring(index + 1); +} +``` + +### 6.3.2 删除按钮 + +```html +
+ + +
+``` + +### 6.3.3 右键菜单删除 + +```javascript +// 文件列表右键菜单 +function showContextMenu(event, panelId, path) { + event.preventDefault(); + + // 创建右键菜单 + const menu = $('
'); + menu.css({ + position: 'absolute', + left: event.pageX + 'px', + top: event.pageY + 'px' + }); + + menu.append(''); + menu.append(''); + + $('body').append(menu); + + // 点击其他地方关闭菜单 + $(document).one('click', function() { + menu.remove(); + }); +} + +// 删除单个文件(通过右键菜单) +function deleteFile(panelId, path) { + const sessionId = panelState[panelId].sessionId; + const fileName = getFileNameFromPath(path); + + if (confirm(`确定要删除 "${fileName}" 吗?`)) { + deleteFiles(sessionId, [path]); + } +} +``` + +## 6.4 安全措施 + +### 6.4.1 删除确认 + +- 必须用户确认后才能执行删除 +- 显示将被删除的文件数量和名称 +- 防止误操作 + +### 6.4.2 权限检查 + +```java +// 检查文件删除权限 +private void checkDeletePermission(File file) throws Exception { + if (!file.canWrite()) { + throw new Exception("没有删除权限: " + file.getPath()); + } + + // 检查是否为系统文件 + String systemPaths = "C:\\Windows,C:\\Program Files,C:\\System32"; + String[] paths = systemPaths.split(","); + for (String systemPath : paths) { + if (file.getPath().toLowerCase().startsWith(systemPath.toLowerCase())) { + throw new Exception("系统文件,禁止删除: " + file.getPath()); + } + } +} +``` + +### 6.4.3 操作日志 + +```java +// 记录删除操作 +@Autowired +private OperationLogService logService; + +public boolean deleteFile(String path) throws Exception { + File file = new File(path); + + try { + checkDeletePermission(file); + boolean result = deleteFileInternal(file); + + // 记录操作日志 + logService.logOperation("delete", "local", path, null, result, null); + + return result; + } catch (Exception e) { + // 记录失败日志 + logService.logOperation("delete", "local", path, null, false, e.getMessage()); + throw e; + } +} +``` + +### 6.4.4 回收站机制(可选) + +```java +// 移动到回收站而不是直接删除 +public boolean moveToRecycleBin(String path) throws Exception { + File file = new File(path); + if (!file.exists()) { + throw new Exception("文件不存在: " + path); + } + + // 创建回收站目录 + File recycleBin = new File(getRecycleBinPath()); + if (!recycleBin.exists()) { + recycleBin.mkdirs(); + } + + // 生成唯一文件名(避免重名) + String newPath = recycleBin.getPath() + File.separator + + file.getName() + "_" + System.currentTimeMillis(); + + return file.renameTo(new File(newPath)); +} + +private String getRecycleBinPath() { + return System.getProperty("user.home") + File.separator + ".sftp-manager-recycle"; +} +``` + +## 实施步骤 + +1. **更新LocalFileService**:添加删除方法 + +2. **更新SftpService**:添加删除方法 + +3. **更新FileController**:添加删除接口 + +4. **添加前端删除功能** + +5. **编译测试** + ``` + mvn clean compile + ``` + +6. **启动服务** + ``` + mvn spring-boot:run + ``` + +## 测试验证 + +### 1. 删除单个文件 + +```bash +curl -X DELETE "http://localhost:8080/sftp-manager/api/files/delete?sessionId=local&path=C:/test/file.txt" +``` + +### 2. 批量删除 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/batch-delete \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "local", + "paths": ["C:/test/file1.txt", "C:/test/file2.txt"] + }' +``` + +### 3. 删除目录 + +```bash +curl -X DELETE "http://localhost:8080/sftp-manager/api/files/delete?sessionId=local&path=C:/test/folder" +``` + +## 注意事项 + +1. **递归删除**:删除目录时需要递归删除所有子文件和子目录 +2. **权限检查**:确保有删除权限,避免删除系统文件 +3. **错误处理**:部分文件删除失败时,继续删除其他文件,最后返回结果 +4. **确认机制**:必须用户确认后才能执行删除 +5. **日志记录**:记录所有删除操作,便于审计 + +## 下一步 + +完成模块06后,继续模块07:文件重命名功能 diff --git a/docs/07-文件重命名功能.md b/docs/07-文件重命名功能.md new file mode 100644 index 0000000..a078748 --- /dev/null +++ b/docs/07-文件重命名功能.md @@ -0,0 +1,606 @@ +# 模块07:文件重命名功能 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 7.1 功能概述 +实现重命名本地文件和SFTP服务器上文件的功能,支持单个文件重命名。 + +## 7.2 后端设计 + +### 7.2.1 LocalFileService重命名方法 + +```java +// 重命名文件 +public boolean renameFile(String oldPath, String newPath) throws Exception { + File oldFile = new File(oldPath); + File newFile = new File(newPath); + + if (!oldFile.exists()) { + throw new Exception("源文件不存在: " + oldPath); + } + + if (newFile.exists()) { + throw new Exception("目标文件已存在: " + newPath); + } + + // 检查新文件名是否有效 + String newFileName = newFile.getName(); + if (!isValidFileName(newFileName)) { + throw new Exception("文件名包含非法字符: " + newFileName); + } + + boolean result = oldFile.renameTo(newFile); + if (!result) { + throw new Exception("重命名失败"); + } + + return true; +} + +// 验证文件名是否有效 +private boolean isValidFileName(String fileName) { + if (fileName == null || fileName.isEmpty()) { + return false; + } + + // Windows非法字符 + String illegalChars = "\\/:*?\"<>|"; + for (char c : illegalChars.toCharArray()) { + if (fileName.indexOf(c) != -1) { + return false; + } + } + + // 检查长度限制 + if (fileName.length() > 255) { + return false; + } + + // 检查保留名称(Windows) + String upperName = fileName.toUpperCase(); + String[] reservedNames = {"CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"}; + for (String reserved : reservedNames) { + if (upperName.equals(reserved)) { + return false; + } + } + + return true; +} + +// 获取文件扩展名 +public String getFileExtension(String fileName) { + int index = fileName.lastIndexOf('.'); + if (index == -1 || index == fileName.length() - 1) { + return ""; + } + return fileName.substring(index); +} + +// 获取文件名(不带扩展名) +public String getFileNameWithoutExtension(String fileName) { + int index = fileName.lastIndexOf('.'); + if (index == -1) { + return fileName; + } + return fileName.substring(0, index); +} +``` + +### 7.2.2 SftpService重命名方法 + +```java +// 重命名文件 +public boolean renameFile(String sessionId, String oldPath, String newPath) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + // 检查源文件是否存在 + channel.stat(oldPath); + + // 检查目标文件是否已存在 + try { + channel.stat(newPath); + throw new Exception("目标文件已存在: " + newPath); + } catch (SftpException e) { + // 文件不存在,可以重命名 + } + + // 执行重命名 + channel.rename(oldPath, newPath); + return true; + } catch (SftpException e) { + throw new Exception("重命名失败: " + e.getMessage(), e); + } +} +``` + +### 7.2.3 FileController重命名接口 + +```java +// 重命名文件 +@PostMapping("/rename") +public ApiResponse renameFile(@RequestBody Map request) { + try { + String sessionId = request.get("sessionId"); + String oldPath = request.get("oldPath"); + String newName = request.get("newName"); + + if (newName == null || newName.isEmpty()) { + return ApiResponse.error("新文件名不能为空"); + } + + // 构建新路径 + String newPath; + if ("local".equals(sessionId)) { + File oldFile = new File(oldPath); + File parentDir = oldFile.getParentFile(); + newPath = parentDir.getPath() + File.separator + newName; + localFileService.renameFile(oldPath, newPath); + } else { + // 获取父目录 + String parentPath = getParentPath(oldPath); + newPath = parentPath.endsWith("/") ? + parentPath + newName : + parentPath + "/" + newName; + sftpService.renameFile(sessionId, oldPath, newPath); + } + + return ApiResponse.success("重命名成功", null); + } catch (Exception e) { + return ApiResponse.error("重命名失败: " + e.getMessage()); + } +} + +// 获取父路径 +private String getParentPath(String path) { + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + int index = path.lastIndexOf("/"); + if (index == -1) { + return "/"; + } + return path.substring(0, index); +} +``` + +## 7.3 前端设计 + +### 7.3.1 重命名交互 + +```javascript +// 显示重命名对话框 +function showRenameDialog(panelId, path) { + const sessionId = panelState[panelId].sessionId; + const oldName = getFileNameFromPath(path); + + const newName = prompt('请输入新文件名:', oldName); + if (newName && newName !== oldName) { + renameFile(sessionId, path, newName); + } +} + +// 重命名文件 +function renameFile(sessionId, oldPath, newName) { + $.ajax({ + url: '/api/files/rename', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sessionId: sessionId, + oldPath: oldPath, + newName: newName + }), + success: function(response) { + if (response.success) { + alert('重命名成功'); + refreshCurrentPanel(); + } else { + alert('重命名失败: ' + response.message); + } + }, + error: handleError + }); +} + +// 从路径获取文件名 +function getFileNameFromPath(path) { + let separator = path.includes('\\') ? '\\' : '/'; + const index = path.lastIndexOf(separator); + if (index === -1) { + return path; + } + return path.substring(index + 1); +} + +// 刷新当前面板 +function refreshCurrentPanel() { + const activePanelId = getActivePanelId(); + loadFiles(activePanelId); +} + +// 获取当前活动面板ID +function getActivePanelId() { + return 'left'; // 默认返回左侧,可根据实际逻辑调整 +} +``` + +### 7.3.2 重命名按钮 + +```html +
+ +
+``` + +```javascript +// 获取选中文件的路径 +function getSelectedPath(panelId) { + const selectedFiles = panelState[panelId].selectedFiles; + if (selectedFiles.length === 0) { + alert('请先选择一个文件'); + return null; + } + if (selectedFiles.length > 1) { + alert('一次只能重命名一个文件'); + return null; + } + return selectedFiles[0]; +} +``` + +### 7.3.3 右键菜单重命名 + +```javascript +// 在右键菜单中添加重命名选项 +function showContextMenu(event, panelId, path) { + event.preventDefault(); + + const menu = $('
'); + menu.css({ + position: 'absolute', + left: event.pageX + 'px', + top: event.pageY + 'px' + }); + + menu.append(''); + menu.append(''); + + $('body').append(menu); + + $(document).one('click', function() { + menu.remove(); + }); +} +``` + +### 7.3.4 在线编辑(高级功能) + +```javascript +// 双击文件名进行重命名 +function enableInlineRename() { + $('.file-item').on('dblclick', function(e) { + const fileItem = $(this); + const isDirectory = fileItem.data('is-dir'); + + // 如果是目录,进入目录;如果是文件,进入重命名模式 + if (!isDirectory) { + const path = fileItem.data('path'); + showRenameDialogInline(fileItem, path); + } + }); +} + +// 在线重命名 +function showRenameDialogInline(fileItem, path) { + const nameElement = fileItem.find('.file-name'); + const currentName = nameElement.text(); + + // 创建输入框 + const input = $(''); + input.val(currentName); + + // 替换文件名显示为输入框 + nameElement.html(input); + input.focus(); + input.select(); + + // 失去焦点时保存 + input.on('blur', function() { + const newName = input.val(); + if (newName && newName !== currentName) { + renameFile(panelState.left.sessionId, path, newName); + } else { + // 取消重命名,恢复原名称 + nameElement.text(currentName); + } + }); + + // 按Enter保存 + input.on('keypress', function(e) { + if (e.which === 13) { + input.blur(); + } + }); + + // 按Esc取消 + input.on('keydown', function(e) { + if (e.which === 27) { + nameElement.text(currentName); + } + }); +} +``` + +## 7.4 输入验证 + +### 7.4.1 前端验证 + +```javascript +// 验证文件名 +function validateFileName(fileName) { + if (!fileName || fileName.trim() === '') { + alert('文件名不能为空'); + return false; + } + + // Windows非法字符 + const illegalChars = /\\\/:\*\?"<>\|/; + if (illegalChars.test(fileName)) { + alert('文件名包含非法字符'); + return false; + } + + // 长度限制 + if (fileName.length > 255) { + alert('文件名过长(最大255字符)'); + return false; + } + + return true; +} + +// 修改重命名对话框 +function showRenameDialog(panelId, path) { + const oldName = getFileNameFromPath(path); + + while (true) { + const newName = prompt('请输入新文件名:', oldName); + if (newName === null) { + // 用户取消 + return; + } + if (newName === '' || newName === oldName) { + // 无效输入 + continue; + } + + if (validateFileName(newName)) { + renameFile(panelState[panelId].sessionId, path, newName); + break; + } + } +} +``` + +### 7.4.2 保留扩展名 + +```javascript +// 提取扩展名 +function getFileExtension(fileName) { + const index = fileName.lastIndexOf('.'); + if (index === -1 || index === fileName.length - 1) { + return ''; + } + return fileName.substring(index); +} + +// 修改重命名对话框(保留扩展名) +function showRenameDialog(panelId, path) { + const sessionId = panelState[panelId].sessionId; + const oldName = getFileNameFromPath(path); + const extension = getFileExtension(oldName); + const baseName = oldName.substring(0, oldName.length - extension.length); + + const newName = prompt('请输入新文件名:', baseName); + if (newName && newName !== baseName) { + // 保留原扩展名 + const fullName = newName + extension; + renameFile(sessionId, path, fullName); + } +} +``` + +### 7.4.3 同名检查 + +```javascript +// 检查同名文件是否存在 +function checkFileExists(sessionId, parentPath, fileName, callback) { + const fullPath = parentPath.endsWith('/') ? + parentPath + fileName : + parentPath + '/' + fileName; + + $.ajax({ + url: '/api/files/info', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sessionId: sessionId, + path: fullPath + }), + success: function(response) { + // 文件存在 + callback(true); + }, + error: function() { + // 文件不存在 + callback(false); + } + }); +} + +// 重命名时检查同名 +function renameFile(sessionId, oldPath, newName) { + const parentPath = getParentPath(oldPath); + + checkFileExists(sessionId, parentPath, newName, function(exists) { + if (exists) { + if (confirm('目标文件已存在,是否覆盖?')) { + doRename(sessionId, oldPath, newName); + } + } else { + doRename(sessionId, oldPath, newName); + } + }); +} + +function doRename(sessionId, oldPath, newName) { + // 原重命名逻辑 + $.ajax({ + url: '/api/files/rename', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sessionId: sessionId, + oldPath: oldPath, + newName: newName + }), + success: function(response) { + if (response.success) { + alert('重命名成功'); + refreshCurrentPanel(); + } else { + alert('重命名失败: ' + response.message); + } + }, + error: handleError + }); +} +``` + +## 实施步骤 + +1. **更新LocalFileService**:添加重命名和验证方法 + +2. **更新SftpService**:添加重命名方法 + +3. **更新FileController**:添加重命名接口 + +4. **添加前端重命名功能** + +5. **编译测试** + ``` + mvn clean compile + ``` + +6. **启动服务** + ``` + mvn spring-boot:run + ``` + +## 测试验证 + +### 1. 重命名文件 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/rename \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "local", + "oldPath": "C:/test/old.txt", + "newName": "new.txt" + }' +``` + +### 2. 测试非法文件名 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/rename \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "local", + "oldPath": "C:/test/test.txt", + "newName": "test<.txt" + }' +``` + +### 3. 测试重名 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/rename \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "local", + "oldPath": "C:/test/file1.txt", + "newName": "file2.txt" + }' +``` + +## 注意事项 + +1. **文件名验证**:严格验证文件名,防止非法字符 +2. **同名检查**:重命名前检查目标名称是否已存在 +3. **扩展名处理**:可选择保留原扩展名 +4. **用户确认**:重名时需要用户确认是否覆盖 +5. **路径处理**:正确处理不同操作系统的路径分隔符 + +## 下一步 + +完成模块07后,继续模块08:新建文件夹功能 diff --git a/docs/08-新建文件夹功能.md b/docs/08-新建文件夹功能.md new file mode 100644 index 0000000..ffc5b8f --- /dev/null +++ b/docs/08-新建文件夹功能.md @@ -0,0 +1,686 @@ +# 模块08:新建文件夹功能 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 8.1 功能概述 +实现在本地文件系统和SFTP服务器上创建新文件夹的功能。 + +## 8.2 后端设计 + +### 8.2.1 LocalFileService创建目录方法 + +```java +// 创建目录 +public boolean createDirectory(String path) throws Exception { + File directory = new File(path); + + if (directory.exists()) { + throw new Exception("目录已存在: " + path); + } + + // 验证目录名 + String dirName = directory.getName(); + if (!isValidDirectoryName(dirName)) { + throw new Exception("目录名包含非法字符: " + dirName); + } + + // 检查父目录是否存在 + File parentDir = directory.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + // 父目录不存在,询问是否创建多级目录 + throw new Exception("父目录不存在: " + parentDir.getPath()); + } + + boolean result = directory.mkdirs(); + if (!result) { + throw new Exception("创建目录失败"); + } + + return true; +} + +// 创建多级目录 +public boolean createDirectories(String path) throws Exception { + File directory = new File(path); + + if (directory.exists()) { + throw new Exception("目录已存在: " + path); + } + + // 验证路径中的每个目录名 + String[] parts = path.split("[/\\\\]"); + for (String part : parts) { + if (!isValidDirectoryName(part)) { + throw new Exception("路径包含非法字符: " + part); + } + } + + boolean result = directory.mkdirs(); + if (!result) { + throw new Exception("创建目录失败"); + } + + return true; +} + +// 验证目录名是否有效 +private boolean isValidDirectoryName(String dirName) { + if (dirName == null || dirName.isEmpty()) { + return false; + } + + // Windows非法字符 + String illegalChars = "\\/:*?\"<>|"; + for (char c : illegalChars.toCharArray()) { + if (dirName.indexOf(c) != -1) { + return false; + } + } + + // 检查长度限制 + if (dirName.length() > 255) { + return false; + } + + // 检查保留名称(Windows) + String upperName = dirName.toUpperCase(); + String[] reservedNames = {"CON", "PRN", "AUX", "NUL"}; + for (String reserved : reservedNames) { + if (upperName.equals(reserved)) { + return false; + } + } + + return true; +} +``` + +### 8.2.2 SftpService创建目录方法 + +```java +// 创建目录 +public boolean createDirectory(String sessionId, String path) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + // 检查目录是否已存在 + try { + channel.stat(path); + throw new Exception("目录已存在: " + path); + } catch (SftpException e) { + if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) { + throw e; + } + } + + // 创建目录 + channel.mkdir(path); + return true; + } catch (SftpException e) { + throw new Exception("创建目录失败: " + e.getMessage(), e); + } +} + +// 创建多级目录 +public boolean createDirectories(String sessionId, String path) throws Exception { + ChannelSftp channel = sessionManager.getSession(sessionId); + if (channel == null) { + throw new Exception("会话不存在或已断开"); + } + + try { + // 检查目录是否已存在 + try { + channel.stat(path); + throw new Exception("目录已存在: " + path); + } catch (SftpException e) { + if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) { + throw e; + } + } + + // 递归创建多级目录 + createDirectoriesRecursive(channel, path); + return true; + } catch (SftpException e) { + throw new Exception("创建目录失败: " + e.getMessage(), e); + } +} + +// 递归创建多级目录 +private void createDirectoriesRecursive(ChannelSftp channel, String path) throws SftpException { + // 如果是根目录,直接返回 + if (path.equals("/") || path.isEmpty()) { + return; + } + + // 检查父目录是否存在 + String parentPath = getParentPath(path); + if (!parentPath.equals(path)) { + try { + channel.stat(parentPath); + } catch (SftpException e) { + if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) { + // 父目录不存在,递归创建 + createDirectoriesRecursive(channel, parentPath); + } else { + throw e; + } + } + } + + // 创建当前目录 + channel.mkdir(path); +} + +// 获取父路径 +private String getParentPath(String path) { + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + int index = path.lastIndexOf("/"); + if (index == -1) { + return "/"; + } + if (index == 0) { + return "/"; + } + return path.substring(0, index); +} +``` + +### 8.2.3 FileController创建目录接口 + +```java +// 创建目录 +@PostMapping("/mkdir") +public ApiResponse createDirectory(@RequestBody Map request) { + try { + String sessionId = request.get("sessionId"); + String path = request.get("path"); + + if (path == null || path.isEmpty()) { + return ApiResponse.error("路径不能为空"); + } + + boolean result; + if ("local".equals(sessionId)) { + result = localFileService.createDirectory(path); + } else { + result = sftpService.createDirectory(sessionId, path); + } + + if (result) { + return ApiResponse.success("创建成功", null); + } else { + return ApiResponse.error("创建失败"); + } + } catch (Exception e) { + return ApiResponse.error("创建失败: " + e.getMessage()); + } +} + +// 创建多级目录 +@PostMapping("/mkdir-p") +public ApiResponse createDirectories(@RequestBody Map request) { + try { + String sessionId = request.get("sessionId"); + String path = request.get("path"); + + if (path == null || path.isEmpty()) { + return ApiResponse.error("路径不能为空"); + } + + boolean result; + if ("local".equals(sessionId)) { + result = localFileService.createDirectories(path); + } else { + result = sftpService.createDirectories(sessionId, path); + } + + if (result) { + return ApiResponse.success("创建成功", null); + } else { + return ApiResponse.error("创建失败"); + } + } catch (Exception e) { + return ApiResponse.error("创建失败: " + e.getMessage()); + } +} +``` + +## 8.3 前端设计 + +### 8.3.1 新建文件夹交互 + +```javascript +// 显示新建文件夹对话框 +function showMkdirDialog(panelId) { + const sessionId = panelState[panelId].sessionId; + const currentPath = panelState[panelId].currentPath; + + const folderName = prompt('请输入文件夹名称:', '新建文件夹'); + if (folderName && folderName.trim() !== '') { + createDirectory(sessionId, currentPath, folderName); + } +} + +// 创建目录 +function createDirectory(sessionId, parentPath, folderName) { + // 验证文件夹名称 + if (!validateDirectoryName(folderName)) { + return; + } + + // 构建完整路径 + let fullPath; + if ("local".equals(sessionId)) { + fullPath = parentPath + File.separator + folderName; + } else { + fullPath = parentPath.endsWith('/') ? + parentPath + folderName : + parentPath + '/' + folderName; + } + + $.ajax({ + url: '/api/files/mkdir', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sessionId: sessionId, + path: fullPath + }), + success: function(response) { + if (response.success) { + alert('文件夹创建成功'); + refreshCurrentPanel(); + } else { + alert('创建失败: ' + response.message); + } + }, + error: handleError + }); +} + +// 验证目录名 +function validateDirectoryName(dirName) { + if (!dirName || dirName.trim() === '') { + alert('文件夹名称不能为空'); + return false; + } + + // Windows非法字符 + const illegalChars = /\\\/:\*\?"<>\|/; + if (illegalChars.test(dirName)) { + alert('文件夹名称包含非法字符'); + return false; + } + + // 长度限制 + if (dirName.length > 255) { + alert('文件夹名称过长(最大255字符)'); + return false; + } + + return true; +} + +// 刷新当前面板 +function refreshCurrentPanel() { + const activePanelId = getActivePanelId(); + loadFiles(activePanelId); +} +``` + +### 8.3.2 新建文件夹按钮 + +```html +
+ +
+``` + +### 8.3.3 右键菜单新建文件夹 + +```javascript +// 在右键菜单中添加新建文件夹选项 +function showContextMenu(event, panelId) { + event.preventDefault(); + + const menu = $('
'); + menu.css({ + position: 'absolute', + left: event.pageX + 'px', + top: event.pageY + 'px' + }); + + menu.append(''); + + $('body').append(menu); + + $(document).one('click', function() { + menu.remove(); + }); +} +``` + +### 8.3.4 快捷键支持 + +```javascript +// 监听键盘事件 +$(document).on('keydown', function(e) { + // Ctrl+Shift+N: 新建文件夹 + if (e.ctrlKey && e.shiftKey && e.key === 'N') { + const activePanelId = getActivePanelId(); + showMkdirDialog(activePanelId); + e.preventDefault(); + } +}); +``` + +### 8.3.5 在线输入(高级功能) + +```javascript +// 在文件列表中直接输入新文件夹名称 +function showInlineMkdir(panelId) { + const fileList = $(`#${panelId}-file-list`); + + // 创建新建文件夹项 + const newItem = $(` +
+ 📁 + + + +
+ `); + + // 插入到列表顶部 + fileList.prepend(newItem); + + // 聚焦输入框 + const input = newItem.find('.new-folder-input'); + input.focus(); + + // 失去焦点时创建 + input.on('blur', function() { + const folderName = input.val().trim(); + if (folderName) { + createDirectory(panelState[panelId].sessionId, + panelState[panelId].currentPath, + folderName); + } + newItem.remove(); + }); + + // 按Enter创建 + input.on('keypress', function(e) { + if (e.which === 13) { + input.blur(); + } + }); + + // 按Esc取消 + input.on('keydown', function(e) { + if (e.which === 27) { + newItem.remove(); + } + }); +} +``` + +## 8.4 输入验证 + +### 8.4.1 前端验证 + +```javascript +// 验证目录名 +function validateDirectoryName(dirName) { + // 基本验证 + if (!dirName || dirName.trim() === '') { + alert('文件夹名称不能为空'); + return false; + } + + // 去除首尾空格 + dirName = dirName.trim(); + + // Windows非法字符 + const illegalChars = /\\\/:\*\?"<>\|/; + if (illegalChars.test(dirName)) { + alert('文件夹名称包含非法字符: \\ / : * ? " < > |'); + return false; + } + + // 不能以点开头或结尾(Linux/Mac隐藏目录) + if (dirName.startsWith('.') || dirName.endsWith('.')) { + alert('文件夹名称不能以点开头或结尾'); + return false; + } + + // 长度限制 + if (dirName.length > 255) { + alert('文件夹名称过长(最大255字符)'); + return false; + } + + // Windows保留名称 + const upperName = dirName.toUpperCase(); + const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']; + if (reservedNames.includes(upperName)) { + alert('文件夹名称是系统保留名称'); + return false; + } + + return true; +} +``` + +### 8.4.2 后端验证 + +```java +// 验证目录名(后端) +private boolean isValidDirectoryName(String dirName) { + if (dirName == null || dirName.isEmpty()) { + return false; + } + + // 去除首尾空格 + dirName = dirName.trim(); + + // Windows非法字符 + String illegalChars = "\\/:*?\"<>|"; + for (char c : illegalChars.toCharArray()) { + if (dirName.indexOf(c) != -1) { + return false; + } + } + + // 不能以点开头或结尾 + if (dirName.startsWith(".") || dirName.endsWith(".")) { + return false; + } + + // 长度限制 + if (dirName.length() > 255) { + return false; + } + + // Windows保留名称 + String upperName = dirName.toUpperCase(); + String[] reservedNames = {"CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"}; + for (String reserved : reservedNames) { + if (upperName.equals(reserved)) { + return false; + } + } + + return true; +} +``` + +### 8.4.3 路径处理 + +```javascript +// 拼接路径 +function joinPath(parentPath, childName) { + // 移除子名称的首尾空格 + childName = childName.trim(); + + // 判断是否为本地路径 + const isLocal = parentPath.includes('\\') || + parentPath.includes('C:') || + parentPath.includes('D:'); + + if (isLocal) { + // Windows路径 + if (parentPath.endsWith('\\') || parentPath.endsWith('/')) { + return parentPath + childName; + } else { + return parentPath + '\\' + childName; + } + } else { + // Linux/SFTP路径 + if (parentPath.endsWith('/')) { + return parentPath + childName; + } else { + return parentPath + '/' + childName; + } + } +} +``` + +## 实施步骤 + +1. **更新LocalFileService**:添加创建目录方法 + +2. **更新SftpService**:添加创建目录方法 + +3. **更新FileController**:添加创建目录接口 + +4. **添加前端创建目录功能** + +5. **编译测试** + ``` + mvn clean compile + ``` + +6. **启动服务** + ``` + mvn spring-boot:run + ``` + +## 测试验证 + +### 1. 创建单级目录 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "local", + "path": "C:/test/newfolder" + }' +``` + +### 2. 创建多级目录 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir-p \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "local", + "path": "C:/test/level1/level2/level3" + }' +``` + +### 3. 在SFTP上创建目录 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "sftp-uuid", + "path": "/home/user/newfolder" + }' +``` + +### 4. 测试非法目录名 + +```bash +curl -X POST http://localhost:8080/sftp-manager/api/files/mkdir \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "local", + "path": "C:/test/folder **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 9.1 界面布局 + +### 9.1.1 整体HTML结构 + +```html + + + + + + SFTP文件管理器 + + + + +
+ + + + +
+
+ + + + + + + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+ 就绪 +
+
+ + + + + + + + + + + + +``` + +## 9.2 样式设计 + +### 9.2.1 主样式(style.css) + +```css +/* 全局样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-size: 14px; + background-color: #f5f5f5; + overflow: hidden; +} + +/* 应用容器 */ +.app-container { + display: flex; + flex-direction: column; + height: 100vh; + background-color: #fff; +} + +/* 导航栏 */ +.navbar { + flex-shrink: 0; + padding: 8px 16px; +} + +.navbar-brand { + font-size: 18px; + font-weight: 600; +} + +/* 工具栏 */ +.toolbar { + flex-shrink: 0; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 8px; +} + +/* 双面板容器 */ +.panels-container { + display: flex; + flex: 1; + overflow: hidden; +} + +/* 面板 */ +.panel { + flex: 1; + display: flex; + flex-direction: column; + border-right: 1px solid #dee2e6; + overflow: hidden; +} + +.panel:last-child { + border-right: none; +} + +/* 面板头部 */ +.panel-header { + flex-shrink: 0; + padding: 8px; + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; + display: flex; + gap: 8px; +} + +.panel-mode { + flex: 1; +} + +.connection-select { + flex: 2; +} + +/* 路径栏 */ +.path-bar { + flex-shrink: 0; + padding: 8px; + display: flex; + gap: 8px; + background-color: #fff; + border-bottom: 1px solid #dee2e6; +} + +.path-input { + flex: 1; +} + +/* 文件列表 */ +.file-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* 文件项 */ +.file-item { + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.15s; + user-select: none; +} + +.file-item:hover { + background-color: #f8f9fa; +} + +.file-item.selected { + background-color: #007bff; + color: white; +} + +.file-icon { + margin-right: 10px; + width: 20px; + text-align: center; + font-size: 16px; +} + +.file-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-size { + margin-left: 10px; + min-width: 80px; + text-align: right; + font-size: 12px; + color: #666; +} + +.file-date { + margin-left: 10px; + min-width: 140px; + text-align: right; + font-size: 12px; + color: #666; +} + +/* 选中状态下的文件大小和日期 */ +.file-item.selected .file-size, +.file-item.selected .file-date { + color: rgba(255, 255, 255, 0.8); +} + +/* 状态栏 */ +.status-bar { + flex-shrink: 0; + padding: 4px 16px; + font-size: 12px; + color: #666; +} + +/* 上下文菜单 */ +.context-menu { + position: absolute; + background-color: #fff; + border: 1px solid #ccc; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + z-index: 1000; + min-width: 120px; +} + +.menu-item { + padding: 8px 12px; + cursor: pointer; + user-select: none; +} + +.menu-item:hover { + background-color: #007bff; + color: white; +} + +/* 新建文件夹项 */ +.new-folder { + background-color: #e7f3ff; +} + +.new-folder-input { + width: 100%; + padding: 2px 4px; + border: 1px solid #007bff; + outline: none; + font-size: 14px; +} + +/* 连接列表 */ +.connection-item { + padding: 8px 12px; + border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.connection-item:hover { + background-color: #f8f9fa; +} + +.connection-name { + font-weight: 600; +} + +.connection-info { + font-size: 12px; + color: #666; +} + +.connection-actions { + display: flex; + gap: 4px; +} + +.connection-actions button { + padding: 2px 8px; + font-size: 12px; +} +``` + +## 9.3 JavaScript基础逻辑 + +### 9.3.1 状态管理(app.js) + +```javascript +// 面板状态 +const panelState = { + left: { + mode: 'local', // 'local' 或 'sftp' + sessionId: 'local', // 'local' 或 SFTP会话ID + currentPath: '', // 当前路径 + selectedFiles: [] // 选中的文件 + }, + right: { + mode: 'local', + sessionId: 'local', + currentPath: '', + selectedFiles: [] + } +}; + +// 活跃连接列表 +let activeConnections = {}; + +// 初始化 +$(document).ready(function() { + // 设置初始路径 + panelState.left.currentPath = getDefaultLocalPath(); + panelState.right.currentPath = getDefaultLocalPath(); + + // 加载文件列表 + loadFiles('left'); + loadFiles('right'); + + // 绑定键盘事件 + bindKeyboardEvents(); +}); + +// 获取默认本地路径 +function getDefaultLocalPath() { + return System.getProperty('user.home'); +} +``` + +### 9.3.2 文件列表加载 + +```javascript +// 加载文件列表 +function loadFiles(panelId) { + const sessionId = panelState[panelId].sessionId; + const path = panelState[panelId].currentPath; + + updateStatus(`正在加载 ${panelId === 'left' ? '左' : '右'}面板文件列表...`); + + $.ajax({ + url: '/api/files/list', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sessionId: sessionId, + path: path + }), + success: function(response) { + if (response.success) { + renderFileList(panelId, response.data); + updatePathInput(panelId, path); + updateStatus('就绪'); + } else { + alert('加载文件列表失败: ' + response.message); + updateStatus('错误: ' + response.message); + } + }, + error: function(xhr, status, error) { + alert('加载文件列表失败: ' + error); + updateStatus('错误: ' + error); + } + }); +} + +// 渲染文件列表 +function renderFileList(panelId, files) { + const fileList = $(`#${panelId}-file-list`); + fileList.empty(); + + if (files.length === 0) { + fileList.html('
文件夹为空
'); + return; + } + + files.forEach(file => { + const icon = getFileIcon(file.name, file.isDirectory); + const size = file.isDirectory ? '' : formatFileSize(file.size); + const date = formatDate(file.modifiedTime); + + const item = $(` +
+ ${icon} + ${file.name} + ${size} + ${date} +
+ `); + + // 单击选择 + item.on('click', function(e) { + if (!e.ctrlKey) { + $(`#${panelId}-file-list .file-item`).removeClass('selected'); + } + item.toggleClass('selected'); + updateSelectedFiles(panelId); + }); + + // 双击进入目录 + item.on('dblclick', function() { + if (file.isDirectory) { + enterDirectory(panelId, file.path); + } + }); + + // 右键菜单 + item.on('contextmenu', function(e) { + e.preventDefault(); + showContextMenu(e, panelId, file.path, file.isDirectory); + }); + + fileList.append(item); + }); +} + +// 获取文件图标 +function getFileIcon(fileName, isDirectory) { + if (isDirectory) { + return '📁'; + } + + const ext = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + const iconMap = { + 'txt': '📄', + 'doc': '📄', 'docx': '📄', + 'xls': '📊', 'xlsx': '📊', + 'ppt': '📊', 'pptx': '📊', + 'pdf': '📕', + 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', + 'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬', + 'mp3': '🎵', 'wav': '🎵', + 'zip': '📦', 'rar': '📦', '7z': '📦', + 'java': '☕', 'js': '💻', 'py': '🐍', 'php': '🐘', + 'html': '🌐', 'css': '🎨', 'json': '📋' + }; + + return iconMap[ext] || '📄'; +} + +// 格式化文件大小 +function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +} + +// 格式化日期 +function formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +// 更新路径输入框 +function updatePathInput(panelId, path) { + $(`#${panelId}-path`).val(path); +} + +// 更新选中文件列表 +function updateSelectedFiles(panelId) { + const selected = $(`#${panelId}-file-list .file-item.selected`); + panelState[panelId].selectedFiles = selected.map(function() { + return $(this).data('path'); + }).get(); +} + +// 更新状态栏 +function updateStatus(text) { + $('#status-text').text(text); +} +``` + +## 9.4 响应式设计 + +### 9.4.1 移动端适配 + +```css +/* 小屏幕设备 */ +@media (max-width: 768px) { + .panels-container { + flex-direction: column; + } + + .panel { + height: 50%; + border-right: none; + border-bottom: 1px solid #dee2e6; + } + + .file-size, + .file-date { + display: none; + } + + .toolbar { + flex-wrap: wrap; + } + + .navbar-brand { + font-size: 16px; + } +} + +/* 极小屏幕设备 */ +@media (max-width: 576px) { + .toolbar button { + padding: 2px 8px; + font-size: 12px; + } + + .file-item { + padding: 6px 8px; + } +} +``` + +## 9.5 交互设计 + +### 9.5.1 文件选择 + +```javascript +// 全选 +function selectAll(panelId) { + $(`#${panelId}-file-list .file-item`).addClass('selected'); + updateSelectedFiles(panelId); +} + +// 取消选择 +function deselectAll(panelId) { + $(`#${panelId}-file-list .file-item`).removeClass('selected'); + updateSelectedFiles(panelId); +} + +// 反选 +function invertSelection(panelId) { + $(`#${panelId}-file-list .file-item`).toggleClass('selected'); + updateSelectedFiles(panelId); +} +``` + +### 9.5.2 拖拽操作 + +```javascript +// 文件拖拽初始化 +function initDragAndDrop() { + $('.file-item').attr('draggable', true); + + // 拖拽开始 + $('.file-item').on('dragstart', function(e) { + const panelId = $(this).closest('.panel').attr('id'); + const path = $(this).data('path'); + e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({ + panelId: panelId, + path: path + })); + $(this).addClass('dragging'); + }); + + // 拖拽结束 + $('.file-item').on('dragend', function() { + $(this).removeClass('dragging'); + }); + + // 拖拽悬停 + $('.file-list').on('dragover', function(e) { + e.preventDefault(); + const panelId = $(this).closest('.panel').attr('id'); + $(this).addClass('drag-over'); + }); + + // 拖拽离开 + $('.file-list').on('dragleave', function() { + $(this).removeClass('drag-over'); + }); + + // 拖拽放置 + $('.file-list').on('drop', function(e) { + e.preventDefault(); + $(this).removeClass('drag-over'); + + try { + const data = JSON.parse(e.originalEvent.dataTransfer.getData('text/plain')); + const targetPanelId = $(this).closest('.panel').attr('id'); + handleFileDrop(data, targetPanelId); + } catch (err) { + console.error('拖拽失败:', err); + } + }); +} + +// 处理文件拖放 +function handleFileDrop(data, targetPanelId) { + const sourcePanelId = data.panelId; + const sourcePath = data.path; + const targetPath = panelState[targetPanelId].currentPath; + + if (sourcePanelId === targetPanelId) { + // 同一面板,可以移动文件 + // 这里可以实现文件移动功能 + alert('文件移动功能开发中...'); + } else { + // 跨面板传输 + $.ajax({ + url: '/api/files/transfer', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + sourceSessionId: panelState[sourcePanelId].sessionId, + sourcePath: sourcePath, + targetSessionId: panelState[targetPanelId].sessionId, + targetPath: targetPath + }), + success: function(response) { + if (response.success) { + alert('传输成功'); + loadFiles(targetPanelId); + } else { + alert('传输失败: ' + response.message); + } + }, + error: handleError + }); + } +} +``` + +### 9.5.3 快捷键支持 + +```javascript +// 绑定键盘事件 +function bindKeyboardEvents() { + $(document).on('keydown', function(e) { + // Delete: 删除选中文件 + if (e.key === 'Delete') { + deleteSelectedFiles(getActivePanelId()); + e.preventDefault(); + } + + // F2: 重命名 + if (e.key === 'F2') { + const panelId = getActivePanelId(); + const selected = panelState[panelId].selectedFiles; + if (selected.length === 1) { + showRenameDialog(panelId, selected[0]); + } + e.preventDefault(); + } + + // F5: 刷新 + if (e.key === 'F5') { + refreshPanels(); + e.preventDefault(); + } + + // Backspace: 返回上级目录 + if (e.key === 'Backspace') { + const panelId = getActivePanelId(); + goUp(panelId); + e.preventDefault(); + } + + // Ctrl+A: 全选 + if (e.ctrlKey && (e.key === 'a' || e.key === 'A')) { + selectAll(getActivePanelId()); + e.preventDefault(); + } + + // Esc: 取消选择 + if (e.key === 'Escape') { + const panelId = getActivePanelId(); + deselectAll(panelId); + e.preventDefault(); + } + + // Ctrl+Shift+N: 新建文件夹 + if (e.ctrlKey && e.shiftKey && (e.key === 'n' || e.key === 'N')) { + showMkdirDialog(getActivePanelId()); + e.preventDefault(); + } + }); +} + +// 获取当前活动面板 +function getActivePanelId() { + // 简单实现:返回左侧面板 + // 可以根据鼠标位置等逻辑判断 + return 'left'; +} +``` + +## 实施步骤 + +1. **创建index.html**:包含完整的HTML结构 + +2. **创建style.css**:编写样式文件 + +3. **创建app.js**:编写JavaScript基础逻辑 + +4. **测试界面**: + ``` + # 将文件放置到正确位置 + cp index.html src/main/resources/templates/ + cp style.css src/main/resources/static/css/ + cp app.js src/main/resources/static/js/ + + # 启动服务 + mvn spring-boot:run + ``` + +5. **访问测试**:http://localhost:8080/sftp-manager + +## 注意事项 + +1. **CDN资源**:确保网络可以访问Bootstrap和jQuery的CDN +2. **静态资源**:确保静态文件路径正确 +3. **浏览器兼容性**:测试主流浏览器兼容性 +4. **响应式**:在不同屏幕尺寸下测试界面 +5. **性能优化**:大文件列表考虑虚拟滚动 + +## 下一步 + +完成模块09后,继续模块10:模式切换功能 diff --git a/docs/10-模式切换功能.md b/docs/10-模式切换功能.md new file mode 100644 index 0000000..67cf784 --- /dev/null +++ b/docs/10-模式切换功能.md @@ -0,0 +1,714 @@ +# 模块10:模式切换功能 + +--- + +## 🎨 UI设计系统概览 + +> **完整设计系统文档请参考:** `UI设计系统.md` + +### 核心设计原则 +- **现代简约**:界面清晰,层次分明 +- **专业高效**:减少操作步骤,提升工作效率 +- **一致性**:统一的视觉语言和交互模式 +- **可访问性**:符合WCAG 2.1 AA标准 + +### 关键设计令牌 + +**颜色系统:** +- 主色:`#0d6efd`(操作按钮、选中状态) +- 成功:`#198754`(连接成功状态) +- 危险:`#dc3545`(删除操作、错误提示) +- 深灰:`#212529`(导航栏背景) +- 浅灰:`#e9ecef`(工具栏背景) + +**字体系统:** +- 字体族:系统字体栈(-apple-system, Segoe UI, Roboto等) +- 正文:14px,行高1.5 +- 标题:20-32px,行高1.2-1.4 +- 小号文字:12px(文件大小、日期等) + +**间距系统:** +- 基础单位:8px +- 标准间距:16px(1rem) +- 组件内边距:8px-16px + +**组件规范:** +- 导航栏:高度48px,深色背景 +- 工具栏:浅灰背景,按钮间距8px +- 文件项:最小高度44px,悬停效果150ms +- 按钮:圆角4px,过渡150ms + +**交互规范:** +- 悬停效果:150ms过渡 +- 触摸目标:最小44x44px +- 键盘导航:Tab、Enter、Delete、F2、F5、Esc +- 焦点状态:2px蓝色轮廓 + +**响应式断点:** +- 移动端:< 768px(双面板垂直排列) +- 平板:768px - 1024px +- 桌面:> 1024px(标准布局) + +--- + +## 10.1 功能概述 +实现左侧和右侧面板在"本地文件"和"SFTP服务器"模式之间切换,支持同时连接多个SFTP服务器。 + +## 10.2 模式状态管理 + +### 10.2.1 JavaScript状态管理 + +```javascript +// 面板状态管理 +const panelState = { + left: { + mode: 'local', // 'local' 或 'sftp' + sessionId: 'local', // 'local' 或 SFTP会话ID + currentPath: '', // 当前路径 + selectedFiles: [] // 选中的文件 + }, + right: { + mode: 'local', + sessionId: 'local', + currentPath: '', + selectedFiles: [] + } +}; + +// 活跃连接映射 +const activeConnections = {}; + +// 已保存的连接列表 +let savedConnections = []; +``` + +### 10.2.2 模式切换逻辑 + +```javascript +// 模式切换 +function onModeChange(panelId) { + const mode = $(`#${panelId}-mode`).val(); + panelState[panelId].mode = mode; + + if (mode === 'local') { + switchToLocalMode(panelId); + } else { + switchToSftpMode(panelId); + } +} + +// 切换到本地模式 +function switchToLocalMode(panelId) { + updateStatus(`正在切换到本地模式...`); + + // 隐藏SFTP连接选择器 + $(`#${panelId}-connection`).hide(); + + // 更新会话ID + panelState[panelId].sessionId = 'local'; + + // 设置默认路径 + panelState[panelId].currentPath = getDefaultLocalPath(); + + // 加载本地文件列表 + loadFiles(panelId); + + updateStatus('已切换到本地模式'); +} + +// 切换到SFTP模式 +function switchToSftpMode(panelId) { + updateStatus(`正在切换到SFTP模式...`); + + // 显示SFTP连接选择器 + $(`#${panelId}-connection`).show(); + + // 加载已保存的连接列表 + loadSavedConnections(panelId); + + // 检查是否有活跃的SFTP连接 + const activeSessionId = findActiveSftpSession(panelId); + if (activeSessionId) { + // 使用已有连接 + panelState[panelId].sessionId = activeSessionId; + $(`#${panelId}-connection`).val(activeSessionId); + loadSftpCurrentPath(panelId, activeSessionId); + } else { + // 提示用户选择或创建连接 + alert('请选择一个SFTP连接或创建新连接'); + } + + updateStatus('已切换到SFTP模式'); +} + +// 获取默认本地路径 +function getDefaultLocalPath() { + return System.getProperty('user.home'); +} + +// 查找活跃的SFTP会话 +function findActiveSftpSession(panelId) { + const connectionSelect = $(`#${panelId}-connection`); + const sessionId = connectionSelect.val(); + if (sessionId && sessionId !== 'local') { + return sessionId; + } + return null; +} + +// 加载SFTP当前路径 +function loadSftpCurrentPath(panelId, sessionId) { + $.ajax({ + url: '/api/files/path', + method: 'GET', + data: {sessionId: sessionId}, + success: function(response) { + if (response.success) { + panelState[panelId].currentPath = response.data.path; + loadFiles(panelId); + } else { + alert('获取路径失败: ' + response.message); + } + }, + error: handleError + }); +} +``` + +## 10.3 连接管理UI + +### 10.3.1 连接列表加载 + +```javascript +// 加载已保存的连接列表 +function loadSavedConnections(panelId) { + $.ajax({ + url: '/api/connection/list', + method: 'GET', + success: function(response) { + if (response.success) { + savedConnections = response.data; + updateConnectionSelect(panelId, savedConnections); + } else { + alert('加载连接列表失败: ' + response.message); + } + }, + error: handleError + }); +} + +// 更新连接选择器 +function updateConnectionSelect(panelId, connections) { + const select = $(`#${panelId}-connection`); + select.empty(); + + // 添加选项 + if (connections.length === 0) { + select.append(''); + } else { + connections.forEach(conn => { + const option = $(''); + } else { + Object.entries(activeConnections).forEach(([sessionId, conn]) => { + const option = $('