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
|
||||
public void delete(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
Connection conn = connectionRepository.findById(id).orElse(null);
|
||||
if (conn == null) {
|
||||
return;
|
||||
}
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string
|
||||
@@ -61,13 +61,19 @@ function handleClickOutside(event: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.show) {
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 0)
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
<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 { useWorkspaceStore } from '../stores/workspace'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
|
||||
import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts'
|
||||
import { useTreeSearch } from '../composables/useTreeSearch'
|
||||
import SessionTreeNode from './SessionTreeNode.vue'
|
||||
import ContextMenu 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 workspaceStore = useWorkspaceStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
|
||||
const showNewFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderParentId = ref<string | null>(null)
|
||||
const showRenameDialog = ref(false)
|
||||
const renameNodeId = ref<string | null>(null)
|
||||
const renameValue = ref('')
|
||||
@@ -30,13 +53,68 @@ const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
const contextMenuNodeId = ref<string | null>(null)
|
||||
|
||||
const rootNodes = computed(() => {
|
||||
const nodes = searchQuery.value ? filteredNodes.value : treeStore.nodes
|
||||
return nodes
|
||||
.filter(n => n.parentId === null)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
const nodesForRender = computed(() => {
|
||||
return searchQuery.value.trim() ? filteredNodes.value : treeStore.nodes
|
||||
})
|
||||
|
||||
const visibleTreeNodes = computed<VisibleTreeNode[]>(() => {
|
||||
const nodes = nodesForRender.value
|
||||
if (nodes.length === 0) return []
|
||||
|
||||
const childrenByParent = new Map<string | null, SessionTreeNode[]>()
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const parentId = node.parentId
|
||||
const siblings = childrenByParent.get(parentId) || []
|
||||
siblings.push(node)
|
||||
childrenByParent.set(parentId, siblings)
|
||||
})
|
||||
|
||||
childrenByParent.forEach((siblings) => {
|
||||
siblings.sort((a, b) => a.order - b.order)
|
||||
})
|
||||
|
||||
const flattened: VisibleTreeNode[] = []
|
||||
|
||||
const walk = (parentId: string | null, level: number) => {
|
||||
const children = childrenByParent.get(parentId) || []
|
||||
children.forEach((child) => {
|
||||
flattened.push({ node: child, level })
|
||||
if (child.type === 'folder' && child.expanded) {
|
||||
walk(child.id, level + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
walk(null, 0)
|
||||
return flattened
|
||||
})
|
||||
|
||||
const treeViewportRef = ref<HTMLElement | null>(null)
|
||||
const { height: viewportHeight } = useElementSize(treeViewportRef)
|
||||
const scrollTop = ref(0)
|
||||
|
||||
const totalRows = computed(() => visibleTreeNodes.value.length)
|
||||
const visibleCount = computed(() => {
|
||||
const rows = Math.ceil((viewportHeight.value || 400) / ROW_HEIGHT)
|
||||
return rows + OVERSCAN * 2
|
||||
})
|
||||
|
||||
const startIndex = computed(() => {
|
||||
return Math.max(Math.floor(scrollTop.value / ROW_HEIGHT) - OVERSCAN, 0)
|
||||
})
|
||||
|
||||
const endIndex = computed(() => {
|
||||
return Math.min(startIndex.value + visibleCount.value, totalRows.value)
|
||||
})
|
||||
|
||||
const virtualNodes = computed(() => {
|
||||
return visibleTreeNodes.value.slice(startIndex.value, endIndex.value)
|
||||
})
|
||||
|
||||
const topPadding = computed(() => startIndex.value * ROW_HEIGHT)
|
||||
const totalHeight = computed(() => totalRows.value * ROW_HEIGHT)
|
||||
|
||||
const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
if (!contextMenuNodeId.value) return []
|
||||
|
||||
@@ -44,6 +122,16 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
if (!node) return []
|
||||
|
||||
const items: ContextMenuItem[] = [
|
||||
...(node.type === 'connection' && node.connectionId
|
||||
? [
|
||||
{
|
||||
label: '编辑连接',
|
||||
icon: Edit2,
|
||||
action: () => openEditConnection(node.connectionId!),
|
||||
} as ContextMenuItem,
|
||||
{ divider: true } as ContextMenuItem,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: '重命名',
|
||||
icon: Edit2,
|
||||
@@ -64,19 +152,17 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
{
|
||||
label: '删除',
|
||||
icon: Trash2,
|
||||
action: () => deleteNode(node.id),
|
||||
action: () => {
|
||||
void deleteNode(node.id)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Provide drag-drop handlers to child components
|
||||
// Drag-drop functionality
|
||||
const dragHandlers = useTreeDragDrop()
|
||||
provide('dragHandlers', dragHandlers)
|
||||
|
||||
// Provide search match checker
|
||||
provide('isSearchMatch', isSearchMatch)
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts([
|
||||
@@ -92,7 +178,7 @@ useKeyboardShortcuts([
|
||||
key: 'Delete',
|
||||
handler: () => {
|
||||
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) {
|
||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node) return
|
||||
@@ -126,6 +216,16 @@ function handleNodeClick(nodeId: string) {
|
||||
treeStore.selectNode(nodeId)
|
||||
}
|
||||
|
||||
function handleNodeDoubleClick(nodeId: string) {
|
||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node || node.type !== 'connection' || !node.connectionId) return
|
||||
openEditConnection(node.connectionId)
|
||||
}
|
||||
|
||||
function openEditConnection(connectionId: number) {
|
||||
workspaceStore.openEditSessionModal(connectionId)
|
||||
}
|
||||
|
||||
function handleNodeContextMenu(nodeId: string, event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
contextMenuNodeId.value = nodeId
|
||||
@@ -135,16 +235,74 @@ function handleNodeContextMenu(nodeId: string, event: MouseEvent) {
|
||||
treeStore.selectNode(nodeId)
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
treeStore.createFolder(newFolderName.value.trim())
|
||||
newFolderName.value = ''
|
||||
showNewFolderDialog.value = false
|
||||
function handleFolderToggle(nodeId: string, event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
treeStore.toggleExpanded(nodeId)
|
||||
treeStore.selectNode(nodeId)
|
||||
}
|
||||
|
||||
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
|
||||
// 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) {
|
||||
@@ -164,7 +322,29 @@ function confirmRename() {
|
||||
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)
|
||||
if (!node) return
|
||||
|
||||
@@ -172,8 +352,19 @@ function deleteNode(nodeId: string) {
|
||||
? `确定要删除文件夹 "${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)
|
||||
} 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-12 px-3 flex items-center gap-2 border-b border-slate-700">
|
||||
<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"
|
||||
title="新建文件夹 (Ctrl+N)"
|
||||
>
|
||||
@@ -211,7 +402,6 @@ function toggleExpandAll() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="px-2 py-2 border-b border-slate-700">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
@@ -231,25 +421,88 @@ function toggleExpandAll() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div v-if="searchQuery && rootNodes.length === 0" class="text-center py-8 text-slate-500 text-sm">
|
||||
<div
|
||||
ref="treeViewportRef"
|
||||
class="flex-1 overflow-y-auto p-2"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div v-if="searchQuery && visibleTreeNodes.length === 0" class="text-center py-8 text-slate-500 text-sm">
|
||||
未找到匹配的会话
|
||||
</div>
|
||||
<SessionTreeNode
|
||||
v-for="node in rootNodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:level="0"
|
||||
@click="handleNodeClick"
|
||||
@contextmenu="handleNodeContextMenu"
|
||||
/>
|
||||
|
||||
<div v-else class="relative" :style="{ height: `${totalHeight}px` }">
|
||||
<div :style="{ transform: `translateY(${topPadding}px)` }">
|
||||
<div
|
||||
v-for="item in virtualNodes"
|
||||
:key="item.node.id"
|
||||
draggable="true"
|
||||
class="w-full h-[34px] flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-slate-800 transition-colors relative"
|
||||
:class="{
|
||||
'bg-slate-800 text-cyan-300': isSelected(item.node.id),
|
||||
'text-slate-300': !isSelected(item.node.id),
|
||||
'opacity-50': isDragging(item.node.id),
|
||||
'ring-2 ring-cyan-500': isDropTarget(item.node.id) && dropPosition(item.node.id) === 'inside',
|
||||
}"
|
||||
:style="{ paddingLeft: `${item.level * 16 + 8}px` }"
|
||||
@click="handleNodeClick(item.node.id)"
|
||||
@dblclick="handleNodeDoubleClick(item.node.id)"
|
||||
@contextmenu="(event) => handleNodeContextMenu(item.node.id, event)"
|
||||
@dragstart="(event) => handleDragStart(event, item.node)"
|
||||
@dragover="(event) => handleDragOver(event, item.node)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<div
|
||||
v-if="isDropTarget(item.node.id) && dropPosition(item.node.id) === 'before'"
|
||||
class="absolute top-0 left-0 right-0 h-0.5 bg-cyan-500"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isDropTarget(item.node.id) && dropPosition(item.node.id) === 'after'"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-500"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="item.node.type === 'folder'"
|
||||
class="w-4 h-4 flex items-center justify-center"
|
||||
@click="(event) => handleFolderToggle(item.node.id, event)"
|
||||
>
|
||||
<ChevronDown v-if="item.node.expanded" class="w-3.5 h-3.5" />
|
||||
<ChevronRight v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div v-else class="w-4" />
|
||||
|
||||
<component
|
||||
:is="item.node.type === 'folder' ? (item.node.expanded ? FolderOpen : Folder) : Server"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
:class="{
|
||||
'text-amber-400': item.node.type === 'folder',
|
||||
'text-cyan-400': item.node.type === 'connection',
|
||||
}"
|
||||
/>
|
||||
|
||||
<span class="text-sm truncate flex-1">{{ item.node.name }}</span>
|
||||
|
||||
<span
|
||||
v-if="isSearchMatch(item.node.id)"
|
||||
class="w-1.5 h-1.5 rounded-full bg-cyan-400 flex-shrink-0"
|
||||
title="搜索匹配"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-else-if="item.node.type === 'connection'"
|
||||
class="w-2 h-2 rounded-full bg-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Folder Dialog -->
|
||||
<div
|
||||
v-if="showNewFolderDialog"
|
||||
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">
|
||||
<h3 class="text-sm font-medium text-slate-100 mb-3">新建文件夹</h3>
|
||||
@@ -268,7 +521,7 @@ function toggleExpandAll() {
|
||||
创建
|
||||
</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"
|
||||
>
|
||||
取消
|
||||
@@ -277,7 +530,6 @@ function toggleExpandAll() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rename Dialog -->
|
||||
<div
|
||||
v-if="showRenameDialog"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
@@ -309,7 +561,6 @@ function toggleExpandAll() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<ContextMenu
|
||||
:show="showContextMenu"
|
||||
:x="contextMenuX"
|
||||
|
||||
@@ -90,7 +90,7 @@ function handleDragEnd() {
|
||||
<div>
|
||||
<div
|
||||
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="{
|
||||
'bg-slate-800 text-cyan-300': isSelected,
|
||||
'text-slate-300': !isSelected,
|
||||
|
||||
@@ -1,9 +1,94 @@
|
||||
<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>
|
||||
|
||||
<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-2">
|
||||
<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 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>
|
||||
@@ -25,6 +129,32 @@ import { Settings, HelpCircle, Bell, MonitorCog } from 'lucide-vue-next'
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="workspaceTabs.length > 0" class="flex items-center gap-1 overflow-x-auto scrollbar-thin">
|
||||
<div
|
||||
v-for="tab in workspaceTabs"
|
||||
:key="tab.connectionId"
|
||||
class="group shrink-0 max-w-[280px] min-h-[32px] flex items-center gap-1 rounded border px-1.5 text-xs transition-colors cursor-pointer"
|
||||
:class="tab.active
|
||||
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-200'
|
||||
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600 hover:text-slate-100'"
|
||||
@click="activateTab(tab.connectionId)"
|
||||
@contextmenu="(e) => openTabContextMenu(tab.connectionId, e)"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full" :class="tab.active ? 'bg-cyan-400' : 'bg-slate-500'" />
|
||||
<span class="truncate max-w-[220px]">{{ tab.title }}</span>
|
||||
<button
|
||||
class="p-0.5 rounded text-slate-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-slate-700 hover:text-slate-200"
|
||||
@click="(e) => closeTab(tab.connectionId, e)"
|
||||
:aria-label="`关闭会话 ${tab.title}`"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-500">未打开会话</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="p-2 rounded text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
|
||||
@@ -46,4 +176,12 @@ import { Settings, HelpCircle, Bell, MonitorCog } from 'lucide-vue-next'
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
:show="showTabContextMenu"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
:items="tabContextMenuItems"
|
||||
@close="showTabContextMenu = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useWorkspaceStore } from '../stores/workspace'
|
||||
import type { WorkspacePanelState } from '../types/workspace'
|
||||
import SplitPane from './SplitPane.vue'
|
||||
import TerminalWidget from './TerminalWidget.vue'
|
||||
import SftpPanel from './SftpPanel.vue'
|
||||
@@ -9,45 +10,59 @@ import { Server } from 'lucide-vue-next'
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
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) {
|
||||
if (activeConnectionId.value) {
|
||||
workspaceStore.updateSplitRatio(activeConnectionId.value, ratio)
|
||||
}
|
||||
function isPanelActive(connectionId: number) {
|
||||
return activeConnectionId.value === connectionId
|
||||
}
|
||||
|
||||
function handleRatioChange(panel: WorkspacePanelState, ratio: number) {
|
||||
workspaceStore.updateSplitRatio(panel.connectionId, ratio)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full bg-slate-950">
|
||||
<div v-if="!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">
|
||||
<Server class="w-16 h-16 mx-auto mb-4 text-slate-600" />
|
||||
<p class="text-lg">请从左侧会话树选择一个连接</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SplitPane
|
||||
v-else-if="activePanel"
|
||||
direction="vertical"
|
||||
:initial-ratio="activePanel.splitRatio"
|
||||
@ratio-change="handleRatioChange"
|
||||
>
|
||||
<template #first>
|
||||
<TerminalWidget
|
||||
v-if="activePanel.terminalVisible"
|
||||
:connection-id="activeConnectionId"
|
||||
:active="true"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="h-full">
|
||||
<div
|
||||
v-for="panel in openPanels"
|
||||
:key="panel.connectionId"
|
||||
v-show="isPanelActive(panel.connectionId)"
|
||||
class="h-full"
|
||||
>
|
||||
<SplitPane
|
||||
direction="vertical"
|
||||
:initial-ratio="panel.splitRatio"
|
||||
@ratio-change="(ratio) => handleRatioChange(panel, ratio)"
|
||||
>
|
||||
<template #first>
|
||||
<TerminalWidget
|
||||
v-if="panel.terminalVisible"
|
||||
:connection-id="panel.connectionId"
|
||||
:active="isPanelActive(panel.connectionId)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #second>
|
||||
<SftpPanel
|
||||
v-if="activePanel.sftpVisible"
|
||||
:connection-id="activeConnectionId"
|
||||
:active="true"
|
||||
/>
|
||||
</template>
|
||||
</SplitPane>
|
||||
<template #second>
|
||||
<SftpPanel
|
||||
v-if="panel.sftpVisible"
|
||||
:connection-id="panel.connectionId"
|
||||
:active="isPanelActive(panel.connectionId)"
|
||||
/>
|
||||
</template>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,8 @@ export function useConnectionSync() {
|
||||
watch(
|
||||
() => connectionsStore.connections.length,
|
||||
(newLength, oldLength) => {
|
||||
if (!treeStore.hydrated) return
|
||||
|
||||
if (newLength > oldLength) {
|
||||
// New connection added
|
||||
treeStore.syncNewConnections()
|
||||
@@ -24,6 +26,7 @@ export function useConnectionSync() {
|
||||
watch(
|
||||
() => connectionsStore.connections.map(c => ({ id: c.id, name: c.name })),
|
||||
(newConnections, oldConnections) => {
|
||||
if (!treeStore.hydrated) return
|
||||
if (!oldConnections) return
|
||||
|
||||
newConnections.forEach((newConn, index) => {
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import type { SessionTreeNode } from '../types/sessionTree'
|
||||
|
||||
export function useTreeSearch(nodesRef: Ref<SessionTreeNode[]>) {
|
||||
const searchQuery = ref('')
|
||||
const debouncedQuery = refDebounced(searchQuery, 150)
|
||||
const searchResults = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
const nodes = nodesRef.value
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
const query = debouncedQuery.value.trim().toLowerCase()
|
||||
if (!query) {
|
||||
searchResults.value = new Set()
|
||||
return nodes
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
const results = new Set<string>()
|
||||
const matchedNodes = new Set<string>()
|
||||
const nodeById = new Map(nodes.map(node => [node.id, node]))
|
||||
|
||||
// Find all matching nodes
|
||||
nodes.forEach(node => {
|
||||
@@ -23,10 +27,13 @@ export function useTreeSearch(nodesRef: Ref<SessionTreeNode[]>) {
|
||||
results.add(node.id)
|
||||
|
||||
// Add all ancestors to results
|
||||
let current = node
|
||||
let current: SessionTreeNode | undefined = node
|
||||
while (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">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen, MonitorCog, Clock3 } from 'lucide-vue-next'
|
||||
import { ArrowLeftRight, Blocks, Clock3, LogOut } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const sftpTabsStore = useSftpTabsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
const now = ref(new Date())
|
||||
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(() => {
|
||||
return now.value.toLocaleTimeString([], {
|
||||
hour: '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() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectionsStore.fetchConnections().catch(() => {})
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
@@ -103,132 +37,32 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-slate-950 text-slate-100">
|
||||
<button
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
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>
|
||||
<p class="text-xs text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="w-64 border-r border-slate-700/80 flex flex-col bg-slate-900/95">
|
||||
<div class="px-4 py-4 border-b border-slate-700/80">
|
||||
<h1 class="text-sm font-semibold tracking-wide text-slate-100">SSH Manager</h1>
|
||||
<p class="text-xs text-slate-400 mt-1">{{ authStore.displayName || authStore.username }}</p>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 px-3 py-3 overflow-y-auto space-y-4 pt-16 lg:pt-3">
|
||||
<div>
|
||||
<p class="px-2 text-[11px] uppercase tracking-wider text-slate-500 mb-2">工作区</p>
|
||||
<div class="space-y-1">
|
||||
<RouterLink
|
||||
to="/connections"
|
||||
@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="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path === '/connections' }"
|
||||
aria-label="连接列表"
|
||||
>
|
||||
<Server class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Connections</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
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="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path === '/transfers' }"
|
||||
aria-label="传输"
|
||||
>
|
||||
<ArrowLeftRight class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Transfers</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex-1 px-3 py-3 overflow-y-auto space-y-1">
|
||||
<RouterLink
|
||||
to="/moba"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path.startsWith('/moba') }"
|
||||
aria-label="主页"
|
||||
>
|
||||
<Blocks class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>主页(Moba)</span>
|
||||
</RouterLink>
|
||||
|
||||
<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>
|
||||
<RouterLink
|
||||
to="/transfers"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path.startsWith('/transfers') }"
|
||||
aria-label="传输队列"
|
||||
>
|
||||
<ArrowLeftRight class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Transfers</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="p-3 border-t border-slate-700/80">
|
||||
@@ -243,22 +77,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</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">
|
||||
<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">{{ 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>
|
||||
<h2 class="text-sm md:text-base font-medium text-slate-100 truncate">Transfer Queue</h2>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-400">
|
||||
<Clock3 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span class="tabular-nums">{{ nowText }}</span>
|
||||
@@ -266,17 +87,7 @@ onUnmounted(() => {
|
||||
</header>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
|
||||
<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>
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,113 @@
|
||||
<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 { useWorkspaceStore } from '../stores/workspace'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useConnectionSync } from '../composables/useConnectionSync'
|
||||
import type { ConnectionCreateRequest } from '../api/connections'
|
||||
import TopToolbar from '../components/TopToolbar.vue'
|
||||
import SessionTree from '../components/SessionTree.vue'
|
||||
import WorkspacePanel from '../components/WorkspacePanel.vue'
|
||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||
import MigrationPrompt from '../components/MigrationPrompt.vue'
|
||||
import TransfersView from '../views/TransfersView.vue'
|
||||
|
||||
const treeStore = useSessionTreeStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const showMigrationPrompt = ref(false)
|
||||
const transfersModalOpen = computed(() => workspaceStore.transfersModalOpen)
|
||||
const sessionModalOpen = computed(() => workspaceStore.sessionModalOpen)
|
||||
const sessionModalMode = computed(() => workspaceStore.sessionModalMode)
|
||||
const currentEditingConnection = computed(() => {
|
||||
if (sessionModalMode.value !== 'edit' || workspaceStore.editingConnectionId == null) {
|
||||
return null
|
||||
}
|
||||
return connectionsStore.getConnection(workspaceStore.editingConnectionId) || null
|
||||
})
|
||||
|
||||
// Enable bidirectional sync
|
||||
useConnectionSync()
|
||||
|
||||
onMounted(async () => {
|
||||
treeStore.restore()
|
||||
workspaceStore.restore()
|
||||
watch(
|
||||
() => route.query.tool,
|
||||
(tool) => {
|
||||
const shouldOpen = tool === 'transfers'
|
||||
if (shouldOpen && !workspaceStore.transfersModalOpen) {
|
||||
workspaceStore.openTransfersModal()
|
||||
} else if (!shouldOpen && workspaceStore.transfersModalOpen) {
|
||||
workspaceStore.closeTransfersModal()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(transfersModalOpen, (isOpen) => {
|
||||
if (isOpen && route.query.tool !== 'transfers') {
|
||||
router.replace({
|
||||
path: '/moba',
|
||||
query: {
|
||||
...route.query,
|
||||
tool: 'transfers',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOpen && route.query.tool === 'transfers') {
|
||||
const query = {
|
||||
...route.query,
|
||||
}
|
||||
delete query.tool
|
||||
router.replace({
|
||||
path: '/moba',
|
||||
query,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await treeStore.restore()
|
||||
workspaceStore.restore()
|
||||
if (route.query.tool === 'transfers') {
|
||||
workspaceStore.openTransfersModal()
|
||||
}
|
||||
|
||||
let connectionsLoaded = false
|
||||
try {
|
||||
await connectionsStore.fetchConnections()
|
||||
connectionsLoaded = true
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch connections on startup:', error)
|
||||
}
|
||||
|
||||
// Check if migration is needed
|
||||
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
|
||||
|
||||
if (!migrationDismissed && hasOldData && !hasNewData) {
|
||||
showMigrationPrompt.value = true
|
||||
} else if (treeStore.nodes.length === 0) {
|
||||
} else if (treeStore.nodes.length === 0 && connectionsLoaded) {
|
||||
await treeStore.initFromConnections()
|
||||
} else {
|
||||
// Sync with connections store
|
||||
} else if (connectionsLoaded) {
|
||||
treeStore.syncNewConnections()
|
||||
treeStore.syncDeletedConnections()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
async function handleMigrate() {
|
||||
await treeStore.initFromConnections()
|
||||
showMigrationPrompt.value = false
|
||||
@@ -50,10 +120,45 @@ function handleDismiss() {
|
||||
function handleClose() {
|
||||
showMigrationPrompt.value = false
|
||||
}
|
||||
|
||||
function closeTransfersModal() {
|
||||
workspaceStore.closeTransfersModal()
|
||||
}
|
||||
|
||||
function closeSessionModal() {
|
||||
workspaceStore.closeSessionModal()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && workspaceStore.transfersModalOpen) {
|
||||
closeTransfersModal()
|
||||
}
|
||||
if (event.key === 'Escape' && workspaceStore.sessionModalOpen) {
|
||||
closeSessionModal()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionSubmit(data: ConnectionCreateRequest) {
|
||||
if (sessionModalMode.value === 'edit') {
|
||||
const editingConnectionId = workspaceStore.editingConnectionId
|
||||
if (editingConnectionId == null) return
|
||||
await connectionsStore.updateConnection(editingConnectionId, data)
|
||||
} else {
|
||||
await connectionsStore.createConnection(data)
|
||||
// Tree node insertion is handled by useConnectionSync -> syncNewConnections.
|
||||
// Avoid manual insertion here to prevent duplicate nodes.
|
||||
}
|
||||
closeSessionModal()
|
||||
}
|
||||
|
||||
function onTransfersModalBackdropClick(event: MouseEvent) {
|
||||
if (event.target !== event.currentTarget) return
|
||||
closeTransfersModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-slate-950 text-slate-100">
|
||||
<div class="h-screen flex flex-col bg-slate-950 text-slate-100 overflow-hidden">
|
||||
<TopToolbar />
|
||||
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
@@ -66,6 +171,45 @@ function handleClose() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="transfersModalOpen"
|
||||
class="fixed inset-0 z-40 bg-slate-950/65 p-4 md:p-6 flex items-center justify-center"
|
||||
@click="onTransfersModalBackdropClick"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[1240px] h-[88vh] rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl flex flex-col overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Transfers 弹窗"
|
||||
>
|
||||
<header class="h-12 px-4 border-b border-slate-800 flex items-center justify-between shrink-0">
|
||||
<div class="inline-flex items-center gap-2 text-sm text-slate-200">
|
||||
<ArrowLeftRight class="w-4 h-4 text-cyan-300" />
|
||||
<span class="font-medium">Transfers</span>
|
||||
</div>
|
||||
<button
|
||||
@click="closeTransfersModal"
|
||||
class="w-9 h-9 rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors cursor-pointer"
|
||||
aria-label="关闭 Transfers 面板"
|
||||
>
|
||||
<X class="w-4 h-4 mx-auto" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<TransfersView embedded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionModalOpen" class="z-50">
|
||||
<ConnectionForm
|
||||
:connection="currentEditingConnection"
|
||||
:on-save="handleSessionSubmit"
|
||||
@close="closeSessionModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MigrationPrompt
|
||||
:show="showMigrationPrompt"
|
||||
@migrate="handleMigrate"
|
||||
|
||||
@@ -9,6 +9,10 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/moba',
|
||||
},
|
||||
{
|
||||
path: '/moba',
|
||||
name: 'MobaLayout',
|
||||
@@ -16,42 +20,8 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
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 },
|
||||
},
|
||||
],
|
||||
path: '/transfers',
|
||||
redirect: '/moba?tool=transfers',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -64,7 +34,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.public) {
|
||||
if (authStore.isAuthenticated && to.path === '/login') {
|
||||
next('/connections')
|
||||
next('/moba')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import * as connectionsApi from '../api/connections'
|
||||
|
||||
export const useConnectionsStore = defineStore('connections', () => {
|
||||
const connections = ref<Connection[]>([])
|
||||
const hasFetched = ref(false)
|
||||
|
||||
async function fetchConnections() {
|
||||
const res = await connectionsApi.listConnections()
|
||||
connections.value = res.data
|
||||
hasFetched.value = true
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ export const useConnectionsStore = defineStore('connections', () => {
|
||||
|
||||
return {
|
||||
connections,
|
||||
hasFetched,
|
||||
fetchConnections,
|
||||
createConnection,
|
||||
updateConnection,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { SessionTreeNode, SessionTreeState } from '../types/sessionTree'
|
||||
import { getSessionTree, saveSessionTree } from '../api/sessionTree'
|
||||
import { useConnectionsStore } from './connections'
|
||||
|
||||
const STORAGE_KEY = 'ssh-manager.session-tree'
|
||||
let persistTimer: number | null = null
|
||||
|
||||
export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
state: (): SessionTreeState => ({
|
||||
nodes: [],
|
||||
selectedNodeId: null,
|
||||
hydrated: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -54,6 +57,18 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
},
|
||||
|
||||
createConnectionNode(connectionId: number, name: string, parentId: string | null = null) {
|
||||
const existingNode = this.nodes.find(
|
||||
n => n.type === 'connection' && n.connectionId === connectionId
|
||||
)
|
||||
if (existingNode) {
|
||||
if (existingNode.name !== name) {
|
||||
existingNode.name = name
|
||||
existingNode.updatedAt = Date.now()
|
||||
this.persist()
|
||||
}
|
||||
return existingNode
|
||||
}
|
||||
|
||||
const node: SessionTreeNode = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'connection',
|
||||
@@ -82,13 +97,16 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
},
|
||||
|
||||
deleteNode(nodeId: string) {
|
||||
const children = this.getChildren(nodeId)
|
||||
children.forEach(child => this.deleteNode(child.id))
|
||||
|
||||
this.nodes = this.nodes.filter(n => n.id !== nodeId)
|
||||
this.deleteNodeInternal(nodeId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
deleteNodeInternal(nodeId: string) {
|
||||
const children = this.getChildren(nodeId)
|
||||
children.forEach(child => this.deleteNodeInternal(child.id))
|
||||
this.nodes = this.nodes.filter(n => n.id !== nodeId)
|
||||
},
|
||||
|
||||
renameNode(nodeId: string, newName: string) {
|
||||
const node = this.nodes.find(n => n.id === nodeId)
|
||||
if (node) {
|
||||
@@ -128,21 +146,68 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
},
|
||||
|
||||
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() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
this.$patch(data)
|
||||
} catch (e) {
|
||||
console.error('Failed to restore session tree:', e)
|
||||
}
|
||||
persistLocal() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
nodes: this.nodes,
|
||||
selectedNodeId: this.selectedNodeId,
|
||||
}))
|
||||
},
|
||||
|
||||
async persistRemote() {
|
||||
try {
|
||||
await saveSessionTree({ nodes: this.nodes })
|
||||
} catch (error) {
|
||||
console.error('Failed to persist session tree to server:', error)
|
||||
}
|
||||
},
|
||||
|
||||
restoreLocal() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
if (Array.isArray(data.nodes)) {
|
||||
this.nodes = data.nodes
|
||||
}
|
||||
if (typeof data.selectedNodeId === 'string' || data.selectedNodeId === null) {
|
||||
this.selectedNodeId = data.selectedNodeId
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore local session tree:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async restore() {
|
||||
if (this.hydrated) return
|
||||
|
||||
this.restoreLocal()
|
||||
|
||||
try {
|
||||
const response = await getSessionTree()
|
||||
if (Array.isArray(response.data?.nodes)) {
|
||||
this.nodes = response.data.nodes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore session tree from server:', error)
|
||||
}
|
||||
|
||||
this.dedupeConnectionNodes()
|
||||
this.hydrated = true
|
||||
this.persistLocal()
|
||||
},
|
||||
|
||||
async initFromConnections() {
|
||||
const connectionsStore = useConnectionsStore()
|
||||
await connectionsStore.fetchConnections()
|
||||
@@ -170,7 +235,6 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
)
|
||||
|
||||
if (newConnections.length > 0) {
|
||||
// Add to root or default folder
|
||||
let targetParentId: string | null = null
|
||||
const defaultFolder = this.nodes.find(
|
||||
n => n.type === 'folder' && n.name === '我的连接'
|
||||
@@ -188,6 +252,8 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
// Sync with connections store - remove deleted connections
|
||||
syncDeletedConnections() {
|
||||
const connectionsStore = useConnectionsStore()
|
||||
if (!connectionsStore.hasFetched) return
|
||||
|
||||
const validConnectionIds = new Set(
|
||||
connectionsStore.connections.map(c => c.id)
|
||||
)
|
||||
@@ -198,9 +264,12 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
!validConnectionIds.has(n.connectionId)
|
||||
)
|
||||
|
||||
if (nodesToDelete.length === 0) return
|
||||
|
||||
nodesToDelete.forEach(node => {
|
||||
this.deleteNode(node.id)
|
||||
this.deleteNodeInternal(node.id)
|
||||
})
|
||||
this.persist()
|
||||
},
|
||||
|
||||
// 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() {
|
||||
this.nodes.forEach(node => {
|
||||
if (node.type === 'folder') {
|
||||
@@ -223,7 +307,6 @@ export const useSessionTreeStore = defineStore('sessionTree', {
|
||||
this.persist()
|
||||
},
|
||||
|
||||
// Collapse all folders
|
||||
collapseAll() {
|
||||
this.nodes.forEach(node => {
|
||||
if (node.type === 'folder') {
|
||||
|
||||
@@ -7,6 +7,11 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
state: (): WorkspaceState => ({
|
||||
activeConnectionId: null,
|
||||
panels: {},
|
||||
panelOrder: [],
|
||||
transfersModalOpen: false,
|
||||
sessionModalOpen: false,
|
||||
sessionModalMode: 'create',
|
||||
editingConnectionId: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -29,6 +34,9 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
selectedFiles: [],
|
||||
lastActiveAt: Date.now(),
|
||||
}
|
||||
this.panelOrder.push(connectionId)
|
||||
} else if (!this.panelOrder.includes(connectionId)) {
|
||||
this.panelOrder.push(connectionId)
|
||||
}
|
||||
|
||||
this.activeConnectionId = connectionId
|
||||
@@ -37,17 +45,94 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
},
|
||||
|
||||
closePanel(connectionId: number) {
|
||||
const closedIndex = this.panelOrder.indexOf(connectionId)
|
||||
delete this.panels[connectionId]
|
||||
this.panelOrder = this.panelOrder.filter((id) => id !== connectionId)
|
||||
|
||||
if (this.activeConnectionId === connectionId) {
|
||||
const sorted = Object.values(this.panels)
|
||||
.sort((a, b) => b.lastActiveAt - a.lastActiveAt)
|
||||
this.activeConnectionId = sorted[0]?.connectionId || null
|
||||
const nextId = closedIndex >= 0
|
||||
? this.panelOrder[closedIndex] ?? this.panelOrder[closedIndex - 1] ?? null
|
||||
: this.panelOrder[0] ?? null
|
||||
this.activeConnectionId = nextId
|
||||
}
|
||||
|
||||
this.persist()
|
||||
},
|
||||
|
||||
closeOtherPanels(connectionId: number) {
|
||||
if (!this.panels[connectionId]) return
|
||||
|
||||
this.panelOrder.forEach((id) => {
|
||||
if (id !== connectionId) {
|
||||
delete this.panels[id]
|
||||
}
|
||||
})
|
||||
|
||||
this.panelOrder = [connectionId]
|
||||
this.activeConnectionId = connectionId
|
||||
this.persist()
|
||||
},
|
||||
|
||||
closePanelsToRight(connectionId: number) {
|
||||
const targetIndex = this.panelOrder.indexOf(connectionId)
|
||||
if (targetIndex < 0) return
|
||||
|
||||
const rightSideIds = this.panelOrder.slice(targetIndex + 1)
|
||||
if (rightSideIds.length === 0) return
|
||||
|
||||
rightSideIds.forEach((id) => {
|
||||
delete this.panels[id]
|
||||
})
|
||||
|
||||
this.panelOrder = this.panelOrder.slice(0, targetIndex + 1)
|
||||
if (this.activeConnectionId && !this.panels[this.activeConnectionId]) {
|
||||
this.activeConnectionId = connectionId
|
||||
}
|
||||
|
||||
this.persist()
|
||||
},
|
||||
|
||||
closeAllPanels() {
|
||||
this.activeConnectionId = null
|
||||
this.panels = {}
|
||||
this.panelOrder = []
|
||||
this.persist()
|
||||
},
|
||||
|
||||
openTransfersModal() {
|
||||
this.transfersModalOpen = true
|
||||
},
|
||||
|
||||
closeTransfersModal() {
|
||||
this.transfersModalOpen = false
|
||||
},
|
||||
|
||||
toggleTransfersModal() {
|
||||
this.transfersModalOpen = !this.transfersModalOpen
|
||||
},
|
||||
|
||||
openCreateSessionModal() {
|
||||
this.sessionModalMode = 'create'
|
||||
this.editingConnectionId = null
|
||||
this.sessionModalOpen = true
|
||||
},
|
||||
|
||||
openEditSessionModal(connectionId: number) {
|
||||
this.sessionModalMode = 'edit'
|
||||
this.editingConnectionId = connectionId
|
||||
this.sessionModalOpen = true
|
||||
},
|
||||
|
||||
closeSessionModal() {
|
||||
this.sessionModalOpen = false
|
||||
this.sessionModalMode = 'create'
|
||||
this.editingConnectionId = null
|
||||
},
|
||||
|
||||
closeCreateSessionModal() {
|
||||
this.closeSessionModal()
|
||||
},
|
||||
|
||||
updateSplitRatio(connectionId: number, ratio: number) {
|
||||
const panel = this.panels[connectionId]
|
||||
if (panel) {
|
||||
@@ -98,6 +183,48 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
this.$patch(data)
|
||||
const panelIds = Object.keys(this.panels).map((id) => Number(id))
|
||||
if (!Array.isArray(this.panelOrder)) {
|
||||
this.panelOrder = []
|
||||
}
|
||||
|
||||
if (typeof this.transfersModalOpen !== 'boolean') {
|
||||
this.transfersModalOpen = typeof data.transfersDrawerOpen === 'boolean'
|
||||
? data.transfersDrawerOpen
|
||||
: false
|
||||
}
|
||||
if (typeof this.sessionModalOpen !== 'boolean') {
|
||||
this.sessionModalOpen = typeof data.createSessionModalOpen === 'boolean'
|
||||
? data.createSessionModalOpen
|
||||
: false
|
||||
}
|
||||
if (this.sessionModalMode !== 'create' && this.sessionModalMode !== 'edit') {
|
||||
this.sessionModalMode = 'create'
|
||||
}
|
||||
if (typeof this.editingConnectionId !== 'number') {
|
||||
this.editingConnectionId = null
|
||||
}
|
||||
this.transfersModalOpen = false
|
||||
this.sessionModalOpen = false
|
||||
this.sessionModalMode = 'create'
|
||||
this.editingConnectionId = null
|
||||
|
||||
this.panelOrder = this.panelOrder.filter((id) => this.panels[id])
|
||||
panelIds.forEach((id) => {
|
||||
if (!this.panelOrder.includes(id)) {
|
||||
this.panelOrder.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
if (this.activeConnectionId && !this.panels[this.activeConnectionId]) {
|
||||
this.activeConnectionId = this.panelOrder[0] ?? null
|
||||
}
|
||||
|
||||
if (!this.activeConnectionId && this.panelOrder.length > 0) {
|
||||
this.activeConnectionId = this.panelOrder[0] ?? null
|
||||
}
|
||||
|
||||
this.persist()
|
||||
} catch (e) {
|
||||
console.error('Failed to restore workspace:', e)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SessionTreeNodeType = 'folder' | 'connection'
|
||||
import type { SessionTreeNodeType } from '../api/sessionTree'
|
||||
|
||||
export interface SessionTreeNode {
|
||||
id: string
|
||||
@@ -15,4 +15,5 @@ export interface SessionTreeNode {
|
||||
export interface SessionTreeState {
|
||||
nodes: SessionTreeNode[]
|
||||
selectedNodeId: string | null
|
||||
hydrated: boolean
|
||||
}
|
||||
|
||||
@@ -11,4 +11,9 @@ export interface WorkspacePanelState {
|
||||
export interface WorkspaceState {
|
||||
activeConnectionId: number | null
|
||||
panels: Record<number, WorkspacePanelState>
|
||||
panelOrder: number[]
|
||||
transfersModalOpen: boolean
|
||||
sessionModalOpen: boolean
|
||||
sessionModalMode: 'create' | 'edit'
|
||||
editingConnectionId: number | null
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ async function handleSubmit() {
|
||||
try {
|
||||
const res = await authApi.login({ username: username.value, password: password.value })
|
||||
authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
|
||||
router.push('/connections')
|
||||
router.push('/moba')
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
||||
error.value = err.response?.data?.message || (err.response?.status === 401 ? '用户名或密码错误' : '登录失败')
|
||||
|
||||
@@ -21,6 +21,11 @@ type Tab = 'local' | 'remote'
|
||||
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const transfersStore = useTransfersStore()
|
||||
const props = withDefaults(defineProps<{
|
||||
embedded?: boolean
|
||||
}>(), {
|
||||
embedded: false,
|
||||
})
|
||||
|
||||
const tab = ref<Tab>('local')
|
||||
|
||||
@@ -246,21 +251,23 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 lg:p-8">
|
||||
<div :class="props.embedded ? 'p-4 lg:p-5' : 'p-6 lg:p-8'">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-slate-50">Transfers</h1>
|
||||
<h1 class="font-semibold tracking-tight text-slate-50" :class="props.embedded ? 'text-xl' : 'text-2xl'">Transfers</h1>
|
||||
<p class="text-sm text-slate-400">本机 -> 多台 / 其他机器 -> 多台</p>
|
||||
</div>
|
||||
<button
|
||||
@click="transfersStore.clearRuns"
|
||||
class="min-h-[44px] inline-flex items-center gap-2 px-3 rounded-lg border border-slate-700 bg-slate-900/40 text-slate-200 hover:bg-slate-800/60 transition-colors cursor-pointer"
|
||||
aria-label="清空队列"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||
清空队列
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="transfersStore.clearRuns"
|
||||
class="min-h-[44px] inline-flex items-center gap-2 px-3 rounded-lg border border-slate-700 bg-slate-900/40 text-slate-200 hover:bg-slate-800/60 transition-colors cursor-pointer"
|
||||
aria-label="清空队列"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||
清空队列
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
@@ -303,7 +310,7 @@ onMounted(async () => {
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[1fr_460px]">
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur p-5">
|
||||
<div v-if="connections.length === 0" class="text-slate-300">
|
||||
<p class="text-sm">暂无连接。请先在 Connections 里添加连接。</p>
|
||||
<p class="text-sm">暂无连接。请先在 Moba 页面里添加连接。</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
|
||||
Reference in New Issue
Block a user