feat(frontend): 支持批量编辑和管理已保存登录凭证

为连接表单补充已保存登录凭证的校验与提交流程,
允许新增、批量新增、测试连接时优先使用凭证

在连接列表中新增登录凭证管理入口,并支持批量编辑
时按连接类型筛选和应用已保存凭证

补充中英日文案,并修复 SSH 密钥选择器的绑定兼容性
This commit is contained in:
yinjianm
2026-03-26 00:23:02 +08:00
parent 1081c73254
commit a2ac4047d9
8 changed files with 158 additions and 20 deletions
@@ -7,6 +7,7 @@ import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { useProxiesStore } from '../stores/proxies.store'; import { useProxiesStore } from '../stores/proxies.store';
import { useTagsStore, type TagInfo } from '../stores/tags.store'; import { useTagsStore, type TagInfo } from '../stores/tags.store';
import { useSshKeysStore, type SshKeyBasicInfo } from '../stores/sshKeys.store'; import { useSshKeysStore, type SshKeyBasicInfo } from '../stores/sshKeys.store';
import { useLoginCredentialsStore, type LoginCredentialBasicInfo } from '../stores/loginCredentials.store';
import TagInput from './TagInput.vue'; import TagInput from './TagInput.vue';
interface BatchUpdateData { interface BatchUpdateData {
@@ -14,6 +15,7 @@ interface BatchUpdateData {
username?: string | null; username?: string | null;
password?: string | null; password?: string | null;
ssh_key_id?: number | null; ssh_key_id?: number | null;
login_credential_id?: number | null;
proxy_id?: number | null; proxy_id?: number | null;
tag_ids?: number[]; tag_ids?: number[];
notes?: string | null; notes?: string | null;
@@ -38,6 +40,7 @@ const uiNotificationsStore = useUiNotificationsStore();
const proxiesStore = useProxiesStore(); const proxiesStore = useProxiesStore();
const tagsStore = useTagsStore(); const tagsStore = useTagsStore();
const sshKeysStore = useSshKeysStore(); const sshKeysStore = useSshKeysStore();
const loginCredentialsStore = useLoginCredentialsStore();
const internalVisible = ref(props.visible); const internalVisible = ref(props.visible);
const isLoading = ref(false); const isLoading = ref(false);
@@ -51,6 +54,14 @@ const enableAdvancedEdit = ref(false);
const availableTags = computed(() => tagsStore.tags as TagInfo[]); const availableTags = computed(() => tagsStore.tags as TagInfo[]);
const availableProxies = computed(() => proxiesStore.proxies); const availableProxies = computed(() => proxiesStore.proxies);
const availableSshKeys = computed(() => sshKeysStore.sshKeys as SshKeyBasicInfo[]); 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) => { watch(() => props.visible, (newVal) => {
internalVisible.value = newVal; internalVisible.value = newVal;
@@ -60,6 +71,7 @@ watch(() => props.visible, (newVal) => {
username: undefined, username: undefined,
password: undefined, password: undefined,
ssh_key_id: undefined, ssh_key_id: undefined,
login_credential_id: undefined,
proxy_id: undefined, proxy_id: undefined,
tag_ids: undefined, tag_ids: undefined,
notes: undefined, // Keep notes initialization notes: undefined, // Keep notes initialization
@@ -78,6 +90,9 @@ watch(() => props.visible, (newVal) => {
if (availableSshKeys.value.length === 0 && !sshKeysStore.isLoading) { if (availableSshKeys.value.length === 0 && !sshKeysStore.isLoading) {
sshKeysStore.fetchSshKeys(); 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) { if (formData.value.ssh_key_id !== undefined) {
updatesToApply.ssh_key_id = formData.value.ssh_key_id; 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) { if (enableAdvancedEdit.value) {
@@ -199,6 +222,9 @@ onMounted(() => {
if (availableSshKeys.value.length === 0 && !sshKeysStore.isLoading) { if (availableSshKeys.value.length === 0 && !sshKeysStore.isLoading) {
sshKeysStore.fetchSshKeys(); sshKeysStore.fetchSshKeys();
} }
if (loginCredentialsStore.loginCredentials.length === 0 && !loginCredentialsStore.isLoading) {
loginCredentialsStore.fetchLoginCredentials();
}
} }
}); });
@@ -264,6 +290,22 @@ onMounted(() => {
</option> </option>
</select> </select>
</div> </div>
<div>
<label for="batch-login-credential" class="block text-sm font-medium text-text-secondary">{{ t('connections.form.savedLoginCredential', '已保存凭证') }}</label>
<select
id="batch-login-credential"
v-model="formData.login_credential_id"
class="mt-1 block w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary sm:text-sm"
:disabled="loginCredentialsStore.isLoading"
>
<option :value="undefined">{{ t('connections.batchEdit.noChange', '-- 不更改 --') }}</option>
<option :value="null">{{ t('connections.form.clearSavedCredential', '取消已保存凭证') }}</option>
<option v-if="selectedConnectionTypes.length !== 1" disabled>{{ t('connections.batchEdit.savedCredentialTypeHint', '仅支持同类型连接批量应用') }}</option>
<option v-for="credential in availableLoginCredentials" :key="credential.id" :value="credential.id">
{{ credential.name }} ({{ credential.username }})
</option>
</select>
</div>
</div> </div>
</div> </div>
@@ -304,7 +304,10 @@ const cancelForm = () => {
</div> </div>
<div v-else> <div v-else>
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.sshKey', 'SSH 密钥') }}</label> <label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.sshKey', 'SSH 密钥') }}</label>
<SshKeySelector v-model="formData.ssh_key_id" /> <SshKeySelector
:model-value="formData.ssh_key_id ?? null"
@update:model-value="value => formData.ssh_key_id = value"
/>
</div> </div>
</template> </template>
@@ -612,7 +612,13 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
const availableTagIds = tags.value.map(t_ => t_.id); const availableTagIds = tags.value.map(t_ => t_.id);
const currentSelectedValidTagIds = formData.tag_ids.filter(id => availableTagIds.includes(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')); uiNotificationsStore.showError(t('connections.form.errorRequiredFields'));
return; return;
} }
@@ -621,7 +627,12 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
return; 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 (!isEditMode.value) {
if (formData.auth_method === 'password' && !formData.password && !formData.host.includes('~')) { if (formData.auth_method === 'password' && !formData.password && !formData.host.includes('~')) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequired')); uiNotificationsStore.showError(t('connections.form.errorPasswordRequired'));
@@ -641,12 +652,12 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
return; return;
} }
} }
} else if (formData.type === 'RDP') { } else if (!usingSavedCredential && formData.type === 'RDP') {
if (!isEditMode.value && !formData.password && !formData.host.includes('~')) { if (!isEditMode.value && !formData.password && !formData.host.includes('~')) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequired')); uiNotificationsStore.showError(t('connections.form.errorPasswordRequired'));
return; return;
} }
} else if (formData.type === 'VNC') { } else if (!usingSavedCredential && formData.type === 'VNC') {
if (!isEditMode.value && !formData.vncPassword && !formData.host.includes('~')) { if (!isEditMode.value && !formData.vncPassword && !formData.host.includes('~')) {
uiNotificationsStore.showError(t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。')); uiNotificationsStore.showError(t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。'));
return; return;
@@ -658,19 +669,23 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
if (Array.isArray(parsedIpsResult)) { if (Array.isArray(parsedIpsResult)) {
const ips = 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 密钥。')); uiNotificationsStore.showError(t('connections.form.errorSshKeyRequiredForBatch', '批量添加 SSH (密钥认证) 连接时,必须选择一个 SSH 密钥。'));
return; 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 (密码认证) 连接时,必须提供密码。')); uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchSSH', '批量添加 SSH (密码认证) 连接时,必须提供密码。'));
return; return;
} }
if (formData.type === 'RDP' && !formData.password) { if (!usingSavedCredential && formData.type === 'RDP' && !formData.password) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchRDP', '批量添加 RDP 连接时,必须提供密码。')); uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchRDP', '批量添加 RDP 连接时,必须提供密码。'));
return; return;
} }
if (formData.type === 'VNC' && !formData.vncPassword) { if (!usingSavedCredential && formData.type === 'VNC' && !formData.vncPassword) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchVNC', '批量添加 VNC 连接时,必须提供 VNC 密码。')); uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchVNC', '批量添加 VNC 连接时,必须提供 VNC 密码。'));
return; return;
} }
@@ -689,23 +704,24 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
host: currentIp, host: currentIp,
port: formData.port, port: formData.port,
username: formData.username, username: formData.username,
login_credential_id: usingSavedCredential ? formData.login_credential_id : null,
notes: formData.notes, notes: formData.notes,
proxy_id: formData.proxy_id || null, proxy_id: formData.proxy_id || null,
tag_ids: currentSelectedValidTagIds, tag_ids: currentSelectedValidTagIds,
proxy_type: formData.proxy_type, proxy_type: formData.proxy_type,
}; };
if (formData.type === 'SSH') { if (!usingSavedCredential && formData.type === 'SSH') {
dataForThisIp.auth_method = formData.auth_method; dataForThisIp.auth_method = formData.auth_method;
if (formData.auth_method === 'password') { if (formData.auth_method === 'password') {
dataForThisIp.password = formData.password; dataForThisIp.password = formData.password;
} else if (formData.auth_method === 'key') { } else if (formData.auth_method === 'key') {
dataForThisIp.ssh_key_id = formData.selected_ssh_key_id; dataForThisIp.ssh_key_id = formData.selected_ssh_key_id;
} }
} else if (formData.type === 'RDP') { } else if (!usingSavedCredential && formData.type === 'RDP') {
dataForThisIp.password = formData.password; dataForThisIp.password = formData.password;
delete dataForThisIp.auth_method; delete dataForThisIp.auth_method;
} else if (formData.type === 'VNC') { } else if (!usingSavedCredential && formData.type === 'VNC') {
dataForThisIp.password = formData.vncPassword; dataForThisIp.password = formData.vncPassword;
delete dataForThisIp.auth_method; delete dataForThisIp.auth_method;
} }
@@ -755,13 +771,14 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
port: formData.port, port: formData.port,
notes: formData.notes, notes: formData.notes,
username: formData.username, username: formData.username,
login_credential_id: usingSavedCredential ? formData.login_credential_id : null,
proxy_id: formData.proxy_id || null, proxy_id: formData.proxy_id || null,
proxy_type: formData.proxy_type, proxy_type: formData.proxy_type,
tag_ids: currentSelectedValidTagIds, tag_ids: currentSelectedValidTagIds,
jump_chain: formData.jump_chain ? JSON.parse(JSON.stringify(formData.jump_chain)) : null, 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; dataToSend.auth_method = formData.auth_method;
if (formData.auth_method === 'password') { if (formData.auth_method === 'password') {
if (formData.password) dataToSend.password = formData.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; 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; if (formData.password) dataToSend.password = formData.password;
delete dataToSend.auth_method; delete dataToSend.auth_method;
} else if (formData.type === 'VNC') { } else if (!usingSavedCredential && formData.type === 'VNC') {
if (formData.vncPassword) dataToSend.password = formData.vncPassword; if (formData.vncPassword) dataToSend.password = formData.vncPassword;
delete dataToSend.auth_method; delete dataToSend.auth_method;
} }
@@ -866,16 +883,20 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
auth_method: formData.auth_method, auth_method: formData.auth_method,
password: formData.auth_method === 'password' ? formData.password : undefined, password: formData.auth_method === 'password' ? formData.password : undefined,
proxy_id: formData.proxy_id || null, 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, 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')); 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')); 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')); throw new Error(t('connections.form.errorSshKeyRequired'));
} }
response = await apiClient.post('/connections/test-unsaved', dataToSend); response = await apiClient.post('/connections/test-unsaved', dataToSend);
+19
View File
@@ -215,6 +215,8 @@
"title": "Batch Edit Connections", "title": "Batch Edit Connections",
"editSelected": "Edit Selected", "editSelected": "Edit Selected",
"noChange":"No change", "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", "selectedItems":"{count} items selected",
"deleteSelectedButton": "Delete Selected", "deleteSelectedButton": "Delete Selected",
"deleteSelectedTooltip": "Delete selected connections", "deleteSelectedTooltip": "Delete selected connections",
@@ -273,6 +275,23 @@
"connectionModeProxy": "Proxy Server", "connectionModeProxy": "Proxy Server",
"connectionModeJumpHost": "Jump Host", "connectionModeJumpHost": "Jump Host",
"connectionType": "Connection Type:", "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", "typeSsh": "SSH",
"typeRdp": "RDP", "typeRdp": "RDP",
"typeVnc": "VNC", "typeVnc": "VNC",
+17
View File
@@ -213,6 +213,23 @@
"errorPasswordRequiredForBatchSSH": "SSH (パスワード認証) 接続を一括追加する場合、パスワードを提供する必要があります。", "errorPasswordRequiredForBatchSSH": "SSH (パスワード認証) 接続を一括追加する場合、パスワードを提供する必要があります。",
"errorPasswordRequiredForBatchRDP": "RDP接続を一括追加する場合、パスワードを提供する必要があります。", "errorPasswordRequiredForBatchRDP": "RDP接続を一括追加する場合、パスワードを提供する必要があります。",
"errorPasswordRequiredForBatchVNC": "VNC接続を一括追加する場合、VNCパスワードを提供する必要があります。", "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}", "errorBatchAddResult": "一括追加: {successCount} 件成功, {errorCount} 件失敗。最初のエラー: {firstErrorEncountered}",
"successBatchAddResult": "一括追加成功: {successCount} 件の接続が作成されました。", "successBatchAddResult": "一括追加成功: {successCount} 件の接続が作成されました。",
"errorIpRangeNotAllowedInEditMode": "編集モードではIP範囲はサポートされていません。単一のIPアドレスを使用してください。", "errorIpRangeNotAllowedInEditMode": "編集モードではIP範囲はサポートされていません。単一のIPアドレスを使用してください。",
+19
View File
@@ -217,6 +217,8 @@
"title": "批量编辑连接", "title": "批量编辑连接",
"selectedItems": "已选项目", "selectedItems": "已选项目",
"noChange": "保持不变", "noChange": "保持不变",
"savedCredentialMixedType": "批量应用已保存凭证前,请先只选择同一种连接类型。",
"savedCredentialTypeHint": "仅支持同类型连接批量应用",
"deleteSelectedButton": "删除选中", "deleteSelectedButton": "删除选中",
"deleteSelectedTooltip": "删除选中的连接", "deleteSelectedTooltip": "删除选中的连接",
"confirmMessage": "您确定要删除选中的 {count} 个连接吗?此操作无法撤销。", "confirmMessage": "您确定要删除选中的 {count} 个连接吗?此操作无法撤销。",
@@ -273,6 +275,23 @@
"connectionModeProxy": "代理服务器", "connectionModeProxy": "代理服务器",
"connectionModeJumpHost": "跳板机", "connectionModeJumpHost": "跳板机",
"connectionType": "连接类型", "connectionType": "连接类型",
"credentialSource": "认证来源",
"credentialSourceDirect": "账号密码 / 密钥",
"credentialSourceSaved": "使用已保存凭证",
"savedLoginCredential": "登录凭证",
"selectLoginCredential": "请选择登录凭证",
"manageLoginCredentials": "管理登录凭证",
"savedCredentialHint": "已保存凭证会在连接和测试时优先使用;切回直填后仍可继续手工输入。",
"loginCredentialManager": "登录凭证",
"addLoginCredential": "新增登录凭证",
"editLoginCredential": "编辑登录凭证",
"noLoginCredentials": "暂无登录凭证",
"clearSavedCredential": "取消已保存凭证",
"errorLoginCredentialRequired": "请选择已保存的登录凭证。",
"errorCredentialRequiredFields": "名称和用户名为必填项。",
"errorCredentialDetails": "获取登录凭证详情失败。",
"confirmDeleteCredential": "确认删除登录凭证 {name}",
"errorDeleteCredential": "删除登录凭证失败。",
"typeSsh": "SSH", "typeSsh": "SSH",
"typeRdp": "RDP", "typeRdp": "RDP",
"typeVnc": "VNC", "typeVnc": "VNC",
@@ -10,6 +10,7 @@ export interface ConnectionInfo {
port: number; port: number;
username: string; username: string;
auth_method: 'password' | 'key'; auth_method: 'password' | 'key';
login_credential_id?: number | null;
proxy_id?: number | null; // 关联的代理 ID (可选) proxy_id?: number | null; // 关联的代理 ID (可选)
proxy_type?: 'proxy' | 'jump' | null; proxy_type?: 'proxy' | 'jump' | null;
tag_ids?: number[]; // 关联的标签 ID 数组 (可选) tag_ids?: number[]; // 关联的标签 ID 数组 (可选)
@@ -95,6 +96,7 @@ export const useConnectionsStore = defineStore('connections', {
port: number; port: number;
username: string; username: string;
auth_method: 'password' | 'key'; // SSH specific auth_method: 'password' | 'key'; // SSH specific
login_credential_id?: number | null;
password?: string; // SSH password or general password password?: string; // SSH password or general password
private_key?: string; // SSH specific private_key?: string; // SSH specific
passphrase?: string; // SSH specific passphrase?: string; // SSH specific
@@ -129,7 +131,7 @@ export const useConnectionsStore = defineStore('connections', {
// 更新连接 Action // 更新连接 Action
// 更新参数类型以包含 proxy_id 和 tag_ids // 更新参数类型以包含 proxy_id 和 tag_ids
// Update parameter type to include 'type' and VNC fields // Update parameter type to include 'type' and VNC fields
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { 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<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { 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.isLoading = true;
this.error = null; this.error = null;
try { try {
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import AddConnectionForm from '../components/AddConnectionForm.vue'; import AddConnectionForm from '../components/AddConnectionForm.vue';
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue'; import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue';
import { useConnectionsStore } from '../stores/connections.store'; import { useConnectionsStore } from '../stores/connections.store';
import { useSessionStore } from '../stores/session.store'; import { useSessionStore } from '../stores/session.store';
import { useTagsStore } from '../stores/tags.store'; import { useTagsStore } from '../stores/tags.store';
@@ -89,6 +90,7 @@ const tagsSectionExpanded = ref(true);
const showAddEditConnectionForm = ref(false); const showAddEditConnectionForm = ref(false);
const connectionToEdit = ref<ConnectionInfo | null>(null); const connectionToEdit = ref<ConnectionInfo | null>(null);
const showLoginCredentialManagement = ref(false);
const isBatchEditMode = ref(false); const isBatchEditMode = ref(false);
const selectedConnectionIdsForBatch = ref<Set<number>>(new Set()); const selectedConnectionIdsForBatch = ref<Set<number>>(new Set());
@@ -1287,6 +1289,14 @@ onBeforeUnmount(() => {
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span>{{ t('connections.addConnection', '新增连接') }}</span> <span>{{ t('connections.addConnection', '新增连接') }}</span>
</button> </button>
<button
@click="showLoginCredentialManagement = true"
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2"
:title="t('connections.form.loginCredentialManager', '登录凭证')"
>
<i class="fas fa-key"></i>
<span>{{ t('connections.form.loginCredentialManager', '登录凭证') }}</span>
</button>
<button <button
@click="handleTestAllFilteredConnections" @click="handleTestAllFilteredConnections"
:disabled="isTestingAll || isLoadingConnections || !filteredAndSortedConnections.some(c => c.type === 'SSH')" :disabled="isTestingAll || isLoadingConnections || !filteredAndSortedConnections.some(c => c.type === 'SSH')"
@@ -1620,6 +1630,11 @@ onBeforeUnmount(() => {
@update:visible="handleBatchEditFormClose" @update:visible="handleBatchEditFormClose"
@saved="handleBatchEditSaved" @saved="handleBatchEditSaved"
/> />
<LoginCredentialManagementModal
v-if="showLoginCredentialManagement"
@close="showLoginCredentialManagement = false"
/>
</div> </div>
</div> </div>
</template> </template>