Compare commits

...

8 Commits

Author SHA1 Message Date
liumangmang
f606d20000 feat: unify moba workspace and persist session tree layout 2026-04-10 11:04:21 +08:00
liumangmang
bba36a2e12 docs: update implementation status - Phase 6 complete 2026-04-03 16:17:44 +08:00
liumangmang
4af11fb043 feat: add expand/collapse all functionality
- Add expandAll() and collapseAll() methods to sessionTree store
- Add toggle button in toolbar to expand/collapse all folders
- Track expansion state with allExpanded ref
- Improve toolbar layout with flex spacing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:14:14 +08:00
liumangmang
cf7b564b3a feat: add session tree search functionality
- Add useTreeSearch composable with filtering logic
- Implement search bar with Ctrl+F shortcut
- Show search results with ancestor paths
- Add search match indicator (cyan dot)
- Display "no results" message when search is empty
- Support clear search with X button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:10:49 +08:00
liumangmang
55953dce83 docs: update implementation status - all phases complete 2026-04-03 15:58:35 +08:00
liumangmang
e23ba1c3c9 feat: add keyboard shortcuts and context menu (Phase 6)
Phase 6 - Enhancements:
- Add useKeyboardShortcuts composable for global shortcuts
- Implement keyboard shortcuts: F2 (rename), Delete, Ctrl+N (new folder)
- Add ContextMenu component with positioning logic
- Implement right-click context menu for tree nodes
- Add rename dialog for nodes
- Support delete with confirmation
- Add "new subfolder" action for folders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:55:39 +08:00
liumangmang
caed481d23 feat: implement drag-drop and data migration (Phase 3 & 5)
Phase 3 - Drag-drop functionality:
- Add useTreeDragDrop composable with drag state management
- Implement drag constraints (prevent dropping on self/descendants)
- Add visual feedback (opacity, drop indicators, highlight)
- Support drop positions: before/after/inside folder

Phase 5 - Data migration and sync:
- Add MigrationPrompt component for first-time users
- Implement bidirectional sync between connections and session tree
- Add syncNewConnections/syncDeletedConnections/syncConnectionName methods
- Create useConnectionSync composable for automatic sync
- Support migration from old layout with user prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:46:22 +08:00
liumangmang
2c06329d68 feat: implement MobaXterm-style layout (Phase 1-2-4)
实现 MobaXterm 风格的界面重构,包含会话树、工作区面板和分屏功能。

新增功能:
- 左侧会话树支持文件夹分组和展开/折叠
- 工作区垂直分屏(终端 + SFTP)
- 可拖拽调整分割比例
- 状态持久化到 localStorage
- 顶部工具栏(样式占位)

技术实现:
- 新增 sessionTreeStore 和 workspaceStore 状态管理
- 新增 SessionTree/SessionTreeNode 递归组件
- 新增 SplitPane 可拖拽分割组件
- 重构 SftpPanel 为 props 驱动
- 新增 MobaLayout 主布局
- 路由默认重定向到 /moba

依赖更新:
- 安装 @vueuse/core 用于拖拽功能

待实现:
- Phase 3: 会话树拖拽排序
- Phase 5: 数据迁移
- Phase 6: 快捷键、右键菜单、搜索等优化

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:14:36 +08:00
39 changed files with 4297 additions and 653 deletions

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -102,8 +102,10 @@ public class ConnectionService {
@Transactional
public void delete(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
Connection conn = connectionRepository.findById(id).orElse(null);
if (conn == null) {
return;
}
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
}

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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",

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

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

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

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

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

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

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

View File

@@ -273,68 +273,55 @@ onUnmounted(() => {
<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 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>
<!-- 内存 -->
<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
v-if="status === 'connected'"
@click="showMonitor = !showMonitor"
class="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
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

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

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

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

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

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

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

View File

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

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

View File

@@ -11,41 +11,17 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
redirect: '/moba',
},
{
path: '/moba',
name: 'MobaLayout',
component: () => import('../layouts/MobaLayout.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 },
},
],
path: '/transfers',
redirect: '/moba?tool=transfers',
},
]
@@ -58,7 +34,7 @@ router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.public) {
if (authStore.isAuthenticated && to.path === '/login') {
next('/connections')
next('/moba')
} else {
next()
}

View File

@@ -5,10 +5,12 @@ import * as connectionsApi from '../api/connections'
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
}
@@ -36,6 +38,7 @@ export const useConnectionsStore = defineStore('connections', () => {
return {
connections,
hasFetched,
fetchConnections,
createConnection,
updateConnection,

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

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

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

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue'
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import { useSftpTabsStore } from '../stores/sftpTabs'
@@ -17,6 +17,9 @@ import {
Lock,
Search,
X,
Activity,
ShieldCheck,
Star,
} from 'lucide-vue-next'
const router = useRouter()
@@ -28,9 +31,13 @@ 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()
@@ -50,13 +57,42 @@ const filteredConnections = computed(() => {
})
})
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) => {
@@ -66,11 +102,55 @@ watch(searchQuery, (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
@@ -110,6 +190,28 @@ function clearSearch() {
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
@@ -122,123 +224,190 @@ function highlightMatch(text: string): string {
</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 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="relative mb-6">
<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-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"
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-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"
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>
<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"
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="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>
<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 transition-colors duration-200 cursor-pointer"
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="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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-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"
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"
>
<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" />
{{ group.label }} · {{ group.items.length }}
</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
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="flex items-center gap-1">
<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="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] 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="p-2 rounded-lg text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
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>
<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>
</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"

View File

@@ -19,7 +19,7 @@ async function handleSubmit() {
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 ? '用户名或密码错误' : '登录失败')

View File

@@ -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">
<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-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
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-5 h-5" aria-hidden="true" />
<ArrowLeft class="w-4 h-4" aria-hidden="true" />
</button>
<h2 class="text-lg font-semibold text-slate-100">
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
<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">
<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 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="搜索文件..."
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="搜索文件"
/>
</div>
<button
@click="showHiddenFiles = !showHiddenFiles"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
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>
@@ -701,33 +874,50 @@ async function submitTransfer() {
加载中...
</div>
<div v-else class="divide-y divide-slate-700">
<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 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}`"
>
<span class="min-w-0 flex items-center gap-3">
<component
:is="file.directory ? FolderOpen : File"
class="w-5 h-5 flex-shrink-0 text-slate-400"
class="w-4 h-4 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="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

View File

@@ -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="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>
</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">
<p class="text-lg mb-2">暂无打开的终端</p>
<p class="text-sm text-slate-500">从左侧连接列表点击"终端"按钮打开</p>
<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">
<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>

View File

@@ -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,13 +251,14 @@ 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>
<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"
@@ -262,6 +268,7 @@ onMounted(async () => {
清空队列
</button>
</div>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<button
@@ -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>