mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-27 16:00:49 +08:00
fix(web): 优化 HID 引导布局并取消完成后自动弹窗
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import 'vue-sonner/style.css'
|
||||
import HidDriverDialog from '@/components/HidDriverDialog.vue'
|
||||
import { readPendingHid, type PendingHid } from '@/lib/hidGuide'
|
||||
import '@/sonner-overrides.css'
|
||||
import { computed, KeepAlive, onMounted, ref, watch } from 'vue'
|
||||
import { computed, KeepAlive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -26,11 +24,6 @@ const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const systemStore = useSystemStore()
|
||||
const { isDark } = useTheme()
|
||||
const pendingGuide = ref<PendingHid | null>(null)
|
||||
watch(() => authStore.isAuthenticated, authenticated => {
|
||||
pendingGuide.value = authenticated ? readPendingHid() : null
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await authStore.checkSetupStatus()
|
||||
@@ -52,8 +45,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HidDriverDialog v-if="pendingGuide && authStore.isAuthenticated" :pending="pendingGuide" @close="pendingGuide = null" />
|
||||
<RouterView v-if="!pendingGuide" v-slot="{ Component, route }">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<KeepAlive v-if="authStore.isAuthenticated">
|
||||
<component :is="Component" v-if="route.name === 'Console'" />
|
||||
</KeepAlive>
|
||||
|
||||
@@ -7,20 +7,19 @@ import { useConfigStore } from '@/stores/config'
|
||||
import { request } from '@/api/request'
|
||||
import type { HidConfig, MsdConfig } from '@/types/generated'
|
||||
import { useHidConnection } from '@/composables/useHidConnection'
|
||||
import { deviceRequest, selectionFrom, matchesSelection, writePendingHid, pendingHidKey, type PendingHid, type HidSelection } from '@/lib/hidGuide'
|
||||
import { deviceRequest, matchesSelection, selectionFrom, type HidSelection } from '@/lib/hidGuide'
|
||||
import HidDriverForm from './HidDriverForm.vue'
|
||||
import HidWiringDiagram from './HidWiringDiagram.vue'
|
||||
import HidDeviceOverview from './HidDeviceOverview.vue'
|
||||
const props = defineProps<{ dirty?: boolean; pending?: PendingHid | null }>()
|
||||
const props = defineProps<{ dirty?: boolean }>()
|
||||
const emit = defineEmits<{ close: []; applied: []; discard: [] }>()
|
||||
const { t } = useI18n(), store = useConfigStore()
|
||||
const draft = ref<HidSelection>(props.pending ? JSON.parse(JSON.stringify(props.pending.selection)) : selectionFrom(store.hid))
|
||||
const draft = ref<HidSelection>(selectionFrom(store.hid))
|
||||
const valid = ref(false), busy = ref(false), error = ref(''), applied = ref(false), loaded = ref(false)
|
||||
const acknowledged = ref(!props.dirty), uncertain = ref(false)
|
||||
const pairingStarted = ref(false), autoPair = ref(false), hasApplied = ref(false)
|
||||
const connectionStarted = ref(Date.now()), connectionTimedOut = ref(false)
|
||||
const disabledUsb = ref<string[]>([])
|
||||
const autoSubmitPending = ref(props.pending?.phase === 'selected')
|
||||
let disposed = false, closed = false
|
||||
async function cleanupPairing() {
|
||||
if (hasApplied.value && store.hid?.backend === 'bluetooth') {
|
||||
@@ -28,7 +27,7 @@ async function cleanupPairing() {
|
||||
}
|
||||
}
|
||||
onUnmounted(() => {
|
||||
disposed = true; autoPair.value = false; autoSubmitPending.value = false
|
||||
disposed = true; autoPair.value = false
|
||||
if (!closed && !busy.value) void cleanupPairing()
|
||||
})
|
||||
const backend = computed(() => applied.value ? store.hid?.backend : undefined)
|
||||
@@ -36,7 +35,6 @@ const active = computed(() => applied.value && !busy.value)
|
||||
const { status, bluetooth, error: statusError, restart } = useHidConnection(active, backend)
|
||||
watch(status, () => { connectionTimedOut.value = Date.now() - connectionStarted.value >= 120000 })
|
||||
const ready = computed(() => store.hid?.backend === 'none' || (store.hid?.backend === 'bluetooth' ? bluetooth.value?.ready : status.value?.online))
|
||||
function remember(phase: PendingHid['phase']) { if (props.pending) writePendingHid({ selection: draft.value, phase }) }
|
||||
async function readConfig<T>(path: string): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
@@ -61,14 +59,12 @@ async function refreshConfigs() {
|
||||
}
|
||||
async function apply() {
|
||||
if (disposed || busy.value || !valid.value || !loaded.value) return
|
||||
autoSubmitPending.value = false
|
||||
busy.value = true; error.value = ''; uncertain.value = false
|
||||
remember('applying')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30000)
|
||||
try {
|
||||
await store.updateHid(deviceRequest(draft.value), controller.signal)
|
||||
applied.value = true; hasApplied.value = true; connectionStarted.value = Date.now(); remember('applied'); autoPair.value = draft.value.backend === 'bluetooth'
|
||||
applied.value = true; hasApplied.value = true; connectionStarted.value = Date.now(); autoPair.value = draft.value.backend === 'bluetooth'
|
||||
await refreshConfigs()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
@@ -99,16 +95,6 @@ watch(() => bluetooth.value?.initialized, initialized => {
|
||||
void action('pair')
|
||||
}
|
||||
})
|
||||
function applyPending() {
|
||||
if (!autoSubmitPending.value || !valid.value || !loaded.value || disposed) return
|
||||
if (props.pending && JSON.stringify(deviceRequest(draft.value)) !== JSON.stringify(deviceRequest(props.pending.selection))) {
|
||||
autoSubmitPending.value = false
|
||||
error.value = t('hidGuide.resumeRetry')
|
||||
return
|
||||
}
|
||||
void apply()
|
||||
}
|
||||
watch(valid, applyPending)
|
||||
async function edit() {
|
||||
if (store.hid?.backend === 'bluetooth' && bluetooth.value?.initialized) {
|
||||
await action('close')
|
||||
@@ -123,7 +109,6 @@ async function close() {
|
||||
await action('close')
|
||||
if (error.value) return
|
||||
}
|
||||
if (props.pending) sessionStorage.removeItem(pendingHidKey)
|
||||
closed = true
|
||||
emit('close')
|
||||
}
|
||||
@@ -131,19 +116,12 @@ async function load() {
|
||||
loaded.value = false; error.value = ''
|
||||
try {
|
||||
await refreshHid()
|
||||
if (!props.pending) draft.value = selectionFrom(store.hid)
|
||||
draft.value = selectionFrom(store.hid)
|
||||
if (store.hid?.backend === 'otg') {
|
||||
const [msd, network, audio] = await Promise.all([refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')])
|
||||
disabledUsb.value = [msd.enabled ? t('hidGuide.msd') : '', network.enabled ? t('hidGuide.network') : '', audio.enabled ? t('hidGuide.audio') : ''].filter(Boolean)
|
||||
}
|
||||
if (props.pending && props.pending.phase !== 'selected') {
|
||||
if (store.hid && matchesSelection(store.hid, props.pending.selection)) {
|
||||
applied.value = true; hasApplied.value = true; remember('applied')
|
||||
// The previous reset may have succeeded. Resume observation, never clear again.
|
||||
} else error.value = t('hidGuide.resumeRetry')
|
||||
}
|
||||
loaded.value = true
|
||||
applyPending()
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
onMounted(load)
|
||||
@@ -178,8 +156,8 @@ onMounted(load)
|
||||
<p v-if="!applied && draft.backend === 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.resetWarning') }}</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
||||
<DialogFooter class="gap-2">
|
||||
<Button variant="outline" :disabled="busy" @click="close">{{ t(applied ? ready ? 'hidGuide.done' : 'hidGuide.later' : props.pending ? 'hidGuide.configureLater' : 'common.cancel') }}</Button>
|
||||
<Button v-if="uncertain && !applied" variant="outline" :disabled="busy" @click="applied = true; hasApplied = true; remember('applied'); error = ''">{{ t('hidGuide.checkConnection') }}</Button>
|
||||
<Button variant="outline" :disabled="busy" @click="close">{{ t(applied ? ready ? 'hidGuide.done' : 'hidGuide.later' : 'common.cancel') }}</Button>
|
||||
<Button v-if="uncertain && !applied" variant="outline" :disabled="busy" @click="applied = true; hasApplied = true; error = ''">{{ t('hidGuide.checkConnection') }}</Button>
|
||||
<Button v-if="!loaded" variant="outline" @click="load">{{ t('common.refresh') }}</Button>
|
||||
<Button v-if="!applied" :disabled="busy || !loaded || !valid" @click="apply">{{ t(busy ? 'actionbar.applying' : 'common.apply') }}</Button>
|
||||
<Button v-else-if="!ready" variant="outline" :disabled="busy" @click="edit()">{{ t('hidGuide.reconfigure') }}</Button>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Cable, RefreshCw } from 'lucide-vue-next'
|
||||
import { configApi } from '@/api'
|
||||
import { request } from '@/api/request'
|
||||
import type { BluetoothAdapter } from '@/types/bluetooth'
|
||||
@@ -52,13 +54,40 @@ onUnmounted(() => generation++)
|
||||
</script>
|
||||
<template>
|
||||
<fieldset :disabled="locked" class="space-y-4 min-w-0">
|
||||
<label class="block space-y-1 text-sm">
|
||||
<span>{{ t('hidGuide.driver') }}</span>
|
||||
<select class="w-full rounded-md border bg-background px-3 py-2" :value="modelValue.backend" @change="emit('update:modelValue', { ...modelValue, backend: ($event.target as HTMLSelectElement).value as Driver })">
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex min-h-8 items-center justify-between gap-2">
|
||||
<label for="hid-driver">{{ t('hidGuide.driver') }}</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<HoverCard v-if="modelValue.backend !== 'none'" :open-delay="150" :close-delay="100">
|
||||
<HoverCardTrigger as-child>
|
||||
<Button type="button" variant="ghost" size="sm" class="h-8 gap-1.5 px-2 text-muted-foreground" :aria-label="t('hidGuide.wiringHelp')">
|
||||
<Cable class="size-4" />
|
||||
<span>{{ t('hidGuide.wiringHelp') }}</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" class="w-[min(480px,calc(100vw-2rem))] p-3">
|
||||
<HidWiringDiagram :backend="modelValue.backend" />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<Button
|
||||
v-if="modelValue.backend !== 'none'"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="loading"
|
||||
:aria-label="t('hidGuide.refreshDevices')"
|
||||
:title="t('hidGuide.refreshDevices')"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw class="size-4" :class="{ 'animate-spin': loading }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<select id="hid-driver" class="w-full rounded-md border bg-background px-3 py-2" :value="modelValue.backend" @change="emit('update:modelValue', { ...modelValue, backend: ($event.target as HTMLSelectElement).value as Driver })">
|
||||
<option v-for="driver in drivers" :key="driver" :value="driver">{{ t(`hidGuide.driver_${driver}`) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<HidWiringDiagram :backend="modelValue.backend" />
|
||||
</div>
|
||||
<p v-if="modelValue.backend === 'none'" class="text-sm text-muted-foreground">{{ t('hidGuide.disabledHelp') }}</p>
|
||||
<label v-if="modelValue.backend !== 'none'" class="block space-y-1 text-sm">
|
||||
<span>{{ t(`hidGuide.device_${modelValue.backend}`) }}</span>
|
||||
<select class="w-full rounded-md border bg-background px-3 py-2" :value="selected" :disabled="loading" @change="device(($event.target as HTMLSelectElement).value)">
|
||||
@@ -79,6 +108,5 @@ onUnmounted(() => generation++)
|
||||
</label>
|
||||
<p v-if="missing" class="text-sm text-warning">{{ missing }}</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
||||
<Button v-if="modelValue.backend !== 'none'" type="button" variant="outline" size="sm" :disabled="loading" @click="refresh">{{ t('common.refresh') }}</Button>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,8 @@ export default {
|
||||
"configure": "Configure driver",
|
||||
"reconfigure": "Reconfigure",
|
||||
"driver": "HID driver",
|
||||
"wiringHelp": "Wiring guide",
|
||||
"refreshDevices": "Refresh HID devices",
|
||||
"driver_otg": "USB OTG",
|
||||
"driver_ch9329": "CH9329",
|
||||
"driver_bluetooth": "Bluetooth HID",
|
||||
|
||||
@@ -8,6 +8,8 @@ export default {
|
||||
"configure": "配置驱动",
|
||||
"reconfigure": "重新配置",
|
||||
"driver": "HID 驱动",
|
||||
"wiringHelp": "接线示意",
|
||||
"refreshDevices": "刷新 HID 设备",
|
||||
"driver_otg": "USB OTG",
|
||||
"driver_ch9329": "CH9329",
|
||||
"driver_bluetooth": "蓝牙 HID",
|
||||
|
||||
@@ -57,17 +57,3 @@ export function hidDeviceStage(status: HidDeviceStatus | null | undefined, bluet
|
||||
if (status.backend === 'otg' && status.error_code === 'udc_not_configured') return 'waiting'
|
||||
return status.online ? 'ready' : 'waiting'
|
||||
}
|
||||
export const pendingHidKey = 'one-kvm.pending-hid.v1'
|
||||
export interface PendingHid { selection: HidSelection; phase: 'selected' | 'applying' | 'applied' }
|
||||
export function readPendingHid(): PendingHid | null {
|
||||
try {
|
||||
const value = JSON.parse(sessionStorage.getItem(pendingHidKey) ?? 'null')
|
||||
if (value && ['selected', 'applying', 'applied'].includes(value.phase)
|
||||
&& ['otg', 'ch9329', 'bluetooth', 'none'].includes(value.selection?.backend)
|
||||
&& typeof value.selection.otg_udc === 'string' && typeof value.selection.ch9329_port === 'string'
|
||||
&& typeof value.selection.ch9329_baudrate === 'number'
|
||||
&& typeof value.selection.bluetooth?.adapter === 'string' && typeof value.selection.bluetooth?.name === 'string') return value
|
||||
} catch { /* Invalid or unavailable session storage is not an applied configuration. */ }
|
||||
return null
|
||||
}
|
||||
export function writePendingHid(value: PendingHid) { sessionStorage.setItem(pendingHidKey, JSON.stringify(value)) }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import HidDriverForm from '@/components/HidDriverForm.vue'
|
||||
import { selectionFrom, readPendingHid, writePendingHid } from '@/lib/hidGuide'
|
||||
import { selectionFrom } from '@/lib/hidGuide'
|
||||
|
||||
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -80,7 +80,7 @@ const audioSupported = computed(() => platform.value?.audio.available ?? true)
|
||||
const totalSteps = 4
|
||||
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
||||
|
||||
const hidSelection = ref(readPendingHid()?.selection ?? selectionFrom())
|
||||
const hidSelection = ref(selectionFrom())
|
||||
const hidSelectionValid = ref(false)
|
||||
|
||||
const ttydEnabled = ref(false)
|
||||
@@ -320,7 +320,6 @@ function validateStep3(): boolean {
|
||||
error.value = t('hidGuide.selectDevice')
|
||||
return false
|
||||
}
|
||||
writePendingHid({ selection: hidSelection.value, phase: 'selected' })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -348,7 +347,7 @@ function prevStep() {
|
||||
async function handleSetup() {
|
||||
error.value = ''
|
||||
|
||||
if (!readPendingHid() || loading.value) return
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
// Reconcile a previous timed-out account request before submitting again.
|
||||
|
||||
Reference in New Issue
Block a user