feat: unify moba workspace and persist session tree layout

This commit is contained in:
liumangmang
2026-04-10 11:04:21 +08:00
parent bba36a2e12
commit f606d20000
27 changed files with 1383 additions and 426 deletions

View File

@@ -0,0 +1,45 @@
package com.sshmanager.controller;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.SessionTreeLayoutService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/session-tree")
public class SessionTreeLayoutController {
private final SessionTreeLayoutService sessionTreeLayoutService;
private final UserRepository userRepository;
public SessionTreeLayoutController(SessionTreeLayoutService sessionTreeLayoutService,
UserRepository userRepository) {
this.sessionTreeLayoutService = sessionTreeLayoutService;
this.userRepository = userRepository;
}
@GetMapping
public ResponseEntity<SessionTreeLayoutDto> getLayout(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(sessionTreeLayoutService.getLayout(userId));
}
@PutMapping
public ResponseEntity<SessionTreeLayoutDto> saveLayout(@RequestBody SessionTreeLayoutDto request,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(sessionTreeLayoutService.saveLayout(userId, request));
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
return user.getId();
}
}

View File

@@ -0,0 +1,16 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionTreeLayoutDto {
private List<SessionTreeNodeDto> nodes = new ArrayList<SessionTreeNodeDto>();
}

View File

@@ -0,0 +1,21 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionTreeNodeDto {
private String id;
private String type;
private String name;
private String parentId;
private Integer order;
private Long connectionId;
private Boolean expanded;
private Long createdAt;
private Long updatedAt;
}

View File

@@ -0,0 +1,34 @@
package com.sshmanager.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "session_tree_layouts")
public class SessionTreeLayout {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private Long userId;
@Column(nullable = false, columnDefinition = "CLOB")
private String layoutJson;
@Column(nullable = false)
private Instant updatedAt = Instant.now();
}

View File

@@ -0,0 +1,11 @@
package com.sshmanager.repository;
import com.sshmanager.entity.SessionTreeLayout;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SessionTreeLayoutRepository extends JpaRepository<SessionTreeLayout, Long> {
Optional<SessionTreeLayout> findByUserId(Long userId);
}

View File

