Files
nexus-terminal/packages/frontend/src/components/WorkspaceConnectionList.vue
T
Baobhan Sith 56833d58e1 update
2025-05-16 22:49:07 +08:00

990 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, defineExpose, watch, nextTick } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store';
import { useTagsStore, TagInfo } from '../stores/tags.store'; // 确保 TagInfo 已导入
import { useSessionStore } from '../stores/session.store';
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // +++ 修正导入大小写 +++
import { useSettingsStore } from '../stores/settings.store';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import ManageTagConnectionsModal from './ManageTagConnectionsModal.vue';
// 定义事件
const emitWorkspaceEvent = useWorkspaceEventEmitter(); // +++ 获取事件发射器 +++
const { t } = useI18n();
// const router = useRouter(); // 不再需要
const connectionsStore = useConnectionsStore();
const tagsStore = useTagsStore();
const sessionStore = useSessionStore(); // 获取 session store 实例
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
const uiNotificationsStore = useUiNotificationsStore(); // +++ 修正实例化大小写 +++
const settingsStore = useSettingsStore(); // 实例化设置 store
const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore);
const { showConnectionTagsBoolean } = storeToRefs(settingsStore); // 获取设置项
// 搜索词
const searchTerm = ref('');
const searchInputRef = ref<HTMLInputElement | null>(null); // 搜索输入框的 ref
// 右键菜单状态
const contextMenuVisible = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const contextTargetConnection = ref<ConnectionInfo | null>(null);
// 标签右键菜单状态
const tagContextMenuVisible = ref(false);
const tagContextMenuPosition = ref({ x: 0, y: 0 });
const contextTargetTagGroup = ref<(typeof filteredAndGroupedConnections.value)[0] | null>(null);
// +++ 管理标签模态框状态 +++
const showManageTagModal = ref(false);
const tagToManage = ref<TagInfo | null>(null);
// +++ 本地存储键名 +++
const EXPANDED_GROUPS_STORAGE_KEY = 'workspaceConnectionListExpandedGroups';
// +++ 加载初始分组展开状态 +++
const loadInitialExpandedGroups = (): Record<string, boolean> => {
try {
const storedState = localStorage.getItem(EXPANDED_GROUPS_STORAGE_KEY);
if (storedState) {
const parsedState = JSON.parse(storedState);
// 简单验证一下是否是对象
if (typeof parsedState === 'object' && parsedState !== null) {
return parsedState;
}
}
} catch (e) {
console.error('Failed to load or parse expanded groups state from localStorage:', e);
localStorage.removeItem(EXPANDED_GROUPS_STORAGE_KEY); // 清除无效状态
}
// 默认返回空对象,让 computed 属性处理默认展开
return {};
};
// 分组展开状态 - 从 localStorage 初始化
const expandedGroups = ref<Record<string, boolean>>(loadInitialExpandedGroups());
// --- 移除 RDP 模态框状态 ---
// const showRdpModal = ref(false);
// const selectedRdpConnection = ref<ConnectionInfo | null>(null);
// 键盘导航状态
const highlightedIndex = ref(-1); // -1 表示没有高亮项
const listAreaRef = ref<HTMLElement | null>(null); // 列表容器的 ref
// 计算属性:扁平化的、当前可见的连接列表(用于键盘导航)
// 注意:这个 flatVisibleConnections 依赖于 filteredAndGroupedConnections 和 expandedGroups
// 当 showConnectionTagsBoolean 为 false 时,它不会被直接使用,但键盘导航逻辑依赖它
const flatVisibleConnections = computed(() => {
const flatList: ConnectionInfo[] = [];
// 如果显示标签,则只包含展开分组的连接
if (showConnectionTagsBoolean.value) {
filteredAndGroupedConnections.value.forEach(group => {
if (expandedGroups.value[group.groupName]) {
flatList.push(...group.connections);
}
});
} else {
// 如果不显示标签,则包含所有过滤后的连接
flatList.push(...flatFilteredConnections.value); // 使用下面定义的 flatFilteredConnections
}
return flatList;
});
// 计算属性:当前高亮连接的 ID
const highlightedConnectionId = computed(() => {
if (highlightedIndex.value >= 0 && highlightedIndex.value < flatVisibleConnections.value.length) {
return flatVisibleConnections.value[highlightedIndex.value].id;
}
return null;
});
// +++ 编辑标签状态 +++
// editingTagId: number -> 编辑现有标签, null -> 编辑 "未标记" 分组 (准备创建新标签)
const editingTagId = ref<number | null | 'untagged'>(null); // 使用 'untagged' 字符串更清晰地区分
const editedTagName = ref(''); // 存储 input 中的临时名称
// const tagInputRef = ref<HTMLInputElement | null>(null); // Removed single ref
const tagInputRefs = ref(new Map<string | number, HTMLInputElement | null>()); // Map to store refs
// Function to set refs in the map
const setTagInputRef = (el: any, id: string | number) => {
if (el) {
tagInputRefs.value.set(id, el as HTMLInputElement);
} else {
// Clean up the ref when the element is unmounted
tagInputRefs.value.delete(id);
}
};
// 计算属性:过滤并按标签分组连接 (仅在 showConnectionTagsBoolean 为 true 时使用)
const filteredAndGroupedConnections = computed(() => {
const groups: Record<string, { connections: ConnectionInfo[], tagId: number | null }> = {}; // 修改:添加 tagId
const untagged: ConnectionInfo[] = [];
const tagMap = new Map(tags.value.map(tag => [tag.id, tag]));
const lowerSearchTerm = searchTerm.value.toLowerCase();
// 1. 过滤连接 (New logic: filter by connection name, host, OR tag name)
const filteredConnections = connections.value.filter(conn => {
// Check connection name
if (conn.name && conn.name.toLowerCase().includes(lowerSearchTerm)) {
return true;
}
// Check connection host
if (conn.host.toLowerCase().includes(lowerSearchTerm)) {
return true;
}
// Check associated tag names (Always check tags for filtering, regardless of display setting)
if (conn.tag_ids && conn.tag_ids.length > 0) {
for (const tagId of conn.tag_ids) {
const tag = tagMap.get(tagId); // Use the existing tagMap
if (tag && tag.name.toLowerCase().includes(lowerSearchTerm)) {
return true; // Match found in tag name
}
}
}
// No match found
return false;
});
// 2. 分组过滤后的连接
filteredConnections.forEach(conn => {
if (conn.tag_ids && conn.tag_ids.length > 0) {
let tagged = false;
conn.tag_ids.forEach(tagId => {
const tag = tagMap.get(tagId);
if (tag) {
const groupName = tag.name;
if (!groups[groupName]) {
groups[groupName] = { connections: [], tagId: tag.id }; // 修改:存储 tagId
// Initialize expanded state only if not already set
if (expandedGroups.value[groupName] === undefined) {
expandedGroups.value[groupName] = true; // Default to expanded
}
}
// Avoid duplicates if a connection has multiple tags matching the search
if (!groups[groupName].connections.some(c => c.id === conn.id)) {
groups[groupName].connections.push(conn);
}
tagged = true;
}
});
// If none of the tags were found in the tagMap (e.g., stale data), treat as untagged
if (!tagged && !untagged.some(c => c.id === conn.id)) {
untagged.push(conn);
}
} else {
// Ensure untagged connections are not duplicated
if (!untagged.some(c => c.id === conn.id)) {
untagged.push(conn);
}
}
});
// 3. 排序和格式化输出
for (const groupName in groups) {
groups[groupName].connections.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
}
untagged.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
const sortedGroupNames = Object.keys(groups).sort();
// 修改:结果包含 tagId
const result: { groupName: string; connections: ConnectionInfo[]; tagId: number | null }[] = sortedGroupNames.map(name => ({
groupName: name,
connections: groups[name].connections,
tagId: groups[name].tagId // 添加 tagId
}));
if (untagged.length > 0) {
const untaggedGroupName = t('workspaceConnectionList.untagged');
// Initialize expanded state only if not already set
if (expandedGroups.value[untaggedGroupName] === undefined) {
expandedGroups.value[untaggedGroupName] = true; // Default to expanded
}
// 未标记的分组没有 tagId
result.push({ groupName: untaggedGroupName, connections: untagged, tagId: null });
}
return result;
});
// 计算属性,仅过滤,不分组 (用于 showConnectionTagsBoolean 为 false 时)
const flatFilteredConnections = computed(() => {
const lowerSearchTerm = searchTerm.value.toLowerCase();
const tagMap = new Map(tags.value.map(tag => [tag.id, tag.name])); // 创建 tagMap 用于搜索
const filtered = connections.value.filter(conn => {
// Check connection name
if (conn.name && conn.name.toLowerCase().includes(lowerSearchTerm)) {
return true;
}
// Check connection host
if (conn.host.toLowerCase().includes(lowerSearchTerm)) {
return true;
}
// Check associated tag names (Always check tags for filtering)
if (conn.tag_ids && conn.tag_ids.length > 0) {
for (const tagId of conn.tag_ids) {
const tagName = tagMap.get(tagId);
if (tagName && tagName.toLowerCase().includes(lowerSearchTerm)) {
return true; // Match found in tag name
}
}
}
// No match found
return false;
});
// Sort the flat list
return filtered.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
});
// +++ 监听分组状态变化并保存到 localStorage +++
watch(expandedGroups, (newState) => {
// Only save if tags are shown
if (showConnectionTagsBoolean.value) {
try {
localStorage.setItem(EXPANDED_GROUPS_STORAGE_KEY, JSON.stringify(newState));
} catch (e) {
console.error('Failed to save expanded groups state to localStorage:', e);
}
}
}, { deep: true });
// 监听搜索词变化,重置高亮索引
watch(searchTerm, () => {
highlightedIndex.value = -1;
});
// 监听分组展开状态变化,重置高亮索引 (这个 watch 保留,用于重置高亮)
watch(expandedGroups, () => {
highlightedIndex.value = -1;
}, { deep: true });
// 监听显示模式变化,重置高亮索引
watch(showConnectionTagsBoolean, () => {
highlightedIndex.value = -1;
});
// +++ 监听编辑状态,自动聚焦输入框 +++
watch(editingTagId, async (newId) => {
if (newId !== null) {
await nextTick();
const inputRef = tagInputRefs.value.get(newId); // Get ref from map using the ID
if (inputRef) {
inputRef.focus();
inputRef.select();
} else {
console.error(`[WkspConnList] Watcher: Input ref for ID ${newId} not found in map after nextTick.`);
}
}
});
// 切换分组展开/折叠
const toggleGroup = (groupName: string) => {
// 状态现在总是 boolean,直接切换
expandedGroups.value[groupName] = !expandedGroups.value[groupName];
};
// 处理单击连接 (左键/Enter) - 使用 session store 处理连接请求
const handleConnect = (connectionId: number, event?: MouseEvent | KeyboardEvent) => {
if (event instanceof MouseEvent && event.button !== 0) {
console.log(`[WkspConnList] DEBUG: handleConnect called with non-left click (button: ${event.button}). Ignoring.`);
return;
}
const connection = connections.value.find(c => c.id === connectionId);
if (!connection) {
console.error(`[WkspConnList] Connection with ID ${connectionId} not found.`);
return;
}
closeContextMenu(); // 关闭右键菜单
// 统一发出 connect-request 事件,让 sessionStore.handleConnectRequest 处理模态框和会话
emitWorkspaceEvent('connection:connect', { connectionId });
};
// --- 移除 closeRdpModal 方法 ---
// const closeRdpModal = () => {
// showRdpModal.value = false;
// selectedRdpConnection.value = null;
// };
// 显示右键菜单
const showContextMenu = (event: MouseEvent, connection: ConnectionInfo) => {
console.log(`[WkspConnList] showContextMenu (右键) called for ID: ${connection.id}. Event:`, event);
event.preventDefault(); // 再次确保阻止默认行为
event.stopPropagation(); // 阻止事件冒泡
event.stopImmediatePropagation(); // 尝试更强力地阻止事件链
console.log('[WkspConnList] Right-click default prevented and propagation stopped.');
contextTargetConnection.value = connection;
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
contextMenuVisible.value = true;
// 添加全局点击监听器以关闭菜单
document.addEventListener('click', closeContextMenu, { once: true });
// 使用 nextTick 获取菜单尺寸并调整位置以防止超出屏幕
nextTick(() => {
const menuElement = document.querySelector('.context-menu') as HTMLElement;
if (menuElement) {
const menuRect = menuElement.getBoundingClientRect();
let finalX = contextMenuPosition.value.x;
let finalY = contextMenuPosition.value.y;
const menuWidth = menuRect.width;
const menuHeight = menuRect.height;
// 调整水平位置
if (finalX + menuWidth > window.innerWidth) {
finalX = window.innerWidth - menuWidth - 5;
}
// 调整垂直位置
if (finalY + menuHeight > window.innerHeight) {
finalY = window.innerHeight - menuHeight - 5;
}
// 确保菜单不超出屏幕左上角
finalX = Math.max(5, finalX);
finalY = Math.max(5, finalY);
// 更新位置
if (finalX !== contextMenuPosition.value.x || finalY !== contextMenuPosition.value.y) {
console.log(`[WkspConnList] Adjusting context menu position: (${contextMenuPosition.value.x}, ${contextMenuPosition.value.y}) -> (${finalX}, ${finalY})`);
contextMenuPosition.value = { x: finalX, y: finalY };
}
}
});
return false; // 彻底停止事件处理
};
// 关闭右键菜单
const closeContextMenu = () => {
contextMenuVisible.value = false;
contextTargetConnection.value = null;
document.removeEventListener('click', closeContextMenu);
};
// 处理右键菜单操作
const handleMenuAction = (action: 'add' | 'edit' | 'delete' | 'clone') => { // 添加 'clone' 类型
const conn = contextTargetConnection.value;
closeContextMenu(); // 先关闭菜单
if (action === 'add') {
console.log('[WorkspaceConnectionList] handleMenuAction called with action: add. Emitting request-add-connection...');
// router.push('/connections/add'); // 改为触发事件
emitWorkspaceEvent('connection:requestAdd');
} else if (conn) {
if (action === 'edit') {
// router.push(`/connections/edit/${conn.id}`); // 改为触发事件
emitWorkspaceEvent('connection:requestEdit', { connectionInfo: conn }); // 传递整个连接对象
} else if (action === 'delete') {
if (confirm(t('connections.prompts.confirmDelete', { name: conn.name || conn.host }))) {
connectionsStore.deleteConnection(conn.id);
// 注意:删除后列表会自动更新,因为 store 是响应式的
}
} else if (action === 'clone') {
// 调用 store 中的 cloneConnection 方法
// 需要先生成新名称
const allConnections = connectionsStore.connections;
let newName = `${conn.name} (1)`;
let counter = 1;
const baseName = conn.name;
const escapedBaseName = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`^${escapedBaseName} \\((\\d+)\\)$`);
while (allConnections.some(c => c.name === newName)) {
counter++;
newName = `${baseName} (${counter})`;
}
if (counter === 1 && allConnections.some(c => c.name === baseName)) {
// 处理原始名称已存在的情况
}
connectionsStore.cloneConnection(conn.id, newName)
.catch(error => {
// 可以在这里处理克隆失败的特定 UI 反馈,如果需要的话
console.error("Cloning failed in component:", error);
// alert(t('connections.errors.cloneFailed', { error: connectionsStore.error || '未知错误' })); // store 中已有错误处理
});
}
}
};
// 显示标签右键菜单
const showTagContextMenu = (event: MouseEvent, groupData: (typeof filteredAndGroupedConnections.value)[0]) => {
event.preventDefault();
event.stopPropagation(); // 阻止事件冒泡到上层,例如关闭连接右键菜单的 document click listener
closeContextMenu(); // 如果连接的右键菜单是打开的,先关闭它
contextTargetTagGroup.value = groupData;
tagContextMenuPosition.value = { x: event.clientX, y: event.clientY };
tagContextMenuVisible.value = true;
// 添加全局点击监听器以关闭菜单
document.addEventListener('click', closeTagContextMenu, { once: true });
// 使用 nextTick 获取菜单尺寸并调整位置以防止超出屏幕
nextTick(() => {
const menuElement = document.querySelector('.tag-context-menu') as HTMLElement;
if (menuElement) {
const menuRect = menuElement.getBoundingClientRect();
let finalX = tagContextMenuPosition.value.x;
let finalY = tagContextMenuPosition.value.y;
const menuWidth = menuRect.width;
const menuHeight = menuRect.height;
// 调整水平位置
if (finalX + menuWidth > window.innerWidth) {
finalX = window.innerWidth - menuWidth - 5;
}
// 调整垂直位置
if (finalY + menuHeight > window.innerHeight) {
finalY = window.innerHeight - menuHeight - 5;
}
// 确保菜单不超出屏幕左上角
finalX = Math.max(5, finalX);
finalY = Math.max(5, finalY);
// 更新位置
if (finalX !== tagContextMenuPosition.value.x || finalY !== tagContextMenuPosition.value.y) {
console.log(`[WkspConnList] Adjusting tag context menu position: (${tagContextMenuPosition.value.x}, ${tagContextMenuPosition.value.y}) -> (${finalX}, ${finalY})`);
tagContextMenuPosition.value = { x: finalX, y: finalY };
}
}
});
};
// 关闭标签右键菜单
const closeTagContextMenu = () => {
tagContextMenuVisible.value = false;
// contextTargetTagGroup.value = null; // 保留 targetGroup 直到菜单完全消失,以便动画(如果未来添加)
document.removeEventListener('click', closeTagContextMenu);
};
// 处理标签右键菜单操作
// 修改:允许直接传递 groupData,用于新的行内编辑按钮
const handleTagMenuAction = (action: 'connectAll' | 'manageTag' | 'deleteAllConnections', directGroupData?: (typeof filteredAndGroupedConnections.value)[0]) => {
const group = directGroupData || contextTargetTagGroup.value; // 优先使用直接传递的 groupData
closeTagContextMenu(); // 先关闭菜单
if (group && action === 'connectAll') {
const sshConnections = group.connections.filter(conn => conn.type === 'SSH');
if (sshConnections.length > 0) {
sshConnections.forEach(conn => {
emitWorkspaceEvent('connection:connect', { connectionId: conn.id });
});
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.connectingAllSshInGroup', { count: sshConnections.length, groupName: group.groupName }),
type: 'info',
});
} else {
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.noSshConnectionsInGroup', { groupName: group.groupName }),
type: 'info',
});
}
} else if (group && action === 'manageTag') {
if (group.tagId !== null) { // 确保不是 "未标记" 分组
tagToManage.value = {
id: group.tagId,
name: group.groupName,
created_at: tags.value.find(t => t.id === group.tagId)?.created_at || Date.now() / 1000, // 尝试获取真实时间,否则用当前
updated_at: tags.value.find(t => t.id === group.tagId)?.updated_at || Date.now() / 1000,
};
showManageTagModal.value = true;
} else {
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.manageTags.cannotManageUntagged'), // 需要添加这个翻译
type: 'warning',
});
}
} else if (group && action === 'deleteAllConnections') {
// 确保是已标记的组
if (group.tagId === null) {
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.cannotDeleteFromUntagged'),
type: 'warning',
});
return;
}
// 确保组内有连接
if (group.connections.length === 0) {
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.noConnectionsToDeleteInGroup', { groupName: group.groupName }),
type: 'info',
});
return;
}
if (confirm(t('workspaceConnectionList.confirmDeleteAllConnectionsInGroup', { count: group.connections.length, groupName: group.groupName }))) {
const connectionIdsToDelete = group.connections.map(conn => conn.id);
const deletePromises = connectionIdsToDelete.map(connId =>
connectionsStore.deleteConnection(connId).catch(err => {
console.error(`[WkspConnList] Failed to delete connection ${connId} in group ${group.groupName}:`, err);
return Promise.reject({ connId, error: err });
})
);
Promise.allSettled(deletePromises)
.then(results => {
const successfulDeletes = results.filter(result => result.status === 'fulfilled').length;
const failedDeletes = results.filter(result => result.status === 'rejected').length;
if (successfulDeletes > 0) {
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.allConnectionsInGroupDeletedSuccess', { count: successfulDeletes, groupName: group.groupName }),
type: 'success',
});
}
if (failedDeletes > 0) {
uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.someConnectionsInGroupDeleteFailed', { count: failedDeletes, groupName: group.groupName }),
type: 'error',
});
}
});
}
}
};
const handleManageTagModalSaved = () => {
connectionsStore.fetchConnections(); // 刷新连接列表
tagsStore.fetchTags(); // 刷新标签列表,以防标签名称等有变动(虽然此模态框不直接改名)
};
// 稍微延迟一下重置,以防是点击列表项导致的失焦
// 如果用户点击了列表项,handleConnect 会先触发
setTimeout(() => {
// 检查此时是否仍然没有焦点在输入框上(避免误清除)
if (document.activeElement !== searchInputRef.value) {
highlightedIndex.value = -1;
}
}, 150); // 150ms 延迟可能更稳妥
// 处理失焦事件,清除高亮
const handleBlur = () => {
// 稍微延迟一下重置,以防是点击列表项导致的失焦
// 如果用户点击了列表项,handleConnect 会先触发
setTimeout(() => {
// 检查此时是否仍然没有焦点在输入框上(避免误清除)
if (document.activeElement !== searchInputRef.value) {
highlightedIndex.value = -1;
}
}, 150); // 150ms 延迟可能更稳妥
};
// 获取数据的 onMounted 调用已移至新的 onMounted 逻辑中
// +++ 注册/注销自定义聚焦动作 +++
let unregisterFocusAction: (() => void) | null = null; // 用于存储注销函数
onMounted(() => {
// 调用新的 registerFocusAction 并存储返回的注销函数
// focusSearchInput 返回 boolean,符合 () => boolean | Promise<boolean | undefined> 类型
unregisterFocusAction = focusSwitcherStore.registerFocusAction('connectionListSearch', focusSearchInput);
connectionsStore.fetchConnections(); // 移到 onMounted
tagsStore.fetchTags(); // 移到 onMounted
// Load initial expanded state after fetching tags/connections
expandedGroups.value = loadInitialExpandedGroups();
});
onBeforeUnmount(() => {
// 调用存储的注销函数
if (unregisterFocusAction) {
unregisterFocusAction();
console.log(`[WkspConnList] Unregistered focus action on unmount.`);
}
unregisterFocusAction = null;
});
// 处理中键点击(在新标签页打开) - 功能已移除
// 暴露聚焦搜索框的方法
const focusSearchInput = (): boolean => {
if (searchInputRef.value) {
searchInputRef.value.focus();
return true; // 聚焦成功
}
return false; // 聚焦失败
};
defineExpose({ focusSearchInput });
// --- 键盘导航和确认 ---
const handleKeyDown = (event: KeyboardEvent) => {
const list = flatVisibleConnections.value; // Always navigate the potentially flat list
if (!list.length) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault(); // 阻止光标移动
highlightedIndex.value = (highlightedIndex.value + 1) % list.length;
scrollToHighlighted();
break;
case 'ArrowUp':
event.preventDefault(); // 阻止光标移动
highlightedIndex.value = (highlightedIndex.value - 1 + list.length) % list.length;
scrollToHighlighted();
break;
case 'Enter':
event.preventDefault(); // 阻止可能的表单提交
if (highlightedConnectionId.value !== null) {
handleConnect(highlightedConnectionId.value);
}
break;
}
};
// 滚动到高亮项
const scrollToHighlighted = async () => {
await nextTick(); // 等待 DOM 更新
if (!listAreaRef.value || highlightedConnectionId.value === null) return;
// Query selector needs to work for both grouped and flat lists
const highlightedElement = listAreaRef.value.querySelector(`li[data-conn-id="${highlightedConnectionId.value}"]`);
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
};
// +++ 启动编辑标签 (或准备创建新标签) +++
const startEditingTag = (tagId: number | null, currentName: string) => { // Removed async
// 如果 tagId 是 null,表示是 "未标记" 分组
editingTagId.value = tagId === null ? 'untagged' : tagId;
editedTagName.value = tagId === null ? '' : currentName; // 未标记组开始编辑时清空输入框
// Focus logic moved to watcher
};
// +++ 完成编辑标签 (或创建新标签并分配) +++
const finishEditingTag = async () => {
const currentEditingId = editingTagId.value;
const newName = editedTagName.value.trim();
const originalTag = typeof currentEditingId === 'number' ? tags.value.find(t => t.id === currentEditingId) : null;
// 如果新名称为空 (除非是 'untagged' 状态,否则取消编辑)
if (newName === '' && currentEditingId !== 'untagged') {
editingTagId.value = null;
return;
}
// 如果是 'untagged' 状态且新名称为空,也取消
if (newName === '' && currentEditingId === 'untagged') {
editingTagId.value = null;
return;
}
let operationSuccess = false; // Track if the core operation (add/update) succeeded
try {
if (currentEditingId === 'untagged') {
// --- 创建新标签并分配 ---
const newTag = await tagsStore.addTag(newName); // Returns TagInfo | null
if (newTag) {
operationSuccess = true; // Core tag creation succeeded
uiNotificationsStore.addNotification({ message: t('tags.createSuccess'), type: 'success' });
const untaggedGroup = filteredAndGroupedConnections.value.find(g => g.tagId === null);
const untaggedConnectionIds = untaggedGroup ? untaggedGroup.connections.map(c => c.id) : [];
if (untaggedConnectionIds.length > 0) {
// 调用新的 action 批量添加标签
const assignSuccess = await connectionsStore.addTagToConnectionsAction(untaggedConnectionIds, newTag.id);
if (assignSuccess) {
uiNotificationsStore.addNotification({ message: t('workspaceConnectionList.allConnectionsTaggedSuccess'), type: 'success' });
}
// Assign failure notification is handled within the action
} else {
uiNotificationsStore.addNotification({ message: t('workspaceConnectionList.noConnectionsToTag'), type: 'info' });
}
// 更新展开状态 only if tag creation was successful
const untaggedGroupName = t('workspaceConnectionList.untagged');
if (expandedGroups.value[untaggedGroupName] !== undefined) {
const currentState = expandedGroups.value[untaggedGroupName];
delete expandedGroups.value[untaggedGroupName];
expandedGroups.value[newName] = currentState;
}
}
// If newTag is null, addTag failed (e.g., name exists), notification handled by store. operationSuccess remains false.
} else if (typeof currentEditingId === 'number') {
// --- 更新现有标签 ---
if (!originalTag) {
console.error(`Tag with ID ${currentEditingId} not found for update.`);
// Exit edit mode in finally block
} else if (originalTag.name === newName) {
operationSuccess = true; // No change needed, consider it success for UI state
} else {
// 名称已改变,尝试更新
const updateResult = await tagsStore.updateTag(currentEditingId, newName); // Returns boolean
if (updateResult) {
operationSuccess = true; // Core tag update succeeded
uiNotificationsStore.addNotification({ message: t('tags.updateSuccess'), type: 'success' });
// 更新展开状态 only if tag update was successful
if (expandedGroups.value[originalTag.name] !== undefined) {
const currentState = expandedGroups.value[originalTag.name];
delete expandedGroups.value[originalTag.name];
expandedGroups.value[newName] = currentState;
}
}
// If updateResult is false, updateTag failed (e.g., name exists), notification handled by store. operationSuccess remains false.
}
}
} catch (error: any) {
// 捕获这两个流程中未被 store action 捕获的意外错误
console.error("Error during finishEditingTag:", error);
uiNotificationsStore.addNotification({ message: t('common.unexpectedError'), type: 'error' });
// operationSuccess remains false
} finally {
// 无论核心操作成功与否,最终都退出编辑模式
// 这样即使用户输入了重复名称,收到通知后,输入框也会消失,恢复原状
editingTagId.value = null;
}
};
// +++ 取消编辑(例如按 Esc 键) +++
const cancelEditingTag = () => {
editingTagId.value = null;
};
</script>
<template>
<div class="h-full flex flex-col overflow-hidden bg-background text-foreground">
<!-- ... Loading/Error states ... -->
<div v-if="(connectionsLoading || tagsLoading) && connections.length === 0 && tags.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="connectionsError || (tagsError && tags.length === 0)" class="flex items-center justify-center h-full text-error px-4 text-center">
<i class="fas fa-exclamation-triangle mr-2"></i> {{ connectionsError || tagsError }}
</div>
<!-- Main Content Area -->
<div v-else class="flex flex-col h-full">
<!-- Search and Add Bar -->
<div class="flex p-2 border-b border-border/50"> <!-- Reduced padding p-3 to p-2 -->
<input
type="text"
v-model="searchTerm"
:placeholder="t('workspaceConnectionList.searchPlaceholder')"
ref="searchInputRef"
class="flex-grow min-w-0 px-4 py-1.5 border border-border/50 rounded-lg bg-input text-foreground text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out"
data-focus-id="connectionListSearch"
@keydown="handleKeyDown"
@blur="handleBlur"
/>
<button
class="ml-2 w-8 h-8 bg-primary text-white border-none rounded-lg text-sm font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 flex-shrink-0 flex items-center justify-center"
@click="handleMenuAction('add')"
:title="t('connections.addConnection')"
>
<i class="fas fa-plus text-white"></i>
</button>
</div>
<!-- Connection List Area -->
<div class="flex-grow overflow-y-auto p-2" ref="listAreaRef">
<!-- No Results / No Connections State -->
<!-- 修改 v-if 条件考虑两种模式并且仅在有搜索词时显示 "No Results" -->
<div v-if="((showConnectionTagsBoolean && filteredAndGroupedConnections.length === 0) || (!showConnectionTagsBoolean && flatFilteredConnections.length === 0)) && connections.length > 0 && searchTerm" class="p-6 text-center text-text-secondary">
<i class="fas fa-search text-xl mb-2"></i>
<p>{{ t('workspaceConnectionList.noResults') }} "{{ searchTerm }}"</p>
</div>
<div v-else-if="connections.length === 0" class="p-6 text-center text-text-secondary">
<i class="fas fa-plug text-xl mb-2"></i>
<p>{{ t('connections.noConnections') }}</p>
<button
class="mt-4 px-4 py-2 bg-primary text-white border-none rounded-lg text-sm font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
@click="handleMenuAction('add')"
>
{{ t('connections.addFirstConnection') }}
</button>
</div>
<!-- Groups and Connections (Conditional Rendering) -->
<div v-else>
<!-- Grouped View -->
<div v-if="showConnectionTagsBoolean">
<div v-for="groupData in filteredAndGroupedConnections" :key="groupData.groupName" class="mb-1 last:mb-0">
<!-- Group Header -->
<div
class="group px-3 py-2 font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150"
:class="{ 'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) }"
@click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null"
@contextmenu.prevent="showTagContextMenu($event, groupData)"
>
<i
:class="['fas', expandedGroups[groupData.groupName] ? 'fa-chevron-down' : 'fa-chevron-right', 'mr-2 w-4 text-center text-text-secondary group-hover:text-foreground transition-transform duration-200 ease-in-out', {'transform rotate-0': !expandedGroups[groupData.groupName]}]"
@click.stop="toggleGroup(groupData.groupName)"
class="cursor-pointer flex-shrink-0"
></i>
<!-- 编辑状态 -->
<input
v-if="editingTagId === (groupData.tagId === null ? 'untagged' : groupData.tagId)"
:key="groupData.tagId === null ? 'untagged-input' : `tag-input-${groupData.tagId}`"
:ref="(el) => setTagInputRef(el, groupData.tagId === null ? 'untagged' : groupData.tagId)"
type="text"
v-model="editedTagName"
class="text-sm bg-input border border-primary rounded px-1 py-0 w-full"
@blur="finishEditingTag"
@keydown.enter.prevent="finishEditingTag"
@keydown.esc.prevent="cancelEditingTag"
@click.stop
/>
<!-- 显示状态 -->
<span
v-else
class="text-sm inline-block overflow-hidden text-ellipsis whitespace-nowrap"
:class="{ 'cursor-pointer hover:underline': true }"
:title="t('workspaceConnectionList.clickToEditTag')"
@click.stop="startEditingTag(groupData.tagId, groupData.groupName)"
>
{{ groupData.groupName }}
</span>
<!-- 占位符占据剩余空间 -->
<div class="flex-grow min-w-0"></div>
<!-- 标签栏右侧的编辑按钮 -->
<button
v-if="groupData.tagId !== null && editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId)"
@click.stop="handleTagMenuAction('manageTag', groupData)"
class="ml-2 px-1 h-6 flex items-center justify-center rounded text-text-secondary hover:text-primary hover:bg-black/10 opacity-0 group-hover:opacity-100 transition-all duration-150 focus:outline-none"
:title="t('workspaceConnectionList.manageTags.menuItem')"
>
<i class="fas fa-edit fa-xs"></i>
</button>
</div>
<!-- Connection Items List -->
<ul v-show="expandedGroups[groupData.groupName]" class="list-none p-0 m-0 pl-3">
<!-- ... li v-for="conn in groupData.connections" ... -->
<li
v-for="conn in groupData.connections"
:key="conn.id"
class="group my-0.5 py-2 pr-3 pl-4 cursor-pointer flex items-center rounded-md whitespace-nowrap overflow-hidden text-ellipsis text-foreground hover:bg-primary/10 transition-colors duration-150"
:class="{ 'bg-primary/20 font-medium': conn.id === highlightedConnectionId }"
:data-conn-id="conn.id"
@click.left="handleConnect(conn.id)"
@click.right.prevent
@contextmenu.prevent="showContextMenu($event, conn)"
>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : (conn.type === 'VNC' ? 'fa-plug' : 'fa-server'), 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
<span class="overflow-hidden text-ellipsis whitespace-nowrap flex-grow text-sm" :title="conn.name || conn.host">
{{ conn.name || conn.host }}
</span>
</li>
</ul>
</div>
</div>
<!-- Flat View -->
<ul v-else class="list-none p-0 m-0">
<li
v-for="conn in flatFilteredConnections"
:key="conn.id"
class="group my-0.5 py-2 pr-3 pl-4 cursor-pointer flex items-center rounded-md whitespace-nowrap overflow-hidden text-ellipsis text-foreground hover:bg-primary/10 transition-colors duration-150"
:class="{ 'bg-primary/20 font-medium': conn.id === highlightedConnectionId }"
:data-conn-id="conn.id"
@click.left="handleConnect(conn.id)"
@click.right.prevent
@contextmenu.prevent="showContextMenu($event, conn)"
>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : (conn.type === 'VNC' ? 'fa-chalkboard' : 'fa-server'), 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
<span class="overflow-hidden text-ellipsis whitespace-nowrap flex-grow text-sm" :title="conn.name || conn.host">
{{ conn.name || conn.host }}
</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Context Menu -->
<div
v-if="contextMenuVisible"
class="fixed bg-background border border-border/50 shadow-xl rounded-lg py-1.5 z-50 min-w-[180px] context-menu"
:style="{ top: `${contextMenuPosition.y}px`, left: `${contextMenuPosition.x}px` }"
@click.stop
>
<ul class="list-none p-0 m-0">
<li class="group px-4 py-1.5 cursor-pointer flex items-center text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1" @click="handleMenuAction('add')">
<i class="fas fa-plus mr-3 w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('connections.addConnection') }}</span>
</li>
<li v-if="contextTargetConnection" class="group px-4 py-1.5 cursor-pointer flex items-center text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1" @click="handleMenuAction('edit')">
<i class="fas fa-edit mr-3 w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('connections.actions.edit') }}</span>
</li>
<li v-if="contextTargetConnection" class="group px-4 py-1.5 cursor-pointer flex items-center text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1" @click="handleMenuAction('clone')">
<i class="fas fa-clone mr-3 w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('connections.actions.clone') }}</span>
</li>
<li v-if="contextTargetConnection" class="group px-4 py-1.5 cursor-pointer flex items-center text-error hover:bg-error/10 text-sm transition-colors duration-150 rounded-md mx-1" @click="handleMenuAction('delete')">
<i class="fas fa-trash-alt mr-3 w-4 text-center text-error/80 group-hover:text-error"></i>
<span>{{ t('connections.actions.delete') }}</span>
</li>
</ul>
</div>
<!-- 标签右键菜单 -->
<div
v-if="tagContextMenuVisible"
class="fixed bg-background border border-border/50 shadow-xl rounded-lg py-1.5 z-50 min-w-[200px] tag-context-menu"
:style="{ top: `${tagContextMenuPosition.y}px`, left: `${tagContextMenuPosition.x}px` }"
@click.stop
>
<ul class="list-none p-0 m-0">
<li
v-if="contextTargetTagGroup && contextTargetTagGroup.connections.some((c: ConnectionInfo) => c.type === 'SSH')"
class="group px-4 py-1.5 cursor-pointer flex items-center text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleTagMenuAction('connectAll')"
>
<i class="fas fa-network-wired mr-3 w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('workspaceConnectionList.connectAllSshInGroupMenu') }}</span>
</li>
<li
v-else-if="contextTargetTagGroup"
class="group px-4 py-1.5 flex items-center text-text-disabled text-sm rounded-md mx-1 cursor-not-allowed"
>
<i class="fas fa-ban mr-3 w-4 text-center text-text-disabled"></i>
<span>{{ t('workspaceConnectionList.noSshConnectionsToConnectMenu') }}</span>
</li>
<li class="my-1 border-t border-border/50" v-if="contextTargetTagGroup && contextTargetTagGroup.tagId !== null"></li>
<li
v-if="contextTargetTagGroup && contextTargetTagGroup.tagId !== null"
class="group px-4 py-1.5 cursor-pointer flex items-center text-foreground hover:bg-primary/10 hover:text-primary text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleTagMenuAction('manageTag')"
>
<i class="fas fa-tags mr-3 w-4 text-center text-text-secondary group-hover:text-primary"></i>
<span>{{ t('workspaceConnectionList.manageTags.menuItem') }}</span>
</li>
<li class="my-1 border-t border-border/50" v-if="contextTargetTagGroup && contextTargetTagGroup.tagId !== null && contextTargetTagGroup.connections.length > 0"></li>
<li
v-if="contextTargetTagGroup && contextTargetTagGroup.tagId !== null && contextTargetTagGroup.connections.length > 0"
class="group px-4 py-1.5 cursor-pointer flex items-center text-error hover:bg-error/10 text-sm transition-colors duration-150 rounded-md mx-1"
@click="handleTagMenuAction('deleteAllConnections')"
>
<i class="fas fa-trash-alt mr-3 w-4 text-center text-error/80 group-hover:text-error"></i>
<span>{{ t('workspaceConnectionList.deleteAllConnectionsInGroupMenu') }}</span>
</li>
</ul>
</div>
<teleport to="body">
<ManageTagConnectionsModal
:tag-info="tagToManage"
v-model:visible="showManageTagModal"
@saved="handleManageTagModalSaved"
/>
</teleport>
</div>
</template>