mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
1023 lines
35 KiB
Vue
1023 lines
35 KiB
Vue
<script setup lang="ts">
|
|
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, 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'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger,
|
|
} from '@/components/ui/hover-card'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Stepper, StepperItem, StepperSeparator, StepperTitle, StepperTrigger } from '@/components/ui/stepper'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
|
import {
|
|
Eye,
|
|
EyeOff,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
User,
|
|
Video,
|
|
Keyboard,
|
|
Check,
|
|
HelpCircle,
|
|
Puzzle,
|
|
RefreshCw,
|
|
AlertTriangle,
|
|
} from 'lucide-vue-next'
|
|
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
const authStore = useAuthStore()
|
|
|
|
const step = ref(1)
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
const slideDirection = ref<'forward' | 'backward'>('forward')
|
|
|
|
const username = ref('')
|
|
const password = ref('')
|
|
const confirmPassword = ref('')
|
|
const showPassword = ref(false)
|
|
|
|
const usernameError = ref('')
|
|
const passwordError = ref('')
|
|
const confirmPasswordError = ref('')
|
|
const usernameTouched = ref(false)
|
|
const passwordTouched = ref(false)
|
|
const confirmPasswordTouched = ref(false)
|
|
|
|
const videoDevice = ref('')
|
|
const videoFormat = ref('')
|
|
const videoResolution = ref('')
|
|
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('')
|
|
const ch9329Baudrate = ref(9600)
|
|
const otgUdc = ref('')
|
|
const hidOtgProfile = ref('full_no_consumer')
|
|
const otgMsdEnabled = ref(true)
|
|
const otgKeyboardLeds = ref(true)
|
|
|
|
const ttydEnabled = ref(false)
|
|
const ttydAvailable = ref(false)
|
|
|
|
// Encoder backend settings
|
|
const encoderBackend = ref('auto')
|
|
const availableBackends = ref<EncoderBackendInfo[]>([])
|
|
const showAdvancedEncoder = ref(false)
|
|
|
|
// Device info from API
|
|
interface VideoDeviceInfo {
|
|
path: string
|
|
name: string
|
|
driver: string
|
|
formats: Array<{
|
|
format: string
|
|
description: string
|
|
resolutions: Array<{
|
|
width: number
|
|
height: number
|
|
fps: number[]
|
|
}>
|
|
}>
|
|
usb_bus: string | null
|
|
has_signal: boolean
|
|
}
|
|
|
|
interface AudioDeviceInfo {
|
|
name: string
|
|
description: string
|
|
is_hdmi: boolean
|
|
usb_bus: string | null
|
|
}
|
|
|
|
interface DeviceInfo {
|
|
video: VideoDeviceInfo[]
|
|
serial: Array<{ path: string; name: string }>
|
|
audio: AudioDeviceInfo[]
|
|
udc: Array<{ name: string }>
|
|
extensions: {
|
|
ttyd_available: boolean
|
|
}
|
|
}
|
|
|
|
const devices = ref<DeviceInfo>({
|
|
video: [],
|
|
serial: [],
|
|
audio: [],
|
|
udc: [],
|
|
extensions: {
|
|
ttyd_available: false,
|
|
},
|
|
})
|
|
|
|
const passwordStrength = computed(() => {
|
|
const pwd = password.value
|
|
if (!pwd) return 0
|
|
let score = 0
|
|
if (pwd.length >= 4) score++
|
|
if (pwd.length >= 8) score++
|
|
if (/[A-Z]/.test(pwd) && /[a-z]/.test(pwd)) score++
|
|
if (/[0-9]/.test(pwd)) score++
|
|
if (/[^A-Za-z0-9]/.test(pwd)) score++
|
|
return Math.min(score, 4)
|
|
})
|
|
|
|
const passwordStrengthText = computed(() => {
|
|
const levels = [
|
|
'',
|
|
t('setup.passwordWeak'),
|
|
t('setup.passwordMedium'),
|
|
t('setup.passwordStrong'),
|
|
t('setup.passwordVeryStrong'),
|
|
]
|
|
return levels[passwordStrength.value] || ''
|
|
})
|
|
|
|
const passwordStrengthColor = computed(() => {
|
|
const colors = ['bg-muted', 'bg-destructive', 'bg-warning', 'bg-warning', 'bg-success']
|
|
return colors[passwordStrength.value] || colors[0]
|
|
})
|
|
|
|
// Whether the selected video device currently has an HDMI signal
|
|
const selectedDeviceHasSignal = computed(() => {
|
|
const device = devices.value.video.find((d) => d.path === videoDevice.value)
|
|
return device?.has_signal ?? true
|
|
})
|
|
|
|
const refreshingDevices = ref(false)
|
|
|
|
async function refreshDeviceList() {
|
|
refreshingDevices.value = true
|
|
try {
|
|
const result = await configApi.listDevices()
|
|
devices.value = result
|
|
if (result.extensions) {
|
|
ttydAvailable.value = result.extensions.ttyd_available
|
|
}
|
|
} catch {
|
|
} finally {
|
|
refreshingDevices.value = false
|
|
}
|
|
}
|
|
|
|
// Computed: available formats for selected video device
|
|
const availableFormats = computed(() => {
|
|
const device = devices.value.video.find((d) => d.path === videoDevice.value)
|
|
return device?.formats || []
|
|
})
|
|
|
|
const availableResolutions = computed(() => {
|
|
const format = availableFormats.value.find((f) => f.format === videoFormat.value)
|
|
return format?.resolutions || []
|
|
})
|
|
|
|
const availableFps = computed(() => {
|
|
const [width, height] = (videoResolution.value || '').split('x').map(Number)
|
|
const resolution = availableResolutions.value.find(
|
|
(r) => r.width === width && r.height === height
|
|
)
|
|
return resolution?.fps || []
|
|
})
|
|
|
|
function applyOtgDefaults() {
|
|
if (hidBackend.value !== 'otg') return
|
|
|
|
hidOtgProfile.value = 'full_no_consumer'
|
|
otgKeyboardLeds.value = true
|
|
}
|
|
|
|
const baudRates = [9600, 19200, 38400, 57600, 115200]
|
|
|
|
const stepLabels = computed(() => [
|
|
t('setup.stepAccount'),
|
|
t('setup.stepAudioVideo'),
|
|
t('setup.stepHid'),
|
|
t('setup.stepExtensions'),
|
|
])
|
|
|
|
function validateUsername() {
|
|
usernameTouched.value = true
|
|
if (username.value.length === 0) {
|
|
usernameError.value = ''
|
|
} else if (username.value.length < 2) {
|
|
usernameError.value = t('setup.usernameHint')
|
|
} else {
|
|
usernameError.value = ''
|
|
}
|
|
}
|
|
|
|
function validatePassword() {
|
|
passwordTouched.value = true
|
|
if (password.value.length === 0) {
|
|
passwordError.value = ''
|
|
} else if (password.value.length < 4) {
|
|
passwordError.value = t('setup.passwordHint')
|
|
} else {
|
|
passwordError.value = ''
|
|
}
|
|
if (confirmPasswordTouched.value) {
|
|
validateConfirmPassword()
|
|
}
|
|
}
|
|
|
|
function validateConfirmPassword() {
|
|
confirmPasswordTouched.value = true
|
|
if (confirmPassword.value.length === 0) {
|
|
confirmPasswordError.value = ''
|
|
} else if (confirmPassword.value !== password.value) {
|
|
confirmPasswordError.value = t('setup.passwordMismatch')
|
|
} else {
|
|
confirmPasswordError.value = ''
|
|
}
|
|
}
|
|
|
|
// Watch video device change to auto-select first format and matching audio device
|
|
watch(videoDevice, (newDevice) => {
|
|
videoFormat.value = ''
|
|
videoResolution.value = ''
|
|
videoFps.value = null
|
|
if (availableFormats.value.length > 0) {
|
|
// Prefer MJPEG if available
|
|
const mjpeg = availableFormats.value.find((f) => f.format.toUpperCase().includes('MJPEG'))
|
|
videoFormat.value = mjpeg?.format || availableFormats.value[0]?.format || ''
|
|
}
|
|
|
|
// Auto-select matching audio device based on USB bus
|
|
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
|
|
const matchedAudio = devices.value.audio.find(
|
|
(a) => a.usb_bus && a.usb_bus === video.usb_bus
|
|
)
|
|
if (matchedAudio) {
|
|
audioDevice.value = matchedAudio.name
|
|
return
|
|
}
|
|
}
|
|
// Fallback: select first HDMI audio device
|
|
const hdmiAudio = devices.value.audio.find((a) => a.is_hdmi)
|
|
if (hdmiAudio) {
|
|
audioDevice.value = hdmiAudio.name
|
|
} else if (devices.value.audio.length > 0 && devices.value.audio[0]) {
|
|
audioDevice.value = devices.value.audio[0].name
|
|
}
|
|
}
|
|
})
|
|
|
|
watch(videoFormat, () => {
|
|
videoResolution.value = ''
|
|
videoFps.value = null
|
|
if (availableResolutions.value.length > 0) {
|
|
const r1080 = availableResolutions.value.find((r) => r.width === 1920 && r.height === 1080)
|
|
const r720 = availableResolutions.value.find((r) => r.width === 1280 && r.height === 720)
|
|
const best = r1080 || r720 || availableResolutions.value[0]
|
|
if (best) {
|
|
videoResolution.value = `${best.width}x${best.height}`
|
|
}
|
|
}
|
|
})
|
|
|
|
watch(videoResolution, () => {
|
|
videoFps.value = null
|
|
if (availableFps.value.length > 0) {
|
|
videoFps.value = availableFps.value.includes(30) ? 30 : availableFps.value[0] || null
|
|
}
|
|
})
|
|
|
|
// Watch HID backend change to set defaults
|
|
watch(hidBackend, (newBackend) => {
|
|
if (newBackend === 'ch9329' && !ch9329Port.value && devices.value.serial.length > 0) {
|
|
ch9329Port.value = devices.value.serial[0]?.path || ''
|
|
}
|
|
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
|
|
otgUdc.value = devices.value.udc[0]?.name || ''
|
|
}
|
|
applyOtgDefaults()
|
|
})
|
|
|
|
watch(otgUdc, () => {
|
|
applyOtgDefaults()
|
|
})
|
|
|
|
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
|
|
|
|
// Auto-select first video device
|
|
if (result.video.length > 0 && result.video[0]) {
|
|
videoDevice.value = result.video[0].path
|
|
}
|
|
|
|
// Auto-select first serial device for CH9329
|
|
if (result.serial.length > 0 && result.serial[0]) {
|
|
ch9329Port.value = result.serial[0].path
|
|
}
|
|
|
|
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 (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 || ''
|
|
}
|
|
|
|
// Set extension availability from devices API
|
|
if (result.extensions) {
|
|
ttydAvailable.value = result.extensions.ttyd_available
|
|
}
|
|
} catch {
|
|
}
|
|
|
|
// Load encoder backends
|
|
try {
|
|
const codecsResult = await streamApi.getCodecs()
|
|
availableBackends.value = codecsResult.backends || []
|
|
} catch {
|
|
}
|
|
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleKeyDown)
|
|
})
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
return
|
|
}
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
if (step.value < totalSteps) {
|
|
nextStep()
|
|
} else {
|
|
handleSetup()
|
|
}
|
|
}
|
|
if (e.key === 'Escape' && step.value > 1) {
|
|
e.preventDefault()
|
|
prevStep()
|
|
}
|
|
}
|
|
|
|
function validateStep1(): boolean {
|
|
validateUsername()
|
|
validatePassword()
|
|
validateConfirmPassword()
|
|
|
|
if (username.value.length < 2) {
|
|
error.value = t('setup.usernameHint')
|
|
return false
|
|
}
|
|
if (password.value.length < 4) {
|
|
error.value = t('setup.passwordHint')
|
|
return false
|
|
}
|
|
if (password.value !== confirmPassword.value) {
|
|
error.value = t('setup.passwordMismatch')
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function validateStep2(): boolean {
|
|
// Video settings are optional, but if device is selected, format should be too
|
|
if (videoDevice.value && !videoFormat.value) {
|
|
error.value = t('setup.selectFormat')
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function validateStep3(): boolean {
|
|
if (hidBackend.value === 'ch9329' && !ch9329Port.value) {
|
|
error.value = t('setup.selectSerialPort')
|
|
return false
|
|
}
|
|
if (hidBackend.value === 'otg' && !otgUdc.value) {
|
|
error.value = t('setup.selectUdc')
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function nextStep() {
|
|
error.value = ''
|
|
|
|
if (step.value === 1 && !validateStep1()) return
|
|
if (step.value === 2 && !validateStep2()) return
|
|
|
|
if (step.value < totalSteps) {
|
|
slideDirection.value = 'forward'
|
|
step.value++
|
|
}
|
|
}
|
|
|
|
function prevStep() {
|
|
error.value = ''
|
|
if (step.value > 1) {
|
|
slideDirection.value = 'backward'
|
|
step.value--
|
|
}
|
|
}
|
|
|
|
async function handleSetup() {
|
|
error.value = ''
|
|
|
|
if (!validateStep3()) return
|
|
|
|
loading.value = true
|
|
|
|
const [width, height] = (videoResolution.value || '').split('x').map(Number)
|
|
|
|
const setupData: Parameters<typeof authStore.setup>[0] = {
|
|
username: username.value,
|
|
password: password.value,
|
|
}
|
|
|
|
if (videoDevice.value) {
|
|
setupData.video_device = videoDevice.value
|
|
}
|
|
if (videoFormat.value) {
|
|
setupData.video_format = videoFormat.value
|
|
}
|
|
if (width && height) {
|
|
setupData.video_width = width
|
|
setupData.video_height = height
|
|
}
|
|
if (videoFps.value) {
|
|
setupData.video_fps = toConfigFps(videoFps.value)
|
|
}
|
|
|
|
setupData.hid_backend = hidBackend.value
|
|
if (hidBackend.value === 'ch9329') {
|
|
setupData.hid_ch9329_port = ch9329Port.value
|
|
setupData.hid_ch9329_baudrate = ch9329Baudrate.value
|
|
}
|
|
if (hidBackend.value === 'otg' && otgUdc.value) {
|
|
setupData.hid_otg_udc = otgUdc.value
|
|
setupData.hid_otg_profile = hidOtgProfile.value
|
|
setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value
|
|
setupData.msd_enabled = otgMsdEnabled.value
|
|
}
|
|
|
|
// Encoder backend setting
|
|
if (encoderBackend.value !== 'auto') {
|
|
setupData.encoder_backend = encoderBackend.value
|
|
}
|
|
|
|
if (audioSupported.value && audioDevice.value && audioDevice.value !== '__none__') {
|
|
setupData.audio_device = audioDevice.value
|
|
}
|
|
|
|
setupData.ttyd_enabled = ttydEnabled.value
|
|
|
|
const success = await authStore.setup(setupData)
|
|
|
|
if (success) {
|
|
await authStore.login(username.value, password.value)
|
|
router.push('/')
|
|
} else {
|
|
error.value = authStore.error || t('setup.setupFailed')
|
|
}
|
|
|
|
loading.value = false
|
|
}
|
|
|
|
const stepIcons = [User, Video, Keyboard, Puzzle]
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen min-h-dvh flex items-start sm:items-center justify-center bg-background px-4 py-6 sm:py-10">
|
|
<Card class="w-full max-w-lg relative">
|
|
<!-- Language Switcher -->
|
|
<div class="absolute top-4 right-4">
|
|
<LanguageToggleButton />
|
|
</div>
|
|
|
|
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
|
|
<div class="mx-auto flex justify-center">
|
|
<BrandMark size="xl" />
|
|
</div>
|
|
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
|
|
<CardDescription>{{ t('setup.description') }}</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent class="space-y-5 sm:space-y-6">
|
|
<!-- Progress Text -->
|
|
<p class="text-sm text-muted-foreground text-center">
|
|
{{ t('setup.progress', { current: step, total: totalSteps }) }}
|
|
</p>
|
|
|
|
<Stepper :model-value="step" class="mb-5 flex w-full items-start gap-1 sm:mb-6 sm:gap-2">
|
|
<StepperItem
|
|
v-for="i in totalSteps"
|
|
:key="i"
|
|
v-slot="{ state }"
|
|
:step="i"
|
|
class="relative flex w-full flex-col items-center justify-center"
|
|
>
|
|
<StepperSeparator
|
|
v-if="i < totalSteps"
|
|
class="absolute left-[calc(50%+18px)] right-[calc(-50%+8px)] top-5 h-0.5 rounded-full group-data-[state=completed]:bg-primary"
|
|
/>
|
|
<StepperTrigger as-child>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
:variant="state === 'completed' || state === 'active' ? 'default' : 'outline'"
|
|
class="z-10 size-9 shrink-0 rounded-full disabled:opacity-100 sm:size-10"
|
|
:class="state === 'active' && 'ring-2 ring-ring ring-offset-2 ring-offset-background'"
|
|
disabled
|
|
>
|
|
<Check v-if="state === 'completed'" class="size-4 sm:size-5" />
|
|
<component :is="stepIcons[i - 1]" v-else class="size-4 sm:size-5" />
|
|
</Button>
|
|
</StepperTrigger>
|
|
<StepperTitle class="mt-1 max-w-14 whitespace-normal text-center text-[10px] leading-tight sm:max-w-16 sm:text-xs" :class="state === 'active' ? 'text-foreground' : 'text-muted-foreground'">
|
|
{{ stepLabels[i - 1] }}
|
|
</StepperTitle>
|
|
</StepperItem>
|
|
</Stepper>
|
|
|
|
<!-- Step Content with Animation -->
|
|
<Transition :name="slideDirection === 'forward' ? 'slide-forward' : 'slide-backward'" mode="out-in">
|
|
<!-- Step 1: Account Setup -->
|
|
<div v-if="step === 1" key="step1" class="space-y-4">
|
|
<h3 class="text-lg font-medium text-center">{{ t('setup.stepAccount') }}</h3>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="username" :class="{ 'text-destructive': usernameError }">
|
|
{{ t('setup.setUsername') }}
|
|
</Label>
|
|
<div class="relative">
|
|
<User
|
|
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
id="username"
|
|
v-model="username"
|
|
type="text"
|
|
autocomplete="username"
|
|
:placeholder="t('setup.usernameHint')"
|
|
class="pl-10"
|
|
:class="{ 'border-destructive focus-visible:ring-destructive': usernameError }"
|
|
@blur="validateUsername"
|
|
@input="usernameTouched && validateUsername()"
|
|
/>
|
|
</div>
|
|
<p v-if="usernameError" class="text-xs text-destructive">{{ usernameError }}</p>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="password" :class="{ 'text-destructive': passwordError }">
|
|
{{ t('setup.setPassword') }}
|
|
</Label>
|
|
<div class="relative">
|
|
<Input
|
|
id="password"
|
|
v-model="password"
|
|
:type="showPassword ? 'text' : 'password'"
|
|
autocomplete="new-password"
|
|
:placeholder="t('setup.passwordHint')"
|
|
class="pr-10"
|
|
:class="{ 'border-destructive focus-visible:ring-destructive': passwordError }"
|
|
@blur="validatePassword"
|
|
@input="passwordTouched && validatePassword()"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
class="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
:aria-label="showPassword ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
|
|
@click="showPassword = !showPassword"
|
|
>
|
|
<Eye v-if="!showPassword" class="w-4 h-4" />
|
|
<EyeOff v-else class="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<p v-if="passwordError" class="text-xs text-destructive">{{ passwordError }}</p>
|
|
|
|
<!-- Password Strength Indicator -->
|
|
<div v-if="password.length > 0" class="space-y-1">
|
|
<div class="flex gap-1 h-1">
|
|
<div
|
|
v-for="i in 4"
|
|
:key="i"
|
|
class="flex-1 rounded-full transition-colors duration-300"
|
|
:class="i <= passwordStrength ? passwordStrengthColor : 'bg-muted'"
|
|
/>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ t('setup.passwordStrength') }}: {{ passwordStrengthText }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="confirmPassword" :class="{ 'text-destructive': confirmPasswordError }">
|
|
{{ t('setup.confirmPassword') }}
|
|
</Label>
|
|
<Input
|
|
id="confirmPassword"
|
|
v-model="confirmPassword"
|
|
:type="showPassword ? 'text' : 'password'"
|
|
autocomplete="new-password"
|
|
:placeholder="t('setup.confirmPassword')"
|
|
:class="{ 'border-destructive focus-visible:ring-destructive': confirmPasswordError }"
|
|
@blur="validateConfirmPassword"
|
|
@input="confirmPasswordTouched && validateConfirmPassword()"
|
|
/>
|
|
<p v-if="confirmPasswordError" class="text-xs text-destructive">{{ confirmPasswordError }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Audio/Video Settings -->
|
|
<div v-else-if="step === 2" key="step2" class="space-y-4">
|
|
<h3 class="text-lg font-medium text-center">{{ t('setup.stepAudioVideo') }}</h3>
|
|
|
|
<div class="space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<Label for="videoDevice">{{ t('setup.videoDevice') }}</Label>
|
|
<HoverCard>
|
|
<HoverCardTrigger as-child>
|
|
<Button type="button" variant="ghost" size="icon-xs" class="text-muted-foreground" :aria-label="t('common.info')">
|
|
<HelpCircle class="w-4 h-4" />
|
|
</Button>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent class="w-64 text-sm">
|
|
{{ t('setup.videoDeviceHelp') }}
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</div>
|
|
<NativeSelect v-model="videoDevice" class="w-full">
|
|
<NativeSelectOption value="">{{ t('setup.selectVideoDevice') }}</NativeSelectOption>
|
|
<NativeSelectOption v-for="dev in devices.video" :key="dev.path" :value="dev.path">
|
|
{{ formatVideoDeviceLabel(dev) }}
|
|
</NativeSelectOption>
|
|
</NativeSelect>
|
|
</div>
|
|
|
|
<Alert v-if="videoDevice && !selectedDeviceHasSignal" variant="warning">
|
|
<AlertTriangle />
|
|
<AlertDescription class="flex items-center gap-3">
|
|
<p class="flex-1">{{ t('setup.noSignalDetected') }}</p>
|
|
<Button variant="outline" size="sm" :disabled="refreshingDevices" @click="refreshDeviceList">
|
|
<RefreshCw class="w-4 h-4 mr-1" :class="{ 'animate-spin': refreshingDevices }" />
|
|
{{ t('setup.refreshDevices') }}
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
<div v-if="videoDevice" class="space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<Label for="videoFormat">{{ t('setup.videoFormat') }}</Label>
|
|
<HoverCard>
|
|
<HoverCardTrigger as-child>
|
|
<Button type="button" variant="ghost" size="icon-xs" class="text-muted-foreground" :aria-label="t('common.info')">
|
|
<HelpCircle class="w-4 h-4" />
|
|
</Button>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent class="w-64 text-sm">
|
|
{{ t('setup.videoFormatHelp') }}
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</div>
|
|
<NativeSelect v-model="videoFormat" class="w-full">
|
|
<NativeSelectOption value="">{{ t('setup.selectFormat') }}</NativeSelectOption>
|
|
<NativeSelectOption v-for="fmt in availableFormats" :key="fmt.format" :value="fmt.format">
|
|
{{ fmt.format }} - {{ fmt.description }}
|
|
</NativeSelectOption>
|
|
</NativeSelect>
|
|
</div>
|
|
|
|
<div v-if="videoFormat" class="grid grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="videoResolution">{{ t('setup.resolution') }}</Label>
|
|
<NativeSelect v-model="videoResolution" class="w-full">
|
|
<NativeSelectOption value="">{{ t('setup.selectResolution') }}</NativeSelectOption>
|
|
<NativeSelectOption
|
|
v-for="res in availableResolutions"
|
|
:key="`${res.width}x${res.height}`"
|
|
:value="`${res.width}x${res.height}`"
|
|
>
|
|
{{ res.width }}x{{ res.height }}
|
|
</NativeSelectOption>
|
|
</NativeSelect>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="videoFps">{{ t('setup.fps') }}</Label>
|
|
<NativeSelect :model-value="videoFps" class="w-full" @update:model-value="value => videoFps = value === '' ? null : Number(value)">
|
|
<NativeSelectOption value="">{{ t('setup.selectFps') }}</NativeSelectOption>
|
|
<NativeSelectOption v-for="fps in availableFps" :key="fps" :value="fps">
|
|
{{ formatFpsLabel(fps) }}
|
|
</NativeSelectOption>
|
|
</NativeSelect>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="!devices.video.length" class="text-sm text-muted-foreground text-center py-4">
|
|
{{ t('setup.noVideoDevices') }}
|
|
</p>
|
|
|
|
<!-- Audio Device Selection -->
|
|
<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>
|
|
<HoverCardTrigger as-child>
|
|
<Button type="button" variant="ghost" size="icon-xs" class="text-muted-foreground" :aria-label="t('common.info')">
|
|
<HelpCircle class="w-4 h-4" />
|
|
</Button>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent class="w-64 text-sm">
|
|
{{ t('setup.audioDeviceHelp') }}
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</div>
|
|
<NativeSelect v-model="audioDevice" class="w-full" :disabled="!audioEnabled">
|
|
<NativeSelectOption value="">{{ t('setup.selectAudioDevice') }}</NativeSelectOption>
|
|
<NativeSelectOption value="__none__">{{ t('setup.noAudio') }}</NativeSelectOption>
|
|
<NativeSelectOption v-for="dev in devices.audio" :key="dev.name" :value="dev.name">{{ dev.description }}{{ dev.is_hdmi ? ' (HDMI)' : '' }}</NativeSelectOption>
|
|
</NativeSelect>
|
|
<p v-if="!devices.audio.length" class="text-xs text-muted-foreground">
|
|
{{ t('setup.noAudioDevices') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Advanced: Encoder Backend (Collapsible) -->
|
|
<div class="mt-4 border rounded-lg">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
class="h-auto w-full justify-between rounded-lg p-3 text-left"
|
|
:aria-label="t('setup.advancedEncoder')"
|
|
@click="showAdvancedEncoder = !showAdvancedEncoder"
|
|
>
|
|
<span class="text-sm font-medium">
|
|
{{ t('setup.advancedEncoder') }} ({{ t('common.optional') }})
|
|
</span>
|
|
<ChevronRight
|
|
class="h-4 w-4 transition-transform duration-200"
|
|
:class="{ 'rotate-90': showAdvancedEncoder }"
|
|
/>
|
|
</Button>
|
|
<div v-if="showAdvancedEncoder" class="px-3 pb-3 space-y-3">
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ t('setup.encoderHint') }}
|
|
</p>
|
|
<Select v-model="encoderBackend">
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="auto">{{ t('setup.autoRecommended') }}</SelectItem>
|
|
<SelectItem v-for="backend in availableBackends" :key="backend.id" :value="backend.id">
|
|
{{ backend.name }}
|
|
({{ backend.is_hardware ? t('setup.hardware') : t('setup.software') }})
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: HID Settings -->
|
|
<div v-else-if="step === 3" key="step3" class="space-y-4">
|
|
<h3 class="text-lg font-medium text-center">{{ t('setup.stepHid') }}</h3>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="hidBackend">{{ t('setup.hidBackend') }}</Label>
|
|
<Select v-model="hidBackend">
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ch9329">
|
|
CH9329 ({{ t('setup.serialHid') }})
|
|
</SelectItem>
|
|
<SelectItem v-if="!isWindows" value="otg">USB OTG</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<!-- CH9329 Settings -->
|
|
<div v-if="hidBackend === 'ch9329'" 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.ch9329Help') }}</p>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="ch9329Port">{{ t('setup.serialPort') }}</Label>
|
|
<NativeSelect v-model="ch9329Port" class="w-full">
|
|
<NativeSelectOption value="">{{ t('setup.selectSerialPort') }}</NativeSelectOption>
|
|
<NativeSelectOption v-for="port in devices.serial" :key="port.path" :value="port.path">
|
|
{{ port.name }} ({{ port.path }})
|
|
</NativeSelectOption>
|
|
</NativeSelect>
|
|
<p v-if="!devices.serial.length" class="text-xs text-muted-foreground">
|
|
{{ t('setup.noSerialDevices') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="ch9329Baudrate">{{ t('setup.baudRate') }}</Label>
|
|
<Select :model-value="ch9329Baudrate" @update:model-value="value => ch9329Baudrate = Number(value)">
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="rate in baudRates" :key="rate" :value="rate">
|
|
{{ rate }} bps
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- OTG Settings -->
|
|
<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>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="otgUdc">{{ t('setup.udc') }}</Label>
|
|
<NativeSelect v-model="otgUdc" class="w-full">
|
|
<NativeSelectOption value="">{{ t('setup.selectUdc') }}</NativeSelectOption>
|
|
<NativeSelectOption v-for="udc in devices.udc" :key="udc.name" :value="udc.name">
|
|
{{ udc.name }}
|
|
</NativeSelectOption>
|
|
</NativeSelect>
|
|
<p v-if="!devices.udc.length" class="text-xs text-muted-foreground">
|
|
{{ t('setup.noUdcDevices') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 4: Extensions Settings -->
|
|
<div v-else-if="step === 4" key="step4" class="space-y-4">
|
|
<h3 class="text-lg font-medium text-center">{{ t('setup.stepExtensions') }}</h3>
|
|
<p class="text-sm text-muted-foreground text-center">
|
|
{{ t('setup.extensionsDescription') }}
|
|
</p>
|
|
|
|
<!-- ttyd -->
|
|
<div class="flex items-center justify-between p-4 rounded-lg border" :class="{ 'opacity-50': !ttydAvailable }">
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<Label class="text-base font-medium">{{ t('setup.ttydTitle') }}</Label>
|
|
<span v-if="!ttydAvailable" class="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
|
{{ t('setup.notInstalled') }}
|
|
</span>
|
|
</div>
|
|
<p class="text-sm text-muted-foreground">
|
|
{{ t('setup.ttydDescription') }}
|
|
</p>
|
|
</div>
|
|
<Switch v-model="ttydEnabled" :disabled="!ttydAvailable" />
|
|
</div>
|
|
|
|
<p class="text-xs text-muted-foreground text-center pt-2">
|
|
{{ t('setup.extensionsHint') }}
|
|
</p>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Error Message -->
|
|
<Transition name="fade">
|
|
<Alert v-if="error" variant="destructive">
|
|
<AlertTriangle />
|
|
<AlertDescription>{{ error }}</AlertDescription>
|
|
</Alert>
|
|
</Transition>
|
|
|
|
<!-- Navigation Buttons -->
|
|
<div class="flex gap-3">
|
|
<Button v-if="step > 1" variant="outline" class="flex-1" @click="prevStep">
|
|
<ChevronLeft class="w-4 h-4 mr-2" />
|
|
{{ t('common.back') }}
|
|
</Button>
|
|
|
|
<Button v-if="step < totalSteps" class="flex-1" @click="nextStep">
|
|
{{ t('common.next') }}
|
|
<ChevronRight class="w-4 h-4 ml-2" />
|
|
</Button>
|
|
|
|
<Button v-if="step === totalSteps" class="flex-1" :disabled="loading" @click="handleSetup">
|
|
<span v-if="loading">{{ t('common.loading') }}</span>
|
|
<span v-else>{{ t('setup.complete') }}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Keyboard shortcuts hint -->
|
|
<p class="text-xs text-muted-foreground text-center">
|
|
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Enter</kbd>
|
|
{{ t('common.next') }}
|
|
<span v-if="step > 1" class="ml-2">
|
|
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Esc</kbd>
|
|
{{ t('common.back') }}
|
|
</span>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Forward slide animation (going to next step) */
|
|
.slide-forward-enter-active,
|
|
.slide-forward-leave-active {
|
|
transition: all 0.25s ease-out;
|
|
}
|
|
|
|
.slide-forward-enter-from {
|
|
opacity: 0;
|
|
transform: translateX(30px);
|
|
}
|
|
|
|
.slide-forward-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-30px);
|
|
}
|
|
|
|
/* Backward slide animation (going to previous step) */
|
|
.slide-backward-enter-active,
|
|
.slide-backward-leave-active {
|
|
transition: all 0.25s ease-out;
|
|
}
|
|
|
|
.slide-backward-enter-from {
|
|
opacity: 0;
|
|
transform: translateX(-30px);
|
|
}
|
|
|
|
.slide-backward-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(30px);
|
|
}
|
|
|
|
/* Fade animation for error messages */
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|