feat: 适配移动端

只保留最基本的ssh功能
Related to #10
This commit is contained in:
Baobhan Sith
2025-05-04 00:21:13 +08:00
parent 9a93bb8aa6
commit f81e647497
8 changed files with 544 additions and 93 deletions
@@ -7,6 +7,7 @@ import { useFocusSwitcherStore } from '../stores/focusSwitcher.store';
import { useSettingsStore } from '../stores/settings.store';
import { useQuickCommandsStore } from '../stores/quickCommands.store';
import { useCommandHistoryStore } from '../stores/commandHistory.store';
import QuickCommandsModal from './QuickCommandsModal.vue'; // +++ Import the modal component +++
const emit = defineEmits(['send-command', 'search', 'find-next', 'find-previous', 'close-search', 'clear-terminal']); // 添加 clear-terminal 事件
const { t } = useI18n();
@@ -31,11 +32,14 @@ const { updateSessionCommandInput } = sessionStore;
// Props definition is now empty as search results are no longer handled here
const props = defineProps<{
// No props defined here currently
// +++ Add isMobile prop +++
isMobile?: boolean;
}>();
// --- 移除本地 commandInput ref ---
// const commandInput = ref('');
const isSearching = ref(false);
const searchTerm = ref('');
const showQuickCommands = ref(false); // +++ Add state for modal visibility +++
// *** 移除本地的搜索结果 ref ***
// const searchResultCount = ref(0);
// const currentSearchResultIndex = ref(0);
@@ -244,11 +248,27 @@ onBeforeUnmount(() => {
unregisterTerminalSearchFocus();
}
});
// +++ Functions to control the quick commands modal +++
const openQuickCommandsModal = () => {
showQuickCommands.value = true;
};
const closeQuickCommandsModal = () => {
showQuickCommands.value = false;
};
// +++ Handler for command execution from the modal +++
const handleQuickCommandExecute = (command: string) => {
console.log(`[CommandInputBar] Executing quick command: ${command}`);
emit('send-command', command); // Emit the command to the parent
closeQuickCommandsModal(); // Close the modal after selection
};
</script>
<template>
<div class="flex items-center px-2 py-1.5 bg-background gap-2"> <!-- Removed border-t and border-border/50 -->
<div class="flex-grow flex items-center bg-transparent relative gap-2"> <!-- Adjusted gap -->
<div class="flex items-center px-2 py-1.5 bg-background gap-2">
<div class="flex-grow flex items-center bg-transparent relative gap-2">
<!-- Clear Terminal Button -->
<button
@click="emit('clear-terminal')"
@@ -257,34 +277,46 @@ onBeforeUnmount(() => {
>
<i class="fas fa-eraser text-base"></i>
</button>
<!-- Focus Switcher Config Button -->
<!-- +++ Quick Commands Button (Mobile only) +++ -->
<button
v-if="props.isMobile"
@click="openQuickCommandsModal"
class="flex-shrink-0 flex items-center justify-center w-8 h-8 border border-border/50 rounded-lg text-text-secondary transition-colors duration-200 hover:bg-border hover:text-foreground"
:title="t('quickCommands.title', '快捷指令')"
>
<i class="fas fa-bolt text-base"></i>
</button>
<!-- Focus Switcher Config Button (Hide on mobile) -->
<button
v-if="!props.isMobile"
@click="focusSwitcherStore.toggleConfigurator(true)"
class="flex-shrink-0 flex items-center justify-center w-8 h-8 border border-border/50 rounded-lg text-text-secondary transition-colors duration-200 hover:bg-border hover:text-foreground"
:title="t('commandInputBar.configureFocusSwitch', '配置焦点切换')"
>
<i class="fas fa-keyboard text-base"></i> <!-- Removed text-primary -->
</button>
<!-- Command Input -->
<!-- Command Input (Hide on mobile when searching) -->
<input
v-if="!props.isMobile || !isSearching"
type="text"
v-model="currentSessionCommandInput"
:placeholder="t('commandInputBar.placeholder')"
class="flex-grow min-w-0 px-4 py-1.5 border border-border/50 rounded-lg bg-input text-foreground text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all duration-300 ease-in-out"
:class="{ 'basis-3/4': isSearching, 'basis-full': !isSearching }"
:class="{ 'basis-3/4': !props.isMobile && isSearching, 'basis-full': !isSearching }"
ref="commandInputRef"
data-focus-id="commandInput"
@keydown="handleCommandInputKeydown"
@blur="handleCommandInputBlur"
/>
<!-- Search Input (Conditional rendering with v-show for transition) -->
<!-- Search Input (Show when searching, adjust width on mobile) -->
<input
v-show="isSearching"
v-if="isSearching"
type="text"
v-model="searchTerm"
:placeholder="t('commandInputBar.searchPlaceholder')"
class="flex-grow min-w-0 px-4 py-1.5 border border-border/50 rounded-lg bg-input text-foreground text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all duration-300 ease-in-out basis-1/4"
class="flex-grow min-w-0 px-4 py-1.5 border border-border/50 rounded-lg bg-input text-foreground text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all duration-300 ease-in-out"
:class="{ 'basis-1/4': !props.isMobile, 'basis-full': props.isMobile }"
data-focus-id="terminalSearch"
@keydown.enter.prevent="findNext"
@keydown.shift.enter.prevent="findPrevious"
@@ -304,7 +336,8 @@ onBeforeUnmount(() => {
<i v-else class="fas fa-times text-base"></i>
</button>
<template v-if="isSearching">
<!-- Search navigation buttons (Hide on mobile when searching) -->
<template v-if="isSearching && !props.isMobile"> <!-- +++ Add !props.isMobile condition +++ -->
<button
@click="findPrevious"
class="flex items-center justify-center w-8 h-8 border border-border/50 rounded-lg text-text-secondary transition-colors duration-200 hover:bg-border hover:text-foreground"
@@ -320,10 +353,17 @@ onBeforeUnmount(() => {
<i class="fas fa-arrow-down text-base"></i>
</button>
</template>
<!-- Note: On mobile, when searching, only the close button (inside toggleSearch button logic) will be effectively visible in this control group -->
</div>
</div>
</div>
<!-- +++ Quick Commands Modal Instance +++ -->
<QuickCommandsModal
:is-visible="showQuickCommands"
@close="closeQuickCommandsModal"
@execute-command="handleQuickCommandExecute"
/>
</template>
<style scoped>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { defineProps, defineEmits, watch } from 'vue';
import QuickCommandsView from '../views/QuickCommandsView.vue'; // 导入视图
const props = defineProps<{
isVisible: boolean;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'execute-command', command: string): void;
}>();
const closeModal = () => {
emit('close');
};
// 处理从 QuickCommandsView 传来的事件
const handleCommandExecute = (command: string) => {
emit('execute-command', command);
closeModal(); // 选择指令后自动关闭
};
// Optional: Add keyboard listener to close on Esc key
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeModal();
}
};
watch(() => props.isVisible, (newValue) => {
if (newValue) {
document.addEventListener('keydown', handleKeydown);
} else {
document.removeEventListener('keydown', handleKeydown);
}
});
// Clean up listener on unmount (though v-if usually handles this)
import { onUnmounted } from 'vue';
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
});
</script>
<template>
<div v-if="isVisible" class="fixed inset-0 bg-overlay flex justify-center items-center z-50 p-4" @click.self="closeModal">
<div class="bg-background text-foreground p-4 rounded-lg shadow-xl border border-border w-full max-w-lg max-h-[85vh] flex flex-col relative">
<!-- Close Button -->
<button class="absolute top-2 right-2 p-1 text-text-secondary hover:text-foreground z-10" @click="closeModal" title="关闭">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Title -->
<h3 class="text-lg font-semibold text-center mb-3 flex-shrink-0">快捷指令</h3>
<!-- Quick Commands View Embedded -->
<div class="flex-grow overflow-hidden border border-border rounded">
<QuickCommandsView @execute-command="handleCommandExecute" />
</div>
</div>
</div>
</template>
<style scoped>
/* Add any specific modal styles if needed */
.bg-overlay {
background-color: rgba(0, 0, 0, 0.6);
}
</style>
@@ -29,6 +29,11 @@ const props = defineProps({
required: false,
default: null,
},
// +++ 添加 isMobile prop +++
isMobile: {
type: Boolean,
default: false,
},
});
// 定义事件 (使用对象语法修复类型)
@@ -259,7 +264,10 @@ const toggleButtonTitle = computed(() => {
</script>
<template>
<div class="flex bg-header border border-border rounded-t-md mx-2 mt-2 overflow-hidden h-10">
<!-- +++ 使用 :class 绑定来条件化样式 +++ -->
<div :class="['flex bg-header border border-border overflow-hidden h-10',
{ 'rounded-t-md mx-2 mt-2': !isMobile } // 只在非移动端应用这些类
]">
<div class="flex items-center overflow-x-auto flex-shrink min-w-0">
<ul class="flex list-none p-0 m-0 h-full flex-shrink-0">
<li
@@ -302,7 +310,8 @@ const toggleButtonTitle = computed(() => {
>
<i :class="[eyeIconClass, 'text-sm']"></i>
</button>
<button class="flex items-center justify-center px-3 h-full border-l border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150"
<!-- +++ 使用 v-if 隐藏移动端的布局按钮 +++ -->
<button v-if="!isMobile" class="flex items-center justify-center px-3 h-full border-l border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150"
@click="openLayoutConfigurator" :title="t('layout.configure', '配置布局')">
<i class="fas fa-th-large text-sm"></i>
</button>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref, defineEmits } from 'vue'; // +++ Import ref +++
const emit = defineEmits<{
(e: 'send-key', keySequence: string): void;
}>();
// +++ Add state for modifier keys +++
const isCtrlActive = ref(false);
const isAltActive = ref(false);
// +++ Function to toggle modifier state +++
const toggleModifier = (modifier: 'ctrl' | 'alt') => {
if (modifier === 'ctrl') {
isCtrlActive.value = !isCtrlActive.value;
isAltActive.value = false; // Ctrl and Alt are mutually exclusive
} else if (modifier === 'alt') {
isAltActive.value = !isAltActive.value;
isCtrlActive.value = false; // Ctrl and Alt are mutually exclusive
}
};
// +++ Modified sendKey function +++
const sendKey = (keyDef: KeyDefinition) => {
// Handle modifier key clicks
if (keyDef.type === 'modifier') {
toggleModifier(keyDef.label.toLowerCase() as 'ctrl' | 'alt');
return; // Just toggle state, don't emit anything
}
// Determine the sequence to send
let sequence = keyDef.sequence ?? keyDef.label; // Default to label if no sequence (e.g., for 'A')
if (isCtrlActive.value) {
// Handle Ctrl combinations (example: convert A-Z to control characters 1-26)
if (keyDef.type === 'char' && keyDef.label.length === 1 && keyDef.label >= 'A' && keyDef.label <= 'Z') {
sequence = String.fromCharCode(keyDef.label.charCodeAt(0) - 'A'.charCodeAt(0) + 1);
} else if (keyDef.label === 'Ctrl+C') { // Keep predefined Ctrl+C
sequence = '\x03';
}
// Add more Ctrl combinations here if needed
console.log(`[VirtualKeyboard] Sending Ctrl + ${keyDef.label} as ${JSON.stringify(sequence)}`);
} else if (isAltActive.value) {
// Handle Alt combinations (typically prefix with ESC)
sequence = '\x1b' + sequence;
console.log(`[VirtualKeyboard] Sending Alt + ${keyDef.label} as ${JSON.stringify(sequence)}`);
} else {
// Send the standard sequence
console.log(`[VirtualKeyboard] Sending key: ${JSON.stringify(sequence)}`);
}
// Emit the final sequence
emit('send-key', sequence);
// Reset modifier state after sending a combined key
if (isCtrlActive.value || isAltActive.value) {
isCtrlActive.value = false;
isAltActive.value = false;
}
};
// +++ Define key structure +++
interface KeyDefinition {
label: string;
sequence?: string; // Sequence if different from label
type: 'modifier' | 'control' | 'char' | 'navigation' | 'special'; // Key type
}
// +++ Updated key layout definition +++
const keys: KeyDefinition[] = [
// Row 1: Modifiers and special controls
{ label: 'Ctrl', type: 'modifier' },
{ label: 'Alt', type: 'modifier' },
{ label: 'Tab', sequence: '\t', type: 'control' },
{ label: 'Esc', sequence: '\x1b', type: 'control' },
// Row 2: Navigation and common symbols
{ label: '↑', sequence: '\x1b[A', type: 'navigation' },
{ label: '↓', sequence: '\x1b[B', type: 'navigation' },
{ label: '←', sequence: '\x1b[D', type: 'navigation' },
{ label: '→', sequence: '\x1b[C', type: 'navigation' },
{ label: 'Home', sequence: '\x1b[1~', type: 'navigation' }, // +++ Home +++
{ label: 'End', sequence: '\x1b[4~', type: 'navigation' }, // +++ End +++
{ label: 'PgUp', sequence: '\x1b[5~', type: 'navigation' }, // +++ PageUp +++
{ label: 'PgDn', sequence: '\x1b[6~', type: 'navigation' }, // +++ PageDown +++
// Row 3: Example character keys for combinations
{ label: 'A', type: 'char' },
{ label: 'B', type: 'char' },
{ label: 'C', type: 'char' },
{ label: 'D', type: 'char' },
{ label: 'F', type: 'char' },
// Add more letters, numbers, or symbols as needed
];
</script>
<template>
<!-- +++ Updated template loop and bindings +++ -->
<div class="virtual-keyboard-bar flex flex-wrap items-center justify-center gap-1 p-1 bg-background border-t border-border">
<button
v-for="keyDef in keys"
:key="keyDef.label"
@click="sendKey(keyDef)"
class="px-3 py-1.5 rounded border border-border bg-input text-foreground text-xs hover:bg-border focus:outline-none focus:ring-1 focus:ring-primary transition-colors duration-150"
:class="{
'bg-primary text-primary-foreground hover:bg-primary/90': // Style for active modifiers
(keyDef.label === 'Ctrl' && isCtrlActive) ||
(keyDef.label === 'Alt' && isAltActive)
}"
:title="keyDef.label"
>
{{ keyDef.label }}
</button>
</div>
</template>
<style scoped>
.virtual-keyboard-bar {
/* Base styles */
flex-wrap: wrap; /* Allow wrapping */
}
button {
min-width: 40px; /* Ensure tappable area */
text-align: center;
}
/* Optional: Add specific styles for modifier keys */
/*
button[title="Ctrl"], button[title="Alt"] {
font-weight: bold;
}
*/
</style>