From a2ac4047d96b167ba17c5e3a4b09614a67fd9fab Mon Sep 17 00:00:00 2001 From: yinjianm Date: Thu, 26 Mar 2026 00:23:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=94=AF=E6=8C=81=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E7=BC=96=E8=BE=91=E5=92=8C=E7=AE=A1=E7=90=86=E5=B7=B2?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=99=BB=E5=BD=95=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为连接表单补充已保存登录凭证的校验与提交流程, 允许新增、批量新增、测试连接时优先使用凭证 在连接列表中新增登录凭证管理入口,并支持批量编辑 时按连接类型筛选和应用已保存凭证 补充中英日文案,并修复 SSH 密钥选择器的绑定兼容性 --- .../components/BatchEditConnectionForm.vue | 44 ++++++++++++++- .../LoginCredentialManagementModal.vue | 5 +- .../src/composables/useAddConnectionForm.ts | 55 +++++++++++++------ packages/frontend/src/locales/en-US.json | 19 +++++++ packages/frontend/src/locales/ja-JP.json | 17 ++++++ packages/frontend/src/locales/zh-CN.json | 19 +++++++ .../frontend/src/stores/connections.store.ts | 4 +- .../frontend/src/views/ConnectionsView.vue | 15 +++++ 8 files changed, 158 insertions(+), 20 deletions(-) diff --git a/packages/frontend/src/components/BatchEditConnectionForm.vue b/packages/frontend/src/components/BatchEditConnectionForm.vue index f6d5b98..873d984 100644 --- a/packages/frontend/src/components/BatchEditConnectionForm.vue +++ b/packages/frontend/src/components/BatchEditConnectionForm.vue @@ -7,6 +7,7 @@ import { useUiNotificationsStore } from '../stores/uiNotifications.store'; import { useProxiesStore } from '../stores/proxies.store'; import { useTagsStore, type TagInfo } from '../stores/tags.store'; import { useSshKeysStore, type SshKeyBasicInfo } from '../stores/sshKeys.store'; +import { useLoginCredentialsStore, type LoginCredentialBasicInfo } from '../stores/loginCredentials.store'; import TagInput from './TagInput.vue'; interface BatchUpdateData { @@ -14,6 +15,7 @@ interface BatchUpdateData { username?: string | null; password?: string | null; ssh_key_id?: number | null; + login_credential_id?: number | null; proxy_id?: number | null; tag_ids?: number[]; notes?: string | null; @@ -38,6 +40,7 @@ const uiNotificationsStore = useUiNotificationsStore(); const proxiesStore = useProxiesStore(); const tagsStore = useTagsStore(); const sshKeysStore = useSshKeysStore(); +const loginCredentialsStore = useLoginCredentialsStore(); const internalVisible = ref(props.visible); const isLoading = ref(false); @@ -51,6 +54,14 @@ const enableAdvancedEdit = ref(false); const availableTags = computed(() => tagsStore.tags as TagInfo[]); const availableProxies = computed(() => proxiesStore.proxies); const availableSshKeys = computed(() => sshKeysStore.sshKeys as SshKeyBasicInfo[]); +const selectedConnections = computed(() => connectionsStore.connections.filter((conn) => props.connectionIds.includes(conn.id))); +const selectedConnectionTypes = computed(() => Array.from(new Set(selectedConnections.value.map((conn) => conn.type)))); +const availableLoginCredentials = computed(() => { + if (selectedConnectionTypes.value.length !== 1) { + return [] as LoginCredentialBasicInfo[]; + } + return loginCredentialsStore.loginCredentials.filter((credential) => credential.type === selectedConnectionTypes.value[0]); +}); watch(() => props.visible, (newVal) => { internalVisible.value = newVal; @@ -60,6 +71,7 @@ watch(() => props.visible, (newVal) => { username: undefined, password: undefined, ssh_key_id: undefined, + login_credential_id: undefined, proxy_id: undefined, tag_ids: undefined, notes: undefined, // Keep notes initialization @@ -78,6 +90,9 @@ watch(() => props.visible, (newVal) => { if (availableSshKeys.value.length === 0 && !sshKeysStore.isLoading) { sshKeysStore.fetchSshKeys(); } + if (loginCredentialsStore.loginCredentials.length === 0 && !loginCredentialsStore.isLoading) { + loginCredentialsStore.fetchLoginCredentials(); + } } }); @@ -118,6 +133,14 @@ const handleSave = async () => { if (formData.value.ssh_key_id !== undefined) { updatesToApply.ssh_key_id = formData.value.ssh_key_id; } + if (formData.value.login_credential_id !== undefined) { + if (selectedConnectionTypes.value.length !== 1 && formData.value.login_credential_id !== null) { + uiNotificationsStore.addNotification({ message: t('connections.batchEdit.savedCredentialMixedType', '批量应用已保存凭证前,请先只选择同一种连接类型。'), type: 'warning' }); + isLoading.value = false; + return; + } + updatesToApply.login_credential_id = formData.value.login_credential_id; + } } if (enableAdvancedEdit.value) { @@ -199,6 +222,9 @@ onMounted(() => { if (availableSshKeys.value.length === 0 && !sshKeysStore.isLoading) { sshKeysStore.fetchSshKeys(); } + if (loginCredentialsStore.loginCredentials.length === 0 && !loginCredentialsStore.isLoading) { + loginCredentialsStore.fetchLoginCredentials(); + } } }); @@ -264,6 +290,22 @@ onMounted(() => { +
+ + +
@@ -333,4 +375,4 @@ onMounted(() => { - \ No newline at end of file + diff --git a/packages/frontend/src/components/LoginCredentialManagementModal.vue b/packages/frontend/src/components/LoginCredentialManagementModal.vue index bcdbe48..3e05dab 100644 --- a/packages/frontend/src/components/LoginCredentialManagementModal.vue +++ b/packages/frontend/src/components/LoginCredentialManagementModal.vue @@ -304,7 +304,10 @@ const cancelForm = () => {
- +
diff --git a/packages/frontend/src/composables/useAddConnectionForm.ts b/packages/frontend/src/composables/useAddConnectionForm.ts index 0253500..c4b4bf2 100644 --- a/packages/frontend/src/composables/useAddConnectionForm.ts +++ b/packages/frontend/src/composables/useAddConnectionForm.ts @@ -612,7 +612,13 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon const availableTagIds = tags.value.map(t_ => t_.id); const currentSelectedValidTagIds = formData.tag_ids.filter(id => availableTagIds.includes(id)); - if (!formData.host || !formData.username) { + const usingSavedCredential = formData.credential_source === 'saved'; + + if (!formData.host) { + uiNotificationsStore.showError(t('connections.form.errorRequiredFields')); + return; + } + if (!usingSavedCredential && !formData.username) { uiNotificationsStore.showError(t('connections.form.errorRequiredFields')); return; } @@ -621,7 +627,12 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon return; } - if (formData.type === 'SSH') { + if (usingSavedCredential && !formData.login_credential_id) { + uiNotificationsStore.showError(t('connections.form.errorLoginCredentialRequired', '请选择已保存的登录凭证。')); + return; + } + + if (!usingSavedCredential && formData.type === 'SSH') { if (!isEditMode.value) { if (formData.auth_method === 'password' && !formData.password && !formData.host.includes('~')) { uiNotificationsStore.showError(t('connections.form.errorPasswordRequired')); @@ -641,12 +652,12 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon return; } } - } else if (formData.type === 'RDP') { + } else if (!usingSavedCredential && formData.type === 'RDP') { if (!isEditMode.value && !formData.password && !formData.host.includes('~')) { uiNotificationsStore.showError(t('connections.form.errorPasswordRequired')); return; } - } else if (formData.type === 'VNC') { + } else if (!usingSavedCredential && formData.type === 'VNC') { if (!isEditMode.value && !formData.vncPassword && !formData.host.includes('~')) { uiNotificationsStore.showError(t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。')); return; @@ -658,19 +669,23 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon if (Array.isArray(parsedIpsResult)) { const ips = parsedIpsResult; - if (formData.type === 'SSH' && formData.auth_method === 'key' && !formData.selected_ssh_key_id) { + if (usingSavedCredential && !formData.login_credential_id) { + uiNotificationsStore.showError(t('connections.form.errorLoginCredentialRequired', '请选择已保存的登录凭证。')); + return; + } + if (!usingSavedCredential && formData.type === 'SSH' && formData.auth_method === 'key' && !formData.selected_ssh_key_id) { uiNotificationsStore.showError(t('connections.form.errorSshKeyRequiredForBatch', '批量添加 SSH (密钥认证) 连接时,必须选择一个 SSH 密钥。')); return; } - if (formData.type === 'SSH' && formData.auth_method === 'password' && !formData.password) { + if (!usingSavedCredential && formData.type === 'SSH' && formData.auth_method === 'password' && !formData.password) { uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchSSH', '批量添加 SSH (密码认证) 连接时,必须提供密码。')); return; } - if (formData.type === 'RDP' && !formData.password) { + if (!usingSavedCredential && formData.type === 'RDP' && !formData.password) { uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchRDP', '批量添加 RDP 连接时,必须提供密码。')); return; } - if (formData.type === 'VNC' && !formData.vncPassword) { + if (!usingSavedCredential && formData.type === 'VNC' && !formData.vncPassword) { uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchVNC', '批量添加 VNC 连接时,必须提供 VNC 密码。')); return; } @@ -689,23 +704,24 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon host: currentIp, port: formData.port, username: formData.username, + login_credential_id: usingSavedCredential ? formData.login_credential_id : null, notes: formData.notes, proxy_id: formData.proxy_id || null, tag_ids: currentSelectedValidTagIds, proxy_type: formData.proxy_type, }; - if (formData.type === 'SSH') { + if (!usingSavedCredential && formData.type === 'SSH') { dataForThisIp.auth_method = formData.auth_method; if (formData.auth_method === 'password') { dataForThisIp.password = formData.password; } else if (formData.auth_method === 'key') { dataForThisIp.ssh_key_id = formData.selected_ssh_key_id; } - } else if (formData.type === 'RDP') { + } else if (!usingSavedCredential && formData.type === 'RDP') { dataForThisIp.password = formData.password; delete dataForThisIp.auth_method; - } else if (formData.type === 'VNC') { + } else if (!usingSavedCredential && formData.type === 'VNC') { dataForThisIp.password = formData.vncPassword; delete dataForThisIp.auth_method; } @@ -755,13 +771,14 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon port: formData.port, notes: formData.notes, username: formData.username, + login_credential_id: usingSavedCredential ? formData.login_credential_id : null, proxy_id: formData.proxy_id || null, proxy_type: formData.proxy_type, tag_ids: currentSelectedValidTagIds, jump_chain: formData.jump_chain ? JSON.parse(JSON.stringify(formData.jump_chain)) : null, }; - if (formData.type === 'SSH') { + if (!usingSavedCredential && formData.type === 'SSH') { dataToSend.auth_method = formData.auth_method; if (formData.auth_method === 'password') { if (formData.password) dataToSend.password = formData.password; @@ -770,10 +787,10 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon dataToSend.ssh_key_id = formData.selected_ssh_key_id; } } - } else if (formData.type === 'RDP') { + } else if (!usingSavedCredential && formData.type === 'RDP') { if (formData.password) dataToSend.password = formData.password; delete dataToSend.auth_method; - } else if (formData.type === 'VNC') { + } else if (!usingSavedCredential && formData.type === 'VNC') { if (formData.vncPassword) dataToSend.password = formData.vncPassword; delete dataToSend.auth_method; } @@ -866,16 +883,20 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon auth_method: formData.auth_method, password: formData.auth_method === 'password' ? formData.password : undefined, proxy_id: formData.proxy_id || null, + login_credential_id: formData.credential_source === 'saved' ? formData.login_credential_id : null, ssh_key_id: formData.auth_method === 'key' ? formData.selected_ssh_key_id : undefined, }; - if (!dataToSend.host || !dataToSend.port || !dataToSend.username || !dataToSend.auth_method) { + if (!dataToSend.host || !dataToSend.port) { throw new Error(t('connections.test.errorMissingFields')); } - if (dataToSend.auth_method === 'password' && !formData.password) { + if (formData.credential_source !== 'saved' && (!dataToSend.username || !dataToSend.auth_method)) { + throw new Error(t('connections.test.errorMissingFields')); + } + if (formData.credential_source !== 'saved' && dataToSend.auth_method === 'password' && !formData.password) { throw new Error(t('connections.form.errorPasswordRequired')); } - if (dataToSend.auth_method === 'key' && !dataToSend.ssh_key_id) { + if (formData.credential_source !== 'saved' && dataToSend.auth_method === 'key' && !dataToSend.ssh_key_id) { throw new Error(t('connections.form.errorSshKeyRequired')); } response = await apiClient.post('/connections/test-unsaved', dataToSend); diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index b0fb2bb..ab7c110 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -215,6 +215,8 @@ "title": "Batch Edit Connections", "editSelected": "Edit Selected", "noChange":"No change", + "savedCredentialMixedType": "Select only one connection type before applying a saved credential in batch.", + "savedCredentialTypeHint": "Batch apply works only for the same connection type", "selectedItems":"{count} items selected", "deleteSelectedButton": "Delete Selected", "deleteSelectedTooltip": "Delete selected connections", @@ -273,6 +275,23 @@ "connectionModeProxy": "Proxy Server", "connectionModeJumpHost": "Jump Host", "connectionType": "Connection Type:", + "credentialSource": "Credential Source", + "credentialSourceDirect": "Account / Password / Key", + "credentialSourceSaved": "Use Saved Credential", + "savedLoginCredential": "Login Credential", + "selectLoginCredential": "Select a saved credential", + "manageLoginCredentials": "Manage Login Credentials", + "savedCredentialHint": "Saved credentials take priority during test and connect. You can switch back to direct input anytime.", + "loginCredentialManager": "Login Credentials", + "addLoginCredential": "Add Login Credential", + "editLoginCredential": "Edit Login Credential", + "noLoginCredentials": "No saved login credentials yet", + "clearSavedCredential": "Clear saved credential", + "errorLoginCredentialRequired": "Please select a saved login credential.", + "errorCredentialRequiredFields": "Name and username are required.", + "errorCredentialDetails": "Failed to load login credential details.", + "confirmDeleteCredential": "Delete login credential {name}?", + "errorDeleteCredential": "Failed to delete login credential.", "typeSsh": "SSH", "typeRdp": "RDP", "typeVnc": "VNC", diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 90c5197..a28e1d3 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -213,6 +213,23 @@ "errorPasswordRequiredForBatchSSH": "SSH (パスワード認証) 接続を一括追加する場合、パスワードを提供する必要があります。", "errorPasswordRequiredForBatchRDP": "RDP接続を一括追加する場合、パスワードを提供する必要があります。", "errorPasswordRequiredForBatchVNC": "VNC接続を一括追加する場合、VNCパスワードを提供する必要があります。", + "credentialSource": "認証ソース", + "credentialSourceDirect": "アカウント / パスワード / キー", + "credentialSourceSaved": "保存済み認証情報を使用", + "savedLoginCredential": "ログイン認証情報", + "selectLoginCredential": "ログイン認証情報を選択してください", + "manageLoginCredentials": "ログイン認証情報を管理", + "savedCredentialHint": "保存済み認証情報はテストと接続時に優先して使用されます。いつでも直接入力へ戻せます。", + "loginCredentialManager": "ログイン認証情報", + "addLoginCredential": "ログイン認証情報を追加", + "editLoginCredential": "ログイン認証情報を編集", + "noLoginCredentials": "保存済みのログイン認証情報はありません", + "clearSavedCredential": "保存済み認証情報を解除", + "errorLoginCredentialRequired": "保存済みのログイン認証情報を選択してください。", + "errorCredentialRequiredFields": "名前とユーザー名は必須です。", + "errorCredentialDetails": "ログイン認証情報の詳細取得に失敗しました。", + "confirmDeleteCredential": "ログイン認証情報 {name} を削除しますか?", + "errorDeleteCredential": "ログイン認証情報の削除に失敗しました。", "errorBatchAddResult": "一括追加: {successCount} 件成功, {errorCount} 件失敗。最初のエラー: {firstErrorEncountered}", "successBatchAddResult": "一括追加成功: {successCount} 件の接続が作成されました。", "errorIpRangeNotAllowedInEditMode": "編集モードではIP範囲はサポートされていません。単一のIPアドレスを使用してください。", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 29a9e6f..7f25552 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -217,6 +217,8 @@ "title": "批量编辑连接", "selectedItems": "已选项目", "noChange": "保持不变", + "savedCredentialMixedType": "批量应用已保存凭证前,请先只选择同一种连接类型。", + "savedCredentialTypeHint": "仅支持同类型连接批量应用", "deleteSelectedButton": "删除选中", "deleteSelectedTooltip": "删除选中的连接", "confirmMessage": "您确定要删除选中的 {count} 个连接吗?此操作无法撤销。", @@ -273,6 +275,23 @@ "connectionModeProxy": "代理服务器", "connectionModeJumpHost": "跳板机", "connectionType": "连接类型", + "credentialSource": "认证来源", + "credentialSourceDirect": "账号密码 / 密钥", + "credentialSourceSaved": "使用已保存凭证", + "savedLoginCredential": "登录凭证", + "selectLoginCredential": "请选择登录凭证", + "manageLoginCredentials": "管理登录凭证", + "savedCredentialHint": "已保存凭证会在连接和测试时优先使用;切回直填后仍可继续手工输入。", + "loginCredentialManager": "登录凭证", + "addLoginCredential": "新增登录凭证", + "editLoginCredential": "编辑登录凭证", + "noLoginCredentials": "暂无登录凭证", + "clearSavedCredential": "取消已保存凭证", + "errorLoginCredentialRequired": "请选择已保存的登录凭证。", + "errorCredentialRequiredFields": "名称和用户名为必填项。", + "errorCredentialDetails": "获取登录凭证详情失败。", + "confirmDeleteCredential": "确认删除登录凭证 {name}?", + "errorDeleteCredential": "删除登录凭证失败。", "typeSsh": "SSH", "typeRdp": "RDP", "typeVnc": "VNC", diff --git a/packages/frontend/src/stores/connections.store.ts b/packages/frontend/src/stores/connections.store.ts index a492a04..fe1c200 100644 --- a/packages/frontend/src/stores/connections.store.ts +++ b/packages/frontend/src/stores/connections.store.ts @@ -10,6 +10,7 @@ export interface ConnectionInfo { port: number; username: string; auth_method: 'password' | 'key'; + login_credential_id?: number | null; proxy_id?: number | null; // 关联的代理 ID (可选) proxy_type?: 'proxy' | 'jump' | null; tag_ids?: number[]; // 关联的标签 ID 数组 (可选) @@ -95,6 +96,7 @@ export const useConnectionsStore = defineStore('connections', { port: number; username: string; auth_method: 'password' | 'key'; // SSH specific + login_credential_id?: number | null; password?: string; // SSH password or general password private_key?: string; // SSH specific passphrase?: string; // SSH specific @@ -129,7 +131,7 @@ export const useConnectionsStore = defineStore('connections', { // 更新连接 Action // 更新参数类型以包含 proxy_id 和 tag_ids // Update parameter type to include 'type' and VNC fields - async updateConnection(connectionId: number, updatedData: Partial & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; proxy_type?: 'proxy' | 'jump' | null; tag_ids?: number[]; jump_chain?: number[] | null; }>) { + async updateConnection(connectionId: number, updatedData: Partial & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; proxy_type?: 'proxy' | 'jump' | null; tag_ids?: number[]; jump_chain?: number[] | null; login_credential_id?: number | null; }>) { this.isLoading = true; this.error = null; try { diff --git a/packages/frontend/src/views/ConnectionsView.vue b/packages/frontend/src/views/ConnectionsView.vue index 43fbfbe..66be40d 100644 --- a/packages/frontend/src/views/ConnectionsView.vue +++ b/packages/frontend/src/views/ConnectionsView.vue @@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import AddConnectionForm from '../components/AddConnectionForm.vue'; import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue'; +import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue'; import { useConnectionsStore } from '../stores/connections.store'; import { useSessionStore } from '../stores/session.store'; import { useTagsStore } from '../stores/tags.store'; @@ -89,6 +90,7 @@ const tagsSectionExpanded = ref(true); const showAddEditConnectionForm = ref(false); const connectionToEdit = ref(null); +const showLoginCredentialManagement = ref(false); const isBatchEditMode = ref(false); const selectedConnectionIdsForBatch = ref>(new Set()); @@ -1287,6 +1289,14 @@ onBeforeUnmount(() => { {{ t('connections.addConnection', '新增连接') }} +