feat: 在连接列表右键菜单添加克隆功能
This commit is contained in:
@@ -379,3 +379,36 @@ export const getRdpSessionToken = async (req: Request, res: Response): Promise<v
|
|||||||
res.status(statusCode).json({ message });
|
res.status(statusCode).json({ message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 克隆连接 (POST /api/v1/connections/:id/clone)
|
||||||
|
*/
|
||||||
|
export const cloneConnection = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
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 || '克隆连接时发生内部服务器错误。' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
testUnsavedConnection,
|
testUnsavedConnection,
|
||||||
exportConnections,
|
exportConnections,
|
||||||
importConnections,
|
importConnections,
|
||||||
getRdpSessionToken // Import the new controller function
|
getRdpSessionToken, // Import the new controller function
|
||||||
|
cloneConnection // +++ Import the clone controller function +++
|
||||||
} from './connections.controller';
|
} from './connections.controller';
|
||||||
|
|
||||||
const router = Router();
|
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
|
// POST /api/v1/connections/:id/rdp-session - Get RDP session token via backend
|
||||||
router.post('/:id/rdp-session', getRdpSessionToken);
|
router.post('/:id/rdp-session', getRdpSessionToken);
|
||||||
|
|
||||||
|
// +++ POST /api/v1/connections/:id/clone - 克隆连接 +++
|
||||||
|
router.post('/:id/clone', cloneConnection);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -135,10 +135,25 @@ export const findFullConnectionById = async (id: number): Promise<FullConnection
|
|||||||
throw new Error('获取连接详细信息失败');
|
throw new Error('获取连接详细信息失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
* 根据名称查找连接 (用于检查名称是否重复)
|
||||||
* 创建新连接 (不处理标签)
|
*/
|
||||||
|
export const findConnectionByName = async (name: string): Promise<ConnectionBase | null> => {
|
||||||
|
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<ConnectionBase>(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'
|
// Update input type to reflect FullConnectionData now has 'type'
|
||||||
export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>): Promise<number> => {
|
export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>): Promise<number> => {
|
||||||
@@ -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('获取连接标签失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量插入连接(用于导入)
|
* 批量插入连接(用于导入)
|
||||||
* 注意:此函数应在事务中调用 (由调用者负责事务)
|
* 注意:此函数应在事务中调用 (由调用者负责事务)
|
||||||
|
|||||||
@@ -432,3 +432,71 @@ export const getConnectionWithDecryptedCredentials = async (
|
|||||||
};
|
};
|
||||||
// 注意:testConnection、importConnections、exportConnections 逻辑
|
// 注意:testConnection、importConnections、exportConnections 逻辑
|
||||||
// 将分别移至 SshService 和 ImportExportService。
|
// 将分别移至 SshService 和 ImportExportService。
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 克隆连接
|
||||||
|
* @param originalId 要克隆的原始连接 ID
|
||||||
|
* @param newName 新连接的名称
|
||||||
|
* @returns 克隆后的新连接信息(包含标签)
|
||||||
|
*/
|
||||||
|
export const cloneConnection = async (originalId: number, newName: string): Promise<ConnectionWithTags> => {
|
||||||
|
// 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<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'> = {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ const closeContextMenu = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 处理右键菜单操作
|
// 处理右键菜单操作
|
||||||
const handleMenuAction = (action: 'add' | 'edit' | 'delete') => {
|
const handleMenuAction = (action: 'add' | 'edit' | 'delete' | 'clone') => { // 添加 'clone' 类型
|
||||||
const conn = contextTargetConnection.value;
|
const conn = contextTargetConnection.value;
|
||||||
closeContextMenu(); // 先关闭菜单
|
closeContextMenu(); // 先关闭菜单
|
||||||
|
|
||||||
@@ -227,6 +227,30 @@ const handleMenuAction = (action: 'add' | 'edit' | 'delete') => {
|
|||||||
connectionsStore.deleteConnection(conn.id);
|
connectionsStore.deleteConnection(conn.id);
|
||||||
// 注意:删除后列表会自动更新,因为 store 是响应式的
|
// 注意:删除后列表会自动更新,因为 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 () => {
|
|||||||
<i class="fas fa-edit mr-3 w-4 text-center text-text-secondary group-hover:text-primary"></i>
|
<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>
|
<span>{{ t('connections.actions.edit') }}</span>
|
||||||
</li>
|
</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')">
|
<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>
|
<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>
|
<span>{{ t('connections.actions.delete') }}</span>
|
||||||
|
|||||||
@@ -126,7 +126,8 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"testing": "Testing..."
|
"testing": "Testing...",
|
||||||
|
"clone": "Clone"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Add New Connection",
|
"title": "Add New Connection",
|
||||||
@@ -186,7 +187,9 @@
|
|||||||
"confirmDelete": "Are you sure you want to delete the connection \"{name}\"? This cannot be undone."
|
"confirmDelete": "Are you sure you want to delete the connection \"{name}\"? This cannot be undone."
|
||||||
},
|
},
|
||||||
"errors": {
|
"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": {
|
"status": {
|
||||||
"never": "Never"
|
"never": "Never"
|
||||||
|
|||||||
@@ -93,14 +93,17 @@
|
|||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"test": "テスト",
|
"test": "テスト",
|
||||||
"testing": "テスト中..."
|
"testing": "テスト中...",
|
||||||
},
|
"clone": "クローン"
|
||||||
"addConnection": "新しい接続を追加",
|
},
|
||||||
"addFirstConnection": "最初の接続を追加",
|
"addConnection": "新しい接続を追加",
|
||||||
|
"addFirstConnection": "最初の接続を追加",
|
||||||
"errors": {
|
"errors": {
|
||||||
"deleteFailed": "接続の削除に失敗しました: {error}"
|
"deleteFailed": "接続の削除に失敗しました: {error}",
|
||||||
},
|
"createFailed": "接続の追加に失敗しました: {error}",
|
||||||
"form": {
|
"cloneFailed": "接続のクローン作成に失敗しました: {error}"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
"adding": "追加中...",
|
"adding": "追加中...",
|
||||||
"authMethod": "認証方法:",
|
"authMethod": "認証方法:",
|
||||||
"authMethodKey": "SSHキー",
|
"authMethodKey": "SSHキー",
|
||||||
|
|||||||
@@ -126,7 +126,8 @@
|
|||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"test": "测试",
|
"test": "测试",
|
||||||
"testing": "测试中..."
|
"testing": "测试中...",
|
||||||
|
"clone": "克隆"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "添加新连接",
|
"title": "添加新连接",
|
||||||
@@ -186,7 +187,9 @@
|
|||||||
"confirmDelete": "确定要删除连接 \"{name}\" 吗?此操作不可撤销。"
|
"confirmDelete": "确定要删除连接 \"{name}\" 吗?此操作不可撤销。"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"deleteFailed": "删除连接失败: {error}"
|
"deleteFailed": "删除连接失败: {error}",
|
||||||
|
"createFailed": "添加连接失败: {error}",
|
||||||
|
"cloneFailed": "克隆连接失败: {error}"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"never": "从未"
|
"never": "从未"
|
||||||
|
|||||||
@@ -207,5 +207,31 @@ export const useConnectionsStore = defineStore('connections', {
|
|||||||
// this.isLoading = false;
|
// this.isLoading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 新增:克隆连接 Action (调用后端克隆接口)
|
||||||
|
async cloneConnection(originalId: number, newName: string): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user