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 { 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(() => {
</option>
</select>
</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>
@@ -333,4 +375,4 @@ onMounted(() => {
</div>
</div>
</Teleport>
</template>
</template>
@@ -304,7 +304,10 @@ const cancelForm = () => {
</div>
<div v-else>
<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>
</template>