Compare commits
8 Commits
9f133bd337
...
f606d20000
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f606d20000 | ||
|
|
bba36a2e12 | ||
|
|
4af11fb043 | ||
|
|
cf7b564b3a | ||
|
|
55953dce83 | ||
|
|
e23ba1c3c9 | ||
|
|
caed481d23 | ||
|
|
2c06329d68 |
204
MOBA_IMPLEMENTATION_STATUS.md
Normal file
204
MOBA_IMPLEMENTATION_STATUS.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# MobaXterm 风格重构实施状态
|
||||
|
||||
## 已完成 ✅
|
||||
|
||||
### Phase 1: 基础架构 (100%)
|
||||
- ✅ 安装依赖 @vueuse/core
|
||||
- ✅ 创建类型定义
|
||||
- `frontend/src/types/sessionTree.ts`
|
||||
- `frontend/src/types/workspace.ts`
|
||||
- ✅ 实现 Pinia stores
|
||||
- `frontend/src/stores/sessionTree.ts`
|
||||
- `frontend/src/stores/workspace.ts`
|
||||
|
||||
### Phase 2: 核心组件开发 (100%)
|
||||
- ✅ `SplitPane.vue` - 可拖拽分割面板
|
||||
- ✅ `TopToolbar.vue` - 顶部工具栏(样式占位)
|
||||
- ✅ `SessionTreeNode.vue` - 递归树节点组件
|
||||
- ✅ `SessionTree.vue` - 会话树主组件
|
||||
- ✅ `SftpPanel.vue` - SFTP 面板(props 驱动)
|
||||
- ✅ `WorkspacePanel.vue` - 工作区面板
|
||||
|
||||
### Phase 3: 拖拽功能 (100%)
|
||||
- ✅ `useTreeDragDrop` composable 实现
|
||||
- ✅ SessionTreeNode 集成拖拽事件
|
||||
- ✅ 拖拽约束(不能拖到自己/子节点)
|
||||
- ✅ 拖拽视觉反馈(半透明、指示线、高亮)
|
||||
- ✅ 支持 before/after/inside 放置位置
|
||||
|
||||
### Phase 4: 布局集成 (100%)
|
||||
- ✅ `MobaLayout.vue` - 主布局
|
||||
- ✅ 路由配置更新
|
||||
- 新增 `/moba` 路由
|
||||
- 默认首页重定向到 `/moba`
|
||||
- ✅ 构建测试通过
|
||||
|
||||
### Phase 5: 数据迁移和同步 (100%)
|
||||
- ✅ `MigrationPrompt.vue` - 迁移提示组件
|
||||
- ✅ `useConnectionSync` composable - 双向同步
|
||||
- ✅ syncNewConnections/syncDeletedConnections 方法
|
||||
- ✅ syncConnectionName 方法
|
||||
- ✅ 首次使用迁移提示
|
||||
- ✅ 自动同步新增/删除的连接
|
||||
|
||||
### Phase 6: 优化完善 (100%)
|
||||
- ✅ `useKeyboardShortcuts` composable
|
||||
- ✅ 键盘快捷键: F2(重命名), Delete(删除), Ctrl+N(新建文件夹), Ctrl+F(搜索)
|
||||
- ✅ `ContextMenu.vue` - 右键菜单组件
|
||||
- ✅ 右键菜单: 重命名、删除、新建子文件夹
|
||||
- ✅ 重命名对话框
|
||||
- ✅ 删除确认提示
|
||||
- ✅ `useTreeSearch` - 搜索功能
|
||||
- ✅ 搜索高亮和结果过滤
|
||||
- ✅ 展开/折叠全部功能
|
||||
- ⏳ 响应式设计(移动端适配)
|
||||
- ⏳ 性能优化(虚拟滚动)
|
||||
|
||||
## 当前状态
|
||||
|
||||
### 可用功能
|
||||
1. **会话树管理**
|
||||
- 创建文件夹(按钮 + Ctrl+N)
|
||||
- 展开/折叠文件夹
|
||||
- 展开/折叠全部(工具栏按钮)
|
||||
- 拖拽节点重新排序
|
||||
- 拖拽节点到文件夹
|
||||
- 搜索会话(Ctrl+F)
|
||||
- 搜索结果高亮
|
||||
- 右键菜单操作
|
||||
- F2 重命名节点
|
||||
- Delete 删除节点
|
||||
- localStorage 持久化
|
||||
|
||||
2. **工作区面板**
|
||||
- 垂直分屏(终端 + SFTP)
|
||||
- 可拖拽调整分割比例
|
||||
- 分割比例持久化
|
||||
- 每个连接独立工作区
|
||||
|
||||
3. **终端集成**
|
||||
- 复用现有 TerminalWidget
|
||||
- 实时监控面板
|
||||
- WebSocket 连接
|
||||
|
||||
4. **SFTP 功能**
|
||||
- 文件浏览
|
||||
- 上传/下载
|
||||
- 创建文件夹
|
||||
- 删除文件
|
||||
|
||||
5. **数据迁移**
|
||||
- 首次使用迁移提示
|
||||
- 自动同步连接变更
|
||||
- 新旧布局共存
|
||||
|
||||
### 访问方式
|
||||
- 新布局: http://localhost:5173/moba
|
||||
- 旧布局: http://localhost:5173/connections (保留兼容)
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. 启动后端服务
|
||||
```bash
|
||||
cd backend
|
||||
go run main.go
|
||||
```
|
||||
|
||||
2. 启动前端服务
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. 访问新布局
|
||||
```
|
||||
http://localhost:5173/moba
|
||||
```
|
||||
|
||||
4. 测试功能
|
||||
- 点击"文件夹"按钮或按 Ctrl+N 创建文件夹
|
||||
- 使用工具栏按钮展开/折叠全部文件夹
|
||||
- 按 Ctrl+F 或点击搜索框搜索会话
|
||||
- 拖拽节点重新排序或移动到文件夹
|
||||
- 右键点击节点查看菜单
|
||||
- 按 F2 重命名选中节点
|
||||
- 按 Delete 删除选中节点
|
||||
- 点击连接节点打开工作区
|
||||
- 拖拽分割条调整终端/SFTP 比例
|
||||
- 刷新页面验证状态持久化
|
||||
|
||||
## 已知问题
|
||||
|
||||
1. **SFTP 功能简化**
|
||||
- 移除了搜索和隐藏文件功能
|
||||
- 移除了远程传输功能
|
||||
- 上传进度显示简化
|
||||
|
||||
2. **待优化项**
|
||||
- 响应式设计(移动端适配)
|
||||
- 大量节点时的虚拟滚动优化
|
||||
|
||||
## 下一步计划
|
||||
|
||||
1. **短期优化**
|
||||
- 响应式设计(移动端/平板适配)
|
||||
- 性能优化(虚拟滚动,大量节点场景)
|
||||
- 添加更多键盘快捷键(Ctrl+C复制,Ctrl+V粘贴等)
|
||||
|
||||
2. **长期扩展**
|
||||
- 多工作区支持(标签页)
|
||||
- 会话模板功能
|
||||
- 云端同步
|
||||
- 导入/导出配置
|
||||
- 会话分组颜色标记
|
||||
- 连接状态实时显示
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3.5.24 (Composition API)
|
||||
- Pinia 3.0.4 (状态管理)
|
||||
- Vue Router 5.0.2 (路由)
|
||||
- Tailwind CSS 3.4.14 (样式)
|
||||
- @vueuse/core (工具库)
|
||||
- xterm.js 5.3.0 (终端)
|
||||
- lucide-vue-next (图标)
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── types/
|
||||
│ ├── sessionTree.ts # 会话树类型定义
|
||||
│ └── workspace.ts # 工作区类型定义
|
||||
├── stores/
|
||||
│ ├── sessionTree.ts # 会话树状态管理
|
||||
│ └── workspace.ts # 工作区状态管理
|
||||
├── composables/
|
||||
│ ├── useTreeDragDrop.ts # 拖拽逻辑
|
||||
│ ├── useConnectionSync.ts # 连接同步
|
||||
│ ├── useKeyboardShortcuts.ts # 键盘快捷键
|
||||
│ └── useTreeSearch.ts # 会话树搜索
|
||||
├── components/
|
||||
│ ├── SessionTree.vue # 会话树主组件
|
||||
│ ├── SessionTreeNode.vue # 树节点组件(递归)
|
||||
│ ├── WorkspacePanel.vue # 工作区面板
|
||||
│ ├── SplitPane.vue # 分割面板
|
||||
│ ├── SftpPanel.vue # SFTP 面板
|
||||
│ ├── TopToolbar.vue # 顶部工具栏
|
||||
│ ├── ContextMenu.vue # 右键菜单
|
||||
│ └── MigrationPrompt.vue # 迁移提示
|
||||
└── layouts/
|
||||
└── MobaLayout.vue # MobaXterm 风格主布局
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-04-03
|
||||
**实施进度**: Phase 1-6 全部完成
|
||||
**Git 提交**: 6 个提交
|
||||
- feat: implement MobaXterm-style layout (Phase 1-2-4)
|
||||
- feat: implement drag-drop and data migration (Phase 3 & 5)
|
||||
- feat: add keyboard shortcuts and context menu (Phase 6)
|
||||
- feat: add session tree search functionality
|
||||
- feat: add expand/collapse all functionality
|
||||
- docs: update implementation status - all phases complete
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.SessionTreeLayoutService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/session-tree")
|
||||
public class SessionTreeLayoutController {
|
||||
|
||||
private final SessionTreeLayoutService sessionTreeLayoutService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public SessionTreeLayoutController(SessionTreeLayoutService sessionTreeLayoutService,
|
||||
UserRepository userRepository) {
|
||||
this.sessionTreeLayoutService = sessionTreeLayoutService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<SessionTreeLayoutDto> getLayout(Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(sessionTreeLayoutService.getLayout(userId));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public ResponseEntity<SessionTreeLayoutDto> saveLayout(@RequestBody SessionTreeLayoutDto request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(sessionTreeLayoutService.saveLayout(userId, request));
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SessionTreeLayoutDto {
|
||||
|
||||
private List<SessionTreeNodeDto> nodes = new ArrayList<SessionTreeNodeDto>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SessionTreeNodeDto {
|
||||
|
||||
private String id;
|
||||
private String type;
|
||||
private String name;
|
||||
private String parentId;
|
||||
private Integer order;
|
||||
private Long connectionId;
|
||||
private Boolean expanded;
|
||||
private Long createdAt;
|
||||
private Long updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.sshmanager.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "session_tree_layouts")
|
||||
public class SessionTreeLayout {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "CLOB")
|
||||
private String layoutJson;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sshmanager.repository;
|
||||
|
||||
import com.sshmanager.entity.SessionTreeLayout;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SessionTreeLayoutRepository extends JpaRepository<SessionTreeLayout, Long> {
|
||||
|
||||
Optional<SessionTreeLayout> findByUserId(Long userId);
|
||||
}
|
||||
@@ -100,15 +100,17 @@ public class ConnectionService {
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
connectionRepository.delete(conn);
|
||||
}
|
||||
@Transactional
|
||||
public void delete(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElse(null);
|
||||
if (conn == null) {
|
||||
return;
|
||||
}
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
connectionRepository.delete(conn);
|
||||
}
|
||||
|
||||
public Connection getConnectionForSsh(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||
import com.sshmanager.entity.SessionTreeLayout;
|
||||
import com.sshmanager.repository.SessionTreeLayoutRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Service
|
||||
public class SessionTreeLayoutService {
|
||||
|
||||
private final SessionTreeLayoutRepository sessionTreeLayoutRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SessionTreeLayoutService(SessionTreeLayoutRepository sessionTreeLayoutRepository,
|
||||
ObjectMapper objectMapper) {
|
||||
this.sessionTreeLayoutRepository = sessionTreeLayoutRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SessionTreeLayoutDto getLayout(Long userId) {
|
||||
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElse(null);
|
||||
if (layout == null || layout.getLayoutJson() == null || layout.getLayoutJson().trim().isEmpty()) {
|
||||
return createEmptyLayout();
|
||||
}
|
||||
|
||||
try {
|
||||
SessionTreeLayoutDto parsed = objectMapper.readValue(layout.getLayoutJson(), SessionTreeLayoutDto.class);
|
||||
if (parsed.getNodes() == null) {
|
||||
parsed.setNodes(new ArrayList<>());
|
||||
}
|
||||
return parsed;
|
||||
} catch (Exception e) {
|
||||
return createEmptyLayout();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SessionTreeLayoutDto saveLayout(Long userId, SessionTreeLayoutDto request) {
|
||||
SessionTreeLayoutDto payload = request == null ? createEmptyLayout() : request;
|
||||
if (payload.getNodes() == null) {
|
||||
payload.setNodes(new ArrayList<>());
|
||||
}
|
||||
|
||||
final String layoutJson;
|
||||
try {
|
||||
layoutJson = objectMapper.writeValueAsString(payload);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to serialize session tree layout", e);
|
||||
}
|
||||
|
||||
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElseGet(SessionTreeLayout::new);
|
||||
layout.setUserId(userId);
|
||||
layout.setLayoutJson(layoutJson);
|
||||
layout.setUpdatedAt(Instant.now());
|
||||
sessionTreeLayoutRepository.save(layout);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private SessionTreeLayoutDto createEmptyLayout() {
|
||||
return new SessionTreeLayoutDto(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.SessionTreeLayoutService;
|
||||
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.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SessionTreeLayoutControllerTest {
|
||||
|
||||
@Mock
|
||||
private SessionTreeLayoutService sessionTreeLayoutService;
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private SessionTreeLayoutController sessionTreeLayoutController;
|
||||
|
||||
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 getLayoutUsesCurrentUserId() {
|
||||
SessionTreeLayoutDto expected = new SessionTreeLayoutDto();
|
||||
when(sessionTreeLayoutService.getLayout(1L)).thenReturn(expected);
|
||||
|
||||
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.getLayout(authentication);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(expected, response.getBody());
|
||||
verify(sessionTreeLayoutService).getLayout(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLayoutUsesCurrentUserId() {
|
||||
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
|
||||
when(sessionTreeLayoutService.saveLayout(1L, request)).thenReturn(request);
|
||||
|
||||
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.saveLayout(request, authentication);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(request, response.getBody());
|
||||
verify(sessionTreeLayoutService).saveLayout(1L, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||
import com.sshmanager.dto.SessionTreeNodeDto;
|
||||
import com.sshmanager.entity.SessionTreeLayout;
|
||||
import com.sshmanager.repository.SessionTreeLayoutRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SessionTreeLayoutServiceTest {
|
||||
|
||||
@Mock
|
||||
private SessionTreeLayoutRepository sessionTreeLayoutRepository;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private SessionTreeLayoutService sessionTreeLayoutService;
|
||||
|
||||
@Test
|
||||
void getLayoutReturnsEmptyWhenNoSavedData() {
|
||||
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||
|
||||
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
|
||||
|
||||
assertTrue(result.getNodes().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLayoutParsesSavedJson() throws Exception {
|
||||
SessionTreeLayout saved = new SessionTreeLayout();
|
||||
saved.setUserId(1L);
|
||||
saved.setLayoutJson("{\"nodes\":[{\"id\":\"n1\"}]}");
|
||||
|
||||
SessionTreeLayoutDto parsed = new SessionTreeLayoutDto(Arrays.asList(
|
||||
new SessionTreeNodeDto("n1", "folder", "我的连接", null, 0, null, true, 1L, 1L)
|
||||
));
|
||||
|
||||
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.of(saved));
|
||||
when(objectMapper.readValue(saved.getLayoutJson(), SessionTreeLayoutDto.class)).thenReturn(parsed);
|
||||
|
||||
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
|
||||
|
||||
assertEquals(1, result.getNodes().size());
|
||||
assertEquals("n1", result.getNodes().get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLayoutNormalizesNullNodes() throws Exception {
|
||||
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
|
||||
request.setNodes(null);
|
||||
|
||||
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
|
||||
|
||||
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, request);
|
||||
|
||||
assertTrue(result.getNodes().isEmpty());
|
||||
ArgumentCaptor<SessionTreeLayout> captor = ArgumentCaptor.forClass(SessionTreeLayout.class);
|
||||
verify(sessionTreeLayoutRepository).save(captor.capture());
|
||||
assertEquals(1L, captor.getValue().getUserId().longValue());
|
||||
assertEquals("{\"nodes\":[]}", captor.getValue().getLayoutJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLayoutCreatesEmptyPayloadWhenRequestIsNull() throws Exception {
|
||||
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
|
||||
|
||||
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, null);
|
||||
|
||||
assertEquals(Collections.emptyList(), result.getNodes());
|
||||
}
|
||||
}
|
||||
45
frontend/package-lock.json
generated
45
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"axios": "^1.13.4",
|
||||
@@ -1004,6 +1005,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
|
||||
@@ -1245,6 +1252,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.2.1.tgz",
|
||||
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.2.1",
|
||||
"@vueuse/shared": "14.2.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.2.1.tgz",
|
||||
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.2.1.tgz",
|
||||
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-attach": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.12.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"axios": "^1.13.4",
|
||||
|
||||
27
frontend/src/api/sessionTree.ts
Normal file
27
frontend/src/api/sessionTree.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import client from './client'
|
||||
|
||||
export type SessionTreeNodeType = 'folder' | 'connection'
|
||||
|
||||
export interface SessionTreeNodePayload {
|
||||
id: string
|
||||
type: SessionTreeNodeType
|
||||
name: string
|
||||
parentId: string | null
|
||||
order: number
|
||||
connectionId?: number
|
||||
expanded?: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface SessionTreeLayoutPayload {
|
||||
nodes: SessionTreeNodePayload[]
|
||||
}
|
||||
|
||||
export function getSessionTree() {
|
||||
return client.get<SessionTreeLayoutPayload>('/session-tree')
|
||||
}
|
||||
|
||||
export function saveSessionTree(payload: SessionTreeLayoutPayload) {
|
||||
return client.put<SessionTreeLayoutPayload>('/session-tree', payload)
|
||||
}
|
||||
115
frontend/src/components/ContextMenu.vue
Normal file
115
frontend/src/components/ContextMenu.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string
|
||||
icon?: any
|
||||
action: () => void
|
||||
divider?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: ContextMenuItem[]
|
||||
x: number
|
||||
y: number
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement>()
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
if (!menuRef.value) {
|
||||
return { left: `${props.x}px`, top: `${props.y}px` }
|
||||
}
|
||||
|
||||
const rect = menuRef.value.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let left = props.x
|
||||
let top = props.y
|
||||
|
||||
// Adjust if menu goes off right edge
|
||||
if (left + rect.width > viewportWidth) {
|
||||
left = viewportWidth - rect.width - 8
|
||||
}
|
||||
|
||||
// Adjust if menu goes off bottom edge
|
||||
if (top + rect.height > viewportHeight) {
|
||||
top = viewportHeight - rect.height - 8
|
||||
}
|
||||
|
||||
return { left: `${left}px`, top: `${top}px` }
|
||||
})
|
||||
|
||||
function handleClick(item: ContextMenuItem) {
|
||||
if (!item.disabled) {
|
||||
item.action()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
ref="menuRef"
|
||||
class="fixed z-50 min-w-[180px] bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<div
|
||||
v-if="item.divider"
|
||||
class="h-px bg-slate-700 my-1"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors"
|
||||
:class="{
|
||||
'text-slate-300 hover:bg-slate-700': !item.disabled,
|
||||
'text-slate-500 cursor-not-allowed': item.disabled,
|
||||
}"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
/>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
92
frontend/src/components/MigrationPrompt.vue
Normal file
92
frontend/src/components/MigrationPrompt.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Info, X } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
migrate: []
|
||||
dismiss: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const dontShowAgain = ref(false)
|
||||
|
||||
function handleMigrate() {
|
||||
if (dontShowAgain.value) {
|
||||
localStorage.setItem('ssh-manager.migration-dismissed', 'true')
|
||||
}
|
||||
emit('migrate')
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
if (dontShowAgain.value) {
|
||||
localStorage.setItem('ssh-manager.migration-dismissed', 'true')
|
||||
}
|
||||
emit('dismiss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="bg-slate-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="flex items-start gap-3 p-5 border-b border-slate-700">
|
||||
<Info class="w-5 h-5 text-cyan-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h3 class="text-base font-medium text-slate-100">欢迎使用新版布局</h3>
|
||||
<p class="text-sm text-slate-400 mt-1">
|
||||
我们检测到您之前使用过旧版布局,是否要迁移您的会话数据?
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="bg-slate-900 rounded p-3 text-sm text-slate-300">
|
||||
<p class="font-medium mb-2">迁移内容:</p>
|
||||
<ul class="space-y-1 text-slate-400">
|
||||
<li>• 所有连接将被导入到会话树</li>
|
||||
<li>• 打开的终端和 SFTP 标签页将被保留</li>
|
||||
<li>• 您可以随时切换回旧版布局</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input
|
||||
v-model="dontShowAgain"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-slate-600 bg-slate-900 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span>不再显示此提示</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 p-5 border-t border-slate-700">
|
||||
<button
|
||||
@click="handleMigrate"
|
||||
class="flex-1 px-4 py-2 rounded bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
立即迁移
|
||||
</button>
|
||||
<button
|
||||
@click="handleDismiss"
|
||||
class="flex-1 px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
稍后再说
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
572
frontend/src/components/SessionTree.vue
Normal file
572
frontend/src/components/SessionTree.vue
Normal file
@@ -0,0 +1,572 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { useSessionTreeStore } from '../stores/sessionTree'
|
||||
import { useWorkspaceStore } from '../stores/workspace'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
|
||||
import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts'
|
||||
import { useTreeSearch } from '../composables/useTreeSearch'
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
import type { ContextMenuItem } from './ContextMenu.vue'
|
||||
import type { SessionTreeNode } from '../types/sessionTree'
|
||||
import {
|
||||
FolderPlus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Search,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Server,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const ROW_HEIGHT = 34
|
||||
const OVERSCAN = 6
|
||||
|
||||
interface VisibleTreeNode {
|
||||
node: SessionTreeNode
|
||||
level: number
|
||||
}
|
||||
|
||||
const treeStore = useSessionTreeStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
|
||||
const showNewFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderParentId = ref<string | null>(null)
|
||||
const showRenameDialog = ref(false)
|
||||
const renameNodeId = ref<string | null>(null)
|
||||
const renameValue = ref('')
|
||||
const allExpanded = ref(true)
|
||||
|
||||
// Search functionality
|
||||
const nodesRef = computed(() => treeStore.nodes)
|
||||
const { searchQuery, filteredNodes, clearSearch, isSearchMatch } = useTreeSearch(nodesRef)
|
||||
|
||||
// Context menu
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
const contextMenuNodeId = ref<string | null>(null)
|
||||
|
||||
const nodesForRender = computed(() => {
|
||||
return searchQuery.value.trim() ? filteredNodes.value : treeStore.nodes
|
||||
})
|
||||
|
||||
const visibleTreeNodes = computed<VisibleTreeNode[]>(() => {
|
||||
const nodes = nodesForRender.value
|
||||
if (nodes.length === 0) return []
|
||||
|
||||
const childrenByParent = new Map<string | null, SessionTreeNode[]>()
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const parentId = node.parentId
|
||||
const siblings = childrenByParent.get(parentId) || []
|
||||
siblings.push(node)
|
||||
childrenByParent.set(parentId, siblings)
|
||||
})
|
||||
|
||||
childrenByParent.forEach((siblings) => {
|
||||
siblings.sort((a, b) => a.order - b.order)
|
||||
})
|
||||
|
||||
const flattened: VisibleTreeNode[] = []
|
||||
|
||||
const walk = (parentId: string | null, level: number) => {
|
||||
const children = childrenByParent.get(parentId) || []
|
||||
children.forEach((child) => {
|
||||
flattened.push({ node: child, level })
|
||||
if (child.type === 'folder' && child.expanded) {
|
||||
walk(child.id, level + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
walk(null, 0)
|
||||
return flattened
|
||||
})
|
||||
|
||||
const treeViewportRef = ref<HTMLElement | null>(null)
|
||||
const { height: viewportHeight } = useElementSize(treeViewportRef)
|
||||
const scrollTop = ref(0)
|
||||
|
||||
const totalRows = computed(() => visibleTreeNodes.value.length)
|
||||
const visibleCount = computed(() => {
|
||||
const rows = Math.ceil((viewportHeight.value || 400) / ROW_HEIGHT)
|
||||
return rows + OVERSCAN * 2
|
||||
})
|
||||
|
||||
const startIndex = computed(() => {
|
||||
return Math.max(Math.floor(scrollTop.value / ROW_HEIGHT) - OVERSCAN, 0)
|
||||
})
|
||||
|
||||
const endIndex = computed(() => {
|
||||
return Math.min(startIndex.value + visibleCount.value, totalRows.value)
|
||||
})
|
||||
|
||||
const virtualNodes = computed(() => {
|
||||
return visibleTreeNodes.value.slice(startIndex.value, endIndex.value)
|
||||
})
|
||||
|
||||
const topPadding = computed(() => startIndex.value * ROW_HEIGHT)
|
||||
const totalHeight = computed(() => totalRows.value * ROW_HEIGHT)
|
||||
|
||||
const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
if (!contextMenuNodeId.value) return []
|
||||
|
||||
const node = treeStore.nodes.find(n => n.id === contextMenuNodeId.value)
|
||||
if (!node) return []
|
||||
|
||||
const items: ContextMenuItem[] = [
|
||||
...(node.type === 'connection' && node.connectionId
|
||||
? [
|
||||
{
|
||||
label: '编辑连接',
|
||||
icon: Edit2,
|
||||
action: () => openEditConnection(node.connectionId!),
|
||||
} as ContextMenuItem,
|
||||
{ divider: true } as ContextMenuItem,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: '重命名',
|
||||
icon: Edit2,
|
||||
action: () => startRename(node.id),
|
||||
},
|
||||
]
|
||||
|
||||
if (node.type === 'folder') {
|
||||
items.push({
|
||||
label: '新建子文件夹',
|
||||
icon: FolderPlus,
|
||||
action: () => createSubFolder(node.id),
|
||||
})
|
||||
}
|
||||
|
||||
items.push(
|
||||
{ divider: true } as ContextMenuItem,
|
||||
{
|
||||
label: '删除',
|
||||
icon: Trash2,
|
||||
action: () => {
|
||||
void deleteNode(node.id)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Drag-drop functionality
|
||||
const dragHandlers = useTreeDragDrop()
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: 'F2',
|
||||
handler: () => {
|
||||
if (treeStore.selectedNodeId) {
|
||||
startRename(treeStore.selectedNodeId)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Delete',
|
||||
handler: () => {
|
||||
if (treeStore.selectedNodeId) {
|
||||
void deleteNode(treeStore.selectedNodeId)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'n',
|
||||
ctrl: true,
|
||||
handler: () => {
|
||||
showNewFolderDialog.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'f',
|
||||
ctrl: true,
|
||||
handler: () => {
|
||||
const searchInput = document.querySelector('.tree-search-input') as HTMLInputElement
|
||||
searchInput?.focus()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
scrollTop.value = (event.target as HTMLElement).scrollTop
|
||||
}
|
||||
|
||||
function handleNodeClick(nodeId: string) {
|
||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
if (node.type === 'folder') {
|
||||
treeStore.toggleExpanded(nodeId)
|
||||
} else if (node.type === 'connection' && node.connectionId) {
|
||||
workspaceStore.openPanel(node.connectionId)
|
||||
}
|
||||
|
||||
treeStore.selectNode(nodeId)
|
||||
}
|
||||
|
||||
function handleNodeDoubleClick(nodeId: string) {
|
||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node || node.type !== 'connection' || !node.connectionId) return
|
||||
openEditConnection(node.connectionId)
|
||||
}
|
||||
|
||||
function openEditConnection(connectionId: number) {
|
||||
workspaceStore.openEditSessionModal(connectionId)
|
||||
}
|
||||
|
||||
function handleNodeContextMenu(nodeId: string, event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
contextMenuNodeId.value = nodeId
|
||||
contextMenuX.value = event.clientX
|
||||
contextMenuY.value = event.clientY
|
||||
showContextMenu.value = true
|
||||
treeStore.selectNode(nodeId)
|
||||
}
|
||||
|
||||
function handleFolderToggle(nodeId: string, event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
treeStore.toggleExpanded(nodeId)
|
||||
treeStore.selectNode(nodeId)
|
||||
}
|
||||
|
||||
function isSelected(nodeId: string) {
|
||||
return treeStore.selectedNodeId === nodeId
|
||||
}
|
||||
|
||||
function isDragging(nodeId: string) {
|
||||
return dragHandlers.draggedNode.value?.id === nodeId
|
||||
}
|
||||
|
||||
function isDropTarget(nodeId: string) {
|
||||
return dragHandlers.dropTarget.value?.id === nodeId
|
||||
}
|
||||
|
||||
function dropPosition(nodeId: string) {
|
||||
if (!isDropTarget(nodeId)) {
|
||||
return null
|
||||
}
|
||||
return dragHandlers.dropPosition.value
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragEvent, node: SessionTreeNode) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
dragHandlers.handleDragStart(node)
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent, node: SessionTreeNode) {
|
||||
dragHandlers.handleDragOver(event, node)
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragHandlers.handleDragLeave()
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
dragHandlers.handleDrop(event)
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
dragHandlers.handleDragEnd()
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
treeStore.createFolder(newFolderName.value.trim(), newFolderParentId.value)
|
||||
closeNewFolderDialog()
|
||||
}
|
||||
|
||||
function createSubFolder(parentId: string) {
|
||||
newFolderParentId.value = parentId
|
||||
newFolderName.value = ''
|
||||
showNewFolderDialog.value = true
|
||||
}
|
||||
|
||||
function openRootFolderDialog() {
|
||||
newFolderParentId.value = null
|
||||
newFolderName.value = ''
|
||||
showNewFolderDialog.value = true
|
||||
}
|
||||
|
||||
function closeNewFolderDialog() {
|
||||
showNewFolderDialog.value = false
|
||||
newFolderParentId.value = null
|
||||
newFolderName.value = ''
|
||||
}
|
||||
|
||||
function startRename(nodeId: string) {
|
||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
renameNodeId.value = nodeId
|
||||
renameValue.value = node.name
|
||||
showRenameDialog.value = true
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (!renameNodeId.value || !renameValue.value.trim()) return
|
||||
treeStore.renameNode(renameNodeId.value, renameValue.value.trim())
|
||||
showRenameDialog.value = false
|
||||
renameNodeId.value = null
|
||||
renameValue.value = ''
|
||||
}
|
||||
|
||||
function collectConnectionIdsInSubtree(nodeId: string): number[] {
|
||||
const connectionIds: number[] = []
|
||||
const queue: string[] = [nodeId]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()
|
||||
if (!currentId) continue
|
||||
|
||||
const currentNode = treeStore.nodes.find(n => n.id === currentId)
|
||||
if (!currentNode) continue
|
||||
|
||||
if (currentNode.type === 'connection' && currentNode.connectionId) {
|
||||
connectionIds.push(currentNode.connectionId)
|
||||
}
|
||||
|
||||
const children = treeStore.nodes.filter(n => n.parentId === currentId)
|
||||
children.forEach((child) => queue.push(child.id))
|
||||
}
|
||||
|
||||
return Array.from(new Set(connectionIds))
|
||||
}
|
||||
|
||||
async function deleteNode(nodeId: string) {
|
||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
const confirmMsg = node.type === 'folder'
|
||||
? `确定要删除文件夹 "${node.name}" 及其所有内容吗?`
|
||||
: `确定要删除连接 "${node.name}" 吗?`
|
||||
|
||||
if (!confirm(confirmMsg)) return
|
||||
|
||||
const connectionIds = collectConnectionIdsInSubtree(nodeId)
|
||||
|
||||
try {
|
||||
for (const connectionId of connectionIds) {
|
||||
await connectionsStore.deleteConnection(connectionId)
|
||||
workspaceStore.closePanel(connectionId)
|
||||
}
|
||||
treeStore.deleteNode(nodeId)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete node:', error)
|
||||
alert('删除失败,未完成此次删除操作。')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpandAll() {
|
||||
if (allExpanded.value) {
|
||||
treeStore.collapseAll()
|
||||
allExpanded.value = false
|
||||
} else {
|
||||
treeStore.expandAll()
|
||||
allExpanded.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-slate-900 border-r border-slate-700">
|
||||
<div class="h-12 px-3 flex items-center gap-2 border-b border-slate-700">
|
||||
<button
|
||||
@click="openRootFolderDialog"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
title="新建文件夹 (Ctrl+N)"
|
||||
>
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
文件夹
|
||||
</button>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<button
|
||||
@click="toggleExpandAll"
|
||||
class="flex items-center gap-1 px-2 py-1.5 rounded text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
|
||||
:title="allExpanded ? '折叠全部' : '展开全部'"
|
||||
>
|
||||
<component :is="allExpanded ? ChevronDown : ChevronRight" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-2 border-b border-slate-700">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索会话... (Ctrl+F)"
|
||||
class="tree-search-input w-full pl-8 pr-8 py-1.5 rounded bg-slate-800 border border-slate-700 text-slate-200 text-sm placeholder-slate-500 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="clearSearch"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="treeViewportRef"
|
||||
class="flex-1 overflow-y-auto p-2"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div v-if="searchQuery && visibleTreeNodes.length === 0" class="text-center py-8 text-slate-500 text-sm">
|
||||
未找到匹配的会话
|
||||
</div>
|
||||
|
||||
<div v-else class="relative" :style="{ height: `${totalHeight}px` }">
|
||||
<div :style="{ transform: `translateY(${topPadding}px)` }">
|
||||
<div
|
||||
v-for="item in virtualNodes"
|
||||
:key="item.node.id"
|
||||
draggable="true"
|
||||
class="w-full h-[34px] flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-slate-800 transition-colors relative"
|
||||
:class="{
|
||||
'bg-slate-800 text-cyan-300': isSelected(item.node.id),
|
||||
'text-slate-300': !isSelected(item.node.id),
|
||||
'opacity-50': isDragging(item.node.id),
|
||||
'ring-2 ring-cyan-500': isDropTarget(item.node.id) && dropPosition(item.node.id) === 'inside',
|
||||
}"
|
||||
:style="{ paddingLeft: `${item.level * 16 + 8}px` }"
|
||||
@click="handleNodeClick(item.node.id)"
|
||||
@dblclick="handleNodeDoubleClick(item.node.id)"
|
||||
@contextmenu="(event) => handleNodeContextMenu(item.node.id, event)"
|
||||
@dragstart="(event) => handleDragStart(event, item.node)"
|
||||
@dragover="(event) => handleDragOver(event, item.node)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<div
|
||||
v-if="isDropTarget(item.node.id) && dropPosition(item.node.id) === 'before'"
|
||||
class="absolute top-0 left-0 right-0 h-0.5 bg-cyan-500"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isDropTarget(item.node.id) && dropPosition(item.node.id) === 'after'"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-500"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="item.node.type === 'folder'"
|
||||
class="w-4 h-4 flex items-center justify-center"
|
||||
@click="(event) => handleFolderToggle(item.node.id, event)"
|
||||
>
|
||||
<ChevronDown v-if="item.node.expanded" class="w-3.5 h-3.5" />
|
||||
<ChevronRight v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div v-else class="w-4" />
|
||||
|
||||
<component
|
||||
:is="item.node.type === 'folder' ? (item.node.expanded ? FolderOpen : Folder) : Server"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
:class="{
|
||||
'text-amber-400': item.node.type === 'folder',
|
||||
'text-cyan-400': item.node.type === 'connection',
|
||||
}"
|
||||
/>
|
||||
|
||||
<span class="text-sm truncate flex-1">{{ item.node.name }}</span>
|
||||
|
||||
<span
|
||||
v-if="isSearchMatch(item.node.id)"
|
||||
class="w-1.5 h-1.5 rounded-full bg-cyan-400 flex-shrink-0"
|
||||
title="搜索匹配"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-else-if="item.node.type === 'connection'"
|
||||
class="w-2 h-2 rounded-full bg-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showNewFolderDialog"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
@click.self="closeNewFolderDialog"
|
||||
>
|
||||
<div class="bg-slate-800 rounded-lg p-4 w-80">
|
||||
<h3 class="text-sm font-medium text-slate-100 mb-3">新建文件夹</h3>
|
||||
<input
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
placeholder="文件夹名称"
|
||||
class="w-full px-3 py-2 rounded bg-slate-900 border border-slate-600 text-slate-100 text-sm focus:border-cyan-500 focus:outline-none"
|
||||
@keyup.enter="createFolder"
|
||||
/>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
@click="createFolder"
|
||||
class="flex-1 px-3 py-2 rounded bg-cyan-600 hover:bg-cyan-500 text-white text-sm"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
<button
|
||||
@click="closeNewFolderDialog"
|
||||
class="flex-1 px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRenameDialog"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
@click.self="showRenameDialog = false"
|
||||
>
|
||||
<div class="bg-slate-800 rounded-lg p-4 w-80">
|
||||
<h3 class="text-sm font-medium text-slate-100 mb-3">重命名</h3>
|
||||
<input
|
||||
v-model="renameValue"
|
||||
type="text"
|
||||
placeholder="新名称"
|
||||
class="w-full px-3 py-2 rounded bg-slate-900 border border-slate-600 text-slate-100 text-sm focus:border-cyan-500 focus:outline-none"
|
||||
@keyup.enter="confirmRename"
|
||||
/>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
@click="confirmRename"
|
||||
class="flex-1 px-3 py-2 rounded bg-cyan-600 hover:bg-cyan-500 text-white text-sm"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
@click="showRenameDialog = false"
|
||||
class="flex-1 px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
:show="showContextMenu"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
:items="contextMenuItems"
|
||||
@close="showContextMenu = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
166
frontend/src/components/SessionTreeNode.vue
Normal file
166
frontend/src/components/SessionTreeNode.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { useSessionTreeStore } from '../stores/sessionTree'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import type { SessionTreeNode as TreeNode } from '../types/sessionTree'
|
||||
import { Folder, FolderOpen, ChevronRight, ChevronDown, Server } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
node: TreeNode
|
||||
level: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
click: [nodeId: string]
|
||||
contextmenu: [nodeId: string, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const treeStore = useSessionTreeStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
|
||||
const children = computed(() => treeStore.getChildren(props.node.id))
|
||||
const isSelected = computed(() => treeStore.selectedNodeId === props.node.id)
|
||||
const isExpanded = computed(() => props.node.expanded)
|
||||
|
||||
const connection = computed(() => {
|
||||
if (props.node.type === 'connection' && props.node.connectionId) {
|
||||
return connectionsStore.connections.find(c => c.id === props.node.connectionId)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Drag-drop functionality
|
||||
const dragHandlers = inject<any>('dragHandlers', null)
|
||||
const isSearchMatch = inject<(nodeId: string) => boolean>('isSearchMatch', () => false)
|
||||
|
||||
const isDragging = computed(() =>
|
||||
dragHandlers?.draggedNode.value?.id === props.node.id
|
||||
)
|
||||
|
||||
const isDropTarget = computed(() =>
|
||||
dragHandlers?.dropTarget.value?.id === props.node.id
|
||||
)
|
||||
|
||||
const dropPosition = computed(() =>
|
||||
isDropTarget.value ? dragHandlers?.dropPosition.value : null
|
||||
)
|
||||
|
||||
function handleClick() {
|
||||
emit('click', props.node.id)
|
||||
}
|
||||
|
||||
function handleContextMenu(event: MouseEvent) {
|
||||
emit('contextmenu', props.node.id, event)
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragEvent) {
|
||||
if (dragHandlers) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
dragHandlers.handleDragStart(props.node)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
if (dragHandlers) {
|
||||
dragHandlers.handleDragOver(event, props.node)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
if (dragHandlers) {
|
||||
dragHandlers.handleDragLeave()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
if (dragHandlers) {
|
||||
dragHandlers.handleDrop(event)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
if (dragHandlers) {
|
||||
dragHandlers.handleDragEnd()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
draggable="true"
|
||||
class="w-full flex items-center gap-1.5 px-2 py-1.5 rounded cursor-pointer hover:bg-slate-800 transition-colors relative"
|
||||
:class="{
|
||||
'bg-slate-800 text-cyan-300': isSelected,
|
||||
'text-slate-300': !isSelected,
|
||||
'opacity-50': isDragging,
|
||||
'ring-2 ring-cyan-500': isDropTarget && dropPosition === 'inside',
|
||||
}"
|
||||
:style="{ paddingLeft: `${level * 16 + 8}px` }"
|
||||
@click="handleClick"
|
||||
@contextmenu="handleContextMenu"
|
||||
@dragstart="handleDragStart"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<!-- Drop indicator - before -->
|
||||
<div
|
||||
v-if="isDropTarget && dropPosition === 'before'"
|
||||
class="absolute top-0 left-0 right-0 h-0.5 bg-cyan-500"
|
||||
/>
|
||||
|
||||
<!-- Drop indicator - after -->
|
||||
<div
|
||||
v-if="isDropTarget && dropPosition === 'after'"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-500"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="node.type === 'folder'"
|
||||
class="w-4 h-4 flex items-center justify-center"
|
||||
@click.stop="treeStore.toggleExpanded(node.id)"
|
||||
>
|
||||
<ChevronDown v-if="isExpanded" class="w-3.5 h-3.5" />
|
||||
<ChevronRight v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div v-else class="w-4" />
|
||||
|
||||
<component
|
||||
:is="node.type === 'folder' ? (isExpanded ? FolderOpen : Folder) : Server"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
:class="{
|
||||
'text-amber-400': node.type === 'folder',
|
||||
'text-cyan-400': node.type === 'connection',
|
||||
}"
|
||||
/>
|
||||
|
||||
<span class="text-sm truncate flex-1">{{ node.name }}</span>
|
||||
|
||||
<!-- Search match indicator -->
|
||||
<span
|
||||
v-if="isSearchMatch(node.id)"
|
||||
class="w-1.5 h-1.5 rounded-full bg-cyan-400 flex-shrink-0"
|
||||
title="搜索匹配"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-else-if="connection"
|
||||
class="w-2 h-2 rounded-full bg-slate-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="node.type === 'folder' && isExpanded">
|
||||
<SessionTreeNode
|
||||
v-for="child in children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:level="level + 1"
|
||||
@click="emit('click', $event)"
|
||||
@contextmenu="(nodeId, event) => emit('contextmenu', nodeId, event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
315
frontend/src/components/SftpPanel.vue
Normal file
315
frontend/src/components/SftpPanel.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useWorkspaceStore } from '../stores/workspace'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import * as sftpApi from '../api/sftp'
|
||||
import type { SftpFileInfo } from '../api/sftp'
|
||||
import {
|
||||
FolderOpen,
|
||||
File,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
connectionId: number
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const toast = useToast()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
|
||||
const conn = computed(() => connectionsStore.getConnection(props.connectionId))
|
||||
const currentPath = computed(() => {
|
||||
const panel = workspaceStore.panels[props.connectionId]
|
||||
return panel?.currentPath || '.'
|
||||
})
|
||||
|
||||
const files = ref<SftpFileInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const pathParts = computed(() => {
|
||||
const path = currentPath.value
|
||||
return path === '/' ? [''] : path.split('/').filter(Boolean)
|
||||
})
|
||||
|
||||
async function loadFiles() {
|
||||
if (!props.active) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const res = await sftpApi.listFiles(props.connectionId, currentPath.value)
|
||||
files.value = res.data.sort((a, b) => {
|
||||
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
} catch (err: any) {
|
||||
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
|
||||
const newPath = base ? (base.endsWith('/') ? base + name : base + '/' + name) : name
|
||||
workspaceStore.updateSftpPath(props.connectionId, newPath)
|
||||
}
|
||||
|
||||
function navigateToIndex(i: number) {
|
||||
if (loading.value) return
|
||||
let newPath: string
|
||||
if (i < 0) {
|
||||
newPath = '.'
|
||||
} else {
|
||||
newPath = pathParts.value.length ? '/' + pathParts.value.slice(0, i + 1).join('/') : '/'
|
||||
}
|
||||
workspaceStore.updateSftpPath(props.connectionId, newPath)
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
if (loading.value) return
|
||||
const path = currentPath.value
|
||||
if (path === '.' || path === '' || path === '/') return
|
||||
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
let newPath: string
|
||||
if (parts.length <= 1) {
|
||||
newPath = '/'
|
||||
} else {
|
||||
parts.pop()
|
||||
newPath = '/' + parts.join('/')
|
||||
}
|
||||
workspaceStore.updateSftpPath(props.connectionId, newPath)
|
||||
}
|
||||
|
||||
function handleFileClick(file: SftpFileInfo) {
|
||||
if (file.directory) {
|
||||
navigateToDir(file.name)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(file: SftpFileInfo) {
|
||||
if (file.directory) return
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
const path = base ? base + '/' + file.name : file.name
|
||||
sftpApi.downloadFile(props.connectionId, path).catch(() => {
|
||||
toast.error('下载失败')
|
||||
})
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const selected = input.files
|
||||
if (!selected?.length) return
|
||||
|
||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const file = selected[i]
|
||||
if (!file) continue
|
||||
|
||||
try {
|
||||
await sftpApi.uploadFile(props.connectionId, path, file)
|
||||
} catch (err: any) {
|
||||
toast.error(`上传 ${file.name} 失败`)
|
||||
}
|
||||
}
|
||||
|
||||
await loadFiles()
|
||||
toast.success(`成功上传 ${selected.length} 个文件`)
|
||||
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleMkdir() {
|
||||
const name = prompt('文件夹名称:')
|
||||
if (!name?.trim()) return
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
const path = base ? base + '/' + name : name
|
||||
sftpApi.createDir(props.connectionId, path)
|
||||
.then(() => loadFiles())
|
||||
.catch(() => toast.error('创建文件夹失败'))
|
||||
}
|
||||
|
||||
function handleDelete(file: SftpFileInfo) {
|
||||
if (!confirm(`确定删除「${file.name}」?`)) return
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
const path = base ? base + '/' + file.name : file.name
|
||||
sftpApi.deleteFile(props.connectionId, path, file.directory)
|
||||
.then(() => loadFiles())
|
||||
.catch(() => toast.error('删除失败'))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
watch(() => props.active, (active) => {
|
||||
if (active) loadFiles()
|
||||
})
|
||||
|
||||
watch(currentPath, () => {
|
||||
loadFiles()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) loadFiles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-slate-950">
|
||||
<div class="h-11 px-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h3 class="text-sm font-semibold text-slate-100 truncate">
|
||||
{{ conn?.name || 'SFTP' }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="triggerUpload"
|
||||
class="px-2.5 py-1.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5" />
|
||||
上传
|
||||
</button>
|
||||
<button
|
||||
@click="handleMkdir"
|
||||
class="px-2.5 py-1.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<FolderPlus class="w-3.5 h-3.5" />
|
||||
新建
|
||||
</button>
|
||||
<button
|
||||
@click="loadFiles()"
|
||||
:disabled="loading"
|
||||
class="px-2.5 py-1.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3 py-2 border-b border-slate-700/80 bg-slate-900/80">
|
||||
<nav class="flex items-center gap-1 text-sm text-slate-400 overflow-x-auto">
|
||||
<button
|
||||
@click="navigateToIndex(-1)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<template v-for="(part, i) in pathParts" :key="i">
|
||||
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" />
|
||||
<button
|
||||
@click="navigateToIndex(i)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors truncate max-w-[160px]"
|
||||
>
|
||||
{{ part || '/' }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center text-slate-400">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto">
|
||||
<div class="grid grid-cols-[2.6fr_1fr_1.3fr_100px] gap-2 px-4 py-2 text-xs uppercase tracking-wider text-slate-500 border-b border-slate-700 bg-slate-900/85 sticky top-0">
|
||||
<span>名称</span>
|
||||
<span>大小</span>
|
||||
<span>修改时间</span>
|
||||
<span class="text-right">操作</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-slate-800">
|
||||
<button
|
||||
v-if="currentPath !== '.' && pathParts.length > 1"
|
||||
@click="goUp"
|
||||
class="w-full grid grid-cols-[2.6fr_1fr_1.3fr_100px] gap-2 items-center px-4 py-3 hover:bg-slate-800/60 transition-colors text-left"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<FolderOpen class="w-4 h-4 text-slate-500" />
|
||||
<span class="text-slate-300">..</span>
|
||||
</span>
|
||||
<span class="text-slate-600">-</span>
|
||||
<span class="text-slate-600">-</span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="file in files"
|
||||
:key="file.name"
|
||||
@click="handleFileClick(file)"
|
||||
@dblclick="!file.directory && handleDownload(file)"
|
||||
class="w-full grid grid-cols-[2.6fr_1fr_1.3fr_100px] gap-2 items-center px-4 py-3 hover:bg-slate-800/60 transition-colors text-left group"
|
||||
>
|
||||
<span class="flex items-center gap-3 min-w-0">
|
||||
<component
|
||||
:is="file.directory ? FolderOpen : File"
|
||||
class="w-4 h-4 flex-shrink-0 text-slate-400"
|
||||
/>
|
||||
<span class="truncate text-sm text-slate-200">{{ file.name }}</span>
|
||||
</span>
|
||||
<span class="text-sm text-slate-500">{{ file.directory ? '-' : formatSize(file.size) }}</span>
|
||||
<span class="text-sm text-slate-500">{{ formatDate(file.mtime) }}</span>
|
||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
v-if="!file.directory"
|
||||
@click.stop="handleDownload(file)"
|
||||
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click.stop="handleDelete(file)"
|
||||
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="files.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
||||
空目录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
93
frontend/src/components/SplitPane.vue
Normal file
93
frontend/src/components/SplitPane.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
interface Props {
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
initialRatio?: number
|
||||
minRatio?: number
|
||||
maxRatio?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
direction: 'vertical',
|
||||
initialRatio: 0.5,
|
||||
minRatio: 0.2,
|
||||
maxRatio: 0.8,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
ratioChange: [ratio: number]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const ratio = ref(props.initialRatio)
|
||||
const isDragging = ref(false)
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
isDragging.value = true
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
if (!isDragging.value || !containerRef.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
let newRatio: number
|
||||
|
||||
if (props.direction === 'vertical') {
|
||||
newRatio = (event.clientY - rect.top) / rect.height
|
||||
} else {
|
||||
newRatio = (event.clientX - rect.left) / rect.width
|
||||
}
|
||||
|
||||
ratio.value = Math.max(props.minRatio, Math.min(props.maxRatio, newRatio))
|
||||
emit('ratioChange', ratio.value)
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative w-full h-full"
|
||||
:class="{
|
||||
'flex flex-col': direction === 'vertical',
|
||||
'flex flex-row': direction === 'horizontal',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
:style="{
|
||||
[direction === 'vertical' ? 'height' : 'width']: `${ratio * 100}%`,
|
||||
}"
|
||||
>
|
||||
<slot name="first" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-slate-700 hover:bg-cyan-500 transition-colors flex-shrink-0"
|
||||
:class="{
|
||||
'h-1 cursor-row-resize': direction === 'vertical',
|
||||
'w-1 cursor-col-resize': direction === 'horizontal',
|
||||
'bg-cyan-500': isDragging,
|
||||
}"
|
||||
@mousedown="handleMouseDown"
|
||||
/>
|
||||
|
||||
<div class="overflow-hidden flex-1">
|
||||
<slot name="second" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -271,72 +271,59 @@ onUnmounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<!-- 监控状态栏 -->
|
||||
<div v-if="status === 'connected'" class="border-b border-slate-700 bg-slate-900/80">
|
||||
<div class="flex items-center justify-between px-3 py-1.5 text-xs">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- CPU -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Cpu class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">CPU:</span>
|
||||
<span :class="getUsageColor(monitorData.cpuUsage)">
|
||||
{{ monitorData.cpuUsage !== undefined ? monitorData.cpuUsage + '%' : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 内存 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<MemoryStick class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">MEM:</span>
|
||||
<span :class="getUsageColor(monitorData.memUsage)">
|
||||
{{ monitorData.memUsage !== undefined ? monitorData.memUsage + '%' : '--' }}
|
||||
</span>
|
||||
<span v-if="monitorData.memUsed && monitorData.memTotal" class="text-slate-500 ml-1">
|
||||
({{ formatBytes(monitorData.memUsed) }}/{{ formatBytes(monitorData.memTotal) }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 磁盘 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<HardDrive class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">DISK:</span>
|
||||
<span :class="getUsageColor(monitorData.diskUsage, 80, 95)">
|
||||
{{ monitorData.diskUsage !== undefined ? monitorData.diskUsage + '%' : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 负载 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Activity class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">LOAD:</span>
|
||||
<span :class="getUsageColor(monitorData.load1, monitorData.cpuCores || 4, (monitorData.cpuCores || 4) * 1.5)">
|
||||
{{ monitorData.load1 !== undefined ? monitorData.load1.toFixed(2) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 运行时间 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">UP:</span>
|
||||
<span class="text-slate-300">
|
||||
{{ monitorData.uptime || '--' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 折叠按钮 -->
|
||||
<button
|
||||
@click="showMonitor = !showMonitor"
|
||||
class="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
:title="showMonitor ? '隐藏监控栏' : '显示监控栏'"
|
||||
>
|
||||
<component :is="showMonitor ? ChevronUp : ChevronDown" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="h-9 border-b border-slate-700 bg-slate-900/85 flex items-center justify-between px-3 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="status === 'connected' ? 'bg-emerald-400' : status === 'error' ? 'bg-red-400' : 'bg-amber-400'" />
|
||||
<span class="text-slate-300">{{ status === 'connected' ? 'CONNECTED' : status === 'error' ? 'FAILED' : 'CONNECTING' }}</span>
|
||||
<span v-if="status === 'connected'" class="text-slate-500">实时监控</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="status === 'connected'"
|
||||
@click="showMonitor = !showMonitor"
|
||||
class="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
:title="showMonitor ? '隐藏监控栏' : '显示监控栏'"
|
||||
>
|
||||
<component :is="showMonitor ? ChevronUp : ChevronDown" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="status === 'connected' && showMonitor" class="border-b border-slate-800 bg-slate-900/70 px-3 py-2">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-2 text-xs">
|
||||
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5">
|
||||
<Cpu class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-500">CPU</span>
|
||||
<span class="ml-auto" :class="getUsageColor(monitorData.cpuUsage)">{{ monitorData.cpuUsage !== undefined ? monitorData.cpuUsage + '%' : '--' }}</span>
|
||||
</div>
|
||||
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5">
|
||||
<MemoryStick class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-500">MEM</span>
|
||||
<span class="ml-auto" :class="getUsageColor(monitorData.memUsage)">{{ monitorData.memUsage !== undefined ? monitorData.memUsage + '%' : '--' }}</span>
|
||||
</div>
|
||||
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5">
|
||||
<HardDrive class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-500">DISK</span>
|
||||
<span class="ml-auto" :class="getUsageColor(monitorData.diskUsage, 80, 95)">{{ monitorData.diskUsage !== undefined ? monitorData.diskUsage + '%' : '--' }}</span>
|
||||
</div>
|
||||
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5">
|
||||
<Activity class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-500">LOAD</span>
|
||||
<span class="ml-auto" :class="getUsageColor(monitorData.load1, monitorData.cpuCores || 4, (monitorData.cpuCores || 4) * 1.5)">
|
||||
{{ monitorData.load1 !== undefined ? monitorData.load1.toFixed(2) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded border border-slate-700 bg-slate-800/75 px-2 py-1.5 flex items-center gap-1.5 col-span-2 lg:col-span-1">
|
||||
<Clock class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-500">UP</span>
|
||||
<span class="ml-auto text-slate-300 truncate">{{ monitorData.uptime || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="monitorData.memUsed && monitorData.memTotal" class="mt-1.5 text-[11px] text-slate-500">
|
||||
内存占用:{{ formatBytes(monitorData.memUsed) }} / {{ formatBytes(monitorData.memTotal) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="flex-1 min-h-0 p-4 xterm-container"
|
||||
|
||||
187
frontend/src/components/TopToolbar.vue
Normal file
187
frontend/src/components/TopToolbar.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Settings, HelpCircle, Bell, MonitorCog, X, ArrowLeftRight, Plus } from 'lucide-vue-next'
|
||||
import { useWorkspaceStore } from '../stores/workspace'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
import type { ContextMenuItem } from './ContextMenu.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
|
||||
const workspaceTabs = computed(() => {
|
||||
return workspaceStore.panelOrder
|
||||
.map((connectionId) => {
|
||||
const panel = workspaceStore.panels[connectionId]
|
||||
if (!panel) return null
|
||||
const connection = connectionsStore.connections.find((item) => item.id === panel.connectionId)
|
||||
return {
|
||||
connectionId: panel.connectionId,
|
||||
title: connection?.name || `连接 ${panel.connectionId}`,
|
||||
active: workspaceStore.activeConnectionId === panel.connectionId,
|
||||
}
|
||||
})
|
||||
.filter((tab): tab is { connectionId: number; title: string; active: boolean } => Boolean(tab))
|
||||
})
|
||||
|
||||
const showTabContextMenu = ref(false)
|
||||
const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
const contextTabConnectionId = ref<number | null>(null)
|
||||
|
||||
function activateTab(connectionId: number) {
|
||||
workspaceStore.openPanel(connectionId)
|
||||
}
|
||||
|
||||
function openTransfers() {
|
||||
workspaceStore.toggleTransfersModal()
|
||||
}
|
||||
|
||||
function openCreateSession() {
|
||||
workspaceStore.openCreateSessionModal()
|
||||
}
|
||||
|
||||
function closeTab(connectionId: number, event: Event) {
|
||||
event.stopPropagation()
|
||||
workspaceStore.closePanel(connectionId)
|
||||
}
|
||||
|
||||
function openTabContextMenu(connectionId: number, event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
contextTabConnectionId.value = connectionId
|
||||
contextMenuX.value = event.clientX
|
||||
contextMenuY.value = event.clientY
|
||||
showTabContextMenu.value = true
|
||||
}
|
||||
|
||||
const tabContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
const connectionId = contextTabConnectionId.value
|
||||
if (!connectionId) return []
|
||||
|
||||
const tabCount = workspaceTabs.value.length
|
||||
const targetIndex = workspaceStore.panelOrder.indexOf(connectionId)
|
||||
const hasRightTabs = targetIndex >= 0 && targetIndex < workspaceStore.panelOrder.length - 1
|
||||
|
||||
return [
|
||||
{
|
||||
label: '关闭当前',
|
||||
action: () => workspaceStore.closePanel(connectionId),
|
||||
disabled: tabCount === 0,
|
||||
},
|
||||
{
|
||||
label: '关闭其他',
|
||||
action: () => workspaceStore.closeOtherPanels(connectionId),
|
||||
disabled: tabCount <= 1,
|
||||
},
|
||||
{
|
||||
label: '关闭右侧',
|
||||
action: () => workspaceStore.closePanelsToRight(connectionId),
|
||||
disabled: !hasRightTabs,
|
||||
},
|
||||
{
|
||||
label: '关闭全部',
|
||||
action: () => workspaceStore.closeAllPanels(),
|
||||
disabled: tabCount === 0,
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-12 bg-slate-900 border-b border-slate-700 px-4 flex items-center gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
|
||||
<MonitorCog class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-slate-100">SSH Manager</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-xs text-slate-400">
|
||||
<button
|
||||
@click="openTransfers"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 rounded transition-colors cursor-pointer"
|
||||
:class="workspaceStore.transfersModalOpen
|
||||
? 'bg-cyan-500/10 text-cyan-200'
|
||||
: 'hover:bg-slate-800 hover:text-slate-200'"
|
||||
aria-label="打开传输页面"
|
||||
>
|
||||
<ArrowLeftRight class="w-3.5 h-3.5" />
|
||||
<span>Transfers</span>
|
||||
</button>
|
||||
<button
|
||||
@click="openCreateSession"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 rounded hover:bg-slate-800 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
aria-label="新增会话"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
<span>新增会话</span>
|
||||
</button>
|
||||
<button class="px-3 py-1.5 rounded hover:bg-slate-800 hover:text-slate-200 transition-colors">
|
||||
会话
|
||||
</button>
|
||||
<button class="px-3 py-1.5 rounded hover:bg-slate-800 hover:text-slate-200 transition-colors">
|
||||
工具
|
||||
</button>
|
||||
<button class="px-3 py-1.5 rounded hover:bg-slate-800 hover:text-slate-200 transition-colors">
|
||||
设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="workspaceTabs.length > 0" class="flex items-center gap-1 overflow-x-auto scrollbar-thin">
|
||||
<div
|
||||
v-for="tab in workspaceTabs"
|
||||
:key="tab.connectionId"
|
||||
class="group shrink-0 max-w-[280px] min-h-[32px] flex items-center gap-1 rounded border px-1.5 text-xs transition-colors cursor-pointer"
|
||||
:class="tab.active
|
||||
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600 hover:text-slate-100'"
|
||||
@click="activateTab(tab.connectionId)"
|
||||
@contextmenu="(e) => openTabContextMenu(tab.connectionId, e)"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-500'" />
|
||||
<span class="truncate max-w-[220px]">{{ tab.title }}</span>
|
||||
<button
|
||||
class="p-0.5 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200"
|
||||
@click="(e) => closeTab(tab.connectionId, e)"
|
||||
:aria-label="`关闭会话 ${tab.title}`"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-500">未打开会话</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
||||
title="通知"
|
||||
>
|
||||
<Bell class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
||||
title="帮助"
|
||||
>
|
||||
<HelpCircle class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
||||
title="设置"
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
:show="showTabContextMenu"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
:items="tabContextMenuItems"
|
||||
@close="showTabContextMenu = false"
|
||||
/>
|
||||
</template>
|
||||
68
frontend/src/components/WorkspacePanel.vue
Normal file
68
frontend/src/components/WorkspacePanel.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useWorkspaceStore } from '../stores/workspace'
|
||||
import type { WorkspacePanelState } from '../types/workspace'
|
||||
import SplitPane from './SplitPane.vue'
|
||||
import TerminalWidget from './TerminalWidget.vue'
|
||||
import SftpPanel from './SftpPanel.vue'
|
||||
import { Server } from 'lucide-vue-next'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
const activeConnectionId = computed(() => workspaceStore.activeConnectionId)
|
||||
const openPanels = computed(() => {
|
||||
return workspaceStore.panelOrder
|
||||
.map((connectionId) => workspaceStore.panels[connectionId])
|
||||
.filter((panel): panel is WorkspacePanelState => Boolean(panel))
|
||||
})
|
||||
|
||||
function isPanelActive(connectionId: number) {
|
||||
return activeConnectionId.value === connectionId
|
||||
}
|
||||
|
||||
function handleRatioChange(panel: WorkspacePanelState, ratio: number) {
|
||||
workspaceStore.updateSplitRatio(panel.connectionId, ratio)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full bg-slate-950">
|
||||
<div v-if="openPanels.length === 0 || !activeConnectionId" class="h-full flex items-center justify-center text-slate-500">
|
||||
<div class="text-center">
|
||||
<Server class="w-16 h-16 mx-auto mb-4 text-slate-600" />
|
||||
<p class="text-lg">请从左侧会话树选择一个连接</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="h-full">
|
||||
<div
|
||||
v-for="panel in openPanels"
|
||||
:key="panel.connectionId"
|
||||
v-show="isPanelActive(panel.connectionId)"
|
||||
class="h-full"
|
||||
>
|
||||
<SplitPane
|
||||
direction="vertical"
|
||||
:initial-ratio="panel.splitRatio"
|
||||
@ratio-change="(ratio) => handleRatioChange(panel, ratio)"
|
||||
>
|
||||
<template #first>
|
||||
<TerminalWidget
|
||||
v-if="panel.terminalVisible"
|
||||
:connection-id="panel.connectionId"
|
||||
:active="isPanelActive(panel.connectionId)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #second>
|
||||
<SftpPanel
|
||||
v-if="panel.sftpVisible"
|
||||
:connection-id="panel.connectionId"
|
||||
:active="isPanelActive(panel.connectionId)"
|
||||
/>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
41
frontend/src/composables/useConnectionSync.ts
Normal file
41
frontend/src/composables/useConnectionSync.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { watch } from 'vue'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useSessionTreeStore } from '../stores/sessionTree'
|
||||
|
||||
export function useConnectionSync() {
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const treeStore = useSessionTreeStore()
|
||||
|
||||
// Watch for new connections
|
||||
watch(
|
||||
() => connectionsStore.connections.length,
|
||||
(newLength, oldLength) => {
|
||||
if (!treeStore.hydrated) return
|
||||
|
||||
if (newLength > oldLength) {
|
||||
// New connection added
|
||||
treeStore.syncNewConnections()
|
||||
} else if (newLength < oldLength) {
|
||||
// Connection deleted
|
||||
treeStore.syncDeletedConnections()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch for connection name changes
|
||||
watch(
|
||||
() => connectionsStore.connections.map(c => ({ id: c.id, name: c.name })),
|
||||
(newConnections, oldConnections) => {
|
||||
if (!treeStore.hydrated) return
|
||||
if (!oldConnections) return
|
||||
|
||||
newConnections.forEach((newConn, index) => {
|
||||
const oldConn = oldConnections[index]
|
||||
if (oldConn && oldConn.id === newConn.id && oldConn.name !== newConn.name) {
|
||||
treeStore.syncConnectionName(newConn.id, newConn.name)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}
|
||||
34
frontend/src/composables/useKeyboardShortcuts.ts
Normal file
34
frontend/src/composables/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
handler: (event: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
for (const shortcut of shortcuts) {
|
||||
const ctrlMatch = shortcut.ctrl === undefined || shortcut.ctrl === (event.ctrlKey || event.metaKey)
|
||||
const shiftMatch = shortcut.shift === undefined || shortcut.shift === event.shiftKey
|
||||
const altMatch = shortcut.alt === undefined || shortcut.alt === event.altKey
|
||||
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
|
||||
|
||||
if (ctrlMatch && shiftMatch && altMatch && keyMatch) {
|
||||
event.preventDefault()
|
||||
shortcut.handler(event)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
}
|
||||
112
frontend/src/composables/useTreeDragDrop.ts
Normal file
112
frontend/src/composables/useTreeDragDrop.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ref } from 'vue'
|
||||
import { useSessionTreeStore } from '../stores/sessionTree'
|
||||
import type { SessionTreeNode } from '../types/sessionTree'
|
||||
|
||||
export function useTreeDragDrop() {
|
||||
const treeStore = useSessionTreeStore()
|
||||
const draggedNode = ref<SessionTreeNode | null>(null)
|
||||
const dropTarget = ref<SessionTreeNode | null>(null)
|
||||
const dropPosition = ref<'before' | 'after' | 'inside' | null>(null)
|
||||
|
||||
function canDropOn(target: SessionTreeNode, dragged: SessionTreeNode): boolean {
|
||||
// Can't drop on itself
|
||||
if (target.id === dragged.id) return false
|
||||
|
||||
// Can't drop on own descendants
|
||||
if (isDescendant(target.id, dragged.id)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isDescendant(nodeId: string, ancestorId: string): boolean {
|
||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node || !node.parentId) return false
|
||||
if (node.parentId === ancestorId) return true
|
||||
return isDescendant(node.parentId, ancestorId)
|
||||
}
|
||||
|
||||
function handleDragStart(node: SessionTreeNode) {
|
||||
draggedNode.value = node
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent, target: SessionTreeNode) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!draggedNode.value || !canDropOn(target, draggedNode.value)) {
|
||||
dropTarget.value = null
|
||||
dropPosition.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const y = event.clientY - rect.top
|
||||
const height = rect.height
|
||||
|
||||
dropTarget.value = target
|
||||
|
||||
// Determine drop position based on cursor position
|
||||
if (target.type === 'folder') {
|
||||
if (y < height * 0.25) {
|
||||
dropPosition.value = 'before'
|
||||
} else if (y > height * 0.75) {
|
||||
dropPosition.value = 'after'
|
||||
} else {
|
||||
dropPosition.value = 'inside'
|
||||
}
|
||||
} else {
|
||||
// Connections can only have before/after
|
||||
dropPosition.value = y < height / 2 ? 'before' : 'after'
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dropTarget.value = null
|
||||
dropPosition.value = null
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!draggedNode.value || !dropTarget.value || !dropPosition.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const dragged = draggedNode.value
|
||||
const target = dropTarget.value
|
||||
const position = dropPosition.value
|
||||
|
||||
if (position === 'inside' && target.type === 'folder') {
|
||||
// Move inside folder
|
||||
treeStore.moveNode(dragged.id, target.id, 0)
|
||||
} else {
|
||||
// Move before/after target
|
||||
const targetOrder = target.order
|
||||
const newParentId = target.parentId
|
||||
const newOrder = position === 'before' ? targetOrder : targetOrder + 1
|
||||
|
||||
treeStore.moveNode(dragged.id, newParentId, newOrder)
|
||||
}
|
||||
|
||||
// Reset state
|
||||
draggedNode.value = null
|
||||
dropTarget.value = null
|
||||
dropPosition.value = null
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
draggedNode.value = null
|
||||
dropTarget.value = null
|
||||
dropPosition.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
draggedNode,
|
||||
dropTarget,
|
||||
dropPosition,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleDragEnd,
|
||||
}
|
||||
}
|
||||
62
frontend/src/composables/useTreeSearch.ts
Normal file
62
frontend/src/composables/useTreeSearch.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import type { SessionTreeNode } from '../types/sessionTree'
|
||||
|
||||
export function useTreeSearch(nodesRef: Ref<SessionTreeNode[]>) {
|
||||
const searchQuery = ref('')
|
||||
const debouncedQuery = refDebounced(searchQuery, 150)
|
||||
const searchResults = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
const nodes = nodesRef.value
|
||||
|
||||
const query = debouncedQuery.value.trim().toLowerCase()
|
||||
if (!query) {
|
||||
searchResults.value = new Set()
|
||||
return nodes
|
||||
}
|
||||
|
||||
const results = new Set<string>()
|
||||
const matchedNodes = new Set<string>()
|
||||
const nodeById = new Map(nodes.map(node => [node.id, node]))
|
||||
|
||||
// Find all matching nodes
|
||||
nodes.forEach(node => {
|
||||
if (node.name.toLowerCase().includes(query)) {
|
||||
matchedNodes.add(node.id)
|
||||
results.add(node.id)
|
||||
|
||||
// Add all ancestors to results
|
||||
let current: SessionTreeNode | undefined = node
|
||||
while (current.parentId) {
|
||||
results.add(current.parentId)
|
||||
current = nodeById.get(current.parentId)
|
||||
if (!current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
searchResults.value = matchedNodes
|
||||
|
||||
// Filter nodes to only show matched paths
|
||||
return nodes.filter(node => results.has(node.id))
|
||||
})
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
searchResults.value.clear()
|
||||
}
|
||||
|
||||
function isSearchMatch(nodeId: string): boolean {
|
||||
return searchResults.value.has(nodeId)
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
filteredNodes,
|
||||
clearSearch,
|
||||
isSearchMatch,
|
||||
}
|
||||
}
|
||||
@@ -1,203 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen } from 'lucide-vue-next'
|
||||
import { ArrowLeftRight, Blocks, Clock3, LogOut } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const sftpTabsStore = useSftpTabsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const terminalTabs = computed(() => tabsStore.tabs)
|
||||
const sftpTabs = computed(() => sftpTabsStore.tabs)
|
||||
const showTerminalWorkspace = computed(() => route.path === '/terminal')
|
||||
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
|
||||
const now = ref(new Date())
|
||||
let clockTimer = 0
|
||||
|
||||
connectionsStore.fetchConnections().catch(() => {})
|
||||
const nowText = computed(() => {
|
||||
return now.value.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
})
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function handleTabClick(tabId: string) {
|
||||
tabsStore.activate(tabId)
|
||||
router.push('/terminal')
|
||||
closeSidebar()
|
||||
}
|
||||
onMounted(() => {
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
function handleTabClose(tabId: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
tabsStore.close(tabId)
|
||||
}
|
||||
|
||||
function isCurrentSftpRoute(connectionId: number) {
|
||||
if (route.name !== 'Sftp') return false
|
||||
|
||||
const routeParamId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
return Number(routeParamId) === connectionId
|
||||
}
|
||||
|
||||
function handleSftpTabClick(tabId: string, connectionId: number) {
|
||||
sftpTabsStore.activate(tabId)
|
||||
router.push(`/sftp/${connectionId}`)
|
||||
closeSidebar()
|
||||
}
|
||||
|
||||
function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
||||
event.stopPropagation()
|
||||
|
||||
const shouldNavigate = isCurrentSftpRoute(connectionId)
|
||||
sftpTabsStore.close(tabId)
|
||||
|
||||
if (!shouldNavigate) return
|
||||
|
||||
if (sftpTabsStore.activeTab) {
|
||||
router.push(`/sftp/${sftpTabsStore.activeTab.connectionId}`)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/connections')
|
||||
}
|
||||
onUnmounted(() => {
|
||||
clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-slate-900">
|
||||
<button
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 cursor-pointer"
|
||||
aria-label="切换侧边栏"
|
||||
>
|
||||
<Menu v-if="!sidebarOpen" class="w-6 h-6" aria-hidden="true" />
|
||||
<X v-else class="w-6 h-6" aria-hidden="true" />
|
||||
</button>
|
||||
<aside
|
||||
:class="[
|
||||
'w-64 bg-slate-800 border-r border-slate-700 flex flex-col transition-transform duration-200 z-20',
|
||||
'fixed lg:static inset-y-0 left-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
]"
|
||||
>
|
||||
<div class="p-4 border-b border-slate-700">
|
||||
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
||||
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||
<div class="flex h-screen bg-slate-950 text-slate-100">
|
||||
<aside class="w-64 border-r border-slate-700/80 flex flex-col bg-slate-900/95">
|
||||
<div class="px-4 py-4 border-b border-slate-700/80">
|
||||
<h1 class="text-sm font-semibold tracking-wide text-slate-100">SSH Manager</h1>
|
||||
<p class="text-xs text-slate-400 mt-1">{{ authStore.displayName || authStore.username }}</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4 overflow-y-auto">
|
||||
|
||||
<nav class="flex-1 px-3 py-3 overflow-y-auto space-y-1">
|
||||
<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="连接列表"
|
||||
to="/moba"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path.startsWith('/moba') }"
|
||||
aria-label="主页"
|
||||
>
|
||||
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>连接列表</span>
|
||||
<Blocks class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>主页(Moba)</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/transfers"
|
||||
@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 === '/transfers' }"
|
||||
aria-label="传输"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path.startsWith('/transfers') }"
|
||||
aria-label="传输队列"
|
||||
>
|
||||
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>传输</span>
|
||||
<ArrowLeftRight class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Transfers</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- 终端标签区域 -->
|
||||
<div v-if="terminalTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||
<span>终端</span>
|
||||
</div>
|
||||
<div class="space-y-1 mt-2">
|
||||
<button
|
||||
v-for="tab in terminalTabs"
|
||||
:key="tab.id"
|
||||
@click="handleTabClick(tab.id)"
|
||||
class="w-full flex items-center justify-between gap-2 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 group"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === '/terminal' }"
|
||||
>
|
||||
<span class="truncate text-sm">{{ tab.title }}</span>
|
||||
<button
|
||||
@click="(e) => handleTabClose(tab.id, e)"
|
||||
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-all duration-200 flex-shrink-0"
|
||||
aria-label="关闭标签"
|
||||
>
|
||||
<X class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sftpTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||
<span>文件</span>
|
||||
</div>
|
||||
<div class="space-y-1 mt-2">
|
||||
<div
|
||||
v-for="tab in sftpTabs"
|
||||
:key="tab.id"
|
||||
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 min-h-[44px] group focus-within:outline-none focus-within:ring-2 focus-within:ring-cyan-500 focus-within:ring-inset"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === `/sftp/${tab.connectionId}` }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSftpTabClick(tab.id, tab.connectionId)"
|
||||
class="flex-1 min-w-0 text-left cursor-pointer focus:outline-none"
|
||||
:aria-label="`打开文件标签 ${tab.title}`"
|
||||
>
|
||||
<span class="truncate text-sm block">{{ tab.title }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="(e) => handleSftpTabClose(tab.id, tab.connectionId, e)"
|
||||
class="p-1 rounded opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100 hover:bg-slate-600 transition-opacity duration-200 transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
aria-label="关闭文件标签"
|
||||
>
|
||||
<X class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-slate-700">
|
||||
|
||||
<div class="p-3 border-t border-slate-700/80">
|
||||
<button
|
||||
@click="authStore.logout(); $router.push('/login')"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||
@click="handleLogout"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
aria-label="退出登录"
|
||||
>
|
||||
<LogOut class="w-5 h-5" aria-hidden="true" />
|
||||
<LogOut class="w-4 h-4" aria-hidden="true" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="lg:hidden fixed inset-0 bg-black/50 z-10"
|
||||
aria-hidden="true"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
<main class="flex-1 overflow-auto min-w-0">
|
||||
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
|
||||
<TerminalWorkspaceView :visible="showTerminalWorkspace" />
|
||||
|
||||
<main class="flex-1 min-w-0 overflow-hidden flex flex-col">
|
||||
<header class="h-12 border-b border-slate-700/80 bg-slate-900/70 backdrop-blur px-4 md:px-5 flex items-center justify-between">
|
||||
<h2 class="text-sm md:text-base font-medium text-slate-100 truncate">Transfer Queue</h2>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-400">
|
||||
<Clock3 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span class="tabular-nums">{{ nowText }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<RouterView />
|
||||
</div>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<template v-if="!showTerminalWorkspace">
|
||||
<keep-alive :max="10" v-if="route.meta.keepAlive">
|
||||
<component :is="Component" :key="route.params.id" />
|
||||
</keep-alive>
|
||||
<component :is="Component" :key="route.fullPath" v-else />
|
||||
</template>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
220
frontend/src/layouts/MobaLayout.vue
Normal file
220
frontend/src/layouts/MobaLayout.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeftRight, X } from 'lucide-vue-next'
|
||||
import { useSessionTreeStore } from '../stores/sessionTree'
|
||||
import { useWorkspaceStore } from '../stores/workspace'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useConnectionSync } from '../composables/useConnectionSync'
|
||||
import type { ConnectionCreateRequest } from '../api/connections'
|
||||
import TopToolbar from '../components/TopToolbar.vue'
|
||||
import SessionTree from '../components/SessionTree.vue'
|
||||
import WorkspacePanel from '../components/WorkspacePanel.vue'
|
||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||
import MigrationPrompt from '../components/MigrationPrompt.vue'
|
||||
import TransfersView from '../views/TransfersView.vue'
|
||||
|
||||
const treeStore = useSessionTreeStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const showMigrationPrompt = ref(false)
|
||||
const transfersModalOpen = computed(() => workspaceStore.transfersModalOpen)
|
||||
const sessionModalOpen = computed(() => workspaceStore.sessionModalOpen)
|
||||
const sessionModalMode = computed(() => workspaceStore.sessionModalMode)
|
||||
const currentEditingConnection = computed(() => {
|
||||
if (sessionModalMode.value !== 'edit' || workspaceStore.editingConnectionId == null) {
|
||||
return null
|
||||
}
|
||||
return connectionsStore.getConnection(workspaceStore.editingConnectionId) || null
|
||||
})
|
||||
|
||||
// Enable bidirectional sync
|
||||
useConnectionSync()
|
||||
|
||||
watch(
|
||||
() => route.query.tool,
|
||||
(tool) => {
|
||||
const shouldOpen = tool === 'transfers'
|
||||
if (shouldOpen && !workspaceStore.transfersModalOpen) {
|
||||
workspaceStore.openTransfersModal()
|
||||
} else if (!shouldOpen && workspaceStore.transfersModalOpen) {
|
||||
workspaceStore.closeTransfersModal()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(transfersModalOpen, (isOpen) => {
|
||||
if (isOpen && route.query.tool !== 'transfers') {
|
||||
router.replace({
|
||||
path: '/moba',
|
||||
query: {
|
||||
...route.query,
|
||||
tool: 'transfers',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOpen && route.query.tool === 'transfers') {
|
||||
const query = {
|
||||
...route.query,
|
||||
}
|
||||
delete query.tool
|
||||
router.replace({
|
||||
path: '/moba',
|
||||
query,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await treeStore.restore()
|
||||
workspaceStore.restore()
|
||||
if (route.query.tool === 'transfers') {
|
||||
workspaceStore.openTransfersModal()
|
||||
}
|
||||
|
||||
let connectionsLoaded = false
|
||||
try {
|
||||
await connectionsStore.fetchConnections()
|
||||
connectionsLoaded = true
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch connections on startup:', error)
|
||||
}
|
||||
|
||||
const migrationDismissed = localStorage.getItem('ssh-manager.migration-dismissed')
|
||||
const hasOldData = connectionsLoaded && connectionsStore.connections.length > 0
|
||||
const hasNewData = treeStore.nodes.length > 0
|
||||
|
||||
if (!migrationDismissed && hasOldData && !hasNewData) {
|
||||
showMigrationPrompt.value = true
|
||||
} else if (treeStore.nodes.length === 0 && connectionsLoaded) {
|
||||
await treeStore.initFromConnections()
|
||||
} else if (connectionsLoaded) {
|
||||
treeStore.syncNewConnections()
|
||||
treeStore.syncDeletedConnections()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
async function handleMigrate() {
|
||||
await treeStore.initFromConnections()
|
||||
showMigrationPrompt.value = false
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
showMigrationPrompt.value = false
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showMigrationPrompt.value = false
|
||||
}
|
||||
|
||||
function closeTransfersModal() {
|
||||
workspaceStore.closeTransfersModal()
|
||||
}
|
||||
|
||||
function closeSessionModal() {
|
||||
workspaceStore.closeSessionModal()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && workspaceStore.transfersModalOpen) {
|
||||
closeTransfersModal()
|
||||
}
|
||||
if (event.key === 'Escape' && workspaceStore.sessionModalOpen) {
|
||||
closeSessionModal()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionSubmit(data: ConnectionCreateRequest) {
|
||||
if (sessionModalMode.value === 'edit') {
|
||||
const editingConnectionId = workspaceStore.editingConnectionId
|
||||
if (editingConnectionId == null) return
|
||||
await connectionsStore.updateConnection(editingConnectionId, data)
|
||||
} else {
|
||||
await connectionsStore.createConnection(data)
|
||||
// Tree node insertion is handled by useConnectionSync -> syncNewConnections.
|
||||
// Avoid manual insertion here to prevent duplicate nodes.
|
||||
}
|
||||
closeSessionModal()
|
||||
}
|
||||
|
||||
function onTransfersModalBackdropClick(event: MouseEvent) {
|
||||
if (event.target !== event.currentTarget) return
|
||||
closeTransfersModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-slate-950 text-slate-100 overflow-hidden">
|
||||
<TopToolbar />
|
||||
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
<div class="w-72 flex-shrink-0">
|
||||
<SessionTree />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<WorkspacePanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="transfersModalOpen"
|
||||
class="fixed inset-0 z-40 bg-slate-950/65 p-4 md:p-6 flex items-center justify-center"
|
||||
@click="onTransfersModalBackdropClick"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[1240px] h-[88vh] rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl flex flex-col overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Transfers 弹窗"
|
||||
>
|
||||
<header class="h-12 px-4 border-b border-slate-800 flex items-center justify-between shrink-0">
|
||||
<div class="inline-flex items-center gap-2 text-sm text-slate-200">
|
||||
<ArrowLeftRight class="w-4 h-4 text-cyan-300" />
|
||||
<span class="font-medium">Transfers</span>
|
||||
</div>
|
||||
<button
|
||||
@click="closeTransfersModal"
|
||||
class="w-9 h-9 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
aria-label="关闭 Transfers 面板"
|
||||
>
|
||||
<X class="w-4 h-4 mx-auto" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<TransfersView embedded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionModalOpen" class="z-50">
|
||||
<ConnectionForm
|
||||
:connection="currentEditingConnection"
|
||||
:on-save="handleSessionSubmit"
|
||||
@close="closeSessionModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MigrationPrompt
|
||||
:show="showMigrationPrompt"
|
||||
@migrate="handleMigrate"
|
||||
@dismiss="handleDismiss"
|
||||
@close="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,50 +2,26 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
redirect: '/connections',
|
||||
},
|
||||
{
|
||||
path: 'transfers',
|
||||
name: 'Transfers',
|
||||
component: () => import('../views/TransfersView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'connections',
|
||||
name: 'Connections',
|
||||
component: () => import('../views/ConnectionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'terminal',
|
||||
name: 'TerminalWorkspace',
|
||||
component: () => import('../views/TerminalWorkspaceView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'terminal/:id',
|
||||
name: 'Terminal',
|
||||
component: () => import('../views/TerminalView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sftp/:id',
|
||||
name: 'Sftp',
|
||||
component: () => import('../views/SftpView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
],
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/moba',
|
||||
},
|
||||
{
|
||||
path: '/moba',
|
||||
name: 'MobaLayout',
|
||||
component: () => import('../layouts/MobaLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/transfers',
|
||||
redirect: '/moba?tool=transfers',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -55,10 +31,10 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.public) {
|
||||
if (authStore.isAuthenticated && to.path === '/login') {
|
||||
next('/connections')
|
||||
next('/moba')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ import { ref } from 'vue'
|
||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||
import * as connectionsApi from '../api/connections'
|
||||
|
||||
export const useConnectionsStore = defineStore('connections', () => {
|
||||
const connections = ref<Connection[]>([])
|
||||
|
||||
async function fetchConnections() {
|
||||
const res = await connectionsApi.listConnections()
|
||||
connections.value = res.data
|
||||
return res.data
|
||||
}
|
||||
export const useConnectionsStore = defineStore('connections', () => {
|
||||
const connections = ref<Connection[]>([])
|
||||
const hasFetched = ref(false)
|
||||
|
||||
async function fetchConnections() {
|
||||
const res = await connectionsApi.listConnections()
|
||||
connections.value = res.data
|
||||
hasFetched.value = true
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function createConnection(data: ConnectionCreateRequest) {
|
||||
const res = await connectionsApi.createConnection(data)
|
||||
@@ -34,11 +36,12 @@ export const useConnectionsStore = defineStore('connections', () => {
|
||||
return connections.value.find((c) => c.id === id)
|
||||
}
|
||||
|
||||
return {
|
||||
connections,
|
||||
fetchConnections,
|
||||
createConnection,
|
||||
updateConnection,
|
||||
return {
|
||||
connections,
|
||||
hasFetched,
|
||||
fetchConnections,
|
||||
createConnection,
|
||||
updateConnection,
|
||||
deleteConnection,
|
||||
getConnection,
|
||||
}
|
||||
|
||||
319
frontend/src/stores/sessionTree.ts
Normal file
319
frontend/src/stores/sessionTree.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { SessionTreeNode, SessionTreeState } from '../types/sessionTree'
|
||||
import { getSessionTree, saveSessionTree } from '../api/sessionTree'
|
||||
import { useConnectionsStore } from './connections'
|
||||
|
||||
const STORAGE_KEY = 'ssh-manager.session-tree'
|
||||
let persistTimer: number | null = null
|
||||
|
||||
export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
state: (): SessionTreeState => ({
|
||||
nodes: [],
|
||||
selectedNodeId: null,
|
||||
hydrated: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
rootNodes: (state) => {
|
||||
return state.nodes
|
||||
.filter(n => n.parentId === null)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
},
|
||||
|
||||
getChildren: (state) => (parentId: string) => {
|
||||
return state.nodes
|
||||
.filter(n => n.parentId === parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
},
|
||||
|
||||
getNodePath: (state) => (nodeId: string): SessionTreeNode[] => {
|
||||
const path: SessionTreeNode[] = []
|
||||
let current = state.nodes.find(n => n.id === nodeId)
|
||||
while (current) {
|
||||
path.unshift(current)
|
||||
current = current.parentId
|
||||
? state.nodes.find(n => n.id === current!.parentId)
|
||||
: undefined
|
||||
}
|
||||
return path
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
createFolder(name: string, parentId: string | null = null) {
|
||||
const node: SessionTreeNode = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'folder',
|
||||
name,
|
||||
parentId,
|
||||
order: this.getMaxOrder(parentId) + 1,
|
||||
expanded: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
this.nodes.push(node)
|
||||
this.persist()
|
||||
return node
|
||||
},
|
||||
|
||||
createConnectionNode(connectionId: number, name: string, parentId: string | null = null) {
|
||||
const existingNode = this.nodes.find(
|
||||
n => n.type === 'connection' && n.connectionId === connectionId
|
||||
)
|
||||
if (existingNode) {
|
||||
if (existingNode.name !== name) {
|
||||
existingNode.name = name
|
||||
existingNode.updatedAt = Date.now()
|
||||
this.persist()
|
||||
}
|
||||
return existingNode
|
||||
}
|
||||
|
||||
const node: SessionTreeNode = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'connection',
|
||||
name,
|
||||
parentId,
|
||||
order: this.getMaxOrder(parentId) + 1,
|
||||
connectionId,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
this.nodes.push(node)
|
||||
this.persist()
|
||||
return node
|
||||
},
|
||||
|
||||
moveNode(nodeId: string, newParentId: string | null, newOrder: number) {
|
||||
const node = this.nodes.find(n => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
node.parentId = newParentId
|
||||
node.order = newOrder
|
||||
node.updatedAt = Date.now()
|
||||
|
||||
this.reorderSiblings(newParentId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
deleteNode(nodeId: string) {
|
||||
this.deleteNodeInternal(nodeId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
deleteNodeInternal(nodeId: string) {
|
||||
const children = this.getChildren(nodeId)
|
||||
children.forEach(child => this.deleteNodeInternal(child.id))
|
||||
this.nodes = this.nodes.filter(n => n.id !== nodeId)
|
||||
},
|
||||
|
||||
renameNode(nodeId: string, newName: string) {
|
||||
const node = this.nodes.find(n => n.id === nodeId)
|
||||
if (node) {
|
||||
node.name = newName
|
||||
node.updatedAt = Date.now()
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
|
||||
toggleExpanded(nodeId: string) {
|
||||
const node = this.nodes.find(n => n.id === nodeId)
|
||||
if (node && node.type === 'folder') {
|
||||
node.expanded = !node.expanded
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
|
||||
selectNode(nodeId: string | null) {
|
||||
this.selectedNodeId = nodeId
|
||||
},
|
||||
|
||||
getMaxOrder(parentId: string | null): number {
|
||||
const siblings = this.nodes.filter(n => n.parentId === parentId)
|
||||
return siblings.length > 0
|
||||
? Math.max(...siblings.map(n => n.order))
|
||||
: 0
|
||||
},
|
||||
|
||||
reorderSiblings(parentId: string | null) {
|
||||
const siblings = this.nodes
|
||||
.filter(n => n.parentId === parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
siblings.forEach((node, index) => {
|
||||
node.order = index
|
||||
})
|
||||
},
|
||||
|
||||
persist() {
|
||||
this.persistLocal()
|
||||
if (!this.hydrated) return
|
||||
|
||||
if (persistTimer !== null) {
|
||||
window.clearTimeout(persistTimer)
|
||||
}
|
||||
persistTimer = window.setTimeout(() => {
|
||||
void this.persistRemote()
|
||||
}, 250)
|
||||
},
|
||||
|
||||
persistLocal() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
nodes: this.nodes,
|
||||
selectedNodeId: this.selectedNodeId,
|
||||
}))
|
||||
},
|
||||
|
||||
async persistRemote() {
|
||||
try {
|
||||
await saveSessionTree({ nodes: this.nodes })
|
||||
} catch (error) {
|
||||
console.error('Failed to persist session tree to server:', error)
|
||||
}
|
||||
},
|
||||
|
||||
restoreLocal() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
if (Array.isArray(data.nodes)) {
|
||||
this.nodes = data.nodes
|
||||
}
|
||||
if (typeof data.selectedNodeId === 'string' || data.selectedNodeId === null) {
|
||||
this.selectedNodeId = data.selectedNodeId
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore local session tree:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async restore() {
|
||||
if (this.hydrated) return
|
||||
|
||||
this.restoreLocal()
|
||||
|
||||
try {
|
||||
const response = await getSessionTree()
|
||||
if (Array.isArray(response.data?.nodes)) {
|
||||
this.nodes = response.data.nodes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore session tree from server:', error)
|
||||
}
|
||||
|
||||
this.dedupeConnectionNodes()
|
||||
this.hydrated = true
|
||||
this.persistLocal()
|
||||
},
|
||||
|
||||
async initFromConnections() {
|
||||
const connectionsStore = useConnectionsStore()
|
||||
await connectionsStore.fetchConnections()
|
||||
|
||||
if (this.nodes.length > 0) return
|
||||
|
||||
const defaultFolder = this.createFolder('我的连接')
|
||||
|
||||
connectionsStore.connections.forEach(conn => {
|
||||
this.createConnectionNode(conn.id, conn.name, defaultFolder.id)
|
||||
})
|
||||
},
|
||||
|
||||
// Sync with connections store - add new connections
|
||||
syncNewConnections() {
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const existingConnectionIds = new Set(
|
||||
this.nodes
|
||||
.filter(n => n.type === 'connection' && n.connectionId)
|
||||
.map(n => n.connectionId!)
|
||||
)
|
||||
|
||||
const newConnections = connectionsStore.connections.filter(
|
||||
conn => !existingConnectionIds.has(conn.id)
|
||||
)
|
||||
|
||||
if (newConnections.length > 0) {
|
||||
let targetParentId: string | null = null
|
||||
const defaultFolder = this.nodes.find(
|
||||
n => n.type === 'folder' && n.name === '我的连接'
|
||||
)
|
||||
if (defaultFolder) {
|
||||
targetParentId = defaultFolder.id
|
||||
}
|
||||
|
||||
newConnections.forEach(conn => {
|
||||
this.createConnectionNode(conn.id, conn.name, targetParentId)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Sync with connections store - remove deleted connections
|
||||
syncDeletedConnections() {
|
||||
const connectionsStore = useConnectionsStore()
|
||||
if (!connectionsStore.hasFetched) return
|
||||
|
||||
const validConnectionIds = new Set(
|
||||
connectionsStore.connections.map(c => c.id)
|
||||
)
|
||||
|
||||
const nodesToDelete = this.nodes.filter(
|
||||
n => n.type === 'connection' &&
|
||||
n.connectionId &&
|
||||
!validConnectionIds.has(n.connectionId)
|
||||
)
|
||||
|
||||
if (nodesToDelete.length === 0) return
|
||||
|
||||
nodesToDelete.forEach(node => {
|
||||
this.deleteNodeInternal(node.id)
|
||||
})
|
||||
this.persist()
|
||||
},
|
||||
|
||||
// Update connection name when changed
|
||||
syncConnectionName(connectionId: number, newName: string) {
|
||||
const node = this.nodes.find(
|
||||
n => n.type === 'connection' && n.connectionId === connectionId
|
||||
)
|
||||
if (node && node.name !== newName) {
|
||||
this.renameNode(node.id, newName)
|
||||
}
|
||||
},
|
||||
|
||||
dedupeConnectionNodes() {
|
||||
const seen = new Set<number>()
|
||||
const before = this.nodes.length
|
||||
|
||||
this.nodes = this.nodes.filter(node => {
|
||||
if (node.type !== 'connection' || !node.connectionId) return true
|
||||
if (seen.has(node.connectionId)) return false
|
||||
seen.add(node.connectionId)
|
||||
return true
|
||||
})
|
||||
|
||||
if (this.nodes.length !== before) {
|
||||
this.persistLocal()
|
||||
}
|
||||
},
|
||||
|
||||
expandAll() {
|
||||
this.nodes.forEach(node => {
|
||||
if (node.type === 'folder') {
|
||||
node.expanded = true
|
||||
}
|
||||
})
|
||||
this.persist()
|
||||
},
|
||||
|
||||
collapseAll() {
|
||||
this.nodes.forEach(node => {
|
||||
if (node.type === 'folder') {
|
||||
node.expanded = false
|
||||
}
|
||||
})
|
||||
this.persist()
|
||||
},
|
||||
},
|
||||
})
|
||||
234
frontend/src/stores/workspace.ts
Normal file
234
frontend/src/stores/workspace.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { WorkspaceState, WorkspacePanelState } from '../types/workspace'
|
||||
|
||||
const STORAGE_KEY = 'ssh-manager.workspace'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', {
|
||||
state: (): WorkspaceState => ({
|
||||
activeConnectionId: null,
|
||||
panels: {},
|
||||
panelOrder: [],
|
||||
transfersModalOpen: false,
|
||||
sessionModalOpen: false,
|
||||
sessionModalMode: 'create',
|
||||
editingConnectionId: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
activePanel: (state): WorkspacePanelState | null => {
|
||||
return state.activeConnectionId
|
||||
? state.panels[state.activeConnectionId] || null
|
||||
: null
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
openPanel(connectionId: number) {
|
||||
if (!this.panels[connectionId]) {
|
||||
this.panels[connectionId] = {
|
||||
connectionId,
|
||||
splitRatio: 0.5,
|
||||
terminalVisible: true,
|
||||
sftpVisible: true,
|
||||
currentPath: '.',
|
||||
selectedFiles: [],
|
||||
lastActiveAt: Date.now(),
|
||||
}
|
||||
this.panelOrder.push(connectionId)
|
||||
} else if (!this.panelOrder.includes(connectionId)) {
|
||||
this.panelOrder.push(connectionId)
|
||||
}
|
||||
|
||||
this.activeConnectionId = connectionId
|
||||
this.panels[connectionId].lastActiveAt = Date.now()
|
||||
this.persist()
|
||||
},
|
||||
|
||||
closePanel(connectionId: number) {
|
||||
const closedIndex = this.panelOrder.indexOf(connectionId)
|
||||
delete this.panels[connectionId]
|
||||
this.panelOrder = this.panelOrder.filter((id) => id !== connectionId)
|
||||
|
||||
if (this.activeConnectionId === connectionId) {
|
||||
const nextId = closedIndex >= 0
|
||||
? this.panelOrder[closedIndex] ?? this.panelOrder[closedIndex - 1] ?? null
|
||||
: this.panelOrder[0] ?? null
|
||||
this.activeConnectionId = nextId
|
||||
}
|
||||
|
||||
this.persist()
|
||||
},
|
||||
|
||||
closeOtherPanels(connectionId: number) {
|
||||
if (!this.panels[connectionId]) return
|
||||
|
||||
this.panelOrder.forEach((id) => {
|
||||
if (id !== connectionId) {
|
||||
delete this.panels[id]
|
||||
}
|
||||
})
|
||||
|
||||
this.panelOrder = [connectionId]
|
||||
this.activeConnectionId = connectionId
|
||||
this.persist()
|
||||
},
|
||||
|
||||
closePanelsToRight(connectionId: number) {
|
||||
const targetIndex = this.panelOrder.indexOf(connectionId)
|
||||
if (targetIndex < 0) return
|
||||
|
||||
const rightSideIds = this.panelOrder.slice(targetIndex + 1)
|
||||
if (rightSideIds.length === 0) return
|
||||
|
||||
rightSideIds.forEach((id) => {
|
||||
delete this.panels[id]
|
||||
})
|
||||
|
||||
this.panelOrder = this.panelOrder.slice(0, targetIndex + 1)
|
||||
if (this.activeConnectionId && !this.panels[this.activeConnectionId]) {
|
||||
this.activeConnectionId = connectionId
|
||||
}
|
||||
|
||||
this.persist()
|
||||
},
|
||||
|
||||
closeAllPanels() {
|
||||
this.activeConnectionId = null
|
||||
this.panels = {}
|
||||
this.panelOrder = []
|
||||
this.persist()
|
||||
},
|
||||
|
||||
openTransfersModal() {
|
||||
this.transfersModalOpen = true
|
||||
},
|
||||
|
||||
closeTransfersModal() {
|
||||
this.transfersModalOpen = false
|
||||
},
|
||||
|
||||
toggleTransfersModal() {
|
||||
this.transfersModalOpen = !this.transfersModalOpen
|
||||
},
|
||||
|
||||
openCreateSessionModal() {
|
||||
this.sessionModalMode = 'create'
|
||||
this.editingConnectionId = null
|
||||
this.sessionModalOpen = true
|
||||
},
|
||||
|
||||
openEditSessionModal(connectionId: number) {
|
||||
this.sessionModalMode = 'edit'
|
||||
this.editingConnectionId = connectionId
|
||||
this.sessionModalOpen = true
|
||||
},
|
||||
|
||||
closeSessionModal() {
|
||||
this.sessionModalOpen = false
|
||||
this.sessionModalMode = 'create'
|
||||
this.editingConnectionId = null
|
||||
},
|
||||
|
||||
closeCreateSessionModal() {
|
||||
this.closeSessionModal()
|
||||
},
|
||||
|
||||
updateSplitRatio(connectionId: number, ratio: number) {
|
||||
const panel = this.panels[connectionId]
|
||||
if (panel) {
|
||||
panel.splitRatio = Math.max(0.2, Math.min(0.8, ratio))
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
|
||||
toggleTerminal(connectionId: number) {
|
||||
const panel = this.panels[connectionId]
|
||||
if (panel) {
|
||||
panel.terminalVisible = !panel.terminalVisible
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
|
||||
toggleSftp(connectionId: number) {
|
||||
const panel = this.panels[connectionId]
|
||||
if (panel) {
|
||||
panel.sftpVisible = !panel.sftpVisible
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
|
||||
updateSftpPath(connectionId: number, path: string) {
|
||||
const panel = this.panels[connectionId]
|
||||
if (panel) {
|
||||
panel.currentPath = path
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
|
||||
updateSelectedFiles(connectionId: number, files: string[]) {
|
||||
const panel = this.panels[connectionId]
|
||||
if (panel) {
|
||||
panel.selectedFiles = files
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
|
||||
persist() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.$state))
|
||||
},
|
||||
|
||||
restore() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
this.$patch(data)
|
||||
const panelIds = Object.keys(this.panels).map((id) => Number(id))
|
||||
if (!Array.isArray(this.panelOrder)) {
|
||||
this.panelOrder = []
|
||||
}
|
||||
|
||||
if (typeof this.transfersModalOpen !== 'boolean') {
|
||||
this.transfersModalOpen = typeof data.transfersDrawerOpen === 'boolean'
|
||||
? data.transfersDrawerOpen
|
||||
: false
|
||||
}
|
||||
if (typeof this.sessionModalOpen !== 'boolean') {
|
||||
this.sessionModalOpen = typeof data.createSessionModalOpen === 'boolean'
|
||||
? data.createSessionModalOpen
|
||||
: false
|
||||
}
|
||||
if (this.sessionModalMode !== 'create' && this.sessionModalMode !== 'edit') {
|
||||
this.sessionModalMode = 'create'
|
||||
}
|
||||
if (typeof this.editingConnectionId !== 'number') {
|
||||
this.editingConnectionId = null
|
||||
}
|
||||
this.transfersModalOpen = false
|
||||
this.sessionModalOpen = false
|
||||
this.sessionModalMode = 'create'
|
||||
this.editingConnectionId = null
|
||||
|
||||
this.panelOrder = this.panelOrder.filter((id) => this.panels[id])
|
||||
panelIds.forEach((id) => {
|
||||
if (!this.panelOrder.includes(id)) {
|
||||
this.panelOrder.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
if (this.activeConnectionId && !this.panels[this.activeConnectionId]) {
|
||||
this.activeConnectionId = this.panelOrder[0] ?? null
|
||||
}
|
||||
|
||||
if (!this.activeConnectionId && this.panelOrder.length > 0) {
|
||||
this.activeConnectionId = this.panelOrder[0] ?? null
|
||||
}
|
||||
|
||||
this.persist()
|
||||
} catch (e) {
|
||||
console.error('Failed to restore workspace:', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
19
frontend/src/types/sessionTree.ts
Normal file
19
frontend/src/types/sessionTree.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { SessionTreeNodeType } from '../api/sessionTree'
|
||||
|
||||
export interface SessionTreeNode {
|
||||
id: string
|
||||
type: SessionTreeNodeType
|
||||
name: string
|
||||
parentId: string | null
|
||||
order: number
|
||||
connectionId?: number
|
||||
expanded?: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface SessionTreeState {
|
||||
nodes: SessionTreeNode[]
|
||||
selectedNodeId: string | null
|
||||
hydrated: boolean
|
||||
}
|
||||
19
frontend/src/types/workspace.ts
Normal file
19
frontend/src/types/workspace.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface WorkspacePanelState {
|
||||
connectionId: number
|
||||
splitRatio: number
|
||||
terminalVisible: boolean
|
||||
sftpVisible: boolean
|
||||
currentPath: string
|
||||
selectedFiles: string[]
|
||||
lastActiveAt: number
|
||||
}
|
||||
|
||||
export interface WorkspaceState {
|
||||
activeConnectionId: number | null
|
||||
panels: Record<number, WorkspacePanelState>
|
||||
panelOrder: number[]
|
||||
transfersModalOpen: boolean
|
||||
sessionModalOpen: boolean
|
||||
sessionModalMode: 'create' | 'edit'
|
||||
editingConnectionId: number | null
|
||||
}
|
||||
@@ -1,250 +1,419 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Key,
|
||||
Lock,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useConnectionsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const sftpTabsStore = useSftpTabsStore()
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingConn = ref<Connection | null>(null)
|
||||
const searchQuery = ref('')
|
||||
let searchDebounceTimer = 0
|
||||
|
||||
const keyword = computed(() => route.query.q as string || '')
|
||||
const filteredConnections = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
|
||||
if (!q) {
|
||||
return store.connections
|
||||
}
|
||||
|
||||
return store.connections.filter((conn) => {
|
||||
const fields = [
|
||||
conn.name,
|
||||
conn.host,
|
||||
conn.username,
|
||||
String(conn.port),
|
||||
]
|
||||
|
||||
return fields.some((field) => field.toLowerCase().includes(q))
|
||||
})
|
||||
})
|
||||
|
||||
const updateSearchParam = (value: string) => {
|
||||
router.push({ query: { ...route.query, q: value } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
searchQuery.value = keyword.value
|
||||
store.fetchConnections()
|
||||
})
|
||||
|
||||
watch(searchQuery, (val) => {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
updateSearchParam(val)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
editingConn.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openEdit(conn: Connection) {
|
||||
editingConn.value = conn
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm.value = false
|
||||
editingConn.value = null
|
||||
}
|
||||
|
||||
async function handleSave(data: ConnectionCreateRequest) {
|
||||
if (editingConn.value) {
|
||||
await store.updateConnection(editingConn.value.id, data)
|
||||
} else {
|
||||
await store.createConnection(data)
|
||||
}
|
||||
closeForm()
|
||||
}
|
||||
|
||||
async function handleDelete(conn: Connection) {
|
||||
if (!confirm(`确定删除连接「${conn.name}」?`)) return
|
||||
await store.deleteConnection(conn.id)
|
||||
}
|
||||
|
||||
function openTerminal(conn: Connection) {
|
||||
tabsStore.openTab(conn)
|
||||
router.push('/terminal')
|
||||
}
|
||||
|
||||
function openSftp(conn: Connection) {
|
||||
sftpTabsStore.openOrFocus(conn)
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
updateSearchParam('')
|
||||
}
|
||||
|
||||
function highlightMatch(text: string): string {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q) return text
|
||||
|
||||
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(escaped, 'gi')
|
||||
|
||||
return text.replace(regex, (match) => `<span class="text-cyan-300 font-semibold">${match}</span>`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-slate-100">连接列表</h2>
|
||||
<button
|
||||
@click="openCreate"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 cursor-pointer"
|
||||
aria-label="添加连接"
|
||||
>
|
||||
<Plus class="w-5 h-5" aria-hidden="true" />
|
||||
添加连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative mb-6">
|
||||
<Search class="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" aria-hidden="true" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索名称、主机、用户名或端口"
|
||||
class="w-full rounded-xl border border-slate-700 bg-slate-800/70 py-3 pl-10 pr-11 text-slate-100 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
aria-label="搜索连接"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="clearSearch"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.connections.length === 0" class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700">
|
||||
<Server class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
||||
<p class="text-slate-400 mb-4">暂无连接</p>
|
||||
<button
|
||||
@click="openCreate"
|
||||
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
添加第一个连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredConnections.length === 0"
|
||||
class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700"
|
||||
>
|
||||
<Search class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
||||
<p class="text-slate-300 mb-2">未找到匹配的连接</p>
|
||||
<p class="text-sm text-slate-500 mb-4">试试搜索名称、主机、用户名或端口</p>
|
||||
<button
|
||||
@click="clearSearch"
|
||||
class="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
清空搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="conn in filteredConnections"
|
||||
:key="conn.id"
|
||||
class="bg-slate-800 rounded-xl border border-slate-700 p-4 hover:border-slate-600 transition-colors duration-200"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
||||
<Server class="w-5 h-5 text-cyan-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-100" v-html="highlightMatch(conn.name)"></h3>
|
||||
<p class="text-sm text-slate-400" v-html="highlightMatch(`${conn.username}@${conn.host}:${conn.port}`)"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="openEdit(conn)"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="编辑"
|
||||
>
|
||||
<Pencil class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(conn)"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<component
|
||||
:is="conn.authType === 'PRIVATE_KEY' ? Key : Lock"
|
||||
class="w-4 h-4 text-slate-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||
终端
|
||||
</button>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||
文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionForm
|
||||
v-if="showForm"
|
||||
:connection="editingConn"
|
||||
:on-save="handleSave"
|
||||
@close="closeForm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Key,
|
||||
Lock,
|
||||
Search,
|
||||
X,
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useConnectionsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const sftpTabsStore = useSftpTabsStore()
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingConn = ref<Connection | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
const FAVORITES_STORAGE_KEY = 'ssh-manager.favorite-connections'
|
||||
const favoriteConnectionIds = ref<number[]>([])
|
||||
let searchDebounceTimer = 0
|
||||
|
||||
const keyword = computed(() => route.query.q as string || '')
|
||||
|
||||
const filteredConnections = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
|
||||
if (!q) {
|
||||
return store.connections
|
||||
}
|
||||
|
||||
return store.connections.filter((conn) => {
|
||||
const fields = [
|
||||
conn.name,
|
||||
conn.host,
|
||||
conn.username,
|
||||
String(conn.port),
|
||||
]
|
||||
|
||||
return fields.some((field) => field.toLowerCase().includes(q))
|
||||
})
|
||||
})
|
||||
|
||||
const favoriteConnectionIdSet = computed(() => new Set(favoriteConnectionIds.value))
|
||||
|
||||
const groupedConnections = computed(() => {
|
||||
const favorites: Connection[] = []
|
||||
const others: Connection[] = []
|
||||
|
||||
filteredConnections.value.forEach((conn) => {
|
||||
if (favoriteConnectionIdSet.value.has(conn.id)) {
|
||||
favorites.push(conn)
|
||||
return
|
||||
}
|
||||
others.push(conn)
|
||||
})
|
||||
|
||||
return [
|
||||
{ key: 'favorites', label: '收藏置顶', items: favorites },
|
||||
{ key: 'others', label: '全部连接', items: others },
|
||||
]
|
||||
})
|
||||
|
||||
const passwordCount = computed(() => store.connections.filter((conn) => conn.authType === 'PASSWORD').length)
|
||||
const keyCount = computed(() => store.connections.filter((conn) => conn.authType === 'PRIVATE_KEY').length)
|
||||
|
||||
const updateSearchParam = (value: string) => {
|
||||
router.push({ query: { ...route.query, q: value } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
searchQuery.value = keyword.value
|
||||
loadFavoriteConnections()
|
||||
store.fetchConnections()
|
||||
window.addEventListener('keydown', handleGlobalShortcuts)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleGlobalShortcuts)
|
||||
})
|
||||
|
||||
watch(searchQuery, (val) => {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
updateSearchParam(val)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.connections.map((conn) => conn.id),
|
||||
(ids) => {
|
||||
const idSet = new Set(ids)
|
||||
const next = favoriteConnectionIds.value.filter((id) => idSet.has(id))
|
||||
if (next.length !== favoriteConnectionIds.value.length) {
|
||||
favoriteConnectionIds.value = next
|
||||
saveFavoriteConnections()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function openCreate() {
|
||||
editingConn.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function loadFavoriteConnections() {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAVORITES_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) {
|
||||
favoriteConnectionIds.value = parsed
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isInteger(id) && id > 0)
|
||||
}
|
||||
} catch {
|
||||
favoriteConnectionIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function saveFavoriteConnections() {
|
||||
localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(favoriteConnectionIds.value))
|
||||
}
|
||||
|
||||
function isFavorite(connId: number) {
|
||||
return favoriteConnectionIdSet.value.has(connId)
|
||||
}
|
||||
|
||||
function toggleFavorite(connId: number) {
|
||||
if (isFavorite(connId)) {
|
||||
favoriteConnectionIds.value = favoriteConnectionIds.value.filter((id) => id !== connId)
|
||||
} else {
|
||||
favoriteConnectionIds.value = [...favoriteConnectionIds.value, connId]
|
||||
}
|
||||
saveFavoriteConnections()
|
||||
}
|
||||
|
||||
function openEdit(conn: Connection) {
|
||||
editingConn.value = conn
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm.value = false
|
||||
editingConn.value = null
|
||||
}
|
||||
|
||||
async function handleSave(data: ConnectionCreateRequest) {
|
||||
if (editingConn.value) {
|
||||
await store.updateConnection(editingConn.value.id, data)
|
||||
} else {
|
||||
await store.createConnection(data)
|
||||
}
|
||||
closeForm()
|
||||
}
|
||||
|
||||
async function handleDelete(conn: Connection) {
|
||||
if (!confirm(`确定删除连接「${conn.name}」?`)) return
|
||||
await store.deleteConnection(conn.id)
|
||||
}
|
||||
|
||||
function openTerminal(conn: Connection) {
|
||||
tabsStore.openTab(conn)
|
||||
router.push('/terminal')
|
||||
}
|
||||
|
||||
function openSftp(conn: Connection) {
|
||||
sftpTabsStore.openOrFocus(conn)
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
updateSearchParam('')
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchInputRef.value?.focus()
|
||||
searchInputRef.value?.select()
|
||||
}
|
||||
|
||||
function handleGlobalShortcuts(event: KeyboardEvent) {
|
||||
if (showForm.value) return
|
||||
if (!(event.ctrlKey || event.metaKey)) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === 'f') {
|
||||
event.preventDefault()
|
||||
focusSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'n') {
|
||||
event.preventDefault()
|
||||
openCreate()
|
||||
}
|
||||
}
|
||||
|
||||
function highlightMatch(text: string): string {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q) return text
|
||||
|
||||
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(escaped, 'gi')
|
||||
|
||||
return text.replace(regex, (match) => `<span class="text-cyan-300 font-semibold">${match}</span>`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full p-4 md:p-6 space-y-4">
|
||||
<section class="rounded-xl border border-slate-700 bg-slate-900/70 backdrop-blur-sm">
|
||||
<div class="px-4 py-3 border-b border-slate-700 flex flex-col lg:flex-row lg:items-center gap-3 lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-100">Connections</h2>
|
||||
<p class="text-xs text-slate-400 mt-1">Session 树模式,快速打开终端与文件会话(Ctrl/Cmd+F 搜索,Ctrl/Cmd+N 新建)</p>
|
||||
</div>
|
||||
<div class="flex w-full lg:w-auto items-center gap-2">
|
||||
<div class="relative flex-1 lg:w-80">
|
||||
<Search class="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" aria-hidden="true" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索名称、主机、用户名或端口"
|
||||
class="w-full rounded-lg border border-slate-600 bg-slate-950/80 py-2.5 pl-10 pr-10 text-sm text-slate-100 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
aria-label="搜索连接"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="clearSearch"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-500 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="openCreate"
|
||||
class="shrink-0 min-h-[44px] px-3.5 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-medium transition-colors duration-200 flex items-center gap-2 cursor-pointer"
|
||||
aria-label="添加连接"
|
||||
>
|
||||
<Plus class="w-4 h-4" aria-hidden="true" />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 p-3 border-b border-slate-700/70">
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-2.5">
|
||||
<p class="text-xs text-slate-500">总连接数</p>
|
||||
<p class="mt-1 text-xl font-semibold text-slate-100">{{ store.connections.length }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-2.5">
|
||||
<p class="text-xs text-slate-500">密码认证</p>
|
||||
<p class="mt-1 text-xl font-semibold text-amber-300">{{ passwordCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-2.5">
|
||||
<p class="text-xs text-slate-500">私钥认证</p>
|
||||
<p class="mt-1 text-xl font-semibold text-cyan-300">{{ keyCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.connections.length === 0" class="px-4 py-14 text-center">
|
||||
<Server class="w-14 h-14 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
||||
<p class="text-slate-300 mb-2">暂无连接配置</p>
|
||||
<p class="text-sm text-slate-500 mb-4">建议先创建一条常用主机作为默认会话入口</p>
|
||||
<button
|
||||
@click="openCreate"
|
||||
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white text-sm transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
添加第一个连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredConnections.length === 0" class="px-4 py-14 text-center">
|
||||
<Search class="w-14 h-14 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
||||
<p class="text-slate-300 mb-1">没有匹配结果</p>
|
||||
<p class="text-sm text-slate-500 mb-4">可尝试主机名、用户名、端口组合搜索</p>
|
||||
<button
|
||||
@click="clearSearch"
|
||||
class="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
清空搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<div class="min-w-[860px]">
|
||||
<div class="grid grid-cols-[2.3fr_1.5fr_1fr_1.6fr] gap-3 px-4 py-2.5 text-xs uppercase tracking-wider text-slate-500 border-b border-slate-700 bg-slate-900/80">
|
||||
<span>Session</span>
|
||||
<span>地址</span>
|
||||
<span>认证</span>
|
||||
<span class="text-right pr-1">操作</span>
|
||||
</div>
|
||||
|
||||
<template v-for="group in groupedConnections" :key="group.key">
|
||||
<div
|
||||
v-if="group.items.length > 0"
|
||||
class="px-4 py-2 text-[11px] font-semibold tracking-wide uppercase text-slate-500 bg-slate-900/65 border-b border-slate-800"
|
||||
>
|
||||
{{ group.label }} · {{ group.items.length }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conn in group.items"
|
||||
:key="conn.id"
|
||||
class="grid grid-cols-[2.3fr_1.5fr_1fr_1.6fr] gap-3 px-4 py-3 border-b border-slate-800 hover:bg-slate-800/45 transition-colors"
|
||||
@dblclick="openTerminal(conn)"
|
||||
>
|
||||
<div class="min-w-0 flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-md border border-slate-700 bg-slate-800 flex items-center justify-center">
|
||||
<Activity class="w-4 h-4 text-cyan-300" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-slate-100 truncate flex items-center gap-1.5">
|
||||
<Star
|
||||
v-if="isFavorite(conn.id)"
|
||||
class="w-3.5 h-3.5 text-amber-300 fill-amber-300/70 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-html="highlightMatch(conn.name)"></span>
|
||||
</p>
|
||||
<p class="text-xs text-slate-500 truncate" v-html="highlightMatch(conn.username)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 self-center text-sm text-slate-300" v-html="highlightMatch(`${conn.host}:${conn.port}`)"></div>
|
||||
|
||||
<div class="self-center">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs"
|
||||
:class="conn.authType === 'PRIVATE_KEY' ? 'border-cyan-700/60 bg-cyan-900/25 text-cyan-300' : 'border-amber-700/60 bg-amber-900/25 text-amber-300'"
|
||||
>
|
||||
<component :is="conn.authType === 'PRIVATE_KEY' ? Key : Lock" class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="self-center flex justify-end items-center gap-1">
|
||||
<button
|
||||
@click="toggleFavorite(conn.id)"
|
||||
class="min-h-[40px] px-2 rounded-md text-slate-400 hover:bg-amber-900/35 hover:text-amber-300 transition-colors duration-200 cursor-pointer"
|
||||
:aria-label="isFavorite(conn.id) ? '取消收藏' : '收藏'"
|
||||
:title="isFavorite(conn.id) ? '取消收藏' : '收藏'"
|
||||
>
|
||||
<Star
|
||||
class="w-4 h-4"
|
||||
:class="isFavorite(conn.id) ? 'fill-amber-300/70 text-amber-300' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
@click="openTerminal(conn)"
|
||||
class="min-h-[40px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-200 hover:border-cyan-500/50 hover:text-cyan-300 transition-colors duration-200 cursor-pointer flex items-center gap-1.5 text-xs"
|
||||
aria-label="打开终端"
|
||||
>
|
||||
<Terminal class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
终端
|
||||
</button>
|
||||
<button
|
||||
@click="openSftp(conn)"
|
||||
class="min-h-[40px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-200 hover:border-emerald-500/50 hover:text-emerald-300 transition-colors duration-200 cursor-pointer flex items-center gap-1.5 text-xs"
|
||||
aria-label="打开文件"
|
||||
>
|
||||
<FolderOpen class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
文件
|
||||
</button>
|
||||
<button
|
||||
@click="openEdit(conn)"
|
||||
class="min-h-[40px] px-2 rounded-md text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="编辑"
|
||||
>
|
||||
<Pencil class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(conn)"
|
||||
class="min-h-[40px] px-2 rounded-md text-slate-400 hover:bg-red-900/45 hover:text-red-400 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-slate-700 bg-slate-900/60 p-3 md:p-4">
|
||||
<div class="flex items-start gap-2 text-xs text-slate-400">
|
||||
<ShieldCheck class="w-4 h-4 mt-0.5 text-cyan-400" aria-hidden="true" />
|
||||
<p>建议把常用主机命名为业务前缀,例如 <code class="text-slate-200">prod-api-01</code>,会话切换与快速搜索更高效。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConnectionForm
|
||||
v-if="showForm"
|
||||
:connection="editingConn"
|
||||
:on-save="handleSave"
|
||||
@close="closeForm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,10 +16,10 @@ const loading = ref(false)
|
||||
async function handleSubmit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
try {
|
||||
const res = await authApi.login({ username: username.value, password: password.value })
|
||||
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
||||
router.push('/connections')
|
||||
router.push('/moba')
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
||||
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SftpView' })
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
@@ -40,8 +40,12 @@ const files = ref<SftpFileInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const uploading = ref(false)
|
||||
const selectedFile = ref<string | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
const pathInputRef = ref<HTMLInputElement | null>(null)
|
||||
const pathDraft = ref('.')
|
||||
const UP_ROW_KEY = '__parent__'
|
||||
const selectedEntryKey = ref<string | null>(null)
|
||||
|
||||
const showHiddenFiles = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -61,10 +65,44 @@ watch([searchQuery, showHiddenFiles, files], () => {
|
||||
}, 300)
|
||||
}, { immediate: true })
|
||||
|
||||
watch(currentPath, (path) => {
|
||||
pathDraft.value = path
|
||||
}, { immediate: true })
|
||||
|
||||
const showParentEntry = computed(() => currentPath.value !== '.' && pathParts.value.length > 1)
|
||||
const entryKeys = computed(() => {
|
||||
const keys: string[] = []
|
||||
if (showParentEntry.value) {
|
||||
keys.push(UP_ROW_KEY)
|
||||
}
|
||||
|
||||
filteredFiles.value.forEach((file) => {
|
||||
keys.push(`file:${file.name}`)
|
||||
})
|
||||
|
||||
return keys
|
||||
})
|
||||
|
||||
watch(entryKeys, (keys) => {
|
||||
if (keys.length === 0) {
|
||||
selectedEntryKey.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedEntryKey.value || !keys.includes(selectedEntryKey.value)) {
|
||||
selectedEntryKey.value = keys[0] ?? null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleGlobalShortcuts)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
invalidateUploadContext()
|
||||
clearTimeout(searchDebounceTimer)
|
||||
stopTransferProgress()
|
||||
window.removeEventListener('keydown', handleGlobalShortcuts)
|
||||
})
|
||||
|
||||
const showUploadProgress = ref(false)
|
||||
@@ -193,7 +231,7 @@ function resetVolatileSftpState() {
|
||||
filteredFiles.value = []
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
selectedFile.value = null
|
||||
selectedEntryKey.value = null
|
||||
searchQuery.value = ''
|
||||
|
||||
showUploadProgress.value = false
|
||||
@@ -357,6 +395,17 @@ function navigateToIndex(i: number) {
|
||||
loadPath()
|
||||
}
|
||||
|
||||
function navigateToTypedPath() {
|
||||
if (loading.value) return
|
||||
|
||||
const raw = pathDraft.value.trim()
|
||||
if (!raw) return
|
||||
|
||||
currentPath.value = raw
|
||||
pathParts.value = raw === '/' ? [''] : raw.split('/').filter(Boolean)
|
||||
loadPath()
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
if (loading.value) return
|
||||
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
||||
@@ -375,13 +424,116 @@ function goUp() {
|
||||
}
|
||||
|
||||
function handleFileClick(file: SftpFileInfo) {
|
||||
selectedEntryKey.value = `file:${file.name}`
|
||||
if (file.directory) {
|
||||
navigateToDir(file.name)
|
||||
} else {
|
||||
selectedFile.value = file.name
|
||||
selectedEntryKey.value = `file:${file.name}`
|
||||
}
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchInputRef.value?.focus()
|
||||
searchInputRef.value?.select()
|
||||
}
|
||||
|
||||
function focusPathInput() {
|
||||
pathInputRef.value?.focus()
|
||||
pathInputRef.value?.select()
|
||||
}
|
||||
|
||||
function handleGlobalShortcuts(event: KeyboardEvent) {
|
||||
if (showTransferModal.value) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
if ((event.ctrlKey || event.metaKey) && key === 'f') {
|
||||
event.preventDefault()
|
||||
focusSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && key === 'l') {
|
||||
event.preventDefault()
|
||||
focusPathInput()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'F5') {
|
||||
event.preventDefault()
|
||||
loadPath()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.altKey && event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
goUp()
|
||||
return
|
||||
}
|
||||
|
||||
if (isTypingTarget(event.target)) return
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
moveSelection(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveSelection(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
activateSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
goUp()
|
||||
}
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
const tag = target.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
||||
}
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
const keys = entryKeys.value
|
||||
if (keys.length === 0) return
|
||||
|
||||
const currentIndex = selectedEntryKey.value ? keys.indexOf(selectedEntryKey.value) : -1
|
||||
const startIndex = currentIndex === -1 ? 0 : currentIndex
|
||||
const nextIndex = (startIndex + delta + keys.length) % keys.length
|
||||
selectedEntryKey.value = keys[nextIndex] ?? null
|
||||
}
|
||||
|
||||
function activateSelection() {
|
||||
const selectedKey = selectedEntryKey.value
|
||||
if (!selectedKey) return
|
||||
|
||||
if (selectedKey === UP_ROW_KEY) {
|
||||
goUp()
|
||||
return
|
||||
}
|
||||
|
||||
const fileName = selectedKey.replace(/^file:/, '')
|
||||
const target = filteredFiles.value.find((file) => file.name === fileName)
|
||||
if (!target) return
|
||||
|
||||
if (target.directory) {
|
||||
navigateToDir(target.name)
|
||||
return
|
||||
}
|
||||
|
||||
handleDownload(target)
|
||||
}
|
||||
|
||||
function handleDownload(file: SftpFileInfo) {
|
||||
if (file.directory) return
|
||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||
@@ -575,24 +727,55 @@ async function submitTransfer() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<button
|
||||
@click="router.push('/connections')"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold text-slate-100">
|
||||
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="h-full flex flex-col p-3 md:p-4 gap-3">
|
||||
<section class="rounded-lg border border-slate-700 bg-slate-900/75 backdrop-blur-sm overflow-hidden">
|
||||
<div class="h-11 px-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
@click="router.push('/connections')"
|
||||
class="p-1.5 rounded text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<h2 class="text-sm md:text-base font-semibold text-slate-100 truncate">
|
||||
{{ conn?.name || 'SFTP' }}
|
||||
</h2>
|
||||
<span class="hidden md:inline text-xs text-slate-500 truncate">{{ conn?.username }}@{{ conn?.host }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="triggerUpload"
|
||||
:disabled="uploading"
|
||||
class="min-h-[34px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors duration-200 cursor-pointer disabled:opacity-50 text-xs flex items-center gap-1.5"
|
||||
aria-label="上传"
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
上传
|
||||
</button>
|
||||
<button
|
||||
@click="handleMkdir"
|
||||
class="min-h-[34px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors duration-200 cursor-pointer text-xs flex items-center gap-1.5"
|
||||
aria-label="新建文件夹"
|
||||
>
|
||||
<FolderPlus class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
新建目录
|
||||
</button>
|
||||
<button
|
||||
@click="loadPath()"
|
||||
:disabled="loading"
|
||||
class="min-h-[34px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors duration-200 cursor-pointer disabled:opacity-50 text-xs flex items-center gap-1.5"
|
||||
aria-label="刷新"
|
||||
>
|
||||
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||
<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 w-full sm:flex-1">
|
||||
<div class="p-3 border-b border-slate-700/80 bg-slate-900/80">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 w-full lg:flex-1 overflow-x-auto">
|
||||
<button
|
||||
@click="navigateToIndex(-1)"
|
||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
|
||||
@@ -603,52 +786,38 @@ async function submitTransfer() {
|
||||
<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-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
|
||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[160px]"
|
||||
>
|
||||
{{ part || '/' }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="w-full sm:w-auto flex items-center gap-2 justify-end">
|
||||
<div class="flex-1 sm:flex-none">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
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>
|
||||
|
||||
<div class="w-full lg:w-auto flex items-center gap-2">
|
||||
<input
|
||||
ref="pathInputRef"
|
||||
v-model="pathDraft"
|
||||
type="text"
|
||||
class="w-full lg:w-72 rounded-md border border-slate-600 bg-slate-950/70 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="路径输入"
|
||||
@keydown.enter.prevent="navigateToTypedPath"
|
||||
/>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="w-full lg:w-64 rounded-md border border-slate-600 bg-slate-950/70 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="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
class="min-h-[40px] min-w-[40px] rounded-md text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer border border-slate-600"
|
||||
: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" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleMkdir"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="新建文件夹"
|
||||
>
|
||||
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="loadPath()"
|
||||
:disabled="loading"
|
||||
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="刷新"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||
<component :is="showHiddenFiles ? EyeOff : Eye" class="w-4 h-4 mx-auto" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -659,8 +828,12 @@ async function submitTransfer() {
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-[11px] text-slate-500">
|
||||
快捷键:Ctrl/Cmd+F 搜索,Ctrl/Cmd+L 路径输入,↑/↓ 选择行,Enter 打开,F5 刷新,Alt+↑ 或 Backspace 返回上级目录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showUploadProgress" class="bg-slate-800/50 border-b border-slate-700 p-4 space-y-3">
|
||||
<div v-if="showUploadProgress" class="bg-slate-800/55 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>
|
||||
@@ -695,39 +868,56 @@ async function submitTransfer() {
|
||||
</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">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="grid grid-cols-[2.6fr_1fr_1.3fr_132px] gap-2 px-4 py-2 text-xs uppercase tracking-wider text-slate-500 border-b border-slate-700 bg-slate-900/85">
|
||||
<span>名称</span>
|
||||
<span>大小</span>
|
||||
<span>修改时间</span>
|
||||
<span class="text-right pr-1">操作</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-slate-700">
|
||||
<div class="divide-y divide-slate-800">
|
||||
<button
|
||||
v-if="currentPath !== '.' && pathParts.length > 1"
|
||||
v-if="showParentEntry"
|
||||
@click="goUp"
|
||||
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"
|
||||
class="w-full grid grid-cols-[2.6fr_1fr_1.3fr_132px] gap-2 items-center px-4 py-3 hover:bg-slate-800/60 transition-colors duration-200 cursor-pointer text-left"
|
||||
:class="selectedEntryKey === UP_ROW_KEY ? 'bg-cyan-950/25 ring-1 ring-inset ring-cyan-700/50' : ''"
|
||||
@mouseenter="selectedEntryKey = UP_ROW_KEY"
|
||||
>
|
||||
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
||||
<span class="text-slate-400">..</span>
|
||||
<span class="flex items-center gap-3 min-w-0">
|
||||
<FolderOpen class="w-4 h-4 text-slate-500 flex-shrink-0" aria-hidden="true" />
|
||||
<span class="text-slate-300 truncate">..</span>
|
||||
</span>
|
||||
<span class="text-slate-600">-</span>
|
||||
<span class="text-slate-600">-</span>
|
||||
<span></span>
|
||||
</button>
|
||||
<button
|
||||
v-for="file in filteredFiles"
|
||||
:key="file.name"
|
||||
@click="handleFileClick(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 grid grid-cols-[2.6fr_1fr_1.3fr_132px] gap-2 items-center px-4 py-3 hover:bg-slate-800/60 transition-colors duration-200 cursor-pointer text-left group"
|
||||
:class="selectedEntryKey === `file:${file.name}` ? 'bg-cyan-950/25 ring-1 ring-inset ring-cyan-700/50' : ''"
|
||||
@mouseenter="selectedEntryKey = `file:${file.name}`"
|
||||
>
|
||||
<component
|
||||
:is="file.directory ? FolderOpen : File"
|
||||
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
|
||||
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
|
||||
{{ formatSize(file.size) }}
|
||||
<span class="min-w-0 flex items-center gap-3">
|
||||
<component
|
||||
:is="file.directory ? FolderOpen : File"
|
||||
class="w-4 h-4 flex-shrink-0 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate text-sm text-slate-200">{{ file.name }}</span>
|
||||
</span>
|
||||
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-sm text-slate-500">{{ file.directory ? '-' : formatSize(file.size) }}</span>
|
||||
<span class="text-sm text-slate-500">{{ formatDate(file.mtime) }}</span>
|
||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
v-if="!file.directory"
|
||||
@click.stop="handleDownload(file)"
|
||||
@@ -758,7 +948,7 @@ async function submitTransfer() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
|
||||
@@ -1,37 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import TerminalWidget from '../components/TerminalWidget.vue'
|
||||
import { TerminalSquare, X, Plus, Monitor } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
visible?: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
|
||||
const tabs = computed(() => tabsStore.tabs)
|
||||
const activeTab = computed(() => tabsStore.activeTab)
|
||||
|
||||
function activateTab(tabId: string) {
|
||||
tabsStore.activate(tabId)
|
||||
}
|
||||
|
||||
function closeTab(tabId: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
tabsStore.close(tabId)
|
||||
if (tabsStore.tabs.length === 0) {
|
||||
router.push('/connections')
|
||||
}
|
||||
}
|
||||
|
||||
function openConnections() {
|
||||
router.push('/connections')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 确保连接列表已加载
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
connectionsStore.fetchConnections()
|
||||
}
|
||||
window.addEventListener('keydown', handleTabShortcuts)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleTabShortcuts)
|
||||
})
|
||||
|
||||
function activateByOffset(offset: number) {
|
||||
if (tabs.value.length <= 1 || !activeTab.value) return
|
||||
|
||||
const currentIndex = tabs.value.findIndex((tab) => tab.id === activeTab.value?.id)
|
||||
if (currentIndex === -1) return
|
||||
|
||||
const nextIndex = (currentIndex + offset + tabs.value.length) % tabs.value.length
|
||||
const nextTab = tabs.value[nextIndex]
|
||||
if (!nextTab) return
|
||||
tabsStore.activate(nextTab.id)
|
||||
}
|
||||
|
||||
function handleTabShortcuts(event: KeyboardEvent) {
|
||||
if (!(event.ctrlKey || event.metaKey)) return
|
||||
if (tabs.value.length === 0) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === 'w' && activeTab.value) {
|
||||
event.preventDefault()
|
||||
tabsStore.close(activeTab.value.id)
|
||||
if (tabsStore.tabs.length === 0) {
|
||||
router.push('/connections')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
activateByOffset(event.shiftKey ? -1 : 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- 终端内容区 -->
|
||||
<div class="flex-1 min-h-0 p-4">
|
||||
<div v-if="tabs.length === 0" class="flex items-center justify-center h-full text-slate-400">
|
||||
<div class="text-center">
|
||||
<p class="text-lg mb-2">暂无打开的终端</p>
|
||||
<p class="text-sm text-slate-500">从左侧连接列表点击"终端"按钮打开</p>
|
||||
<div class="h-full flex flex-col p-3 md:p-4 gap-3">
|
||||
<section class="rounded-lg border border-slate-700 bg-slate-900/75 backdrop-blur-sm overflow-hidden">
|
||||
<div class="h-10 px-3 flex items-center justify-between border-b border-slate-700">
|
||||
<div class="flex items-center gap-2 text-xs uppercase tracking-wider text-slate-400">
|
||||
<TerminalSquare class="w-4 h-4 text-cyan-400" aria-hidden="true" />
|
||||
<span>Terminal Workspace</span>
|
||||
<span class="px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-[11px]">{{ tabs.length }}</span>
|
||||
<span class="hidden lg:inline text-[11px] text-slate-500 normal-case">Ctrl/Cmd+Tab 切换,Ctrl/Cmd+W 关闭</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openConnections"
|
||||
class="min-h-[32px] px-2.5 rounded-md border border-slate-600 bg-slate-800 text-slate-300 hover:text-slate-100 hover:border-cyan-500/50 transition-colors duration-200 cursor-pointer text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
新建会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tabs.length > 0" class="px-2 py-1.5 border-b border-slate-800 bg-slate-900/80 flex items-center gap-1 overflow-x-auto">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="group shrink-0 max-w-[260px] min-h-[34px] flex items-center gap-1.5 px-1.5 rounded-md border text-xs transition-colors duration-200"
|
||||
:class="tab.active
|
||||
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600 hover:text-slate-100'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="activateTab(tab.id)"
|
||||
class="min-h-[30px] min-w-0 flex-1 flex items-center gap-2 px-1 cursor-pointer text-left"
|
||||
:aria-label="`激活终端标签 ${tab.title}`"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-500'" />
|
||||
<span class="truncate">{{ tab.title }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="(e) => closeTab(tab.id, e)"
|
||||
class="p-0.5 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200 cursor-pointer"
|
||||
aria-label="关闭终端标签"
|
||||
>
|
||||
<X class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-full">
|
||||
</section>
|
||||
|
||||
<section class="flex-1 min-h-0 rounded-lg border border-slate-700 bg-slate-950 overflow-hidden">
|
||||
<div v-if="tabs.length === 0" class="h-full flex items-center justify-center px-4">
|
||||
<div class="text-center">
|
||||
<Monitor class="w-12 h-12 text-slate-600 mx-auto mb-3" aria-hidden="true" />
|
||||
<p class="text-base text-slate-300 mb-1">暂无打开的终端</p>
|
||||
<p class="text-sm text-slate-500 mb-4">从 Connections 页面启动会话,支持并行多标签</p>
|
||||
<button
|
||||
@click="openConnections"
|
||||
class="min-h-[44px] px-4 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white text-sm transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
前往连接列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="h-full min-h-0">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@@ -44,6 +155,12 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab" class="hidden lg:block rounded-lg border border-slate-700 bg-slate-900/65 px-3 py-2">
|
||||
<p class="text-xs text-slate-400">
|
||||
当前会话:<span class="text-cyan-300">{{ activeTab.title }}</span>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,11 @@ type Tab = 'local' | 'remote'
|
||||
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const transfersStore = useTransfersStore()
|
||||
const props = withDefaults(defineProps<{
|
||||
embedded?: boolean
|
||||
}>(), {
|
||||
embedded: false,
|
||||
})
|
||||
|
||||
const tab = ref<Tab>('local')
|
||||
|
||||
@@ -246,21 +251,23 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 lg:p-8">
|
||||
<div :class="props.embedded ? 'p-4 lg:p-5' : '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>
|
||||
<h1 class="font-semibold tracking-tight text-slate-50" :class="props.embedded ? 'text-xl' : 'text-2xl'">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 class="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
@@ -303,7 +310,7 @@ onMounted(async () => {
|
||||
<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>
|
||||
<p class="text-sm">暂无连接。请先在 Moba 页面里添加连接。</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
|
||||
Reference in New Issue
Block a user