14
src/main/java/com/sftp/manager/SftpManagerApplication.java
Normal file
14
src/main/java/com/sftp/manager/SftpManagerApplication.java
Normal file
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
26
src/main/java/com/sftp/manager/config/WebConfig.java
Normal file
26
src/main/java/com/sftp/manager/config/WebConfig.java
Normal file
@@ -0,0 +1,26 @@
|
||||
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) {
|
||||
// Spring Boot 默认已经处理了 static 目录,这里可以移除或保留作为额外配置
|
||||
// registry.addResourceHandler("/**")
|
||||
// .addResourceLocations("classpath:/static/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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<String> 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<Void> disconnect(@RequestBody Map<String, String> 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<Connection> 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<List<Connection>> listConnections() {
|
||||
try {
|
||||
List<Connection> connections = connectionService.listConnections();
|
||||
return ApiResponse.success("查询成功", connections);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<Connection> 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<Void> deleteConnection(@PathVariable Long id) {
|
||||
try {
|
||||
connectionService.deleteConnection(id);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("删除失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/active")
|
||||
public ApiResponse<Map<String, Connection>> getActiveConnections() {
|
||||
try {
|
||||
return ApiResponse.success("查询成功", sessionManager.getAllActiveConnections());
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
361
src/main/java/com/sftp/manager/controller/FileController.java
Normal file
361
src/main/java/com/sftp/manager/controller/FileController.java
Normal file
@@ -0,0 +1,361 @@
|
||||
package com.sftp.manager.controller;
|
||||
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.sftp.manager.dto.ApiResponse;
|
||||
import com.sftp.manager.dto.BatchDeleteResult;
|
||||
import com.sftp.manager.dto.FileListRequest;
|
||||
import com.sftp.manager.dto.FileOperationRequest;
|
||||
import com.sftp.manager.dto.TransferRequest;
|
||||
import com.sftp.manager.model.FileInfo;
|
||||
import com.sftp.manager.service.LocalFileService;
|
||||
import com.sftp.manager.service.SessionManager;
|
||||
import com.sftp.manager.service.SftpService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.file.Files;
|
||||
import java.util.HashMap;
|
||||
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;
|
||||
|
||||
@Autowired
|
||||
private SessionManager sessionManager;
|
||||
|
||||
@PostMapping("/list")
|
||||
public ApiResponse<List<FileInfo>> listFiles(@RequestBody FileListRequest request) {
|
||||
try {
|
||||
String sessionId = request.getSessionId();
|
||||
String path = request.getPath();
|
||||
boolean showHidden = Boolean.TRUE.equals(request.getShowHidden());
|
||||
|
||||
List<FileInfo> files;
|
||||
if ("local".equals(sessionId)) {
|
||||
files = localFileService.listFiles(path, showHidden);
|
||||
} else {
|
||||
files = sftpService.listFiles(sessionId, path, showHidden);
|
||||
}
|
||||
|
||||
return ApiResponse.success("查询成功", files);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("列出文件失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/info")
|
||||
public ApiResponse<FileInfo> 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<Map<String, String>> getCurrentPath(@RequestParam String sessionId) {
|
||||
try {
|
||||
Map<String, String> result = new 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());
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
@PostMapping("/upload")
|
||||
public ApiResponse<Void> 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<Void> 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());
|
||||
}
|
||||
}
|
||||
|
||||
// 删除单个文件
|
||||
@DeleteMapping("/delete")
|
||||
public ApiResponse<Void> 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("/mkdir")
|
||||
public ApiResponse<Void> createDirectory(@RequestBody Map<String, String> 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<Void> createDirectories(@RequestBody Map<String, String> 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());
|
||||
}
|
||||
}
|
||||
|
||||
// 重命名文件
|
||||
@PostMapping("/rename")
|
||||
public ApiResponse<Void> renameFile(@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
String sessionId = request.get("sessionId");
|
||||
String oldPath = request.get("oldPath");
|
||||
String newName = request.get("newName");
|
||||
|
||||
if (newName == null || newName.trim().isEmpty()) {
|
||||
return ApiResponse.error("新文件名不能为空");
|
||||
}
|
||||
newName = newName.trim();
|
||||
|
||||
String newPath;
|
||||
if ("local".equals(sessionId)) {
|
||||
File oldFile = new File(oldPath);
|
||||
File parentDir = oldFile.getParentFile();
|
||||
if (parentDir == null) {
|
||||
return ApiResponse.error("无法获取父目录");
|
||||
}
|
||||
newPath = parentDir.getPath() + File.separator + newName;
|
||||
localFileService.renameFile(oldPath, newPath);
|
||||
} else {
|
||||
String parentPath = getParentPathFromString(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());
|
||||
}
|
||||
}
|
||||
|
||||
// 从路径字符串获取父路径(用于 SFTP 路径)
|
||||
private String getParentPathFromString(String path) {
|
||||
if (path == null || path.isEmpty()) return "/";
|
||||
if (path.endsWith("/")) {
|
||||
path = path.substring(0, path.length() - 1);
|
||||
}
|
||||
int index = path.lastIndexOf("/");
|
||||
if (index <= 0) {
|
||||
return "/";
|
||||
}
|
||||
return path.substring(0, index);
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
@PostMapping("/batch-delete")
|
||||
public ApiResponse<BatchDeleteResult> batchDelete(@RequestBody Map<String, Object> request) {
|
||||
try {
|
||||
String sessionId = (String) request.get("sessionId");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> paths = (List<String>) request.get("paths");
|
||||
if (paths == null) {
|
||||
paths = new java.util.ArrayList<>();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/main/java/com/sftp/manager/dto/ApiResponse.java
Normal file
41
src/main/java/com/sftp/manager/dto/ApiResponse.java
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.sftp.manager.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ApiResponse<T> {
|
||||
private boolean success; // 操作是否成功
|
||||
private String message; // 响应消息
|
||||
private T data; // 响应数据
|
||||
private String error; // 错误信息
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
ApiResponse<T> response = new ApiResponse<>();
|
||||
response.setSuccess(true);
|
||||
response.setData(data);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
ApiResponse<T> response = new ApiResponse<>();
|
||||
response.setSuccess(true);
|
||||
response.setMessage(message);
|
||||
response.setData(data);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String message) {
|
||||
ApiResponse<T> response = new ApiResponse<>();
|
||||
response.setSuccess(false);
|
||||
response.setMessage(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String message, String error) {
|
||||
ApiResponse<T> response = new ApiResponse<>();
|
||||
response.setSuccess(false);
|
||||
response.setMessage(message);
|
||||
response.setError(error);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
13
src/main/java/com/sftp/manager/dto/BatchDeleteResult.java
Normal file
13
src/main/java/com/sftp/manager/dto/BatchDeleteResult.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.sftp.manager.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class BatchDeleteResult {
|
||||
private int successCount;
|
||||
private int failCount;
|
||||
private List<String> failedFiles = new ArrayList<>();
|
||||
}
|
||||
16
src/main/java/com/sftp/manager/dto/ConnectionRequest.java
Normal file
16
src/main/java/com/sftp/manager/dto/ConnectionRequest.java
Normal file
@@ -0,0 +1,16 @@
|
||||
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; // 私钥密码
|
||||
private String rootPath; // 默认登录后路径
|
||||
}
|
||||
10
src/main/java/com/sftp/manager/dto/FileListRequest.java
Normal file
10
src/main/java/com/sftp/manager/dto/FileListRequest.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.sftp.manager.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class FileListRequest {
|
||||
private String sessionId; // 会话ID("local"表示本地,否则为SFTP连接ID)
|
||||
private String path; // 要浏览的目录路径
|
||||
private Boolean showHidden; // 是否展示隐藏文件(默认 false)
|
||||
}
|
||||
12
src/main/java/com/sftp/manager/dto/FileOperationRequest.java
Normal file
12
src/main/java/com/sftp/manager/dto/FileOperationRequest.java
Normal file
@@ -0,0 +1,12 @@
|
||||
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(跨服务器传输使用)
|
||||
}
|
||||
11
src/main/java/com/sftp/manager/dto/TransferRequest.java
Normal file
11
src/main/java/com/sftp/manager/dto/TransferRequest.java
Normal file
@@ -0,0 +1,11 @@
|
||||
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; // 目标路径
|
||||
}
|
||||
59
src/main/java/com/sftp/manager/model/Connection.java
Normal file
59
src/main/java/com/sftp/manager/model/Connection.java
Normal file
@@ -0,0 +1,59 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
18
src/main/java/com/sftp/manager/model/FileInfo.java
Normal file
18
src/main/java/com/sftp/manager/model/FileInfo.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.sftp.manager.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class FileInfo {
|
||||
private String name; // 文件名
|
||||
private String path; // 完整路径
|
||||
private long size; // 文件大小(字节)
|
||||
|
||||
@JsonProperty("isDirectory")
|
||||
private boolean isDirectory; // 是否为目录
|
||||
|
||||
private LocalDateTime modifiedTime; // 修改时间
|
||||
private String permissions; // 文件权限(如:-rw-r--r--)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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<Connection, Long> {
|
||||
List<Connection> findByOrderByCreatedAtDesc(); // 按创建时间倒序查询
|
||||
Optional<Connection> findByName(String name); // 按名称查询
|
||||
}
|
||||
156
src/main/java/com/sftp/manager/service/ConnectionService.java
Normal file
156
src/main/java/com/sftp/manager/service/ConnectionService.java
Normal file
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
Exception lastException = null;
|
||||
|
||||
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) {
|
||||
lastException = e;
|
||||
retryCount++;
|
||||
|
||||
// 清理资源
|
||||
if (channel != null && channel.isConnected()) {
|
||||
try {
|
||||
channel.disconnect();
|
||||
} catch (Exception ex) {
|
||||
// 忽略关闭异常
|
||||
}
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
try {
|
||||
session.disconnect();
|
||||
} catch (Exception ex) {
|
||||
// 忽略关闭异常
|
||||
}
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
throw new Exception("连接失败: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// 重置变量以便下次重试
|
||||
session = null;
|
||||
channel = null;
|
||||
sftpChannel = null;
|
||||
|
||||
try {
|
||||
Thread.sleep(1000); // 等待1秒后重试
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new Exception("连接中断", ie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception("连接失败", lastException);
|
||||
}
|
||||
|
||||
public void disconnect(String sessionId) {
|
||||
sessionManager.removeSession(sessionId);
|
||||
}
|
||||
|
||||
public Connection saveConnection(Connection connection) {
|
||||
return connectionRepository.save(connection);
|
||||
}
|
||||
|
||||
public List<Connection> listConnections() {
|
||||
return connectionRepository.findByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
public Connection getConnectionById(Long id) {
|
||||
return connectionRepository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
public void deleteConnection(Long id) {
|
||||
connectionRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
358
src/main/java/com/sftp/manager/service/LocalFileService.java
Normal file
358
src/main/java/com/sftp/manager/service/LocalFileService.java
Normal file
@@ -0,0 +1,358 @@
|
||||
package com.sftp.manager.service;
|
||||
|
||||
import com.sftp.manager.dto.BatchDeleteResult;
|
||||
import com.sftp.manager.model.FileInfo;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
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<FileInfo> listFiles(String path) throws Exception {
|
||||
return listFiles(path, false);
|
||||
}
|
||||
|
||||
public List<FileInfo> listFiles(String path, boolean showHidden) throws Exception {
|
||||
List<FileInfo> 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) {
|
||||
// 不展示隐藏文件时:过滤系统隐藏属性 + 以.开头的名称(Unix 惯例,Windows 上常见)
|
||||
if (!showHidden && (file.isHidden() || file.getName().startsWith("."))) {
|
||||
continue;
|
||||
}
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setName(file.getName());
|
||||
fileInfo.setPath(file.getAbsolutePath());
|
||||
fileInfo.setSize(file.length());
|
||||
fileInfo.setDirectory(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.setDirectory(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);
|
||||
String parent = file.getParent();
|
||||
return parent;
|
||||
}
|
||||
|
||||
// 上传本地文件到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);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除单个文件或目录
|
||||
public boolean deleteFile(String path) throws Exception {
|
||||
File file = new File(path);
|
||||
if (!file.exists()) {
|
||||
throw new Exception("文件不存在: " + path);
|
||||
}
|
||||
checkDeletePermission(file);
|
||||
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();
|
||||
}
|
||||
|
||||
// 检查删除权限(本地)
|
||||
private void checkDeletePermission(File file) throws Exception {
|
||||
if (!file.canWrite()) {
|
||||
throw new Exception("没有删除权限: " + file.getPath());
|
||||
}
|
||||
String systemPaths = "C:\\Windows,C:\\Program Files,C:\\Program Files (x86),C:\\System32";
|
||||
String[] paths = systemPaths.split(",");
|
||||
for (String systemPath : paths) {
|
||||
String p = systemPath.trim().toLowerCase();
|
||||
if (p.isEmpty()) continue;
|
||||
if (file.getPath().toLowerCase().startsWith(p)) {
|
||||
throw new Exception("系统目录禁止删除: " + file.getPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重命名文件
|
||||
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;
|
||||
}
|
||||
|
||||
String illegalChars = "\\/:*?\"<>|";
|
||||
for (char c : illegalChars.toCharArray()) {
|
||||
if (fileName.indexOf(c) != -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileName.length() > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 创建目录(单级,父目录必须存在)
|
||||
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.replace('\\', '/').split("/");
|
||||
for (String part : parts) {
|
||||
if (part.isEmpty()) continue;
|
||||
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;
|
||||
}
|
||||
|
||||
dirName = dirName.trim();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
public BatchDeleteResult batchDelete(List<String> paths) {
|
||||
BatchDeleteResult result = new BatchDeleteResult();
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
56
src/main/java/com/sftp/manager/service/SessionManager.java
Normal file
56
src/main/java/com/sftp/manager/service/SessionManager.java
Normal file
@@ -0,0 +1,56 @@
|
||||
package com.sftp.manager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.sftp.manager.model.Connection;
|
||||
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<String, ChannelSftp> activeSessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Connection> 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<String, Connection> getAllActiveConnections() {
|
||||
return new ConcurrentHashMap<>(sessionConnections);
|
||||
}
|
||||
|
||||
public int getActiveSessionCount() {
|
||||
return activeSessions.size();
|
||||
}
|
||||
}
|
||||
384
src/main/java/com/sftp/manager/service/SftpService.java
Normal file
384
src/main/java/com/sftp/manager/service/SftpService.java
Normal file
@@ -0,0 +1,384 @@
|
||||
package com.sftp.manager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelSftp;
|
||||
import com.jcraft.jsch.SftpATTRS;
|
||||
import com.jcraft.jsch.SftpException;
|
||||
import com.sftp.manager.dto.BatchDeleteResult;
|
||||
import com.sftp.manager.model.FileInfo;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.Vector;
|
||||
|
||||
@Service
|
||||
public class SftpService {
|
||||
|
||||
@Autowired
|
||||
private SessionManager sessionManager;
|
||||
|
||||
public List<FileInfo> listFiles(String sessionId, String path) throws Exception {
|
||||
return listFiles(sessionId, path, false);
|
||||
}
|
||||
|
||||
public List<FileInfo> listFiles(String sessionId, String path, boolean showHidden) throws Exception {
|
||||
ChannelSftp channel = sessionManager.getSession(sessionId);
|
||||
if (channel == null) {
|
||||
throw new Exception("会话不存在或已断开");
|
||||
}
|
||||
|
||||
try {
|
||||
List<FileInfo> files = new ArrayList<>();
|
||||
Vector<?> entries = channel.ls(path);
|
||||
|
||||
for (Object obj : entries) {
|
||||
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||
String fileName = entry.getFilename();
|
||||
|
||||
// 跳过.和..
|
||||
if (".".equals(fileName) || "..".equals(fileName)) {
|
||||
continue;
|
||||
}
|
||||
// 不展示隐藏文件时跳过以.开头的文件/目录(Unix 惯例)
|
||||
if (!showHidden && fileName.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setName(fileName);
|
||||
fileInfo.setPath(path.endsWith("/") ? path + fileName : path + "/" + fileName);
|
||||
fileInfo.setSize(entry.getAttrs().getSize());
|
||||
fileInfo.setDirectory(entry.getAttrs().isDir());
|
||||
|
||||
// 获取修改时间
|
||||
int mtime = entry.getAttrs().getMTime();
|
||||
fileInfo.setModifiedTime(LocalDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(mtime),
|
||||
ZoneId.systemDefault()));
|
||||
|
||||
// 获取权限
|
||||
fileInfo.setPermissions(entry.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 {
|
||||
SftpATTRS attrs = channel.stat(path);
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
|
||||
// 从路径提取文件名
|
||||
String fileName = path;
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
|
||||
fileName = path.substring(lastSlash + 1);
|
||||
}
|
||||
|
||||
fileInfo.setName(fileName);
|
||||
fileInfo.setPath(path);
|
||||
fileInfo.setSize(attrs.getSize());
|
||||
fileInfo.setDirectory(attrs.isDir());
|
||||
|
||||
int mtime = attrs.getMTime();
|
||||
fileInfo.setModifiedTime(LocalDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(mtime),
|
||||
ZoneId.systemDefault()));
|
||||
|
||||
fileInfo.setPermissions(attrs.getPermissionsString());
|
||||
|
||||
return fileInfo;
|
||||
} catch (SftpException e) {
|
||||
throw new Exception("获取文件信息失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件到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);
|
||||
}
|
||||
// 注意:不在此处关闭流,由调用者使用try-with-resources管理
|
||||
}
|
||||
|
||||
// 从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);
|
||||
}
|
||||
// 注意:不在此处关闭流,由调用者使用try-with-resources管理
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除单个文件或目录
|
||||
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 (Object obj : entries) {
|
||||
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||
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 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 == null || path.equals("/") || path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String parentPath = getSftpParentPath(path);
|
||||
if (parentPath != null && !parentPath.equals(path) && !parentPath.isEmpty()) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 获取 SFTP 父路径
|
||||
private String getSftpParentPath(String path) {
|
||||
if (path == null || path.isEmpty()) return "/";
|
||||
String p = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
|
||||
int index = p.lastIndexOf("/");
|
||||
if (index <= 0) return "/";
|
||||
return p.substring(0, index);
|
||||
}
|
||||
|
||||
// 重命名文件
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
public BatchDeleteResult batchDelete(String sessionId, List<String> paths) {
|
||||
BatchDeleteResult result = new BatchDeleteResult();
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
50
src/main/resources/application-prod.yml
Normal file
50
src/main/resources/application-prod.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
# 生产环境配置
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
context-path: /sftp-manager
|
||||
tomcat:
|
||||
max-threads: 200
|
||||
accept-count: 100
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:file:./data/sftp-manager
|
||||
username: ${DB_USERNAME:sa}
|
||||
password: ${DB_PASSWORD:}
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 5
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
show-sql: false
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 500MB
|
||||
max-request-size: 2GB
|
||||
|
||||
app:
|
||||
sftp:
|
||||
session-timeout: 60000
|
||||
connection-timeout: 30000
|
||||
max-retries: 5
|
||||
|
||||
# Actuator 生产环境暴露健康与指标
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
logging:
|
||||
file:
|
||||
name: ./logs/sftp-manager.log
|
||||
level:
|
||||
root: INFO
|
||||
com.sftp.manager: INFO
|
||||
56
src/main/resources/application.yml
Normal file
56
src/main/resources/application.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
server:
|
||||
port: 8080 # 服务端口
|
||||
|
||||
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 # 连接失败重试次数
|
||||
|
||||
# Actuator 监控端点(开发环境仅暴露 health、info)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.sftp.manager: DEBUG
|
||||
org.hibernate.SQL: DEBUG
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||
373
src/main/resources/static/css/style.css
Normal file
373
src/main/resources/static/css/style.css
Normal file
@@ -0,0 +1,373 @@
|
||||
/* SFTP Manager 自定义样式 */
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background-color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 应用容器 */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* 上传进度条 */
|
||||
.upload-progress {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.upload-progress .progress {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-progress .progress-bar {
|
||||
transition: width 0.3s ease;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
|
||||
/* 双面板容器 */
|
||||
.panels-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 面板 */
|
||||
.panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #dee2e6;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* 拖拽上传效果 */
|
||||
.panel.drag-over {
|
||||
background-color: #e7f3ff;
|
||||
border: 2px dashed #0d6efd;
|
||||
}
|
||||
|
||||
/* 面板头部 */
|
||||
.panel-header {
|
||||
flex-shrink: 0;
|
||||
padding: 8px;
|
||||
background-color: #e9ecef;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-mode {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.connection-select {
|
||||
flex: 2;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 连接状态指示器 */
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.status-dot[data-status="connected"] {
|
||||
background-color: #198754;
|
||||
}
|
||||
|
||||
.status-dot[data-status="disconnected"] {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
.status-dot[data-status="connecting"] {
|
||||
background-color: #ffc107;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.connection-status .status-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.connection-status.connected .status-text {
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
.connection-status.disconnected .status-text {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.connection-status.connecting .status-text {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
/* 路径栏 */
|
||||
.path-bar {
|
||||
flex-shrink: 0;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 文件列表 */
|
||||
.file-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* 文件列表拖拽悬停效果 */
|
||||
.file-list.drag-over {
|
||||
background-color: #e7f3ff;
|
||||
outline: 2px dashed #0d6efd;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 文件项拖拽中 */
|
||||
.file-item.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 文件项 */
|
||||
.file-item {
|
||||
padding: 8px 12px;
|
||||
min-height: 44px;
|
||||
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: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
margin-right: 10px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-date {
|
||||
margin-left: 10px;
|
||||
min-width: 140px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 选中状态下的文件大小和日期 */
|
||||
.file-item.selected .file-size,
|
||||
.file-item.selected .file-date {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.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: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 连接列表 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 新建文件夹项(内联编辑) */
|
||||
.new-folder {
|
||||
background-color: #e7f3ff;
|
||||
}
|
||||
|
||||
.new-folder-input {
|
||||
width: 100%;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #0d6efd;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
176
src/main/resources/static/index.html
Normal file
176
src/main/resources/static/index.html
Normal file
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SFTP文件管理器</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<nav class="navbar navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">SFTP文件管理器</span>
|
||||
<div>
|
||||
<button class="btn btn-primary btn-sm" onclick="showConnectionDialog()">连接管理</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar bg-light border-bottom">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="uploadFiles()">上传</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="downloadFiles()">下载</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="transferFiles()">传输到右侧</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFiles()">删除</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="showRenameDialog()">重命名</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="showMkdirDialog()">新建文件夹</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-show-hidden" onclick="toggleShowHidden()" title="切换是否显示隐藏文件">显示隐藏文件</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshPanels()">刷新</button>
|
||||
</div>
|
||||
<!-- 文件上传输入框(隐藏) -->
|
||||
<input type="file" id="file-input" multiple style="display:none">
|
||||
<!-- 上传进度条 -->
|
||||
<div class="upload-progress" id="upload-progress" style="display:none;">
|
||||
<div class="progress" style="height: 20px; margin-left: 10px; width: 200px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双面板区域 -->
|
||||
<div class="panels-container">
|
||||
<!-- 左面板 -->
|
||||
<div class="panel" id="left-panel">
|
||||
<div class="panel-header">
|
||||
<select class="form-select form-select-sm panel-mode" id="left-mode" onchange="onModeChange('left')">
|
||||
<option value="local">本地文件</option>
|
||||
<option value="sftp">SFTP服务器</option>
|
||||
</select>
|
||||
<div class="connection-status" id="left-status" style="display:none;">
|
||||
<span class="status-dot" data-status="disconnected"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
</div>
|
||||
<select class="form-select form-select-sm connection-select" id="left-connection" style="display:none;" onchange="onConnectionChange('left')">
|
||||
<option value="">选择连接</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="path-bar">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="goUp('left')">↑</button>
|
||||
<input type="text" class="form-control form-control-sm path-input" id="left-path" readonly>
|
||||
</div>
|
||||
<div class="file-list" id="left-file-list">
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右面板 -->
|
||||
<div class="panel" id="right-panel">
|
||||
<div class="panel-header">
|
||||
<select class="form-select form-select-sm panel-mode" id="right-mode" onchange="onModeChange('right')">
|
||||
<option value="local">本地文件</option>
|
||||
<option value="sftp">SFTP服务器</option>
|
||||
</select>
|
||||
<div class="connection-status" id="right-status" style="display:none;">
|
||||
<span class="status-dot" data-status="disconnected"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
</div>
|
||||
<select class="form-select form-select-sm connection-select" id="right-connection" style="display:none;" onchange="onConnectionChange('right')">
|
||||
<option value="">选择连接</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="path-bar">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="goUp('right')">↑</button>
|
||||
<input type="text" class="form-control form-control-sm path-input" id="right-path" readonly>
|
||||
</div>
|
||||
<div class="file-list" id="right-file-list">
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div class="status-bar bg-light border-top">
|
||||
<span id="status-text">就绪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接管理对话框 -->
|
||||
<div class="modal fade" id="connectionModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">连接管理</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary" onclick="showAddConnectionDialog()">添加连接</button>
|
||||
</div>
|
||||
<div class="connection-list" id="connection-list">
|
||||
<!-- 连接列表动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加连接对话框 -->
|
||||
<div class="modal fade" id="addConnectionModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">添加SFTP连接</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="connection-form">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">连接名称</label>
|
||||
<input type="text" class="form-control" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">主机地址</label>
|
||||
<input type="text" class="form-control" name="host" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">端口</label>
|
||||
<input type="number" class="form-control" name="port" value="22" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">用户名</label>
|
||||
<input type="text" class="form-control" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">密码</label>
|
||||
<input type="password" class="form-control" name="password">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">私钥路径(可选)</label>
|
||||
<input type="text" class="form-control" name="privateKeyPath">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">私钥密码(可选)</label>
|
||||
<input type="password" class="form-control" name="passPhrase">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveConnection()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1340
src/main/resources/static/js/app.js
Normal file
1340
src/main/resources/static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
164
src/main/resources/templates/index.html
Normal file
164
src/main/resources/templates/index.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SFTP文件管理器</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<nav class="navbar navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">SFTP文件管理器</span>
|
||||
<div>
|
||||
<button class="btn btn-primary btn-sm" onclick="showConnectionDialog()">连接管理</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar bg-light border-bottom">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="uploadFiles()">上传</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="downloadFiles()">下载</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="transferFiles()">传输到右侧</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFiles()">删除</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="showRenameDialog()">重命名</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="showMkdirDialog()">新建文件夹</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-show-hidden" onclick="toggleShowHidden()" title="切换是否显示隐藏文件">显示隐藏文件</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshPanels()">刷新</button>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple style="display:none">
|
||||
<div class="upload-progress" id="upload-progress" style="display:none;">
|
||||
<div class="progress" style="height: 20px; margin-left: 10px; width: 200px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双面板区域 -->
|
||||
<div class="panels-container">
|
||||
<!-- 左面板 -->
|
||||
<div class="panel" id="left-panel">
|
||||
<div class="panel-header">
|
||||
<select class="form-select form-select-sm panel-mode" id="left-mode" onchange="onModeChange('left')">
|
||||
<option value="local">本地文件</option>
|
||||
<option value="sftp">SFTP服务器</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm connection-select" id="left-connection" style="display:none;" onchange="onConnectionChange('left')">
|
||||
<option value="">选择连接</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="path-bar">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="goUp('left')">↑</button>
|
||||
<input type="text" class="form-control form-control-sm path-input" id="left-path" readonly>
|
||||
</div>
|
||||
<div class="file-list" id="left-file-list">
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右面板 -->
|
||||
<div class="panel" id="right-panel">
|
||||
<div class="panel-header">
|
||||
<select class="form-select form-select-sm panel-mode" id="right-mode" onchange="onModeChange('right')">
|
||||
<option value="local">本地文件</option>
|
||||
<option value="sftp">SFTP服务器</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm connection-select" id="right-connection" style="display:none;" onchange="onConnectionChange('right')">
|
||||
<option value="">选择连接</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="path-bar">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="goUp('right')">↑</button>
|
||||
<input type="text" class="form-control form-control-sm path-input" id="right-path" readonly>
|
||||
</div>
|
||||
<div class="file-list" id="right-file-list">
|
||||
<div class="text-center text-muted p-3">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div class="status-bar bg-light border-top">
|
||||
<span id="status-text">就绪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接管理对话框 -->
|
||||
<div class="modal fade" id="connectionModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">连接管理</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary" onclick="showAddConnectionDialog()">添加连接</button>
|
||||
</div>
|
||||
<div class="connection-list" id="connection-list"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加连接对话框 -->
|
||||
<div class="modal fade" id="addConnectionModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">添加SFTP连接</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="connection-form">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">连接名称</label>
|
||||
<input type="text" class="form-control" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">主机地址</label>
|
||||
<input type="text" class="form-control" name="host" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">端口</label>
|
||||
<input type="number" class="form-control" name="port" value="22" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">用户名</label>
|
||||
<input type="text" class="form-control" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">密码</label>
|
||||
<input type="password" class="form-control" name="password">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">私钥路径(可选)</label>
|
||||
<input type="text" class="form-control" name="privateKeyPath">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">私钥密码(可选)</label>
|
||||
<input type="password" class="form-control" name="passPhrase">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveConnection()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user