mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
feat: 初步增加 Windows 支持
This commit is contained in:
@@ -46,6 +46,30 @@ export interface DeviceInfo {
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
network_addresses: NetworkAddress[]
|
||||
serial_ports: string[]
|
||||
}
|
||||
|
||||
export interface FeatureCapability {
|
||||
available: boolean
|
||||
backends: string[]
|
||||
selected_backend?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface PlatformCapabilities {
|
||||
mode: 'linux' | 'windows'
|
||||
mode_label: string
|
||||
video_capture: FeatureCapability
|
||||
encoder: FeatureCapability
|
||||
hid: FeatureCapability
|
||||
atx: FeatureCapability
|
||||
msd: FeatureCapability
|
||||
otg: FeatureCapability
|
||||
audio: FeatureCapability
|
||||
rustdesk: FeatureCapability
|
||||
diagnostics: FeatureCapability
|
||||
extensions: FeatureCapability
|
||||
service_installation: FeatureCapability
|
||||
}
|
||||
|
||||
export const systemApi = {
|
||||
@@ -54,12 +78,14 @@ export const systemApi = {
|
||||
version: string
|
||||
build_date: string
|
||||
initialized: boolean
|
||||
platform: PlatformCapabilities
|
||||
capabilities: {
|
||||
video: { available: boolean; backend?: string }
|
||||
hid: { available: boolean; backend?: string }
|
||||
msd: { available: boolean }
|
||||
atx: { available: boolean; backend?: string }
|
||||
audio: { available: boolean; backend?: string }
|
||||
video: { available: boolean; backend?: string; reason?: string }
|
||||
hid: { available: boolean; backend?: string; reason?: string }
|
||||
msd: { available: boolean; backend?: string; reason?: string }
|
||||
atx: { available: boolean; backend?: string; reason?: string }
|
||||
audio: { available: boolean; backend?: string; reason?: string }
|
||||
rustdesk: { available: boolean; backend?: string; reason?: string }
|
||||
}
|
||||
disk_space?: {
|
||||
total: number
|
||||
@@ -72,7 +98,7 @@ export const systemApi = {
|
||||
health: () => request<{ status: string; version: string }>('/health'),
|
||||
|
||||
setupStatus: () =>
|
||||
request<{ initialized: boolean; needs_setup: boolean }>('/setup'),
|
||||
request<{ initialized: boolean; needs_setup: boolean; platform: PlatformCapabilities }>('/setup'),
|
||||
|
||||
setup: (data: {
|
||||
username: string
|
||||
|
||||
@@ -63,6 +63,7 @@ const props = defineProps<{
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
videoMode?: VideoMode
|
||||
ttydRunning?: boolean
|
||||
showTerminal?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -194,6 +195,7 @@ const RIGHT_FIXED_PX = 120
|
||||
const collapsibleItems = computed(() => {
|
||||
const items = ITEM_SPECS.slice(3).filter(item => {
|
||||
if (item.id === 'msd' && !showMsd.value) return false
|
||||
if (item.id === 'extension' && props.showTerminal === false) return false
|
||||
return true
|
||||
})
|
||||
return items
|
||||
@@ -339,7 +341,7 @@ const hasOverflow = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Extension Menu - Adaptive -->
|
||||
<div v-if="isVisible('extension')">
|
||||
<div v-if="props.showTerminal !== false && isVisible('extension')">
|
||||
<Popover v-model:open="extensionOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
@@ -449,7 +451,7 @@ const hasOverflow = computed(() => {
|
||||
{{ t('actionbar.paste') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator v-if="(!isVisible('msd') || !isVisible('atx') || !isVisible('paste')) && (!isVisible('stats') || !isVisible('extension') || !isVisible('settings'))" />
|
||||
<DropdownMenuSeparator v-if="(!isVisible('msd') || !isVisible('atx') || !isVisible('paste')) && (!isVisible('stats') || (props.showTerminal !== false && !isVisible('extension')) || !isVisible('settings'))" />
|
||||
|
||||
<!-- Stats -->
|
||||
<DropdownMenuItem v-if="!isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
@@ -459,7 +461,7 @@ const hasOverflow = computed(() => {
|
||||
|
||||
<!-- Extension -->
|
||||
<DropdownMenuItem
|
||||
v-if="!isVisible('extension')"
|
||||
v-if="props.showTerminal !== false && !isVisible('extension')"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||
>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@/api'
|
||||
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
|
||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -782,7 +783,7 @@ watch(
|
||||
:value="device.path"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ device.name }} ({{ device.path }})
|
||||
{{ formatVideoDeviceLabel(device) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -274,8 +274,10 @@ export default {
|
||||
extensionsDescription: 'Choose which extensions to auto-start',
|
||||
ttydTitle: 'Web Terminal (ttyd)',
|
||||
ttydDescription: 'Access device command line in browser',
|
||||
ttydWindowsUnsupportedDescription: 'ttyd Web Terminal is not supported on Windows and will not be enabled.',
|
||||
extensionsHint: 'These settings can be changed later in Settings',
|
||||
notInstalled: 'Not installed',
|
||||
notSupportedOnWindows: 'Unsupported on Windows',
|
||||
passwordStrength: 'Password Strength',
|
||||
passwordWeak: 'Weak',
|
||||
passwordMedium: 'Medium',
|
||||
@@ -969,13 +971,16 @@ export default {
|
||||
desc: 'NAT traversal via GOSTC',
|
||||
addr: 'Server Address',
|
||||
addrPlaceholder: 'Hostname or IP (required)',
|
||||
addrRequired: 'Enter the GOSTC server address',
|
||||
key: 'Client Key',
|
||||
keyRequired: 'Enter the GOSTC client key',
|
||||
tls: 'Enable TLS',
|
||||
},
|
||||
easytier: {
|
||||
title: 'Easytier Network',
|
||||
desc: 'P2P VPN networking via EasyTier',
|
||||
networkName: 'Network Name',
|
||||
networkNameRequired: 'Enter the EasyTier network name',
|
||||
networkSecret: 'Network Secret',
|
||||
peers: 'Peer Nodes',
|
||||
addPeer: 'Add Peer',
|
||||
@@ -989,6 +994,7 @@ export default {
|
||||
rendezvousServer: 'ID Server',
|
||||
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
||||
rendezvousServerHint: 'Configure your RustDesk server address (port optional, defaults to 21116)',
|
||||
rendezvousServerRequired: 'Enter the RustDesk ID server',
|
||||
relayServer: 'Relay Server',
|
||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
|
||||
|
||||
@@ -274,8 +274,10 @@ export default {
|
||||
extensionsDescription: '选择要自动启动的扩展服务',
|
||||
ttydTitle: 'Web 终端 (ttyd)',
|
||||
ttydDescription: '在浏览器中访问设备的命令行终端',
|
||||
ttydWindowsUnsupportedDescription: 'Windows 平台暂不支持 ttyd Web 终端,此扩展不会启用。',
|
||||
extensionsHint: '这些设置可以在设置页面中随时更改',
|
||||
notInstalled: '未安装',
|
||||
notSupportedOnWindows: 'Windows 不支持',
|
||||
passwordStrength: '密码强度',
|
||||
passwordWeak: '弱',
|
||||
passwordMedium: '中',
|
||||
@@ -968,13 +970,16 @@ export default {
|
||||
desc: '通过 GOSTC 实现内网穿透',
|
||||
addr: '服务器地址',
|
||||
addrPlaceholder: '主机名或 IP(必填)',
|
||||
addrRequired: '请填写 GOSTC 服务器地址',
|
||||
key: '客户端密钥',
|
||||
keyRequired: '请填写 GOSTC 客户端密钥',
|
||||
tls: '启用 TLS',
|
||||
},
|
||||
easytier: {
|
||||
title: 'Easytier 组网',
|
||||
desc: '通过 EasyTier 实现 P2P VPN 组网',
|
||||
networkName: '网络名称',
|
||||
networkNameRequired: '请填写 EasyTier 网络名称',
|
||||
networkSecret: '网络密钥',
|
||||
peers: '对等节点',
|
||||
addPeer: '添加节点',
|
||||
@@ -988,6 +993,7 @@ export default {
|
||||
rendezvousServer: 'ID 服务器',
|
||||
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
||||
rendezvousServerHint: '请配置您的 RustDesk 服务器地址(端口可省略,默认 21116)',
|
||||
rendezvousServerRequired: '请填写 RustDesk ID 服务器',
|
||||
relayServer: '中继服务器',
|
||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||
relayServerHint: '中继服务器地址(端口可省略,默认 21117),留空则自动从 ID 服务器推导',
|
||||
|
||||
51
web/src/lib/video-device-label.ts
Normal file
51
web/src/lib/video-device-label.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface VideoDeviceLabelSource {
|
||||
name?: string | null
|
||||
path: string
|
||||
}
|
||||
|
||||
const WINDOWS_USB_ID_RE = /vid[_-]([0-9a-f]{4}).*pid[_-]([0-9a-f]{4})/i
|
||||
const WINDOWS_SYMBOLIC_LINK_RE = /^\\\\\?\\/
|
||||
const WINDOWS_DIRECTSHOW_RE = /^dshow:/i
|
||||
|
||||
function shortDevicePath(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const parts = normalized.split('/').filter(Boolean)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
function isWindowsSymbolicLink(path: string): boolean {
|
||||
return WINDOWS_SYMBOLIC_LINK_RE.test(path)
|
||||
}
|
||||
|
||||
function isWindowsDirectShowPath(path: string): boolean {
|
||||
return WINDOWS_DIRECTSHOW_RE.test(path)
|
||||
}
|
||||
|
||||
function cleanWindowsDirectShowLabel(value: string): string {
|
||||
return value.replace(WINDOWS_DIRECTSHOW_RE, '').trim()
|
||||
}
|
||||
|
||||
function fallbackDeviceName(path: string): string {
|
||||
if (isWindowsDirectShowPath(path)) return cleanWindowsDirectShowLabel(path) || 'Windows Capture Device'
|
||||
if (isWindowsSymbolicLink(path)) return 'Windows Capture Device'
|
||||
return shortDevicePath(path)
|
||||
}
|
||||
|
||||
export function formatVideoDeviceLabel(device: VideoDeviceLabelSource): string {
|
||||
const path = device.path.trim()
|
||||
const rawName = device.name?.trim() || fallbackDeviceName(path)
|
||||
const name = isWindowsDirectShowPath(rawName) ? cleanWindowsDirectShowLabel(rawName) : rawName
|
||||
const usbId = path.match(WINDOWS_USB_ID_RE)
|
||||
|
||||
if (usbId?.[1] && usbId[2]) {
|
||||
return `${name} (${usbId[1].toLowerCase()}:${usbId[2].toLowerCase()})`
|
||||
}
|
||||
|
||||
if (!path) return name
|
||||
|
||||
if (isWindowsDirectShowPath(path)) return cleanWindowsDirectShowLabel(name) || fallbackDeviceName(path)
|
||||
|
||||
if (isWindowsSymbolicLink(path)) return name
|
||||
|
||||
return `${name} (${shortDevicePath(path)})`
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo } from '@/api'
|
||||
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo, type PlatformCapabilities } from '@/api'
|
||||
|
||||
interface SystemCapabilities {
|
||||
video: { available: boolean; backend?: string }
|
||||
hid: { available: boolean; backend?: string }
|
||||
msd: { available: boolean }
|
||||
atx: { available: boolean; backend?: string }
|
||||
audio: { available: boolean; backend?: string }
|
||||
video: { available: boolean; backend?: string; reason?: string }
|
||||
hid: { available: boolean; backend?: string; reason?: string }
|
||||
msd: { available: boolean; backend?: string; reason?: string }
|
||||
atx: { available: boolean; backend?: string; reason?: string }
|
||||
audio: { available: boolean; backend?: string; reason?: string }
|
||||
}
|
||||
|
||||
interface DiskSpaceInfo {
|
||||
@@ -149,6 +149,7 @@ export interface DeviceInfoEvent {
|
||||
export const useSystemStore = defineStore('system', () => {
|
||||
const version = ref<string>('')
|
||||
const buildDate = ref<string>('')
|
||||
const platform = ref<PlatformCapabilities | null>(null)
|
||||
const capabilities = ref<SystemCapabilities | null>(null)
|
||||
const diskSpace = ref<DiskSpaceInfo | null>(null)
|
||||
const deviceInfo = ref<DeviceInfo | null>(null)
|
||||
@@ -171,6 +172,7 @@ export const useSystemStore = defineStore('system', () => {
|
||||
const info = await systemApi.info()
|
||||
version.value = info.version
|
||||
buildDate.value = info.build_date
|
||||
platform.value = info.platform
|
||||
capabilities.value = info.capabilities
|
||||
diskSpace.value = info.disk_space ?? null
|
||||
deviceInfo.value = info.device_info ?? null
|
||||
@@ -424,6 +426,7 @@ export const useSystemStore = defineStore('system', () => {
|
||||
return {
|
||||
version,
|
||||
buildDate,
|
||||
platform,
|
||||
capabilities,
|
||||
diskSpace,
|
||||
deviceInfo,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { toast } from 'vue-sonner'
|
||||
import { cn, generateUUID } from '@/lib/utils'
|
||||
import { formatFpsValue } from '@/lib/fps'
|
||||
import { videoDebugLog } from '@/lib/debugLog'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
|
||||
import type { StreamDeviceLostEventData } from '@/types/websocket'
|
||||
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
||||
@@ -170,6 +171,7 @@ const changingPassword = ref(false)
|
||||
|
||||
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
|
||||
const showTerminalDialog = ref(false)
|
||||
const showTerminal = computed(() => ttydStatus.value?.available !== false)
|
||||
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
@@ -233,7 +235,7 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
||||
: 'error'
|
||||
|
||||
const details: StatusDetail[] = [
|
||||
{ label: t('statusCard.device'), value: stream.device || '-' },
|
||||
{ label: t('statusCard.device'), value: stream.device ? formatVideoDeviceLabel({ path: stream.device }) : '-' },
|
||||
{ label: t('statusCard.format'), value: formatDisplay },
|
||||
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
|
||||
{ label: t('statusCard.fpsTarget'), value: targetFpsValue },
|
||||
@@ -1865,11 +1867,14 @@ async function handleChangePassword() {
|
||||
}
|
||||
|
||||
function openTerminal() {
|
||||
if (!showTerminal.value) return
|
||||
if (!ttydStatus.value?.running) return
|
||||
showTerminalDialog.value = true
|
||||
}
|
||||
|
||||
function openTerminalInNewTab() {
|
||||
if (!showTerminal.value) return
|
||||
if (!ttydStatus.value?.running) return
|
||||
window.open('/api/terminal/', '_blank')
|
||||
}
|
||||
|
||||
@@ -2497,6 +2502,7 @@ onMounted(async () => {
|
||||
}, { immediate: true })
|
||||
|
||||
await systemStore.startStream().catch(() => {})
|
||||
await systemStore.fetchSystemInfo().catch(() => {})
|
||||
await systemStore.fetchAllStates()
|
||||
await configStore.refreshHid().then(() => {
|
||||
syncMouseModeFromConfig()
|
||||
@@ -2688,6 +2694,7 @@ onUnmounted(() => {
|
||||
:mouse-mode="mouseMode"
|
||||
:video-mode="videoMode"
|
||||
:ttyd-running="ttydStatus?.running"
|
||||
:show-terminal="showTerminal"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@toggle-stats="statsSheetOpen = true"
|
||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||
@@ -2929,7 +2936,7 @@ onUnmounted(() => {
|
||||
:ws-latency="0"
|
||||
:webrtc-stats="webrtc.stats.value"
|
||||
/>
|
||||
<Dialog v-model:open="showTerminalDialog">
|
||||
<Dialog v-if="showTerminal" v-model:open="showTerminalDialog">
|
||||
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
|
||||
<DialogTitle class="flex items-center justify-between w-full">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -42,6 +43,7 @@ import type {
|
||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getVideoFormatState } from '@/lib/video-format-support'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -109,6 +111,8 @@ const systemStore = useSystemStore()
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isWindows = computed(() => systemStore.platform?.mode === 'windows')
|
||||
|
||||
const activeSection = ref('appearance')
|
||||
const mobileMenuOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -203,6 +207,12 @@ function normalizeSettingsSection(value: unknown): string | null {
|
||||
return SETTINGS_SECTION_IDS.has(value) ? value : null
|
||||
}
|
||||
|
||||
function ensureVisibleSection() {
|
||||
if (!SETTINGS_SECTION_IDS.has(activeSection.value)) {
|
||||
activeSection.value = 'appearance'
|
||||
}
|
||||
}
|
||||
|
||||
const theme = ref<'light' | 'dark' | 'system'>('system')
|
||||
|
||||
const usernameInput = ref('')
|
||||
@@ -247,6 +257,17 @@ const extConfig = ref({
|
||||
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
||||
})
|
||||
|
||||
const gostcValidationMessage = computed(() => {
|
||||
if (!extConfig.value.gostc.addr?.trim()) return t('extensions.gostc.addrRequired')
|
||||
if (!extConfig.value.gostc.key) return t('extensions.gostc.keyRequired')
|
||||
return ''
|
||||
})
|
||||
|
||||
const easytierValidationMessage = computed(() => {
|
||||
if (!extConfig.value.easytier.network_name?.trim()) return t('extensions.easytier.networkNameRequired')
|
||||
return ''
|
||||
})
|
||||
|
||||
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
|
||||
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
|
||||
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
|
||||
@@ -260,6 +281,13 @@ const rustdeskLocalConfig = ref({
|
||||
relay_key: '',
|
||||
})
|
||||
|
||||
const rustdeskValidationMessage = computed(() => {
|
||||
if (!rustdeskLocalConfig.value.rendezvous_server?.trim()) {
|
||||
return t('extensions.rustdesk.rendezvousServerRequired')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const rtspStatus = ref<RtspStatusResponse | null>(null)
|
||||
const rtspLoading = ref(false)
|
||||
const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
|
||||
@@ -871,6 +899,18 @@ const ch9329ReservedSerialDevice = computed(() => {
|
||||
return config.value.hid_serial_device.trim()
|
||||
})
|
||||
|
||||
const atxDriverOptions = computed(() => {
|
||||
const options = [
|
||||
{ value: 'none' as AtxDriverType, label: t('settings.atxDriverNone') },
|
||||
{ value: 'gpio' as AtxDriverType, label: t('settings.atxDriverGpio') },
|
||||
{ value: 'usbrelay' as AtxDriverType, label: t('settings.atxDriverUsbRelay') },
|
||||
{ value: 'serial' as AtxDriverType, label: t('settings.atxDriverSerial') },
|
||||
]
|
||||
return isWindows.value
|
||||
? options.filter(option => ['none', 'serial'].includes(option.value))
|
||||
: options
|
||||
})
|
||||
|
||||
const isSharedAtxSerialRelay = computed(() => {
|
||||
const power = atxConfig.value.power
|
||||
const reset = atxConfig.value.reset
|
||||
@@ -1005,6 +1045,19 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const hasDeviceCpuUsage = computed(() => {
|
||||
return !!systemStore.deviceInfo
|
||||
})
|
||||
|
||||
const hasDeviceMemoryUsage = computed(() => {
|
||||
const info = systemStore.deviceInfo
|
||||
return !!info && info.memory_total > 0
|
||||
})
|
||||
|
||||
const hasDeviceNetworkAddresses = computed(() => {
|
||||
return (systemStore.deviceInfo?.network_addresses.length ?? 0) > 0
|
||||
})
|
||||
|
||||
function setTheme(newTheme: 'light' | 'dark' | 'system') {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
@@ -1272,6 +1325,8 @@ async function loadExtensions() {
|
||||
}
|
||||
|
||||
async function startExtension(id: 'ttyd' | 'gostc' | 'easytier') {
|
||||
if ((id === 'gostc' || id === 'easytier') && !validateExtensionConfig(id)) return
|
||||
|
||||
try {
|
||||
await extensionsApi.start(id)
|
||||
await loadExtensions()
|
||||
@@ -1299,6 +1354,8 @@ async function refreshExtensionLogs(id: 'ttyd' | 'gostc' | 'easytier') {
|
||||
}
|
||||
|
||||
async function saveExtensionConfig(id: 'ttyd' | 'gostc' | 'easytier') {
|
||||
if ((id === 'gostc' || id === 'easytier') && extConfig.value[id].enabled && !validateExtensionConfig(id)) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (id === 'ttyd') {
|
||||
@@ -1548,6 +1605,26 @@ function normalizeRustdeskRelayKey(value: string): string | undefined {
|
||||
return cleaned || undefined
|
||||
}
|
||||
|
||||
function showValidationError(message: string): boolean {
|
||||
toast.error(t('api.operationFailed'), {
|
||||
description: message,
|
||||
duration: 4000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
function validateExtensionConfig(id: 'gostc' | 'easytier'): boolean {
|
||||
const message = id === 'gostc'
|
||||
? gostcValidationMessage.value
|
||||
: easytierValidationMessage.value
|
||||
|
||||
return !message || showValidationError(message)
|
||||
}
|
||||
|
||||
function validateRustdeskConfig(): boolean {
|
||||
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value)
|
||||
}
|
||||
|
||||
function normalizeRtspPath(path: string): string {
|
||||
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
||||
}
|
||||
@@ -1864,6 +1941,8 @@ function updateStatusBadgeText(): string {
|
||||
}
|
||||
|
||||
async function saveRustdeskConfig() {
|
||||
if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return
|
||||
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
@@ -1918,6 +1997,8 @@ async function regenerateRustdeskPassword() {
|
||||
}
|
||||
|
||||
async function startRustdesk() {
|
||||
if (!validateRustdeskConfig()) return
|
||||
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
await configStore.updateRustdesk({ enabled: true })
|
||||
@@ -2119,6 +2200,7 @@ onMounted(async () => {
|
||||
refreshUpdateStatus(),
|
||||
fetchUsbDevices(),
|
||||
])
|
||||
ensureVisibleSection()
|
||||
usernameInput.value = authStore.user || ''
|
||||
|
||||
if (updateRunning.value) {
|
||||
@@ -2141,6 +2223,10 @@ watch(() => route.query.tab, (tab) => {
|
||||
selectSection(section)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(isWindows, () => {
|
||||
ensureVisibleSection()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -2370,7 +2456,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<Label for="video-device">{{ t('settings.videoDevice') }}</Label>
|
||||
<select id="video-device" v-model="config.video_device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="">{{ t('settings.selectDevice') }}</option>
|
||||
<option v-for="dev in devices.video" :key="dev.path" :value="dev.path">{{ dev.name }} ({{ dev.path }})</option>
|
||||
<option v-for="dev in devices.video" :key="dev.path" :value="dev.path">{{ formatVideoDeviceLabel(dev) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -3369,10 +3455,9 @@ watch(() => route.query.tab, (tab) => {
|
||||
<div class="space-y-2">
|
||||
<Label for="power-driver">{{ t('settings.atxDriver') }}</Label>
|
||||
<select id="power-driver" v-model="atxConfig.power.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="none">{{ t('settings.atxDriverNone') }}</option>
|
||||
<option value="gpio">{{ t('settings.atxDriverGpio') }}</option>
|
||||
<option value="usbrelay">{{ t('settings.atxDriverUsbRelay') }}</option>
|
||||
<option value="serial">{{ t('settings.atxDriverSerial') }}</option>
|
||||
<option v-for="option in atxDriverOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -3433,10 +3518,9 @@ watch(() => route.query.tab, (tab) => {
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-driver">{{ t('settings.atxDriver') }}</Label>
|
||||
<select id="reset-driver" v-model="atxConfig.reset.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="none">{{ t('settings.atxDriverNone') }}</option>
|
||||
<option value="gpio">{{ t('settings.atxDriverGpio') }}</option>
|
||||
<option value="usbrelay">{{ t('settings.atxDriverUsbRelay') }}</option>
|
||||
<option value="serial">{{ t('settings.atxDriverSerial') }}</option>
|
||||
<option v-for="option in atxDriverOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -3495,7 +3579,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
</Card>
|
||||
|
||||
<!-- LED Sensing Config -->
|
||||
<Card v-if="atxConfig.enabled">
|
||||
<Card v-if="atxConfig.enabled && !isWindows">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.atxLedSensing') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.atxLedSensingDesc') }}</CardDescription>
|
||||
@@ -3583,7 +3667,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div v-if="!extensions?.ttyd?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
|
||||
{{ t('extensions.binaryNotFound', { path: '/usr/bin/ttyd' }) }}
|
||||
{{ t('extensions.binaryNotFound', { path: isWindows ? 'ttyd.win32.exe' : '/usr/bin/ttyd' }) }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Status and controls -->
|
||||
@@ -3632,7 +3716,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.ttyd.shell') }}</Label>
|
||||
<Input v-model="extConfig.ttyd.shell" class="sm:col-span-3" placeholder="/bin/bash" :disabled="isExtRunning(extensions?.ttyd?.status)" />
|
||||
<Input v-model="extConfig.ttyd.shell" class="sm:col-span-3" :placeholder="isWindows ? 'cmd' : '/bin/bash'" :disabled="isExtRunning(extensions?.ttyd?.status)" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Logs -->
|
||||
@@ -3676,7 +3760,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div v-if="!extensions?.gostc?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
|
||||
{{ t('extensions.binaryNotFound', { path: '/usr/bin/gostc' }) }}
|
||||
{{ t('extensions.binaryNotFound', { path: isWindows ? 'gostc.exe' : '/usr/bin/gostc' }) }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Status and controls -->
|
||||
@@ -3690,7 +3774,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
v-if="!isExtRunning(extensions?.gostc?.status)"
|
||||
size="sm"
|
||||
@click="startExtension('gostc')"
|
||||
:disabled="extensionsLoading || !extConfig.gostc.key || !extConfig.gostc.addr?.trim()"
|
||||
:disabled="extensionsLoading || !!gostcValidationMessage"
|
||||
>
|
||||
<Play class="h-4 w-4 mr-1" />
|
||||
{{ t('extensions.start') }}
|
||||
@@ -3716,11 +3800,17 @@ watch(() => route.query.tab, (tab) => {
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
|
||||
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.gostc.addr" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
<p v-if="extConfig.gostc.enabled && !extConfig.gostc.addr?.trim()" class="text-xs text-destructive">{{ t('extensions.gostc.addrRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
|
||||
<Input v-model="extConfig.gostc.key" type="password" class="sm:col-span-3" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.gostc.key" type="password" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
<p v-if="extConfig.gostc.enabled && !extConfig.gostc.key" class="text-xs text-destructive">{{ t('extensions.gostc.keyRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.gostc.tls') }}</Label>
|
||||
@@ -3767,7 +3857,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div v-if="!extensions?.easytier?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
|
||||
{{ t('extensions.binaryNotFound', { path: '/usr/bin/easytier-core' }) }}
|
||||
{{ t('extensions.binaryNotFound', { path: isWindows ? 'easytier-core.exe' : '/usr/bin/easytier-core' }) }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Status and controls -->
|
||||
@@ -3781,7 +3871,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
v-if="!isExtRunning(extensions?.easytier?.status)"
|
||||
size="sm"
|
||||
@click="startExtension('easytier')"
|
||||
:disabled="extensionsLoading || !extConfig.easytier.network_name"
|
||||
:disabled="extensionsLoading || !!easytierValidationMessage"
|
||||
>
|
||||
<Play class="h-4 w-4 mr-1" />
|
||||
{{ t('extensions.start') }}
|
||||
@@ -3807,7 +3897,10 @@ watch(() => route.query.tab, (tab) => {
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.networkName') }}</Label>
|
||||
<Input v-model="extConfig.easytier.network_name" class="sm:col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="extConfig.easytier.network_name" :disabled="isExtRunning(extensions?.easytier?.status)" />
|
||||
<p v-if="extConfig.easytier.enabled && !extConfig.easytier.network_name?.trim()" class="text-xs text-destructive">{{ t('extensions.easytier.networkNameRequired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.easytier.networkSecret') }}</Label>
|
||||
@@ -4045,6 +4138,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
:placeholder="t('extensions.rustdesk.rendezvousServerPlaceholder')"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.rendezvousServerHint') }}</p>
|
||||
<p v-if="rustdeskLocalConfig.enabled && rustdeskValidationMessage" class="text-xs text-destructive">{{ rustdeskValidationMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
@@ -4249,24 +4343,21 @@ watch(() => route.query.tab, (tab) => {
|
||||
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.cpuModel') }}</span>
|
||||
<span class="text-sm font-medium truncate max-w-[60%] text-right">{{ systemStore.deviceInfo.cpu_model }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b">
|
||||
<div v-if="hasDeviceCpuUsage" class="flex justify-between items-center py-2 border-b">
|
||||
<span class="text-sm text-muted-foreground">{{ t('settings.cpuUsage') }}</span>
|
||||
<span class="text-sm font-medium">{{ systemStore.deviceInfo.cpu_usage.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b">
|
||||
<div v-if="hasDeviceMemoryUsage" class="flex justify-between items-center py-2 border-b">
|
||||
<span class="text-sm text-muted-foreground">{{ t('settings.memoryUsage') }}</span>
|
||||
<span class="text-sm font-medium">{{ formatBytes(systemStore.deviceInfo.memory_used) }} / {{ formatBytes(systemStore.deviceInfo.memory_total) }}</span>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div v-if="hasDeviceNetworkAddresses" class="py-2">
|
||||
<span class="text-sm text-muted-foreground">{{ t('settings.networkAddresses') }}</span>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div v-for="addr in systemStore.deviceInfo.network_addresses" :key="addr.interface" class="flex justify-between items-center text-sm">
|
||||
<span class="text-muted-foreground">{{ addr.interface }}</span>
|
||||
<code class="font-mono bg-muted px-2 py-0.5 rounded">{{ addr.ip }}</code>
|
||||
</div>
|
||||
<div v-if="systemStore.deviceInfo.network_addresses.length === 0" class="text-sm text-muted-foreground">
|
||||
{{ t('common.unknown') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
|
||||
import { configApi, streamApi, type EncoderBackendInfo, type PlatformCapabilities } from '@/api'
|
||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import BrandMark from '@/components/BrandMark.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -43,7 +44,6 @@ const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const step = ref(1)
|
||||
const totalSteps = 4
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const slideDirection = ref<'forward' | 'backward'>('forward')
|
||||
@@ -67,6 +67,10 @@ const videoFps = ref<number | null>(null)
|
||||
|
||||
const audioDevice = ref('')
|
||||
const audioEnabled = ref(true)
|
||||
const platform = ref<PlatformCapabilities | null>(null)
|
||||
const isWindows = computed(() => platform.value?.mode === 'windows')
|
||||
const audioSupported = computed(() => platform.value?.audio.available ?? true)
|
||||
const totalSteps = 4
|
||||
|
||||
const hidBackend = ref('ch9329')
|
||||
const ch9329Port = ref('')
|
||||
@@ -298,7 +302,7 @@ watch(videoDevice, (newDevice) => {
|
||||
}
|
||||
|
||||
// Auto-select matching audio device based on USB bus
|
||||
if (newDevice && audioEnabled.value) {
|
||||
if (newDevice && audioEnabled.value && audioSupported.value) {
|
||||
const video = devices.value.video.find((d) => d.path === newDevice)
|
||||
if (video?.usb_bus) {
|
||||
// Find audio device on the same USB bus
|
||||
@@ -356,6 +360,20 @@ watch(otgUdc, () => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const status = await authStore.checkSetupStatus()
|
||||
platform.value = status.platform
|
||||
if (isWindows.value) {
|
||||
hidBackend.value = 'ch9329'
|
||||
otgMsdEnabled.value = false
|
||||
}
|
||||
if (!audioSupported.value) {
|
||||
audioEnabled.value = false
|
||||
audioDevice.value = '__none__'
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await configApi.listDevices()
|
||||
devices.value = result
|
||||
@@ -370,13 +388,13 @@ onMounted(async () => {
|
||||
ch9329Port.value = result.serial[0].path
|
||||
}
|
||||
|
||||
if (result.udc.length > 0 && result.udc[0]) {
|
||||
if (!isWindows.value && result.udc.length > 0 && result.udc[0]) {
|
||||
otgUdc.value = result.udc[0].name
|
||||
}
|
||||
applyOtgDefaults()
|
||||
|
||||
// Auto-select audio device if available (and no video device to trigger watch)
|
||||
if (result.audio.length > 0 && !audioDevice.value) {
|
||||
if (audioSupported.value && result.audio.length > 0 && !audioDevice.value) {
|
||||
// Prefer HDMI audio device
|
||||
const hdmiAudio = result.audio.find((a) => a.is_hdmi)
|
||||
audioDevice.value = hdmiAudio?.name || result.audio[0]?.name || ''
|
||||
@@ -535,7 +553,7 @@ async function handleSetup() {
|
||||
setupData.encoder_backend = encoderBackend.value
|
||||
}
|
||||
|
||||
if (audioDevice.value && audioDevice.value !== '__none__') {
|
||||
if (audioSupported.value && audioDevice.value && audioDevice.value !== '__none__') {
|
||||
setupData.audio_device = audioDevice.value
|
||||
}
|
||||
|
||||
@@ -722,7 +740,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="dev in devices.video" :key="dev.path" :value="dev.path">
|
||||
{{ dev.name }} ({{ dev.path }})
|
||||
{{ formatVideoDeviceLabel(dev) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -801,7 +819,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</p>
|
||||
|
||||
<!-- Audio Device Selection -->
|
||||
<div class="space-y-2 pt-2 border-t">
|
||||
<div v-if="!isWindows" class="space-y-2 pt-2 border-t">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="audioDevice">{{ t('setup.audioDevice') }}</Label>
|
||||
<HoverCard>
|
||||
@@ -882,7 +900,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
<SelectItem value="ch9329">
|
||||
CH9329 ({{ t('setup.serialHid') }})
|
||||
</SelectItem>
|
||||
<SelectItem value="otg">USB OTG</SelectItem>
|
||||
<SelectItem v-if="!isWindows" value="otg">USB OTG</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -927,7 +945,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</div>
|
||||
|
||||
<!-- OTG Settings -->
|
||||
<div v-if="hidBackend === 'otg'" class="space-y-4 p-4 rounded-lg bg-muted/50">
|
||||
<div v-if="hidBackend === 'otg' && !isWindows" class="space-y-4 p-4 rounded-lg bg-muted/50">
|
||||
<div class="flex items-start gap-2 text-sm text-muted-foreground mb-2">
|
||||
<HelpCircle class="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<p>{{ t('setup.otgHelp') }}</p>
|
||||
|
||||
Reference in New Issue
Block a user