diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 654e814..4583cdd 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -412,3 +412,76 @@ export const cloneConnection = async (req: Request, res: Response): Promise => { + try { + const { connection_ids, tag_id } = req.body; + + // 验证输入 + if (!Array.isArray(connection_ids) || !connection_ids.every(id => typeof id === 'number')) { + res.status(400).json({ message: 'connection_ids 必须是一个数字数组。' }); + return; + } + if (typeof tag_id !== 'number' || tag_id <= 0) { + res.status(400).json({ message: 'tag_id 必须是一个有效的正整数。' }); + return; + } + if (connection_ids.length === 0) { + res.status(400).json({ message: 'connection_ids 不能为空数组。' }); + return; + } + + // 调用服务层批量添加标签 + await ConnectionService.addTagToConnections(connection_ids, tag_id); + + res.status(200).json({ message: '标签已成功添加到指定连接。' }); + + } catch (error: any) { + console.error(`Controller: 为多个连接添加标签 ${req.body?.tag_id} 时发生错误:`, error); + // 可以根据服务层抛出的错误类型返回更具体的错误码 + if (error.message.includes('标签 ID') && error.message.includes('不存在')) { + res.status(400).json({ message: error.message }); // Bad request if tag doesn't exist + } else { + res.status(500).json({ message: error.message || '为连接添加标签时发生内部服务器错误。' }); + } + } +}; + + +/** + * 更新单个连接的标签 (PUT /api/v1/connections/:id/tags) + * (保留此接口,但主要逻辑由 addTagToConnections 处理) + */ +export const updateConnectionTags = async (req: Request, res: Response): Promise => { + try { + const connectionId = parseInt(req.params.id, 10); + const { tag_ids } = req.body; + + if (isNaN(connectionId)) { + res.status(400).json({ message: '无效的连接 ID。' }); + return; + } + if (!Array.isArray(tag_ids) || !tag_ids.every(id => typeof id === 'number')) { + res.status(400).json({ message: 'tag_ids 必须是一个数字数组。' }); + return; + } + + const success = await ConnectionService.updateConnectionTags(connectionId, tag_ids); + + if (!success) { + res.status(404).json({ message: '连接未找到或更新标签失败。' }); + } else { + res.status(200).json({ message: '连接标签更新成功。' }); + } + } catch (error: any) { + console.error(`Controller: 更新连接 ${req.params.id} 的标签时发生错误:`, error); + if (error.message.includes('未找到')) { + res.status(404).json({ message: error.message }); + } else { + res.status(500).json({ message: error.message || '更新连接标签时发生内部服务器错误。' }); + } + } +}; diff --git a/packages/backend/src/connections/connections.routes.ts b/packages/backend/src/connections/connections.routes.ts index d6cc2f6..5098d6c 100644 --- a/packages/backend/src/connections/connections.routes.ts +++ b/packages/backend/src/connections/connections.routes.ts @@ -12,7 +12,9 @@ import { exportConnections, importConnections, getRdpSessionToken, // Import the new controller function - cloneConnection // +++ Import the clone controller function +++ + cloneConnection, // +++ Import the clone controller function +++ + // updateConnectionTags, // No longer directly used by primary flow + addTagToConnections // +++ Import the new controller function for adding tag to multiple connections +++ } from './connections.controller'; const router = Router(); @@ -84,4 +86,10 @@ router.post('/:id/rdp-session', getRdpSessionToken); // +++ POST /api/v1/connections/:id/clone - 克隆连接 +++ router.post('/:id/clone', cloneConnection); +// +++ POST /api/v1/connections/add-tag - 为多个连接添加一个标签 +++ +router.post('/add-tag', addTagToConnections); + +// Note: PUT /:id/tags route is removed as the primary flow uses the bulk add endpoint now. +// It could be kept if there's a separate use case for updating a single connection's tags. + export default router; diff --git a/packages/backend/src/repositories/connection.repository.ts b/packages/backend/src/repositories/connection.repository.ts index 5579583..d2c0ca6 100644 --- a/packages/backend/src/repositories/connection.repository.ts +++ b/packages/backend/src/repositories/connection.repository.ts @@ -260,34 +260,57 @@ export const updateLastConnected = async (id: number, timestamp: number): Promis * @param connectionId 连接 ID * @param tagIds 新的标签 ID 数组 (空数组表示清除所有标签) */ -export const updateConnectionTags = async (connectionId: number, tagIds: number[]): Promise => { +export const updateConnectionTags = async (connectionId: number, tagIds: number[]): Promise => { // 修改返回类型为 boolean const db = await getDbInstance(); + + // 1. 检查连接是否存在 + try { + const connectionExists = await getDbRow<{ id: number }>(db, `SELECT id FROM connections WHERE id = ?`, [connectionId]); + if (!connectionExists) { + console.warn(`Repository: updateConnectionTags - Connection with ID ${connectionId} not found.`); + return false; // 连接不存在,返回 false + } + } catch (checkErr: any) { + console.error(`Repository: 检查连接 ${connectionId} 是否存在时出错:`, checkErr.message); + throw new Error('检查连接是否存在时失败'); // 抛出检查错误 + } + + + // 2. 执行标签更新事务 try { await runDb(db, 'BEGIN TRANSACTION'); - + // 删除旧关联 await runDb(db, `DELETE FROM connection_tags WHERE connection_id = ?`, [connectionId]); + // 插入新关联 (如果 tagIds 不为空) if (tagIds.length > 0) { const insertSql = `INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`; - - const insertPromises = tagIds - .filter(tagId => typeof tagId === 'number' && tagId > 0) - .map(tagId => runDb(db, insertSql, [connectionId, tagId]).catch(err => { - console.warn(`Repository: 更新连接 ${connectionId} 标签时,插入 tag_id ${tagId} 失败: ${err.message}`); - })); + // 过滤无效 ID + const validTagIds = tagIds.filter(tagId => typeof tagId === 'number' && tagId > 0); + + // 使用 Promise.all 确保所有插入完成或失败 + const insertPromises = validTagIds.map(tagId => + runDb(db, insertSql, [connectionId, tagId]) + ); + // 如果任何插入失败,Promise.all 会 reject,错误会被下面的 catch 捕获 await Promise.all(insertPromises); } await runDb(db, 'COMMIT'); + return true; // 事务成功提交,返回 true } catch (err: any) { - console.error(`Repository: 更新连接 ${connectionId} 的标签关联时出错:`, err.message); + console.error(`Repository: 更新连接 ${connectionId} 的标签关联事务出错:`, err.message); try { - await runDb(db, 'ROLLBACK'); + await runDb(db, 'ROLLBACK'); + console.log(`Repository: Transaction rolled back for connection ${connectionId} tag update.`); } catch (rollbackErr: any) { console.error(`Repository: 回滚连接 ${connectionId} 的标签更新事务失败:`, rollbackErr.message); + // 即使回滚失败,原始错误也更重要 } - throw new Error('处理标签关联失败'); + // 直接重新抛出原始事务错误,让上层处理 + // SQLite 在事务中遇到错误时通常会自动回滚 + throw err; } }; @@ -326,7 +349,6 @@ export const bulkInsertConnections = async ( const results: { connectionId: number, originalData: any }[] = []; const now = Math.floor(Date.now() / 1000); - for (const connData of connections) { const params = [ connData.name ?? null, connData.type, connData.host, connData.port, connData.username, connData.auth_method, // Add type parameter @@ -349,3 +371,37 @@ export const bulkInsertConnections = async ( } return results; }; + +/** + * 为多个连接添加同一个标签 (使用事务) + * @param connectionIds 连接 ID 数组 + * @param tagId 要添加的标签 ID + */ +export const addTagToMultipleConnections = async (connectionIds: number[], tagId: number): Promise => { + if (connectionIds.length === 0 || typeof tagId !== 'number' || tagId <= 0) { + console.warn('[Repository] addTagToMultipleConnections called with empty connectionIds or invalid tagId.'); + return; // 无需操作 + } + + const db = await getDbInstance(); + try { + await runDb(db, 'BEGIN TRANSACTION'); + + const insertSql = `INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`; + // 使用 Promise.all 确保所有插入完成或失败 + const insertPromises = connectionIds.map(connId => + runDb(db, insertSql, [connId, tagId]) + ); + await Promise.all(insertPromises); + + await runDb(db, 'COMMIT'); + } catch (err: any) { + console.error(`Repository: 为多个连接添加标签 ${tagId} 时事务出错:`, err.message); + try { + await runDb(db, 'ROLLBACK'); + } catch (rollbackErr: any) { + console.error(`Repository: 回滚为多个连接添加标签 ${tagId} 的事务失败:`, rollbackErr.message); + } + throw new Error(`为多个连接添加标签失败: ${err.message}`); + } +}; diff --git a/packages/backend/src/services/connection.service.ts b/packages/backend/src/services/connection.service.ts index da6444f..05fbbd6 100644 --- a/packages/backend/src/services/connection.service.ts +++ b/packages/backend/src/services/connection.service.ts @@ -500,3 +500,52 @@ export const cloneConnection = async (originalId: number, newName: string): Prom // 7. 返回新创建的带标签的连接 return clonedConnection; }; +// 注意:updateConnectionTags 现在主要由 updateConnection 内部调用, +// 或者可以保留用于单独更新单个连接标签的场景(如果需要的话)。 +// 为了解决嵌套事务问题,我们添加一个新的批量添加函数。 + +/** + * 为指定的一组连接添加一个标签 + * @param connectionIds 连接 ID 数组 + * @param tagId 要添加的标签 ID + */ +export const addTagToConnections = async (connectionIds: number[], tagId: number): Promise => { + // 1. 验证 tagId 是否有效(可选,但建议) + // const tagExists = await TagRepository.findTagById(tagId); // 需要导入 TagRepository + // if (!tagExists) { + // throw new Error(`标签 ID ${tagId} 不存在。`); + // } + + // 2. 调用仓库层批量添加标签 + try { + await ConnectionRepository.addTagToMultipleConnections(connectionIds, tagId); + + // 记录审计日志 (可以考虑为批量操作定义新的审计类型) + // TODO: 定义 'CONNECTIONS_TAG_ADDED' 审计日志类型 + // auditLogService.logAction('CONNECTIONS_TAG_ADDED', { connectionIds, tagId }); + + } catch (error: any) { + console.error(`Service: 为连接 ${connectionIds.join(', ')} 添加标签 ${tagId} 时发生错误:`, error); + throw error; // 重新抛出错误 + } +}; + +/** + * 更新指定连接的标签关联 (保留此函数用于可能的其他用途,但主要逻辑转移到 addTagToConnections) + * @param connectionId 连接 ID + * @param tagIds 新的标签 ID 数组 + * @returns boolean 指示操作是否成功(找到连接并尝试更新) + */ +export const updateConnectionTags = async (connectionId: number, tagIds: number[]): Promise => { + try { + const updated = await ConnectionRepository.updateConnectionTags(connectionId, tagIds); + // if (updated) { + // // TODO: 定义 'CONNECTION_TAGS_UPDATED' 审计日志类型 + // // auditLogService.logAction('CONNECTION_TAGS_UPDATED', { connectionId, tagIds }); + // } + return updated; + } catch (error: any) { + console.error(`Service: 更新连接 ${connectionId} 的标签时发生错误:`, error); + throw error; + } +}; diff --git a/packages/frontend/src/components/WorkspaceConnectionList.vue b/packages/frontend/src/components/WorkspaceConnectionList.vue index 0ac3711..117f369 100644 --- a/packages/frontend/src/components/WorkspaceConnectionList.vue +++ b/packages/frontend/src/components/WorkspaceConnectionList.vue @@ -5,9 +5,10 @@ import { storeToRefs } from 'pinia'; import { useI18n } from 'vue-i18n'; import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; -import { useTagsStore, TagInfo } from '../stores/tags.store'; -import { useSessionStore } from '../stores/session.store'; -import { useFocusSwitcherStore } from '../stores/focusSwitcher.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'; // +++ 修正导入大小写 +++ // 定义事件 const emit = defineEmits([ @@ -24,6 +25,7 @@ const connectionsStore = useConnectionsStore(); const tagsStore = useTagsStore(); const sessionStore = useSessionStore(); // 获取 session store 实例 const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++ +const uiNotificationsStore = useUiNotificationsStore(); // +++ 修正实例化大小写 +++ const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore); const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore); @@ -91,9 +93,27 @@ const highlightedConnectionId = computed(() => { }); +// +++ 编辑标签状态 +++ +// editingTagId: number -> 编辑现有标签, null -> 编辑 "未标记" 分组 (准备创建新标签) +const editingTagId = ref(null); // 使用 'untagged' 字符串更清晰地区分 +const editedTagName = ref(''); // 存储 input 中的临时名称 +// const tagInputRef = ref(null); // Removed single ref +const tagInputRefs = ref(new Map()); // 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); + } +}; + // 计算属性:过滤并按标签分组连接 +// 需要修改 filteredAndGroupedConnections,使其包含 tagId const filteredAndGroupedConnections = computed(() => { - const groups: Record = {}; + const groups: Record = {}; // 修改:添加 tagId const untagged: ConnectionInfo[] = []; const tagMap = new Map(tags.value.map(tag => [tag.id, tag])); const lowerSearchTerm = searchTerm.value.toLowerCase(); @@ -102,35 +122,29 @@ const filteredAndGroupedConnections = computed(() => { const filteredConnections = connections.value.filter(conn => { const nameMatch = conn.name && conn.name.toLowerCase().includes(lowerSearchTerm); const hostMatch = conn.host.toLowerCase().includes(lowerSearchTerm); - // 如果有 IP 地址字段,也应包含在此处 - // const ipMatch = conn.ipAddress && conn.ipAddress.toLowerCase().includes(lowerSearchTerm); - return nameMatch || hostMatch; // || ipMatch; + return nameMatch || hostMatch; }); // 2. 分组过滤后的连接 filteredConnections.forEach(conn => { if (conn.tag_ids && conn.tag_ids.length > 0) { - let tagged = false; // 标记是否至少加入了一个分组 + let tagged = false; conn.tag_ids.forEach(tagId => { const tag = tagMap.get(tagId); - // 确保标签存在才分组 if (tag) { const groupName = tag.name; if (!groups[groupName]) { - groups[groupName] = []; - // +++ 如果状态未定义,则明确设为 true (展开) +++ + groups[groupName] = { connections: [], tagId: tag.id }; // 修改:存储 tagId if (expandedGroups.value[groupName] === undefined) { expandedGroups.value[groupName] = true; } } - // 避免重复添加(如果一个连接有多个标签) - if (!groups[groupName].some(c => c.id === conn.id)) { - groups[groupName].push(conn); + if (!groups[groupName].connections.some(c => c.id === conn.id)) { + groups[groupName].connections.push(conn); } tagged = true; } }); - // 如果所有标签都无效或未找到,则归入未标记 if (!tagged) { untagged.push(conn); } @@ -141,27 +155,29 @@ const filteredAndGroupedConnections = computed(() => { // 3. 排序和格式化输出 for (const groupName in groups) { - groups[groupName].sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host)); + 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(); - const result: { groupName: string; connections: ConnectionInfo[] }[] = sortedGroupNames.map(name => ({ + // 修改:结果包含 tagId + const result: { groupName: string; connections: ConnectionInfo[]; tagId: number | null }[] = sortedGroupNames.map(name => ({ groupName: name, - connections: groups[name] + connections: groups[name].connections, + tagId: groups[name].tagId // 添加 tagId })); if (untagged.length > 0) { const untaggedGroupName = t('workspaceConnectionList.untagged'); - // +++ 如果状态未定义,则明确设为 true (展开) +++ if (expandedGroups.value[untaggedGroupName] === undefined) { expandedGroups.value[untaggedGroupName] = true; } - result.push({ groupName: untaggedGroupName, connections: untagged }); + // 未标记的分组没有 tagId + result.push({ groupName: untaggedGroupName, connections: untagged, tagId: null }); } return result; - }); +}); // +++ 监听分组状态变化并保存到 localStorage +++ watch(expandedGroups, (newState) => { @@ -181,6 +197,19 @@ const filteredAndGroupedConnections = computed(() => { watch(expandedGroups, () => { highlightedIndex.value = -1; }, { deep: true }); + // +++ 监听编辑状态,自动聚焦输入框 +++ + 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) => { @@ -378,15 +407,112 @@ const scrollToHighlighted = async () => { 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; +};