feat: unify moba workspace and persist session tree layout
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||||
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.repository.UserRepository;
|
||||||
|
import com.sshmanager.service.SessionTreeLayoutService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/session-tree")
|
||||||
|
public class SessionTreeLayoutController {
|
||||||
|
|
||||||
|
private final SessionTreeLayoutService sessionTreeLayoutService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public SessionTreeLayoutController(SessionTreeLayoutService sessionTreeLayoutService,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.sessionTreeLayoutService = sessionTreeLayoutService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<SessionTreeLayoutDto> getLayout(Authentication authentication) {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
return ResponseEntity.ok(sessionTreeLayoutService.getLayout(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
public ResponseEntity<SessionTreeLayoutDto> saveLayout(@RequestBody SessionTreeLayoutDto request,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
return ResponseEntity.ok(sessionTreeLayoutService.saveLayout(userId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getCurrentUserId(Authentication auth) {
|
||||||
|
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.sshmanager.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SessionTreeLayoutDto {
|
||||||
|
|
||||||
|
private List<SessionTreeNodeDto> nodes = new ArrayList<SessionTreeNodeDto>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.sshmanager.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SessionTreeNodeDto {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String type;
|
||||||
|
private String name;
|
||||||
|
private String parentId;
|
||||||
|
private Integer order;
|
||||||
|
private Long connectionId;
|
||||||
|
private Boolean expanded;
|
||||||
|
private Long createdAt;
|
||||||
|
private Long updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.sshmanager.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "session_tree_layouts")
|
||||||
|
public class SessionTreeLayout {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "CLOB")
|
||||||
|
private String layoutJson;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant updatedAt = Instant.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.sshmanager.repository;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.SessionTreeLayout;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SessionTreeLayoutRepository extends JpaRepository<SessionTreeLayout, Long> {
|
||||||
|
|
||||||
|
Optional<SessionTreeLayout> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -102,8 +102,10 @@ public class ConnectionService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id, Long userId) {
|
public void delete(Long id, Long userId) {
|
||||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
Connection conn = connectionRepository.findById(id).orElse(null);
|
||||||
() -> new RuntimeException("Connection not found: " + id));
|
if (conn == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!conn.getUserId().equals(userId)) {
|
if (!conn.getUserId().equals(userId)) {
|
||||||
throw new RuntimeException("Access denied");
|
throw new RuntimeException("Access denied");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||||
|
import com.sshmanager.entity.SessionTreeLayout;
|
||||||
|
import com.sshmanager.repository.SessionTreeLayoutRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SessionTreeLayoutService {
|
||||||
|
|
||||||
|
private final SessionTreeLayoutRepository sessionTreeLayoutRepository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public SessionTreeLayoutService(SessionTreeLayoutRepository sessionTreeLayoutRepository,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.sessionTreeLayoutRepository = sessionTreeLayoutRepository;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public SessionTreeLayoutDto getLayout(Long userId) {
|
||||||
|
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElse(null);
|
||||||
|
if (layout == null || layout.getLayoutJson() == null || layout.getLayoutJson().trim().isEmpty()) {
|
||||||
|
return createEmptyLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SessionTreeLayoutDto parsed = objectMapper.readValue(layout.getLayoutJson(), SessionTreeLayoutDto.class);
|
||||||
|
if (parsed.getNodes() == null) {
|
||||||
|
parsed.setNodes(new ArrayList<>());
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return createEmptyLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public SessionTreeLayoutDto saveLayout(Long userId, SessionTreeLayoutDto request) {
|
||||||
|
SessionTreeLayoutDto payload = request == null ? createEmptyLayout() : request;
|
||||||
|
if (payload.getNodes() == null) {
|
||||||
|
payload.setNodes(new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
final String layoutJson;
|
||||||
|
try {
|
||||||
|
layoutJson = objectMapper.writeValueAsString(payload);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to serialize session tree layout", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElseGet(SessionTreeLayout::new);
|
||||||
|
layout.setUserId(userId);
|
||||||
|
layout.setLayoutJson(layoutJson);
|
||||||
|
layout.setUpdatedAt(Instant.now());
|
||||||
|
sessionTreeLayoutRepository.save(layout);
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionTreeLayoutDto createEmptyLayout() {
|
||||||
|
return new SessionTreeLayoutDto(new ArrayList<>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||||
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.repository.UserRepository;
|
||||||
|
import com.sshmanager.service.SessionTreeLayoutService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SessionTreeLayoutControllerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SessionTreeLayoutService sessionTreeLayoutService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private SessionTreeLayoutController sessionTreeLayoutController;
|
||||||
|
|
||||||
|
private Authentication authentication;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
authentication = mock(Authentication.class);
|
||||||
|
when(authentication.getName()).thenReturn("testuser");
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getLayoutUsesCurrentUserId() {
|
||||||
|
SessionTreeLayoutDto expected = new SessionTreeLayoutDto();
|
||||||
|
when(sessionTreeLayoutService.getLayout(1L)).thenReturn(expected);
|
||||||
|
|
||||||
|
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.getLayout(authentication);
|
||||||
|
|
||||||
|
assertEquals(200, response.getStatusCode().value());
|
||||||
|
assertEquals(expected, response.getBody());
|
||||||
|
verify(sessionTreeLayoutService).getLayout(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveLayoutUsesCurrentUserId() {
|
||||||
|
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
|
||||||
|
when(sessionTreeLayoutService.saveLayout(1L, request)).thenReturn(request);
|
||||||
|
|
||||||
|
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.saveLayout(request, authentication);
|
||||||
|
|
||||||
|
assertEquals(200, response.getStatusCode().value());
|
||||||
|
assertEquals(request, response.getBody());
|
||||||
|
verify(sessionTreeLayoutService).saveLayout(1L, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.sshmanager.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||||
|
import com.sshmanager.dto.SessionTreeNodeDto;
|
||||||
|
import com.sshmanager.entity.SessionTreeLayout;
|
||||||
|
import com.sshmanager.repository.SessionTreeLayoutRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SessionTreeLayoutServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SessionTreeLayoutRepository sessionTreeLayoutRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private SessionTreeLayoutService sessionTreeLayoutService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getLayoutReturnsEmptyWhenNoSavedData() {
|
||||||
|
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
|
||||||
|
|
||||||
|
assertTrue(result.getNodes().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getLayoutParsesSavedJson() throws Exception {
|
||||||
|
SessionTreeLayout saved = new SessionTreeLayout();
|
||||||
|
saved.setUserId(1L);
|
||||||
|
saved.setLayoutJson("{\"nodes\":[{\"id\":\"n1\"}]}");
|
||||||
|
|
||||||
|
SessionTreeLayoutDto parsed = new SessionTreeLayoutDto(Arrays.asList(
|
||||||
|
new SessionTreeNodeDto("n1", "folder", "我的连接", null, 0, null, true, 1L, 1L)
|
||||||
|
));
|
||||||
|
|
||||||
|
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.of(saved));
|
||||||
|
when(objectMapper.readValue(saved.getLayoutJson(), SessionTreeLayoutDto.class)).thenReturn(parsed);
|
||||||
|
|
||||||
|
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
|
||||||
|
|
||||||
|
assertEquals(1, result.getNodes().size());
|
||||||
|
assertEquals("n1", result.getNodes().get(0).getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveLayoutNormalizesNullNodes() throws Exception {
|
||||||
|
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
|
||||||
|
request.setNodes(null);
|
||||||
|
|
||||||
|
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||||
|
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
|
||||||
|
|
||||||
|
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, request);
|
||||||
|
|
||||||
|
assertTrue(result.getNodes().isEmpty());
|
||||||
|
ArgumentCaptor<SessionTreeLayout> captor = ArgumentCaptor.forClass(SessionTreeLayout.class);
|
||||||
|
verify(sessionTreeLayoutRepository).save(captor.capture());
|
||||||
|
assertEquals(1L, captor.getValue().getUserId().longValue());
|
||||||
|
assertEquals("{\"nodes\":[]}", captor.getValue().getLayoutJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveLayoutCreatesEmptyPayloadWhenRequestIsNull() throws Exception {
|
||||||
|
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||||
|
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
|
||||||
|
|
||||||
|
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, null);
|
||||||
|
|
||||||
|
assertEquals(Collections.emptyList(), result.getNodes());
|
||||||
|
}
|
||||||
|
}
|
||||||
27
frontend/src/api/sessionTree.ts
Normal file
27
frontend/src/api/sessionTree.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export type SessionTreeNodeType = 'folder' | 'connection'
|
||||||
|
|
||||||
|
export interface SessionTreeNodePayload {
|
||||||
|
id: string
|
||||||
|
type: SessionTreeNodeType
|
||||||
|
name: string
|
||||||
|
parentId: string | null
|
||||||
|
order: number
|
||||||
|
connectionId?: number
|
||||||
|
expanded?: boolean
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionTreeLayoutPayload {
|
||||||
|
nodes: SessionTreeNodePayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionTree() {
|
||||||
|
return client.get<SessionTreeLayoutPayload>('/session-tree')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSessionTree(payload: SessionTreeLayoutPayload) {
|
||||||
|
return client.put<SessionTreeLayoutPayload>('/session-tree', payload)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
export interface ContextMenuItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -61,13 +61,19 @@ function handleClickOutside(event: MouseEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
watch(
|
||||||
if (props.show) {
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (show) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
}, 0)
|
}, 0)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
|||||||
@@ -1,20 +1,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, provide } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
import { useSessionTreeStore } from '../stores/sessionTree'
|
import { useSessionTreeStore } from '../stores/sessionTree'
|
||||||
import { useWorkspaceStore } from '../stores/workspace'
|
import { useWorkspaceStore } from '../stores/workspace'
|
||||||
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
|
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
|
||||||
import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts'
|
||||||
import { useTreeSearch } from '../composables/useTreeSearch'
|
import { useTreeSearch } from '../composables/useTreeSearch'
|
||||||
import SessionTreeNode from './SessionTreeNode.vue'
|
|
||||||
import ContextMenu from './ContextMenu.vue'
|
import ContextMenu from './ContextMenu.vue'
|
||||||
import type { ContextMenuItem } from './ContextMenu.vue'
|
import type { ContextMenuItem } from './ContextMenu.vue'
|
||||||
import { FolderPlus, Edit2, Trash2, Search, X, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
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 treeStore = useSessionTreeStore()
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
const connectionsStore = useConnectionsStore()
|
||||||
|
|
||||||
const showNewFolderDialog = ref(false)
|
const showNewFolderDialog = ref(false)
|
||||||
const newFolderName = ref('')
|
const newFolderName = ref('')
|
||||||
|
const newFolderParentId = ref<string | null>(null)
|
||||||
const showRenameDialog = ref(false)
|
const showRenameDialog = ref(false)
|
||||||
const renameNodeId = ref<string | null>(null)
|
const renameNodeId = ref<string | null>(null)
|
||||||
const renameValue = ref('')
|
const renameValue = ref('')
|
||||||
@@ -30,13 +53,68 @@ const contextMenuX = ref(0)
|
|||||||
const contextMenuY = ref(0)
|
const contextMenuY = ref(0)
|
||||||
const contextMenuNodeId = ref<string | null>(null)
|
const contextMenuNodeId = ref<string | null>(null)
|
||||||
|
|
||||||
const rootNodes = computed(() => {
|
const nodesForRender = computed(() => {
|
||||||
const nodes = searchQuery.value ? filteredNodes.value : treeStore.nodes
|
return searchQuery.value.trim() ? filteredNodes.value : treeStore.nodes
|
||||||
return nodes
|
|
||||||
.filter(n => n.parentId === null)
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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[]>(() => {
|
const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||||
if (!contextMenuNodeId.value) return []
|
if (!contextMenuNodeId.value) return []
|
||||||
|
|
||||||
@@ -44,6 +122,16 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
if (!node) return []
|
if (!node) return []
|
||||||
|
|
||||||
const items: ContextMenuItem[] = [
|
const items: ContextMenuItem[] = [
|
||||||
|
...(node.type === 'connection' && node.connectionId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: '编辑连接',
|
||||||
|
icon: Edit2,
|
||||||
|
action: () => openEditConnection(node.connectionId!),
|
||||||
|
} as ContextMenuItem,
|
||||||
|
{ divider: true } as ContextMenuItem,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: '重命名',
|
label: '重命名',
|
||||||
icon: Edit2,
|
icon: Edit2,
|
||||||
@@ -64,19 +152,17 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
{
|
{
|
||||||
label: '删除',
|
label: '删除',
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
action: () => deleteNode(node.id),
|
action: () => {
|
||||||
|
void deleteNode(node.id)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
// Provide drag-drop handlers to child components
|
// Drag-drop functionality
|
||||||
const dragHandlers = useTreeDragDrop()
|
const dragHandlers = useTreeDragDrop()
|
||||||
provide('dragHandlers', dragHandlers)
|
|
||||||
|
|
||||||
// Provide search match checker
|
|
||||||
provide('isSearchMatch', isSearchMatch)
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useKeyboardShortcuts([
|
useKeyboardShortcuts([
|
||||||
@@ -92,7 +178,7 @@ useKeyboardShortcuts([
|
|||||||
key: 'Delete',
|
key: 'Delete',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
if (treeStore.selectedNodeId) {
|
if (treeStore.selectedNodeId) {
|
||||||
deleteNode(treeStore.selectedNodeId)
|
void deleteNode(treeStore.selectedNodeId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -113,6 +199,10 @@ useKeyboardShortcuts([
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
function handleScroll(event: Event) {
|
||||||
|
scrollTop.value = (event.target as HTMLElement).scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
function handleNodeClick(nodeId: string) {
|
function handleNodeClick(nodeId: string) {
|
||||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
@@ -126,6 +216,16 @@ function handleNodeClick(nodeId: string) {
|
|||||||
treeStore.selectNode(nodeId)
|
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) {
|
function handleNodeContextMenu(nodeId: string, event: MouseEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
contextMenuNodeId.value = nodeId
|
contextMenuNodeId.value = nodeId
|
||||||
@@ -135,16 +235,74 @@ function handleNodeContextMenu(nodeId: string, event: MouseEvent) {
|
|||||||
treeStore.selectNode(nodeId)
|
treeStore.selectNode(nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFolder() {
|
function handleFolderToggle(nodeId: string, event: MouseEvent) {
|
||||||
if (!newFolderName.value.trim()) return
|
event.stopPropagation()
|
||||||
treeStore.createFolder(newFolderName.value.trim())
|
treeStore.toggleExpanded(nodeId)
|
||||||
newFolderName.value = ''
|
treeStore.selectNode(nodeId)
|
||||||
showNewFolderDialog.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSubFolder(_parentId: string) {
|
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
|
showNewFolderDialog.value = true
|
||||||
// TODO: Set parent context for new folder
|
}
|
||||||
|
|
||||||
|
function openRootFolderDialog() {
|
||||||
|
newFolderParentId.value = null
|
||||||
|
newFolderName.value = ''
|
||||||
|
showNewFolderDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNewFolderDialog() {
|
||||||
|
showNewFolderDialog.value = false
|
||||||
|
newFolderParentId.value = null
|
||||||
|
newFolderName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRename(nodeId: string) {
|
function startRename(nodeId: string) {
|
||||||
@@ -164,7 +322,29 @@ function confirmRename() {
|
|||||||
renameValue.value = ''
|
renameValue.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteNode(nodeId: string) {
|
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)
|
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
|
||||||
@@ -172,8 +352,19 @@ function deleteNode(nodeId: string) {
|
|||||||
? `确定要删除文件夹 "${node.name}" 及其所有内容吗?`
|
? `确定要删除文件夹 "${node.name}" 及其所有内容吗?`
|
||||||
: `确定要删除连接 "${node.name}" 吗?`
|
: `确定要删除连接 "${node.name}" 吗?`
|
||||||
|
|
||||||
if (confirm(confirmMsg)) {
|
if (!confirm(confirmMsg)) return
|
||||||
|
|
||||||
|
const connectionIds = collectConnectionIdsInSubtree(nodeId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const connectionId of connectionIds) {
|
||||||
|
await connectionsStore.deleteConnection(connectionId)
|
||||||
|
workspaceStore.closePanel(connectionId)
|
||||||
|
}
|
||||||
treeStore.deleteNode(nodeId)
|
treeStore.deleteNode(nodeId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete node:', error)
|
||||||
|
alert('删除失败,未完成此次删除操作。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +383,7 @@ function toggleExpandAll() {
|
|||||||
<div class="h-full flex flex-col bg-slate-900 border-r border-slate-700">
|
<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">
|
<div class="h-12 px-3 flex items-center gap-2 border-b border-slate-700">
|
||||||
<button
|
<button
|
||||||
@click="showNewFolderDialog = true"
|
@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"
|
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)"
|
title="新建文件夹 (Ctrl+N)"
|
||||||
>
|
>
|
||||||
@@ -211,7 +402,6 @@ function toggleExpandAll() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Bar -->
|
|
||||||
<div class="px-2 py-2 border-b border-slate-700">
|
<div class="px-2 py-2 border-b border-slate-700">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
@@ -231,25 +421,88 @@ function toggleExpandAll() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-2">
|
<div
|
||||||
<div v-if="searchQuery && rootNodes.length === 0" class="text-center py-8 text-slate-500 text-sm">
|
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>
|
||||||
<SessionTreeNode
|
|
||||||
v-for="node in rootNodes"
|
<div v-else class="relative" :style="{ height: `${totalHeight}px` }">
|
||||||
:key="node.id"
|
<div :style="{ transform: `translateY(${topPadding}px)` }">
|
||||||
:node="node"
|
<div
|
||||||
:level="0"
|
v-for="item in virtualNodes"
|
||||||
@click="handleNodeClick"
|
:key="item.node.id"
|
||||||
@contextmenu="handleNodeContextMenu"
|
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>
|
||||||
|
|
||||||
<!-- New Folder Dialog -->
|
|
||||||
<div
|
<div
|
||||||
v-if="showNewFolderDialog"
|
v-if="showNewFolderDialog"
|
||||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
@click.self="showNewFolderDialog = false"
|
@click.self="closeNewFolderDialog"
|
||||||
>
|
>
|
||||||
<div class="bg-slate-800 rounded-lg p-4 w-80">
|
<div class="bg-slate-800 rounded-lg p-4 w-80">
|
||||||
<h3 class="text-sm font-medium text-slate-100 mb-3">新建文件夹</h3>
|
<h3 class="text-sm font-medium text-slate-100 mb-3">新建文件夹</h3>
|
||||||
@@ -268,7 +521,7 @@ function toggleExpandAll() {
|
|||||||
创建
|
创建
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showNewFolderDialog = false"
|
@click="closeNewFolderDialog"
|
||||||
class="flex-1 px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm"
|
class="flex-1 px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -277,7 +530,6 @@ function toggleExpandAll() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rename Dialog -->
|
|
||||||
<div
|
<div
|
||||||
v-if="showRenameDialog"
|
v-if="showRenameDialog"
|
||||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
@@ -309,7 +561,6 @@ function toggleExpandAll() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Context Menu -->
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
:show="showContextMenu"
|
:show="showContextMenu"
|
||||||
:x="contextMenuX"
|
:x="contextMenuX"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function handleDragEnd() {
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
draggable="true"
|
draggable="true"
|
||||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded cursor-pointer hover:bg-slate-800 transition-colors relative"
|
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="{
|
:class="{
|
||||||
'bg-slate-800 text-cyan-300': isSelected,
|
'bg-slate-800 text-cyan-300': isSelected,
|
||||||
'text-slate-300': !isSelected,
|
'text-slate-300': !isSelected,
|
||||||
|
|||||||
@@ -1,9 +1,94 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Settings, HelpCircle, Bell, MonitorCog } from 'lucide-vue-next'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-12 bg-slate-900 border-b border-slate-700 px-4 flex items-center justify-between">
|
<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-4">
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<div class="w-7 h-7 rounded bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
|
||||||
@@ -13,6 +98,25 @@ import { Settings, HelpCircle, Bell, MonitorCog } from 'lucide-vue-next'
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 text-xs text-slate-400">
|
<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 class="px-3 py-1.5 rounded hover:bg-slate-800 hover:text-slate-200 transition-colors">
|
||||||
会话
|
会话
|
||||||
</button>
|
</button>
|
||||||
@@ -25,6 +129,32 @@ import { Settings, HelpCircle, Bell, MonitorCog } from 'lucide-vue-next'
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
||||||
@@ -46,4 +176,12 @@ import { Settings, HelpCircle, Bell, MonitorCog } from 'lucide-vue-next'
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
:show="showTabContextMenu"
|
||||||
|
:x="contextMenuX"
|
||||||
|
:y="contextMenuY"
|
||||||
|
:items="tabContextMenuItems"
|
||||||
|
@close="showTabContextMenu = false"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useWorkspaceStore } from '../stores/workspace'
|
import { useWorkspaceStore } from '../stores/workspace'
|
||||||
|
import type { WorkspacePanelState } from '../types/workspace'
|
||||||
import SplitPane from './SplitPane.vue'
|
import SplitPane from './SplitPane.vue'
|
||||||
import TerminalWidget from './TerminalWidget.vue'
|
import TerminalWidget from './TerminalWidget.vue'
|
||||||
import SftpPanel from './SftpPanel.vue'
|
import SftpPanel from './SftpPanel.vue'
|
||||||
@@ -9,45 +10,59 @@ import { Server } from 'lucide-vue-next'
|
|||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
const activeConnectionId = computed(() => workspaceStore.activeConnectionId)
|
const activeConnectionId = computed(() => workspaceStore.activeConnectionId)
|
||||||
const activePanel = computed(() => workspaceStore.activePanel)
|
const openPanels = computed(() => {
|
||||||
|
return workspaceStore.panelOrder
|
||||||
|
.map((connectionId) => workspaceStore.panels[connectionId])
|
||||||
|
.filter((panel): panel is WorkspacePanelState => Boolean(panel))
|
||||||
|
})
|
||||||
|
|
||||||
function handleRatioChange(ratio: number) {
|
function isPanelActive(connectionId: number) {
|
||||||
if (activeConnectionId.value) {
|
return activeConnectionId.value === connectionId
|
||||||
workspaceStore.updateSplitRatio(activeConnectionId.value, ratio)
|
}
|
||||||
}
|
|
||||||
|
function handleRatioChange(panel: WorkspacePanelState, ratio: number) {
|
||||||
|
workspaceStore.updateSplitRatio(panel.connectionId, ratio)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full bg-slate-950">
|
<div class="h-full bg-slate-950">
|
||||||
<div v-if="!activeConnectionId" class="h-full flex items-center justify-center text-slate-500">
|
<div v-if="openPanels.length === 0 || !activeConnectionId" class="h-full flex items-center justify-center text-slate-500">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<Server class="w-16 h-16 mx-auto mb-4 text-slate-600" />
|
<Server class="w-16 h-16 mx-auto mb-4 text-slate-600" />
|
||||||
<p class="text-lg">请从左侧会话树选择一个连接</p>
|
<p class="text-lg">请从左侧会话树选择一个连接</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<SplitPane
|
||||||
v-else-if="activePanel"
|
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
:initial-ratio="activePanel.splitRatio"
|
:initial-ratio="panel.splitRatio"
|
||||||
@ratio-change="handleRatioChange"
|
@ratio-change="(ratio) => handleRatioChange(panel, ratio)"
|
||||||
>
|
>
|
||||||
<template #first>
|
<template #first>
|
||||||
<TerminalWidget
|
<TerminalWidget
|
||||||
v-if="activePanel.terminalVisible"
|
v-if="panel.terminalVisible"
|
||||||
:connection-id="activeConnectionId"
|
:connection-id="panel.connectionId"
|
||||||
:active="true"
|
:active="isPanelActive(panel.connectionId)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #second>
|
<template #second>
|
||||||
<SftpPanel
|
<SftpPanel
|
||||||
v-if="activePanel.sftpVisible"
|
v-if="panel.sftpVisible"
|
||||||
:connection-id="activeConnectionId"
|
:connection-id="panel.connectionId"
|
||||||
:active="true"
|
:active="isPanelActive(panel.connectionId)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SplitPane>
|
</SplitPane>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export function useConnectionSync() {
|
|||||||
watch(
|
watch(
|
||||||
() => connectionsStore.connections.length,
|
() => connectionsStore.connections.length,
|
||||||
(newLength, oldLength) => {
|
(newLength, oldLength) => {
|
||||||
|
if (!treeStore.hydrated) return
|
||||||
|
|
||||||
if (newLength > oldLength) {
|
if (newLength > oldLength) {
|
||||||
// New connection added
|
// New connection added
|
||||||
treeStore.syncNewConnections()
|
treeStore.syncNewConnections()
|
||||||
@@ -24,6 +26,7 @@ export function useConnectionSync() {
|
|||||||
watch(
|
watch(
|
||||||
() => connectionsStore.connections.map(c => ({ id: c.id, name: c.name })),
|
() => connectionsStore.connections.map(c => ({ id: c.id, name: c.name })),
|
||||||
(newConnections, oldConnections) => {
|
(newConnections, oldConnections) => {
|
||||||
|
if (!treeStore.hydrated) return
|
||||||
if (!oldConnections) return
|
if (!oldConnections) return
|
||||||
|
|
||||||
newConnections.forEach((newConn, index) => {
|
newConnections.forEach((newConn, index) => {
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { ref, computed, type Ref } from 'vue'
|
import { ref, computed, type Ref } from 'vue'
|
||||||
|
import { refDebounced } from '@vueuse/core'
|
||||||
import type { SessionTreeNode } from '../types/sessionTree'
|
import type { SessionTreeNode } from '../types/sessionTree'
|
||||||
|
|
||||||
export function useTreeSearch(nodesRef: Ref<SessionTreeNode[]>) {
|
export function useTreeSearch(nodesRef: Ref<SessionTreeNode[]>) {
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const debouncedQuery = refDebounced(searchQuery, 150)
|
||||||
const searchResults = ref<Set<string>>(new Set())
|
const searchResults = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const filteredNodes = computed(() => {
|
const filteredNodes = computed(() => {
|
||||||
const nodes = nodesRef.value
|
const nodes = nodesRef.value
|
||||||
|
|
||||||
if (!searchQuery.value.trim()) {
|
const query = debouncedQuery.value.trim().toLowerCase()
|
||||||
|
if (!query) {
|
||||||
|
searchResults.value = new Set()
|
||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
const results = new Set<string>()
|
const results = new Set<string>()
|
||||||
const matchedNodes = new Set<string>()
|
const matchedNodes = new Set<string>()
|
||||||
|
const nodeById = new Map(nodes.map(node => [node.id, node]))
|
||||||
|
|
||||||
// Find all matching nodes
|
// Find all matching nodes
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
@@ -23,10 +27,13 @@ export function useTreeSearch(nodesRef: Ref<SessionTreeNode[]>) {
|
|||||||
results.add(node.id)
|
results.add(node.id)
|
||||||
|
|
||||||
// Add all ancestors to results
|
// Add all ancestors to results
|
||||||
let current = node
|
let current: SessionTreeNode | undefined = node
|
||||||
while (current.parentId) {
|
while (current.parentId) {
|
||||||
results.add(current.parentId)
|
results.add(current.parentId)
|
||||||
current = nodes.find(n => n.id === current.parentId)!
|
current = nodeById.get(current.parentId)
|
||||||
|
if (!current) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,96 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { ArrowLeftRight, Blocks, Clock3, LogOut } from 'lucide-vue-next'
|
||||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
|
||||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
|
||||||
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
|
||||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen, MonitorCog, Clock3 } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const connectionsStore = useConnectionsStore()
|
|
||||||
const sftpTabsStore = useSftpTabsStore()
|
|
||||||
const tabsStore = useTerminalTabsStore()
|
|
||||||
|
|
||||||
const sidebarOpen = ref(false)
|
|
||||||
const now = ref(new Date())
|
const now = ref(new Date())
|
||||||
let clockTimer = 0
|
let clockTimer = 0
|
||||||
|
|
||||||
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 currentSectionTitle = computed(() => {
|
|
||||||
if (route.path.startsWith('/connections')) return 'Session Manager'
|
|
||||||
if (route.path.startsWith('/terminal')) return 'Terminal Workspace'
|
|
||||||
if (route.path.startsWith('/sftp/')) return 'SFTP Browser'
|
|
||||||
if (route.path.startsWith('/transfers')) return 'Transfer Queue'
|
|
||||||
return 'SSH Manager'
|
|
||||||
})
|
|
||||||
|
|
||||||
const nowText = computed(() => {
|
const nowText = computed(() => {
|
||||||
return now.value.toLocaleTimeString([], {
|
return now.value.toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit'
|
second: '2-digit',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function closeSidebar() {
|
|
||||||
sidebarOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTabClick(tabId: string) {
|
|
||||||
tabsStore.activate(tabId)
|
|
||||||
router.push('/terminal')
|
|
||||||
closeSidebar()
|
|
||||||
}
|
|
||||||
|
|
||||||
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
connectionsStore.fetchConnections().catch(() => {})
|
|
||||||
clockTimer = window.setInterval(() => {
|
clockTimer = window.setInterval(() => {
|
||||||
now.value = new Date()
|
now.value = new Date()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
@@ -103,132 +37,32 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen bg-slate-950 text-slate-100">
|
<div class="flex h-screen bg-slate-950 text-slate-100">
|
||||||
<button
|
<aside class="w-64 border-r border-slate-700/80 flex flex-col bg-slate-900/95">
|
||||||
@click="sidebarOpen = !sidebarOpen"
|
<div class="px-4 py-4 border-b border-slate-700/80">
|
||||||
class="lg:hidden fixed top-3 left-3 z-40 p-2 rounded-md bg-slate-800 border border-slate-600 text-slate-200 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-400 cursor-pointer"
|
|
||||||
aria-label="切换侧边栏"
|
|
||||||
>
|
|
||||||
<Menu v-if="!sidebarOpen" class="w-5 h-5" aria-hidden="true" />
|
|
||||||
<X v-else class="w-5 h-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<aside
|
|
||||||
:class="[
|
|
||||||
'w-72 border-r border-slate-700/80 flex flex-col transition-transform duration-200 z-30 bg-slate-900/95 backdrop-blur-sm',
|
|
||||||
'fixed lg:static inset-y-0 left-0',
|
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="px-4 py-3 border-b border-slate-700/80">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-9 h-9 rounded-md bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
|
|
||||||
<MonitorCog class="w-5 h-5 text-white" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-sm font-semibold tracking-wide text-slate-100">SSH Manager</h1>
|
<h1 class="text-sm font-semibold tracking-wide text-slate-100">SSH Manager</h1>
|
||||||
<p class="text-xs text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
<p class="text-xs text-slate-400 mt-1">{{ authStore.displayName || authStore.username }}</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 px-3 py-3 overflow-y-auto space-y-4 pt-16 lg:pt-3">
|
<nav class="flex-1 px-3 py-3 overflow-y-auto space-y-1">
|
||||||
<div>
|
|
||||||
<p class="px-2 text-[11px] uppercase tracking-wider text-slate-500 mb-2">工作区</p>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/connections"
|
to="/moba"
|
||||||
@click="closeSidebar"
|
|
||||||
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="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 === '/connections' }"
|
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path.startsWith('/moba') }"
|
||||||
aria-label="连接列表"
|
aria-label="主页"
|
||||||
>
|
>
|
||||||
<Server class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
<Blocks class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||||
<span>Connections</span>
|
<span>主页(Moba)</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/transfers"
|
to="/transfers"
|
||||||
@click="closeSidebar"
|
|
||||||
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="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 === '/transfers' }"
|
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path.startsWith('/transfers') }"
|
||||||
aria-label="传输"
|
aria-label="传输队列"
|
||||||
>
|
>
|
||||||
<ArrowLeftRight class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
<ArrowLeftRight class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||||
<span>Transfers</span>
|
<span>Transfers</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="terminalTabs.length > 0" class="pt-3 border-t border-slate-700/70">
|
|
||||||
<div class="flex items-center justify-between px-2 mb-2">
|
|
||||||
<p class="text-[11px] uppercase tracking-wider text-slate-500">Terminal Sessions</p>
|
|
||||||
<span class="text-[11px] text-slate-500">{{ terminalTabs.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div
|
|
||||||
v-for="tab in terminalTabs"
|
|
||||||
:key="tab.id"
|
|
||||||
class="group flex items-center gap-2 rounded-md border border-transparent px-2 py-2 hover:bg-slate-800 transition-colors"
|
|
||||||
:class="{ 'bg-slate-800 border-cyan-500/30': tab.active && route.path === '/terminal' }"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="handleTabClick(tab.id)"
|
|
||||||
class="flex-1 min-w-0 text-left cursor-pointer focus:outline-none"
|
|
||||||
:aria-label="`切换终端会话 ${tab.title}`"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-600'" />
|
|
||||||
<Terminal class="w-3.5 h-3.5 text-slate-400" aria-hidden="true" />
|
|
||||||
<span class="truncate text-sm text-slate-200">{{ tab.title }}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="(e) => handleTabClose(tab.id, e)"
|
|
||||||
class="p-1 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200 transition-opacity duration-200 cursor-pointer"
|
|
||||||
aria-label="关闭终端标签"
|
|
||||||
>
|
|
||||||
<X class="w-3 h-3" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="sftpTabs.length > 0" class="pt-3 border-t border-slate-700/70">
|
|
||||||
<div class="flex items-center justify-between px-2 mb-2">
|
|
||||||
<p class="text-[11px] uppercase tracking-wider text-slate-500">File Sessions</p>
|
|
||||||
<span class="text-[11px] text-slate-500">{{ sftpTabs.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div
|
|
||||||
v-for="tab in sftpTabs"
|
|
||||||
:key="tab.id"
|
|
||||||
class="group flex items-center gap-2 rounded-md border border-transparent px-2 py-2 hover:bg-slate-800 transition-colors"
|
|
||||||
:class="{ 'bg-slate-800 border-cyan-500/30': 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}`"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-emerald-400' : 'bg-slate-600'" />
|
|
||||||
<FolderOpen class="w-3.5 h-3.5 text-slate-400" aria-hidden="true" />
|
|
||||||
<span class="truncate text-sm text-slate-200">{{ tab.title }}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="(e) => handleSftpTabClose(tab.id, tab.connectionId, e)"
|
|
||||||
class="p-1 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200 transition-opacity duration-200 cursor-pointer"
|
|
||||||
aria-label="关闭文件标签"
|
|
||||||
>
|
|
||||||
<X class="w-3 h-3" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="p-3 border-t border-slate-700/80">
|
<div class="p-3 border-t border-slate-700/80">
|
||||||
@@ -243,22 +77,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="sidebarOpen"
|
|
||||||
class="lg:hidden fixed inset-0 bg-black/55 z-20"
|
|
||||||
aria-hidden="true"
|
|
||||||
@click="sidebarOpen = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main class="flex-1 min-w-0 overflow-hidden flex flex-col">
|
<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">
|
<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">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<h2 class="text-sm md:text-base font-medium text-slate-100 truncate">Transfer Queue</h2>
|
||||||
<h2 class="text-sm md:text-base font-medium text-slate-100 truncate">{{ currentSectionTitle }}</h2>
|
|
||||||
<div class="hidden md:flex items-center gap-2 text-xs text-slate-400">
|
|
||||||
<span class="px-2 py-1 rounded bg-slate-800 border border-slate-700">TTY {{ terminalTabs.length }}</span>
|
|
||||||
<span class="px-2 py-1 rounded bg-slate-800 border border-slate-700">SFTP {{ sftpTabs.length }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 text-xs text-slate-400">
|
<div class="flex items-center gap-2 text-xs text-slate-400">
|
||||||
<Clock3 class="w-3.5 h-3.5" aria-hidden="true" />
|
<Clock3 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
<span class="tabular-nums">{{ nowText }}</span>
|
<span class="tabular-nums">{{ nowText }}</span>
|
||||||
@@ -266,17 +87,7 @@ onUnmounted(() => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 overflow-auto">
|
<div class="flex-1 min-h-0 overflow-auto">
|
||||||
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
|
<RouterView />
|
||||||
<TerminalWorkspaceView :visible="showTerminalWorkspace" />
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,113 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
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 { useSessionTreeStore } from '../stores/sessionTree'
|
||||||
import { useWorkspaceStore } from '../stores/workspace'
|
import { useWorkspaceStore } from '../stores/workspace'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import { useConnectionSync } from '../composables/useConnectionSync'
|
import { useConnectionSync } from '../composables/useConnectionSync'
|
||||||
|
import type { ConnectionCreateRequest } from '../api/connections'
|
||||||
import TopToolbar from '../components/TopToolbar.vue'
|
import TopToolbar from '../components/TopToolbar.vue'
|
||||||
import SessionTree from '../components/SessionTree.vue'
|
import SessionTree from '../components/SessionTree.vue'
|
||||||
import WorkspacePanel from '../components/WorkspacePanel.vue'
|
import WorkspacePanel from '../components/WorkspacePanel.vue'
|
||||||
|
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||||
import MigrationPrompt from '../components/MigrationPrompt.vue'
|
import MigrationPrompt from '../components/MigrationPrompt.vue'
|
||||||
|
import TransfersView from '../views/TransfersView.vue'
|
||||||
|
|
||||||
const treeStore = useSessionTreeStore()
|
const treeStore = useSessionTreeStore()
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const connectionsStore = useConnectionsStore()
|
const connectionsStore = useConnectionsStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const showMigrationPrompt = ref(false)
|
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
|
// Enable bidirectional sync
|
||||||
useConnectionSync()
|
useConnectionSync()
|
||||||
|
|
||||||
onMounted(async () => {
|
watch(
|
||||||
treeStore.restore()
|
() => route.query.tool,
|
||||||
workspaceStore.restore()
|
(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)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if migration is needed
|
|
||||||
const migrationDismissed = localStorage.getItem('ssh-manager.migration-dismissed')
|
const migrationDismissed = localStorage.getItem('ssh-manager.migration-dismissed')
|
||||||
const hasOldData = connectionsStore.connections.length > 0
|
const hasOldData = connectionsLoaded && connectionsStore.connections.length > 0
|
||||||
const hasNewData = treeStore.nodes.length > 0
|
const hasNewData = treeStore.nodes.length > 0
|
||||||
|
|
||||||
if (!migrationDismissed && hasOldData && !hasNewData) {
|
if (!migrationDismissed && hasOldData && !hasNewData) {
|
||||||
showMigrationPrompt.value = true
|
showMigrationPrompt.value = true
|
||||||
} else if (treeStore.nodes.length === 0) {
|
} else if (treeStore.nodes.length === 0 && connectionsLoaded) {
|
||||||
await treeStore.initFromConnections()
|
await treeStore.initFromConnections()
|
||||||
} else {
|
} else if (connectionsLoaded) {
|
||||||
// Sync with connections store
|
|
||||||
treeStore.syncNewConnections()
|
treeStore.syncNewConnections()
|
||||||
treeStore.syncDeletedConnections()
|
treeStore.syncDeletedConnections()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
async function handleMigrate() {
|
async function handleMigrate() {
|
||||||
await treeStore.initFromConnections()
|
await treeStore.initFromConnections()
|
||||||
showMigrationPrompt.value = false
|
showMigrationPrompt.value = false
|
||||||
@@ -50,10 +120,45 @@ function handleDismiss() {
|
|||||||
function handleClose() {
|
function handleClose() {
|
||||||
showMigrationPrompt.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col bg-slate-950 text-slate-100">
|
<div class="h-screen flex flex-col bg-slate-950 text-slate-100 overflow-hidden">
|
||||||
<TopToolbar />
|
<TopToolbar />
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 flex">
|
<div class="flex-1 min-h-0 flex">
|
||||||
@@ -66,6 +171,45 @@ function handleClose() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<MigrationPrompt
|
||||||
:show="showMigrationPrompt"
|
:show="showMigrationPrompt"
|
||||||
@migrate="handleMigrate"
|
@migrate="handleMigrate"
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../views/LoginView.vue'),
|
component: () => import('../views/LoginView.vue'),
|
||||||
meta: { public: true },
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/moba',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/moba',
|
path: '/moba',
|
||||||
name: 'MobaLayout',
|
name: 'MobaLayout',
|
||||||
@@ -16,42 +20,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/transfers',
|
||||||
component: () => import('../layouts/MainLayout.vue'),
|
redirect: '/moba?tool=transfers',
|
||||||
meta: { requiresAuth: true },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
name: 'Home',
|
|
||||||
redirect: '/moba',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -64,7 +34,7 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (to.meta.public) {
|
if (to.meta.public) {
|
||||||
if (authStore.isAuthenticated && to.path === '/login') {
|
if (authStore.isAuthenticated && to.path === '/login') {
|
||||||
next('/connections')
|
next('/moba')
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import * as connectionsApi from '../api/connections'
|
|||||||
|
|
||||||
export const useConnectionsStore = defineStore('connections', () => {
|
export const useConnectionsStore = defineStore('connections', () => {
|
||||||
const connections = ref<Connection[]>([])
|
const connections = ref<Connection[]>([])
|
||||||
|
const hasFetched = ref(false)
|
||||||
|
|
||||||
async function fetchConnections() {
|
async function fetchConnections() {
|
||||||
const res = await connectionsApi.listConnections()
|
const res = await connectionsApi.listConnections()
|
||||||
connections.value = res.data
|
connections.value = res.data
|
||||||
|
hasFetched.value = true
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ export const useConnectionsStore = defineStore('connections', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
connections,
|
connections,
|
||||||
|
hasFetched,
|
||||||
fetchConnections,
|
fetchConnections,
|
||||||
createConnection,
|
createConnection,
|
||||||
updateConnection,
|
updateConnection,
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { SessionTreeNode, SessionTreeState } from '../types/sessionTree'
|
import type { SessionTreeNode, SessionTreeState } from '../types/sessionTree'
|
||||||
|
import { getSessionTree, saveSessionTree } from '../api/sessionTree'
|
||||||
import { useConnectionsStore } from './connections'
|
import { useConnectionsStore } from './connections'
|
||||||
|
|
||||||
const STORAGE_KEY = 'ssh-manager.session-tree'
|
const STORAGE_KEY = 'ssh-manager.session-tree'
|
||||||
|
let persistTimer: number | null = null
|
||||||
|
|
||||||
export const useSessionTreeStore = defineStore('sessionTree', {
|
export const useSessionTreeStore = defineStore('sessionTree', {
|
||||||
state: (): SessionTreeState => ({
|
state: (): SessionTreeState => ({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
hydrated: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -54,6 +57,18 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createConnectionNode(connectionId: number, name: string, parentId: string | null = null) {
|
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 = {
|
const node: SessionTreeNode = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: 'connection',
|
type: 'connection',
|
||||||
@@ -82,13 +97,16 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteNode(nodeId: string) {
|
deleteNode(nodeId: string) {
|
||||||
const children = this.getChildren(nodeId)
|
this.deleteNodeInternal(nodeId)
|
||||||
children.forEach(child => this.deleteNode(child.id))
|
|
||||||
|
|
||||||
this.nodes = this.nodes.filter(n => n.id !== nodeId)
|
|
||||||
this.persist()
|
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) {
|
renameNode(nodeId: string, newName: string) {
|
||||||
const node = this.nodes.find(n => n.id === nodeId)
|
const node = this.nodes.find(n => n.id === nodeId)
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -128,19 +146,66 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
persist() {
|
persist() {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.$state))
|
this.persistLocal()
|
||||||
|
if (!this.hydrated) return
|
||||||
|
|
||||||
|
if (persistTimer !== null) {
|
||||||
|
window.clearTimeout(persistTimer)
|
||||||
|
}
|
||||||
|
persistTimer = window.setTimeout(() => {
|
||||||
|
void this.persistRemote()
|
||||||
|
}, 250)
|
||||||
},
|
},
|
||||||
|
|
||||||
restore() {
|
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)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) {
|
if (!raw) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw)
|
const data = JSON.parse(raw)
|
||||||
this.$patch(data)
|
if (Array.isArray(data.nodes)) {
|
||||||
|
this.nodes = data.nodes
|
||||||
|
}
|
||||||
|
if (typeof data.selectedNodeId === 'string' || data.selectedNodeId === null) {
|
||||||
|
this.selectedNodeId = data.selectedNodeId
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to restore session tree:', 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() {
|
async initFromConnections() {
|
||||||
@@ -170,7 +235,6 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (newConnections.length > 0) {
|
if (newConnections.length > 0) {
|
||||||
// Add to root or default folder
|
|
||||||
let targetParentId: string | null = null
|
let targetParentId: string | null = null
|
||||||
const defaultFolder = this.nodes.find(
|
const defaultFolder = this.nodes.find(
|
||||||
n => n.type === 'folder' && n.name === '我的连接'
|
n => n.type === 'folder' && n.name === '我的连接'
|
||||||
@@ -188,6 +252,8 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
|||||||
// Sync with connections store - remove deleted connections
|
// Sync with connections store - remove deleted connections
|
||||||
syncDeletedConnections() {
|
syncDeletedConnections() {
|
||||||
const connectionsStore = useConnectionsStore()
|
const connectionsStore = useConnectionsStore()
|
||||||
|
if (!connectionsStore.hasFetched) return
|
||||||
|
|
||||||
const validConnectionIds = new Set(
|
const validConnectionIds = new Set(
|
||||||
connectionsStore.connections.map(c => c.id)
|
connectionsStore.connections.map(c => c.id)
|
||||||
)
|
)
|
||||||
@@ -198,9 +264,12 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
|||||||
!validConnectionIds.has(n.connectionId)
|
!validConnectionIds.has(n.connectionId)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (nodesToDelete.length === 0) return
|
||||||
|
|
||||||
nodesToDelete.forEach(node => {
|
nodesToDelete.forEach(node => {
|
||||||
this.deleteNode(node.id)
|
this.deleteNodeInternal(node.id)
|
||||||
})
|
})
|
||||||
|
this.persist()
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update connection name when changed
|
// Update connection name when changed
|
||||||
@@ -213,7 +282,22 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Expand all folders
|
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() {
|
expandAll() {
|
||||||
this.nodes.forEach(node => {
|
this.nodes.forEach(node => {
|
||||||
if (node.type === 'folder') {
|
if (node.type === 'folder') {
|
||||||
@@ -223,7 +307,6 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
|||||||
this.persist()
|
this.persist()
|
||||||
},
|
},
|
||||||
|
|
||||||
// Collapse all folders
|
|
||||||
collapseAll() {
|
collapseAll() {
|
||||||
this.nodes.forEach(node => {
|
this.nodes.forEach(node => {
|
||||||
if (node.type === 'folder') {
|
if (node.type === 'folder') {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export const useWorkspaceStore = defineStore('workspace', {
|
|||||||
state: (): WorkspaceState => ({
|
state: (): WorkspaceState => ({
|
||||||
activeConnectionId: null,
|
activeConnectionId: null,
|
||||||
panels: {},
|
panels: {},
|
||||||
|
panelOrder: [],
|
||||||
|
transfersModalOpen: false,
|
||||||
|
sessionModalOpen: false,
|
||||||
|
sessionModalMode: 'create',
|
||||||
|
editingConnectionId: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -29,6 +34,9 @@ export const useWorkspaceStore = defineStore('workspace', {
|
|||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
lastActiveAt: Date.now(),
|
lastActiveAt: Date.now(),
|
||||||
}
|
}
|
||||||
|
this.panelOrder.push(connectionId)
|
||||||
|
} else if (!this.panelOrder.includes(connectionId)) {
|
||||||
|
this.panelOrder.push(connectionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeConnectionId = connectionId
|
this.activeConnectionId = connectionId
|
||||||
@@ -37,17 +45,94 @@ export const useWorkspaceStore = defineStore('workspace', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
closePanel(connectionId: number) {
|
closePanel(connectionId: number) {
|
||||||
|
const closedIndex = this.panelOrder.indexOf(connectionId)
|
||||||
delete this.panels[connectionId]
|
delete this.panels[connectionId]
|
||||||
|
this.panelOrder = this.panelOrder.filter((id) => id !== connectionId)
|
||||||
|
|
||||||
if (this.activeConnectionId === connectionId) {
|
if (this.activeConnectionId === connectionId) {
|
||||||
const sorted = Object.values(this.panels)
|
const nextId = closedIndex >= 0
|
||||||
.sort((a, b) => b.lastActiveAt - a.lastActiveAt)
|
? this.panelOrder[closedIndex] ?? this.panelOrder[closedIndex - 1] ?? null
|
||||||
this.activeConnectionId = sorted[0]?.connectionId || null
|
: this.panelOrder[0] ?? null
|
||||||
|
this.activeConnectionId = nextId
|
||||||
}
|
}
|
||||||
|
|
||||||
this.persist()
|
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) {
|
updateSplitRatio(connectionId: number, ratio: number) {
|
||||||
const panel = this.panels[connectionId]
|
const panel = this.panels[connectionId]
|
||||||
if (panel) {
|
if (panel) {
|
||||||
@@ -98,6 +183,48 @@ export const useWorkspaceStore = defineStore('workspace', {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw)
|
const data = JSON.parse(raw)
|
||||||
this.$patch(data)
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to restore workspace:', e)
|
console.error('Failed to restore workspace:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type SessionTreeNodeType = 'folder' | 'connection'
|
import type { SessionTreeNodeType } from '../api/sessionTree'
|
||||||
|
|
||||||
export interface SessionTreeNode {
|
export interface SessionTreeNode {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,4 +15,5 @@ export interface SessionTreeNode {
|
|||||||
export interface SessionTreeState {
|
export interface SessionTreeState {
|
||||||
nodes: SessionTreeNode[]
|
nodes: SessionTreeNode[]
|
||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
|
hydrated: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,9 @@ export interface WorkspacePanelState {
|
|||||||
export interface WorkspaceState {
|
export interface WorkspaceState {
|
||||||
activeConnectionId: number | null
|
activeConnectionId: number | null
|
||||||
panels: Record<number, WorkspacePanelState>
|
panels: Record<number, WorkspacePanelState>
|
||||||
|
panelOrder: number[]
|
||||||
|
transfersModalOpen: boolean
|
||||||
|
sessionModalOpen: boolean
|
||||||
|
sessionModalMode: 'create' | 'edit'
|
||||||
|
editingConnectionId: number | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async function handleSubmit() {
|
|||||||
try {
|
try {
|
||||||
const res = await authApi.login({ username: username.value, password: password.value })
|
const res = await authApi.login({ username: username.value, password: password.value })
|
||||||
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
||||||
router.push('/connections')
|
router.push('/moba')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
||||||
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
|
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ type Tab = 'local' | 'remote'
|
|||||||
|
|
||||||
const connectionsStore = useConnectionsStore()
|
const connectionsStore = useConnectionsStore()
|
||||||
const transfersStore = useTransfersStore()
|
const transfersStore = useTransfersStore()
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
embedded?: boolean
|
||||||
|
}>(), {
|
||||||
|
embedded: false,
|
||||||
|
})
|
||||||
|
|
||||||
const tab = ref<Tab>('local')
|
const tab = ref<Tab>('local')
|
||||||
|
|
||||||
@@ -246,13 +251,14 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 flex-col gap-2">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<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>
|
<p class="text-sm text-slate-400">本机 -> 多台 / 其他机器 -> 多台</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@click="transfersStore.clearRuns"
|
@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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
<button
|
<button
|
||||||
@@ -303,7 +310,7 @@ onMounted(async () => {
|
|||||||
<div class="mt-6 grid gap-6 lg:grid-cols-[1fr_460px]">
|
<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">
|
<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">
|
<div v-if="connections.length === 0" class="text-slate-300">
|
||||||
<p class="text-sm">暂无连接。请先在 Connections 里添加连接。</p>
|
<p class="text-sm">暂无连接。请先在 Moba 页面里添加连接。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
|||||||
Reference in New Issue
Block a user