9e49fcea61
为连接管理页左侧标签树增加悬停工具按钮, 补充分隔标题行与节点拖拽目标高亮提示。 同时同步多语言文案与 helloagents 归档记录, 为后续真实重排交互预留可见反馈入口
1626 lines
64 KiB
Vue
1626 lines
64 KiB
Vue
<script setup lang="ts">
|
|
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';
|
|
import { useSessionStore } from '../stores/session.store';
|
|
import { useTagsStore } from '../stores/tags.store';
|
|
import type { TagInfo } from '../stores/tags.store';
|
|
import type { SortField, SortOrder } from '../stores/settings.store';
|
|
import { useI18n } from 'vue-i18n';
|
|
import type { ConnectionInfo } from '../stores/connections.store';
|
|
import { useConfirmDialog } from '../composables/useConfirmDialog';
|
|
import { useAlertDialog } from '../composables/useAlertDialog';
|
|
import { storeToRefs } from 'pinia';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
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}` | `group:${string}`;
|
|
type ConnectionSortField = SortField | 'host';
|
|
|
|
interface ScopeNode {
|
|
id: ScopeId;
|
|
label: string;
|
|
count: number;
|
|
}
|
|
|
|
interface TagTreeNode extends ScopeNode {
|
|
fullLabel: string;
|
|
level: number;
|
|
expandable: boolean;
|
|
children: TagTreeNode[];
|
|
}
|
|
|
|
interface ConnectionTestState {
|
|
status: 'idle' | 'testing' | 'success' | 'error';
|
|
resultText: string;
|
|
latency?: number;
|
|
latencyColor?: string;
|
|
}
|
|
|
|
const { t, locale } = useI18n();
|
|
const { showConfirmDialog } = useConfirmDialog();
|
|
const { showAlertDialog } = useAlertDialog();
|
|
const connectionsStore = useConnectionsStore();
|
|
const sessionStore = useSessionStore();
|
|
const tagsStore = useTagsStore();
|
|
|
|
const { connections, isLoading: isLoadingConnections } = storeToRefs(connectionsStore);
|
|
const { tags } = storeToRefs(tagsStore);
|
|
|
|
const LS_SORT_BY_KEY = 'connections_view_sort_by';
|
|
const LS_SORT_ORDER_KEY = 'connections_view_sort_order';
|
|
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<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:') ||
|
|
storedScope?.startsWith('group:')
|
|
) {
|
|
return storedScope as ScopeId;
|
|
}
|
|
|
|
const legacyTagValue = localStorage.getItem(LS_FILTER_TAG_KEY);
|
|
if (legacyTagValue && legacyTagValue !== 'null') {
|
|
const parsedTagId = parseInt(legacyTagValue, 10);
|
|
if (!Number.isNaN(parsedTagId)) {
|
|
return `tag:${parsedTagId}`;
|
|
}
|
|
}
|
|
|
|
return 'all';
|
|
};
|
|
|
|
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);
|
|
const connectionToEdit = ref<ConnectionInfo | null>(null);
|
|
|
|
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 hoveredTreeNodeId = ref<ScopeId | null>(null);
|
|
const draggingTreeNodeId = ref<ScopeId | null>(null);
|
|
const dropTargetTreeNodeId = ref<ScopeId | null>(null);
|
|
const treeDragNoticeVisible = ref(false);
|
|
|
|
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: 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 normalizedTreeSearchQuery = computed(() => treeSearchQuery.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) => {
|
|
map.set(tag.id, tag);
|
|
});
|
|
return map;
|
|
});
|
|
|
|
const getConnectionTagNames = (conn: ConnectionInfo): string[] => {
|
|
if (!conn.tag_ids?.length) {
|
|
return [];
|
|
}
|
|
|
|
return conn.tag_ids
|
|
.map((tagId) => tagLookup.value.get(tagId)?.name)
|
|
.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;
|
|
}
|
|
|
|
const haystacks = [
|
|
conn.name,
|
|
conn.host,
|
|
conn.username,
|
|
conn.notes,
|
|
conn.type,
|
|
conn.port?.toString(),
|
|
...getConnectionTagNames(conn),
|
|
];
|
|
|
|
return haystacks.some((value) => value?.toLowerCase().includes(query));
|
|
};
|
|
|
|
const matchesScope = (conn: ConnectionInfo, scope: ScopeId): boolean => {
|
|
if (scope === 'all') {
|
|
return true;
|
|
}
|
|
|
|
if (scope === 'untagged') {
|
|
return !conn.tag_ids?.length;
|
|
}
|
|
|
|
const tagId = parseInt(scope.replace('tag:', ''), 10);
|
|
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(() => {
|
|
return connections.value.filter((conn) => {
|
|
const typeMatched = activeTypeFilter.value === 'ALL' || conn.type === activeTypeFilter.value;
|
|
return typeMatched && matchesSearchQuery(conn, normalizedSearchQuery.value);
|
|
});
|
|
});
|
|
|
|
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 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 && (isSearchMode || (expandedTreeNodes.value[node.id] ?? true))) {
|
|
appendVisibleNodes(node.children);
|
|
}
|
|
});
|
|
};
|
|
|
|
appendVisibleNodes(filteredTagTreeNodes.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 hasTreeSearchResults = computed(() => visibleTagTreeNodes.value.length > 0);
|
|
|
|
const primaryScopeNodes = computed<ScopeNode[]>(() => {
|
|
return [
|
|
{
|
|
id: 'all',
|
|
label: t('dashboard.filterTags.all', '全部'),
|
|
count: matchedConnections.value.length,
|
|
},
|
|
{
|
|
id: 'untagged',
|
|
label: t('connections.untaggedGroup', '未标记'),
|
|
count: matchedConnections.value.filter((conn) => !conn.tag_ids?.length).length,
|
|
},
|
|
];
|
|
});
|
|
|
|
const filteredAndSortedConnections = computed(() => {
|
|
const sortBy = localSortBy.value;
|
|
const sortOrderFactor = localSortOrder.value === 'desc' ? -1 : 1;
|
|
|
|
return matchedConnections.value
|
|
.filter((conn) => matchesScope(conn, selectedScope.value))
|
|
.slice()
|
|
.sort((left, right) => {
|
|
let leftValue: string | number | null = null;
|
|
let rightValue: string | number | null = null;
|
|
|
|
switch (sortBy) {
|
|
case 'name':
|
|
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 || '';
|
|
return String(leftValue).localeCompare(String(rightValue)) * sortOrderFactor;
|
|
case 'created_at':
|
|
leftValue = left.created_at ?? 0;
|
|
rightValue = right.created_at ?? 0;
|
|
return (Number(leftValue) - Number(rightValue)) * sortOrderFactor;
|
|
case 'updated_at':
|
|
leftValue = left.updated_at ?? 0;
|
|
rightValue = right.updated_at ?? 0;
|
|
return (Number(leftValue) - Number(rightValue)) * sortOrderFactor;
|
|
case 'last_connected_at':
|
|
default:
|
|
leftValue = left.last_connected_at ?? (localSortOrder.value === 'desc' ? -Infinity : Infinity);
|
|
rightValue = right.last_connected_at ?? (localSortOrder.value === 'desc' ? -Infinity : Infinity);
|
|
if (leftValue === rightValue) {
|
|
return 0;
|
|
}
|
|
return Number(leftValue) < Number(rightValue) ? -1 * sortOrderFactor : 1 * sortOrderFactor;
|
|
}
|
|
});
|
|
});
|
|
|
|
const selectedScopeTitle = computed(() => {
|
|
if (selectedScope.value === 'all') {
|
|
return t('dashboard.filterTags.all', '全部');
|
|
}
|
|
|
|
if (selectedScope.value === 'untagged') {
|
|
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', '标签');
|
|
});
|
|
|
|
const currentRangeHint = computed(() => {
|
|
if (selectedScope.value === 'all') {
|
|
return t('connections.searchHint', '搜索名称、地址、用户、备注和标签');
|
|
}
|
|
|
|
if (selectedScope.value === 'untagged') {
|
|
return t('connections.untaggedHint', '当前仅显示未绑定标签的连接');
|
|
}
|
|
|
|
return t('connections.tagScopeHint', '当前仅显示所选标签下的连接');
|
|
});
|
|
|
|
const typeCounts = computed(() => {
|
|
const counts: Record<ConnectionTypeFilter, number> = {
|
|
ALL: connections.value.length,
|
|
SSH: connections.value.filter((conn) => conn.type === 'SSH').length,
|
|
RDP: connections.value.filter((conn) => conn.type === 'RDP').length,
|
|
VNC: connections.value.filter((conn) => conn.type === 'VNC').length,
|
|
};
|
|
|
|
return [
|
|
{ value: 'ALL' as const, label: t('common.all', '全部'), count: counts.ALL },
|
|
{ value: 'SSH' as const, label: 'SSH', count: counts.SSH },
|
|
{ value: 'RDP' as const, label: 'RDP', count: counts.RDP },
|
|
{ value: 'VNC' as const, label: 'VNC', count: counts.VNC },
|
|
];
|
|
});
|
|
|
|
const selectedResultCount = computed(() => selectedConnectionIdsForBatch.value.size);
|
|
const isAscending = computed(() => localSortOrder.value === 'asc');
|
|
|
|
const dateFnsLocales: Record<string, Locale> = {
|
|
'en-US': enUS,
|
|
'zh-CN': zhCN,
|
|
'ja-JP': ja,
|
|
en: enUS,
|
|
zh: zhCN,
|
|
ja,
|
|
};
|
|
|
|
const formatRelativeTime = (timestampInSeconds: number | null | undefined): string => {
|
|
if (!timestampInSeconds) {
|
|
return t('connections.status.never', '从未');
|
|
}
|
|
|
|
try {
|
|
const timestampInMs = timestampInSeconds * 1000;
|
|
const currentLocale = locale.value;
|
|
const languagePart = currentLocale.split('-')[0];
|
|
const targetLocale = dateFnsLocales[currentLocale] || dateFnsLocales[languagePart] || enUS;
|
|
return formatDistanceToNow(new Date(timestampInMs), { addSuffix: true, locale: targetLocale });
|
|
} catch (error) {
|
|
console.error('格式化连接时间失败:', error);
|
|
return String(timestampInSeconds);
|
|
}
|
|
};
|
|
|
|
const getLatencyColorString = (latencyMs?: number): string => {
|
|
if (latencyMs === undefined) {
|
|
return 'inherit';
|
|
}
|
|
if (latencyMs < 100) {
|
|
return 'var(--color-success, #4CAF50)';
|
|
}
|
|
if (latencyMs < 300) {
|
|
return 'var(--color-warning, #ff9800)';
|
|
}
|
|
return 'var(--color-error, #F44336)';
|
|
};
|
|
|
|
const getTypePillClass = (type: ConnectionTypeFilter) => {
|
|
if (activeTypeFilter.value === type) {
|
|
return 'bg-button text-button-text border-button shadow-sm';
|
|
}
|
|
return 'bg-background text-text-secondary border-border hover:bg-border hover:text-foreground';
|
|
};
|
|
|
|
const getScopeNodeClass = (nodeId: ScopeId) => {
|
|
if (selectedScope.value === nodeId) {
|
|
return 'bg-primary/15 text-foreground border-primary/30 shadow-sm';
|
|
}
|
|
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 (dropTargetTreeNodeId.value === node.id) {
|
|
return 'border-amber-400/35 bg-amber-500/10 text-foreground 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';
|
|
}
|
|
if (type === 'RDP') {
|
|
return 'bg-sky-500/12 text-sky-300 border-sky-400/25';
|
|
}
|
|
return 'bg-amber-500/12 text-amber-300 border-amber-400/25';
|
|
};
|
|
|
|
const getSingleTestButtonInfo = (connId: number | undefined, connType: string | undefined) => {
|
|
const state = connId ? connectionTestStates.value.get(connId) : undefined;
|
|
|
|
if (connType !== 'SSH') {
|
|
return {
|
|
text: t('connections.actions.test', '测试'),
|
|
iconClass: 'fas fa-flask',
|
|
disabled: true,
|
|
title: t('connections.test.onlySshSupportedTest', '仅 SSH 连接支持测试。'),
|
|
};
|
|
}
|
|
|
|
if (!connId) {
|
|
return {
|
|
text: t('connections.actions.test', '测试'),
|
|
iconClass: 'fas fa-flask',
|
|
disabled: true,
|
|
title: '',
|
|
};
|
|
}
|
|
|
|
if (state?.status === 'testing') {
|
|
return {
|
|
text: t('connections.actions.testing', '测试中'),
|
|
iconClass: 'fas fa-spinner fa-spin',
|
|
disabled: true,
|
|
title: t('connections.actions.testing', '测试中'),
|
|
};
|
|
}
|
|
|
|
return {
|
|
text: t('connections.actions.test', '测试'),
|
|
iconClass: 'fas fa-flask',
|
|
disabled: false,
|
|
title: t('connections.actions.test', '测试'),
|
|
};
|
|
};
|
|
|
|
const getTruncatedNotes = (notes: string | null | undefined): string => {
|
|
if (!notes?.trim()) {
|
|
return '';
|
|
}
|
|
|
|
const maxLength = 80;
|
|
return notes.length <= maxLength ? notes : `${notes.slice(0, maxLength)}...`;
|
|
};
|
|
|
|
const ensureDataLoaded = async () => {
|
|
if (connections.value.length === 0) {
|
|
await connectionsStore.fetchConnections();
|
|
} else {
|
|
connectionsStore.fetchConnections();
|
|
}
|
|
|
|
await tagsStore.fetchTags();
|
|
};
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
await ensureDataLoaded();
|
|
} catch (error) {
|
|
console.error('加载连接管理数据失败:', error);
|
|
}
|
|
});
|
|
|
|
watch(localSortBy, (newValue) => {
|
|
localStorage.setItem(LS_SORT_BY_KEY, newValue);
|
|
});
|
|
|
|
watch(localSortOrder, (newValue) => {
|
|
localStorage.setItem(LS_SORT_ORDER_KEY, newValue);
|
|
});
|
|
|
|
watch(selectedScope, (newValue) => {
|
|
localStorage.setItem(LS_FILTER_SCOPE_KEY, newValue);
|
|
});
|
|
|
|
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));
|
|
selectedConnectionIdsForBatch.value.forEach((id) => {
|
|
if (!visibleIds.has(id)) {
|
|
selectedConnectionIdsForBatch.value.delete(id);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
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 clearTreeSearch = () => {
|
|
treeSearchQuery.value = '';
|
|
};
|
|
|
|
const setHoveredTreeNode = (nodeId: ScopeId | null) => {
|
|
hoveredTreeNodeId.value = nodeId;
|
|
};
|
|
|
|
const toggleTreeNodeFromAction = (node: TagTreeNode) => {
|
|
if (!node.expandable) {
|
|
return;
|
|
}
|
|
|
|
toggleTreeNode(node.id);
|
|
};
|
|
|
|
const startTreeDrag = (node: TagTreeNode) => {
|
|
draggingTreeNodeId.value = node.id;
|
|
dropTargetTreeNodeId.value = node.id;
|
|
treeDragNoticeVisible.value = true;
|
|
};
|
|
|
|
const updateTreeDropTarget = (node: TagTreeNode) => {
|
|
if (!draggingTreeNodeId.value || draggingTreeNodeId.value === node.id) {
|
|
return;
|
|
}
|
|
|
|
dropTargetTreeNodeId.value = node.id;
|
|
};
|
|
|
|
const finishTreeDrag = () => {
|
|
draggingTreeNodeId.value = null;
|
|
dropTargetTreeNodeId.value = null;
|
|
treeDragNoticeVisible.value = false;
|
|
};
|
|
|
|
const connectTo = (connection: ConnectionInfo) => {
|
|
sessionStore.handleConnectRequest(connection);
|
|
};
|
|
|
|
const toggleSortOrder = () => {
|
|
localSortOrder.value = localSortOrder.value === 'asc' ? 'desc' : 'asc';
|
|
};
|
|
|
|
const openAddConnectionForm = () => {
|
|
connectionToEdit.value = null;
|
|
showAddEditConnectionForm.value = true;
|
|
};
|
|
|
|
const openEditConnectionForm = (connection: ConnectionInfo) => {
|
|
connectionToEdit.value = connection;
|
|
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;
|
|
};
|
|
|
|
const handleConnectionModified = async () => {
|
|
showAddEditConnectionForm.value = false;
|
|
connectionToEdit.value = null;
|
|
await connectionsStore.fetchConnections();
|
|
};
|
|
|
|
const toggleBatchEditMode = () => {
|
|
isBatchEditMode.value = !isBatchEditMode.value;
|
|
if (!isBatchEditMode.value) {
|
|
selectedConnectionIdsForBatch.value.clear();
|
|
}
|
|
};
|
|
|
|
const handleConnectionClick = (connId: number) => {
|
|
if (!isBatchEditMode.value) {
|
|
return;
|
|
}
|
|
|
|
if (selectedConnectionIdsForBatch.value.has(connId)) {
|
|
selectedConnectionIdsForBatch.value.delete(connId);
|
|
} else {
|
|
selectedConnectionIdsForBatch.value.add(connId);
|
|
}
|
|
};
|
|
|
|
const isConnectionSelectedForBatch = (connId: number) => {
|
|
return selectedConnectionIdsForBatch.value.has(connId);
|
|
};
|
|
|
|
const selectAllConnections = () => {
|
|
if (!isBatchEditMode.value) {
|
|
return;
|
|
}
|
|
|
|
filteredAndSortedConnections.value.forEach((conn) => selectedConnectionIdsForBatch.value.add(conn.id));
|
|
};
|
|
|
|
const deselectAllConnections = () => {
|
|
if (!isBatchEditMode.value) {
|
|
return;
|
|
}
|
|
selectedConnectionIdsForBatch.value.clear();
|
|
};
|
|
|
|
const invertSelection = () => {
|
|
if (!isBatchEditMode.value) {
|
|
return;
|
|
}
|
|
|
|
const visibleIds = new Set(filteredAndSortedConnections.value.map((conn) => conn.id));
|
|
visibleIds.forEach((id) => {
|
|
if (selectedConnectionIdsForBatch.value.has(id)) {
|
|
selectedConnectionIdsForBatch.value.delete(id);
|
|
} else {
|
|
selectedConnectionIdsForBatch.value.add(id);
|
|
}
|
|
});
|
|
};
|
|
|
|
const openBatchEditModal = () => {
|
|
if (selectedConnectionIdsForBatch.value.size === 0) {
|
|
showAlertDialog({
|
|
title: t('common.alert', '提示'),
|
|
message: t('connections.batchEdit.noSelectionForEdit', '请至少选择一个连接进行编辑。'),
|
|
});
|
|
return;
|
|
}
|
|
|
|
showBatchEditForm.value = true;
|
|
};
|
|
|
|
const handleBatchEditSaved = async () => {
|
|
showBatchEditForm.value = false;
|
|
selectedConnectionIdsForBatch.value.clear();
|
|
await connectionsStore.fetchConnections();
|
|
};
|
|
|
|
const handleBatchEditFormClose = () => {
|
|
showBatchEditForm.value = false;
|
|
};
|
|
|
|
const handleBatchDeleteConnections = async () => {
|
|
if (selectedConnectionIdsForBatch.value.size === 0 || isDeletingSelectedConnections.value) {
|
|
return;
|
|
}
|
|
|
|
const confirmed = await showConfirmDialog({
|
|
message: t(
|
|
'connections.batchEdit.confirmMessage',
|
|
{ count: selectedConnectionIdsForBatch.value.size },
|
|
`您确定要删除选中的 ${selectedConnectionIdsForBatch.value.size} 个连接吗?此操作无法撤销。`,
|
|
),
|
|
});
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
isDeletingSelectedConnections.value = true;
|
|
try {
|
|
const idsToDelete = Array.from(selectedConnectionIdsForBatch.value);
|
|
await connectionsStore.deleteBatchConnections(idsToDelete);
|
|
showAlertDialog({
|
|
title: t('common.success', '成功'),
|
|
message: t('connections.batchEdit.successMessage', '选中的连接已成功删除。'),
|
|
});
|
|
selectedConnectionIdsForBatch.value.clear();
|
|
await connectionsStore.fetchConnections();
|
|
} catch (error: any) {
|
|
console.error('Batch delete connections error:', error);
|
|
showAlertDialog({
|
|
title: t('common.error', '错误'),
|
|
message: t('connections.batchEdit.errorMessage', `批量删除连接失败: ${error.message || '未知错误'}`),
|
|
});
|
|
} finally {
|
|
isDeletingSelectedConnections.value = false;
|
|
}
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
connectionTestStates.value.set(connection.id, {
|
|
status: 'testing',
|
|
resultText: t('connections.test.testingInProgress', '测试中...'),
|
|
});
|
|
|
|
try {
|
|
const result = await connectionsStore.testConnection(connection.id);
|
|
if (result.success) {
|
|
const latencyMs = result.latency;
|
|
connectionTestStates.value.set(connection.id, {
|
|
status: 'success',
|
|
resultText: latencyMs !== undefined ? `${latencyMs}ms` : t('connections.test.success', '已连接'),
|
|
latency: latencyMs,
|
|
latencyColor: getLatencyColorString(latencyMs ?? 0),
|
|
});
|
|
} else {
|
|
connectionTestStates.value.set(connection.id, {
|
|
status: 'error',
|
|
resultText: result.message || t('connections.test.unknownError', '未知错误'),
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
connectionTestStates.value.set(connection.id, {
|
|
status: 'error',
|
|
resultText: error.message || t('connections.test.unknownError', '未知错误'),
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTestAllFilteredConnections = async () => {
|
|
if (isTestingAll.value || isLoadingConnections.value) {
|
|
return;
|
|
}
|
|
|
|
const sshConnections = filteredAndSortedConnections.value.filter((conn) => conn.type === 'SSH' && conn.id != null);
|
|
if (!sshConnections.length) {
|
|
return;
|
|
}
|
|
|
|
isTestingAll.value = true;
|
|
try {
|
|
await Promise.all(
|
|
sshConnections.map(async (conn) => {
|
|
await handleTestSingleConnection(conn);
|
|
}),
|
|
);
|
|
} finally {
|
|
isTestingAll.value = false;
|
|
}
|
|
};
|
|
|
|
const handleConnectAllFilteredConnections = async () => {
|
|
if (isConnectingAll.value || isLoadingConnections.value) {
|
|
return;
|
|
}
|
|
|
|
const sshConnections = filteredAndSortedConnections.value.filter((conn) => conn.type === 'SSH');
|
|
if (!sshConnections.length) {
|
|
return;
|
|
}
|
|
|
|
isConnectingAll.value = true;
|
|
try {
|
|
sshConnections.forEach((conn) => connectTo(conn));
|
|
} catch (error) {
|
|
console.error('Error connecting to all filtered SSH connections:', error);
|
|
} finally {
|
|
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>
|
|
<div class="p-4 md:p-6 lg:p-8 bg-background text-foreground">
|
|
<div class="max-w-[1500px] mx-auto">
|
|
<div class="flex items-center justify-between gap-4 mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-semibold">{{ t('nav.connections', '连接管理') }}</h1>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
{{ currentRangeHint }}
|
|
</p>
|
|
</div>
|
|
<div class="hidden lg:flex items-center gap-2 text-sm text-text-secondary">
|
|
<span class="px-3 py-1 rounded-full border border-border bg-header/60">
|
|
{{ selectedScopeTitle }}
|
|
</span>
|
|
<span class="px-3 py-1 rounded-full border border-border bg-header/60">
|
|
{{ filteredAndSortedConnections.length }} {{ t('connections.resultUnit', '条结果') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<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 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="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 fa-crosshairs"></i>
|
|
<span>{{ t('common.reset', '重置') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-3 space-y-5">
|
|
<section>
|
|
<div class="px-2 mb-2 flex items-center gap-3">
|
|
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-secondary/80 whitespace-nowrap">
|
|
{{ t('connections.scopePrimary', '视图') }}
|
|
</span>
|
|
<span class="h-px flex-1 bg-gradient-to-r from-border/80 to-transparent"></span>
|
|
<span class="text-[10px] text-text-secondary/70">{{ primaryScopeNodes.length }}</span>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<button
|
|
v-for="node in primaryScopeNodes"
|
|
: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)
|
|
]"
|
|
>
|
|
<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>
|
|
<span class="truncate">{{ node.label }}</span>
|
|
</span>
|
|
<span
|
|
:class="[
|
|
'px-2 py-0.5 rounded-full text-xs border transition-colors',
|
|
getTreeCountClass(node)
|
|
]"
|
|
>
|
|
{{ node.count }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="px-2 mb-2 flex items-center gap-3">
|
|
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-secondary/80 whitespace-nowrap">
|
|
{{ t('connections.scopeExplorerTitle', '标签资源管理器') }}
|
|
</span>
|
|
<span class="h-px flex-1 bg-gradient-to-r from-border/80 to-transparent"></span>
|
|
<span class="text-[10px] text-text-secondary/70">{{ visibleTagTreeNodes.length }}</span>
|
|
</div>
|
|
|
|
<div v-show="tagsSectionExpanded" class="space-y-2">
|
|
<div
|
|
v-if="treeDragNoticeVisible"
|
|
class="mx-2 rounded-xl border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-100 flex items-center gap-2"
|
|
>
|
|
<i class="fas fa-grip-lines"></i>
|
|
<span>{{ t('connections.scopeDragPlaceholder', '拖拽排序预留中,当前仅展示目标占位反馈') }}</span>
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<div class="px-2 flex items-center justify-between gap-3 text-[11px] text-text-secondary">
|
|
<span>{{ t('connections.scopeHintCompact', '树节点按标签路径自动分层') }}</span>
|
|
<span class="truncate text-right">{{ normalizedTreeSearchQuery ? t('connections.scopeSearchMode', '命中路径已自动展开') : selectedScopeTitle }}</span>
|
|
</div>
|
|
|
|
<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="[
|
|
'group 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` }"
|
|
draggable="true"
|
|
@mouseenter="setHoveredTreeNode(node.id)"
|
|
@mouseleave="setHoveredTreeNode(null)"
|
|
@dragstart="startTreeDrag(node)"
|
|
@dragenter.prevent="updateTreeDropTarget(node)"
|
|
@dragover.prevent
|
|
@dragend="finishTreeDrag"
|
|
@drop.prevent="finishTreeDrag"
|
|
>
|
|
<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
|
|
:class="[
|
|
'flex items-center gap-1 flex-shrink-0 transition-opacity duration-150',
|
|
hoveredTreeNodeId === node.id ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
]"
|
|
>
|
|
<button
|
|
v-if="node.expandable"
|
|
@click.stop="toggleTreeNodeFromAction(node)"
|
|
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center"
|
|
:title="(expandedTreeNodes[node.id] ?? true) ? t('common.collapse', '收起') : t('common.expand', '展开')"
|
|
>
|
|
<i :class="['fas text-[11px]', (expandedTreeNodes[node.id] ?? true) ? 'fa-compress-alt' : 'fa-expand-alt']"></i>
|
|
</button>
|
|
<button
|
|
@click.stop="selectScope(node.id)"
|
|
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center"
|
|
:title="t('connections.scopePinAction', '定位到此范围')"
|
|
>
|
|
<i class="fas fa-crosshairs text-[11px]"></i>
|
|
</button>
|
|
<button
|
|
@mousedown.stop
|
|
class="w-7 h-7 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center justify-center cursor-grab active:cursor-grabbing"
|
|
:title="t('connections.scopeDragAction', '拖拽重排(预留)')"
|
|
>
|
|
<i class="fas fa-grip-lines text-[11px]"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="bg-card text-card-foreground border border-border rounded-2xl overflow-hidden min-h-[720px] flex flex-col">
|
|
<div class="px-4 py-4 border-b border-border/60 bg-header/30">
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex flex-col lg:flex-row lg:items-center gap-3">
|
|
<div class="relative flex-1 min-w-0">
|
|
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary text-sm"></i>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="t('dashboard.searchConnectionsPlaceholder', '搜索连接...')"
|
|
class="w-full h-11 pl-10 pr-4 rounded-xl border border-border/60 bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<button
|
|
@click="openAddConnectionForm"
|
|
class="h-11 px-4 rounded-xl bg-button text-button-text border border-button shadow-sm hover:bg-button-hover transition-colors inline-flex items-center gap-2"
|
|
:title="t('connections.addConnection', '新增连接')"
|
|
>
|
|
<i class="fas fa-plus"></i>
|
|
<span>{{ t('connections.addConnection', '新增连接') }}</span>
|
|
</button>
|
|
<button
|
|
@click="handleTestAllFilteredConnections"
|
|
:disabled="isTestingAll || isLoadingConnections || !filteredAndSortedConnections.some(c => c.type === 'SSH')"
|
|
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<i :class="['fas', isTestingAll ? 'fa-spinner fa-spin' : 'fa-check-double']"></i>
|
|
<span>{{ t('connections.actions.testAllFiltered', '测试全部') }}</span>
|
|
</button>
|
|
<button
|
|
@click="handleConnectAllFilteredConnections"
|
|
:disabled="isConnectingAll || isLoadingConnections || !filteredAndSortedConnections.some(c => c.type === 'SSH')"
|
|
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<i :class="['fas', isConnectingAll ? 'fa-spinner fa-spin' : 'fa-network-wired']"></i>
|
|
<span>{{ t('workspaceConnectionList.connectAllSshInGroupMenu', '连接全部') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<button
|
|
v-for="filterItem in typeCounts"
|
|
:key="filterItem.value"
|
|
@click="activeTypeFilter = filterItem.value"
|
|
:class="[
|
|
'h-9 px-3 rounded-xl border text-sm inline-flex items-center gap-2 transition-colors',
|
|
getTypePillClass(filterItem.value)
|
|
]"
|
|
>
|
|
<span>{{ filterItem.label }}</span>
|
|
<span class="px-2 py-0.5 rounded-full text-xs bg-black/10 border border-current/15">
|
|
{{ filterItem.count }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<div class="flex items-center mr-2">
|
|
<label for="batch-edit-toggle" class="mr-2 text-sm font-medium text-text-secondary">
|
|
{{ t('connections.batchEdit.toggleLabel', '批量修改') }}
|
|
</label>
|
|
<button
|
|
id="batch-edit-toggle"
|
|
@click="toggleBatchEditMode"
|
|
:class="[
|
|
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary',
|
|
isBatchEditMode ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'
|
|
]"
|
|
role="switch"
|
|
:aria-checked="isBatchEditMode"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
:class="[
|
|
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
|
isBatchEditMode ? 'translate-x-5' : 'translate-x-0'
|
|
]"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
|
|
<select
|
|
v-model="localSortBy"
|
|
class="h-9 px-3 py-1 text-sm border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
>
|
|
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
|
{{ t(option.labelKey, option.value.replace('_', ' ')) }}
|
|
</option>
|
|
</select>
|
|
|
|
<button
|
|
@click="toggleSortOrder"
|
|
class="h-9 w-9 border border-border rounded-xl hover:bg-border focus:outline-none focus:ring-1 focus:ring-primary inline-flex items-center justify-center"
|
|
:aria-label="isAscending ? t('common.sortAscending', '升序') : t('common.sortDescending', '降序')"
|
|
:title="isAscending ? t('common.sortAscending', '升序') : t('common.sortDescending', '降序')"
|
|
>
|
|
<i :class="['fas', isAscending ? 'fa-arrow-up-a-z' : 'fa-arrow-down-z-a']"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="isBatchEditMode" class="px-4 py-3 border-b border-border/50 bg-background/70 flex flex-wrap items-center gap-2">
|
|
<button
|
|
@click="selectAllConnections"
|
|
class="px-3 py-1.5 text-sm bg-transparent text-text-secondary border border-border rounded-lg hover:bg-border hover:text-foreground transition-colors"
|
|
>
|
|
{{ t('connections.batchEdit.selectAll', '全选') }} ({{ selectedResultCount }})
|
|
</button>
|
|
<button
|
|
@click="deselectAllConnections"
|
|
class="px-3 py-1.5 text-sm bg-transparent text-text-secondary border border-border rounded-lg hover:bg-border hover:text-foreground transition-colors"
|
|
>
|
|
{{ t('connections.batchEdit.deselectAll', '取消全选') }}
|
|
</button>
|
|
<button
|
|
@click="invertSelection"
|
|
class="px-3 py-1.5 text-sm bg-transparent text-text-secondary border border-border rounded-lg hover:bg-border hover:text-foreground transition-colors"
|
|
>
|
|
{{ t('connections.batchEdit.invertSelection', '反选') }}
|
|
</button>
|
|
<button
|
|
@click="openBatchEditModal"
|
|
:disabled="selectedResultCount === 0"
|
|
class="px-4 py-1.5 text-sm bg-button text-button-text rounded-lg hover:bg-button-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<i class="fas fa-edit mr-1"></i>{{ t('connections.batchEdit.editSelected', '编辑选中') }}
|
|
</button>
|
|
<button
|
|
@click="handleBatchDeleteConnections"
|
|
:disabled="selectedResultCount === 0 || isDeletingSelectedConnections"
|
|
class="px-4 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<i :class="['fas', isDeletingSelectedConnections ? 'fa-spinner fa-spin' : 'fa-trash-alt', 'mr-1']"></i>
|
|
{{ t('connections.batchEdit.deleteSelectedButton', '删除选中') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 min-h-0 overflow-y-auto">
|
|
<div v-if="isLoadingConnections && filteredAndSortedConnections.length === 0" class="flex items-center justify-center h-full text-text-secondary">
|
|
<i class="fas fa-spinner fa-spin mr-2"></i>{{ t('common.loading', '加载中') }}
|
|
</div>
|
|
|
|
<div v-else-if="filteredAndSortedConnections.length === 0" class="flex flex-col items-center justify-center h-full px-6 text-center text-text-secondary">
|
|
<i class="fas fa-search text-2xl mb-3"></i>
|
|
<p class="text-base font-medium text-foreground">
|
|
{{ t('dashboard.noConnectionsMatchSearch', '没有连接匹配搜索条件') }}
|
|
</p>
|
|
<p class="mt-2 text-sm">
|
|
{{ currentRangeHint }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="min-w-0">
|
|
<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>
|
|
<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>
|
|
|
|
<ul class="divide-y divide-border/50">
|
|
<li
|
|
v-for="conn in filteredAndSortedConnections"
|
|
:key="conn.id"
|
|
@click="handleConnectionClick(conn.id)"
|
|
:class="[
|
|
'px-4 py-4 transition-colors duration-150',
|
|
isBatchEditMode ? 'cursor-pointer hover:bg-border/30' : 'hover:bg-header/25',
|
|
isBatchEditMode && isConnectionSelectedForBatch(conn.id) ? 'bg-primary/10' : ''
|
|
]"
|
|
>
|
|
<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"
|
|
type="checkbox"
|
|
class="mt-1 h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
|
|
:checked="isConnectionSelectedForBatch(conn.id)"
|
|
@click.stop="handleConnectionClick(conn.id)"
|
|
@change.stop
|
|
/>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span :class="['inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-xs font-medium', getTypeBadgeClass(conn.type)]">
|
|
<i :class="['fas', conn.type === 'SSH' ? 'fa-server' : conn.type === 'RDP' ? 'fa-desktop' : 'fa-plug']"></i>
|
|
{{ conn.type }}
|
|
</span>
|
|
<h3 class="text-base font-semibold text-foreground truncate" :title="conn.name || conn.host">
|
|
{{ conn.name || conn.host || t('connections.unnamedFallback', '未命名连接') }}
|
|
</h3>
|
|
</div>
|
|
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-text-secondary">
|
|
<span>{{ conn.username }}</span>
|
|
<span>{{ conn.auth_method }}</span>
|
|
<span>{{ conn.port }}</span>
|
|
<span>{{ t('connections.createdAt', '创建于') }} {{ formatRelativeTime(conn.created_at) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-medium text-foreground truncate" :title="conn.host">
|
|
{{ conn.host }}
|
|
</div>
|
|
<div class="mt-2 text-sm text-text-secondary truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
|
|
{{ conn.username }}@{{ conn.host }}:{{ conn.port }}
|
|
</div>
|
|
<div class="mt-2 text-xs text-text-secondary">
|
|
{{ conn.proxy_type ? `${t('connections.proxyType', '代理')}: ${conn.proxy_type}` : t('connections.noProxy', '未使用代理') }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="flex flex-wrap gap-1.5">
|
|
<span
|
|
v-for="tagName in getConnectionTagNames(conn)"
|
|
:key="tagName"
|
|
class="px-2 py-0.5 text-xs rounded-full bg-header border border-border text-text-secondary"
|
|
>
|
|
{{ tagName }}
|
|
</span>
|
|
<span
|
|
v-if="getConnectionTagNames(conn).length === 0"
|
|
class="px-2 py-0.5 text-xs rounded-full bg-background border border-dashed border-border text-text-secondary"
|
|
>
|
|
{{ t('connections.untaggedGroup', '未标记') }}
|
|
</span>
|
|
</div>
|
|
<div v-if="conn.notes && conn.notes.trim() !== ''" class="mt-2 text-sm text-text-secondary leading-relaxed" :title="conn.notes">
|
|
{{ getTruncatedNotes(conn.notes) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-medium text-foreground">
|
|
{{ formatRelativeTime(conn.last_connected_at) }}
|
|
</div>
|
|
<div
|
|
v-if="conn.type === 'SSH' && connectionTestStates.get(conn.id) && connectionTestStates.get(conn.id)?.status !== 'idle'"
|
|
class="mt-2 text-sm font-medium"
|
|
:class="connectionTestStates.get(conn.id)?.status === 'error' ? 'text-error' : ''"
|
|
:style="connectionTestStates.get(conn.id)?.status === 'success' ? { color: connectionTestStates.get(conn.id)?.latencyColor || 'inherit' } : {}"
|
|
>
|
|
<i
|
|
:class="[
|
|
'fas mr-1.5 text-xs',
|
|
connectionTestStates.get(conn.id)?.status === 'testing'
|
|
? 'fa-spinner fa-spin'
|
|
: connectionTestStates.get(conn.id)?.status === 'success'
|
|
? 'fa-check-circle'
|
|
: 'fa-times-circle'
|
|
]"
|
|
></i>
|
|
{{ connectionTestStates.get(conn.id)?.resultText }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap justify-start xl:justify-end gap-2 relative">
|
|
<button
|
|
@click.stop="connectTo(conn)"
|
|
:disabled="isBatchEditMode"
|
|
class="px-4 py-2 rounded-lg bg-button text-button-text border border-button hover:bg-button-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2 text-sm font-medium"
|
|
>
|
|
<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>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<AddConnectionForm
|
|
v-if="showAddEditConnectionForm"
|
|
:connectionToEdit="connectionToEdit"
|
|
@close="handleFormClose"
|
|
@connection-added="handleConnectionModified"
|
|
@connection-updated="handleConnectionModified"
|
|
/>
|
|
|
|
<BatchEditConnectionForm
|
|
v-if="showBatchEditForm"
|
|
:visible="showBatchEditForm"
|
|
:connection-ids="Array.from(selectedConnectionIdsForBatch)"
|
|
@update:visible="handleBatchEditFormClose"
|
|
@saved="handleBatchEditSaved"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|