Initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
liu
2026-02-03 10:10:11 +08:00
commit 14289beb66
45 changed files with 15479 additions and 0 deletions

View 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);
}
}

View 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/");
}
}

View File

@@ -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());
}
}
}

View 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());
}
}
}

View 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;
}
}

View 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<>();
}

View 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; // 默认登录后路径
}

View 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
}

View 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跨服务器传输使用
}

View 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; // 目标路径
}

View 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();
}
}

View 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--
}

View File

@@ -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); // 按名称查询
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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;
}
}

View 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

View 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

View 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;
}
}

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

File diff suppressed because it is too large Load Diff

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