diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index a8c90f9..654e814 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -379,3 +379,36 @@ export const getRdpSessionToken = async (req: Request, res: Response): Promise => { + try { + const originalConnectionId = parseInt(req.params.id, 10); + const { name: newName } = req.body; // 从请求体获取新名称 + + if (isNaN(originalConnectionId)) { + res.status(400).json({ message: '无效的原始连接 ID。' }); + return; + } + if (!newName || typeof newName !== 'string') { + res.status(400).json({ message: '需要提供有效的字符串类型的新连接名称 (name)。' }); + return; + } + + const clonedConnection = await ConnectionService.cloneConnection(originalConnectionId, newName); + + res.status(201).json({ message: '连接克隆成功。', connection: clonedConnection }); + + } catch (error: any) { + console.error(`Controller: 克隆连接 ${req.params.id} 时发生错误:`, error); + if (error.message.includes('未找到')) { + res.status(404).json({ message: error.message }); + } else if (error.message.includes('名称已存在')) { + res.status(409).json({ message: error.message }); // 409 Conflict for duplicate name + } 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 0456561..d6cc2f6 100644 --- a/packages/backend/src/connections/connections.routes.ts +++ b/packages/backend/src/connections/connections.routes.ts @@ -11,7 +11,8 @@ import { testUnsavedConnection, exportConnections, importConnections, - getRdpSessionToken // Import the new controller function + getRdpSessionToken, // Import the new controller function + cloneConnection // +++ Import the clone controller function +++ } from './connections.controller'; const router = Router(); @@ -80,4 +81,7 @@ router.post('/test-unsaved', testUnsavedConnection); // POST /api/v1/connections/:id/rdp-session - Get RDP session token via backend router.post('/:id/rdp-session', getRdpSessionToken); +// +++ POST /api/v1/connections/:id/clone - 克隆连接 +++ +router.post('/:id/clone', cloneConnection); + export default router; diff --git a/packages/backend/src/repositories/connection.repository.ts b/packages/backend/src/repositories/connection.repository.ts index f5630ed..5579583 100644 --- a/packages/backend/src/repositories/connection.repository.ts +++ b/packages/backend/src/repositories/connection.repository.ts @@ -135,10 +135,25 @@ export const findFullConnectionById = async (id: number): Promise => { + const sql = `SELECT id, name, type, host, port, username, auth_method, proxy_id, ssh_key_id, created_at, updated_at, last_connected_at FROM connections WHERE name = ?`; + try { + const db = await getDbInstance(); + const row = await getDbRow(db, sql, [name]); + return row || null; + } catch (err: any) { + console.error(`Repository: 查询连接名称 "${name}" 时出错:`, err.message); + throw new Error('查找连接名称失败'); + } + }; + + + /** + * 创建新连接 (不处理标签) */ // Update input type to reflect FullConnectionData now has 'type' export const createConnection = async (data: Omit): Promise => { @@ -276,6 +291,27 @@ export const updateConnectionTags = async (connectionId: number, tagIds: number[ } }; +/** + * 查找指定连接的所有标签 + * @param connectionId 连接 ID + * @returns 标签对象数组 { id: number, name: string }[] + */ +export const findConnectionTags = async (connectionId: number): Promise<{ id: number, name: string }[]> => { + const sql = ` + SELECT t.id, t.name + FROM tags t + JOIN connection_tags ct ON t.id = ct.tag_id + WHERE ct.connection_id = ?`; + try { + const db = await getDbInstance(); + const rows = await allDb<{ id: number, name: string }>(db, sql, [connectionId]); + return rows; + } catch (err: any) { + console.error(`Repository: 查询连接 ${connectionId} 的标签时出错:`, err.message); + throw new Error('获取连接标签失败'); + } +}; + /** * 批量插入连接(用于导入) * 注意:此函数应在事务中调用 (由调用者负责事务) diff --git a/packages/backend/src/services/connection.service.ts b/packages/backend/src/services/connection.service.ts index bfa430c..da6444f 100644 --- a/packages/backend/src/services/connection.service.ts +++ b/packages/backend/src/services/connection.service.ts @@ -432,3 +432,71 @@ export const getConnectionWithDecryptedCredentials = async ( }; // 注意:testConnection、importConnections、exportConnections 逻辑 // 将分别移至 SshService 和 ImportExportService。 + + +/** + * 克隆连接 + * @param originalId 要克隆的原始连接 ID + * @param newName 新连接的名称 + * @returns 克隆后的新连接信息(包含标签) + */ +export const cloneConnection = async (originalId: number, newName: string): Promise => { + // 1. 检查新名称是否已存在 + const existingByName = await ConnectionRepository.findConnectionByName(newName); + if (existingByName) { + throw new Error(`名称为 "${newName}" 的连接已存在。`); + } + + // 2. 获取原始连接的完整数据(包括加密字段和 ssh_key_id) + const originalFullConnection = await ConnectionRepository.findFullConnectionById(originalId); + if (!originalFullConnection) { + throw new Error(`ID 为 ${originalId} 的原始连接未找到。`); + } + + // 3. 准备新连接的数据 + // 使用 Omit 来排除不需要的字段,并确保类型正确 + const dataForNewConnection: Omit = { + name: newName, + type: originalFullConnection.type, + host: originalFullConnection.host, + port: originalFullConnection.port, + username: originalFullConnection.username, + auth_method: originalFullConnection.auth_method, + encrypted_password: originalFullConnection.encrypted_password ?? null, + encrypted_private_key: originalFullConnection.encrypted_private_key ?? null, + encrypted_passphrase: originalFullConnection.encrypted_passphrase ?? null, + ssh_key_id: originalFullConnection.ssh_key_id ?? null, // 保留原始的 ssh_key_id + proxy_id: originalFullConnection.proxy_id ?? null, + // 移除不存在的 RDP 字段复制 + // ...(originalFullConnection.rdp_security && { rdp_security: originalFullConnection.rdp_security }), + // ...(originalFullConnection.rdp_ignore_cert !== undefined && { rdp_ignore_cert: originalFullConnection.rdp_ignore_cert }), + }; + + // 4. 创建新连接记录 + const newConnectionId = await ConnectionRepository.createConnection(dataForNewConnection); + + // 5. 复制原始连接的标签 + const originalTags = await ConnectionRepository.findConnectionTags(originalId); + if (originalTags.length > 0) { + const tagIds = originalTags.map(tag => tag.id); + await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds); + } + + // 6. 记录审计操作 + const clonedConnection = await getConnectionById(newConnectionId); + if (!clonedConnection) { + console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after cloning from ${originalId}.`); + throw new Error('克隆连接后无法检索到该连接。'); + } + // 使用 CONNECTION_CREATED 事件,但添加额外信息表明是克隆操作 + auditLogService.logAction('CONNECTION_CREATED', { + connectionId: clonedConnection.id, + type: clonedConnection.type, + name: clonedConnection.name, + host: clonedConnection.host, + clonedFromId: originalId // 添加克隆来源信息 + }); + + // 7. 返回新创建的带标签的连接 + return clonedConnection; +}; diff --git a/packages/frontend/src/components/WorkspaceConnectionList.vue b/packages/frontend/src/components/WorkspaceConnectionList.vue index 17fffc3..30927d3 100644 --- a/packages/frontend/src/components/WorkspaceConnectionList.vue +++ b/packages/frontend/src/components/WorkspaceConnectionList.vue @@ -210,7 +210,7 @@ const closeContextMenu = () => { }; // 处理右键菜单操作 -const handleMenuAction = (action: 'add' | 'edit' | 'delete') => { +const handleMenuAction = (action: 'add' | 'edit' | 'delete' | 'clone') => { // 添加 'clone' 类型 const conn = contextTargetConnection.value; closeContextMenu(); // 先关闭菜单 @@ -227,6 +227,30 @@ const handleMenuAction = (action: 'add' | 'edit' | 'delete') => { 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 中已有错误处理 + }); } } }; @@ -424,6 +448,10 @@ const scrollToHighlighted = async () => { {{ t('connections.actions.edit') }} +
  • + + {{ t('connections.actions.clone') }} +
  • {{ t('connections.actions.delete') }} diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 06ab9ee..d0138c1 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -126,7 +126,8 @@ "edit": "Edit", "delete": "Delete", "test": "Test", - "testing": "Testing..." + "testing": "Testing...", + "clone": "Clone" }, "form": { "title": "Add New Connection", @@ -186,7 +187,9 @@ "confirmDelete": "Are you sure you want to delete the connection \"{name}\"? This cannot be undone." }, "errors": { - "deleteFailed": "Failed to delete connection: {error}" + "deleteFailed": "Failed to delete connection: {error}", + "createFailed": "Failed to add connection: {error}", + "cloneFailed": "Failed to clone connection: {error}" }, "status": { "never": "Never" diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 6b25a62..86364de 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -93,14 +93,17 @@ "delete": "削除", "edit": "編集", "test": "テスト", - "testing": "テスト中..." - }, - "addConnection": "新しい接続を追加", - "addFirstConnection": "最初の接続を追加", + "testing": "テスト中...", + "clone": "クローン" + }, + "addConnection": "新しい接続を追加", + "addFirstConnection": "最初の接続を追加", "errors": { - "deleteFailed": "接続の削除に失敗しました: {error}" - }, - "form": { + "deleteFailed": "接続の削除に失敗しました: {error}", + "createFailed": "接続の追加に失敗しました: {error}", + "cloneFailed": "接続のクローン作成に失敗しました: {error}" + }, + "form": { "adding": "追加中...", "authMethod": "認証方法:", "authMethodKey": "SSHキー", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 1264dfa..78133e2 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -126,7 +126,8 @@ "edit": "编辑", "delete": "删除", "test": "测试", - "testing": "测试中..." + "testing": "测试中...", + "clone": "克隆" }, "form": { "title": "添加新连接", @@ -186,7 +187,9 @@ "confirmDelete": "确定要删除连接 \"{name}\" 吗?此操作不可撤销。" }, "errors": { - "deleteFailed": "删除连接失败: {error}" + "deleteFailed": "删除连接失败: {error}", + "createFailed": "添加连接失败: {error}", + "cloneFailed": "克隆连接失败: {error}" }, "status": { "never": "从未" diff --git a/packages/frontend/src/stores/connections.store.ts b/packages/frontend/src/stores/connections.store.ts index dd8734c..f070a64 100644 --- a/packages/frontend/src/stores/connections.store.ts +++ b/packages/frontend/src/stores/connections.store.ts @@ -207,5 +207,31 @@ export const useConnectionsStore = defineStore('connections', { // this.isLoading = false; } }, + + // 新增:克隆连接 Action (调用后端克隆接口) + async cloneConnection(originalId: number, newName: string): Promise { + this.isLoading = true; // 可以考虑为克隆操作设置单独的加载状态 + this.error = null; + try { + // 调用后端的克隆接口,例如 POST /connections/:id/clone + // 请求体可以包含新名称等信息 + // 假设后端接口需要 { name: newName } 作为请求体 + await apiClient.post(`/connections/${originalId}/clone`, { name: newName }); + + // 克隆成功后,清除缓存并重新获取列表以显示新连接 + localStorage.removeItem('connectionsCache'); + await this.fetchConnections(); // 重新获取以保证数据一致性 + return true; // 表示成功 + } catch (err: any) { + console.error(`克隆连接 ${originalId} 失败:`, err); + this.error = err.response?.data?.message || err.message || `克隆连接时发生未知错误。`; + if (err.response?.status === 401) { + console.warn('未授权,需要登录才能克隆连接。'); + } + return false; // 表示失败 + } finally { + this.isLoading = false; + } + }, }, });