update
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import TagInput from './TagInput.vue'; // Assuming TagInput is used here
|
||||||
|
import type { ProxyInfo } from '../stores/proxies.store'; // Corrected Proxy to ProxyInfo
|
||||||
|
import type { TagInfo } from '../stores/tags.store'; // Corrected Tag to TagInfo
|
||||||
|
|
||||||
|
// Define Props.
|
||||||
|
const props = defineProps<{
|
||||||
|
formData: {
|
||||||
|
type: 'SSH' | 'RDP' | 'VNC'; // Needed to conditionally show proxy selector
|
||||||
|
proxy_id: number | null;
|
||||||
|
tag_ids: number[];
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
proxies: ProxyInfo[]; // List of available proxies
|
||||||
|
tags: TagInfo[]; // List of available tags
|
||||||
|
isProxyLoading: boolean;
|
||||||
|
proxyStoreError: string | null;
|
||||||
|
isTagLoading: boolean;
|
||||||
|
tagStoreError: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Define Emits for tag creation and deletion
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'create-tag', tagName: string): void;
|
||||||
|
(e: 'delete-tag', tagId: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const handleCreateTagEvent = (tagName: string) => {
|
||||||
|
emit('create-tag', tagName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTagEvent = (tagId: number) => {
|
||||||
|
emit('delete-tag', tagId);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Advanced Options Section -->
|
||||||
|
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
||||||
|
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionAdvanced', '高级选项') }}</h4>
|
||||||
|
|
||||||
|
<!-- Proxy Select - Show only for SSH -->
|
||||||
|
<div v-if="props.formData.type === 'SSH'">
|
||||||
|
<label for="conn-proxy" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.proxy') }} ({{ t('connections.form.optional') }})</label>
|
||||||
|
<select id="conn-proxy" v-model="props.formData.proxy_id"
|
||||||
|
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 appearance-none bg-no-repeat bg-right pr-8"
|
||||||
|
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
|
||||||
|
<option :value="null">{{ t('connections.form.noProxy') }}</option>
|
||||||
|
<option v-for="proxy in props.proxies" :key="proxy.id" :value="proxy.id">
|
||||||
|
{{ proxy.name }} ({{ proxy.type }} - {{ proxy.host }}:{{ proxy.port }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-if="props.isProxyLoading" class="mt-1 text-xs text-text-secondary">{{ t('proxies.loading') }}</div>
|
||||||
|
<div v-if="props.proxyStoreError" class="mt-1 text-xs text-error">{{ t('proxies.error', { error: props.proxyStoreError }) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.tags') }} ({{ t('connections.form.optional') }})</label>
|
||||||
|
<TagInput
|
||||||
|
v-model="props.formData.tag_ids"
|
||||||
|
:available-tags="props.tags"
|
||||||
|
:allow-create="true"
|
||||||
|
:allow-delete="true"
|
||||||
|
@create-tag="handleCreateTagEvent"
|
||||||
|
@delete-tag="handleDeleteTagEvent"
|
||||||
|
:placeholder="t('tags.inputPlaceholder', '添加或选择标签...')"
|
||||||
|
/>
|
||||||
|
<div v-if="props.isTagLoading" class="mt-1 text-xs text-text-secondary">{{ t('tags.loading') }}</div>
|
||||||
|
<div v-if="props.tagStoreError" class="mt-1 text-xs text-error">{{ t('tags.error', { error: props.tagStoreError }) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes Section -->
|
||||||
|
<div>
|
||||||
|
<label for="conn-notes" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.notes', '备注') }}</label>
|
||||||
|
<textarea id="conn-notes" v-model="props.formData.notes" rows="3"
|
||||||
|
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"
|
||||||
|
:placeholder="t('connections.form.notesPlaceholder', '输入连接备注...')"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import SshKeySelector from './SshKeySelector.vue'; // Assuming SshKeySelector is used here
|
||||||
|
|
||||||
|
// Define Props. formData is expected to be a reactive object from the parent composable.
|
||||||
|
const props = defineProps<{
|
||||||
|
formData: {
|
||||||
|
type: 'SSH' | 'RDP' | 'VNC';
|
||||||
|
username: string;
|
||||||
|
auth_method: 'password' | 'key'; // SSH specific
|
||||||
|
password?: string; // Optional because it might not be set or sent
|
||||||
|
selected_ssh_key_id: number | null; // SSH specific
|
||||||
|
vncPassword?: string; // VNC specific, optional for the same reasons as password
|
||||||
|
};
|
||||||
|
isEditMode: boolean; // To determine if fields are required
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Authentication Section -->
|
||||||
|
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
||||||
|
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionAuth', '认证信息') }}</h4>
|
||||||
|
<div>
|
||||||
|
<label for="conn-username" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.username') }}</label>
|
||||||
|
<input type="text" id="conn-username" v-model="props.formData.username" required
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- SSH Specific Auth -->
|
||||||
|
<template v-if="props.formData.type === 'SSH'">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.authMethod') }}</label>
|
||||||
|
<div class="flex rounded-md shadow-sm">
|
||||||
|
<button type="button"
|
||||||
|
@click="props.formData.auth_method = 'password'"
|
||||||
|
:class="['flex-1 px-3 py-2 border border-border text-sm font-medium focus:outline-none',
|
||||||
|
props.formData.auth_method === 'password' ? 'bg-primary text-white' : 'bg-background text-foreground hover:bg-border',
|
||||||
|
'rounded-l-md']">
|
||||||
|
{{ t('connections.form.authMethodPassword') }}
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="props.formData.auth_method = 'key'"
|
||||||
|
:class="['flex-1 px-3 py-2 border-t border-b border-r border-border text-sm font-medium focus:outline-none -ml-px',
|
||||||
|
props.formData.auth_method === 'key' ? 'bg-primary text-white' : 'bg-background text-foreground hover:bg-border',
|
||||||
|
'rounded-r-md']">
|
||||||
|
{{ t('connections.form.authMethodKey') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div v-if="props.formData.auth_method === 'key'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.sshKey') }}</label>
|
||||||
|
<SshKeySelector v-model="props.formData.selected_ssh_key_id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- RDP Specific Auth -->
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- VNC Specific Auth -->
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick, Teleport } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
// Define Props. formData is expected to be a reactive object from the parent composable.
|
||||||
|
const props = defineProps<{
|
||||||
|
formData: {
|
||||||
|
name: string;
|
||||||
|
type: 'SSH' | 'RDP' | 'VNC';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Tooltip state and refs for the host input
|
||||||
|
const showHostTooltip = ref(false);
|
||||||
|
const hostTooltipStyle = ref({});
|
||||||
|
const hostIconRef = ref<HTMLElement | null>(null);
|
||||||
|
const hostTooltipContentRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const handleHostIconMouseEnter = async () => {
|
||||||
|
showHostTooltip.value = true;
|
||||||
|
await nextTick(); // Wait for DOM update so tooltipRect can be calculated
|
||||||
|
|
||||||
|
if (hostIconRef.value && hostTooltipContentRef.value) {
|
||||||
|
const iconRect = hostIconRef.value.getBoundingClientRect();
|
||||||
|
const tooltipRect = hostTooltipContentRef.value.getBoundingClientRect();
|
||||||
|
|
||||||
|
let top = iconRect.top - tooltipRect.height - 8; // 8px offset above the icon
|
||||||
|
let left = iconRect.left + (iconRect.width / 2) - (tooltipRect.width / 2); // Center the tooltip
|
||||||
|
|
||||||
|
// Boundary checks to keep tooltip within viewport
|
||||||
|
if (top < 0) { // If not enough space on top, show below
|
||||||
|
top = iconRect.bottom + 8;
|
||||||
|
}
|
||||||
|
if (left < 0) {
|
||||||
|
left = 0;
|
||||||
|
}
|
||||||
|
if (left + tooltipRect.width > window.innerWidth) {
|
||||||
|
left = window.innerWidth - tooltipRect.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
hostTooltipStyle.value = {
|
||||||
|
position: 'fixed', // Ensure positioning is relative to viewport
|
||||||
|
top: `${top}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHostIconMouseLeave = () => {
|
||||||
|
showHostTooltip.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showHostTooltip"
|
||||||
|
ref="hostTooltipContentRef"
|
||||||
|
:style="hostTooltipStyle"
|
||||||
|
class="fixed w-max max-w-xs p-2 text-xs text-white bg-gray-800 rounded shadow-lg z-[1000] whitespace-pre-wrap pointer-events-none"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
{{ t('connections.form.hostTooltip', '支持 IP 范围, 例如 192.168.1.10~192.168.1.15 (仅限添加模式)') }}
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
<!-- Basic Info Section -->
|
||||||
|
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
||||||
|
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionBasic', '基本信息') }}</h4>
|
||||||
|
<div>
|
||||||
|
<label for="conn-name" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.name') }} ({{ t('connections.form.optional') }})</label>
|
||||||
|
<input type="text" id="conn-name" v-model="props.formData.name"
|
||||||
|
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>
|
||||||
|
<!-- Connection Type -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.connectionType', '连接类型') }}</label>
|
||||||
|
<div class="flex rounded-md shadow-sm">
|
||||||
|
<button type="button"
|
||||||
|
@click="props.formData.type = 'SSH'"
|
||||||
|
:class="['flex-1 px-3 py-2 border border-border text-sm font-medium focus:outline-none',
|
||||||
|
props.formData.type === 'SSH' ? 'bg-primary text-white' : 'bg-background text-foreground hover:bg-border',
|
||||||
|
'rounded-l-md']">
|
||||||
|
{{ t('connections.form.typeSsh', 'SSH') }}
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="props.formData.type = 'RDP'"
|
||||||
|
:class="['flex-1 px-3 py-2 border-t border-b border-r border-border text-sm font-medium focus:outline-none -ml-px',
|
||||||
|
props.formData.type === 'RDP' ? 'bg-primary text-white' : 'bg-background text-foreground hover:bg-border']">
|
||||||
|
{{ t('connections.form.typeRdp', 'RDP') }}
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="props.formData.type = 'VNC'"
|
||||||
|
:class="['flex-1 px-3 py-2 border border-border text-sm font-medium focus:outline-none -ml-px',
|
||||||
|
props.formData.type === 'VNC' ? 'bg-primary text-white' : 'bg-background text-foreground hover:bg-border',
|
||||||
|
'rounded-r-md']">
|
||||||
|
{{ t('connections.form.typeVnc', 'VNC') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Host and Port Row -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="conn-host" class="block text-sm font-medium text-text-secondary mb-1">
|
||||||
|
{{ t('connections.form.host') }}
|
||||||
|
<span class="relative ml-1" @mouseenter="handleHostIconMouseEnter" @mouseleave="handleHostIconMouseLeave">
|
||||||
|
<i ref="hostIconRef" class="fas fa-exclamation-circle text-text-secondary cursor-help"></i>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="conn-host" v-model="props.formData.host" required
|
||||||
|
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>
|
||||||
|
<div>
|
||||||
|
<label for="conn-port" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.port') }}</label>
|
||||||
|
<input type="number" id="conn-port" v-model.number="props.formData.port" required min="1" max="65535"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,847 @@
|
|||||||
|
import { ref, reactive, watch, computed, onMounted, toRefs } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import apiClient from '../utils/apiClient';
|
||||||
|
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store';
|
||||||
|
import { useProxiesStore } from '../stores/proxies.store';
|
||||||
|
import { useTagsStore } from '../stores/tags.store';
|
||||||
|
import { useSshKeysStore } from '../stores/sshKeys.store';
|
||||||
|
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||||
|
|
||||||
|
// Define Props interface based on the component's props
|
||||||
|
interface AddConnectionFormProps {
|
||||||
|
connectionToEdit: ConnectionInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define Emits type based on the component's emits
|
||||||
|
type AddConnectionFormEmits = {
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'connection-added'): void;
|
||||||
|
(e: 'connection-updated'): void;
|
||||||
|
(e: 'connection-deleted'): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddConnectionFormEmits) {
|
||||||
|
const { connectionToEdit } = toRefs(props);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const connectionsStore = useConnectionsStore();
|
||||||
|
const proxiesStore = useProxiesStore();
|
||||||
|
const tagsStore = useTagsStore();
|
||||||
|
const sshKeysStore = useSshKeysStore();
|
||||||
|
const uiNotificationsStore = useUiNotificationsStore();
|
||||||
|
|
||||||
|
const { isLoading: isConnLoading, error: connStoreError } = storeToRefs(connectionsStore);
|
||||||
|
const { proxies, isLoading: isProxyLoading, error: proxyStoreError } = storeToRefs(proxiesStore);
|
||||||
|
const { tags, isLoading: isTagLoading, error: tagStoreError } = storeToRefs(tagsStore);
|
||||||
|
const { sshKeys, isLoading: isSshKeyLoading, error: sshKeyStoreError } = storeToRefs(sshKeysStore);
|
||||||
|
|
||||||
|
// 表单数据模型
|
||||||
|
const initialFormData = {
|
||||||
|
type: 'SSH' as 'SSH' | 'RDP' | 'VNC', // Use uppercase to match ConnectionInfo
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: 22,
|
||||||
|
username: '',
|
||||||
|
auth_method: 'password' as 'password' | 'key', // SSH specific
|
||||||
|
password: '',
|
||||||
|
private_key: '', // SSH specific (for direct input) - This field seems unused in the new logic, but kept for initialData consistency
|
||||||
|
passphrase: '', // SSH specific (for direct input) - This field seems unused, kept for consistency
|
||||||
|
selected_ssh_key_id: null as number | null, // +++ Add field for selected key ID +++
|
||||||
|
proxy_id: null as number | null,
|
||||||
|
tag_ids: [] as number[], // 新增 tag_ids 字段
|
||||||
|
notes: '', // 新增备注字段
|
||||||
|
vncPassword: '', // VNC specific password
|
||||||
|
};
|
||||||
|
const formData = reactive({ ...initialFormData });
|
||||||
|
|
||||||
|
const formError = ref<string | null>(null); // 表单级别的错误信息
|
||||||
|
// 合并所有 store 的加载和错误状态
|
||||||
|
const isLoading = computed(() => isConnLoading.value || isProxyLoading.value || isTagLoading.value || isSshKeyLoading.value); // +++ Include SSH Key loading +++
|
||||||
|
const storeError = computed(() => connStoreError.value || proxyStoreError.value || tagStoreError.value || sshKeyStoreError.value); // +++ Include SSH Key error +++
|
||||||
|
|
||||||
|
// 测试连接状态
|
||||||
|
const testStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle');
|
||||||
|
const testResult = ref<string | number | null>(null); // 存储延迟或错误信息
|
||||||
|
const testLatency = ref<number | null>(null); // 单独存储延迟用于颜色计算
|
||||||
|
|
||||||
|
// Script Mode State
|
||||||
|
const isScriptModeActive = ref(false);
|
||||||
|
const scriptInputText = ref('');
|
||||||
|
|
||||||
|
// 计算属性判断是否为编辑模式
|
||||||
|
const isEditMode = computed(() => !!connectionToEdit.value);
|
||||||
|
|
||||||
|
// When switching to edit mode, disable script mode
|
||||||
|
watch(isEditMode, (editing) => {
|
||||||
|
if (editing) {
|
||||||
|
isScriptModeActive.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性动态设置表单标题
|
||||||
|
const formTitle = computed(() => {
|
||||||
|
return isEditMode.value ? t('connections.form.titleEdit') : t('connections.form.title');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性动态设置提交按钮文本
|
||||||
|
const submitButtonText = computed(() => {
|
||||||
|
if (isLoading.value) {
|
||||||
|
return isEditMode.value ? t('connections.form.saving') : t('connections.form.adding');
|
||||||
|
}
|
||||||
|
return isEditMode.value ? t('connections.form.confirmEdit') : t('connections.form.confirm');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 prop 变化以填充或重置表单
|
||||||
|
watch(connectionToEdit, (newVal) => {
|
||||||
|
formError.value = null; // 清除错误
|
||||||
|
if (newVal) {
|
||||||
|
formData.type = newVal.type as 'SSH' | 'RDP' | 'VNC';
|
||||||
|
formData.name = newVal.name;
|
||||||
|
formData.host = newVal.host;
|
||||||
|
formData.port = newVal.port;
|
||||||
|
formData.username = newVal.username;
|
||||||
|
formData.auth_method = newVal.auth_method;
|
||||||
|
formData.proxy_id = newVal.proxy_id ?? null;
|
||||||
|
formData.notes = newVal.notes ?? '';
|
||||||
|
formData.tag_ids = newVal.tag_ids ? [...newVal.tag_ids] : [];
|
||||||
|
|
||||||
|
if (newVal.type === 'SSH' && newVal.auth_method === 'key') {
|
||||||
|
formData.selected_ssh_key_id = newVal.ssh_key_id ?? null;
|
||||||
|
} else {
|
||||||
|
formData.selected_ssh_key_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.password = '';
|
||||||
|
formData.private_key = '';
|
||||||
|
formData.passphrase = '';
|
||||||
|
if (newVal.type !== 'VNC') {
|
||||||
|
formData.vncPassword = '';
|
||||||
|
} else {
|
||||||
|
formData.vncPassword = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Object.assign(formData, initialFormData);
|
||||||
|
formData.tag_ids = [];
|
||||||
|
formData.selected_ssh_key_id = null;
|
||||||
|
formData.notes = '';
|
||||||
|
formData.vncPassword = '';
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 组件挂载时获取代理、标签和 SSH 密钥列表
|
||||||
|
onMounted(() => {
|
||||||
|
proxiesStore.fetchProxies();
|
||||||
|
tagsStore.fetchTags();
|
||||||
|
sshKeysStore.fetchSshKeys();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听连接类型变化,动态调整默认端口
|
||||||
|
watch(() => formData.type, (newType) => {
|
||||||
|
if (newType === 'RDP') {
|
||||||
|
if (formData.port === 22 || formData.port === 5900 || formData.port === 5901) formData.port = 3389;
|
||||||
|
formData.auth_method = 'password';
|
||||||
|
formData.selected_ssh_key_id = null;
|
||||||
|
} else if (newType === 'SSH') {
|
||||||
|
if (formData.port === 3389 || formData.port === 5900 || formData.port === 5901) formData.port = 22;
|
||||||
|
} else if (newType === 'VNC') {
|
||||||
|
if (formData.port === 22 || formData.port === 3389) formData.port = 5900;
|
||||||
|
formData.auth_method = 'password';
|
||||||
|
formData.selected_ssh_key_id = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to parse IP range
|
||||||
|
const parseIpRange = (ipRangeStr: string): string[] | { error: string } => {
|
||||||
|
if (!ipRangeStr.includes('~')) {
|
||||||
|
return { error: 'not_a_range' };
|
||||||
|
}
|
||||||
|
const parts = ipRangeStr.split('~');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return { error: t('connections.form.errorInvalidIpRangeFormat', 'IP 范围格式应为 start_ip~end_ip') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startIpStr, endIpStr] = parts.map(p => p.trim());
|
||||||
|
|
||||||
|
const ipRegex = /^((\d{1,3}\.){3})\d{1,3}$/;
|
||||||
|
if (!ipRegex.test(startIpStr) || !ipRegex.test(endIpStr)) {
|
||||||
|
return { error: t('connections.form.errorInvalidIpFormat', '起始或结束 IP 地址格式无效') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIpParts = startIpStr.split('.');
|
||||||
|
const endIpParts = endIpStr.split('.');
|
||||||
|
|
||||||
|
if (startIpParts.slice(0, 3).join('.') !== endIpParts.slice(0, 3).join('.')) {
|
||||||
|
return { error: t('connections.form.errorIpRangeNotSameSubnet', 'IP 范围必须在同一个C段子网中 (例如 1.2.3.x ~ 1.2.3.y)') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSuffix = parseInt(startIpParts[3], 10);
|
||||||
|
const endSuffix = parseInt(endIpParts[3], 10);
|
||||||
|
|
||||||
|
if (isNaN(startSuffix) || isNaN(endSuffix) || startSuffix < 0 || startSuffix > 255 || endSuffix < 0 || endSuffix > 255) {
|
||||||
|
return { error: t('connections.form.errorInvalidIpSuffix', 'IP 地址最后一段必须是 0-255 之间的数字') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startSuffix > endSuffix) {
|
||||||
|
return { error: t('connections.form.errorIpRangeStartAfterEnd', 'IP 范围的起始 IP 不能大于结束 IP') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numIps = endSuffix - startSuffix + 1;
|
||||||
|
if (numIps <= 0) {
|
||||||
|
return { error: t('connections.form.errorIpRangeEmpty', 'IP 范围不能为空。') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseIp = startIpParts.slice(0, 3).join('.');
|
||||||
|
const ips: string[] = [];
|
||||||
|
for (let i = startSuffix; i <= endSuffix; i++) {
|
||||||
|
ips.push(`${baseIp}.${i}`);
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to parse a single script line
|
||||||
|
const parseScriptLine = (line: string): { type: 'SSH' | 'RDP' | 'VNC' | null, userHostPort: string, name: string | null, password: string | null, keyName: string | null, tags: string[], note: string | null, error?: string } => {
|
||||||
|
const firstSpaceIndex = line.indexOf(' ');
|
||||||
|
let userHostPortPart = '';
|
||||||
|
let remainingLine = line;
|
||||||
|
|
||||||
|
if (firstSpaceIndex !== -1) {
|
||||||
|
userHostPortPart = line.substring(0, firstSpaceIndex);
|
||||||
|
remainingLine = line.substring(firstSpaceIndex + 1).trim();
|
||||||
|
} else {
|
||||||
|
userHostPortPart = line;
|
||||||
|
remainingLine = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userHostPortPart) return { type: null, userHostPort: '', name: null, password: null, keyName: null, tags: [], note: null, error: t('connections.form.scriptErrorMissingHost', '缺少 user@host:port 部分') };
|
||||||
|
|
||||||
|
let type: 'SSH' | 'RDP' | 'VNC' | null = 'SSH';
|
||||||
|
let name: string | null = null;
|
||||||
|
let password: string | null = null;
|
||||||
|
let keyName: string | null = null;
|
||||||
|
const tags: string[] = [];
|
||||||
|
let note: string | null = null;
|
||||||
|
let currentArg: string | null = null;
|
||||||
|
let noteParts: string[] = [];
|
||||||
|
|
||||||
|
const argRegex = /(-[^=\s]+)(?:\s+("(?:\\"|[^"])*"|[^-\s][^\s]*))?/g;
|
||||||
|
let match;
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
while ((match = argRegex.exec(remainingLine)) !== null) {
|
||||||
|
const arg = match[1];
|
||||||
|
let value = match[2] || '';
|
||||||
|
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1).replace(/\\"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noteParts.length > 0 && currentArg === '-note') {
|
||||||
|
note = noteParts.join(' ');
|
||||||
|
noteParts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
currentArg = arg;
|
||||||
|
|
||||||
|
if (arg === '-tags') {
|
||||||
|
// Tags handled later
|
||||||
|
} else if (arg === '-note') {
|
||||||
|
// Note handled later
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
switch (arg) {
|
||||||
|
case '-type':
|
||||||
|
const upperType = value.toUpperCase();
|
||||||
|
if (upperType === 'SSH' || upperType === 'RDP' || upperType === 'VNC') {
|
||||||
|
type = upperType as 'SSH' | 'RDP' | 'VNC';
|
||||||
|
} else {
|
||||||
|
return { type: null, userHostPort: userHostPortPart, name, password, keyName, tags, note, error: t('connections.form.scriptErrorInvalidType', { type: value }) };
|
||||||
|
}
|
||||||
|
currentArg = null;
|
||||||
|
break;
|
||||||
|
case '-name':
|
||||||
|
name = value;
|
||||||
|
currentArg = null;
|
||||||
|
break;
|
||||||
|
case '-p':
|
||||||
|
password = value;
|
||||||
|
currentArg = null;
|
||||||
|
break;
|
||||||
|
case '-k':
|
||||||
|
keyName = value;
|
||||||
|
currentArg = null;
|
||||||
|
break;
|
||||||
|
case '-tags':
|
||||||
|
tags.push(value);
|
||||||
|
break;
|
||||||
|
case '-note':
|
||||||
|
noteParts.push(value);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (currentArg === '-note') {
|
||||||
|
noteParts.push(value);
|
||||||
|
} else {
|
||||||
|
return { type, userHostPort: userHostPortPart, name, password, keyName, tags, note, error: t('connections.form.scriptErrorUnknownArg', { arg: currentArg }) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastIndex = argRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingPart = remainingLine.substring(lastIndex).trim();
|
||||||
|
if (remainingPart) {
|
||||||
|
if (currentArg === '-tags') {
|
||||||
|
const tagRegex = /("(?:\\"|[^"])*"|[^\s"]+)/g;
|
||||||
|
let tagMatch;
|
||||||
|
while ((tagMatch = tagRegex.exec(remainingPart)) !== null) {
|
||||||
|
let tag = tagMatch[1];
|
||||||
|
if (tag.startsWith('"') && tag.endsWith('"')) {
|
||||||
|
tag = tag.slice(1, -1).replace(/\\"/g, '"');
|
||||||
|
}
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
} else if (currentArg === '-note') {
|
||||||
|
noteParts.push(remainingPart);
|
||||||
|
} else {
|
||||||
|
return { type, userHostPort: userHostPortPart, name, password, keyName, tags, note, error: t('connections.form.scriptErrorUnexpectedToken', { token: remainingPart }) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noteParts.length > 0 && currentArg === '-note') {
|
||||||
|
note = noteParts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userHostPortRegex = /^[^@]+@[^:]+(:[0-9]+)?$/;
|
||||||
|
if (!userHostPortRegex.test(userHostPortPart)) {
|
||||||
|
return { type, userHostPort: userHostPortPart, name, password, keyName, tags, note, error: t('connections.form.scriptErrorInvalidUserHostPort', { part: userHostPortPart })};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type, userHostPort: userHostPortPart, name, password, keyName, tags, note };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理表单提交
|
||||||
|
const handleScriptModeSubmit = async () => {
|
||||||
|
const lines = scriptInputText.value.split('\n').filter(line => line.trim() !== '');
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptModeEmpty', '脚本输入不能为空。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allConnectionsValid = true;
|
||||||
|
const connectionsToAdd = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const parsed = parseScriptLine(line);
|
||||||
|
if (parsed.error) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorInLine', { line, error: parsed.error }));
|
||||||
|
allConnectionsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.type) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorMissingType', { line }));
|
||||||
|
allConnectionsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userHost, portStr] = parsed.userHostPort.split(':');
|
||||||
|
const [username, host] = userHost.split('@');
|
||||||
|
const port = portStr ? parseInt(portStr, 10) : (parsed.type === 'RDP' ? 3389 : (parsed.type === 'VNC' ? 5900 : 22));
|
||||||
|
|
||||||
|
if (!username || !host) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorInvalidUserHostFormat', { line }));
|
||||||
|
allConnectionsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (isNaN(port) || port <= 0 || port > 65535) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorInvalidPort', { line, port: portStr || (parsed.type === 'RDP' ? '3389' : (parsed.type === 'VNC' ? '5900' : '22')) }));
|
||||||
|
allConnectionsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionData: any = {
|
||||||
|
type: parsed.type,
|
||||||
|
name: parsed.name || `${username}@${host}`,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
notes: parsed.note || '',
|
||||||
|
tag_names: parsed.tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parsed.type === 'SSH') {
|
||||||
|
connectionData.auth_method = parsed.keyName ? 'key' : 'password';
|
||||||
|
if (connectionData.auth_method === 'password') {
|
||||||
|
if (!parsed.password) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorMissingPasswordForSsh', { line }));
|
||||||
|
allConnectionsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
connectionData.password = parsed.password;
|
||||||
|
} else {
|
||||||
|
if (!parsed.keyName) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorMissingKeyNameForSsh', { line }));
|
||||||
|
allConnectionsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
connectionData.ssh_key_name = parsed.keyName;
|
||||||
|
}
|
||||||
|
} else if (parsed.type === 'RDP' || parsed.type === 'VNC') {
|
||||||
|
if (!parsed.password) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorMissingPasswordForType', { line, type: parsed.type }));
|
||||||
|
allConnectionsValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
connectionData.password = parsed.password;
|
||||||
|
}
|
||||||
|
connectionsToAdd.push(connectionData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allConnectionsValid || connectionsToAdd.length === 0) {
|
||||||
|
if (connectionsToAdd.length > 0 && !allConnectionsValid) {
|
||||||
|
// Errors were already shown
|
||||||
|
} else if (lines.length > 0 && connectionsToAdd.length === 0 && allConnectionsValid) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorInternal', '内部解析错误。'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullyProcessedConnections = [];
|
||||||
|
let resolutionErrorOccurred = false;
|
||||||
|
|
||||||
|
for (const connData of connectionsToAdd) {
|
||||||
|
if (connData.tag_names && connData.tag_names.length > 0) {
|
||||||
|
const tagIds = [];
|
||||||
|
for (const tagName of connData.tag_names) {
|
||||||
|
const foundTag = tags.value.find(t_ => t_.name === tagName); // Renamed t to t_ to avoid conflict
|
||||||
|
if (foundTag) {
|
||||||
|
tagIds.push(foundTag.id);
|
||||||
|
} else {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorTagNotFound', { tagName }));
|
||||||
|
resolutionErrorOccurred = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolutionErrorOccurred) break;
|
||||||
|
connData.tag_ids = tagIds;
|
||||||
|
} else {
|
||||||
|
connData.tag_ids = [];
|
||||||
|
}
|
||||||
|
delete connData.tag_names;
|
||||||
|
|
||||||
|
if (connData.type === 'SSH' && connData.auth_method === 'key' && connData.ssh_key_name) {
|
||||||
|
const foundKey = sshKeys.value.find(k => k.name === connData.ssh_key_name);
|
||||||
|
if (foundKey) {
|
||||||
|
connData.ssh_key_id = foundKey.id;
|
||||||
|
} else {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorSshKeyNotFound', { keyName: connData.ssh_key_name }));
|
||||||
|
resolutionErrorOccurred = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
delete connData.ssh_key_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connData.type !== 'SSH' || connData.auth_method !== 'key') delete connData.ssh_key_id;
|
||||||
|
if (connData.type === 'SSH' && connData.auth_method === 'key') delete connData.password;
|
||||||
|
if (connData.type !== 'SSH') delete connData.auth_method;
|
||||||
|
|
||||||
|
fullyProcessedConnections.push(connData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolutionErrorOccurred || (fullyProcessedConnections.length === 0 && lines.length > 0)) {
|
||||||
|
if (!resolutionErrorOccurred && lines.length > 0 && fullyProcessedConnections.length === 0) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.scriptErrorNothingToProcess', '没有可处理的有效连接数据。'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullyProcessedConnections.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uiNotificationsStore.showInfo(t('connections.form.scriptModeAddingConnections', { count: fullyProcessedConnections.length }));
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
let firstErrorEncountered: string | null = null;
|
||||||
|
|
||||||
|
for (const finalConnectionData of fullyProcessedConnections) {
|
||||||
|
const success = await connectionsStore.addConnection(finalConnectionData);
|
||||||
|
if (success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
if (!firstErrorEncountered) {
|
||||||
|
firstErrorEncountered = connectionsStore.error || t('errors.unknown', '未知错误');
|
||||||
|
}
|
||||||
|
console.error(`Failed to add connection: ${finalConnectionData.name}`, connectionsStore.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCount > 0) {
|
||||||
|
const message = t('connections.form.errorBatchAddResult', { successCount, errorCount, firstErrorEncountered: firstErrorEncountered || t('errors.unknown', '未知错误') });
|
||||||
|
if (successCount > 0) {
|
||||||
|
uiNotificationsStore.showWarning(message);
|
||||||
|
} else {
|
||||||
|
uiNotificationsStore.showError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
if (errorCount === 0) {
|
||||||
|
uiNotificationsStore.showSuccess(t('connections.form.successBatchAddResult', { successCount }));
|
||||||
|
}
|
||||||
|
emit('connection-added');
|
||||||
|
if (errorCount === 0) {
|
||||||
|
scriptInputText.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (isScriptModeActive.value) {
|
||||||
|
await handleScriptModeSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formError.value = null;
|
||||||
|
connectionsStore.error = null;
|
||||||
|
proxiesStore.error = null;
|
||||||
|
|
||||||
|
const availableTagIds = tags.value.map(t_ => t_.id);
|
||||||
|
const currentSelectedValidTagIds = formData.tag_ids.filter(id => availableTagIds.includes(id));
|
||||||
|
|
||||||
|
if (!formData.host || !formData.username) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorRequiredFields'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.port <= 0 || formData.port > 65535) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorPort'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.type === 'SSH') {
|
||||||
|
if (!isEditMode.value) {
|
||||||
|
if (formData.auth_method === 'password' && !formData.password && !formData.host.includes('~')) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorPasswordRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.auth_method === 'key' && !formData.selected_ssh_key_id && !formData.host.includes('~')) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorSshKeyRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (formData.auth_method === 'password' && !formData.password && connectionToEdit.value?.auth_method !== 'password') {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredOnSwitch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.auth_method === 'key' && !formData.selected_ssh_key_id && connectionToEdit.value?.auth_method !== 'key') {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorSshKeyRequiredOnSwitch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (formData.type === 'RDP') {
|
||||||
|
if (!isEditMode.value && !formData.password && !formData.host.includes('~')) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorPasswordRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (formData.type === 'VNC') {
|
||||||
|
if (!isEditMode.value && !formData.vncPassword && !formData.host.includes('~')) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditMode.value && formData.host.includes('~')) {
|
||||||
|
const parsedIpsResult = parseIpRange(formData.host);
|
||||||
|
|
||||||
|
if (Array.isArray(parsedIpsResult)) {
|
||||||
|
const ips = parsedIpsResult;
|
||||||
|
if (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) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchSSH', '批量添加 SSH (密码认证) 连接时,必须提供密码。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.type === 'RDP' && !formData.password) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchRDP', '批量添加 RDP 连接时,必须提供密码。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.type === 'VNC' && !formData.vncPassword) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchVNC', '批量添加 VNC 连接时,必须提供 VNC 密码。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
let firstErrorEncountered: string | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < ips.length; i++) {
|
||||||
|
const currentIp = ips[i];
|
||||||
|
const ipSuffix = currentIp.split('.').pop() || `${i + 1}`;
|
||||||
|
|
||||||
|
const dataForThisIp: any = {
|
||||||
|
type: formData.type,
|
||||||
|
name: formData.name ? `${formData.name}-${ipSuffix}` : currentIp,
|
||||||
|
host: currentIp,
|
||||||
|
port: formData.port,
|
||||||
|
username: formData.username,
|
||||||
|
notes: formData.notes,
|
||||||
|
proxy_id: formData.proxy_id || null,
|
||||||
|
tag_ids: currentSelectedValidTagIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (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') {
|
||||||
|
dataForThisIp.password = formData.password;
|
||||||
|
delete dataForThisIp.auth_method;
|
||||||
|
} else if (formData.type === 'VNC') {
|
||||||
|
dataForThisIp.password = formData.vncPassword;
|
||||||
|
delete dataForThisIp.auth_method;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataForThisIp.type !== 'SSH' || dataForThisIp.auth_method !== 'key') delete dataForThisIp.ssh_key_id;
|
||||||
|
if (dataForThisIp.type === 'SSH' && dataForThisIp.auth_method === 'key') delete dataForThisIp.password;
|
||||||
|
if (dataForThisIp.type !== 'SSH') delete dataForThisIp.auth_method;
|
||||||
|
|
||||||
|
const success = await connectionsStore.addConnection(dataForThisIp);
|
||||||
|
if (success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
if (!firstErrorEncountered) {
|
||||||
|
firstErrorEncountered = connectionsStore.error || t('errors.unknown', '未知错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCount > 0) {
|
||||||
|
const message = t('connections.form.errorBatchAddResult', { successCount, errorCount, firstErrorEncountered: firstErrorEncountered || t('errors.unknown', '未知错误') });
|
||||||
|
if (successCount > 0) {
|
||||||
|
uiNotificationsStore.showWarning(message);
|
||||||
|
} else {
|
||||||
|
uiNotificationsStore.showError(message);
|
||||||
|
}
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
uiNotificationsStore.showSuccess(t('connections.form.successBatchAddResult', { successCount }));
|
||||||
|
emit('connection-added');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (parsedIpsResult.error && parsedIpsResult.error !== 'not_a_range') {
|
||||||
|
uiNotificationsStore.showError(parsedIpsResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditMode.value && formData.host.includes('~')) {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorIpRangeNotAllowedInEditMode', '编辑模式下不支持 IP 范围。请使用单个 IP 地址。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToSend: any = {
|
||||||
|
type: formData.type,
|
||||||
|
name: formData.name,
|
||||||
|
host: formData.host,
|
||||||
|
port: formData.port,
|
||||||
|
notes: formData.notes,
|
||||||
|
username: formData.username,
|
||||||
|
proxy_id: formData.proxy_id || null,
|
||||||
|
tag_ids: currentSelectedValidTagIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.type === 'SSH') {
|
||||||
|
dataToSend.auth_method = formData.auth_method;
|
||||||
|
if (formData.auth_method === 'password') {
|
||||||
|
if (formData.password) dataToSend.password = formData.password;
|
||||||
|
} else if (formData.auth_method === 'key') {
|
||||||
|
if (formData.selected_ssh_key_id) {
|
||||||
|
dataToSend.ssh_key_id = formData.selected_ssh_key_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (formData.type === 'RDP') {
|
||||||
|
if (formData.password) dataToSend.password = formData.password;
|
||||||
|
delete dataToSend.auth_method;
|
||||||
|
} else if (formData.type === 'VNC') {
|
||||||
|
if (formData.vncPassword) dataToSend.password = formData.vncPassword;
|
||||||
|
delete dataToSend.auth_method;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataToSend.type !== 'SSH' || dataToSend.auth_method !== 'key') delete dataToSend.ssh_key_id;
|
||||||
|
if (dataToSend.type === 'SSH' && dataToSend.auth_method === 'key') delete dataToSend.password;
|
||||||
|
if (dataToSend.type !== 'SSH') delete dataToSend.auth_method;
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
if (isEditMode.value && connectionToEdit.value) {
|
||||||
|
success = await connectionsStore.updateConnection(connectionToEdit.value.id, dataToSend);
|
||||||
|
if (success) {
|
||||||
|
emit('connection-updated');
|
||||||
|
} else {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorUpdate', { error: connectionsStore.error || '未知错误' }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
success = await connectionsStore.addConnection(dataToSend);
|
||||||
|
if (success) {
|
||||||
|
emit('connection-added');
|
||||||
|
} else {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorAdd', { error: connectionsStore.error || '未知错误' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理删除连接
|
||||||
|
const handleDeleteConnection = async () => {
|
||||||
|
if (!isEditMode.value || !connectionToEdit.value) return;
|
||||||
|
|
||||||
|
const connectionName = connectionToEdit.value.name || `ID: ${connectionToEdit.value.id}`;
|
||||||
|
if (!confirm(t('connections.prompts.confirmDelete', { name: connectionName }))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formError.value = null;
|
||||||
|
connectionsStore.error = null;
|
||||||
|
|
||||||
|
const success = await connectionsStore.deleteConnection(connectionToEdit.value.id);
|
||||||
|
if (success) {
|
||||||
|
emit('connection-deleted');
|
||||||
|
emit('close');
|
||||||
|
} else {
|
||||||
|
uiNotificationsStore.showError(t('connections.form.errorDelete', { error: connectionsStore.error || t('errors.unknown', '未知错误') }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tag Creation/Deletion Handling ---
|
||||||
|
const handleCreateTag = async (tagName: string) => {
|
||||||
|
if (!tagName || tagName.trim().length === 0) return;
|
||||||
|
const newTag = await tagsStore.addTag(tagName.trim());
|
||||||
|
if (newTag && !formData.tag_ids.includes(newTag.id)) {
|
||||||
|
formData.tag_ids.push(newTag.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTag = async (tagId: number) => {
|
||||||
|
const tagToDelete = tags.value.find(t_ => t_.id === tagId);
|
||||||
|
if (!tagToDelete) return;
|
||||||
|
|
||||||
|
if (confirm(t('tags.prompts.confirmDelete', { name: tagToDelete.name }))) {
|
||||||
|
const success = await tagsStore.deleteTag(tagId);
|
||||||
|
if (!success) {
|
||||||
|
alert(t('tags.errorDelete', { error: tagsStore.error || '未知错误' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理测试连接
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
testStatus.value = 'testing';
|
||||||
|
testResult.value = null;
|
||||||
|
testLatency.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (isEditMode.value && connectionToEdit.value) {
|
||||||
|
response = await apiClient.post(`/connections/${connectionToEdit.value.id}/test`);
|
||||||
|
} else {
|
||||||
|
const dataToSend = {
|
||||||
|
host: formData.host,
|
||||||
|
port: formData.port,
|
||||||
|
username: formData.username,
|
||||||
|
auth_method: formData.auth_method,
|
||||||
|
password: formData.auth_method === 'password' ? formData.password : undefined,
|
||||||
|
proxy_id: formData.proxy_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) {
|
||||||
|
throw new Error(t('connections.test.errorMissingFields'));
|
||||||
|
}
|
||||||
|
if (dataToSend.auth_method === 'password' && !formData.password) {
|
||||||
|
throw new Error(t('connections.form.errorPasswordRequired'));
|
||||||
|
}
|
||||||
|
if (dataToSend.auth_method === 'key' && !dataToSend.ssh_key_id) {
|
||||||
|
throw new Error(t('connections.form.errorSshKeyRequired'));
|
||||||
|
}
|
||||||
|
response = await apiClient.post('/connections/test-unsaved', dataToSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
testStatus.value = 'success';
|
||||||
|
testLatency.value = response.data.latency;
|
||||||
|
testResult.value = `${response.data.latency} ms`;
|
||||||
|
} else {
|
||||||
|
testStatus.value = 'error';
|
||||||
|
const errorMessage = response.data.message || t('connections.test.errorUnknown');
|
||||||
|
testResult.value = errorMessage;
|
||||||
|
uiNotificationsStore.showError(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('测试连接失败:', error);
|
||||||
|
testStatus.value = 'error';
|
||||||
|
let errorMessageToShow: string;
|
||||||
|
if (error.response && error.response.data && error.response.data.message) {
|
||||||
|
errorMessageToShow = error.response.data.message;
|
||||||
|
} else {
|
||||||
|
errorMessageToShow = error.message || t('connections.test.errorNetwork');
|
||||||
|
}
|
||||||
|
testResult.value = errorMessageToShow;
|
||||||
|
uiNotificationsStore.showError(errorMessageToShow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算延迟颜色
|
||||||
|
const latencyColor = computed(() => {
|
||||||
|
if (testStatus.value !== 'success' || testLatency.value === null) {
|
||||||
|
return 'inherit';
|
||||||
|
}
|
||||||
|
const latency = testLatency.value;
|
||||||
|
if (latency < 100) return 'var(--color-success, #28a745)';
|
||||||
|
if (latency < 500) return 'var(--color-warning, #ffc107)';
|
||||||
|
return 'var(--color-danger, #dc3545)';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算测试按钮文本
|
||||||
|
const testButtonText = computed(() => {
|
||||||
|
if (testStatus.value === 'testing') {
|
||||||
|
return t('connections.form.testing');
|
||||||
|
}
|
||||||
|
return t('connections.form.testConnection');
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
formData,
|
||||||
|
isLoading,
|
||||||
|
testStatus,
|
||||||
|
testResult,
|
||||||
|
testLatency,
|
||||||
|
isScriptModeActive,
|
||||||
|
scriptInputText,
|
||||||
|
isEditMode,
|
||||||
|
formTitle,
|
||||||
|
submitButtonText,
|
||||||
|
proxies, // for <select>
|
||||||
|
tags, // for <TagInput :available-tags="tags">
|
||||||
|
isProxyLoading,
|
||||||
|
proxyStoreError,
|
||||||
|
isTagLoading,
|
||||||
|
tagStoreError,
|
||||||
|
handleSubmit,
|
||||||
|
handleDeleteConnection,
|
||||||
|
handleTestConnection,
|
||||||
|
handleCreateTag,
|
||||||
|
handleDeleteTag,
|
||||||
|
latencyColor,
|
||||||
|
testButtonText,
|
||||||
|
// Expose stores if child components or template parts need direct access, though usually not.
|
||||||
|
// uiNotificationsStore, // Used internally, not needed to be returned
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user