feat(frontend): polish connection tree and terminal groups

Add explorer-style tree search in the connections view with
match-path expansion, clearer count highlighting, and a refined
sidebar header layout.

Improve terminal tab grouping by keeping new sessions appended
within their server group, highlighting the active group, and
deduplicating broadcast actions to send commands once per server.
This commit is contained in:
yinjianm
2026-03-25 23:19:53 +08:00
parent 385e916c54
commit 1662b2b9e8
15 changed files with 563 additions and 83 deletions
@@ -0,0 +1 @@
{"status":"in_progress","completed":1,"failed":0,"pending":3,"total":4,"done":1,"percent":25,"current":"在 ConnectionsView 中实现左侧树搜索和资源管理器式头部布局","updated_at":"2026-03-25 23:10:00"}
@@ -0,0 +1,60 @@
# 变更提案: connections-tree-search-explorer-polish
## 元信息
```yaml
类型: 功能增强
方案类型: implementation
优先级: P1
状态: 进行中
状态说明: 已确认补左侧树搜索、节点计数高亮和资源管理器风格头部布局
创建: 2026-03-25
```
---
## 1. 需求
### 背景
连接管理页左侧树已经具备层级浏览和工具栏控制,但仍缺少独立的树内搜索能力,节点计数也偏弱提示;顶部结构离资源管理器式的“标题 + 工具栏 + 搜索条”仍有差距。
### 目标
- 为左侧树增加独立搜索输入。
- 搜索时仅保留命中节点及其祖先路径,并自动展开命中链路。
- 强化节点计数的视觉提示。
- 把左侧树头部整理成更像资源管理器的结构。
### 约束条件
```yaml
范围约束: 优先限制在 ConnectionsView.vue,不改后端接口和 store 结构
交互约束: 树搜索独立于右侧全局连接搜索,不改变右侧结果筛选逻辑
展示约束: 仅显示命中节点及祖先路径,不把整棵子树全部保留
视觉约束: 延续当前黑绿主题与现有双栏管理台风格
```
### 验收标准
- [ ] 左侧树提供独立搜索框
- [ ] 搜索时仅显示命中节点及祖先路径,并自动展开命中链路
- [ ] 节点计数高亮明显强于当前普通状态
- [ ] 左侧头部布局更接近资源管理器风格
- [ ] 前端构建通过
---
## 2. 方案
### 技术方案
`ConnectionsView.vue` 中增加独立的 `treeSearchQuery` 和递归树过滤逻辑,对标签树按“自身命中或后代命中”规则裁剪,并在搜索态下强制展开命中链路;同时根据选中态、搜索态和命中态为节点计数生成不同的徽标样式。左侧头部改为资源管理器风格的标题区、工具条与搜索条组合。
### 影响范围
```yaml
涉及模块:
- frontend: ConnectionsView.vue
预计变更文件: 1
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 递归筛选后节点引用变化影响现有展开逻辑 | 中 | 搜索态和常规态分开处理,常规态继续复用 expandedTreeNodes,搜索态强制展开命中链路 |
| 左侧树搜索与右侧全局搜索语义混淆 | 低 | 明确区分占位文案和头部层级,让树搜索只作用于标签浏览范围 |
| 高亮过强破坏现有主题一致性 | 低 | 只增强计数徽标与命中行,不重写整套主题变量 |
@@ -0,0 +1,39 @@
# 任务清单: connections-tree-search-explorer-polish
```yaml
@feature: connections-tree-search-explorer-polish
@created: 2026-03-25
@status: in_progress
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 1 | 0 | 0 | 4 |
---
## 任务列表
### 1. 方案与范围确认
- [√] 1.1 创建左侧树搜索与头部布局增强方案包 | depends_on: []
### 2. 交互增强实现
- [ ] 2.1 在 `ConnectionsView.vue` 中实现左侧树搜索与命中链路过滤 | depends_on: [1.1]
- [ ] 2.2 重做左侧头部布局并增强节点计数高亮 | depends_on: [2.1]
### 3. 验证与同步
- [ ] 3.1 运行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [2.2]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-25 23:10 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为 ConnectionsView.vue 的左侧树搜索与资源管理器式头部增强 |
@@ -0,0 +1 @@
{"status":"in_progress","completed":4,"failed":1,"pending":0,"total":5,"done":4,"percent":80,"current":"前端改动已完成,验证被现有 ConnectionsView.vue duplicate attribute 错误阻塞","updated_at":"2026-03-25 23:21:00"}
@@ -0,0 +1,69 @@
# 变更提案: terminal-group-and-broadcast-dedupe
## 元信息
```yaml
类型: 功能增强
方案类型: implementation
优先级: P1
状态: 实施中
状态说明: 代码改动已完成,前端全量构建被现有 ConnectionsView.vue 重复属性错误阻塞
创建: 2026-03-25
```
---
## 1. 需求
### 背景
当前顶部终端标签栏已经有“服务器组头 + 终端子标签”的雏形,但同一服务器新增终端后仍会按全局尾部插入,导致不同服务器之间组关系错乱。另外,`快捷指令``命令历史` 的“发送到全部会话”仍按终端实例广播,同一服务器有多个终端时会重复执行。
### 目标
- 把服务器组头做成更稳定的胶囊式 tab group,组头固定、子标签更紧凑、激活组整块高亮。
- 修复多服务器并行使用时的“新增终端乱插组”问题,确保新终端始终插入到所属服务器组尾部。
-`快捷指令``命令历史` 的“发送到全部会话”统一改成“每台服务器只执行一次”。
### 约束条件
```yaml
范围约束: 只改前端终端标签栏、session 顺序维护和两处广播入口,不改后端协议
排序约束: 继续复用 localStorage 中的 sessionOrder,但允许在新增同组终端时做定向插入
广播约束: 每个 connectionId 最多选择一个已连接 SSH 终端,避免重复执行
兼容约束: RDP/VNC 不参与组内新增终端,也不参与 SSH 广播去重逻辑
```
### 验收标准
- [ ] 多台服务器并行打开时,为某台服务器新增终端会稳定插入到该服务器组尾部
- [ ] 顶部标签栏具备更明显的“胶囊分组”视觉,激活组有整块高亮关系
- [ ] `快捷指令``命令历史` 的“发送到全部会话”都按服务器去重,只执行一次
- [ ] 前端构建通过
---
## 2. 方案
### 技术方案
`sessionActions.ts` 中新增针对 `sessionOrder` 的组内插入逻辑:为某连接创建新终端时,优先把新 `sessionId` 插到同连接最后一个 session 的后面,而不是简单追加到尾部。`TerminalTabBar.vue` 则继续利用连续同 `connectionId` 的会话数组,在组头、子标签和组尾按钮三层上补更强的胶囊边界和激活组高亮。`QuickCommandsView.vue``CommandHistoryView.vue` 共享一套“按 connectionId 选取代表会话”的广播去重策略,只对每台服务器选择一个已连接 SSH 终端发送指令。
### 影响范围
```yaml
涉及模块:
- frontend: session/actions/sessionActions.ts, TerminalTabBar.vue, QuickCommandsView.vue, CommandHistoryView.vue, locales
预计变更文件: 5-7
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| sessionOrder 定向插入与拖拽排序持久化冲突 | 中 | 只在“新增同组终端”时做最小插入,不改已有排序语义 |
| 广播去重选错终端,导致发到非激活或异常会话 | 低 | 只从已连接 SSH 会话中选择每个 connectionId 的首个代表 |
| 胶囊分组视觉过重影响移动端 | 低 | 保持结构复用,移动端沿用同逻辑但不额外堆复杂控件 |
---
## 3. 技术决策
### terminal-group-and-broadcast-dedupe#D001: 新终端按 connectionId 插回组尾,广播按 connectionId 去重
**日期**: 2026-03-25
**状态**: ✅采纳
**决策**: 会话仍保持独立 `sessionId`,但新增同服务器终端时要把其 `sessionId` 插入到该连接组尾部;批量发送场景统一按 `connectionId` 选择代表会话。
**理由**: 问题本质是展示顺序和广播粒度,而不是底层 SSH 能力。按连接做最小排序修正和广播去重,能解决“乱加”和“重复执行”两个核心问题,而且改动边界清晰。
**影响**: 影响 `sessionOrder` 维护逻辑、顶部标签栏分组样式,以及 `快捷指令` / `命令历史` 两个广播入口。
@@ -0,0 +1,54 @@
# 任务清单: terminal-group-and-broadcast-dedupe
```yaml
@feature: terminal-group-and-broadcast-dedupe
@created: 2026-03-25
@status: in_progress
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 1 | 0 | 5 |
---
## 任务列表
### 1. 方案与边界确认
- [√] 1.1 创建终端分组插入与广播去重方案包 | depends_on: []
### 2. 分组与新增终端修复
- [√] 2.1 在 `sessionActions.ts` 中实现同服务器新终端插回组尾的顺序维护 | depends_on: [1.1]
- [√] 2.2 调整 `TerminalTabBar.vue` 的胶囊分组样式和激活组高亮 | depends_on: [2.1]
### 3. 广播去重
- [√] 3.1 调整 `QuickCommandsView.vue``CommandHistoryView.vue`,按服务器去重发送 | depends_on: [2.1]
### 4. 验证与同步
- [X] 4.1 执行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [2.2, 3.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-25 23:11 | 1.1 | 完成 | 创建 implementation 方案包,并锁定“组尾插入 + 按服务器去重广播”方向 |
| 2026-03-25 23:18 | 2.1 / 2.2 | 完成 | 新增基于 sessionOrder 的组尾插入逻辑,并强化终端组胶囊高亮样式 |
| 2026-03-25 23:20 | 3.1 | 完成 | 快捷指令和命令历史的批量发送已统一改成按 connectionId 去重 |
| 2026-03-25 23:21 | 4.1 | 失败 | `npm --prefix packages/frontend run build` 被现有 `ConnectionsView.vue` duplicate attribute 错误阻塞 |
---
## 执行备注
- 本轮以修正会话插入顺序和广播粒度为主,不改后端 SSH 协议。
- “每台服务器只执行一次”适用于快捷指令和命令历史两个入口。
- 如果发现空壳 plan 目录,不参与本轮任务状态流转。
@@ -64,6 +64,14 @@ const showConnectionListPopup = ref(false); // 连接列表弹出状态
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态
const activeConnectionId = computed(() => {
if (!props.activeSessionId) {
return null;
}
return sessionStore.sessions.get(props.activeSessionId)?.connectionId ?? null;
});
const openConnectionPicker = () => {
showConnectionListPopup.value = true;
};
@@ -481,13 +489,32 @@ onBeforeUnmount(() => {
<template #item="{ element: session, index }">
<li
:key="session.sessionId"
class="flex h-full flex-shrink-0 items-stretch py-1 pl-1"
:class="['flex h-full flex-shrink-0 items-stretch py-1', isGroupStart(index) ? 'pl-1' : 'pl-0']"
@dragstart="handleDragStart"
>
<div class="flex h-full items-stretch overflow-hidden rounded-md border border-border/70 bg-header/80 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]">
<div
:class="[
'flex h-full items-stretch overflow-hidden border transition-all duration-150',
session.connectionId === activeConnectionId
? 'border-primary/60 bg-primary/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'border-border/70 bg-header/80 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]',
isGroupStart(index) && isGroupEnd(index)
? 'rounded-md'
: isGroupStart(index)
? 'rounded-l-md rounded-r-none'
: isGroupEnd(index)
? '-ml-px rounded-r-md rounded-l-none'
: '-ml-px rounded-none',
]"
>
<div
v-if="isGroupStart(index)"
class="flex max-w-[160px] items-center border-r border-border/70 bg-black/15 px-2.5 text-xs font-semibold tracking-wide text-text-secondary"
:class="[
'flex max-w-[160px] items-center border-r px-2.5 text-xs font-semibold tracking-wide',
session.connectionId === activeConnectionId
? 'border-primary/50 bg-primary/15 text-foreground'
: 'border-border/70 bg-black/15 text-text-secondary',
]"
:title="session.connectionName"
>
<i class="fas fa-server mr-1.5 text-[10px] text-primary/80"></i>
@@ -496,9 +523,11 @@ onBeforeUnmount(() => {
<div
:class="[
'group flex h-full items-center px-3 transition-colors duration-150 relative',
'group flex h-full items-center px-2.5 transition-colors duration-150 relative',
session.sessionId === activeSessionId
? 'bg-background text-foreground'
? 'bg-background text-foreground shadow-[inset_0_1px_0_rgba(34,197,94,0.15)]'
: session.connectionId === activeConnectionId
? 'bg-primary/5 text-foreground/85 hover:bg-primary/10'
: 'bg-header text-text-secondary hover:bg-border',
!isGroupStart(index) ? 'border-l border-border/60' : '',
]"
@@ -513,7 +542,7 @@ onBeforeUnmount(() => {
session.status === 'connected' ? 'bg-green-500' :
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="whitespace-nowrap text-xs font-medium">
<span class="whitespace-nowrap text-[11px] font-medium">
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }}
</span>
<button
@@ -532,7 +561,12 @@ onBeforeUnmount(() => {
<button
v-if="isGroupEnd(index) && canOpenSiblingTerminal(session.connectionId)"
type="button"
class="flex h-full items-center border-l border-border/60 bg-black/10 px-2.5 text-text-secondary transition-colors duration-150 hover:bg-border hover:text-foreground"
:class="[
'flex h-full items-center border-l px-2.5 transition-colors duration-150',
session.connectionId === activeConnectionId
? 'border-primary/40 bg-primary/10 text-foreground/80 hover:bg-primary/15 hover:text-foreground'
: 'border-border/60 bg-black/10 text-text-secondary hover:bg-border hover:text-foreground',
]"
@click.stop="openNewTerminalForConnection(session.connectionId)"
:title="t('terminalTabBar.newTerminalTooltip')"
>
+4 -4
View File
@@ -1315,10 +1315,10 @@
"copied": "Copied to clipboard",
"copyFailed": "Copy failed",
"actions": {
"sendToAllSessions": "Send to All Sessions"
"sendToAllSessions": "Send to All Servers"
},
"notifications": {
"sentToAllSessions": "Command sent to {count} sessions.",
"sentToAllSessions": "Command sent to {count} servers.",
"noActiveSshSessions": "No active SSH sessions to send command to."
}
},
@@ -1356,10 +1356,10 @@
"clickToEditTag": "Click to edit tag name"
},
"actions": {
"sendToAllSessions": "Send to All Sessions"
"sendToAllSessions": "Send to All Servers"
},
"notifications": {
"sentToAllSessions": "Command sent to {count} sessions.",
"sentToAllSessions": "Command sent to {count} servers.",
"noActiveSshSessions": "No active SSH sessions to send command to."
}
},
+5 -5
View File
@@ -60,10 +60,10 @@
"loading": "ロード中...",
"searchPlaceholder": "履歴を検索...",
"actions": {
"sendToAllSessions": "すべてのセッションに送信"
"sendToAllSessions": "すべてのサーバーに送信"
},
"notifications": {
"sentToAllSessions": "コマンドは {count} 個のセッションに送信されました。",
"sentToAllSessions": "コマンドは {count} 台のサーバーに送信されました。",
"noActiveSshSessions": "コマンドを送信するアクティブな SSH セッションはありません。"
}
},
@@ -730,13 +730,13 @@
"sortByUsage": "使用頻度",
"usageCount": "使用回数",
"actions": {
"sendToAllSessions": "すべてのセッションに送信"
"sendToAllSessions": "すべてのサーバーに送信"
},
"notifications": {
"sentToAllSessions": "コマンドは {count} 個のセッションに送信されました。",
"sentToAllSessions": "コマンドは {count} 台のサーバーに送信されました。",
"noActiveSshSessions": "コマンドを送信するアクティブな SSH セッションはありません。"
}
},
},
"remoteDesktopModal": {
"errors": {
"clientError": "クライアントエラー",
+4 -4
View File
@@ -1319,10 +1319,10 @@
"copied": "已复制到剪贴板",
"copyFailed": "复制失败",
"actions": {
"sendToAllSessions": "发送到全部会话"
"sendToAllSessions": "发送到全部服务器"
},
"notifications": {
"sentToAllSessions": "指令已发送到 {count} 个会话。",
"sentToAllSessions": "指令已发送到 {count} 台服务器。",
"noActiveSshSessions": "没有活动的 SSH 会话可发送指令。"
}
},
@@ -1360,10 +1360,10 @@
"clickToEditTag": "点击编辑标签名称"
},
"actions": {
"sendToAllSessions": "发送到全部会话"
"sendToAllSessions": "发送到全部服务器"
},
"notifications": {
"sentToAllSessions": "指令已发送到 {count} 个会话。",
"sentToAllSessions": "指令已发送到 {count} 台服务器。",
"noActiveSshSessions": "没有活动的 SSH 会话可发送指令。"
}
},
@@ -15,6 +15,8 @@ import { createStatusMonitorManager, type StatusMonitorDependencies } from '../.
import { createDockerManager, type DockerManagerDependencies } from '../../../composables/useDockerManager';
import { registerSshSuspendHandlers } from './sshSuspendActions';
const SESSION_ORDER_STORAGE_KEY = 'sessionOrder';
// --- 辅助函数 (特定于此模块的 actions) ---
const findConnectionInfo = (connectionId: number | string, connectionsStore: ReturnType<typeof useConnectionsStore>): ConnectionInfo | undefined => {
@@ -33,6 +35,75 @@ const getNextTerminalIndex = (connectionId: string): number => {
return maxTerminalIndex + 1;
};
const getOrderedSessionIds = (): string[] => {
const savedOrderStr = localStorage.getItem(SESSION_ORDER_STORAGE_KEY);
let savedOrder: string[] = [];
if (savedOrderStr) {
try {
savedOrder = JSON.parse(savedOrderStr);
} catch (error) {
console.error('[SessionActions] 解析 sessionOrder 失败,回退到创建顺序。', error);
savedOrder = [];
}
}
const sessionList = Array.from(sessions.value.values());
if (savedOrder.length === 0) {
return sessionList
.sort((a, b) => a.createdAt - b.createdAt)
.map((session) => session.sessionId);
}
return sessionList
.sort((a, b) => {
const indexA = savedOrder.indexOf(a.sessionId);
const indexB = savedOrder.indexOf(b.sessionId);
if (indexA === -1 && indexB === -1) return a.createdAt - b.createdAt;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
})
.map((session) => session.sessionId);
};
const saveOrderedSessionIds = (sessionIds: string[]) => {
localStorage.setItem(SESSION_ORDER_STORAGE_KEY, JSON.stringify(sessionIds));
};
const insertSessionIdIntoOrder = (sessionId: string, connectionId: string) => {
const orderedSessionIds = getOrderedSessionIds();
const nextOrder = [...orderedSessionIds];
if (nextOrder.includes(sessionId)) {
return;
}
let insertionIndex = nextOrder.length;
for (let index = nextOrder.length - 1; index >= 0; index -= 1) {
const orderedSession = sessions.value.get(nextOrder[index]);
if (orderedSession?.connectionId === connectionId) {
insertionIndex = index + 1;
break;
}
}
nextOrder.splice(insertionIndex, 0, sessionId);
saveOrderedSessionIds(nextOrder);
};
const replaceSessionIdInOrder = (previousSessionId: string, nextSessionId: string) => {
const orderedSessionIds = getOrderedSessionIds();
const updatedOrder = orderedSessionIds.map((sessionId) => (sessionId === previousSessionId ? nextSessionId : sessionId));
saveOrderedSessionIds(Array.from(new Set(updatedOrder)));
};
const removeSessionIdFromOrder = (sessionId: string) => {
const orderedSessionIds = getOrderedSessionIds().filter((orderedSessionId) => orderedSessionId !== sessionId);
saveOrderedSessionIds(orderedSessionIds);
};
// --- Actions ---
export const openNewSession = (
connectionOrId: ConnectionInfo | number | string,
@@ -128,6 +199,7 @@ export const openNewSession = (
const newSessionsMap = new Map(sessions.value);
newSessionsMap.set(newSessionId, newSession);
sessions.value = newSessionsMap;
insertSessionIdIntoOrder(newSessionId, dbConnId);
activeSessionId.value = newSessionId;
console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId} (terminal #${terminalIndex})`);
@@ -157,6 +229,7 @@ export const openNewSession = (
currentSessions.set(backendSID, sessionToUpdate);
sessions.value = currentSessions;
replaceSessionIdInOrder(originalFrontendSessionIdForHandler, backendSID);
if (activeSessionId.value === originalFrontendSessionIdForHandler) {
activeSessionId.value = backendSID;
@@ -253,6 +326,7 @@ export const closeSession = (sessionId: string) => {
const newSessionsMap = new Map(sessions.value);
newSessionsMap.delete(sessionId);
sessions.value = newSessionsMap;
removeSessionIdFromOrder(sessionId);
console.log(`[SessionActions] 已从 Map 中移除会话: ${sessionId}`);
// 3. 切换活动标签页
@@ -333,5 +407,6 @@ export const cleanupAllSessions = () => {
newSessionsMap.clear();
sessions.value = newSessionsMap;
}
saveOrderedSessionIds([]);
activeSessionId.value = null;
};
@@ -0,0 +1,30 @@
import type { ConnectionInfo } from '../stores/connections.store';
import type { SessionState } from '../stores/session/types';
export const getUniqueConnectedSshSessions = (
sessionMap: Map<string, SessionState>,
connections: ConnectionInfo[],
): SessionState[] => {
const selectedSessions: SessionState[] = [];
const seenConnectionIds = new Set<string>();
for (const session of sessionMap.values()) {
if (session.wsManager.connectionStatus.value !== 'connected') {
continue;
}
const connectionInfo = connections.find((connection) => connection.id === Number(session.connectionId));
if (!connectionInfo || connectionInfo.type !== 'SSH') {
continue;
}
if (seenConnectionIds.has(session.connectionId)) {
continue;
}
seenConnectionIds.add(session.connectionId);
selectedSessions.push(session);
}
return selectedSessions;
};
@@ -93,6 +93,7 @@ import { useSessionStore } from '../stores/session.store';
import type { SessionState } from '../stores/session/types';
import { useConnectionsStore } from '../stores/connections.store';
import { useConfirmDialog } from '../composables/useConfirmDialog';
import { getUniqueConnectedSshSessions } from '../utils/sessionSelection';
const commandHistoryStore = useCommandHistoryStore();
const { showConfirmDialog } = useConfirmDialog();
@@ -302,13 +303,7 @@ const closeCommandHistoryContextMenu = () => {
const handleCommandHistoryMenuAction = (action: 'sendToAllSessions', entry: CommandHistoryEntryFE) => {
closeCommandHistoryContextMenu();
if (action === 'sendToAllSessions') {
const activeSshSessions = Array.from(sessionStore.sessions.values()).filter(
(s: SessionState) => {
if (s.wsManager.connectionStatus.value !== 'connected') return false;
const connInfo = connectionsStore.connections.find(c => c.id === Number(s.connectionId));
return connInfo?.type === 'SSH';
}
);
const activeSshSessions = getUniqueConnectedSshSessions(sessionStore.sessions, connectionsStore.connections);
if (activeSshSessions.length > 0) {
activeSshSessions.forEach((session: SessionState) => {
+176 -49
View File
@@ -84,6 +84,7 @@ const getInitialSelectedScope = (): ScopeId => {
const selectedScope = ref<ScopeId>(getInitialSelectedScope());
const activeTypeFilter = ref<ConnectionTypeFilter>((localStorage.getItem(LS_TYPE_FILTER_KEY) as ConnectionTypeFilter) || 'ALL');
const searchQuery = ref('');
const treeSearchQuery = ref('');
const tagsSectionExpanded = ref(true);
const showAddEditConnectionForm = ref(false);
@@ -113,6 +114,7 @@ const TREE_EXPANDED_STORAGE_KEY = 'connections_view_tree_expanded';
const tagPathSeparatorRegex = /\s*(?:\/|>|\\)\s*/;
const normalizedSearchQuery = computed(() => searchQuery.value.toLowerCase().trim());
const normalizedTreeSearchQuery = computed(() => treeSearchQuery.value.toLowerCase().trim());
const loadInitialExpandedTreeState = (): Record<string, boolean> => {
try {
@@ -285,19 +287,69 @@ const tagTreeNodes = computed<TagTreeNode[]>(() => {
return buildNodes(rootChildren, 0);
});
const filteredTagTreeNodes = computed<TagTreeNode[]>(() => {
const query = normalizedTreeSearchQuery.value;
if (!query) {
return tagTreeNodes.value;
}
const filterNodes = (nodes: TagTreeNode[]): TagTreeNode[] => {
return nodes.flatMap((node) => {
const filteredChildren = filterNodes(node.children);
const selfMatches = node.label.toLowerCase().includes(query) || node.fullLabel.toLowerCase().includes(query);
if (!selfMatches && filteredChildren.length === 0) {
return [];
}
return [{
...node,
children: filteredChildren,
}];
});
};
return filterNodes(tagTreeNodes.value);
});
const matchingTreeNodeIds = computed<Set<ScopeId>>(() => {
const matches = new Set<ScopeId>();
const query = normalizedTreeSearchQuery.value;
if (!query) {
return matches;
}
const walkNodes = (nodes: TagTreeNode[]) => {
nodes.forEach((node) => {
if (node.label.toLowerCase().includes(query) || node.fullLabel.toLowerCase().includes(query)) {
matches.add(node.id);
}
if (node.children.length > 0) {
walkNodes(node.children);
}
});
};
walkNodes(tagTreeNodes.value);
return matches;
});
const visibleTagTreeNodes = computed<TagTreeNode[]>(() => {
const rows: TagTreeNode[] = [];
const isSearchMode = Boolean(normalizedTreeSearchQuery.value);
const appendVisibleNodes = (nodes: TagTreeNode[]) => {
nodes.forEach((node) => {
rows.push(node);
if (node.expandable && (expandedTreeNodes.value[node.id] ?? true)) {
if (node.expandable && (isSearchMode || (expandedTreeNodes.value[node.id] ?? true))) {
appendVisibleNodes(node.children);
}
});
};
appendVisibleNodes(tagTreeNodes.value);
appendVisibleNodes(filteredTagTreeNodes.value);
return rows;
});
@@ -318,6 +370,7 @@ const expandableTreeNodeIds = computed<ScopeId[]>(() => {
});
const hasExpandableTreeNodes = computed(() => expandableTreeNodeIds.value.length > 0);
const hasTreeSearchResults = computed(() => visibleTagTreeNodes.value.length > 0);
const primaryScopeNodes = computed<ScopeNode[]>(() => {
return [
@@ -479,6 +532,34 @@ const getScopeNodeClass = (nodeId: ScopeId) => {
return 'text-text-secondary border-transparent hover:bg-header hover:text-foreground';
};
const getTreeNodeRowClass = (node: TagTreeNode) => {
if (selectedScope.value === node.id) {
return 'bg-primary/15 text-foreground border-primary/30 shadow-sm';
}
if (matchingTreeNodeIds.value.has(node.id)) {
return 'border-emerald-400/30 bg-emerald-500/8 text-emerald-100 shadow-sm';
}
return 'text-text-secondary border-transparent hover:bg-header hover:text-foreground';
};
const getTreeCountClass = (node: ScopeNode) => {
if (selectedScope.value === node.id) {
return 'border-primary/30 bg-primary/20 text-foreground';
}
if (matchingTreeNodeIds.value.has(node.id)) {
return 'border-emerald-400/25 bg-emerald-500/18 text-emerald-100';
}
if (node.count > 0) {
return 'border-emerald-500/15 bg-emerald-500/10 text-emerald-200';
}
return 'border-current/15 bg-black/10 text-text-secondary';
};
const getTypeBadgeClass = (type: ConnectionInfo['type']) => {
if (type === 'SSH') {
return 'bg-emerald-500/12 text-emerald-300 border-emerald-400/25';
@@ -625,6 +706,10 @@ const resetScopeSelection = () => {
selectScope('all');
};
const clearTreeSearch = () => {
treeSearchQuery.value = '';
};
const connectTo = (connection: ConnectionInfo) => {
sessionStore.handleConnectRequest(connection);
};
@@ -924,18 +1009,58 @@ onBeforeUnmount(() => {
<div class="grid grid-cols-1 xl:grid-cols-[280px_minmax(0,1fr)] gap-4">
<aside class="bg-card text-card-foreground border border-border rounded-2xl overflow-hidden min-h-[720px]">
<div class="px-4 py-4 border-b border-border/60 bg-header/40">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold tracking-wide text-foreground">{{ t('connections.scopeTitle', '浏览范围') }}</h2>
<p class="mt-1 text-xs text-text-secondary">{{ t('connections.scopeDesc', '按标签和分组快速切换连接范围') }}</p>
<div class="px-4 pt-4 pb-3 border-b border-border/60 bg-gradient-to-b from-header/70 via-header/35 to-background/70">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="w-9 h-9 rounded-xl border border-emerald-400/20 bg-emerald-500/10 text-emerald-200 inline-flex items-center justify-center">
<i class="fas fa-folder-tree text-sm"></i>
</div>
<div class="min-w-0">
<h2 class="text-sm font-semibold tracking-[0.18em] uppercase text-foreground">{{ t('connections.scopeTitle', '浏览范围') }}</h2>
<p class="mt-0.5 text-xs text-text-secondary truncate">{{ t('connections.scopeDesc', '按标签和分组快速切换连接范围') }}</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span class="px-2.5 py-1 rounded-full border border-emerald-400/20 bg-emerald-500/10 text-[11px] font-medium text-emerald-100">
{{ visibleTagTreeNodes.length }} {{ t('connections.table.tags', '标签') }}
</span>
<button
@click="tagsSectionExpanded = !tagsSectionExpanded"
class="w-8 h-8 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors"
:title="tagsSectionExpanded ? t('common.collapse', '收起') : t('common.expand', '展开')"
>
<i :class="['fas', tagsSectionExpanded ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</button>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<div class="relative flex-1 min-w-0">
<i class="fas fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary text-xs"></i>
<input
v-model="treeSearchQuery"
type="text"
:placeholder="t('connections.scopeTreeSearch', '搜索标签树...')"
class="w-full h-9 pl-9 pr-9 rounded-xl border border-border/60 bg-background/95 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition"
/>
<button
v-if="treeSearchQuery"
@click="clearTreeSearch"
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 rounded-md text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center"
:title="t('common.clear', '清空')"
>
<i class="fas fa-xmark text-xs"></i>
</button>
</div>
<button
@click="tagsSectionExpanded = !tagsSectionExpanded"
class="w-8 h-8 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors"
:title="tagsSectionExpanded ? t('common.collapse', '收起') : t('common.expand', '展开')"
@click="resetScopeSelection"
:disabled="selectedScope === 'all'"
class="h-9 px-3 rounded-xl border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i :class="['fas', tagsSectionExpanded ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
<i class="fas fa-crosshairs"></i>
<span>{{ t('common.reset', '重置') }}</span>
</button>
</div>
</div>
@@ -954,6 +1079,7 @@ onBeforeUnmount(() => {
'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getScopeNodeClass(node.id)
]"
:class="getTreeCountClass(node)"
>
<span class="flex items-center gap-2 min-w-0">
<i :class="['fas', node.id === 'all' ? 'fa-layer-group' : 'fa-tag', 'w-4 text-center']"></i>
@@ -968,7 +1094,7 @@ onBeforeUnmount(() => {
<section>
<div class="px-2 mb-2 flex items-center justify-between gap-3 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary/80">
<span>{{ t('connections.table.tags', '标签') }}</span>
<span>{{ t('connections.table.tags', '标签资源管理器') }}</span>
<span class="text-[11px] tracking-normal normal-case text-text-secondary">{{ visibleTagTreeNodes.length }}</span>
</div>
@@ -990,50 +1116,51 @@ onBeforeUnmount(() => {
<i class="fas fa-square-minus"></i>
<span>{{ t('common.collapseAll', '收起全部') }}</span>
</button>
<button
@click="resetScopeSelection"
:disabled="selectedScope === 'all'"
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-rotate-left"></i>
<span>{{ t('common.reset', '重置范围') }}</span>
</button>
</div>
<div class="px-2 flex items-center justify-between gap-3 text-[11px] text-text-secondary">
<span>{{ t('connections.scopeHintCompact', '树节点按标签路径自动分层') }}</span>
<span>{{ selectedScopeTitle }}</span>
<span class="truncate text-right">{{ normalizedTreeSearchQuery ? t('connections.scopeSearchMode', '命中路径已自动展开') : selectedScopeTitle }}</span>
</div>
<div class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
<div
v-for="node in visibleTagTreeNodes"
:key="node.id"
:class="[
'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getScopeNodeClass(node.id),
node.count === 0 ? 'opacity-55' : ''
]"
:style="{ paddingLeft: `${0.75 + node.level * 1.05}rem` }"
>
<button
class="flex items-center gap-2 min-w-0 flex-1"
@click="handleTreeNodeSelect(node)"
>
<i
v-if="node.expandable"
:class="[
'fas w-4 text-center transition-transform duration-150',
(expandedTreeNodes[node.id] ?? true) ? 'fa-chevron-down' : 'fa-chevron-right'
]"
></i>
<i v-else class="fas fa-circle text-[8px] w-4 text-center opacity-60"></i>
<span class="truncate" :title="node.fullLabel">{{ node.label }}</span>
</button>
<span class="px-2 py-0.5 rounded-full text-xs border border-current/15 bg-black/10 flex-shrink-0">
{{ node.count }}
</span>
<div v-if="normalizedTreeSearchQuery && !hasTreeSearchResults" class="mx-2 rounded-xl border border-dashed border-border/70 bg-background/70 px-3 py-4 text-xs text-text-secondary text-center">
{{ t('connections.scopeTreeNoMatch', '没有匹配的树节点') }}
</div>
<div v-else class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
<div
v-for="node in visibleTagTreeNodes"
:key="node.id"
:class="[
'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getTreeNodeRowClass(node),
node.count === 0 ? 'opacity-55' : ''
]"
:style="{ paddingLeft: `${0.75 + node.level * 1.05}rem` }"
>
<button
class="flex items-center gap-2 min-w-0 flex-1"
@click="handleTreeNodeSelect(node)"
>
<i
v-if="node.expandable"
:class="[
'fas w-4 text-center transition-transform duration-150',
(normalizedTreeSearchQuery || (expandedTreeNodes[node.id] ?? true)) ? 'fa-chevron-down' : 'fa-chevron-right'
]"
></i>
<i v-else class="fas fa-circle text-[8px] w-4 text-center opacity-60"></i>
<span class="truncate" :title="node.fullLabel">{{ node.label }}</span>
</button>
<span
:class="[
'px-2 py-0.5 rounded-full text-xs border flex-shrink-0 transition-colors',
getTreeCountClass(node)
]"
>
{{ node.count }}
</span>
</div>
</div>
</div>
</section>
@@ -241,6 +241,7 @@ import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import { useSessionStore } from '../stores/session.store';
import type { SessionState } from '../stores/session/types';
import { useConnectionsStore } from '../stores/connections.store';
import { getUniqueConnectedSshSessions } from '../utils/sessionSelection';
const quickCommandsStore = useQuickCommandsStore();
const quickCommandTagsStore = useQuickCommandTagsStore();
@@ -763,13 +764,7 @@ const closeQuickCommandContextMenu = () => {
const handleQuickCommandMenuAction = (action: 'sendToAllSessions', command: QuickCommandFE) => {
closeQuickCommandContextMenu();
if (action === 'sendToAllSessions') {
const activeSshSessions = Array.from(sessionStore.sessions.values()).filter(
(s: SessionState) => {
if (s.wsManager.connectionStatus.value !== 'connected') return false;
const connInfo = connectionsStore.connections.find(c => c.id === Number(s.connectionId));
return connInfo?.type === 'SSH';
}
);
const activeSshSessions = getUniqueConnectedSshSessions(sessionStore.sessions, connectionsStore.connections);
if (activeSshSessions.length > 0) {
activeSshSessions.forEach((session: SessionState) => {