Files
One-KVM/web/src/components/HidConfigPopover.vue

417 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi } from '@/api'
import { useConfigStore } from '@/stores/config'
import { HidBackend } from '@/types/generated'
import type { HidConfigUpdate } from '@/types/generated'
const props = defineProps<{
open: boolean
mouseMode?: 'absolute' | 'relative'
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'update:mouseMode', value: 'absolute' | 'relative'): void
}>()
const { t } = useI18n()
const configStore = useConfigStore()
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
function clampMouseMoveSendIntervalMs(ms: number): number {
if (!Number.isFinite(ms)) return DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
return Math.max(0, Math.min(1000, Math.floor(ms)))
}
function loadMouseMoveSendIntervalFromStorage(): number {
const raw = localStorage.getItem('hidMouseThrottle')
const parsed = raw === null ? NaN : Number(raw)
return clampMouseMoveSendIntervalMs(
Number.isFinite(parsed) ? parsed : DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
)
}
// Mouse Settings (real-time)
const mouseThrottle = ref<number>(
loadMouseMoveSendIntervalFromStorage()
)
const showCursor = ref<boolean>(
localStorage.getItem('hidShowCursor') !== 'false' // default true
)
// Watch showCursor changes and sync to localStorage + notify ConsoleView
watch(showCursor, (newValue, oldValue) => {
// Only sync if value actually changed (avoid triggering on initialization)
if (newValue !== oldValue) {
localStorage.setItem('hidShowCursor', newValue ? 'true' : 'false')
window.dispatchEvent(new CustomEvent('hidCursorVisibilityChanged', {
detail: { visible: newValue }
}))
}
})
// HID Device Settings (requires apply)
const hidBackend = ref<HidBackend>(HidBackend.None)
const devicePath = ref<string>('')
const baudrate = ref<number>(9600)
// UI state
const applying = ref(false)
const loadingDevices = ref(false)
// Device lists
const serialDevices = ref<Array<{ path: string; name: string }>>([])
const udcDevices = ref<Array<{ name: string }>>([])
// Button display text - simplified to just show label
const buttonText = computed(() => t('actionbar.hidConfig'))
// Available device paths based on backend type
const availableDevicePaths = computed(() => {
if (hidBackend.value === HidBackend.Ch9329) {
return serialDevices.value
} else if (hidBackend.value === HidBackend.Otg) {
// For OTG, we show UDC devices
return udcDevices.value.map(udc => ({
path: udc.name,
name: udc.name,
}))
}
return []
})
// Load devices
async function loadDevices() {
loadingDevices.value = true
try {
const result = await configApi.listDevices()
serialDevices.value = result.serial
udcDevices.value = result.udc
} catch (e) {
console.info('[HidConfig] Failed to load devices')
} finally {
loadingDevices.value = false
}
}
// Initialize from current config
function initializeFromCurrent() {
// Re-sync real-time settings from localStorage
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
const storedCursor = localStorage.getItem('hidShowCursor') !== 'false'
showCursor.value = storedCursor
// Initialize HID device settings from system state
const hid = configStore.hid
if (hid) {
hidBackend.value = hid.backend || HidBackend.None
if (hidBackend.value === HidBackend.Ch9329) {
devicePath.value = hid.ch9329_port || ''
baudrate.value = hid.ch9329_baudrate || 9600
} else if (hidBackend.value === HidBackend.Otg) {
devicePath.value = hid.otg_udc || ''
} else {
devicePath.value = ''
}
}
}
// Toggle mouse mode (real-time)
function toggleMouseMode() {
const newMode = props.mouseMode === 'absolute' ? 'relative' : 'absolute'
emit('update:mouseMode', newMode)
// Update backend config
configStore.updateHid({
mouse_absolute: newMode === 'absolute',
}).catch(_e => {
console.info('[HidConfig] Failed to update mouse mode')
toast.error(t('config.updateFailed'))
})
}
// Update mouse throttle (real-time)
function handleThrottleChange(value: number[] | undefined) {
if (!value || value.length === 0 || value[0] === undefined) return
const throttleValue = clampMouseMoveSendIntervalMs(value[0])
mouseThrottle.value = throttleValue
// Save to localStorage
localStorage.setItem('hidMouseThrottle', String(throttleValue))
// Notify ConsoleView (storage event doesn't fire in same tab)
window.dispatchEvent(new CustomEvent('hidMouseSendIntervalChanged', {
detail: { intervalMs: throttleValue },
}))
}
// Handle backend change
function handleBackendChange(backend: unknown) {
if (typeof backend !== 'string') return
if (backend === HidBackend.Otg || backend === HidBackend.Ch9329 || backend === HidBackend.None) {
hidBackend.value = backend
} else {
return
}
// Clear device path when changing backend
devicePath.value = ''
// Auto-select first device if available
if (availableDevicePaths.value.length > 0 && availableDevicePaths.value[0]) {
devicePath.value = availableDevicePaths.value[0].path
}
}
// Handle device path change
function handleDevicePathChange(path: unknown) {
if (typeof path !== 'string') return
devicePath.value = path
}
// Handle baudrate change
function handleBaudrateChange(rate: unknown) {
if (typeof rate !== 'string') return
baudrate.value = Number(rate)
}
// Apply HID device configuration
async function applyHidConfig() {
applying.value = true
try {
const config: HidConfigUpdate = {
backend: hidBackend.value,
}
if (hidBackend.value === HidBackend.Ch9329) {
config.ch9329_port = devicePath.value
config.ch9329_baudrate = baudrate.value
} else if (hidBackend.value === HidBackend.Otg) {
config.otg_udc = devicePath.value
}
await configStore.updateHid(config)
toast.success(t('config.applied'))
// HID state will be updated via WebSocket device_info event
} catch (e) {
console.info('[HidConfig] Failed to apply config:', e)
// Error toast already shown by API layer
} finally {
applying.value = false
}
}
// Watch open state
watch(() => props.open, (isOpen) => {
if (!isOpen) return
// Load devices on first open
if (serialDevices.value.length === 0) {
loadDevices()
}
configStore.refreshHid()
.then(() => {
initializeFromCurrent()
})
.catch(() => {
initializeFromCurrent()
})
})
</script>
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<MousePointer v-if="mouseMode === 'absolute'" class="h-4 w-4" />
<Move v-else class="h-4 w-4" />
<span class="hidden sm:inline">{{ buttonText }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
<Separator />
<!-- Mouse Settings (Real-time) -->
<div class="space-y-3">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.mouseSettings') }}</h5>
<!-- Positioning Mode -->
<div class="space-y-2">
<div class="flex items-center gap-1">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.positioningMode') }}</Label>
<HelpTooltip :content="mouseMode === 'absolute' ? t('help.absoluteMode') : t('help.relativeMode')" icon-size="sm" />
</div>
<div class="flex gap-2">
<Button
:variant="mouseMode === 'absolute' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="toggleMouseMode"
>
<MousePointer class="h-3.5 w-3.5 mr-1" />
{{ t('actionbar.absolute') }}
</Button>
<Button
:variant="mouseMode === 'relative' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="toggleMouseMode"
>
<Move class="h-3.5 w-3.5 mr-1" />
{{ t('actionbar.relative') }}
</Button>
</div>
</div>
<!-- Event Throttle -->
<div class="space-y-2">
<div class="flex justify-between items-center">
<div class="flex items-center gap-1">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.sendInterval') }}</Label>
<HelpTooltip :content="t('help.mouseThrottle')" icon-size="sm" />
</div>
<span class="text-xs font-mono">{{ mouseThrottle }}ms</span>
</div>
<Slider
:model-value="[mouseThrottle]"
@update:model-value="handleThrottleChange"
:min="0"
:max="1000"
:step="1"
class="py-2"
/>
<div class="flex justify-between text-xs text-muted-foreground">
<span>0ms</span>
<span>1000ms</span>
</div>
</div>
<!-- Show Cursor -->
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.showCursor') }}</Label>
<Switch v-model="showCursor" />
</div>
</div>
<!-- HID Device Settings (Requires Apply) -->
<Separator />
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:disabled="loadingDevices"
@click="loadDevices"
>
<RefreshCw :class="['h-3.5 w-3.5', loadingDevices && 'animate-spin']" />
</Button>
</div>
<!-- Backend Type -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.backend') }}</Label>
<Select
:model-value="hidBackend"
@update:model-value="handleBackendChange"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="HidBackend.Otg" class="text-xs">USB OTG</SelectItem>
<SelectItem :value="HidBackend.Ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
<SelectItem :value="HidBackend.None" class="text-xs">{{ t('common.disabled') }}</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Device Path (OTG or CH9329) -->
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
<Select
:model-value="devicePath"
@update:model-value="handleDevicePathChange"
:disabled="availableDevicePaths.length === 0"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('actionbar.selectDevice')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="device in availableDevicePaths"
:key="device.path"
:value="device.path"
class="text-xs"
>
{{ device.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Baudrate (CH9329 only) -->
<div v-if="hidBackend === HidBackend.Ch9329" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.baudrate') }}</Label>
<Select
:model-value="String(baudrate)"
@update:model-value="handleBaudrateChange"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="9600" class="text-xs">9600</SelectItem>
<SelectItem value="19200" class="text-xs">19200</SelectItem>
<SelectItem value="38400" class="text-xs">38400</SelectItem>
<SelectItem value="57600" class="text-xs">57600</SelectItem>
<SelectItem value="115200" class="text-xs">115200</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Apply Button -->
<Button
class="w-full h-8 text-xs"
:disabled="applying"
@click="applyHidConfig"
>
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</template>