@@ -66,9 +66,20 @@ const hostTooltipStyle = ref({});
|
||||
const hostIconRef = ref<HTMLElement | null>(null);
|
||||
const hostTooltipContentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Script Mode State
|
||||
const isScriptModeActive = ref(false);
|
||||
const scriptInputText = ref('');
|
||||
|
||||
// 计算属性判断是否为编辑模式
|
||||
const isEditMode = computed(() => !!props.connectionToEdit);
|
||||
|
||||
// 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');
|
||||
@@ -204,8 +215,338 @@ const parseIpRange = (ipRangeStr: string): string[] | { error: string } => {
|
||||
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 } => {
|
||||
// 首先提取 user@host:port 部分
|
||||
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'; // Default to 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') {
|
||||
// 标签将在后续处理
|
||||
} else if (arg === '-note') {
|
||||
// 备注将在后续处理
|
||||
}
|
||||
|
||||
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(' ');
|
||||
}
|
||||
|
||||
// Basic validation for userHostPort
|
||||
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; // Stop on first error for now, or collect all errors
|
||||
}
|
||||
|
||||
if (!parsed.type) { // Should be caught by parsed.error, but as a safeguard
|
||||
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_ids will be resolved later if tags are provided
|
||||
tag_names: parsed.tags, // Store tag names for now, resolve to IDs before API call
|
||||
};
|
||||
|
||||
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 { // key auth
|
||||
if (!parsed.keyName) { // Should not happen if auth_method is 'key'
|
||||
uiNotificationsStore.showError(t('connections.form.scriptErrorMissingKeyNameForSsh', { line }));
|
||||
allConnectionsValid = false;
|
||||
break;
|
||||
}
|
||||
// We'll need to find ssh_key_id from keyName later
|
||||
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) {
|
||||
// This case should ideally not be reached if parsing is correct
|
||||
uiNotificationsStore.showError(t('connections.form.scriptErrorInternal', '内部解析错误。'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Parsed connections to add (pre-resolution):', connectionsToAdd);
|
||||
|
||||
const fullyProcessedConnections = [];
|
||||
let resolutionErrorOccurred = false;
|
||||
|
||||
// Resolve tag names and SSH key names to IDs
|
||||
for (const connData of connectionsToAdd) {
|
||||
// Resolve Tag IDs
|
||||
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);
|
||||
if (foundTag) {
|
||||
tagIds.push(foundTag.id);
|
||||
} else {
|
||||
// Option: Create tag if not found, or show error. For now, error.
|
||||
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; // Remove temporary field
|
||||
|
||||
// Resolve SSH Key ID
|
||||
if (connData.type === 'SSH' && connData.auth_method === 'key' && connData.ssh_key_name) {
|
||||
const foundKey = sshKeysStore.sshKeys.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; // Remove temporary field
|
||||
}
|
||||
|
||||
// Clean up unnecessary fields based on type and auth_method, similar to single add
|
||||
if (connData.type !== 'SSH' || connData.auth_method !== 'key') delete connData.ssh_key_id;
|
||||
if (connData.type === 'SSH' && connData.auth_method === 'key') delete connData.password; // No password if key auth
|
||||
if (connData.type !== 'SSH') delete connData.auth_method; // RDP/VNC don't have auth_method in backend
|
||||
|
||||
fullyProcessedConnections.push(connData);
|
||||
}
|
||||
|
||||
if (resolutionErrorOccurred || (fullyProcessedConnections.length === 0 && lines.length > 0)) {
|
||||
// Errors shown by resolver, or if no connections were processed but there were lines
|
||||
if (!resolutionErrorOccurred && lines.length > 0 && fullyProcessedConnections.length === 0) {
|
||||
uiNotificationsStore.showError(t('connections.form.scriptErrorNothingToProcess', '没有可处理的有效连接数据。'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fullyProcessedConnections.length === 0) { // Should be caught by earlier checks
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Fully processed connections:', fullyProcessedConnections);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let firstErrorEncountered: string | null = null;
|
||||
|
||||
uiNotificationsStore.showInfo(t('connections.form.scriptModeAddingConnections', { count: fullyProcessedConnections.length }));
|
||||
|
||||
for (const finalConnectionData of fullyProcessedConnections) {
|
||||
const success = await connectionsStore.addConnection(finalConnectionData);
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorCount++;
|
||||
if (!firstErrorEncountered) {
|
||||
firstErrorEncountered = connectionsStore.error || t('errors.unknown', '未知错误');
|
||||
}
|
||||
// Optionally, log which connection failed
|
||||
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) { // All successful
|
||||
uiNotificationsStore.showSuccess(t('connections.form.successBatchAddResult', { successCount }));
|
||||
}
|
||||
emit('connection-added'); // Emit even if there were partial successes
|
||||
if (errorCount === 0) { // Clear input only if all were successful
|
||||
scriptInputText.value = '';
|
||||
// emit('close'); // Optionally close form on full success
|
||||
}
|
||||
}
|
||||
|
||||
// If successCount is 0 and errorCount is 0 but there were lines, it means something went wrong before this loop.
|
||||
// That case should be handled by the `fullyProcessedConnections.length === 0` check earlier.
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isScriptModeActive.value) {
|
||||
await handleScriptModeSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
formError.value = null;
|
||||
connectionsStore.error = null;
|
||||
proxiesStore.error = null; // 同时清除代理 store 的错误
|
||||
@@ -616,10 +957,12 @@ const handleHostIconMouseLeave = () => {
|
||||
<h3 class="text-xl font-semibold text-center mb-6 flex-shrink-0">{{ formTitle }}</h3> <!-- Title -->
|
||||
<form @submit.prevent="handleSubmit" class="flex-grow overflow-y-auto pr-2 space-y-6"> <!-- Form with scroll and spacing -->
|
||||
|
||||
<!-- 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>
|
||||
<!-- Regular Form Sections (conditionally rendered) -->
|
||||
<template v-if="!isScriptModeActive">
|
||||
<!-- 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="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" />
|
||||
@@ -656,7 +999,7 @@ const handleHostIconMouseLeave = () => {
|
||||
<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-info-circle text-text-secondary cursor-help"></i>
|
||||
<i ref="hostIconRef" class="fas fa-exclamation-circle text-text-secondary cursor-help"></i>
|
||||
<!-- Tooltip is now handled by Teleport -->
|
||||
</span>
|
||||
</label>
|
||||
@@ -789,15 +1132,55 @@ const handleHostIconMouseLeave = () => {
|
||||
:placeholder="t('connections.form.notesPlaceholder', '输入连接备注...')"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message DIV removed -->
|
||||
|
||||
</template> <!-- End of v-if="!isScriptModeActive" -->
|
||||
|
||||
<!-- Script Mode Section Toggle -->
|
||||
<div v-if="!isEditMode" class="space-y-4 p-4 border border-border rounded-md bg-header/30 mt-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-base font-semibold">{{ t('connections.form.sectionScriptMode', '脚本模式') }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
@click="isScriptModeActive = !isScriptModeActive"
|
||||
:class="[
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary',
|
||||
isScriptModeActive ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'
|
||||
]"
|
||||
role="switch"
|
||||
:aria-checked="isScriptModeActive"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
isScriptModeActive ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isScriptModeActive" class="mt-4">
|
||||
<label for="conn-script-input" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.scriptModeInputLabel', '连接脚本 (每行一个)') }}</label>
|
||||
<textarea
|
||||
id="conn-script-input"
|
||||
v-model="scriptInputText"
|
||||
rows="10"
|
||||
wrap="off"
|
||||
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.scriptModePlaceholder')"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-text-secondary">
|
||||
{{ t('connections.form.scriptModeFormatInfo', '格式: user@host:port [-type TYPE] [-name NAME] [-p PASSWORD] [-k KEY_NAME] [-tags TAG1 TAG2...] [-note NOTE_TEXT]') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message DIV removed -->
|
||||
|
||||
</form> <!-- End Form -->
|
||||
|
||||
<!-- Form Actions -->
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-between items-center pt-5 mt-6 flex-shrink-0">
|
||||
<!-- Test Area (Only show for SSH) -->
|
||||
<div v-if="formData.type === 'SSH'" class="flex flex-col items-start gap-1">
|
||||
<!-- Test Area (Only show for SSH and when script mode is NOT active) -->
|
||||
<div v-if="formData.type === 'SSH' && !isScriptModeActive" class="flex flex-col items-start gap-1">
|
||||
<div class="flex items-center gap-2"> <!-- Button and Icon -->
|
||||
<button type="button" @click="handleTestConnection" :disabled="isLoading || testStatus === 'testing'"
|
||||
class="px-3 py-1.5 border border-border rounded-md text-sm font-medium text-text-secondary bg-background hover:bg-border focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center transition-colors duration-150">
|
||||
@@ -829,10 +1212,11 @@ const handleHostIconMouseLeave = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Placeholder for alignment when test button is hidden -->
|
||||
<div v-else class="flex-1"></div> <!-- This div ensures the main action buttons are pushed to the right when test area is hidden -->
|
||||
<!-- Placeholder for alignment when test button is hidden or script mode is active -->
|
||||
<div v-else-if="!isScriptModeActive" class="flex-1"></div>
|
||||
<div v-else class="flex-1"></div> <!-- Also take up space if script mode is active, pushing buttons right -->
|
||||
<div class="flex space-x-3"> <!-- Main Actions -->
|
||||
<button v-if="isEditMode" type="button" @click="handleDeleteConnection" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
|
||||
<button v-if="isEditMode && !isScriptModeActive" type="button" @click="handleDeleteConnection" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
|
||||
class="px-4 py-2 bg-transparent text-red-600 border border-red-500 rounded-md shadow-sm hover:bg-red-500/10 hover:text-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
|
||||
{{ t('connections.actions.delete') }}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user