重构(前端): 持久化快速命令排序和密码切换

添加持久化排序字段并重新排序快速命令和标签的端点,更新前端以支持手动拖放排序,并为连接和凭据表单添加密码可见性切换。此外,将 SSH 连接测试作为连接列表中的默认操作,并刷新相关模块文档和更改日志。
This commit is contained in:
yinjianm
2026-04-19 02:50:44 +08:00
parent 00d7c6c2f3
commit 8ce007a305
33 changed files with 1996 additions and 975 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import SshKeySelector from './SshKeySelector.vue'; // Assuming SshKeySelector is used here
import LoginCredentialSelector from './LoginCredentialSelector.vue';
@@ -19,6 +20,26 @@ const props = defineProps<{
}>();
const { t } = useI18n();
const visiblePasswordFields = reactive({
sshPassword: false,
rdpPassword: false,
vncPassword: false,
});
const resetPasswordVisibility = (): void => {
visiblePasswordFields.sshPassword = false;
visiblePasswordFields.rdpPassword = false;
visiblePasswordFields.vncPassword = false;
};
const togglePasswordVisibility = (field: keyof typeof visiblePasswordFields): void => {
visiblePasswordFields[field] = !visiblePasswordFields[field];
};
watch(() => props.formData.type, resetPasswordVisibility);
watch(() => props.formData.auth_method, resetPasswordVisibility);
watch(() => props.formData.credential_source, resetPasswordVisibility);
</script>
<template>
@@ -86,8 +107,20 @@ const { t } = useI18n();
<div v-if="props.formData.auth_method === 'password'">
<label for="conn-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
<input type="password" id="conn-password" v-model="props.formData.password" :required="props.formData.auth_method === 'password' && !isEditMode" autocomplete="new-password"
class="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 focus:border-primary" />
<div class="relative">
<input :type="visiblePasswordFields.sshPassword ? 'text' : 'password'" id="conn-password" v-model="props.formData.password" :required="props.formData.auth_method === 'password' && !isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.sshPassword"
@click="togglePasswordVisibility('sshPassword')"
>
<i :class="visiblePasswordFields.sshPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<div v-if="props.formData.auth_method === 'key'" class="space-y-4">
@@ -102,8 +135,20 @@ const { t } = useI18n();
<template v-if="props.formData.type === 'RDP'">
<div>
<label for="conn-password-rdp" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
<input type="password" id="conn-password-rdp" v-model="props.formData.password" :required="!isEditMode" autocomplete="new-password"
class="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 focus:border-primary" />
<div class="relative">
<input :type="visiblePasswordFields.rdpPassword ? 'text' : 'password'" id="conn-password-rdp" v-model="props.formData.password" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.rdpPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.rdpPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.rdpPassword"
@click="togglePasswordVisibility('rdpPassword')"
>
<i :class="visiblePasswordFields.rdpPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
</template>
@@ -111,8 +156,20 @@ const { t } = useI18n();
<template v-if="props.formData.type === 'VNC'">
<div>
<label for="conn-password-vnc" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.vncPassword', 'VNC 密码') }}</label>
<input type="password" id="conn-password-vnc" v-model="props.formData.vncPassword" :required="!isEditMode" autocomplete="new-password"
class="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 focus:border-primary" />
<div class="relative">
<input :type="visiblePasswordFields.vncPassword ? 'text' : 'password'" id="conn-password-vnc" v-model="props.formData.vncPassword" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.vncPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.vncPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.vncPassword"
@click="togglePasswordVisibility('vncPassword')"
>
<i :class="visiblePasswordFields.vncPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
</template>
</template>
@@ -4,9 +4,9 @@
ref="modalContentRef"
class="bg-background text-foreground p-6 rounded-xl border border-border/50 shadow-2xl flex flex-col"
:style="{
width: resizableWidth ? `${resizableWidth}px` : undefined,
width: resizableWidth ? `${resizableWidth}px` : `min(calc(100vw - ${MODAL_VIEWPORT_GUTTER_PX}px), ${MODAL_DEFAULT_WIDTH_RATIO * 100}vw)`,
height: resizableHeight ? `${resizableHeight}px` : undefined,
maxWidth: 'calc(100vw - 2rem)',
maxWidth: `calc(100vw - ${MODAL_VIEWPORT_GUTTER_PX}px)`,
maxHeight: 'calc(100vh - 2rem)',
}"
>
@@ -183,6 +183,8 @@ const modalContentRef = ref<HTMLElement | null>(null);
const commandTextareaRef = ref<HTMLTextAreaElement | null>(null);
const R_MIN_WIDTH = 580; // 可调整大小的最小宽度 (像素)
const R_MIN_HEIGHT = 440; // 可调整大小的最小高度 (像素)
const MODAL_DEFAULT_WIDTH_RATIO = 0.6;
const MODAL_VIEWPORT_GUTTER_PX = 32;
const placeholder = t('quickCommands.form.commandPlaceholder') + 'echo "Hello,\${USERNAME}"'
const { width: resizableWidth, height: resizableHeight } = useResizable(modalContentRef, {
@@ -239,7 +241,7 @@ watch(() => formData.command, (newCommand) => {
// 初始化表单数据 (如果是编辑模式)
onMounted(() => {
if (typeof window !== 'undefined') {
let initialW = Math.min(window.innerWidth * 0.74, 860); // 目标 74vw,最大 860px
let initialW = Math.min(window.innerWidth * MODAL_DEFAULT_WIDTH_RATIO, window.innerWidth - MODAL_VIEWPORT_GUTTER_PX);
let initialH = Math.min(window.innerHeight * 0.68, 600); // 目标 68vh,最大 600px
initialW = Math.max(R_MIN_WIDTH, initialW);
@@ -39,6 +39,19 @@ const initialFormData: LoginCredentialInput = {
const formData = reactive({ ...initialFormData });
const formError = ref<string | null>(null);
const visiblePasswordFields = reactive({
sshPassword: false,
genericPassword: false,
});
const resetPasswordVisibility = (): void => {
visiblePasswordFields.sshPassword = false;
visiblePasswordFields.genericPassword = false;
};
const togglePasswordVisibility = (field: keyof typeof visiblePasswordFields): void => {
visiblePasswordFields[field] = !visiblePasswordFields[field];
};
watch(() => props.initialType, (newValue) => {
if (!credentialToEdit.value && newValue) {
@@ -47,12 +60,17 @@ watch(() => props.initialType, (newValue) => {
});
watch(() => formData.type, (newType) => {
resetPasswordVisibility();
if (newType !== 'SSH') {
formData.auth_method = 'password';
formData.ssh_key_id = null;
}
});
watch(() => formData.auth_method, () => {
resetPasswordVisibility();
});
onMounted(() => {
loginCredentialsStore.fetchLoginCredentials();
});
@@ -60,6 +78,7 @@ onMounted(() => {
const resetForm = () => {
Object.assign(formData, initialFormData, { type: props.initialType || 'SSH' });
formError.value = null;
resetPasswordVisibility();
};
const showAddForm = () => {
@@ -71,6 +90,7 @@ const showAddForm = () => {
const showEditForm = async (credential: LoginCredentialBasicInfo) => {
formError.value = null;
credentialToEdit.value = credential;
resetPasswordVisibility();
const details = await loginCredentialsStore.fetchLoginCredentialDetails(credential.id);
if (!details) {
@@ -300,7 +320,19 @@ const cancelForm = () => {
</div>
<div v-if="formData.auth_method === 'password'">
<label for="credential-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password', '密码') }}</label>
<input id="credential-password" v-model="formData.password" type="password" autocomplete="new-password" class="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 focus:border-primary" />
<div class="relative">
<input id="credential-password" v-model="formData.password" :type="visiblePasswordFields.sshPassword ? 'text' : 'password'" autocomplete="new-password" class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.sshPassword"
@click="togglePasswordVisibility('sshPassword')"
>
<i :class="visiblePasswordFields.sshPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<div v-else>
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.sshKey', 'SSH 密钥') }}</label>
@@ -313,7 +345,19 @@ const cancelForm = () => {
<div v-else>
<label for="credential-password-generic" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password', '密码') }}</label>
<input id="credential-password-generic" v-model="formData.password" type="password" autocomplete="new-password" class="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 focus:border-primary" />
<div class="relative">
<input id="credential-password-generic" v-model="formData.password" :type="visiblePasswordFields.genericPassword ? 'text' : 'password'" autocomplete="new-password" class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.genericPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.genericPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.genericPassword"
@click="togglePasswordVisibility('genericPassword')"
>
<i :class="visiblePasswordFields.genericPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<div>