@@ -100,15 +100,17 @@ public class ConnectionService {
return ConnectionDto.fromEntity(conn); return ConnectionDto.fromEntity(conn);
} }
@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) {
if (!conn.getUserId().equals(userId)) { return;
throw new RuntimeException("Access denied"); }
} if (!conn.getUserId().equals(userId)) {
connectionRepository.delete(conn); throw new RuntimeException("Access denied");
} }
connectionRepository.delete(conn);
}
public Connection getConnectionForSsh(Long id, Long userId) { public Connection getConnectionForSsh(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow( Connection conn = connectionRepository.findById(id).orElseThrow(

View File

@@ -0,0 +1,69 @@
package com.sshmanager.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.entity.SessionTreeLayout;
import com.sshmanager.repository.SessionTreeLayoutRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
@Service
public class SessionTreeLayoutService {
private final SessionTreeLayoutRepository sessionTreeLayoutRepository;
private final ObjectMapper objectMapper;
public SessionTreeLayoutService(SessionTreeLayoutRepository sessionTreeLayoutRepository,
ObjectMapper objectMapper) {
this.sessionTreeLayoutRepository = sessionTreeLayoutRepository;
this.objectMapper = objectMapper;
}
@Transactional(readOnly = true)
public SessionTreeLayoutDto getLayout(Long userId) {
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElse(null);
if (layout == null || layout.getLayoutJson() == null || layout.getLayoutJson().trim().isEmpty()) {
return createEmptyLayout();
}
try {
SessionTreeLayoutDto parsed = objectMapper.readValue(layout.getLayoutJson(), SessionTreeLayoutDto.class);
if (parsed.getNodes() == null) {
parsed.setNodes(new ArrayList<>());
}
return parsed;
} catch (Exception e) {
return createEmptyLayout();
}
}
@Transactional
public SessionTreeLayoutDto saveLayout(Long userId, SessionTreeLayoutDto request) {
SessionTreeLayoutDto payload = request == null ? createEmptyLayout() : request;
if (payload.getNodes() == null) {
payload.setNodes(new ArrayList<>());
}
final String layoutJson;
try {
layoutJson = objectMapper.writeValueAsString(payload);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize session tree layout", e);
}
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElseGet(SessionTreeLayout::new);
layout.setUserId(userId);
layout.setLayoutJson(layoutJson);
layout.setUpdatedAt(Instant.now());
sessionTreeLayoutRepository.save(layout);
return payload;
}
private SessionTreeLayoutDto createEmptyLayout() {
return new SessionTreeLayoutDto(new ArrayList<>());
}
}

View File

@@ -0,0 +1,70 @@
package com.sshmanager.controller;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.SessionTreeLayoutService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SessionTreeLayoutControllerTest {
@Mock
private SessionTreeLayoutService sessionTreeLayoutService;
@Mock
private UserRepository userRepository;
@InjectMocks
private SessionTreeLayoutController sessionTreeLayoutController;
private Authentication authentication;
@BeforeEach
void setUp() {
authentication = mock(Authentication.class);
when(authentication.getName()).thenReturn("testuser");
User user = new User();
user.setId(1L);
when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user));
}
@Test
void getLayoutUsesCurrentUserId() {
SessionTreeLayoutDto expected = new SessionTreeLayoutDto();
when(sessionTreeLayoutService.getLayout(1L)).thenReturn(expected);
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.getLayout(authentication);
assertEquals(200, response.getStatusCode().value());
assertEquals(expected, response.getBody());
verify(sessionTreeLayoutService).getLayout(1L);
}
@Test
void saveLayoutUsesCurrentUserId() {
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
when(sessionTreeLayoutService.saveLayout(1L, request)).thenReturn(request);
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.saveLayout(request, authentication);
assertEquals(200, response.getStatusCode().value());
assertEquals(request, response.getBody());
verify(sessionTreeLayoutService).saveLayout(1L, request);
}
}

View File

@@ -0,0 +1,91 @@
package com.sshmanager.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.dto.SessionTreeNodeDto;
import com.sshmanager.entity.SessionTreeLayout;
import com.sshmanager.repository.SessionTreeLayoutRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SessionTreeLayoutServiceTest {
@Mock
private SessionTreeLayoutRepository sessionTreeLayoutRepository;
@Mock
private ObjectMapper objectMapper;
@InjectMocks
private SessionTreeLayoutService sessionTreeLayoutService;
@Test
void getLayoutReturnsEmptyWhenNoSavedData() {
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
assertTrue(result.getNodes().isEmpty());
}
@Test
void getLayoutParsesSavedJson() throws Exception {
SessionTreeLayout saved = new SessionTreeLayout();
saved.setUserId(1L);
saved.setLayoutJson("{\"nodes\":[{\"id\":\"n1\"}]}");
SessionTreeLayoutDto parsed = new SessionTreeLayoutDto(Arrays.asList(
new SessionTreeNodeDto("n1", "folder", "我的连接", null, 0, null, true, 1L, 1L)
));
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.of(saved));
when(objectMapper.readValue(saved.getLayoutJson(), SessionTreeLayoutDto.class)).thenReturn(parsed);
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
assertEquals(1, result.getNodes().size());
assertEquals("n1", result.getNodes().get(0).getId());
}
@Test
void saveLayoutNormalizesNullNodes() throws Exception {
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
request.setNodes(null);
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, request);
assertTrue(result.getNodes().isEmpty());
ArgumentCaptor<SessionTreeLayout> captor = ArgumentCaptor.forClass(SessionTreeLayout.class);
verify(sessionTreeLayoutRepository).save(captor.capture());
assertEquals(1L, captor.getValue().getUserId().longValue());
assertEquals("{\"nodes\":[]}", captor.getValue().getLayoutJson());
}
@Test
void saveLayoutCreatesEmptyPayloadWhenRequestIsNull() throws Exception {
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, null);
assertEquals(Collections.emptyList(), result.getNodes());
}
}

View File

@@ -0,0 +1,27 @@
import client from './client'
export type SessionTreeNodeType = 'folder' | 'connection'
export interface SessionTreeNodePayload {
id: string
type: SessionTreeNodeType
name: string
parentId: string | null
order: number
connectionId?: number
expanded?: boolean
createdAt: number
updatedAt: number
}
export interface SessionTreeLayoutPayload {
nodes: SessionTreeNodePayload[]
}
export function getSessionTree() {
return client.get<SessionTreeLayoutPayload>('/session-tree')
}
export function saveSessionTree(payload: SessionTreeLayoutPayload) {
return client.put<SessionTreeLayoutPayload>('/session-tree', payload)
}

View File

@@ -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,
setTimeout(() => { (show) => {
document.addEventListener('click', handleClickOutside) if (show) {
}, 0) setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 0)
return
}
document.removeEventListener('click', handleClickOutside)
} }
}) )
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)

View File

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

View File

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

View File

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

View File

@@ -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>
<SplitPane <div v-else class="h-full">
v-else-if="activePanel" <div
direction="vertical" v-for="panel in openPanels"
:initial-ratio="activePanel.splitRatio" :key="panel.connectionId"
@ratio-change="handleRatioChange" v-show="isPanelActive(panel.connectionId)"
> class="h-full"
<template #first> >
<TerminalWidget <SplitPane
v-if="activePanel.terminalVisible" direction="vertical"
:connection-id="activeConnectionId" :initial-ratio="panel.splitRatio"
:active="true" @ratio-change="(ratio) => handleRatioChange(panel, ratio)"
/> >
</template> <template #first>
<TerminalWidget
v-if="panel.terminalVisible"
:connection-id="panel.connectionId"
:active="isPanelActive(panel.connectionId)"
/>
</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>

View File

