✨ feat(workspace): 增强连接管理与终端状态展示
- 为连接管理页补充多级标签树、列头排序和行级更多菜单
- 支持同一 SSH 连接打开多个终端并显示终端序号
- 补充状态监控的内存与磁盘详情字段
---
✨ feat(workspace): enhance connection management and terminal status visibility
- add multi-level tag tree, sortable columns, and row-level more menu
- support multiple terminals per SSH connection with terminal indices
- extend status monitor with memory and disk detail fields
This commit is contained in:
@@ -9,12 +9,20 @@ interface ServerStatus {
|
||||
memPercent?: number;
|
||||
memUsed?: number; // MB
|
||||
memTotal?: number; // MB
|
||||
memFree?: number; // MB
|
||||
memCached?: number; // MB
|
||||
swapPercent?: number;
|
||||
swapUsed?: number; // MB
|
||||
swapTotal?: number; // MB
|
||||
diskPercent?: number;
|
||||
diskUsed?: number; // KB
|
||||
diskTotal?: number; // KB
|
||||
diskAvailable?: number; // KB
|
||||
diskMountPoint?: string;
|
||||
diskFsType?: string;
|
||||
diskDevice?: string;
|
||||
diskReadRate?: number; // Bytes per second
|
||||
diskWriteRate?: number; // Bytes per second
|
||||
cpuModel?: string;
|
||||
netRxRate?: number; // Bytes per second
|
||||
netTxRate?: number; // Bytes per second
|
||||
@@ -34,9 +42,17 @@ interface NetworkStats {
|
||||
}
|
||||
}
|
||||
|
||||
interface DiskIoStats {
|
||||
[deviceName: string]: {
|
||||
readBytes: number;
|
||||
writeBytes: number;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 用于存储上一次的网络统计信息以计算速率
|
||||
const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
|
||||
const previousDiskStats = new Map<string, { device: string, readBytes: number, writeBytes: number, timestamp: number }>();
|
||||
|
||||
export class StatusMonitorService {
|
||||
private clientStates: Map<string, ClientState>; // 使用导入的 ClientState
|
||||
@@ -47,6 +63,32 @@ export class StatusMonitorService {
|
||||
this.clientStates = clientStates;
|
||||
}
|
||||
|
||||
private async parseProcDiskStats(sshClient: Client): Promise<DiskIoStats | null> {
|
||||
try {
|
||||
const output = await this.executeSshCommand(sshClient, 'cat /proc/diskstats');
|
||||
const stats: DiskIoStats = {};
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 10) continue;
|
||||
|
||||
const deviceName = parts[2];
|
||||
const sectorsRead = parseInt(parts[5], 10);
|
||||
const sectorsWritten = parseInt(parts[9], 10);
|
||||
if (!isNaN(sectorsRead) && !isNaN(sectorsWritten)) {
|
||||
stats[deviceName] = {
|
||||
readBytes: sectorsRead * 512,
|
||||
writeBytes: sectorsWritten * 512,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(stats).length > 0 ? stats : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动指定会话的状态轮询
|
||||
* @param sessionId 会话 ID
|
||||
@@ -88,6 +130,7 @@ export class StatusMonitorService {
|
||||
clearInterval(state.statusIntervalId);
|
||||
state.statusIntervalId = undefined;
|
||||
previousNetStats.delete(sessionId); // 清理网络统计缓存
|
||||
previousDiskStats.delete(sessionId);
|
||||
this.previousCpuStats.delete(sessionId); // 清理 CPU 统计缓存
|
||||
}
|
||||
}
|
||||
@@ -170,22 +213,52 @@ export class StatusMonitorService {
|
||||
}
|
||||
const freeOutput = await this.executeSshCommand(sshClient, freeCommand);
|
||||
const lines = freeOutput.split('\n');
|
||||
const headerLine = lines.find(line => line.toLowerCase().includes('total') && line.toLowerCase().includes('used'));
|
||||
const memLine = lines.find(line => line.startsWith('Mem:'));
|
||||
const swapLine = lines.find(line => line.startsWith('Swap:'));
|
||||
if (memLine) {
|
||||
if (memLine && headerLine) {
|
||||
const headers = headerLine.trim().split(/\s+/);
|
||||
const values = memLine.trim().split(/\s+/).slice(1);
|
||||
const memoryFields: Record<string, number> = {};
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
const rawValue = parseInt(values[index], 10);
|
||||
if (!isNaN(rawValue)) {
|
||||
memoryFields[header.toLowerCase()] = isBusyBox ? Math.round(rawValue / 1024) : rawValue;
|
||||
}
|
||||
});
|
||||
|
||||
const totalVal = memoryFields.total;
|
||||
const usedVal = memoryFields.used;
|
||||
const freeVal = memoryFields.free;
|
||||
const cachedVal = memoryFields['buff/cache'] ?? ((memoryFields.buffers ?? 0) + (memoryFields.cached ?? 0));
|
||||
|
||||
if (!isNaN(totalVal) && !isNaN(usedVal)) {
|
||||
status.memTotal = totalVal;
|
||||
status.memUsed = usedVal;
|
||||
status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
|
||||
status.memFree = !isNaN(freeVal) ? freeVal : Math.max(totalVal - usedVal - (cachedVal || 0), 0);
|
||||
if (cachedVal > 0) {
|
||||
status.memCached = cachedVal;
|
||||
}
|
||||
}
|
||||
} else if (memLine) {
|
||||
const parts = memLine.split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
if (parts.length >= 4) {
|
||||
let totalVal = parseInt(parts[1], 10);
|
||||
let usedVal = parseInt(parts[2], 10);
|
||||
let freeVal = parseInt(parts[3], 10);
|
||||
|
||||
if (isBusyBox) {
|
||||
if (isBusyBox) {
|
||||
if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024);
|
||||
if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024);
|
||||
if (!isNaN(freeVal)) freeVal = Math.round(freeVal / 1024);
|
||||
}
|
||||
|
||||
if (!isNaN(totalVal) && !isNaN(usedVal)) {
|
||||
status.memTotal = totalVal;
|
||||
status.memUsed = usedVal;
|
||||
status.memFree = !isNaN(freeVal) ? freeVal : undefined;
|
||||
status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
|
||||
}
|
||||
}
|
||||
@@ -216,12 +289,12 @@ export class StatusMonitorService {
|
||||
|
||||
|
||||
try {
|
||||
let dfCommand = "df -kP /"; // 优先尝试 POSIX 标准格式
|
||||
let dfCommand = "df -kPT /";
|
||||
let dfOutput: string;
|
||||
try {
|
||||
dfOutput = await this.executeSshCommand(sshClient, dfCommand);
|
||||
} catch (errP) {
|
||||
dfCommand = "df -k /"; // 备用方案
|
||||
dfCommand = "df -kP /";
|
||||
try {
|
||||
dfOutput = await this.executeSshCommand(sshClient, dfCommand);
|
||||
} catch (errK) {
|
||||
@@ -231,13 +304,38 @@ export class StatusMonitorService {
|
||||
|
||||
if (dfOutput) {
|
||||
const lines = dfOutput.split('\n');
|
||||
let rawDiskDevice: string | undefined;
|
||||
let parsedDiskInfo = false;
|
||||
// 从第二行开始查找根挂载点信息 (跳过表头)
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
// 确保是根挂载点,通常以 " /" 结尾
|
||||
if (line.endsWith(" /")) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (!line.endsWith(" /")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = line.split(/\s+/);
|
||||
const hasTypeColumn = parts.length >= 7;
|
||||
const totalIndex = hasTypeColumn ? 2 : 1;
|
||||
const usedIndex = hasTypeColumn ? 3 : 2;
|
||||
const availableIndex = hasTypeColumn ? 4 : 3;
|
||||
const percentIndex = hasTypeColumn ? 5 : 4;
|
||||
const mountIndex = hasTypeColumn ? 6 : 5;
|
||||
const total = parseInt(parts[totalIndex], 10);
|
||||
const used = parseInt(parts[usedIndex], 10);
|
||||
const available = parseInt(parts[availableIndex], 10);
|
||||
const percentMatch = parts[percentIndex]?.match(/(\d+)%/);
|
||||
|
||||
if (!isNaN(total) && !isNaN(used) && !isNaN(available) && percentMatch?.[1]) {
|
||||
rawDiskDevice = parts[0];
|
||||
status.diskFsType = hasTypeColumn ? parts[1] : status.diskFsType;
|
||||
status.diskTotal = total;
|
||||
status.diskUsed = used;
|
||||
status.diskAvailable = available;
|
||||
status.diskPercent = parseFloat(percentMatch[1]);
|
||||
status.diskMountPoint = parts[mountIndex] || '/';
|
||||
break;
|
||||
}
|
||||
// 预期 parts 至少包含: 文件系统, 总量(KB), 已用(KB), 可用(KB), 百分比%, 挂载点
|
||||
// 例如: /dev/sda1 10307920 3841884 5941800 40% /
|
||||
if (parts.length >= 5) {
|
||||
@@ -259,6 +357,46 @@ export class StatusMonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawDiskDevice || !status.diskFsType || !status.diskMountPoint) {
|
||||
try {
|
||||
const findmntOutput = await this.executeSshCommand(sshClient, 'findmnt -n -o SOURCE,FSTYPE,TARGET /');
|
||||
const findmntParts = findmntOutput.trim().split(/\s+/);
|
||||
rawDiskDevice = rawDiskDevice || findmntParts[0];
|
||||
status.diskFsType = status.diskFsType || findmntParts[1];
|
||||
status.diskMountPoint = status.diskMountPoint || findmntParts[2] || '/';
|
||||
} catch (findmntErr) { /* 静默处理 */ }
|
||||
}
|
||||
|
||||
status.diskDevice = this.normalizeDiskDevice(rawDiskDevice);
|
||||
|
||||
if (status.diskDevice) {
|
||||
const currentDiskStats = await this.parseProcDiskStats(sshClient);
|
||||
const deviceStats = currentDiskStats?.[status.diskDevice];
|
||||
if (deviceStats) {
|
||||
const previousStats = previousDiskStats.get(sessionId);
|
||||
if (previousStats && previousStats.device === status.diskDevice && previousStats.timestamp < timestamp) {
|
||||
const timeDiffSeconds = (timestamp - previousStats.timestamp) / 1000;
|
||||
if (timeDiffSeconds > 0.1) {
|
||||
status.diskReadRate = Math.max(0, Math.round((deviceStats.readBytes - previousStats.readBytes) / timeDiffSeconds));
|
||||
status.diskWriteRate = Math.max(0, Math.round((deviceStats.writeBytes - previousStats.writeBytes) / timeDiffSeconds));
|
||||
} else {
|
||||
status.diskReadRate = 0;
|
||||
status.diskWriteRate = 0;
|
||||
}
|
||||
} else {
|
||||
status.diskReadRate = 0;
|
||||
status.diskWriteRate = 0;
|
||||
}
|
||||
|
||||
previousDiskStats.set(sessionId, {
|
||||
device: status.diskDevice,
|
||||
readBytes: deviceStats.readBytes,
|
||||
writeBytes: deviceStats.writeBytes,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果捕获到错误 (例如 executeSshCommand 内部的 Promise reject), disk* 字段将保持 undefined
|
||||
@@ -414,6 +552,31 @@ export class StatusMonitorService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private normalizeDiskDevice(rawDevice?: string): string | undefined {
|
||||
if (!rawDevice) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let normalized = rawDevice.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/dev/')) {
|
||||
normalized = normalized.slice(5);
|
||||
}
|
||||
|
||||
if (/^(sd[a-z]+|vd[a-z]+|xvd[a-z]+|hd[a-z]+)\d+$/.test(normalized)) {
|
||||
return normalized.replace(/\d+$/, '');
|
||||
}
|
||||
|
||||
if (/^(nvme\d+n\d+|mmcblk\d+)p\d+$/.test(normalized)) {
|
||||
return normalized.replace(/p\d+$/, '');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 SSH 连接上执行单个命令
|
||||
* @param sshClient SSH 客户端实例
|
||||
|
||||
@@ -64,6 +64,39 @@ const showConnectionListPopup = ref(false); // 连接列表弹出状态
|
||||
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
|
||||
const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态
|
||||
|
||||
const activeSessionState = computed(() => {
|
||||
if (!props.activeSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionStore.sessions.get(props.activeSessionId) ?? null;
|
||||
});
|
||||
|
||||
const activeConnectionInfo = computed(() => {
|
||||
const activeSession = activeSessionState.value;
|
||||
if (!activeSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return connectionsStore.connections.find((connection) => connection.id === Number(activeSession.connectionId)) ?? null;
|
||||
});
|
||||
|
||||
const canAddTerminalToActiveConnection = computed(() => activeConnectionInfo.value?.type === 'SSH');
|
||||
|
||||
const openNewTerminalForActiveConnection = () => {
|
||||
const activeConnection = activeConnectionInfo.value;
|
||||
if (!activeConnection || activeConnection.type !== 'SSH') {
|
||||
showConnectionListPopup.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStore.handleOpenNewSession(activeConnection.id);
|
||||
};
|
||||
|
||||
const openConnectionPicker = () => {
|
||||
showConnectionListPopup.value = true;
|
||||
};
|
||||
|
||||
// + Watch prop changes to update local state
|
||||
watch(() => props.sessions, (newSessions) => {
|
||||
// Create a shallow copy to avoid modifying the prop directly
|
||||
@@ -174,6 +207,22 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
|
||||
// 注意:关闭左侧通常不包括当前标签本身
|
||||
emitWorkspaceEvent('session:closeToLeft', { targetSessionId: targetId });
|
||||
break;
|
||||
case 'new-terminal': {
|
||||
const targetSessionState = sessionStore.sessions.get(targetId);
|
||||
if (!targetSessionState) {
|
||||
console.warn(`[TabBar] 'new-terminal' action failed: session ${targetId} not found.`);
|
||||
break;
|
||||
}
|
||||
|
||||
const targetConnectionInfo = connectionsStore.connections.find(c => c.id === Number(targetSessionState.connectionId));
|
||||
if (!targetConnectionInfo || targetConnectionInfo.type !== 'SSH') {
|
||||
console.warn(`[TabBar] 'new-terminal' action ignored for non-SSH connection. targetId=${targetId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
sessionStore.handleOpenNewSession(targetConnectionInfo.id);
|
||||
break;
|
||||
}
|
||||
case 'mark-for-suspend': // +++ 修改 action 名称 +++
|
||||
if (typeof targetId === 'string') {
|
||||
console.log(`[TabBar] Context menu action 'mark-for-suspend' requested for session ID: ${targetId}`);
|
||||
@@ -213,6 +262,7 @@ const contextMenuItems = computed(() => {
|
||||
|
||||
// 添加标记/取消标记挂起会话菜单项(如果适用)
|
||||
if (connectionInfo && connectionInfo.type === 'SSH') {
|
||||
items.push({ label: 'terminalTabBar.newTerminalTooltip', action: 'new-terminal' });
|
||||
const isActiveSession = targetSessionState.wsManager.isConnected.value;
|
||||
if (isActiveSession) { // 只对活动的SSH会话显示相关操作
|
||||
if (targetSessionState.isMarkedForSuspend) {
|
||||
@@ -446,6 +496,12 @@ onBeforeUnmount(() => {
|
||||
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
|
||||
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
|
||||
<span class="truncate text-sm" style="transform: translateY(-1px);">{{ session.connectionName }}</span>
|
||||
<span
|
||||
class="ml-2 inline-flex flex-shrink-0 items-center rounded-full border border-border px-1.5 py-0.5 text-[11px] leading-none text-text-secondary"
|
||||
:title="t('terminalTabBar.terminalBadge', { index: session.terminalIndex })"
|
||||
>
|
||||
{{ session.terminalIndex }}
|
||||
</span>
|
||||
<button class="ml-2 p-0.5 rounded-full text-text-secondary hover:bg-border hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
:class="{'text-foreground hover:bg-header': session.sessionId === activeSessionId}"
|
||||
@click="closeSession($event, session.sessionId)" :title="$t('tabs.closeTabTooltip')">
|
||||
@@ -456,11 +512,18 @@ onBeforeUnmount(() => {
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
<!-- Add Tab Button -->
|
||||
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
|
||||
@click="togglePopup" :title="$t('tabs.newTabTooltip')">
|
||||
<!-- Add Terminal Button -->
|
||||
<button
|
||||
class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
|
||||
@click="openNewTerminalForActiveConnection"
|
||||
:title="canAddTerminalToActiveConnection ? t('terminalTabBar.newTerminalTooltip') : t('tabs.newTabTooltip')">
|
||||
<i class="fas fa-plus text-sm"></i>
|
||||
</button>
|
||||
<!-- Open Connection Picker Button -->
|
||||
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
|
||||
@click="openConnectionPicker" :title="t('terminalTabBar.openConnectionPickerTooltip')">
|
||||
<i class="fas fa-server text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center ml-auto h-full flex-shrink-0">
|
||||
|
||||
@@ -1457,7 +1457,10 @@
|
||||
},
|
||||
"terminalTabBar": {
|
||||
"selectServerTitle": "Select server to connect",
|
||||
"showTransferProgressTooltip": "Show/Hide Transfer Progress"
|
||||
"showTransferProgressTooltip": "Show/Hide Transfer Progress",
|
||||
"newTerminalTooltip": "Open another terminal for the current server",
|
||||
"openConnectionPickerTooltip": "Choose another server",
|
||||
"terminalBadge": "Terminal {index}"
|
||||
},
|
||||
"tabs": {
|
||||
"contextMenu": {
|
||||
|
||||
@@ -1419,7 +1419,10 @@
|
||||
},
|
||||
"terminalTabBar": {
|
||||
"selectServerTitle": "接続するサーバーを選択",
|
||||
"showTransferProgressTooltip": "転送進捗の表示/非表示"
|
||||
"showTransferProgressTooltip": "転送進捗の表示/非表示",
|
||||
"newTerminalTooltip": "現在のサーバーに新しいターミナルを追加",
|
||||
"openConnectionPickerTooltip": "別のサーバーを選択",
|
||||
"terminalBadge": "端末 {index}"
|
||||
},
|
||||
"workspace": {
|
||||
"terminal": {
|
||||
|
||||
@@ -1461,7 +1461,10 @@
|
||||
},
|
||||
"terminalTabBar": {
|
||||
"selectServerTitle": "选择要连接的服务器",
|
||||
"showTransferProgressTooltip": "显示/隐藏传输进度"
|
||||
"showTransferProgressTooltip": "显示/隐藏传输进度",
|
||||
"newTerminalTooltip": "为当前服务器新增终端",
|
||||
"openConnectionPickerTooltip": "选择其他服务器",
|
||||
"terminalBadge": "终端 {index}"
|
||||
},
|
||||
"tabs": {
|
||||
"contextMenu": {
|
||||
|
||||
@@ -21,6 +21,18 @@ const findConnectionInfo = (connectionId: number | string, connectionsStore: Ret
|
||||
return connectionsStore.connections.find(c => c.id === Number(connectionId));
|
||||
};
|
||||
|
||||
const getNextTerminalIndex = (connectionId: string): number => {
|
||||
let maxTerminalIndex = 0;
|
||||
|
||||
sessions.value.forEach((session) => {
|
||||
if (session.connectionId === connectionId) {
|
||||
maxTerminalIndex = Math.max(maxTerminalIndex, session.terminalIndex || 0);
|
||||
}
|
||||
});
|
||||
|
||||
return maxTerminalIndex + 1;
|
||||
};
|
||||
|
||||
// --- Actions ---
|
||||
export const openNewSession = (
|
||||
connectionOrId: ConnectionInfo | number | string,
|
||||
@@ -51,6 +63,7 @@ export const openNewSession = (
|
||||
|
||||
const newSessionId = existingSessionId || generateSessionId();
|
||||
const dbConnId = String(connInfo.id); // connInfo is now guaranteed to be defined here
|
||||
const terminalIndex = getNextTerminalIndex(dbConnId);
|
||||
|
||||
// 1. 创建管理器实例
|
||||
const isResume = !!existingSessionId; // 如果提供了 existingSessionId,则为恢复流程
|
||||
@@ -60,6 +73,7 @@ export const openNewSession = (
|
||||
sessionId: newSessionId,
|
||||
connectionId: dbConnId,
|
||||
connectionName: connInfo.name || connInfo.host,
|
||||
terminalIndex,
|
||||
editorTabs: ref([]),
|
||||
activeEditorTabId: ref(null),
|
||||
commandInputContent: ref(''),
|
||||
@@ -115,7 +129,7 @@ export const openNewSession = (
|
||||
newSessionsMap.set(newSessionId, newSession);
|
||||
sessions.value = newSessionsMap;
|
||||
activeSessionId.value = newSessionId;
|
||||
console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId}`);
|
||||
console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId} (terminal #${terminalIndex})`);
|
||||
|
||||
// +++ 在连接前设置 ssh:connected 处理器以更新 sessionId +++
|
||||
const originalFrontendSessionIdForHandler = newSessionId; // 捕获初始ID给闭包
|
||||
@@ -320,4 +334,4 @@ export const cleanupAllSessions = () => {
|
||||
sessions.value = newSessionsMap;
|
||||
}
|
||||
activeSessionId.value = null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { SessionState, SessionTabInfoWithStatus } from './types';
|
||||
export const sessionTabs = computed(() => {
|
||||
return Array.from(sessions.value.values()).map(session => ({
|
||||
sessionId: session.sessionId,
|
||||
connectionId: session.connectionId,
|
||||
connectionName: session.connectionName,
|
||||
terminalIndex: session.terminalIndex,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -39,7 +41,9 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
|
||||
})
|
||||
.map(session => ({
|
||||
sessionId: session.sessionId,
|
||||
connectionId: session.connectionId,
|
||||
connectionName: session.connectionName,
|
||||
terminalIndex: session.terminalIndex,
|
||||
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
|
||||
isMarkedForSuspend: session.isMarkedForSuspend,
|
||||
}));
|
||||
@@ -49,7 +53,9 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
.map(session => ({
|
||||
sessionId: session.sessionId,
|
||||
connectionId: session.connectionId,
|
||||
connectionName: session.connectionName,
|
||||
terminalIndex: session.terminalIndex,
|
||||
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
|
||||
isMarkedForSuspend: session.isMarkedForSuspend,
|
||||
}));
|
||||
@@ -59,4 +65,4 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
|
||||
export const activeSession = computed((): SessionState | null => {
|
||||
if (!activeSessionId.value) return null;
|
||||
return sessions.value.get(activeSessionId.value) || null;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface SessionState {
|
||||
sessionId: string;
|
||||
connectionId: string; // 数据库中的连接 ID
|
||||
connectionName: string; // 用于显示
|
||||
terminalIndex: number; // 同一连接下的终端序号,从 1 开始
|
||||
wsManager: WsManagerInstance;
|
||||
sftpManagers: Map<string, SftpManagerInstance>; // 使用 Map 管理多个实例
|
||||
terminalManager: SshTerminalInstance;
|
||||
@@ -49,7 +50,9 @@ export interface SessionState {
|
||||
// 为标签栏定义包含状态的类型
|
||||
export interface SessionTabInfoWithStatus {
|
||||
sessionId: string;
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
terminalIndex: number;
|
||||
status: WsConnectionStatus; // 添加状态字段
|
||||
isMarkedForSuspend?: boolean; // +++ 用于UI指示会话是否已标记待挂起 +++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue';
|
||||
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
@@ -17,7 +17,8 @@ import { zhCN, enUS, ja } from 'date-fns/locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
|
||||
type ConnectionTypeFilter = 'ALL' | 'SSH' | 'RDP' | 'VNC';
|
||||
type ScopeId = 'all' | 'untagged' | `tag:${number}`;
|
||||
type ScopeId = 'all' | 'untagged' | `tag:${number}` | `group:${string}`;
|
||||
type ConnectionSortField = SortField | 'host';
|
||||
|
||||
interface ScopeNode {
|
||||
id: ScopeId;
|
||||
@@ -25,6 +26,13 @@ interface ScopeNode {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface TagTreeNode extends ScopeNode {
|
||||
fullLabel: string;
|
||||
level: number;
|
||||
expandable: boolean;
|
||||
children: TagTreeNode[];
|
||||
}
|
||||
|
||||
interface ConnectionTestState {
|
||||
status: 'idle' | 'testing' | 'success' | 'error';
|
||||
resultText: string;
|
||||
@@ -48,12 +56,17 @@ const LS_FILTER_TAG_KEY = 'connections_view_filter_tag';
|
||||
const LS_FILTER_SCOPE_KEY = 'connections_view_filter_scope';
|
||||
const LS_TYPE_FILTER_KEY = 'connections_view_type_filter';
|
||||
|
||||
const localSortBy = ref<SortField>(localStorage.getItem(LS_SORT_BY_KEY) as SortField || 'last_connected_at');
|
||||
const localSortBy = ref<ConnectionSortField>((localStorage.getItem(LS_SORT_BY_KEY) as ConnectionSortField) || 'last_connected_at');
|
||||
const localSortOrder = ref<SortOrder>(localStorage.getItem(LS_SORT_ORDER_KEY) as SortOrder || 'desc');
|
||||
|
||||
const getInitialSelectedScope = (): ScopeId => {
|
||||
const storedScope = localStorage.getItem(LS_FILTER_SCOPE_KEY);
|
||||
if (storedScope === 'all' || storedScope === 'untagged' || storedScope?.startsWith('tag:')) {
|
||||
if (
|
||||
storedScope === 'all' ||
|
||||
storedScope === 'untagged' ||
|
||||
storedScope?.startsWith('tag:') ||
|
||||
storedScope?.startsWith('group:')
|
||||
) {
|
||||
return storedScope as ScopeId;
|
||||
}
|
||||
|
||||
@@ -80,21 +93,45 @@ const isBatchEditMode = ref(false);
|
||||
const selectedConnectionIdsForBatch = ref<Set<number>>(new Set());
|
||||
const showBatchEditForm = ref(false);
|
||||
const isDeletingSelectedConnections = ref(false);
|
||||
const expandedTreeNodes = ref<Record<string, boolean>>({});
|
||||
|
||||
const connectionTestStates = ref<Map<number, ConnectionTestState>>(new Map());
|
||||
const isTestingAll = ref(false);
|
||||
const isConnectingAll = ref(false);
|
||||
const moreMenuOpenForId = ref<number | null>(null);
|
||||
|
||||
const sortOptions: { value: SortField; labelKey: string }[] = [
|
||||
const sortOptions: { value: ConnectionSortField; labelKey: string }[] = [
|
||||
{ value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' },
|
||||
{ value: 'name', labelKey: 'dashboard.sortOptions.name' },
|
||||
{ value: 'host', labelKey: 'connections.table.host' },
|
||||
{ value: 'type', labelKey: 'dashboard.sortOptions.type' },
|
||||
{ value: 'updated_at', labelKey: 'dashboard.sortOptions.updated' },
|
||||
{ value: 'created_at', labelKey: 'dashboard.sortOptions.created' },
|
||||
];
|
||||
|
||||
const TREE_EXPANDED_STORAGE_KEY = 'connections_view_tree_expanded';
|
||||
const tagPathSeparatorRegex = /\s*(?:\/|>|\\)\s*/;
|
||||
|
||||
const normalizedSearchQuery = computed(() => searchQuery.value.toLowerCase().trim());
|
||||
|
||||
const loadInitialExpandedTreeState = (): Record<string, boolean> => {
|
||||
try {
|
||||
const rawValue = localStorage.getItem(TREE_EXPANDED_STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawValue);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch (error) {
|
||||
console.error('读取连接管理树展开状态失败:', error);
|
||||
localStorage.removeItem(TREE_EXPANDED_STORAGE_KEY);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
expandedTreeNodes.value = loadInitialExpandedTreeState();
|
||||
|
||||
const tagLookup = computed(() => {
|
||||
const map = new Map<number, TagInfo>();
|
||||
(tags.value as TagInfo[]).forEach((tag) => {
|
||||
@@ -113,6 +150,21 @@ const getConnectionTagNames = (conn: ConnectionInfo): string[] => {
|
||||
.filter((tagName): tagName is string => Boolean(tagName));
|
||||
};
|
||||
|
||||
const getTagPathSegments = (tagName: string): string[] => {
|
||||
return tagName
|
||||
.split(tagPathSeparatorRegex)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const encodeGroupScopeId = (pathKey: string): ScopeId => {
|
||||
return `group:${encodeURIComponent(pathKey)}`;
|
||||
};
|
||||
|
||||
const decodeGroupScopeId = (scopeId: ScopeId): string => {
|
||||
return decodeURIComponent(scopeId.replace('group:', ''));
|
||||
};
|
||||
|
||||
const matchesSearchQuery = (conn: ConnectionInfo, query: string): boolean => {
|
||||
if (!query) {
|
||||
return true;
|
||||
@@ -141,7 +193,19 @@ const matchesScope = (conn: ConnectionInfo, scope: ScopeId): boolean => {
|
||||
}
|
||||
|
||||
const tagId = parseInt(scope.replace('tag:', ''), 10);
|
||||
return conn.tag_ids?.includes(tagId) ?? false;
|
||||
if (!Number.isNaN(tagId) && conn.tag_ids?.includes(tagId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (scope.startsWith('group:')) {
|
||||
const pathPrefix = decodeGroupScopeId(scope);
|
||||
return getConnectionTagNames(conn).some((tagName) => {
|
||||
const pathKey = getTagPathSegments(tagName).join('/');
|
||||
return pathKey === pathPrefix || pathKey.startsWith(`${pathPrefix}/`);
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const matchedConnections = computed(() => {
|
||||
@@ -151,16 +215,110 @@ const matchedConnections = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const tagTreeNodes = computed<ScopeNode[]>(() => {
|
||||
return (tags.value as TagInfo[])
|
||||
.map((tag) => ({
|
||||
id: `tag:${tag.id}` as ScopeId,
|
||||
label: tag.name,
|
||||
count: matchedConnections.value.filter((conn) => conn.tag_ids?.includes(tag.id)).length,
|
||||
}))
|
||||
.sort((left, right) => left.label.localeCompare(right.label));
|
||||
const tagTreeNodes = computed<TagTreeNode[]>(() => {
|
||||
type DraftTreeNode = {
|
||||
id: ScopeId;
|
||||
label: string;
|
||||
fullLabel: string;
|
||||
children: Map<string, DraftTreeNode>;
|
||||
tagId: number | null;
|
||||
};
|
||||
|
||||
const rootChildren = new Map<string, DraftTreeNode>();
|
||||
|
||||
(tags.value as TagInfo[]).forEach((tag) => {
|
||||
const segments = getTagPathSegments(tag.name);
|
||||
if (!segments.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentChildren = rootChildren;
|
||||
const currentPathSegments: string[] = [];
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
currentPathSegments.push(segment);
|
||||
const pathKey = currentPathSegments.join('/');
|
||||
const isLeaf = index === segments.length - 1;
|
||||
const nodeKey = (isLeaf ? `tag:${tag.id}` : encodeGroupScopeId(pathKey)) as ScopeId;
|
||||
|
||||
if (!currentChildren.has(nodeKey)) {
|
||||
currentChildren.set(nodeKey, {
|
||||
id: nodeKey,
|
||||
label: segment,
|
||||
fullLabel: currentPathSegments.join(' / '),
|
||||
children: new Map<string, DraftTreeNode>(),
|
||||
tagId: isLeaf ? tag.id : null,
|
||||
});
|
||||
}
|
||||
|
||||
const currentNode = currentChildren.get(nodeKey)!;
|
||||
if (isLeaf) {
|
||||
currentNode.fullLabel = tag.name;
|
||||
currentNode.tagId = tag.id;
|
||||
}
|
||||
currentChildren = currentNode.children;
|
||||
});
|
||||
});
|
||||
|
||||
const buildNodes = (source: Map<string, DraftTreeNode>, level: number): TagTreeNode[] => {
|
||||
return Array.from(source.values())
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map((node) => {
|
||||
const children = buildNodes(node.children, level + 1);
|
||||
const count =
|
||||
node.tagId !== null
|
||||
? matchedConnections.value.filter((conn) => conn.tag_ids?.includes(node.tagId!)).length
|
||||
: matchedConnections.value.filter((conn) => matchesScope(conn, node.id)).length;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
fullLabel: node.fullLabel,
|
||||
count,
|
||||
level,
|
||||
expandable: children.length > 0,
|
||||
children,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return buildNodes(rootChildren, 0);
|
||||
});
|
||||
|
||||
const visibleTagTreeNodes = computed<TagTreeNode[]>(() => {
|
||||
const rows: TagTreeNode[] = [];
|
||||
|
||||
const appendVisibleNodes = (nodes: TagTreeNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
rows.push(node);
|
||||
if (node.expandable && (expandedTreeNodes.value[node.id] ?? true)) {
|
||||
appendVisibleNodes(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
appendVisibleNodes(tagTreeNodes.value);
|
||||
return rows;
|
||||
});
|
||||
|
||||
const expandableTreeNodeIds = computed<ScopeId[]>(() => {
|
||||
const ids: ScopeId[] = [];
|
||||
|
||||
const collectNodeIds = (nodes: TagTreeNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.expandable) {
|
||||
ids.push(node.id);
|
||||
collectNodeIds(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
collectNodeIds(tagTreeNodes.value);
|
||||
return ids;
|
||||
});
|
||||
|
||||
const hasExpandableTreeNodes = computed(() => expandableTreeNodeIds.value.length > 0);
|
||||
|
||||
const primaryScopeNodes = computed<ScopeNode[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -192,6 +350,10 @@ const filteredAndSortedConnections = computed(() => {
|
||||
leftValue = left.name || left.host;
|
||||
rightValue = right.name || right.host;
|
||||
return String(leftValue).localeCompare(String(rightValue)) * sortOrderFactor;
|
||||
case 'host':
|
||||
leftValue = left.host || '';
|
||||
rightValue = right.host || '';
|
||||
return String(leftValue).localeCompare(String(rightValue)) * sortOrderFactor;
|
||||
case 'type':
|
||||
leftValue = left.type || '';
|
||||
rightValue = right.type || '';
|
||||
@@ -225,6 +387,10 @@ const selectedScopeTitle = computed(() => {
|
||||
return t('connections.untaggedGroup', '未标记');
|
||||
}
|
||||
|
||||
if (selectedScope.value.startsWith('group:')) {
|
||||
return decodeGroupScopeId(selectedScope.value).replaceAll('/', ' / ');
|
||||
}
|
||||
|
||||
const selectedTagId = parseInt(selectedScope.value.replace('tag:', ''), 10);
|
||||
return tagLookup.value.get(selectedTagId)?.name || t('connections.table.tags', '标签');
|
||||
});
|
||||
@@ -404,6 +570,14 @@ watch(activeTypeFilter, (newValue) => {
|
||||
localStorage.setItem(LS_TYPE_FILTER_KEY, newValue);
|
||||
});
|
||||
|
||||
watch(
|
||||
expandedTreeNodes,
|
||||
(newValue) => {
|
||||
localStorage.setItem(TREE_EXPANDED_STORAGE_KEY, JSON.stringify(newValue));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch([selectedScope, activeTypeFilter, searchQuery], () => {
|
||||
if (isBatchEditMode.value) {
|
||||
const visibleIds = new Set(filteredAndSortedConnections.value.map((conn) => conn.id));
|
||||
@@ -419,6 +593,38 @@ const selectScope = (scopeId: ScopeId) => {
|
||||
selectedScope.value = scopeId;
|
||||
};
|
||||
|
||||
const toggleTreeNode = (nodeId: ScopeId) => {
|
||||
expandedTreeNodes.value[nodeId] = !(expandedTreeNodes.value[nodeId] ?? true);
|
||||
};
|
||||
|
||||
const handleTreeNodeSelect = (node: TagTreeNode) => {
|
||||
selectScope(node.id);
|
||||
if (node.expandable) {
|
||||
toggleTreeNode(node.id);
|
||||
}
|
||||
};
|
||||
|
||||
const expandAllTreeNodes = () => {
|
||||
if (!hasExpandableTreeNodes.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
tagsSectionExpanded.value = true;
|
||||
expandedTreeNodes.value = Object.fromEntries(expandableTreeNodeIds.value.map((nodeId) => [nodeId, true]));
|
||||
};
|
||||
|
||||
const collapseAllTreeNodes = () => {
|
||||
if (!hasExpandableTreeNodes.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandedTreeNodes.value = Object.fromEntries(expandableTreeNodeIds.value.map((nodeId) => [nodeId, false]));
|
||||
};
|
||||
|
||||
const resetScopeSelection = () => {
|
||||
selectScope('all');
|
||||
};
|
||||
|
||||
const connectTo = (connection: ConnectionInfo) => {
|
||||
sessionStore.handleConnectRequest(connection);
|
||||
};
|
||||
@@ -437,6 +643,16 @@ const openEditConnectionForm = (connection: ConnectionInfo) => {
|
||||
showAddEditConnectionForm.value = true;
|
||||
};
|
||||
|
||||
const handleSortByColumn = (field: ConnectionSortField) => {
|
||||
if (localSortBy.value === field) {
|
||||
toggleSortOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
localSortBy.value = field;
|
||||
localSortOrder.value = field === 'last_connected_at' ? 'desc' : 'asc';
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
showAddEditConnectionForm.value = false;
|
||||
connectionToEdit.value = null;
|
||||
@@ -561,6 +777,34 @@ const handleBatchDeleteConnections = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneConnection = async (connection: ConnectionInfo) => {
|
||||
const allConnections = connectionsStore.connections;
|
||||
const baseName = connection.name || connection.host;
|
||||
let counter = 1;
|
||||
let newName = `${baseName} (${counter})`;
|
||||
|
||||
while (allConnections.some((item) => item.name === newName)) {
|
||||
counter += 1;
|
||||
newName = `${baseName} (${counter})`;
|
||||
}
|
||||
|
||||
await connectionsStore.cloneConnection(connection.id, newName);
|
||||
await connectionsStore.fetchConnections();
|
||||
};
|
||||
|
||||
const handleDeleteSingleConnection = async (connection: ConnectionInfo) => {
|
||||
const confirmed = await showConfirmDialog({
|
||||
message: t('connections.prompts.confirmDelete', { name: connection.name || connection.host }),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await connectionsStore.deleteConnection(connection.id);
|
||||
await connectionsStore.fetchConnections();
|
||||
};
|
||||
|
||||
const handleTestSingleConnection = async (connection: ConnectionInfo) => {
|
||||
if (!connection.id || connection.type !== 'SSH') {
|
||||
return;
|
||||
@@ -636,6 +880,26 @@ const handleConnectAllFilteredConnections = async () => {
|
||||
isConnectingAll.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMoreMenu = (connectionId: number) => {
|
||||
moreMenuOpenForId.value = moreMenuOpenForId.value === connectionId ? null : connectionId;
|
||||
};
|
||||
|
||||
const closeMoreMenu = () => {
|
||||
moreMenuOpenForId.value = null;
|
||||
};
|
||||
|
||||
const handleGlobalClick = () => {
|
||||
closeMoreMenu();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleGlobalClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleGlobalClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -705,28 +969,72 @@ const handleConnectAllFilteredConnections = async () => {
|
||||
<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 class="text-[11px] tracking-normal normal-case text-text-secondary">{{ tagTreeNodes.length }}</span>
|
||||
<span class="text-[11px] tracking-normal normal-case text-text-secondary">{{ visibleTagTreeNodes.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-show="tagsSectionExpanded" class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="node in tagTreeNodes"
|
||||
<div v-show="tagsSectionExpanded" class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2 px-2">
|
||||
<button
|
||||
@click="expandAllTreeNodes"
|
||||
:disabled="!hasExpandableTreeNodes"
|
||||
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i class="fas fa-square-plus"></i>
|
||||
<span>{{ t('common.expandAll', '展开全部') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="collapseAllTreeNodes"
|
||||
:disabled="!hasExpandableTreeNodes"
|
||||
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="node in visibleTagTreeNodes"
|
||||
:key="node.id"
|
||||
@click="selectScope(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` }"
|
||||
>
|
||||
<span class="flex items-center gap-2 min-w-0">
|
||||
<i class="fas fa-folder-tree w-4 text-center"></i>
|
||||
<span class="truncate">{{ node.label }}</span>
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-xs border border-current/15 bg-black/10">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -891,11 +1199,20 @@ const handleConnectAllFilteredConnections = async () => {
|
||||
</div>
|
||||
|
||||
<div v-else class="min-w-0">
|
||||
<div class="hidden xl:grid grid-cols-[minmax(0,2.2fr)_minmax(0,1.4fr)_minmax(0,1.3fr)_160px_220px] gap-4 px-4 py-3 text-xs font-semibold uppercase tracking-[0.16em] text-text-secondary border-b border-border/50 bg-background/40 sticky top-0 z-10">
|
||||
<div>{{ t('connections.table.name', '名称') }}</div>
|
||||
<div>{{ t('connections.table.host', '地址') }}</div>
|
||||
<div class="hidden xl:grid grid-cols-[minmax(0,2.25fr)_minmax(0,1.45fr)_minmax(0,1.25fr)_160px_170px] gap-4 px-4 py-3 text-xs font-semibold uppercase tracking-[0.16em] text-text-secondary border-b border-border/50 bg-background/40 sticky top-0 z-10">
|
||||
<button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('name')">
|
||||
<span>{{ t('connections.table.name', '名称') }}</span>
|
||||
<i v-if="localSortBy === 'name'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
|
||||
</button>
|
||||
<button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('host')">
|
||||
<span>{{ t('connections.table.host', '地址') }}</span>
|
||||
<i v-if="localSortBy === 'host'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
|
||||
</button>
|
||||
<div>{{ t('connections.table.tags', '标签 / 备注') }}</div>
|
||||
<div>{{ t('dashboard.lastConnected', '上次连接') }}</div>
|
||||
<button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('last_connected_at')">
|
||||
<span>{{ t('dashboard.lastConnected', '上次连接') }}</span>
|
||||
<i v-if="localSortBy === 'last_connected_at'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
|
||||
</button>
|
||||
<div>{{ t('connections.table.actions', '操作') }}</div>
|
||||
</div>
|
||||
|
||||
@@ -910,7 +1227,7 @@ const handleConnectAllFilteredConnections = async () => {
|
||||
isBatchEditMode && isConnectionSelectedForBatch(conn.id) ? 'bg-primary/10' : ''
|
||||
]"
|
||||
>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,2.2fr)_minmax(0,1.4fr)_minmax(0,1.3fr)_160px_220px] gap-4 items-start">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,2.25fr)_minmax(0,1.45fr)_minmax(0,1.25fr)_160px_170px] gap-4 items-start">
|
||||
<div class="min-w-0 flex items-start gap-3">
|
||||
<input
|
||||
v-if="isBatchEditMode"
|
||||
@@ -997,27 +1314,7 @@ const handleConnectAllFilteredConnections = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-start xl:justify-end gap-2">
|
||||
<button
|
||||
v-if="conn.type === 'SSH'"
|
||||
@click.stop="handleTestSingleConnection(conn)"
|
||||
:disabled="isBatchEditMode || getSingleTestButtonInfo(conn.id, conn.type).disabled"
|
||||
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2 text-sm"
|
||||
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
|
||||
>
|
||||
<i :class="getSingleTestButtonInfo(conn.id, conn.type).iconClass"></i>
|
||||
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click.stop="openEditConnectionForm(conn)"
|
||||
:disabled="isBatchEditMode"
|
||||
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2 text-sm"
|
||||
>
|
||||
<i class="fas fa-pen"></i>
|
||||
<span>{{ t('connections.actions.edit', '编辑') }}</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap justify-start xl:justify-end gap-2 relative">
|
||||
<button
|
||||
@click.stop="connectTo(conn)"
|
||||
:disabled="isBatchEditMode"
|
||||
@@ -1026,6 +1323,55 @@ const handleConnectAllFilteredConnections = async () => {
|
||||
<i class="fas fa-arrow-right-to-bracket"></i>
|
||||
<span>{{ t('connections.actions.connect', '连接') }}</span>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
@click.stop="toggleMoreMenu(conn.id)"
|
||||
:disabled="isBatchEditMode"
|
||||
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i class="fas fa-ellipsis"></i>
|
||||
<span>{{ t('common.more', '更多') }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="moreMenuOpenForId === conn.id"
|
||||
class="absolute right-0 top-full mt-2 w-48 rounded-xl border border-border bg-background shadow-xl z-20 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2"
|
||||
@click.stop="openEditConnectionForm(conn); closeMoreMenu()"
|
||||
>
|
||||
<i class="fas fa-pen w-4 text-center"></i>
|
||||
<span>{{ t('connections.actions.edit', '编辑') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="conn.type === 'SSH'"
|
||||
:disabled="getSingleTestButtonInfo(conn.id, conn.type).disabled"
|
||||
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
|
||||
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click.stop="handleTestSingleConnection(conn); closeMoreMenu()"
|
||||
>
|
||||
<i :class="[getSingleTestButtonInfo(conn.id, conn.type).iconClass, 'w-4 text-center']"></i>
|
||||
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2"
|
||||
@click.stop="handleCloneConnection(conn); closeMoreMenu()"
|
||||
>
|
||||
<i class="fas fa-clone w-4 text-center"></i>
|
||||
<span>{{ t('connections.actions.clone', '克隆') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-error hover:bg-error/10 transition-colors flex items-center gap-2"
|
||||
@click.stop="handleDeleteSingleConnection(conn); closeMoreMenu()"
|
||||
>
|
||||
<i class="fas fa-trash-alt w-4 text-center"></i>
|
||||
<span>{{ t('connections.actions.delete', '删除') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user