Compare commits
5 Commits
v1.0.1
...
80fc5c8a0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80fc5c8a0f | ||
|
|
085123697e | ||
|
|
8845847ce2 | ||
| 939b2ff287 | |||
| e5b9399350 |
59
AGENTS.md
59
AGENTS.md
@@ -150,10 +150,65 @@
|
|||||||
- 检查未提交敏感信息与本地配置
|
- 检查未提交敏感信息与本地配置
|
||||||
- 仅提交与需求直接相关的文件
|
- 仅提交与需求直接相关的文件
|
||||||
|
|
||||||
## 9) 文档与规则文件检查结果
|
## 9) Makefile 快捷命令(仓库根目录)
|
||||||
|
|
||||||
- `AGENTS.md`:本文件为新建(仓库根目录)
|
- `make build`:构建 Docker 镜像
|
||||||
|
- `make up`:构建并后台启动服务
|
||||||
|
- `make down`:停止并移除服务
|
||||||
|
- `make restart`:重启服务
|
||||||
|
- `make logs`:查看服务日志
|
||||||
|
- `make ps`:查看服务状态
|
||||||
|
|
||||||
|
## 10) 文档与规则文件检查结果
|
||||||
|
|
||||||
|
- `AGENTS.md`:本文件(仓库根目录)
|
||||||
- Cursor 规则:未发现 `.cursor/rules/` 或 `.cursorrules`
|
- Cursor 规则:未发现 `.cursor/rules/` 或 `.cursorrules`
|
||||||
- Copilot 规则:未发现 `.github/copilot-instructions.md`
|
- Copilot 规则:未发现 `.github/copilot-instructions.md`
|
||||||
|
|
||||||
若未来新增上述规则文件,agents 必须先读取并将其视为高优先级约束。
|
若未来新增上述规则文件,agents 必须先读取并将其视为高优先级约束。
|
||||||
|
|
||||||
|
## 11) 近期修复记录
|
||||||
|
|
||||||
|
### 11.1 Docker 启动失败修复
|
||||||
|
|
||||||
|
**问题现象**
|
||||||
|
```text
|
||||||
|
Could not resolve placeholder 'SSHMANAGER_JWT_SECRET'
|
||||||
|
Encryption key must be 32 bytes (256 bits)
|
||||||
|
No qualifying bean of type 'ExecutorService' available: expected single matching bean but found 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复措施**
|
||||||
|
1. **`application.yml`** - 为安全配置添加空字符串默认值
|
||||||
|
```yaml
|
||||||
|
sshmanager:
|
||||||
|
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY ""}
|
||||||
|
jwt-secret: ${SSHMANAGER_JWT_SECRET ""}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`docker-compose.yml`** - 提供有效的默认密钥(仅用于开发/测试)
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- SSHMANAGER_JWT_SECRET=ssh-manager-prod-jwt-secret-20240311
|
||||||
|
- SSHMANAGER_ENCRYPTION_KEY=MLVt7pE35KULIppEiit0doUMvSjozZJ037oNGeXjhVA=
|
||||||
|
```
|
||||||
|
> 注:`MLVt7pE35KULIppEiit0doUMvSjozZJ037oNGeXjhVA=` 是通过 `openssl rand -base64 32` 生成的有效 32 字节 AES-256 密钥
|
||||||
|
|
||||||
|
3. **`TerminalWebSocketHandler.java`** - 解决依赖注入歧义
|
||||||
|
```java
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
|
||||||
|
public TerminalWebSocketHandler(
|
||||||
|
// ... 其他参数
|
||||||
|
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证结果**
|
||||||
|
```
|
||||||
|
Started SshManagerApplication in 3.469 seconds (JVM running for 3.836)
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意事项**
|
||||||
|
- **生产环境部署时必须修改** `SSHMANAGER_JWT_SECRET` 和 `SSHMANAGER_ENCRYPTION_KEY`
|
||||||
|
- 建议取消 `docker-compose.yml` 中 `volumes` 注释以持久化 H2 数据库文件
|
||||||
|
|||||||
33
Makefile
Normal file
33
Makefile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
.PHONY: help build up down restart logs ps
|
||||||
|
|
||||||
|
COMPOSE_FILE := docker/docker-compose.yml
|
||||||
|
COMPOSE := docker compose -f $(COMPOSE_FILE)
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf "Available targets:\n"
|
||||||
|
@printf " make build Build Docker images\n"
|
||||||
|
@printf " make up Build and start services in background\n"
|
||||||
|
@printf " make down Stop and remove services\n"
|
||||||
|
@printf " make restart Restart services\n"
|
||||||
|
@printf " make logs Follow service logs\n"
|
||||||
|
@printf " make ps Show service status\n"
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(COMPOSE) build
|
||||||
|
|
||||||
|
up:
|
||||||
|
$(COMPOSE) build
|
||||||
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
|
down:
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
restart:
|
||||||
|
$(COMPOSE) down
|
||||||
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
ps:
|
||||||
|
$(COMPOSE) ps
|
||||||
32
README.md
32
README.md
@@ -53,22 +53,22 @@ cd frontend && npm run build
|
|||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
ssh-manager/
|
ssh-manager/
|
||||||
├── backend/ # Spring Boot(JDK 8)
|
├── backend/ # Spring Boot(JDK 8)
|
||||||
│ └── src/main/java/com/sshmanager/
|
│ └── src/main/java/com/sshmanager/
|
||||||
│ ├── config/ # 安全、WebSocket、CORS
|
│ ├── config/ # 安全、WebSocket、CORS
|
||||||
│ ├── controller/
|
│ ├── controller/
|
||||||
│ ├── service/
|
│ ├── service/
|
||||||
│ ├── entity/
|
│ ├── entity/
|
||||||
│ └── repository/
|
│ └── repository/
|
||||||
├── frontend/ # Vue 3 + Vite + Tailwind
|
├── frontend/ # Vue 3 + Vite + Tailwind
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── views/
|
│ ├── views/
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ ├── stores/
|
│ ├── stores/
|
||||||
│ └── api/
|
│ └── api/
|
||||||
└── design-system/ # UI/UX 规范
|
└── docs/design-system/ # UI/UX 规范
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.sshmanager.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class ConfigurationValidator implements CommandLineRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ConfigurationValidator.class);
|
||||||
|
|
||||||
|
@Value("${SSHMANAGER_ENCRYPTION_KEY:}")
|
||||||
|
private String encryptionKey;
|
||||||
|
|
||||||
|
@Value("${SSHMANAGER_JWT_SECRET:}")
|
||||||
|
private String jwtSecret;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
Set<String> missingConfigs = new HashSet<>();
|
||||||
|
|
||||||
|
if (encryptionKey == null || encryptionKey.trim().isEmpty()) {
|
||||||
|
missingConfigs.add("SSHMANAGER_ENCRYPTION_KEY");
|
||||||
|
}
|
||||||
|
if (jwtSecret == null || jwtSecret.trim().isEmpty()) {
|
||||||
|
missingConfigs.add("SSHMANAGER_JWT_SECRET");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!missingConfigs.isEmpty()) {
|
||||||
|
String missing = String.join(", ", missingConfigs);
|
||||||
|
log.error("Missing required environment variables: {}", missing);
|
||||||
|
log.error("Please set the following environment variables:");
|
||||||
|
missingConfigs.forEach(key -> log.error(" - {} (required)", key));
|
||||||
|
log.error("Application will not start without these configurations.");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("ssh-manager-jwt-secret-change-in-production".equals(jwtSecret)) {
|
||||||
|
log.error("Default JWT secret detected. Please set SSHMANAGER_JWT_SECRET to a secure random value.");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encryptionKey.length() != 44) { // Base64 encoded 32 bytes = 44 chars
|
||||||
|
log.error("Invalid encryption key length. Expected 44 characters (Base64 encoded 32 bytes).");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Configuration validation passed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import org.springframework.boot.CommandLineRunner;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class DataInitializer implements CommandLineRunner {
|
public class DataInitializer implements CommandLineRunner {
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ public class DataInitializer implements CommandLineRunner {
|
|||||||
admin.setUsername("admin");
|
admin.setUsername("admin");
|
||||||
admin.setPasswordHash(passwordEncoder.encode("admin123"));
|
admin.setPasswordHash(passwordEncoder.encode("admin123"));
|
||||||
admin.setDisplayName("Administrator");
|
admin.setDisplayName("Administrator");
|
||||||
|
admin.setPasswordChangedAt(Instant.now());
|
||||||
userRepository.save(admin);
|
userRepository.save(admin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.sshmanager.config;
|
package com.sshmanager.config;
|
||||||
|
|
||||||
import com.sshmanager.security.JwtAuthenticationFilter;
|
import com.sshmanager.security.JwtAuthenticationFilter;
|
||||||
|
import com.sshmanager.security.PasswordExpirationFilter;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -16,6 +17,8 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
|||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -26,10 +29,14 @@ public class SecurityConfig {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private SecurityExceptionHandler securityExceptionHandler;
|
private SecurityExceptionHandler securityExceptionHandler;
|
||||||
|
|
||||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||||
|
PasswordExpirationFilter passwordExpirationFilter) {
|
||||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||||
|
this.passwordExpirationFilter = passwordExpirationFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final PasswordExpirationFilter passwordExpirationFilter;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
@@ -49,7 +56,8 @@ public class SecurityConfig {
|
|||||||
e.authenticationEntryPoint(securityExceptionHandler);
|
e.authenticationEntryPoint(securityExceptionHandler);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterBefore(passwordExpirationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
@@ -60,15 +68,14 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
config.setAllowedOrigins(Arrays.asList(
|
// Docker/remote deployments may be accessed via IP/hostname.
|
||||||
"http://localhost:5173", "http://127.0.0.1:5173",
|
// API and WS are still protected by JWT.
|
||||||
"http://localhost:48080", "http://127.0.0.1:48080"
|
config.addAllowedOriginPattern("*");
|
||||||
));
|
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
config.setAllowedHeaders(Arrays.asList("*"));
|
||||||
config.setAllowedHeaders(Arrays.asList("*"));
|
config.setAllowCredentials(true);
|
||||||
config.setAllowCredentials(true);
|
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
source.registerCorsConfiguration("/**", config);
|
source.registerCorsConfiguration("/**", config);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.sshmanager.config;
|
||||||
|
|
||||||
|
import com.sshmanager.controller.SftpController;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SftpSessionCleanupTask {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SftpSessionCleanupTask.class);
|
||||||
|
|
||||||
|
@Value("${sshmanager.sftp-session-timeout-minutes:30}")
|
||||||
|
private int sessionTimeoutMinutes;
|
||||||
|
|
||||||
|
@Value("${sshmanager.transfer-task-timeout-minutes:30}")
|
||||||
|
private int transferTaskTimeoutMinutes;
|
||||||
|
|
||||||
|
private final SftpController sftpController;
|
||||||
|
|
||||||
|
public SftpSessionCleanupTask(SftpController sftpController) {
|
||||||
|
this.sftpController = sftpController;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 60000)
|
||||||
|
public void cleanupIdleSessions() {
|
||||||
|
log.debug("Running SFTP session cleanup task");
|
||||||
|
sftpController.cleanupExpiredSessions(sessionTimeoutMinutes);
|
||||||
|
sftpController.cleanupExpiredTransferTasks(transferTaskTimeoutMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,30 @@
|
|||||||
package com.sshmanager.config;
|
package com.sshmanager.config;
|
||||||
|
|
||||||
import com.sshmanager.controller.TerminalWebSocketHandler;
|
import com.sshmanager.controller.TerminalWebSocketHandler;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
public class WebSocketConfig implements WebSocketConfigurer {
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
private final TerminalWebSocketHandler terminalWebSocketHandler;
|
||||||
|
private final TerminalHandshakeInterceptor terminalHandshakeInterceptor;
|
||||||
|
|
||||||
|
public WebSocketConfig(TerminalWebSocketHandler terminalWebSocketHandler,
|
||||||
|
TerminalHandshakeInterceptor terminalHandshakeInterceptor) {
|
||||||
|
this.terminalWebSocketHandler = terminalWebSocketHandler;
|
||||||
|
this.terminalHandshakeInterceptor = terminalHandshakeInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
private final TerminalWebSocketHandler terminalWebSocketHandler;
|
@Override
|
||||||
private final TerminalHandshakeInterceptor terminalHandshakeInterceptor;
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
||||||
public WebSocketConfig(TerminalWebSocketHandler terminalWebSocketHandler,
|
.addInterceptors(terminalHandshakeInterceptor)
|
||||||
TerminalHandshakeInterceptor terminalHandshakeInterceptor) {
|
// Docker/remote deployments often use non-localhost origins.
|
||||||
this.terminalWebSocketHandler = terminalWebSocketHandler;
|
// WebSocket access is still protected by JWT in the handshake.
|
||||||
this.terminalHandshakeInterceptor = terminalHandshakeInterceptor;
|
.setAllowedOrigins("*");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@Override
|
|
||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
|
||||||
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
|
||||||
.addInterceptors(terminalHandshakeInterceptor)
|
|
||||||
.setAllowedOrigins(
|
|
||||||
"http://localhost:5173", "http://127.0.0.1:5173",
|
|
||||||
"http://localhost:48080", "http://127.0.0.1:48080"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.sshmanager.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class WebSocketThreadPoolConfig {
|
||||||
|
|
||||||
|
@Value("${sshmanager.terminal.websocket.thread-pool.core-size:10}")
|
||||||
|
private int coreSize;
|
||||||
|
|
||||||
|
@Value("${sshmanager.terminal.websocket.thread-pool.max-size:50}")
|
||||||
|
private int maxSize;
|
||||||
|
|
||||||
|
@Value("${sshmanager.terminal.websocket.thread-pool.keep-alive-seconds:60}")
|
||||||
|
private int keepAliveSeconds;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ThreadPoolExecutor terminalWebSocketExecutor() {
|
||||||
|
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
|
||||||
|
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||||
|
coreSize,
|
||||||
|
maxSize,
|
||||||
|
keepAliveSeconds,
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
queue
|
||||||
|
);
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ScheduledExecutorService websocketCleanupScheduler() {
|
||||||
|
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
|
||||||
|
scheduler.scheduleAtFixedRate(this::cleanupIdleSessions, 30, 30, TimeUnit.MINUTES);
|
||||||
|
return scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupIdleSessions() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.sshmanager.controller;
|
|||||||
|
|
||||||
import com.sshmanager.dto.ConnectionCreateRequest;
|
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||||
import com.sshmanager.dto.ConnectionDto;
|
import com.sshmanager.dto.ConnectionDto;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
import com.sshmanager.entity.User;
|
import com.sshmanager.entity.User;
|
||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.service.ConnectionService;
|
import com.sshmanager.service.ConnectionService;
|
||||||
@@ -67,4 +68,27 @@ public class ConnectionController {
|
|||||||
result.put("message", "Deleted");
|
result.put("message", "Deleted");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/test")
|
||||||
|
public ResponseEntity<Map<String, Object>> connectivity(@RequestBody Connection connection,
|
||||||
|
Authentication authentication) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
Connection fullConn = connectionService.getConnectionForSsh(connection.getId(), userId);
|
||||||
|
String password = connectionService.getDecryptedPassword(fullConn);
|
||||||
|
String privateKey = connectionService.getDecryptedPrivateKey(fullConn);
|
||||||
|
String passphrase = connectionService.getDecryptedPassphrase(fullConn);
|
||||||
|
|
||||||
|
connectionService.testConnection(fullConn, password, privateKey, passphrase);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("message", "Connection test successful");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "Connection failed: " + e.getMessage());
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import com.sshmanager.repository.ConnectionRepository;
|
|||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.service.ConnectionService;
|
import com.sshmanager.service.ConnectionService;
|
||||||
import com.sshmanager.service.SshService;
|
import com.sshmanager.service.SshService;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.socket.CloseStatus;
|
import org.springframework.web.socket.CloseStatus;
|
||||||
import org.springframework.web.socket.TextMessage;
|
import org.springframework.web.socket.TextMessage;
|
||||||
@@ -17,7 +18,7 @@ import java.io.InputStream;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||||
@@ -26,19 +27,23 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final ConnectionService connectionService;
|
private final ConnectionService connectionService;
|
||||||
private final SshService sshService;
|
private final SshService sshService;
|
||||||
|
private final ExecutorService executor;
|
||||||
|
|
||||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
private final AtomicInteger sessionCount = new AtomicInteger(0);
|
||||||
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Long> lastActivity = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
|
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
ConnectionService connectionService,
|
ConnectionService connectionService,
|
||||||
SshService sshService) {
|
SshService sshService,
|
||||||
|
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
|
||||||
this.connectionRepository = connectionRepository;
|
this.connectionRepository = connectionRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.connectionService = connectionService;
|
this.connectionService = connectionService;
|
||||||
this.sshService = sshService;
|
this.sshService = sshService;
|
||||||
}
|
this.executor = executor;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception {
|
public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception {
|
||||||
@@ -69,6 +74,8 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
try {
|
try {
|
||||||
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||||
sessions.put(webSocketSession.getId(), sshSession);
|
sessions.put(webSocketSession.getId(), sshSession);
|
||||||
|
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||||
|
sessionCount.incrementAndGet();
|
||||||
|
|
||||||
executor.submit(() -> {
|
executor.submit(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -97,6 +104,7 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
||||||
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
||||||
if (sshSession != null && sshSession.isConnected()) {
|
if (sshSession != null && sshSession.isConnected()) {
|
||||||
|
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||||
sshSession.getInputStream().write(message.asBytes());
|
sshSession.getInputStream().write(message.asBytes());
|
||||||
sshSession.getInputStream().flush();
|
sshSession.getInputStream().flush();
|
||||||
}
|
}
|
||||||
@@ -105,8 +113,10 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
@Override
|
@Override
|
||||||
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
|
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
|
||||||
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
||||||
|
lastActivity.remove(webSocketSession.getId());
|
||||||
if (sshSession != null) {
|
if (sshSession != null) {
|
||||||
sshSession.disconnect();
|
sshSession.disconnect();
|
||||||
|
sessionCount.decrementAndGet();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import lombok.AllArgsConstructor;
|
|||||||
|
|
||||||
import javax.persistence.*;
|
import javax.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@@ -32,4 +33,7 @@ public class User {
|
|||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Instant updatedAt = Instant.now();
|
private Instant updatedAt = Instant.now();
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant passwordChangedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class AccessDeniedException extends SshManagerException {
|
||||||
|
public AccessDeniedException(String message) {
|
||||||
|
super(403, "ACCESS_DENIED", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
import org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
import org.springframework.web.multipart.MultipartException;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleMaxUploadSize(MaxUploadSizeExceededException e) {
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "上传失败:文件大小超过限制");
|
||||||
|
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MultipartException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleMultipart(MultipartException e) {
|
||||||
|
Throwable root = e;
|
||||||
|
while (root.getCause() != null && root.getCause() != root) {
|
||||||
|
root = root.getCause();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root instanceof FileSizeLimitExceededException
|
||||||
|
|| (root.getMessage() != null && root.getMessage().contains("exceeds its maximum permitted size"))) {
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "上传失败:文件大小超过限制");
|
||||||
|
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> err = new HashMap<>();
|
||||||
|
err.put("error", "上传失败:表单解析异常");
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(SshManagerException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleSshManagerException(SshManagerException e) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("error", e.getErrorCode());
|
||||||
|
error.put("message", e.getMessage());
|
||||||
|
return ResponseEntity.status(e.getStatusCode()).body(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleException(Exception e) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("error", "INTERNAL_ERROR");
|
||||||
|
error.put("message", "Internal server error");
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class InvalidOperationException extends SshManagerException {
|
||||||
|
public InvalidOperationException(String message) {
|
||||||
|
super(400, "INVALID_OPERATION", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class NotFoundException extends SshManagerException {
|
||||||
|
public NotFoundException(String message) {
|
||||||
|
super(404, "NOT_FOUND", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class SshManagerException extends RuntimeException {
|
||||||
|
private final int statusCode;
|
||||||
|
private final String errorCode;
|
||||||
|
|
||||||
|
public SshManagerException(int statusCode, String errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStatusCode() {
|
||||||
|
return statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sshmanager.exception;
|
||||||
|
|
||||||
|
public class UnauthorizedException extends SshManagerException {
|
||||||
|
public UnauthorizedException(String message) {
|
||||||
|
super(401, "UNAUTHORIZED", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,8 +52,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||||
return bearerToken.substring(7);
|
return bearerToken.substring(7);
|
||||||
}
|
}
|
||||||
// WebSocket handshake sends token as query param
|
// WebSocket handshake and SSE endpoints send token as query param
|
||||||
if (request.getRequestURI() != null && request.getRequestURI().startsWith("/ws/")) {
|
String uri = request.getRequestURI();
|
||||||
|
if (uri != null && (uri.startsWith("/ws/") || uri.contains("/progress"))) {
|
||||||
String token = request.getParameter("token");
|
String token = request.getParameter("token");
|
||||||
if (StringUtils.hasText(token)) {
|
if (StringUtils.hasText(token)) {
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.sshmanager.security;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import javax.servlet.FilterChain;
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class PasswordExpirationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
@Value("${sshmanager.password-expiration-days:90}")
|
||||||
|
private int passwordExpirationDays;
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public PasswordExpirationFilter(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication != null && authentication.isAuthenticated()) {
|
||||||
|
String username = authentication.getName();
|
||||||
|
User user = userRepository.findByUsername(username).orElse(null);
|
||||||
|
if (user != null && isPasswordExpired(user)) {
|
||||||
|
request.setAttribute("passwordExpired", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPasswordExpired(User user) {
|
||||||
|
Instant passwordChangedAt = user.getPasswordChangedAt();
|
||||||
|
if (passwordChangedAt == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return passwordChangedAt.isBefore(Instant.now().minus(passwordExpirationDays, ChronoUnit.DAYS));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,14 @@ public class ConnectionService {
|
|||||||
|
|
||||||
private final ConnectionRepository connectionRepository;
|
private final ConnectionRepository connectionRepository;
|
||||||
private final EncryptionService encryptionService;
|
private final EncryptionService encryptionService;
|
||||||
|
private final SshService sshService;
|
||||||
|
|
||||||
public ConnectionService(ConnectionRepository connectionRepository,
|
public ConnectionService(ConnectionRepository connectionRepository,
|
||||||
EncryptionService encryptionService) {
|
EncryptionService encryptionService,
|
||||||
|
SshService sshService) {
|
||||||
this.connectionRepository = connectionRepository;
|
this.connectionRepository = connectionRepository;
|
||||||
this.encryptionService = encryptionService;
|
this.encryptionService = encryptionService;
|
||||||
|
this.sshService = sshService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ConnectionDto> listByUserId(Long userId) {
|
public List<ConnectionDto> listByUserId(Long userId) {
|
||||||
@@ -130,4 +133,18 @@ public class ConnectionService {
|
|||||||
return conn.getPassphrase() != null ?
|
return conn.getPassphrase() != null ?
|
||||||
encryptionService.decrypt(conn.getPassphrase()) : null;
|
encryptionService.decrypt(conn.getPassphrase()) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Connection testConnection(Connection conn, String password, String privateKey, String passphrase) {
|
||||||
|
SshService.SshSession session = null;
|
||||||
|
try {
|
||||||
|
session = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||||
|
return conn;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Connection test failed: " + e.getMessage(), e);
|
||||||
|
} finally {
|
||||||
|
if (session != null) {
|
||||||
|
session.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,37 @@
|
|||||||
package com.sshmanager.service;
|
package com.sshmanager.service;
|
||||||
|
|
||||||
import com.jcraft.jsch.ChannelSftp;
|
import com.jcraft.jsch.ChannelSftp;
|
||||||
import com.jcraft.jsch.JSch;
|
import com.jcraft.jsch.JSch;
|
||||||
import com.jcraft.jsch.Session;
|
import com.jcraft.jsch.Session;
|
||||||
import com.jcraft.jsch.SftpException;
|
import com.jcraft.jsch.SftpATTRS;
|
||||||
|
import com.jcraft.jsch.SftpException;
|
||||||
|
import com.jcraft.jsch.SftpProgressMonitor;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.io.PipedInputStream;
|
import java.io.PipedInputStream;
|
||||||
import java.io.PipedOutputStream;
|
import java.io.PipedOutputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Vector;
|
import java.util.Vector;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.TimeoutException;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SftpService {
|
public class SftpService {
|
||||||
|
|
||||||
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
|
private ExecutorService executorService = Executors.newFixedThreadPool(2);
|
||||||
|
|
||||||
|
public void setExecutorService(ExecutorService executorService) {
|
||||||
|
this.executorService = executorService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
JSch jsch = new JSch();
|
JSch jsch = new JSch();
|
||||||
|
|
||||||
@@ -40,8 +46,14 @@ public class SftpService {
|
|||||||
session.setConfig("StrictHostKeyChecking", "no");
|
session.setConfig("StrictHostKeyChecking", "no");
|
||||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||||
|
if (password == null || password.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Password is required for password authentication");
|
||||||
|
}
|
||||||
|
session.setConfig("PreferredAuthentications", "password");
|
||||||
session.setPassword(password);
|
session.setPassword(password);
|
||||||
|
} else {
|
||||||
|
session.setConfig("PreferredAuthentications", "publickey");
|
||||||
}
|
}
|
||||||
session.connect(10000);
|
session.connect(10000);
|
||||||
|
|
||||||
@@ -78,7 +90,7 @@ public class SftpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class FileInfo {
|
public static class FileInfo {
|
||||||
public String name;
|
public String name;
|
||||||
public boolean directory;
|
public boolean directory;
|
||||||
public long size;
|
public long size;
|
||||||
@@ -89,8 +101,14 @@ public class SftpService {
|
|||||||
this.directory = directory;
|
this.directory = directory;
|
||||||
this.size = size;
|
this.size = size;
|
||||||
this.mtime = mtime;
|
this.mtime = mtime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface TransferProgressListener {
|
||||||
|
void onStart(long totalBytes);
|
||||||
|
|
||||||
|
void onProgress(long transferredBytes, long totalBytes);
|
||||||
|
}
|
||||||
|
|
||||||
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
||||||
String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim();
|
String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim();
|
||||||
@@ -152,8 +170,32 @@ public class SftpService {
|
|||||||
sftpSession.getChannel().get(remotePath, out);
|
sftpSession.getChannel().get(remotePath, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception {
|
public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception {
|
||||||
sftpSession.getChannel().put(in, remotePath);
|
sftpSession.getChannel().put(in, remotePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void upload(SftpSession sftpSession, String remotePath, InputStream in, TransferProgressListener progressListener) throws Exception {
|
||||||
|
sftpSession.getChannel().put(in, remotePath, new SftpProgressMonitor() {
|
||||||
|
@Override
|
||||||
|
public void init(int op, String src, String dest, long max) {
|
||||||
|
if (progressListener != null) {
|
||||||
|
progressListener.onStart(max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean count(long count) {
|
||||||
|
if (progressListener != null) {
|
||||||
|
progressListener.onProgress(count, 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void end() {
|
||||||
|
// Progress listener will be notified by controller
|
||||||
|
}
|
||||||
|
}, ChannelSftp.OVERWRITE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
|
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
|
||||||
@@ -184,44 +226,75 @@ public class SftpService {
|
|||||||
* Transfer a single file from source session to target session (streaming, no full file in memory).
|
* Transfer a single file from source session to target session (streaming, no full file in memory).
|
||||||
* Fails if sourcePath is a directory.
|
* Fails if sourcePath is a directory.
|
||||||
*/
|
*/
|
||||||
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
|
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
if (source.getChannel().stat(sourcePath).isDir()) {
|
transferRemote(source, sourcePath, target, targetPath, null);
|
||||||
throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported");
|
}
|
||||||
}
|
|
||||||
final int pipeBufferSize = 65536;
|
public void transferRemote(SftpSession source,
|
||||||
PipedOutputStream pos = new PipedOutputStream();
|
String sourcePath,
|
||||||
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
|
SftpSession target,
|
||||||
|
String targetPath,
|
||||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
TransferProgressListener progressListener) throws Exception {
|
||||||
try {
|
SftpATTRS attrs = source.getChannel().stat(sourcePath);
|
||||||
Future<?> putFuture = executor.submit(() -> {
|
if (attrs.isDir()) {
|
||||||
try {
|
throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported");
|
||||||
target.getChannel().put(pis, targetPath);
|
}
|
||||||
} catch (Exception e) {
|
final long totalBytes = attrs.getSize();
|
||||||
throw new RuntimeException(e);
|
final int pipeBufferSize = 65536;
|
||||||
}
|
PipedOutputStream pos = new PipedOutputStream();
|
||||||
});
|
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
|
||||||
source.getChannel().get(sourcePath, pos);
|
AtomicLong transferredBytes = new AtomicLong(0);
|
||||||
pos.close();
|
|
||||||
putFuture.get(5, TimeUnit.MINUTES);
|
if (progressListener != null) {
|
||||||
} catch (ExecutionException e) {
|
progressListener.onStart(totalBytes);
|
||||||
Throwable cause = e.getCause();
|
}
|
||||||
if (cause instanceof RuntimeException && cause.getCause() instanceof Exception) {
|
|
||||||
throw (Exception) cause.getCause();
|
Future<?> putFuture = executorService.submit(() -> {
|
||||||
}
|
try {
|
||||||
if (cause instanceof Exception) {
|
target.getChannel().put(pis, targetPath, new SftpProgressMonitor() {
|
||||||
throw (Exception) cause;
|
@Override
|
||||||
}
|
public void init(int op, String src, String dest, long max) {
|
||||||
throw new RuntimeException(cause);
|
if (progressListener != null) {
|
||||||
} catch (TimeoutException e) {
|
progressListener.onStart(totalBytes);
|
||||||
throw new RuntimeException("Transfer timeout", e);
|
}
|
||||||
} finally {
|
}
|
||||||
executor.shutdownNow();
|
|
||||||
try {
|
@Override
|
||||||
pis.close();
|
public boolean count(long count) {
|
||||||
} catch (Exception ignored) {
|
long current = transferredBytes.addAndGet(count);
|
||||||
}
|
if (progressListener != null) {
|
||||||
}
|
progressListener.onProgress(current, totalBytes);
|
||||||
}
|
}
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void end() {
|
||||||
|
if (progressListener != null) {
|
||||||
|
progressListener.onProgress(totalBytes, totalBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, ChannelSftp.OVERWRITE);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
source.getChannel().get(sourcePath, pos);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
pos.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
putFuture.get();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
pis.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.sshmanager.service;
|
package com.sshmanager.service;
|
||||||
|
|
||||||
import com.jcraft.jsch.ChannelShell;
|
import com.jcraft.jsch.ChannelShell;
|
||||||
import com.jcraft.jsch.JSch;
|
import com.jcraft.jsch.JSch;
|
||||||
import com.jcraft.jsch.Session;
|
import com.jcraft.jsch.Session;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -13,10 +13,10 @@ import java.io.PipedInputStream;
|
|||||||
import java.io.PipedOutputStream;
|
import java.io.PipedOutputStream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SshService {
|
public class SshService {
|
||||||
|
|
||||||
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
JSch jsch = new JSch();
|
JSch jsch = new JSch();
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||||
@@ -31,8 +31,14 @@ public class SshService {
|
|||||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD && password != null) {
|
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||||
|
if (password == null || password.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Password is required for password authentication");
|
||||||
|
}
|
||||||
|
session.setConfig("PreferredAuthentications", "password");
|
||||||
session.setPassword(password);
|
session.setPassword(password);
|
||||||
|
} else {
|
||||||
|
session.setConfig("PreferredAuthentications", "publickey");
|
||||||
}
|
}
|
||||||
|
|
||||||
session.connect(10000);
|
session.connect(10000);
|
||||||
@@ -59,10 +65,10 @@ public class SshService {
|
|||||||
}
|
}
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
return new SshSession(session, channel, channelOut, pipeToChannel);
|
return new SshSession(session, channel, channelOut, pipeToChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SshSession {
|
public static class SshSession {
|
||||||
private final Session session;
|
private final Session session;
|
||||||
private final ChannelShell channel;
|
private final ChannelShell channel;
|
||||||
private final InputStream outputStream;
|
private final InputStream outputStream;
|
||||||
@@ -95,5 +101,5 @@ public class SshService {
|
|||||||
public boolean isConnected() {
|
public boolean isConnected() {
|
||||||
return channel != null && channel.isConnected();
|
return channel != null && channel.isConnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
server:
|
server:
|
||||||
port: 48080
|
port: 48080
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
web:
|
web:
|
||||||
resources:
|
resources:
|
||||||
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
|
add-mappings: false # 使用 SpaForwardConfig 统一处理静态与 SPA 回退
|
||||||
datasource:
|
servlet:
|
||||||
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
multipart:
|
||||||
driver-class-name: org.h2.Driver
|
max-file-size: 2048MB
|
||||||
username: sa
|
max-request-size: 2048MB
|
||||||
password:
|
datasource:
|
||||||
h2:
|
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
||||||
console:
|
driver-class-name: org.h2.Driver
|
||||||
enabled: false
|
username: sa
|
||||||
jpa:
|
password:
|
||||||
hibernate:
|
h2:
|
||||||
ddl-auto: update
|
console:
|
||||||
show-sql: false
|
enabled: false
|
||||||
properties:
|
path: /h2
|
||||||
hibernate:
|
settings:
|
||||||
format_sql: true
|
web-allow-others: true
|
||||||
dialect: org.hibernate.dialect.H2Dialect
|
jpa:
|
||||||
open-in-view: false
|
hibernate:
|
||||||
|
ddl-auto: update
|
||||||
# Encryption key for connection passwords (base64, 32 bytes for AES-256)
|
show-sql: false
|
||||||
sshmanager:
|
properties:
|
||||||
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=}
|
hibernate:
|
||||||
jwt-secret: ${SSHMANAGER_JWT_SECRET:ssh-manager-jwt-secret-change-in-production}
|
format_sql: true
|
||||||
jwt-expiration-ms: 86400000
|
dialect: org.hibernate.dialect.H2Dialect
|
||||||
|
open-in-view: false
|
||||||
|
|
||||||
|
# Encryption key for connection passwords (base64, 32 bytes for AES-256)
|
||||||
|
sshmanager:
|
||||||
|
encryption-key: ${SSHMANAGER_ENCRYPTION_KEY ""}
|
||||||
|
jwt-secret: ${SSHMANAGER_JWT_SECRET ""}
|
||||||
|
jwt-expiration-ms: 86400000
|
||||||
|
password-expiration-days: ${SSHMANAGER_PASSWORD_EXPIRATION_DAYS:90}
|
||||||
|
terminal:
|
||||||
|
websocket:
|
||||||
|
thread-pool:
|
||||||
|
core-size: 10
|
||||||
|
max-size: 50
|
||||||
|
keep-alive-seconds: 60
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.repository.UserRepository;
|
||||||
|
import com.sshmanager.service.ConnectionService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ConnectionControllerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ConnectionService connectionService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ConnectionController connectionController;
|
||||||
|
|
||||||
|
private Authentication authentication;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
authentication = mock(Authentication.class);
|
||||||
|
when(authentication.getName()).thenReturn("testuser");
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testConnectivityWithValidConnection() {
|
||||||
|
Long connectionId = 1L;
|
||||||
|
Connection conn = new Connection();
|
||||||
|
conn.setId(connectionId);
|
||||||
|
conn.setHost("127.0.0.1");
|
||||||
|
conn.setPort(22);
|
||||||
|
conn.setUsername("root");
|
||||||
|
conn.setAuthType(Connection.AuthType.PASSWORD);
|
||||||
|
conn.setUserId(1L);
|
||||||
|
|
||||||
|
when(connectionService.getConnectionForSsh(connectionId, 1L)).thenReturn(conn);
|
||||||
|
when(connectionService.getDecryptedPassword(conn)).thenReturn("password");
|
||||||
|
|
||||||
|
ResponseEntity<?> response = connectionController.connectivity(conn, authentication);
|
||||||
|
|
||||||
|
assertEquals(200, response.getStatusCode().value());
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> body = (Map<String, Object>) response.getBody();
|
||||||
|
assertTrue((Boolean) body.get("success"));
|
||||||
|
assertEquals("Connection test successful", body.get("message"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testConnectivityWithConnectionFailure() {
|
||||||
|
Long connectionId = 1L;
|
||||||
|
Connection conn = new Connection();
|
||||||
|
conn.setId(connectionId);
|
||||||
|
conn.setHost("127.0.0.1");
|
||||||
|
conn.setPort(22);
|
||||||
|
conn.setUsername("root");
|
||||||
|
conn.setAuthType(Connection.AuthType.PASSWORD);
|
||||||
|
conn.setUserId(1L);
|
||||||
|
|
||||||
|
when(connectionService.getConnectionForSsh(connectionId, 1L)).thenReturn(conn);
|
||||||
|
when(connectionService.getDecryptedPassword(conn)).thenReturn("password");
|
||||||
|
doThrow(new RuntimeException("Connection refused")).when(connectionService).testConnection(conn, "password", null, null);
|
||||||
|
|
||||||
|
ResponseEntity<?> response = connectionController.connectivity(conn, authentication);
|
||||||
|
|
||||||
|
assertEquals(200, response.getStatusCode().value());
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> body = (Map<String, Object>) response.getBody();
|
||||||
|
assertFalse((Boolean) body.get("success"));
|
||||||
|
assertTrue(((String) body.get("message")).contains("Connection failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class SftpServiceTest {
|
||||||
|
|
||||||
|
private SftpService sftpService;
|
||||||
|
private ExecutorService executorService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
executorService = Executors.newFixedThreadPool(2);
|
||||||
|
sftpService = new SftpService();
|
||||||
|
sftpService.setExecutorService(executorService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testPasswordAuthenticationRequiredWithValidConnection() {
|
||||||
|
Exception exception = assertThrows(Exception.class, () -> {
|
||||||
|
Connection conn = new Connection();
|
||||||
|
conn.setHost("127.0.0.1");
|
||||||
|
conn.setPort(22);
|
||||||
|
conn.setUsername("test");
|
||||||
|
conn.setAuthType(Connection.AuthType.PASSWORD);
|
||||||
|
sftpService.connect(conn, "", null, null);
|
||||||
|
});
|
||||||
|
assertTrue(exception.getMessage().contains("Password is required") ||
|
||||||
|
exception instanceof IllegalArgumentException);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testPasswordAuthenticationRequiredWithNullConn() {
|
||||||
|
Exception exception = assertThrows(Exception.class, () -> {
|
||||||
|
sftpService.connect(null, "", null, null);
|
||||||
|
});
|
||||||
|
assertTrue(exception instanceof NullPointerException || exception instanceof IllegalArgumentException);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testExecutorServiceShutdown() throws Exception {
|
||||||
|
executorService.shutdown();
|
||||||
|
assertTrue(executorService.isTerminated() || executorService.isShutdown());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# SSH 管理器设计系统
|
|
||||||
|
|
||||||
## 风格
|
|
||||||
|
|
||||||
- **产品类型**:管理后台 / 开发工具仪表盘
|
|
||||||
- **主题**:深色、专业、终端风格
|
|
||||||
- **布局**:侧边栏 + 主内容区
|
|
||||||
|
|
||||||
## 色彩
|
|
||||||
|
|
||||||
- 背景:slate-900 (#0f172a)、slate-800
|
|
||||||
- 表面:slate-800、slate-700
|
|
||||||
- 主文字:slate-100 (#f1f5f9)
|
|
||||||
- 次要文字:slate-400
|
|
||||||
- 强调(成功/连接):emerald-500、cyan-500
|
|
||||||
- 边框:slate-600、slate-700
|
|
||||||
|
|
||||||
## 字体
|
|
||||||
|
|
||||||
- 字体:Inter 或 system-ui
|
|
||||||
- 正文:最小 16px,行高 1.5
|
|
||||||
|
|
||||||
## 图标
|
|
||||||
|
|
||||||
- 仅使用 Lucide 图标,不使用 emoji
|
|
||||||
- 尺寸:统一 20px 或 24px
|
|
||||||
|
|
||||||
## 交互
|
|
||||||
|
|
||||||
- 所有可点击元素使用 cursor-pointer
|
|
||||||
- transition-colors duration-200
|
|
||||||
- 最小触控区域 44×44px
|
|
||||||
|
|
||||||
## 无障碍
|
|
||||||
|
|
||||||
- 对比度 4.5:1
|
|
||||||
- 可见焦点环
|
|
||||||
- 仅图标按钮需设置 aria-label
|
|
||||||
@@ -7,20 +7,31 @@
|
|||||||
- **npm**:`docker/.npmrc` 使用 npmmirror(淘宝镜像)
|
- **npm**:`docker/.npmrc` 使用 npmmirror(淘宝镜像)
|
||||||
- **Maven**:`docker/maven-settings.xml` 使用阿里云仓库
|
- **Maven**:`docker/maven-settings.xml` 使用阿里云仓库
|
||||||
|
|
||||||
## 构建与运行
|
## 构建与运行
|
||||||
|
|
||||||
在**项目根目录**执行:
|
在**项目根目录**执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建镜像
|
# 一键(推荐)
|
||||||
docker compose -f docker/docker-compose.yml build
|
make up
|
||||||
|
|
||||||
|
# 构建镜像
|
||||||
|
docker compose -f docker/docker-compose.yml build
|
||||||
|
|
||||||
# 前台运行
|
# 前台运行
|
||||||
docker compose -f docker/docker-compose.yml up
|
docker compose -f docker/docker-compose.yml up
|
||||||
|
|
||||||
# 后台运行
|
# 后台运行
|
||||||
docker compose -f docker/docker-compose.yml up -d
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make logs # 查看日志
|
||||||
|
make ps # 查看状态
|
||||||
|
make down # 停止并移除容器
|
||||||
|
```
|
||||||
|
|
||||||
访问:http://localhost:48080
|
访问:http://localhost:48080
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ services:
|
|||||||
- "48080:48080"
|
- "48080:48080"
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# 生产环境建议设置并挂载密钥
|
# JWT Secret (change in production!)
|
||||||
# - SSHMANAGER_ENCRYPTION_KEY=...
|
- SSHMANAGER_JWT_SECRET=ssh-manager-prod-jwt-secret-20240311
|
||||||
# - SSHMANAGER_JWT_SECRET=...
|
# Encryption Key (base64, 32 bytes; change in production!)
|
||||||
|
- SSHMANAGER_ENCRYPTION_KEY=MLVt7pE35KULIppEiit0doUMvSjozZJ037oNGeXjhVA=
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
43
docs/design-system/MASTER.md
Normal file
43
docs/design-system/MASTER.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# SSH Manager Transfer Console - Design System (Master)
|
||||||
|
|
||||||
|
Goal: a fast, reliable, ops-style UI for moving data across many hosts.
|
||||||
|
|
||||||
|
Design principles
|
||||||
|
- Transfer-first: primary surface is "plans / queue / progress"; connections are supporting data.
|
||||||
|
- Dense but calm: show more information without visual noise; consistent rhythm and spacing.
|
||||||
|
- Failure is actionable: errors are specific, local to the job, and keep context.
|
||||||
|
- Keyboard-friendly: visible focus rings, logical tab order, no hover-only actions.
|
||||||
|
|
||||||
|
Color and surfaces (dark-first)
|
||||||
|
- Background: deep slate with subtle gradient + faint grid/noise.
|
||||||
|
- Surfaces: layered cards (solid + slight transparency) with visible borders.
|
||||||
|
- Accent: cyan for primary actions and progress.
|
||||||
|
- Status:
|
||||||
|
- Success: green
|
||||||
|
- Warning: amber
|
||||||
|
- Danger: red
|
||||||
|
|
||||||
|
Typography
|
||||||
|
- Headings: IBM Plex Sans (600-700)
|
||||||
|
- Body: IBM Plex Sans (400-500)
|
||||||
|
- Mono (paths, hostnames, commands): IBM Plex Mono
|
||||||
|
|
||||||
|
Spacing and layout
|
||||||
|
- App shell: left rail (nav) + main content; content uses max width on desktop.
|
||||||
|
- Cards: 12-16px padding on mobile, 16-20px on desktop.
|
||||||
|
- Touch targets: >= 44px for buttons / list rows.
|
||||||
|
|
||||||
|
Interaction
|
||||||
|
- Buttons: disable during async; show inline spinner + label change ("Starting…").
|
||||||
|
- Loading: skeleton for lists; avoid layout jump.
|
||||||
|
- Motion: 150-250ms transitions; respect prefers-reduced-motion.
|
||||||
|
|
||||||
|
Accessibility
|
||||||
|
- Contrast: normal text >= 4.5:1.
|
||||||
|
- Focus: always visible focus ring on interactive elements.
|
||||||
|
- Icon-only buttons must have aria-label.
|
||||||
|
|
||||||
|
Transfer UX patterns
|
||||||
|
- "Plan" = input + targets + options; "Run" produces jobs in a queue.
|
||||||
|
- Queue rows show: source, targets count, status, progress, started/finished, retry.
|
||||||
|
- Progress: per-target progress when available (XHR upload), otherwise discrete states.
|
||||||
@@ -4,7 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SSH 管理器</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>SSH 传输控制台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^5.0.2",
|
"vue-router": "^5.0.2",
|
||||||
|
"vue-toast-notification": "^3.1.3",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3327,6 +3328,18 @@
|
|||||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-toast-notification": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vue-toast-notification/-/vue-toast-notification-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-XNyWqwLIGBFfX5G9sK+clq3N3IPlhDjzNdbZaXkEElcotPlWs0wWZailk1vqhdtLYT/93Y4FHAVuzyatLmPZRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.15.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-tsc": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
||||||
|
|||||||
@@ -16,15 +16,16 @@
|
|||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^5.0.2",
|
"vue-router": "^5.0.2",
|
||||||
|
"vue-toast-notification": "^3.1.3",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
|
||||||
"@vue/tsconfig": "^0.8.1",
|
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
|
|||||||
@@ -7,13 +7,26 @@ const client = axios.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
client.interceptors.request.use((config) => {
|
client.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
|
||||||
})
|
// Let the browser set the correct multipart boundary.
|
||||||
|
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
|
||||||
|
const headers: any = config.headers ?? {}
|
||||||
|
if (typeof headers.set === 'function') {
|
||||||
|
headers.set('Content-Type', undefined)
|
||||||
|
} else {
|
||||||
|
delete headers['Content-Type']
|
||||||
|
delete headers['content-type']
|
||||||
|
}
|
||||||
|
config.headers = headers
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
|
|||||||
@@ -1,81 +1,226 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
|
|
||||||
export interface SftpFileInfo {
|
export interface SftpFileInfo {
|
||||||
name: string
|
name: string
|
||||||
directory: boolean
|
directory: boolean
|
||||||
size: number
|
size: number
|
||||||
mtime: number
|
mtime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFiles(connectionId: number, path: string) {
|
export interface RemoteTransferTask {
|
||||||
return client.get<SftpFileInfo[]>('/sftp/list', {
|
taskId: string
|
||||||
params: { connectionId, path: path || '.' },
|
status: 'queued' | 'running' | 'success' | 'error' | 'cancelled'
|
||||||
})
|
progress: number
|
||||||
}
|
transferredBytes: number
|
||||||
|
totalBytes: number
|
||||||
export function getPwd(connectionId: number) {
|
error?: string
|
||||||
return client.get<{ path: string }>('/sftp/pwd', {
|
createdAt: number
|
||||||
params: { connectionId },
|
startedAt: number
|
||||||
})
|
finishedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadFile(connectionId: number, path: string) {
|
export function listFiles(connectionId: number, path: string) {
|
||||||
const token = localStorage.getItem('token')
|
return client.get<SftpFileInfo[]>('/sftp/list', {
|
||||||
const params = new URLSearchParams({ connectionId: String(connectionId), path })
|
params: { connectionId, path: path || '.' },
|
||||||
const res = await fetch(`/api/sftp/download?${params}`, {
|
})
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
}
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('Download failed')
|
export function getPwd(connectionId: number) {
|
||||||
const blob = await res.blob()
|
return client.get<{ path: string }>('/sftp/pwd', {
|
||||||
const url = URL.createObjectURL(blob)
|
params: { connectionId },
|
||||||
const link = document.createElement('a')
|
})
|
||||||
link.href = url
|
}
|
||||||
link.download = path.split('/').pop() || 'download'
|
|
||||||
document.body.appendChild(link)
|
export async function downloadFile(connectionId: number, path: string) {
|
||||||
link.click()
|
const token = localStorage.getItem('token')
|
||||||
document.body.removeChild(link)
|
const params = new URLSearchParams({ connectionId: String(connectionId), path })
|
||||||
URL.revokeObjectURL(url)
|
const res = await fetch(`/api/sftp/download?${params}`, {
|
||||||
}
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
export function uploadFile(connectionId: number, path: string, file: File) {
|
if (!res.ok) throw new Error('Download failed')
|
||||||
const form = new FormData()
|
const blob = await res.blob()
|
||||||
form.append('file', file)
|
const url = URL.createObjectURL(blob)
|
||||||
return client.post('/sftp/upload', form, {
|
const link = document.createElement('a')
|
||||||
params: { connectionId, path },
|
link.href = url
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
link.download = path.split('/').pop() || 'download'
|
||||||
})
|
document.body.appendChild(link)
|
||||||
}
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
export function deleteFile(connectionId: number, path: string, directory: boolean) {
|
URL.revokeObjectURL(url)
|
||||||
return client.delete('/sftp/delete', {
|
}
|
||||||
params: { connectionId, path, directory },
|
|
||||||
})
|
export function uploadFileWithProgress(connectionId: number, path: string, file: File) {
|
||||||
}
|
const token = localStorage.getItem('token')
|
||||||
|
const url = `/api/sftp/upload?connectionId=${connectionId}&path=${encodeURIComponent(path)}`
|
||||||
export function createDir(connectionId: number, path: string) {
|
const xhr = new XMLHttpRequest()
|
||||||
return client.post('/sftp/mkdir', null, {
|
const form = new FormData()
|
||||||
params: { connectionId, path },
|
form.append('file', file, file.name)
|
||||||
})
|
|
||||||
}
|
xhr.open('POST', url)
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||||
export function renameFile(connectionId: number, oldPath: string, newPath: string) {
|
|
||||||
return client.post('/sftp/rename', null, {
|
// Create a wrapper object to hold the progress callback
|
||||||
params: { connectionId, oldPath, newPath },
|
const wrapper = { onProgress: undefined as ((percent: number) => void) | undefined }
|
||||||
})
|
|
||||||
}
|
// Allow caller to attach handlers after this function returns.
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
export function transferRemote(
|
console.log('[Upload Progress] event fired:', { lengthComputable: event.lengthComputable, loaded: event.loaded, total: event.total })
|
||||||
sourceConnectionId: number,
|
if (!event.lengthComputable) return
|
||||||
sourcePath: string,
|
const percent = Math.round((event.loaded / event.total) * 100)
|
||||||
targetConnectionId: number,
|
console.log('[Upload Progress] percent:', percent, 'hasCallback:', !!wrapper.onProgress)
|
||||||
targetPath: string
|
if (wrapper.onProgress) wrapper.onProgress(percent)
|
||||||
) {
|
}
|
||||||
return client.post<{ message: string }>('/sftp/transfer-remote', null, {
|
|
||||||
params: {
|
// Defer send so callers can attach onload/onerror/onProgress safely.
|
||||||
sourceConnectionId,
|
setTimeout(() => {
|
||||||
sourcePath,
|
try {
|
||||||
targetConnectionId,
|
xhr.send(form)
|
||||||
targetPath,
|
} catch {
|
||||||
},
|
// ignore
|
||||||
})
|
}
|
||||||
}
|
}, 0)
|
||||||
|
|
||||||
|
// Return XHR with a setter that updates the wrapper
|
||||||
|
const result = xhr as XMLHttpRequest & { onProgress?: (percent: number) => void }
|
||||||
|
Object.defineProperty(result, 'onProgress', {
|
||||||
|
get: () => wrapper.onProgress,
|
||||||
|
set: (fn) => { wrapper.onProgress = fn },
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadFile(connectionId: number, path: string, file: File) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file, file.name)
|
||||||
|
return client.post<{ taskId: string; message: string }>('/sftp/upload', form, {
|
||||||
|
params: { connectionId, path },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadTask {
|
||||||
|
taskId: string
|
||||||
|
status: 'queued' | 'running' | 'success' | 'error'
|
||||||
|
progress: number
|
||||||
|
transferredBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
filename: string
|
||||||
|
error?: string
|
||||||
|
createdAt: number
|
||||||
|
startedAt: number
|
||||||
|
finishedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUploadTask(taskId: string) {
|
||||||
|
return client.get<UploadTask>(`/sftp/upload/tasks/${encodeURIComponent(taskId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFile(connectionId: number, path: string, directory: boolean) {
|
||||||
|
return client.delete('/sftp/delete', {
|
||||||
|
params: { connectionId, path, directory },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDir(connectionId: number, path: string) {
|
||||||
|
return client.post('/sftp/mkdir', null, {
|
||||||
|
params: { connectionId, path },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameFile(connectionId: number, oldPath: string, newPath: string) {
|
||||||
|
return client.post('/sftp/rename', null, {
|
||||||
|
params: { connectionId, oldPath, newPath },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transferRemote(
|
||||||
|
sourceConnectionId: number,
|
||||||
|
sourcePath: string,
|
||||||
|
targetConnectionId: number,
|
||||||
|
targetPath: string
|
||||||
|
) {
|
||||||
|
return client.post<{ message: string }>('/sftp/transfer-remote', null, {
|
||||||
|
params: {
|
||||||
|
sourceConnectionId,
|
||||||
|
sourcePath,
|
||||||
|
targetConnectionId,
|
||||||
|
targetPath,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRemoteTransferTask(
|
||||||
|
sourceConnectionId: number,
|
||||||
|
sourcePath: string,
|
||||||
|
targetConnectionId: number,
|
||||||
|
targetPath: string
|
||||||
|
) {
|
||||||
|
return client.post<RemoteTransferTask>('/sftp/transfer-remote/tasks', null, {
|
||||||
|
params: {
|
||||||
|
sourceConnectionId,
|
||||||
|
sourcePath,
|
||||||
|
targetConnectionId,
|
||||||
|
targetPath,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemoteTransferTask(taskId: string) {
|
||||||
|
return client.get<RemoteTransferTask>(`/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeRemoteTransferProgress(taskId: string, onProgress: (task: RemoteTransferTask) => void): () => void {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const url = `/api/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}/progress`
|
||||||
|
const eventSource = new EventSource(`${url}?token=${encodeURIComponent(token || '')}`)
|
||||||
|
|
||||||
|
eventSource.addEventListener('progress', (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
console.log('[SSE] Received progress event:', data)
|
||||||
|
onProgress(data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse SSE progress data:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('error', (event) => {
|
||||||
|
console.error('SSE connection error:', event)
|
||||||
|
eventSource.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeUploadProgress(taskId: string, onProgress: (task: UploadTask) => void): () => void {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const url = `/api/sftp/upload/tasks/${encodeURIComponent(taskId)}/progress`
|
||||||
|
const eventSource = new EventSource(`${url}?token=${encodeURIComponent(token || '')}`)
|
||||||
|
|
||||||
|
eventSource.addEventListener('progress', (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
onProgress(data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse SSE progress data:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('error', (event) => {
|
||||||
|
console.error('SSE connection error:', event)
|
||||||
|
eventSource.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelRemoteTransferTask(taskId: string) {
|
||||||
|
return client.delete<RemoteTransferTask & { cancelRequested: boolean; message?: string }>(
|
||||||
|
`/sftp/transfer-remote/tasks/${encodeURIComponent(taskId)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,41 +20,125 @@ const username = ref('')
|
|||||||
const authType = ref<AuthType>('PASSWORD')
|
const authType = ref<AuthType>('PASSWORD')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const privateKey = ref('')
|
const privateKey = ref('')
|
||||||
|
const privateKeyFileName = ref('')
|
||||||
|
const privateKeyInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const passphrase = ref('')
|
const passphrase = ref('')
|
||||||
|
|
||||||
const isEdit = computed(() => !!props.connection)
|
const isEdit = computed(() => !!props.connection)
|
||||||
|
|
||||||
|
const hostError = computed(() => {
|
||||||
|
const h = host.value.trim()
|
||||||
|
if (!h) return ''
|
||||||
|
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
|
||||||
|
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||||
|
if (ipv4Regex.test(h)) {
|
||||||
|
const parts = h.match(ipv4Regex)
|
||||||
|
if (parts && parts.slice(1, 5).every(p => parseInt(p) <= 255)) return ''
|
||||||
|
return 'IP地址格式无效'
|
||||||
|
}
|
||||||
|
if (hostnameRegex.test(h)) return ''
|
||||||
|
return '主机名格式无效'
|
||||||
|
})
|
||||||
|
|
||||||
|
const portError = computed(() => {
|
||||||
|
const p = port.value
|
||||||
|
if (p < 1 || p > 65535) return '端口号必须在1-65535之间'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.connection,
|
() => props.connection,
|
||||||
(c) => {
|
(c) => {
|
||||||
if (c) {
|
if (c) {
|
||||||
name.value = c.name
|
name.value = c.name
|
||||||
host.value = c.host
|
host.value = c.host
|
||||||
port.value = c.port
|
port.value = c.port
|
||||||
username.value = c.username
|
username.value = c.username
|
||||||
authType.value = c.authType
|
authType.value = c.authType
|
||||||
password.value = ''
|
password.value = ''
|
||||||
privateKey.value = ''
|
privateKey.value = ''
|
||||||
passphrase.value = ''
|
privateKeyFileName.value = ''
|
||||||
} else {
|
passphrase.value = ''
|
||||||
|
} else {
|
||||||
name.value = ''
|
name.value = ''
|
||||||
host.value = ''
|
host.value = ''
|
||||||
port.value = 22
|
port.value = 22
|
||||||
username.value = ''
|
username.value = ''
|
||||||
authType.value = 'PASSWORD'
|
authType.value = 'PASSWORD'
|
||||||
password.value = ''
|
password.value = ''
|
||||||
privateKey.value = ''
|
privateKey.value = ''
|
||||||
passphrase.value = ''
|
privateKeyFileName.value = ''
|
||||||
}
|
passphrase.value = ''
|
||||||
},
|
}
|
||||||
{ immediate: true }
|
},
|
||||||
)
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const backdropPressed = ref(false)
|
||||||
|
|
||||||
|
function handleBackdropMouseDown() {
|
||||||
|
backdropPressed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropMouseUp() {
|
||||||
|
if (backdropPressed.value) {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
backdropPressed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogMouseDown() {
|
||||||
|
backdropPressed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKeyText(text: string) {
|
||||||
|
return text.replace(/\r\n?/g, '\n').trim() + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrivateKeyFileChange(e: Event) {
|
||||||
|
error.value = ''
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Keep it generous; OpenSSH keys are usually a few KB.
|
||||||
|
const MAX_SIZE = 256 * 1024
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
error.value = '私钥文件过大(>256KB),请检查是否选错文件'
|
||||||
|
input.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
privateKey.value = normalizeKeyText(text)
|
||||||
|
privateKeyFileName.value = file.name
|
||||||
|
} catch {
|
||||||
|
error.value = '读取私钥文件失败'
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPrivateKeyFile() {
|
||||||
|
privateKey.value = ''
|
||||||
|
privateKeyFileName.value = ''
|
||||||
|
if (privateKeyInputRef.value) privateKeyInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
const hostErr = hostError.value
|
||||||
|
const portErr = portError.value
|
||||||
|
if (hostErr) {
|
||||||
|
error.value = hostErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (portErr) {
|
||||||
|
error.value = portErr
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!name.value.trim()) {
|
if (!name.value.trim()) {
|
||||||
error.value = '请填写名称'
|
error.value = '请填写名称'
|
||||||
return
|
return
|
||||||
@@ -108,13 +192,18 @@ async function handleSubmit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
<div
|
||||||
<div
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
class="w-full max-w-lg bg-slate-800 rounded-xl border border-slate-700 shadow-xl"
|
@mousedown.self="handleBackdropMouseDown"
|
||||||
role="dialog"
|
@mouseup.self="handleBackdropMouseUp"
|
||||||
aria-modal="true"
|
>
|
||||||
aria-labelledby="form-title"
|
<div
|
||||||
>
|
class="w-full max-w-lg bg-slate-800 rounded-xl border border-slate-700 shadow-xl"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="form-title"
|
||||||
|
@mousedown="handleDialogMouseDown"
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between p-4 border-b border-slate-700">
|
<div class="flex items-center justify-between p-4 border-b border-slate-700">
|
||||||
<h2 id="form-title" class="text-lg font-semibold text-slate-100">
|
<h2 id="form-title" class="text-lg font-semibold text-slate-100">
|
||||||
{{ isEdit ? '编辑连接' : '新建连接' }}
|
{{ isEdit ? '编辑连接' : '新建连接' }}
|
||||||
@@ -148,6 +237,7 @@ async function handleSubmit() {
|
|||||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
placeholder="192.168.1.1"
|
placeholder="192.168.1.1"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="hostError" class="mt-1 text-xs text-red-400">{{ hostError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
|
<label for="port" class="block text-sm font-medium text-slate-300 mb-1">端口</label>
|
||||||
@@ -159,6 +249,7 @@ async function handleSubmit() {
|
|||||||
max="65535"
|
max="65535"
|
||||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="portError" class="mt-1 text-xs text-red-400">{{ portError }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -196,25 +287,36 @@ async function handleSubmit() {
|
|||||||
:placeholder="isEdit ? '••••••••' : ''"
|
:placeholder="isEdit ? '••••••••' : ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
|
<div v-if="authType === 'PRIVATE_KEY'" class="space-y-2">
|
||||||
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
|
<label for="privateKey" class="block text-sm font-medium text-slate-300 mb-1">
|
||||||
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
|
私钥 {{ isEdit ? '(留空则不修改)' : '' }}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<input
|
||||||
id="privateKey"
|
ref="privateKeyInputRef"
|
||||||
v-model="privateKey"
|
id="privateKey"
|
||||||
rows="6"
|
type="file"
|
||||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
accept=".pem,.key,.ppk,.txt,application/x-pem-file"
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500 file:mr-4 file:rounded-md file:border-0 file:bg-slate-600 file:px-3 file:py-2 file:text-slate-100 hover:file:bg-slate-500"
|
||||||
></textarea>
|
@change="handlePrivateKeyFileChange"
|
||||||
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令(可选)</label>
|
/>
|
||||||
<input
|
<div v-if="privateKeyFileName" class="flex items-center justify-between gap-3">
|
||||||
id="passphrase"
|
<p class="text-xs text-slate-400 truncate">已选择:{{ privateKeyFileName }}</p>
|
||||||
v-model="passphrase"
|
<button
|
||||||
type="password"
|
type="button"
|
||||||
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
class="text-xs text-slate-300 hover:text-slate-100 hover:underline"
|
||||||
/>
|
@click="clearPrivateKeyFile"
|
||||||
</div>
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label for="passphrase" class="block text-sm font-medium text-slate-300 mb-1">私钥口令(可选)</label>
|
||||||
|
<input
|
||||||
|
id="passphrase"
|
||||||
|
v-model="passphrase"
|
||||||
|
type="password"
|
||||||
|
class="w-full px-4 py-2.5 rounded-lg bg-slate-700 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
222
frontend/src/components/SftpFilePickerModal.vue
Normal file
222
frontend/src/components/SftpFilePickerModal.vue
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import * as sftpApi from '../api/sftp'
|
||||||
|
import type { SftpFileInfo } from '../api/sftp'
|
||||||
|
import { X, FolderOpen, File, ChevronRight, RefreshCw, Eye, EyeOff } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean; connectionId: number | null }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'select', path: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentPath = ref('.')
|
||||||
|
const pathParts = ref<string[]>([])
|
||||||
|
const files = ref<SftpFileInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const showHiddenFiles = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
let searchDebounceTimer = 0
|
||||||
|
const filteredFiles = ref<SftpFileInfo[]>([])
|
||||||
|
|
||||||
|
const canInteract = computed(() => props.open && props.connectionId != null)
|
||||||
|
|
||||||
|
function applyFileFilters() {
|
||||||
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
|
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
|
||||||
|
filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([searchQuery, showHiddenFiles, files], () => {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
applyFileFilters()
|
||||||
|
}, 300)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function initPath() {
|
||||||
|
if (!canInteract.value || props.connectionId == null) return
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await sftpApi.getPwd(props.connectionId)
|
||||||
|
const p = res.data.path || '/'
|
||||||
|
currentPath.value = p === '/' ? '/' : p
|
||||||
|
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
currentPath.value = '.'
|
||||||
|
pathParts.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!canInteract.value || props.connectionId == null) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await sftpApi.listFiles(props.connectionId, currentPath.value)
|
||||||
|
files.value = res.data
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } }
|
||||||
|
error.value = err?.response?.data?.error ?? '获取文件列表失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToDir(name: string) {
|
||||||
|
if (loading.value) return
|
||||||
|
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
||||||
|
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
||||||
|
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToIndex(i: number) {
|
||||||
|
if (loading.value) return
|
||||||
|
if (i < 0) {
|
||||||
|
currentPath.value = '.'
|
||||||
|
} else {
|
||||||
|
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
|
||||||
|
}
|
||||||
|
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function filePath(file: SftpFileInfo) {
|
||||||
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
|
return base ? base.replace(/\/$/, '') + '/' + file.name : file.name
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(file: SftpFileInfo) {
|
||||||
|
if (file.directory) {
|
||||||
|
navigateToDir(file.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('select', filePath(file))
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.connectionId] as const,
|
||||||
|
async ([open]) => {
|
||||||
|
if (!open) return
|
||||||
|
await initPath()
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (!props.open) return
|
||||||
|
if (e.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKeyDown))
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="fixed inset-0 z-50 bg-black/60 p-4 flex items-center justify-center" role="dialog" aria-modal="true">
|
||||||
|
<div class="w-full max-w-3xl rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur shadow-2xl overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-slate-100 font-semibold truncate">选择源文件</h3>
|
||||||
|
<p class="text-xs text-slate-400 truncate">双击文件不需要,单击即选择</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="load"
|
||||||
|
:disabled="loading || !canInteract"
|
||||||
|
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 disabled:opacity-50 cursor-pointer transition-colors"
|
||||||
|
aria-label="刷新"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||||
|
刷新
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="emit('close')"
|
||||||
|
class="min-h-[44px] w-11 grid place-items-center rounded-lg border border-slate-700 bg-slate-800/60 text-slate-200 hover:bg-slate-800 cursor-pointer transition-colors"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X class="w-5 h-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-3 border-b border-slate-700 bg-slate-900/40">
|
||||||
|
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0">
|
||||||
|
<button
|
||||||
|
@click="navigateToIndex(-1)"
|
||||||
|
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
|
<template v-for="(part, i) in pathParts" :key="i">
|
||||||
|
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
@click="navigateToIndex(i)"
|
||||||
|
class="px-2 py-1 rounded hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer truncate max-w-[140px]"
|
||||||
|
>
|
||||||
|
{{ part || '/' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 rounded-lg border border-slate-600 bg-slate-900/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||||
|
placeholder="搜索文件..."
|
||||||
|
aria-label="搜索文件"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="showHiddenFiles = !showHiddenFiles"
|
||||||
|
class="min-h-[44px] p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors cursor-pointer"
|
||||||
|
:aria-label="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||||
|
:title="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||||
|
>
|
||||||
|
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="mt-2 text-sm text-red-400">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[60vh] overflow-auto divide-y divide-slate-800">
|
||||||
|
<button
|
||||||
|
v-for="file in filteredFiles"
|
||||||
|
:key="file.name"
|
||||||
|
@click="handleClick(file)"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-800/40 transition-colors cursor-pointer min-h-[44px]"
|
||||||
|
:aria-label="file.directory ? '打开目录' : '选择文件'"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="file.directory ? FolderOpen : File"
|
||||||
|
class="w-5 h-5 flex-shrink-0"
|
||||||
|
:class="file.directory ? 'text-cyan-300' : 'text-slate-300'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 min-w-0 truncate text-slate-100">{{ file.name }}</span>
|
||||||
|
<span v-if="!file.directory" class="text-xs text-slate-500">{{ Math.round(file.size / 1024) }} KB</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="filteredFiles.length === 0 && !loading" class="px-4 py-10 text-center text-slate-500">
|
||||||
|
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
|||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import { Server, LogOut, Menu, X } from 'lucide-vue-next'
|
import { ArrowLeftRight, Server, LogOut, Menu, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -35,21 +35,31 @@ function closeSidebar() {
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="p-4 border-b border-slate-700">
|
<div class="p-4 border-b border-slate-700">
|
||||||
<h1 class="text-lg font-semibold text-slate-100">SSH 管理器</h1>
|
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
||||||
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
|
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/connections"
|
to="/transfers"
|
||||||
@click="closeSidebar"
|
@click="closeSidebar"
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/transfers' }"
|
||||||
aria-label="连接列表"
|
aria-label="传输"
|
||||||
>
|
>
|
||||||
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||||
<span>连接列表</span>
|
<span>传输</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
<RouterLink
|
||||||
|
to="/connections"
|
||||||
|
@click="closeSidebar"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||||
|
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
||||||
|
aria-label="连接列表"
|
||||||
|
>
|
||||||
|
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>连接列表</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
<div class="p-4 border-t border-slate-700">
|
<div class="p-4 border-t border-slate-700">
|
||||||
<button
|
<button
|
||||||
@click="authStore.logout(); $router.push('/login')"
|
@click="authStore.logout(); $router.push('/login')"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import Toast from 'vue-toast-notification'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
@@ -7,4 +8,9 @@ import './style.css'
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(Toast, {
|
||||||
|
position: 'top-right',
|
||||||
|
duration: 3000,
|
||||||
|
dismissible: true,
|
||||||
|
})
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -14,29 +14,34 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../layouts/MainLayout.vue'),
|
component: () => import('../layouts/MainLayout.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
redirect: '/connections',
|
redirect: '/connections',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'connections',
|
path: 'transfers',
|
||||||
name: 'Connections',
|
name: 'Transfers',
|
||||||
component: () => import('../views/ConnectionsView.vue'),
|
component: () => import('../views/TransfersView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'connections',
|
||||||
|
name: 'Connections',
|
||||||
|
component: () => import('../views/ConnectionsView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'terminal/:id',
|
path: 'terminal/:id',
|
||||||
name: 'Terminal',
|
name: 'Terminal',
|
||||||
component: () => import('../views/TerminalView.vue'),
|
component: () => import('../views/TerminalView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'sftp/:id',
|
path: 'sftp/:id',
|
||||||
name: 'Sftp',
|
name: 'Sftp',
|
||||||
component: () => import('../views/SftpView.vue'),
|
component: () => import('../views/SftpView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -45,12 +50,12 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach(async (to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (to.meta.public) {
|
if (to.meta.public) {
|
||||||
if (authStore.isAuthenticated && to.path === '/login') {
|
if (authStore.isAuthenticated && to.path === '/login') {
|
||||||
next('/connections')
|
next('/connections')
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
|||||||
389
frontend/src/stores/transfers.ts
Normal file
389
frontend/src/stores/transfers.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
import { createRemoteTransferTask, subscribeRemoteTransferProgress, uploadFileWithProgress } from '../api/sftp'
|
||||||
|
|
||||||
|
export type TransferMode = 'LOCAL_TO_MANY' | 'REMOTE_TO_MANY'
|
||||||
|
export type TransferItemStatus = 'queued' | 'running' | 'success' | 'error' | 'cancelled'
|
||||||
|
|
||||||
|
export interface TransferItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
status: TransferItemStatus
|
||||||
|
message?: string
|
||||||
|
progress?: number
|
||||||
|
startedAt?: number
|
||||||
|
finishedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferRun {
|
||||||
|
id: string
|
||||||
|
mode: TransferMode
|
||||||
|
title: string
|
||||||
|
createdAt: number
|
||||||
|
items: TransferItem[]
|
||||||
|
status: TransferItemStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunController = {
|
||||||
|
abortAll: () => void
|
||||||
|
unsubscribers: (() => void)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function uid(prefix: string) {
|
||||||
|
return `${prefix}-${now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPseudoProgress(item: TransferItem) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (item.status !== 'running') {
|
||||||
|
clearInterval(timer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = typeof item.progress === 'number' ? item.progress : 0
|
||||||
|
if (current >= 95) return
|
||||||
|
const step = current < 20 ? 3 : current < 60 ? 2 : 1
|
||||||
|
item.progress = Math.min(95, current + step)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithConcurrency<T>(
|
||||||
|
tasks: (() => Promise<T>)[],
|
||||||
|
concurrency: number
|
||||||
|
): Promise<void> {
|
||||||
|
const queue = tasks.slice()
|
||||||
|
const workers: Promise<void>[] = []
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const task = queue.shift()
|
||||||
|
if (!task) return
|
||||||
|
await task()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = Math.max(1, Math.min(concurrency, tasks.length || 1))
|
||||||
|
for (let i = 0; i < c; i++) workers.push(worker())
|
||||||
|
await Promise.allSettled(workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRemoteTransfer(taskId: string, onProgress: (progress: number) => void, unsubscribers: (() => void)[]) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
console.log('[waitForRemoteTransfer] Subscribing to task:', taskId)
|
||||||
|
const unsubscribe = subscribeRemoteTransferProgress(taskId, (task) => {
|
||||||
|
const progress = Math.max(0, Math.min(100, task.progress || 0))
|
||||||
|
console.log('[waitForRemoteTransfer] Progress from SSE:', progress, 'status:', task.status)
|
||||||
|
onProgress(progress)
|
||||||
|
|
||||||
|
if (task.status === 'success') {
|
||||||
|
console.log('[waitForRemoteTransfer] Task succeeded:', taskId)
|
||||||
|
resolve()
|
||||||
|
} else if (task.status === 'error') {
|
||||||
|
console.error('[waitForRemoteTransfer] Task errored:', taskId, task.error)
|
||||||
|
reject(new Error(task.error || 'Transfer failed'))
|
||||||
|
} else if (task.status === 'cancelled') {
|
||||||
|
console.log('[waitForRemoteTransfer] Task cancelled:', taskId)
|
||||||
|
reject(new Error('Cancelled'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
unsubscribers.push(unsubscribe)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteTransferPath(targetDir: string, filename: string) {
|
||||||
|
let targetPath = targetDir.trim()
|
||||||
|
if (!targetPath) targetPath = '/'
|
||||||
|
if (!targetPath.endsWith('/')) targetPath = targetPath + '/'
|
||||||
|
return targetPath + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTransfersStore = defineStore('transfers', () => {
|
||||||
|
const runs = ref<TransferRun[]>([] as TransferRun[])
|
||||||
|
const controllers = new Map<string, RunController>()
|
||||||
|
|
||||||
|
const recentRuns = computed(() => runs.value.slice(0, 20))
|
||||||
|
|
||||||
|
function clearRuns() {
|
||||||
|
for (const c of controllers.values()) {
|
||||||
|
try {
|
||||||
|
c.abortAll()
|
||||||
|
for (const unsub of c.unsubscribers) {
|
||||||
|
try {
|
||||||
|
unsub()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controllers.clear()
|
||||||
|
runs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRun(runId: string) {
|
||||||
|
const runIndex = runs.value.findIndex((r) => r.id === runId)
|
||||||
|
if (runIndex === -1) return
|
||||||
|
const ctrl = controllers.get(runId)
|
||||||
|
if (ctrl) {
|
||||||
|
ctrl.abortAll()
|
||||||
|
for (const unsub of ctrl.unsubscribers) {
|
||||||
|
try {
|
||||||
|
unsub()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const run = runs.value[runIndex]!
|
||||||
|
run.items.forEach((item) => {
|
||||||
|
if (item.status === 'queued' || item.status === 'running') {
|
||||||
|
item.status = 'cancelled'
|
||||||
|
item.progress = 100
|
||||||
|
item.finishedAt = now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startLocalToMany(params: {
|
||||||
|
files: File[]
|
||||||
|
targetConnectionIds: number[]
|
||||||
|
targetDir: string
|
||||||
|
concurrency?: number
|
||||||
|
}) {
|
||||||
|
const { files, targetConnectionIds, targetDir } = params
|
||||||
|
const concurrency = params.concurrency ?? 3
|
||||||
|
|
||||||
|
const runId = uid('run')
|
||||||
|
const runItems: TransferItem[] = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
for (const connectionId of targetConnectionIds) {
|
||||||
|
runItems.push({
|
||||||
|
id: uid('item'),
|
||||||
|
label: `${file.name} -> #${connectionId}:${targetDir || ''}`,
|
||||||
|
status: 'queued' as const,
|
||||||
|
progress: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const run: TransferRun = {
|
||||||
|
id: runId,
|
||||||
|
mode: 'LOCAL_TO_MANY' as const,
|
||||||
|
title: `Local -> ${targetConnectionIds.length} targets`,
|
||||||
|
createdAt: now(),
|
||||||
|
items: runItems,
|
||||||
|
status: 'queued' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
runs.value = [run, ...runs.value]
|
||||||
|
|
||||||
|
const activeXhrs: XMLHttpRequest[] = []
|
||||||
|
const unsubscribers: (() => void)[] = []
|
||||||
|
controllers.set(runId, {
|
||||||
|
abortAll: () => {
|
||||||
|
for (const xhr of activeXhrs) {
|
||||||
|
try {
|
||||||
|
xhr.abort()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unsubscribers,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tasks: (() => Promise<void>)[] = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
for (const connectionId of targetConnectionIds) {
|
||||||
|
const itemIndex = runItems.findIndex((i) => i.label.includes(file.name) && i.label.includes(`#${connectionId}`))
|
||||||
|
if (itemIndex === -1) continue
|
||||||
|
const item = runItems[itemIndex]!
|
||||||
|
tasks.push(async () => {
|
||||||
|
if (item.status === 'cancelled') return
|
||||||
|
item.status = 'running'
|
||||||
|
item.progress = 0
|
||||||
|
item.startedAt = now()
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
const stopPseudoProgress = startPseudoProgress(item)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xhr = uploadFileWithProgress(connectionId, targetDir || '', file)
|
||||||
|
activeXhrs.push(xhr)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let lastTick = 0
|
||||||
|
xhr.onProgress = (percent) => {
|
||||||
|
console.log('[Transfers] onProgress callback fired:', percent, 'item:', item.label)
|
||||||
|
const t = now()
|
||||||
|
if (t - lastTick < 100 && percent !== 100) return
|
||||||
|
lastTick = t
|
||||||
|
const newProgress = Math.max(item.progress || 0, Math.max(0, Math.min(100, percent)))
|
||||||
|
console.log('[Transfers] Updating item.progress from', item.progress, 'to', newProgress)
|
||||||
|
item.progress = newProgress
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
}
|
||||||
|
console.log('[Transfers] Set onProgress callback for:', item.label)
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) resolve()
|
||||||
|
else reject(new Error(xhr.responseText || `HTTP ${xhr.status}`))
|
||||||
|
}
|
||||||
|
xhr.onerror = () => reject(new Error('Network error'))
|
||||||
|
xhr.onabort = () => reject(new Error('Cancelled'))
|
||||||
|
})
|
||||||
|
|
||||||
|
item.status = 'success'
|
||||||
|
item.progress = 100
|
||||||
|
item.finishedAt = now()
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = (e as Error)?.message || 'Upload failed'
|
||||||
|
if (msg === 'Cancelled') {
|
||||||
|
item.status = 'cancelled'
|
||||||
|
item.progress = 100
|
||||||
|
} else {
|
||||||
|
item.status = 'error'
|
||||||
|
item.progress = 100
|
||||||
|
item.message = msg
|
||||||
|
}
|
||||||
|
item.finishedAt = now()
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
} finally {
|
||||||
|
stopPseudoProgress()
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runWithConcurrency(tasks, concurrency)
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRemoteToMany(params: {
|
||||||
|
sourceConnectionId: number
|
||||||
|
sourcePath: string
|
||||||
|
targetConnectionIds: number[]
|
||||||
|
targetDirOrPath: string
|
||||||
|
concurrency?: number
|
||||||
|
}) {
|
||||||
|
const { sourceConnectionId, sourcePath, targetConnectionIds, targetDirOrPath } = params
|
||||||
|
const concurrency = params.concurrency ?? 3
|
||||||
|
|
||||||
|
if (sourceConnectionId == null) return
|
||||||
|
|
||||||
|
const runId = uid('run')
|
||||||
|
const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath
|
||||||
|
const runItems: TransferItem[] = targetConnectionIds.map((targetId) => ({
|
||||||
|
id: uid('item'),
|
||||||
|
label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`,
|
||||||
|
status: 'queued' as const,
|
||||||
|
progress: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const run: TransferRun = {
|
||||||
|
id: runId,
|
||||||
|
mode: 'REMOTE_TO_MANY' as const,
|
||||||
|
title: `Remote ${filename} -> ${targetConnectionIds.length} targets`,
|
||||||
|
createdAt: now(),
|
||||||
|
items: runItems,
|
||||||
|
status: 'queued' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
runs.value = [run, ...runs.value]
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const unsubscribers: (() => void)[] = []
|
||||||
|
controllers.set(runId, {
|
||||||
|
abortAll: () => {
|
||||||
|
cancelled = true
|
||||||
|
},
|
||||||
|
unsubscribers,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tasks = runItems.map((item, index) => {
|
||||||
|
return async () => {
|
||||||
|
const targetId = targetConnectionIds[index]
|
||||||
|
if (targetId == null) {
|
||||||
|
item.status = 'error'
|
||||||
|
item.progress = 100
|
||||||
|
item.message = 'Missing target connection'
|
||||||
|
item.finishedAt = now()
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cancelled) {
|
||||||
|
item.status = 'cancelled'
|
||||||
|
item.progress = 100
|
||||||
|
item.finishedAt = now()
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.status = 'running'
|
||||||
|
item.progress = 0
|
||||||
|
item.startedAt = now()
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
console.log('[Remote->Many] Starting transfer:', item.label, 'targetId:', targetId)
|
||||||
|
try {
|
||||||
|
const targetPath = buildRemoteTransferPath(targetDirOrPath, filename)
|
||||||
|
console.log('[Remote->Many] Target path:', targetPath)
|
||||||
|
|
||||||
|
const task = await createRemoteTransferTask(sourceConnectionId, sourcePath, targetId, targetPath)
|
||||||
|
const taskId = task.data.taskId
|
||||||
|
console.log('[Remote->Many] Task created:', taskId)
|
||||||
|
await waitForRemoteTransfer(taskId, (progress) => {
|
||||||
|
console.log('[Remote->Many] Progress update:', progress, 'item:', item.label)
|
||||||
|
item.progress = Math.max(item.progress || 0, progress)
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
}, unsubscribers)
|
||||||
|
|
||||||
|
item.status = 'success'
|
||||||
|
item.progress = 100
|
||||||
|
item.finishedAt = now()
|
||||||
|
console.log('[Remote->Many] Transfer completed:', item.label)
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } }
|
||||||
|
const msg = err?.response?.data?.error || (e as Error)?.message || 'Transfer failed'
|
||||||
|
console.error('[Remote->Many] Transfer failed:', item.label, 'error:', msg)
|
||||||
|
if (msg === 'Cancelled') {
|
||||||
|
item.status = 'cancelled'
|
||||||
|
item.progress = 100
|
||||||
|
} else {
|
||||||
|
item.status = 'error'
|
||||||
|
item.progress = 100
|
||||||
|
item.message = msg
|
||||||
|
}
|
||||||
|
item.finishedAt = now()
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
} finally {
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await runWithConcurrency(tasks, concurrency)
|
||||||
|
runs.value = [...runs.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runs,
|
||||||
|
recentRuns,
|
||||||
|
controllers,
|
||||||
|
clearRuns,
|
||||||
|
cancelRun,
|
||||||
|
startLocalToMany,
|
||||||
|
startRemoteToMany,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -3,8 +3,28 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--app-bg-0: #0b1220;
|
||||||
|
--app-bg-1: #0a1626;
|
||||||
|
--app-card: rgba(17, 24, 39, 0.72);
|
||||||
|
--app-border: rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-slate-900 text-slate-100 antialiased;
|
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
|
||||||
|
@apply text-slate-100 antialiased;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, rgba(34, 211, 238, 0.16), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 90% 0%, rgba(59, 130, 246, 0.10), transparent 55%),
|
||||||
|
linear-gradient(180deg, var(--app-bg-0), var(--app-bg-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||||
|
"Courier New", monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Plus,
|
Plus,
|
||||||
Terminal,
|
Terminal,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
Key,
|
Key,
|
||||||
Lock,
|
Lock,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useConnectionsStore()
|
const store = useConnectionsStore()
|
||||||
@@ -56,10 +56,10 @@ function openTerminal(conn: Connection) {
|
|||||||
router.push(`/terminal/${conn.id}`)
|
router.push(`/terminal/${conn.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSftp(conn: Connection) {
|
function openSftp(conn: Connection) {
|
||||||
router.push(`/sftp/${conn.id}`)
|
router.push(`/sftp/${conn.id}`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
@@ -127,24 +127,24 @@ function openSftp(conn: Connection) {
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
|
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@click="openTerminal(conn)"
|
@click="openTerminal(conn)"
|
||||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||||
>
|
>
|
||||||
<Terminal class="w-4 h-4" aria-hidden="true" />
|
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||||
终端
|
终端
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="openSftp(conn)"
|
@click="openSftp(conn)"
|
||||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||||
>
|
>
|
||||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||||
文件
|
文件
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConnectionForm
|
<ConnectionForm
|
||||||
v-if="showForm"
|
v-if="showForm"
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ async function handleSubmit() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await authApi.login({ username: username.value, password: password.value })
|
const res = await authApi.login({ username: username.value, password: password.value })
|
||||||
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
||||||
router.push('/connections')
|
router.push('/connections')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
||||||
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
|
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toast-notification'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import * as sftpApi from '../api/sftp'
|
import * as sftpApi from '../api/sftp'
|
||||||
import type { SftpFileInfo } from '../api/sftp'
|
import type { SftpFileInfo } from '../api/sftp'
|
||||||
@@ -11,14 +12,20 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
Download,
|
Download,
|
||||||
Trash2,
|
Trash2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-vue-next'
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Loader,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const store = useConnectionsStore()
|
const store = useConnectionsStore()
|
||||||
|
|
||||||
const connectionId = computed(() => Number(route.params.id))
|
const connectionId = computed(() => Number(route.params.id))
|
||||||
@@ -33,12 +40,129 @@ const uploading = ref(false)
|
|||||||
const selectedFile = ref<string | null>(null)
|
const selectedFile = ref<string | null>(null)
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const showTransferModal = ref(false)
|
const showHiddenFiles = ref(false)
|
||||||
const transferFile = ref<SftpFileInfo | null>(null)
|
const searchQuery = ref('')
|
||||||
const transferTargetConnectionId = ref<number | null>(null)
|
let searchDebounceTimer = 0
|
||||||
const transferTargetPath = ref('')
|
const filteredFiles = ref<SftpFileInfo[]>([])
|
||||||
const transferring = ref(false)
|
|
||||||
const transferError = ref('')
|
function applyFileFilters() {
|
||||||
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
|
const base = showHiddenFiles.value ? files.value : files.value.filter((f) => !f.name.startsWith('.'))
|
||||||
|
filteredFiles.value = q ? base.filter((f) => f.name.toLowerCase().includes(q)) : base
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([searchQuery, showHiddenFiles, files], () => {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
applyFileFilters()
|
||||||
|
}, 300)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
stopTransferProgress()
|
||||||
|
})
|
||||||
|
|
||||||
|
const showUploadProgress = ref(false)
|
||||||
|
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
|
||||||
|
|
||||||
|
const totalProgress = computed(() => {
|
||||||
|
if (uploadProgressList.value.length === 0) return 0
|
||||||
|
const totalSize = uploadProgressList.value.reduce((sum, item) => sum + item.size, 0)
|
||||||
|
const uploadedSize = uploadProgressList.value.reduce((sum, item) => {
|
||||||
|
if (item.status === 'success') return sum + item.size
|
||||||
|
if (item.status === 'uploading') return sum + item.uploaded
|
||||||
|
return sum
|
||||||
|
}, 0)
|
||||||
|
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentUploadingFile = computed(() => {
|
||||||
|
return uploadProgressList.value.find(item => item.status === 'uploading')?.name || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTransferModal = ref(false)
|
||||||
|
const transferFile = ref<SftpFileInfo | null>(null)
|
||||||
|
const transferTargetConnectionId = ref<number | null>(null)
|
||||||
|
const transferTargetPath = ref('')
|
||||||
|
const transferring = ref(false)
|
||||||
|
const transferError = ref('')
|
||||||
|
const transferProgress = ref(0)
|
||||||
|
const transferTransferredBytes = ref(0)
|
||||||
|
const transferTotalBytes = ref(0)
|
||||||
|
const transferTaskId = ref('')
|
||||||
|
let transferPollAbort = false
|
||||||
|
|
||||||
|
function resetTransferProgress() {
|
||||||
|
transferProgress.value = 0
|
||||||
|
transferTransferredBytes.value = 0
|
||||||
|
transferTotalBytes.value = 0
|
||||||
|
transferTaskId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTransferProgress() {
|
||||||
|
transferPollAbort = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTransferBytes(bytes: number) {
|
||||||
|
return formatSize(Math.max(0, bytes || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTransferTask(taskId: string) {
|
||||||
|
transferPollAbort = false
|
||||||
|
transferTaskId.value = taskId
|
||||||
|
while (!transferPollAbort) {
|
||||||
|
const res = await sftpApi.getRemoteTransferTask(taskId)
|
||||||
|
const task = res.data
|
||||||
|
transferProgress.value = Math.max(0, Math.min(100, task.progress || 0))
|
||||||
|
transferTransferredBytes.value = Math.max(0, task.transferredBytes || 0)
|
||||||
|
transferTotalBytes.value = Math.max(0, task.totalBytes || 0)
|
||||||
|
|
||||||
|
if (task.status === 'success') return task
|
||||||
|
if (task.status === 'error') throw new Error(task.error || '传输失败')
|
||||||
|
if (task.status === 'cancelled') throw new Error('传输已取消')
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('传输已取消')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelTransfer() {
|
||||||
|
const taskId = transferTaskId.value
|
||||||
|
stopTransferProgress()
|
||||||
|
if (!taskId) {
|
||||||
|
transferring.value = false
|
||||||
|
transferError.value = '传输已取消'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await sftpApi.cancelRemoteTransferTask(taskId)
|
||||||
|
const task = res.data
|
||||||
|
transferProgress.value = Math.max(0, Math.min(100, task.progress || transferProgress.value))
|
||||||
|
transferTransferredBytes.value = Math.max(0, task.transferredBytes || transferTransferredBytes.value)
|
||||||
|
transferTotalBytes.value = Math.max(0, task.totalBytes || transferTotalBytes.value)
|
||||||
|
if (task.cancelRequested) {
|
||||||
|
transferError.value = '已请求取消传输'
|
||||||
|
} else {
|
||||||
|
transferError.value = task.message || '当前传输正在收尾,稍后会结束'
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const res = err as { response?: { data?: { error?: string } } }
|
||||||
|
transferError.value = res?.response?.data?.error ?? '取消传输失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
conn.value = store.getConnection(connectionId.value)
|
conn.value = store.getConnection(connectionId.value)
|
||||||
@@ -55,7 +179,7 @@ onMounted(() => {
|
|||||||
function initPath() {
|
function initPath() {
|
||||||
sftpApi.getPwd(connectionId.value).then((res) => {
|
sftpApi.getPwd(connectionId.value).then((res) => {
|
||||||
const p = res.data.path || '/'
|
const p = res.data.path || '/'
|
||||||
currentPath.value = p || '.'
|
currentPath.value = p === '/' ? '/' : p
|
||||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||||
loadPath()
|
loadPath()
|
||||||
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
||||||
@@ -85,38 +209,41 @@ function loadPath() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToDir(name: string) {
|
function navigateToDir(name: string) {
|
||||||
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
if (loading.value) return
|
||||||
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
||||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
currentPath.value = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
||||||
loadPath()
|
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||||
}
|
loadPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToIndex(i: number) {
|
||||||
|
if (loading.value) return
|
||||||
|
if (i < 0) {
|
||||||
|
currentPath.value = '.'
|
||||||
|
} else {
|
||||||
|
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
|
||||||
|
}
|
||||||
|
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||||
|
loadPath()
|
||||||
|
}
|
||||||
|
|
||||||
function navigateToIndex(i: number) {
|
function goUp() {
|
||||||
if (i < 0) {
|
if (loading.value) return
|
||||||
currentPath.value = '.'
|
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
||||||
} else {
|
return
|
||||||
currentPath.value = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
|
}
|
||||||
}
|
const parts = currentPath.value.split('/').filter(Boolean)
|
||||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
if (parts.length <= 1) {
|
||||||
loadPath()
|
currentPath.value = '/'
|
||||||
}
|
pathParts.value = ['']
|
||||||
|
} else {
|
||||||
function goUp() {
|
parts.pop()
|
||||||
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
currentPath.value = '/' + parts.join('/')
|
||||||
return
|
pathParts.value = parts
|
||||||
}
|
}
|
||||||
const parts = currentPath.value.split('/').filter(Boolean)
|
loadPath()
|
||||||
if (parts.length <= 1) {
|
}
|
||||||
currentPath.value = '/'
|
|
||||||
pathParts.value = ['']
|
|
||||||
} else {
|
|
||||||
parts.pop()
|
|
||||||
currentPath.value = '/' + parts.join('/')
|
|
||||||
pathParts.value = parts
|
|
||||||
}
|
|
||||||
loadPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileClick(file: SftpFileInfo) {
|
function handleFileClick(file: SftpFileInfo) {
|
||||||
if (file.directory) {
|
if (file.directory) {
|
||||||
@@ -146,20 +273,78 @@ async function handleFileSelect(e: Event) {
|
|||||||
uploading.value = true
|
uploading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||||
try {
|
|
||||||
for (let i = 0; i < selected.length; i++) {
|
const uploadTasks: { id: string; file: File; taskId?: string }[] = []
|
||||||
const file = selected[i]
|
for (let i = 0; i < selected.length; i++) {
|
||||||
if (!file) continue
|
const file = selected[i]
|
||||||
await sftpApi.uploadFile(connectionId.value, path, file)
|
if (!file) continue
|
||||||
}
|
uploadTasks.push({ id: `${Date.now()}-${i}`, file })
|
||||||
loadPath()
|
|
||||||
} catch {
|
|
||||||
error.value = '上传失败'
|
|
||||||
} finally {
|
|
||||||
uploading.value = false
|
|
||||||
input.value = ''
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
uploadProgressList.value = uploadTasks.map(({ id, file }) => ({
|
||||||
|
id,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
uploaded: 0,
|
||||||
|
total: file.size,
|
||||||
|
status: 'pending',
|
||||||
|
}))
|
||||||
|
|
||||||
|
showUploadProgress.value = true
|
||||||
|
|
||||||
|
const MAX_PARALLEL = 5
|
||||||
|
|
||||||
|
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
|
||||||
|
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
|
||||||
|
const batchPromises = batch.map(async task => {
|
||||||
|
if (!task) return
|
||||||
|
const { id, file } = task
|
||||||
|
const item = uploadProgressList.value.find(item => item.id === id)
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
item.status = 'uploading'
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start upload and get taskId
|
||||||
|
const uploadRes = await sftpApi.uploadFile(connectionId.value, path, file)
|
||||||
|
const taskId = uploadRes.data.taskId
|
||||||
|
|
||||||
|
// Poll for progress
|
||||||
|
while (true) {
|
||||||
|
const statusRes = await sftpApi.getUploadTask(taskId)
|
||||||
|
const taskStatus = statusRes.data
|
||||||
|
|
||||||
|
item.uploaded = taskStatus.transferredBytes
|
||||||
|
item.total = taskStatus.totalBytes
|
||||||
|
|
||||||
|
if (taskStatus.status === 'success') {
|
||||||
|
item.status = 'success'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (taskStatus.status === 'error') {
|
||||||
|
item.status = 'error'
|
||||||
|
item.message = taskStatus.error || 'Upload failed'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
item.status = 'error'
|
||||||
|
item.message = err?.response?.data?.error || 'Upload failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.allSettled(batchPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPath()
|
||||||
|
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
||||||
|
showUploadProgress.value = false
|
||||||
|
uploadProgressList.value = []
|
||||||
|
uploading.value = false
|
||||||
|
fileInputRef.value!.value = ''
|
||||||
|
toast.success(`成功上传 ${successCount} 个文件`)
|
||||||
|
}
|
||||||
|
|
||||||
function handleMkdir() {
|
function handleMkdir() {
|
||||||
const name = prompt('文件夹名称:')
|
const name = prompt('文件夹名称:')
|
||||||
@@ -196,13 +381,16 @@ async function openTransferModal(file: SftpFileInfo) {
|
|||||||
showTransferModal.value = true
|
showTransferModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTransferModal() {
|
function closeTransferModal() {
|
||||||
showTransferModal.value = false
|
if (transferring.value) return
|
||||||
transferFile.value = null
|
stopTransferProgress()
|
||||||
transferTargetConnectionId.value = null
|
showTransferModal.value = false
|
||||||
transferTargetPath.value = ''
|
transferFile.value = null
|
||||||
transferError.value = ''
|
transferTargetConnectionId.value = null
|
||||||
}
|
transferTargetPath.value = ''
|
||||||
|
transferError.value = ''
|
||||||
|
resetTransferProgress()
|
||||||
|
}
|
||||||
|
|
||||||
async function submitTransfer() {
|
async function submitTransfer() {
|
||||||
const file = transferFile.value
|
const file = transferFile.value
|
||||||
@@ -210,31 +398,25 @@ async function submitTransfer() {
|
|||||||
if (!file || targetId == null) return
|
if (!file || targetId == null) return
|
||||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
const sourcePath = base ? base + '/' + file.name : file.name
|
const sourcePath = base ? base + '/' + file.name : file.name
|
||||||
let targetPath = transferTargetPath.value.trim()
|
let targetPath = transferTargetPath.value.trim()
|
||||||
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
|
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
|
||||||
transferring.value = true
|
transferring.value = true
|
||||||
transferError.value = ''
|
transferError.value = ''
|
||||||
try {
|
resetTransferProgress()
|
||||||
await sftpApi.transferRemote(connectionId.value, sourcePath, targetId, targetPath)
|
try {
|
||||||
loadPath()
|
const created = await sftpApi.createRemoteTransferTask(connectionId.value, sourcePath, targetId, targetPath)
|
||||||
closeTransferModal()
|
await waitForTransferTask(created.data.taskId)
|
||||||
} catch (err: unknown) {
|
transferProgress.value = 100
|
||||||
const res = err as { response?: { data?: { error?: string } } }
|
await loadPath()
|
||||||
transferError.value = res?.response?.data?.error ?? '传输失败'
|
closeTransferModal()
|
||||||
} finally {
|
} catch (err: unknown) {
|
||||||
transferring.value = false
|
const res = err as { response?: { data?: { error?: string } } }
|
||||||
}
|
transferError.value = res?.response?.data?.error ?? (err as Error)?.message ?? '传输失败'
|
||||||
}
|
} finally {
|
||||||
|
stopTransferProgress()
|
||||||
function formatSize(bytes: number): string {
|
transferring.value = false
|
||||||
if (bytes < 1024) return bytes + ' B'
|
}
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
}
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
|
||||||
return new Date(ts).toLocaleString()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -254,8 +436,8 @@ function formatDate(ts: number): string {
|
|||||||
|
|
||||||
<div class="flex-1 overflow-auto p-4">
|
<div class="flex-1 overflow-auto p-4">
|
||||||
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||||
<div class="flex items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
|
||||||
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 flex-1">
|
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 w-full sm:flex-1">
|
||||||
<button
|
<button
|
||||||
@click="navigateToIndex(-1)"
|
@click="navigateToIndex(-1)"
|
||||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
|
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
|
||||||
@@ -272,12 +454,29 @@ function formatDate(ts: number): string {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="flex items-center gap-1 flex-shrink-0">
|
<div class="w-full sm:w-auto flex items-center gap-2 justify-end">
|
||||||
<button
|
<div class="flex-1 sm:flex-none">
|
||||||
@click="triggerUpload"
|
<input
|
||||||
:disabled="uploading"
|
v-model="searchQuery"
|
||||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
type="text"
|
||||||
aria-label="上传"
|
class="w-full sm:w-56 rounded-lg border border-slate-600 bg-slate-900/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||||
|
placeholder="搜索文件..."
|
||||||
|
aria-label="搜索文件"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="showHiddenFiles = !showHiddenFiles"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
:aria-label="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||||
|
:title="showHiddenFiles ? '隐藏隐藏文件' : '显示隐藏文件'"
|
||||||
|
>
|
||||||
|
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="triggerUpload"
|
||||||
|
:disabled="uploading"
|
||||||
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||||||
|
aria-label="上传"
|
||||||
>
|
>
|
||||||
<Upload class="w-4 h-4" aria-hidden="true" />
|
<Upload class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -306,6 +505,41 @@ function formatDate(ts: number): string {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showUploadProgress" class="bg-slate-800/50 border-b border-slate-700 p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-slate-300">上传进度: {{ totalProgress }}%</span>
|
||||||
|
<span class="text-sm text-slate-400">{{ currentUploadingFile || '准备上传...' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-slate-700 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="bg-cyan-600 h-full transition-all duration-300"
|
||||||
|
:style="{ width: totalProgress + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2 max-h-40 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="item in uploadProgressList"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle v-if="item.status === 'success'" class="w-4 h-4 flex-shrink-0 text-green-500" aria-hidden="true" />
|
||||||
|
<AlertCircle v-else-if="item.status === 'error'" class="w-4 h-4 flex-shrink-0 text-red-500" aria-hidden="true" />
|
||||||
|
<Loader v-else-if="item.status === 'uploading'" class="w-4 h-4 flex-shrink-0 text-cyan-500 animate-spin" aria-hidden="true" />
|
||||||
|
<File v-else class="w-4 h-4 flex-shrink-0 text-slate-500" aria-hidden="true" />
|
||||||
|
<span class="flex-1 truncate text-slate-300">{{ item.name }}</span>
|
||||||
|
<span class="text-slate-400 text-xs">
|
||||||
|
{{ formatSize(item.size) }}
|
||||||
|
<template v-if="item.status === 'uploading'">
|
||||||
|
({{ Math.round((item.uploaded / item.total) * 100) }}%)
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'success'">
|
||||||
|
✓
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
<div v-if="loading" class="p-8 text-center text-slate-400">
|
<div v-if="loading" class="p-8 text-center text-slate-400">
|
||||||
@@ -321,13 +555,13 @@ function formatDate(ts: number): string {
|
|||||||
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
||||||
<span class="text-slate-400">..</span>
|
<span class="text-slate-400">..</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="file in files"
|
v-for="file in filteredFiles"
|
||||||
:key="file.name"
|
:key="file.name"
|
||||||
@click="handleFileClick(file)"
|
@click="handleFileClick(file)"
|
||||||
@dblclick="file.directory ? navigateToDir(file.name) : handleDownload(file)"
|
@dblclick="!file.directory && handleDownload(file)"
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
:is="file.directory ? FolderOpen : File"
|
:is="file.directory ? FolderOpen : File"
|
||||||
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
||||||
@@ -364,12 +598,12 @@ function formatDate(ts: number): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="files.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
||||||
空目录
|
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -412,17 +646,32 @@ function formatDate(ts: number): string {
|
|||||||
placeholder="/"
|
placeholder="/"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
|
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
|
||||||
<div class="mt-5 flex justify-end gap-2">
|
<div v-if="transferring" class="mt-3 space-y-2">
|
||||||
<button
|
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||||
type="button"
|
<span>传输进度</span>
|
||||||
@click="closeTransferModal"
|
<span>{{ transferProgress }}%</span>
|
||||||
:disabled="transferring"
|
</div>
|
||||||
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 disabled:opacity-50 cursor-pointer"
|
<div class="h-2 overflow-hidden rounded-full bg-slate-700">
|
||||||
>
|
<div
|
||||||
取消
|
class="h-full bg-cyan-500 transition-all duration-300"
|
||||||
</button>
|
:style="{ width: transferProgress + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>{{ formatTransferBytes(transferTransferredBytes) }}</span>
|
||||||
|
<span>{{ transferTotalBytes > 0 ? formatTransferBytes(transferTotalBytes) : '--' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="transferring ? cancelTransfer() : closeTransferModal()"
|
||||||
|
class="rounded-lg border border-slate-600 px-4 py-2 text-slate-300 hover:bg-slate-700 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ transferring ? '取消传输' : '取消' }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="submitTransfer"
|
@click="submitTransfer"
|
||||||
|
|||||||
516
frontend/src/views/TransfersView.vue
Normal file
516
frontend/src/views/TransfersView.vue
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
|
import { useTransfersStore } from '../stores/transfers'
|
||||||
|
import SftpFilePickerModal from '../components/SftpFilePickerModal.vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowLeftRight,
|
||||||
|
CloudUpload,
|
||||||
|
FolderOpen,
|
||||||
|
XCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
type Tab = 'local' | 'remote'
|
||||||
|
|
||||||
|
const connectionsStore = useConnectionsStore()
|
||||||
|
const transfersStore = useTransfersStore()
|
||||||
|
|
||||||
|
const tab = ref<Tab>('local')
|
||||||
|
|
||||||
|
// Local -> many
|
||||||
|
const localFiles = ref<File[]>([])
|
||||||
|
const localTargetDir = ref('/')
|
||||||
|
const localSelectedTargets = ref<number[]>([])
|
||||||
|
const localConcurrency = ref(3)
|
||||||
|
|
||||||
|
// Remote -> many
|
||||||
|
const remoteSourceConnectionId = ref<number | null>(null)
|
||||||
|
const remoteSourcePath = ref('')
|
||||||
|
const remoteTargetDirOrPath = ref('/')
|
||||||
|
const remoteSelectedTargets = ref<number[]>([])
|
||||||
|
const remoteConcurrency = ref(3)
|
||||||
|
|
||||||
|
// Picker
|
||||||
|
const pickerOpen = ref(false)
|
||||||
|
|
||||||
|
const connections = computed(() => connectionsStore.connections)
|
||||||
|
const connectionOptions = computed(() => connections.value.slice().sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
|
||||||
|
// Remote -> Many 模式下的目标连接列表(排除源连接)
|
||||||
|
const remoteTargetConnectionOptions = computed(() =>
|
||||||
|
connectionOptions.value.filter((c) => c.id !== remoteSourceConnectionId.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const canStartLocal = computed(() => localFiles.value.length > 0 && localSelectedTargets.value.length > 0)
|
||||||
|
const canStartRemote = computed(
|
||||||
|
() => remoteSourceConnectionId.value != null && remoteSourcePath.value.trim() && remoteSelectedTargets.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function onLocalFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const list = input.files
|
||||||
|
if (!list) return
|
||||||
|
localFiles.value = Array.from(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllLocalTargets() {
|
||||||
|
localSelectedTargets.value = connectionOptions.value.map((c) => c.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalTargets() {
|
||||||
|
localSelectedTargets.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllRemoteTargets() {
|
||||||
|
remoteSelectedTargets.value = remoteTargetConnectionOptions.value.map((c) => c.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRemoteTargets() {
|
||||||
|
remoteSelectedTargets.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanRunStatus(status: string) {
|
||||||
|
if (status === 'queued') return 'Queued'
|
||||||
|
if (status === 'running') return 'Running'
|
||||||
|
if (status === 'success') return 'Success'
|
||||||
|
if (status === 'error') return 'Error'
|
||||||
|
if (status === 'cancelled') return 'Cancelled'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBadgeClass(status: string) {
|
||||||
|
if (status === 'success') return 'bg-emerald-500/10 text-emerald-200 border-emerald-500/20'
|
||||||
|
if (status === 'error') return 'bg-red-500/10 text-red-200 border-red-500/20'
|
||||||
|
if (status === 'running') return 'bg-cyan-500/10 text-cyan-200 border-cyan-500/20'
|
||||||
|
if (status === 'cancelled') return 'bg-slate-500/10 text-slate-200 border-slate-500/20'
|
||||||
|
return 'bg-amber-500/10 text-amber-200 border-amber-500/20'
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProgressPercent(run: { items: { status: string; progress?: number }[]; lastUpdate?: number }) {
|
||||||
|
// Access lastUpdate to ensure reactivity
|
||||||
|
void run.lastUpdate
|
||||||
|
const items = run.items
|
||||||
|
if (!items.length) return 0
|
||||||
|
let sum = 0
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.status === 'success' || it.status === 'error' || it.status === 'cancelled') {
|
||||||
|
sum += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (it.status === 'running') {
|
||||||
|
const p = typeof it.progress === 'number' ? it.progress : 0
|
||||||
|
sum += Math.max(0, Math.min(1, p / 100))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// queued
|
||||||
|
sum += 0
|
||||||
|
}
|
||||||
|
return Math.round((sum / items.length) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startLocal() {
|
||||||
|
if (!canStartLocal.value) return
|
||||||
|
await transfersStore.startLocalToMany({
|
||||||
|
files: localFiles.value,
|
||||||
|
targetConnectionIds: localSelectedTargets.value,
|
||||||
|
targetDir: localTargetDir.value,
|
||||||
|
concurrency: localConcurrency.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRemote() {
|
||||||
|
if (!canStartRemote.value || remoteSourceConnectionId.value == null) return
|
||||||
|
await transfersStore.startRemoteToMany({
|
||||||
|
sourceConnectionId: remoteSourceConnectionId.value,
|
||||||
|
sourcePath: remoteSourcePath.value.trim(),
|
||||||
|
targetConnectionIds: remoteSelectedTargets.value,
|
||||||
|
targetDirOrPath: remoteTargetDirOrPath.value,
|
||||||
|
concurrency: remoteConcurrency.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
if (remoteSourceConnectionId.value == null) return
|
||||||
|
pickerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (connectionsStore.connections.length === 0) {
|
||||||
|
await connectionsStore.fetchConnections().catch(() => {})
|
||||||
|
}
|
||||||
|
if (remoteSourceConnectionId.value == null) {
|
||||||
|
remoteSourceConnectionId.value = connectionOptions.value[0]?.id ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 lg:p-8">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-slate-50">Transfers</h1>
|
||||||
|
<p class="text-sm text-slate-400">本机 -> 多台 / 其他机器 -> 多台</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="transfersStore.clearRuns"
|
||||||
|
class="min-h-[44px] inline-flex items-center gap-2 px-3 rounded-lg border border-slate-700 bg-slate-900/40 text-slate-200 hover:bg-slate-800/60 transition-colors cursor-pointer"
|
||||||
|
aria-label="清空队列"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||||
|
清空队列
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
@click="tab = 'local'"
|
||||||
|
class="rounded-2xl border p-4 text-left transition-colors cursor-pointer min-h-[88px]"
|
||||||
|
:class="tab === 'local' ? 'border-cyan-500/40 bg-slate-900/55' : 'border-slate-800 bg-slate-900/35 hover:bg-slate-900/45'"
|
||||||
|
aria-label="切换到本机上传"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/20 grid place-items-center">
|
||||||
|
<CloudUpload class="w-5 h-5 text-cyan-200" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-slate-100">Local -> Many</p>
|
||||||
|
<p class="text-xs text-slate-400">选择本机文件,分发到多个目标连接</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="tab = 'remote'"
|
||||||
|
class="rounded-2xl border p-4 text-left transition-colors cursor-pointer min-h-[88px]"
|
||||||
|
:class="tab === 'remote' ? 'border-cyan-500/40 bg-slate-900/55' : 'border-slate-800 bg-slate-900/35 hover:bg-slate-900/45'"
|
||||||
|
aria-label="切换到远程转发"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/20 grid place-items-center">
|
||||||
|
<ArrowLeftRight class="w-5 h-5 text-cyan-200" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-slate-100">Remote -> Many</p>
|
||||||
|
<p class="text-xs text-slate-400">从一台机器取文件,推送到多个目标连接</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-6 lg:grid-cols-[1fr_460px]">
|
||||||
|
<section class="rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur p-5">
|
||||||
|
<div v-if="connections.length === 0" class="text-slate-300">
|
||||||
|
<p class="text-sm">暂无连接。请先在 Connections 里添加连接。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="tab === 'local'" class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-100">Local -> Many</h2>
|
||||||
|
<span class="text-xs text-slate-400">并发: {{ localConcurrency }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<label class="text-sm text-slate-300">选择本机文件</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="block w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-200 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-800 file:px-3 file:py-2 file:text-slate-200 hover:file:bg-slate-700"
|
||||||
|
@change="onLocalFileChange"
|
||||||
|
aria-label="选择本机文件"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-slate-500">
|
||||||
|
已选择 {{ localFiles.length }} 个文件
|
||||||
|
<span v-if="localFiles.length">(只支持文件,目录请先打包)</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<label for="local-target-dir" class="text-sm text-slate-300">目标目录</label>
|
||||||
|
<input
|
||||||
|
id="local-target-dir"
|
||||||
|
v-model="localTargetDir"
|
||||||
|
type="text"
|
||||||
|
placeholder="/"
|
||||||
|
class="w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-sm text-slate-300">目标连接</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="selectAllLocalTargets"
|
||||||
|
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="clearLocalTargets"
|
||||||
|
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="c in connectionOptions"
|
||||||
|
:key="c.id"
|
||||||
|
class="flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-950/20 px-3 py-2 min-h-[44px] cursor-pointer hover:bg-slate-950/30"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="localSelectedTargets"
|
||||||
|
:value="c.id"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-cyan-500 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm text-slate-100 truncate">{{ c.name }}</p>
|
||||||
|
<p class="text-xs text-slate-500 truncate">{{ c.username }}@{{ c.host }}:{{ c.port }}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<label for="local-concurrency" class="text-sm text-slate-300">并发</label>
|
||||||
|
<input
|
||||||
|
id="local-concurrency"
|
||||||
|
v-model.number="localConcurrency"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="6"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-slate-500">建议 2-4。并发越高越吃带宽与 CPU。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="startLocal"
|
||||||
|
:disabled="!canStartLocal"
|
||||||
|
class="min-h-[44px] inline-flex items-center justify-center gap-2 rounded-xl bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
aria-label="开始上传"
|
||||||
|
>
|
||||||
|
<ArrowUpRight class="w-4 h-4" aria-hidden="true" />
|
||||||
|
开始分发
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-100">Remote -> Many</h2>
|
||||||
|
<span class="text-xs text-slate-400">并发: {{ remoteConcurrency }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<label for="remote-source-conn" class="text-sm text-slate-300">源连接</label>
|
||||||
|
<select
|
||||||
|
id="remote-source-conn"
|
||||||
|
v-model.number="remoteSourceConnectionId"
|
||||||
|
class="w-full min-h-[44px] rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
>
|
||||||
|
<option v-for="c in connectionOptions" :key="c.id" :value="c.id">{{ c.name }} ({{ c.username }}@{{ c.host }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<label for="remote-source-path" class="text-sm text-slate-300">源文件路径</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="remote-source-path"
|
||||||
|
v-model="remoteSourcePath"
|
||||||
|
type="text"
|
||||||
|
placeholder="/path/to/file"
|
||||||
|
class="flex-1 rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="openPicker"
|
||||||
|
:disabled="remoteSourceConnectionId == null"
|
||||||
|
class="min-h-[44px] px-3 rounded-xl border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 disabled:opacity-50 cursor-pointer transition-colors"
|
||||||
|
aria-label="浏览远程文件"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||||
|
浏览
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<label for="remote-target-dir" class="text-sm text-slate-300">目标目录或路径</label>
|
||||||
|
<input
|
||||||
|
id="remote-target-dir"
|
||||||
|
v-model="remoteTargetDirOrPath"
|
||||||
|
type="text"
|
||||||
|
placeholder="/target/dir/"
|
||||||
|
class="w-full rounded-xl border border-slate-700 bg-slate-950/30 px-3 py-2 text-sm text-slate-100 placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-slate-500">以 / 结尾视为目录,会自动拼接文件名。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-sm text-slate-300">目标连接</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="selectAllRemoteTargets"
|
||||||
|
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="clearRemoteTargets"
|
||||||
|
class="min-h-[44px] px-3 rounded-lg border border-slate-700 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="c in remoteTargetConnectionOptions"
|
||||||
|
:key="c.id"
|
||||||
|
class="flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-950/20 px-3 py-2 min-h-[44px] cursor-pointer hover:bg-slate-950/30"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="remoteSelectedTargets"
|
||||||
|
:value="c.id"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-cyan-500 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm text-slate-100 truncate">{{ c.name }}</p>
|
||||||
|
<p class="text-xs text-slate-500 truncate">{{ c.username }}@{{ c.host }}:{{ c.port }}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="remoteTargetConnectionOptions.length === 0" class="text-xs text-amber-400">
|
||||||
|
没有可用的目标连接(源连接已自动排除)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<label for="remote-concurrency" class="text-sm text-slate-300">并发</label>
|
||||||
|
<input
|
||||||
|
id="remote-concurrency"
|
||||||
|
v-model.number="remoteConcurrency"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="6"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-slate-500">后端是逐个调用 transfer-remote;并发适中即可。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="startRemote"
|
||||||
|
:disabled="!canStartRemote"
|
||||||
|
class="min-h-[44px] inline-flex items-center justify-center gap-2 rounded-xl bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
aria-label="开始远程转发"
|
||||||
|
>
|
||||||
|
<ArrowUpRight class="w-4 h-4" aria-hidden="true" />
|
||||||
|
开始转发
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-100">Queue</h2>
|
||||||
|
<span class="text-xs text-slate-500">最近 20 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="transfersStore.recentRuns.length === 0" class="mt-4 text-sm text-slate-500">
|
||||||
|
暂无任务。创建一个 plan 然后开始。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-4 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="run in transfersStore.recentRuns"
|
||||||
|
:key="run.id"
|
||||||
|
class="rounded-2xl border border-slate-800 bg-slate-950/20 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-slate-100 truncate">{{ run.title }}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500 truncate">{{ new Date(run.createdAt).toLocaleString() }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full border" :class="runBadgeClass(run.status)">
|
||||||
|
{{ humanRunStatus(run.status) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="run.status === 'running' || run.status === 'queued'"
|
||||||
|
@click="transfersStore.cancelRun(run.id)"
|
||||||
|
class="w-10 h-10 grid place-items-center rounded-lg border border-slate-800 bg-slate-900/30 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer"
|
||||||
|
aria-label="取消任务"
|
||||||
|
>
|
||||||
|
<XCircle class="w-5 h-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<span>{{ run.items.length }} items</span>
|
||||||
|
<span>{{ runProgressPercent(run) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full bg-cyan-500/80 transition-all duration-200"
|
||||||
|
:style="{ width: runProgressPercent(run) + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 max-h-48 overflow-auto space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in run.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-start gap-2 rounded-xl border border-slate-800 bg-slate-950/10 px-3 py-2"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="item.status === 'running'" class="w-4 h-4 mt-0.5 text-cyan-300 animate-spin" aria-hidden="true" />
|
||||||
|
<CheckCircle2 v-else-if="item.status === 'success'" class="w-4 h-4 mt-0.5 text-emerald-300" aria-hidden="true" />
|
||||||
|
<AlertTriangle v-else-if="item.status === 'error'" class="w-4 h-4 mt-0.5 text-red-300" aria-hidden="true" />
|
||||||
|
<XCircle v-else-if="item.status === 'cancelled'" class="w-4 h-4 mt-0.5 text-slate-300" aria-hidden="true" />
|
||||||
|
<span v-else class="w-4 h-4 mt-0.5 rounded-full bg-amber-400/30" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs text-slate-200 truncate">{{ item.label }}</p>
|
||||||
|
<p v-if="item.status === 'running' && item.progress != null" class="mt-1 text-[11px] text-slate-500">
|
||||||
|
{{ item.progress }}%
|
||||||
|
</p>
|
||||||
|
<p v-if="item.status === 'error' && item.message" class="mt-1 text-[11px] text-red-300 break-words">
|
||||||
|
{{ item.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SftpFilePickerModal
|
||||||
|
:open="pickerOpen"
|
||||||
|
:connection-id="remoteSourceConnectionId"
|
||||||
|
@close="pickerOpen = false"
|
||||||
|
@select="(p) => (remoteSourcePath = p)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -12,5 +12,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user