@@ -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) => {

View File

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

View File

@@ -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" <h1 class="text-sm font-semibold tracking-wide text-slate-100">SSH Manager</h1>
aria-label="切换侧边栏" <p class="text-xs text-slate-400 mt-1">{{ authStore.displayName || authStore.username }}</p>
>
<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>
</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> <RouterLink
<p class="px-2 text-[11px] uppercase tracking-wider text-slate-500 mb-2">工作区</p> to="/moba"
<div class="space-y-1"> 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"
<RouterLink :class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path.startsWith('/moba') }"
to="/connections" aria-label="主页"
@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" <Blocks class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
:class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path === '/connections' }" <span>主页Moba</span>
aria-label="连接列表" </RouterLink>
>
<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>
<div v-if="terminalTabs.length > 0" class="pt-3 border-t border-slate-700/70"> <RouterLink
<div class="flex items-center justify-between px-2 mb-2"> to="/transfers"
<p class="text-[11px] uppercase tracking-wider text-slate-500">Terminal Sessions</p> 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"
<span class="text-[11px] text-slate-500">{{ terminalTabs.length }}</span> :class="{ 'bg-slate-800 text-cyan-300 border border-cyan-500/30': route.path.startsWith('/transfers') }"
</div> aria-label="传输队列"
<div class="space-y-1"> >
<div <ArrowLeftRight class="w-4 h-4 flex-shrink-0" aria-hidden="true" />
v-for="tab in terminalTabs" <span>Transfers</span>
:key="tab.id" </RouterLink>
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>

View File

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

View File

@@ -2,58 +2,28 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
component: () => import('../views/LoginView.vue'), component: () => import('../views/LoginView.vue'),
meta: { public: true }, meta: { public: true },
}, },
{ {
path: '/moba', path: '/',
name: 'MobaLayout', redirect: '/moba',
component: () => import('../layouts/MobaLayout.vue'), },
meta: { requiresAuth: true }, {
}, path: '/moba',
{ name: 'MobaLayout',
path: '/', component: () => import('../layouts/MobaLayout.vue'),
component: () => import('../layouts/MainLayout.vue'), meta: { requiresAuth: true },
meta: { requiresAuth: true }, },
children: [ {
{ path: '/transfers',
path: '', redirect: '/moba?tool=transfers',
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 },
},
],
},
]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -61,13 +31,13 @@ const router = createRouter({
}) })
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
if (to.meta.public) { if (to.meta.public) {
if (authStore.isAuthenticated && to.path === '/login') { if (authStore.isAuthenticated && to.path === '/login') {
next('/connections') next('/moba')
} else { } else {
next() next()
} }
return return
} }
if (to.meta.requiresAuth && !authStore.isAuthenticated) { if (to.meta.requiresAuth && !authStore.isAuthenticated) {

View File

@@ -3,14 +3,16 @@ import { ref } from 'vue'
import type { Connection, ConnectionCreateRequest } from '../api/connections' import type { Connection, ConnectionCreateRequest } from '../api/connections'
import * as connectionsApi from '../api/connections' 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() {
const res = await connectionsApi.listConnections() async function fetchConnections() {
connections.value = res.data const res = await connectionsApi.listConnections()
return res.data connections.value = res.data
} hasFetched.value = true
return res.data
}
async function createConnection(data: ConnectionCreateRequest) { async function createConnection(data: ConnectionCreateRequest) {
const res = await connectionsApi.createConnection(data) const res = await connectionsApi.createConnection(data)
@@ -34,11 +36,12 @@ export const useConnectionsStore = defineStore('connections', () => {
return connections.value.find((c) => c.id === id) return connections.value.find((c) => c.id === id)
} }
return { return {
connections, connections,
fetchConnections, hasFetched,
createConnection, fetchConnections,
updateConnection, createConnection,
updateConnection,
deleteConnection, deleteConnection,
getConnection, getConnection,
} }

View File

@@ -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,21 +146,68 @@ 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() {
const raw = localStorage.getItem(STORAGE_KEY) localStorage.setItem(STORAGE_KEY, JSON.stringify({
if (raw) { nodes: this.nodes,
try { selectedNodeId: this.selectedNodeId,
const data = JSON.parse(raw) }))
this.$patch(data) },
} catch (e) {
console.error('Failed to restore session tree:', e) 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() { async initFromConnections() {
const connectionsStore = useConnectionsStore() const connectionsStore = useConnectionsStore()
await connectionsStore.fetchConnections() await connectionsStore.fetchConnections()
@@ -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') {

View File

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

View File

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

View File

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

View File

@@ -16,10 +16,10 @@ const loading = ref(false)
async function handleSubmit() { async function handleSubmit() {
error.value = '' error.value = ''
loading.value = true loading.value = true
try { try {
const res = await authApi.login({ username: username.value, password: password.value }) const res = await authApi.login({ username: username.value, password: password.value })
authStore.setAuth(res.data.token, res.data.username, res.data.displayName) authStore.setAuth(res.data.token, res.data.username, res.data.displayName)
router.push('/connections') router.push('/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 ? '用户名或密码错误' : '登录失败')

View File

@@ -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,21 +251,23 @@ 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>
<button <div class="flex items-center gap-2">
@click="transfersStore.clearRuns" <button
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" @click="transfersStore.clearRuns"
aria-label="清空队列" 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" /> >
清空队列 <Trash2 class="w-4 h-4" aria-hidden="true" />
</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">
@@ -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>