feat: 添加“脚本模式”

Refs #28
This commit is contained in:
Baobhan Sith
2025-05-11 17:34:26 +08:00
parent 2fae89a0ac
commit 4c634c6fde
4 changed files with 473 additions and 17 deletions
@@ -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>