fix(web): 优化 HID 引导布局并取消完成后自动弹窗

This commit is contained in:
mofeng-git
2026-09-26 00:31:36 +08:00
parent 4cf3f4e252
commit 6010c39cd8
7 changed files with 51 additions and 64 deletions

View File

@@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import 'vue-sonner/style.css' import 'vue-sonner/style.css'
import HidDriverDialog from '@/components/HidDriverDialog.vue'
import { readPendingHid, type PendingHid } from '@/lib/hidGuide'
import '@/sonner-overrides.css' import '@/sonner-overrides.css'
import { computed, KeepAlive, onMounted, ref, watch } from 'vue' import { computed, KeepAlive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { RouterView, useRouter } from 'vue-router' import { RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -26,11 +24,6 @@ const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const systemStore = useSystemStore() const systemStore = useSystemStore()
const { isDark } = useTheme() const { isDark } = useTheme()
const pendingGuide = ref<PendingHid | null>(null)
watch(() => authStore.isAuthenticated, authenticated => {
pendingGuide.value = authenticated ? readPendingHid() : null
}, { immediate: true })
onMounted(async () => { onMounted(async () => {
try { try {
await authStore.checkSetupStatus() await authStore.checkSetupStatus()
@@ -52,8 +45,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<HidDriverDialog v-if="pendingGuide && authStore.isAuthenticated" :pending="pendingGuide" @close="pendingGuide = null" /> <RouterView v-slot="{ Component, route }">
<RouterView v-if="!pendingGuide" v-slot="{ Component, route }">
<KeepAlive v-if="authStore.isAuthenticated"> <KeepAlive v-if="authStore.isAuthenticated">
<component :is="Component" v-if="route.name === 'Console'" /> <component :is="Component" v-if="route.name === 'Console'" />
</KeepAlive> </KeepAlive>

View File

@@ -7,20 +7,19 @@ import { useConfigStore } from '@/stores/config'
import { request } from '@/api/request' import { request } from '@/api/request'
import type { HidConfig, MsdConfig } from '@/types/generated' import type { HidConfig, MsdConfig } from '@/types/generated'
import { useHidConnection } from '@/composables/useHidConnection' 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 HidDriverForm from './HidDriverForm.vue'
import HidWiringDiagram from './HidWiringDiagram.vue' import HidWiringDiagram from './HidWiringDiagram.vue'
import HidDeviceOverview from './HidDeviceOverview.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 emit = defineEmits<{ close: []; applied: []; discard: [] }>()
const { t } = useI18n(), store = useConfigStore() 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 valid = ref(false), busy = ref(false), error = ref(''), applied = ref(false), loaded = ref(false)
const acknowledged = ref(!props.dirty), uncertain = ref(false) const acknowledged = ref(!props.dirty), uncertain = ref(false)
const pairingStarted = ref(false), autoPair = ref(false), hasApplied = ref(false) const pairingStarted = ref(false), autoPair = ref(false), hasApplied = ref(false)
const connectionStarted = ref(Date.now()), connectionTimedOut = ref(false) const connectionStarted = ref(Date.now()), connectionTimedOut = ref(false)
const disabledUsb = ref<string[]>([]) const disabledUsb = ref<string[]>([])
const autoSubmitPending = ref(props.pending?.phase === 'selected')
let disposed = false, closed = false let disposed = false, closed = false
async function cleanupPairing() { async function cleanupPairing() {
if (hasApplied.value && store.hid?.backend === 'bluetooth') { if (hasApplied.value && store.hid?.backend === 'bluetooth') {
@@ -28,7 +27,7 @@ async function cleanupPairing() {
} }
} }
onUnmounted(() => { onUnmounted(() => {
disposed = true; autoPair.value = false; autoSubmitPending.value = false disposed = true; autoPair.value = false
if (!closed && !busy.value) void cleanupPairing() if (!closed && !busy.value) void cleanupPairing()
}) })
const backend = computed(() => applied.value ? store.hid?.backend : undefined) 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) const { status, bluetooth, error: statusError, restart } = useHidConnection(active, backend)
watch(status, () => { connectionTimedOut.value = Date.now() - connectionStarted.value >= 120000 }) 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)) 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> { async function readConfig<T>(path: string): Promise<T> {
const controller = new AbortController() const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000) const timeout = setTimeout(() => controller.abort(), 5000)
@@ -61,14 +59,12 @@ async function refreshConfigs() {
} }
async function apply() { async function apply() {
if (disposed || busy.value || !valid.value || !loaded.value) return if (disposed || busy.value || !valid.value || !loaded.value) return
autoSubmitPending.value = false
busy.value = true; error.value = ''; uncertain.value = false busy.value = true; error.value = ''; uncertain.value = false
remember('applying')
const controller = new AbortController() const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000) const timeout = setTimeout(() => controller.abort(), 30000)
try { try {
await store.updateHid(deviceRequest(draft.value), controller.signal) 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() await refreshConfigs()
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : String(e) error.value = e instanceof Error ? e.message : String(e)
@@ -99,16 +95,6 @@ watch(() => bluetooth.value?.initialized, initialized => {
void action('pair') 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() { async function edit() {
if (store.hid?.backend === 'bluetooth' && bluetooth.value?.initialized) { if (store.hid?.backend === 'bluetooth' && bluetooth.value?.initialized) {
await action('close') await action('close')
@@ -123,7 +109,6 @@ async function close() {
await action('close') await action('close')
if (error.value) return if (error.value) return
} }
if (props.pending) sessionStorage.removeItem(pendingHidKey)
closed = true closed = true
emit('close') emit('close')
} }
@@ -131,19 +116,12 @@ async function load() {
loaded.value = false; error.value = '' loaded.value = false; error.value = ''
try { try {
await refreshHid() await refreshHid()
if (!props.pending) draft.value = selectionFrom(store.hid) draft.value = selectionFrom(store.hid)
if (store.hid?.backend === 'otg') { if (store.hid?.backend === 'otg') {
const [msd, network, audio] = await Promise.all([refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')]) 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) 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 loaded.value = true
applyPending()
} catch (e) { error.value = e instanceof Error ? e.message : String(e) } } catch (e) { error.value = e instanceof Error ? e.message : String(e) }
} }
onMounted(load) 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="!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> <p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
<DialogFooter class="gap-2"> <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 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; remember('applied'); error = ''">{{ t('hidGuide.checkConnection') }}</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="!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-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> <Button v-else-if="!ready" variant="outline" :disabled="busy" @click="edit()">{{ t('hidGuide.reconfigure') }}</Button>

View File

@@ -3,6 +3,8 @@ import { computed, ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' 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 { configApi } from '@/api'
import { request } from '@/api/request' import { request } from '@/api/request'
import type { BluetoothAdapter } from '@/types/bluetooth' import type { BluetoothAdapter } from '@/types/bluetooth'
@@ -52,13 +54,40 @@ onUnmounted(() => generation++)
</script> </script>
<template> <template>
<fieldset :disabled="locked" class="space-y-4 min-w-0"> <fieldset :disabled="locked" class="space-y-4 min-w-0">
<label class="block space-y-1 text-sm"> <div class="space-y-1 text-sm">
<span>{{ t('hidGuide.driver') }}</span> <div class="flex min-h-8 items-center justify-between gap-2">
<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 })"> <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> <option v-for="driver in drivers" :key="driver" :value="driver">{{ t(`hidGuide.driver_${driver}`) }}</option>
</select> </select>
</label> </div>
<HidWiringDiagram :backend="modelValue.backend" /> <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"> <label v-if="modelValue.backend !== 'none'" class="block space-y-1 text-sm">
<span>{{ t(`hidGuide.device_${modelValue.backend}`) }}</span> <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)"> <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> </label>
<p v-if="missing" class="text-sm text-warning">{{ missing }}</p> <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> <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> </fieldset>
</template> </template>

View File

@@ -8,6 +8,8 @@ export default {
"configure": "Configure driver", "configure": "Configure driver",
"reconfigure": "Reconfigure", "reconfigure": "Reconfigure",
"driver": "HID driver", "driver": "HID driver",
"wiringHelp": "Wiring guide",
"refreshDevices": "Refresh HID devices",
"driver_otg": "USB OTG", "driver_otg": "USB OTG",
"driver_ch9329": "CH9329", "driver_ch9329": "CH9329",
"driver_bluetooth": "Bluetooth HID", "driver_bluetooth": "Bluetooth HID",

View File

@@ -8,6 +8,8 @@ export default {
"configure": "配置驱动", "configure": "配置驱动",
"reconfigure": "重新配置", "reconfigure": "重新配置",
"driver": "HID 驱动", "driver": "HID 驱动",
"wiringHelp": "接线示意",
"refreshDevices": "刷新 HID 设备",
"driver_otg": "USB OTG", "driver_otg": "USB OTG",
"driver_ch9329": "CH9329", "driver_ch9329": "CH9329",
"driver_bluetooth": "蓝牙 HID", "driver_bluetooth": "蓝牙 HID",

View File

@@ -57,17 +57,3 @@ export function hidDeviceStage(status: HidDeviceStatus | null | undefined, bluet
if (status.backend === 'otg' && status.error_code === 'udc_not_configured') return 'waiting' if (status.backend === 'otg' && status.error_code === 'udc_not_configured') return 'waiting'
return status.online ? 'ready' : '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)) }

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import HidDriverForm from '@/components/HidDriverForm.vue' 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 { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -80,7 +80,7 @@ const audioSupported = computed(() => platform.value?.audio.available ?? true)
const totalSteps = 4 const totalSteps = 4
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__' const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
const hidSelection = ref(readPendingHid()?.selection ?? selectionFrom()) const hidSelection = ref(selectionFrom())
const hidSelectionValid = ref(false) const hidSelectionValid = ref(false)
const ttydEnabled = ref(false) const ttydEnabled = ref(false)
@@ -320,7 +320,6 @@ function validateStep3(): boolean {
error.value = t('hidGuide.selectDevice') error.value = t('hidGuide.selectDevice')
return false return false
} }
writePendingHid({ selection: hidSelection.value, phase: 'selected' })
return true return true
} }
@@ -348,7 +347,7 @@ function prevStep() {
async function handleSetup() { async function handleSetup() {
error.value = '' error.value = ''
if (!readPendingHid() || loading.value) return if (loading.value) return
loading.value = true loading.value = true
// Reconcile a previous timed-out account request before submitting again. // Reconcile a previous timed-out account request before submitting again.