mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-04-30 01:46:37 +08:00
feat: 完善 USB UVC 设备异常处理,添加 USB 设备复位功能
This commit is contained in:
@@ -765,4 +765,28 @@ export const audioApi = {
|
||||
}),
|
||||
}
|
||||
|
||||
// USB API
|
||||
export interface UsbDeviceInfo {
|
||||
bus_num: number
|
||||
dev_num: number
|
||||
id_vendor: string
|
||||
id_product: string
|
||||
product?: string
|
||||
manufacturer?: string
|
||||
speed?: string
|
||||
authorized?: boolean
|
||||
video_device?: string
|
||||
}
|
||||
|
||||
export const usbApi = {
|
||||
listDevices: () =>
|
||||
request<UsbDeviceInfo[]>('/devices/usb'),
|
||||
|
||||
resetDevice: (busNum: number, devNum: number) =>
|
||||
request<{ success: boolean; message: string }>('/devices/usb/reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ bus_num: busNum, dev_num: devNum }),
|
||||
}),
|
||||
}
|
||||
|
||||
export { ApiError }
|
||||
|
||||
@@ -328,6 +328,14 @@ export default {
|
||||
title: 'Video channel busy',
|
||||
detail: 'Applying a new configuration or another component is using the device, please wait…',
|
||||
},
|
||||
uvc_usb_error: {
|
||||
title: 'USB capture transport error',
|
||||
detail: 'The USB capture device encountered a protocol error (EPROTO). You can try resetting the device from Settings → Environment → USB Devices.',
|
||||
},
|
||||
uvc_capture_stall: {
|
||||
title: 'UVC capture stalled',
|
||||
detail: 'Check the device connection. If already connected, try changing the capture format and resetting the device.',
|
||||
},
|
||||
reason: {
|
||||
no_cable: 'HDMI cable not detected — check the cable and that the target is powered on',
|
||||
no_sync: 'Unstable signal: timings could not be locked — try a lower resolution or refresh rate',
|
||||
@@ -337,6 +345,9 @@ export default {
|
||||
device_lost: 'Video node disappeared, waiting for the driver to recover',
|
||||
config_changing: 'Applying new configuration',
|
||||
mode_switching: 'Switching video mode',
|
||||
uvc_usb_error:
|
||||
'Try another USB port or cable, avoid hubs, or reconnect the device. You can also reset the device from Settings → Environment → USB Devices.',
|
||||
uvc_capture_stall: '',
|
||||
},
|
||||
},
|
||||
// WebRTC
|
||||
@@ -836,6 +847,21 @@ export default {
|
||||
currentHardwareEncoder: 'Current Hardware Encoder',
|
||||
none: 'None',
|
||||
},
|
||||
usbDevices: {
|
||||
title: 'USB Devices',
|
||||
desc: 'View connected USB devices and reset them to recover from errors',
|
||||
refresh: 'Refresh',
|
||||
loadFailed: 'Failed to load USB device list',
|
||||
noDevices: 'No USB devices found',
|
||||
colDevice: 'Device',
|
||||
colSpeed: 'Speed',
|
||||
colVideo: 'Video',
|
||||
colAction: 'Action',
|
||||
reset: 'Reset',
|
||||
resetConfirmTitle: 'Confirm USB Device Reset',
|
||||
resetConfirmDesc: 'This will reset USB device "{device}" by cycling its authorized attribute. All connections to this device will be temporarily interrupted. Continue?',
|
||||
resetAction: 'Reset Device',
|
||||
},
|
||||
// WebRTC / ICE
|
||||
webrtcSettings: 'WebRTC Settings',
|
||||
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
|
||||
|
||||
@@ -327,6 +327,14 @@ export default {
|
||||
title: '视频通道忙',
|
||||
detail: '正在切换配置或被其他组件占用,请稍候…',
|
||||
},
|
||||
uvc_usb_error: {
|
||||
title: 'USB 采集传输异常',
|
||||
detail: 'USB 采集卡遇到协议错误(EPROTO),可在 设置 → 环境 → USB 设备 中尝试复位。',
|
||||
},
|
||||
uvc_capture_stall: {
|
||||
title: 'UVC 采集超时',
|
||||
detail: '检查设备连接,若设备已连接,可尝试修改采集格式并复位设备。',
|
||||
},
|
||||
reason: {
|
||||
no_cable: '未检测到 HDMI 线缆,请检查连接或被控机是否已开机',
|
||||
no_sync: '信号不稳定,无法锁定时序,可尝试降低被控机分辨率/刷新率',
|
||||
@@ -336,6 +344,9 @@ export default {
|
||||
device_lost: '视频节点丢失,等待驱动恢复',
|
||||
config_changing: '正在应用新配置',
|
||||
mode_switching: '正在切换视频模式',
|
||||
uvc_usb_error:
|
||||
'可尝试更换 USB 口或线、避免 HUB、或重新插拔设备;也可在 设置 → 环境 → USB 设备 中复位。',
|
||||
uvc_capture_stall: '',
|
||||
},
|
||||
},
|
||||
// WebRTC
|
||||
@@ -835,6 +846,21 @@ export default {
|
||||
currentHardwareEncoder: '当前硬件编码器',
|
||||
none: '无',
|
||||
},
|
||||
usbDevices: {
|
||||
title: 'USB 设备',
|
||||
desc: '查看已连接的 USB 设备,可通过复位恢复异常设备',
|
||||
refresh: '刷新',
|
||||
loadFailed: '加载 USB 设备列表失败',
|
||||
noDevices: '未发现 USB 设备',
|
||||
colDevice: '设备',
|
||||
colSpeed: '速度',
|
||||
colVideo: '视频',
|
||||
colAction: '操作',
|
||||
reset: '复位',
|
||||
resetConfirmTitle: '确认复位 USB 设备',
|
||||
resetConfirmDesc: '将通过 authorized 属性复位 USB 设备「{device}」,该设备上的所有连接将短暂中断。确定继续?',
|
||||
resetAction: '确认复位',
|
||||
},
|
||||
// WebRTC / ICE
|
||||
webrtcSettings: 'WebRTC 设置',
|
||||
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
|
||||
|
||||
@@ -1102,6 +1102,20 @@ const signalOverlayInfo = computed(() => {
|
||||
const reasonHintKey = reason ? `console.signal.reason.${reason}` : ''
|
||||
const hint = reasonHintKey && te(reasonHintKey) ? t(reasonHintKey) : ''
|
||||
|
||||
// UVC-specific overlay when we have the detailed reason
|
||||
if (streamSignalState.value === 'no_signal' && reason) {
|
||||
const titleKey = `console.signal.${reason}.title`
|
||||
const detailKey = `console.signal.${reason}.detail`
|
||||
if (te(titleKey) && te(detailKey)) {
|
||||
return {
|
||||
title: t(titleKey),
|
||||
detail: t(detailKey),
|
||||
hint,
|
||||
tone: 'info' as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (streamSignalState.value) {
|
||||
case 'no_signal':
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
extensionsApi,
|
||||
systemApi,
|
||||
updateApi,
|
||||
usbApi,
|
||||
type EncoderBackendInfo,
|
||||
type AuthConfig,
|
||||
type RustDeskConfigResponse,
|
||||
@@ -59,6 +60,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Monitor,
|
||||
Keyboard,
|
||||
@@ -647,6 +658,52 @@ async function onRunVideoEncoderSelfCheckClick() {
|
||||
await runVideoEncoderSelfCheck()
|
||||
}
|
||||
|
||||
// USB devices state
|
||||
const usbDevices = ref<import('@/api').UsbDeviceInfo[]>([])
|
||||
const usbDevicesLoading = ref(false)
|
||||
const usbDevicesError = ref('')
|
||||
const usbResetTarget = ref<import('@/api').UsbDeviceInfo | null>(null)
|
||||
const usbResetLoading = ref(false)
|
||||
|
||||
async function fetchUsbDevices() {
|
||||
usbDevicesLoading.value = true
|
||||
usbDevicesError.value = ''
|
||||
try {
|
||||
usbDevices.value = await usbApi.listDevices()
|
||||
} catch {
|
||||
usbDevicesError.value = t('settings.usbDevices.loadFailed')
|
||||
} finally {
|
||||
usbDevicesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUsbReset() {
|
||||
if (!usbResetTarget.value) return
|
||||
usbResetLoading.value = true
|
||||
try {
|
||||
await usbApi.resetDevice(usbResetTarget.value.bus_num, usbResetTarget.value.dev_num)
|
||||
} catch {
|
||||
// Error already shown by request helper toast
|
||||
} finally {
|
||||
usbResetLoading.value = false
|
||||
usbResetTarget.value = null
|
||||
// Refresh the list after a short delay for USB re-enumeration
|
||||
setTimeout(() => fetchUsbDevices(), 1500)
|
||||
}
|
||||
}
|
||||
|
||||
function usbSpeedLabel(speed?: string): string {
|
||||
if (!speed) return '-'
|
||||
const map: Record<string, string> = {
|
||||
'1.5': '1.5 Mbps',
|
||||
'12': '12 Mbps',
|
||||
'480': '480 Mbps',
|
||||
'5000': '5 Gbps',
|
||||
'10000': '10 Gbps',
|
||||
}
|
||||
return map[speed] || `${speed} Mbps`
|
||||
}
|
||||
|
||||
function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget {
|
||||
return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget
|
||||
}
|
||||
@@ -2024,6 +2081,7 @@ onMounted(async () => {
|
||||
loadWebServerConfig(),
|
||||
loadUpdateOverview(),
|
||||
refreshUpdateStatus(),
|
||||
fetchUsbDevices(),
|
||||
])
|
||||
usernameInput.value = authStore.user || ''
|
||||
|
||||
@@ -2742,9 +2800,103 @@ watch(() => route.query.tab, (tab) => {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Network Section -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-start justify-between space-y-0">
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('settings.usbDevices.title') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.usbDevices.desc') }}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="usbDevicesLoading"
|
||||
@click="fetchUsbDevices()"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 mr-2" :class="{ 'animate-spin': usbDevicesLoading }" />
|
||||
{{ t('settings.usbDevices.refresh') }}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<p v-if="usbDevicesError" class="text-xs text-red-600 dark:text-red-400">
|
||||
{{ usbDevicesError }}
|
||||
</p>
|
||||
|
||||
<template v-if="usbDevices.length > 0">
|
||||
<div class="rounded-md border overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[540px]">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/40">
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('settings.usbDevices.colDevice') }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">VID:PID</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('settings.usbDevices.colSpeed') }}</th>
|
||||
<th class="px-3 py-2 text-left font-medium">{{ t('settings.usbDevices.colVideo') }}</th>
|
||||
<th class="px-3 py-2 text-right font-medium">{{ t('settings.usbDevices.colAction') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="dev in usbDevices"
|
||||
:key="`${dev.bus_num}-${dev.dev_num}`"
|
||||
class="border-b last:border-b-0 hover:bg-muted/20"
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium truncate max-w-[180px]" :title="dev.product || dev.manufacturer || `${dev.id_vendor}:${dev.id_product}`">{{ dev.product || dev.manufacturer || `${dev.id_vendor}:${dev.id_product}` }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ dev.id_vendor }}:{{ dev.id_product }}</td>
|
||||
<td class="px-3 py-2 text-xs">{{ usbSpeedLabel(dev.speed) }}</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<code v-if="dev.video_device">{{ dev.video_device }}</code>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<Button
|
||||
v-if="dev.authorized != null"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs bg-black text-white border-black hover:bg-black/90 hover:text-white dark:bg-white dark:text-black dark:border-white dark:hover:bg-white/90 dark:hover:text-black"
|
||||
:disabled="usbResetLoading"
|
||||
@click="usbResetTarget = dev"
|
||||
>
|
||||
{{ t('settings.usbDevices.reset') }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else-if="usbDevicesLoading" class="text-xs text-muted-foreground">
|
||||
{{ t('common.loading') }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted-foreground">
|
||||
{{ t('settings.usbDevices.noDevices') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- USB Reset Confirmation Dialog -->
|
||||
<AlertDialog :open="usbResetTarget != null">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ t('settings.usbDevices.resetConfirmTitle') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ t('settings.usbDevices.resetConfirmDesc', { device: usbResetTarget?.product || `${usbResetTarget?.id_vendor}:${usbResetTarget?.id_product}` }) }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="usbResetTarget = null">{{ t('common.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
:disabled="usbResetLoading"
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
@click="confirmUsbReset()"
|
||||
>
|
||||
{{ t('settings.usbDevices.resetAction') }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div v-show="activeSection === 'network'" class="space-y-6">
|
||||
|
||||
<!-- Auto-restart: restarting progress -->
|
||||
|
||||
Reference in New Issue
Block a user