mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 02:54:26 +08:00
feat: CLI 改密、自定义 TLS、移动端适配与扩展校验
- 新增 one-kvm user set-password(交互式),改密后吊销该用户全部会话 - /api/config/web 支持 PEM 证书/密钥上传与清除,响应含 has_custom_cert - 移动端:ActionBar 溢出菜单、ATX/粘贴底部 Sheet、BrandMark 与控制台等响应式优化 - GOSTC:校验服务器地址非空,管理器启动条件与 HTTP 热更新一致 - RustDesk:中继密钥 relay_key 校验为标准 Base64 且解码后恰好 32 字节 - StatusCard、InfoBar:合并精简冗余状态信息
This commit is contained in:
@@ -27,6 +27,7 @@ import InfoBar from '@/components/InfoBar.vue'
|
||||
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
||||
import StatsSheet from '@/components/StatsSheet.vue'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import BrandMark from '@/components/BrandMark.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import {
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Monitor,
|
||||
MonitorOff,
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
@@ -224,25 +224,23 @@ const videoQuickInfo = computed(() => {
|
||||
const videoDetails = computed<StatusDetail[]>(() => {
|
||||
const stream = systemStore.stream
|
||||
if (!stream) return []
|
||||
// Use backend-provided FPS from WebSocket
|
||||
const receivedFps = backendFps.value
|
||||
// Display mode: use local videoMode which is synced with server
|
||||
const modeDisplay = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
|
||||
const details: StatusDetail[] = [
|
||||
|
||||
// Input (capture) format → output (delivery) mode
|
||||
const inputFmt = stream.format || 'MJPEG'
|
||||
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
|
||||
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
||||
|
||||
// Target / actual FPS combined
|
||||
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
|
||||
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
|
||||
|
||||
return [
|
||||
{ label: t('statusCard.device'), value: stream.device || '-' },
|
||||
{ label: t('statusCard.mode'), value: modeDisplay, status: 'ok' },
|
||||
{ label: t('statusCard.format'), value: stream.format || 'MJPEG' },
|
||||
{ label: t('statusCard.format'), value: formatDisplay },
|
||||
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
|
||||
{ label: t('statusCard.targetFps'), value: formatFpsValue(stream.targetFps ?? 0) },
|
||||
{ label: t('statusCard.fps'), value: formatFpsValue(receivedFps), status: receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined },
|
||||
{ label: t('statusCard.fps'), value: fpsDisplay, status: fpsStatus },
|
||||
]
|
||||
|
||||
// Show network error if WebSocket has network issue
|
||||
if (wsNetworkError.value) {
|
||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.networkError'), status: 'warning' })
|
||||
}
|
||||
|
||||
return details
|
||||
})
|
||||
|
||||
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||
@@ -358,54 +356,59 @@ const hidDetails = computed<StatusDetail[]>(() => {
|
||||
const hidErrorStatus: StatusDetail['status'] =
|
||||
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
|
||||
|
||||
const details: StatusDetail[] = [
|
||||
{ label: t('statusCard.device'), value: hid.device || '-' },
|
||||
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
|
||||
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error && hid.errorCode !== 'udc_not_configured' ? 'error' : hid.initialized ? 'ok' : 'warning' },
|
||||
{ label: t('statusCard.online'), value: hid.online ? t('statusCard.yes') : t('statusCard.no'), status: hid.online ? 'ok' : hid.initialized ? 'warning' : 'error' },
|
||||
{ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' },
|
||||
{
|
||||
label: t('settings.otgKeyboardLeds'),
|
||||
value: hid.keyboardLedsEnabled
|
||||
? `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`
|
||||
: t('infobar.keyboardLedUnavailable'),
|
||||
status: hid.keyboardLedsEnabled ? 'ok' : undefined,
|
||||
},
|
||||
]
|
||||
const details: StatusDetail[] = []
|
||||
|
||||
if (hid.errorCode) {
|
||||
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: hidErrorStatus })
|
||||
}
|
||||
// Backend + device combined
|
||||
const backendStr = hid.backend || t('common.unknown')
|
||||
const deviceStr = hid.device ? ` @ ${hid.device}` : ''
|
||||
details.push({ label: t('statusCard.backend'), value: `${backendStr}${deviceStr}` })
|
||||
|
||||
// Error message (with error code as suffix when present) OR normal-state info
|
||||
if (errorMessage) {
|
||||
details.push({ label: t('common.error'), value: errorMessage, status: hidErrorStatus })
|
||||
const codeSuffix = hid.errorCode ? ` (${hid.errorCode})` : ''
|
||||
details.push({ label: t('common.error'), value: `${errorMessage}${codeSuffix}`, status: hidErrorStatus })
|
||||
} else if (hid.online) {
|
||||
details.push({ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' })
|
||||
if (hid.keyboardLedsEnabled) {
|
||||
details.push({
|
||||
label: t('settings.otgKeyboardLeds'),
|
||||
value: `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`,
|
||||
status: 'ok',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add HID channel info based on video mode
|
||||
// Channel (merged with availability / connection state)
|
||||
let channelValue: string
|
||||
let channelStatus: StatusDetail['status']
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
// WebRTC mode - show DataChannel status
|
||||
if (webrtc.dataChannelReady.value) {
|
||||
details.push({ label: t('statusCard.channel'), value: 'DataChannel (WebRTC)', status: 'ok' })
|
||||
channelValue = 'DataChannel (WebRTC)'
|
||||
channelStatus = 'ok'
|
||||
} else if (webrtc.isConnecting.value || webrtc.isConnected.value) {
|
||||
details.push({ label: t('statusCard.channel'), value: 'DataChannel', status: 'warning' })
|
||||
channelValue = 'DataChannel'
|
||||
channelStatus = 'warning'
|
||||
} else {
|
||||
// Fallback to WebSocket
|
||||
details.push({ label: t('statusCard.channel'), value: 'WebSocket (fallback)', status: hidWs.connected.value ? 'ok' : 'warning' })
|
||||
channelValue = 'WebSocket (fallback)'
|
||||
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
|
||||
}
|
||||
} else {
|
||||
// MJPEG mode - WebSocket HID
|
||||
details.push({ label: t('statusCard.channel'), value: 'WebSocket', status: hidWs.connected.value ? 'ok' : 'warning' })
|
||||
channelValue = 'WebSocket'
|
||||
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
|
||||
}
|
||||
|
||||
// Add connection status for WebSocket (only relevant for MJPEG or fallback)
|
||||
if (videoMode.value === 'mjpeg' || !webrtc.dataChannelReady.value) {
|
||||
if (hidWs.networkError.value) {
|
||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.networkError'), status: 'warning' })
|
||||
channelValue += ` (${t('statusCard.networkError')})`
|
||||
channelStatus = 'warning'
|
||||
} else if (!hidWs.connected.value) {
|
||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.disconnected'), status: 'warning' })
|
||||
channelValue += ` (${t('statusCard.disconnected')})`
|
||||
channelStatus = 'warning'
|
||||
} else if (hidWs.hidUnavailable.value) {
|
||||
details.push({ label: t('statusCard.availability'), value: t('statusCard.hidUnavailable'), status: 'warning' })
|
||||
channelValue += ` (${t('statusCard.hidUnavailable')})`
|
||||
channelStatus = 'warning'
|
||||
}
|
||||
}
|
||||
details.push({ label: t('statusCard.channel'), value: channelValue, status: channelStatus })
|
||||
|
||||
return details
|
||||
})
|
||||
@@ -2347,18 +2350,42 @@ onUnmounted(() => {
|
||||
<div class="h-screen h-dvh flex flex-col bg-background">
|
||||
<!-- Header -->
|
||||
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="px-4">
|
||||
<div class="h-14 flex items-center justify-between">
|
||||
<div class="px-2 sm:px-4">
|
||||
<div class="h-10 sm:h-14 flex items-center justify-between">
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Monitor class="h-6 w-6 text-primary" />
|
||||
<span class="font-bold text-lg">One-KVM</span>
|
||||
<div class="flex items-center gap-2 sm:gap-6">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2">
|
||||
<BrandMark size="md" class="hidden sm:block" />
|
||||
<BrandMark size="sm" class="sm:hidden" />
|
||||
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Status Indicators (inline, minimal) -->
|
||||
<div class="flex md:hidden items-center gap-1">
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
:status="videoStatus"
|
||||
:quick-info="videoQuickInfo"
|
||||
:error-message="videoErrorMessage"
|
||||
:details="videoDetails"
|
||||
compact
|
||||
/>
|
||||
|
||||
<StatusCard
|
||||
:title="t('statusCard.hid')"
|
||||
type="hid"
|
||||
:status="hidStatus"
|
||||
:quick-info="hidQuickInfo"
|
||||
:details="hidDetails"
|
||||
:hover-align="hidHoverAlign"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Status Cards + User Menu -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<!-- Video Status -->
|
||||
<StatusCard
|
||||
@@ -2420,9 +2447,9 @@ onUnmounted(() => {
|
||||
<!-- User Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="gap-1.5">
|
||||
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 h-7 sm:h-9 px-2 sm:px-3">
|
||||
<span class="text-xs max-w-[60px] sm:max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
|
||||
<ChevronDown class="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -2453,60 +2480,6 @@ onUnmounted(() => {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Status Row -->
|
||||
<div class="md:hidden pb-2">
|
||||
<div class="flex items-center gap-2 overflow-x-auto">
|
||||
<div class="shrink-0">
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
:status="videoStatus"
|
||||
:quick-info="videoQuickInfo"
|
||||
:error-message="videoErrorMessage"
|
||||
:details="videoDetails"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="systemStore.audio?.available" class="shrink-0">
|
||||
<StatusCard
|
||||
:title="t('statusCard.audio')"
|
||||
type="audio"
|
||||
:status="audioStatus"
|
||||
:quick-info="audioQuickInfo"
|
||||
:error-message="audioErrorMessage"
|
||||
:details="audioDetails"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<StatusCard
|
||||
:title="t('statusCard.hid')"
|
||||
type="hid"
|
||||
:status="hidStatus"
|
||||
:quick-info="hidQuickInfo"
|
||||
:details="hidDetails"
|
||||
:hover-align="hidHoverAlign"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showMsdStatusCard" class="shrink-0">
|
||||
<StatusCard
|
||||
:title="t('statusCard.msd')"
|
||||
type="msd"
|
||||
:status="msdStatus"
|
||||
:quick-info="msdQuickInfo"
|
||||
:error-message="msdErrorMessage"
|
||||
:details="msdDetails"
|
||||
hover-align="end"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2539,7 +2512,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
|
||||
<!-- Video Container -->
|
||||
<div class="relative h-full w-full flex items-center justify-center p-2 sm:p-4">
|
||||
<div class="relative h-full w-full flex items-center justify-center p-1 sm:p-4">
|
||||
<div
|
||||
ref="videoContainerRef"
|
||||
class="relative bg-black overflow-hidden flex items-center justify-center"
|
||||
@@ -2547,8 +2520,7 @@ onUnmounted(() => {
|
||||
aspectRatio: videoAspectRatio ?? '16/9',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
minWidth: '320px',
|
||||
minHeight: '180px',
|
||||
minHeight: '120px',
|
||||
}"
|
||||
:class="{
|
||||
'opacity-60': videoLoading || videoError,
|
||||
@@ -2602,11 +2574,11 @@ onUnmounted(() => {
|
||||
<div class="absolute w-full h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" style="top: 50%; animation-duration: 1.5s;" />
|
||||
</div>
|
||||
|
||||
<Spinner class="h-16 w-16 text-white mb-4" />
|
||||
<p class="text-white/90 text-lg font-medium">
|
||||
<Spinner class="h-10 w-10 sm:h-16 sm:w-16 text-white mb-2 sm:mb-4" />
|
||||
<p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4">
|
||||
{{ webrtcLoadingMessage }}
|
||||
</p>
|
||||
<p class="text-white/50 text-sm mt-2">
|
||||
<p class="text-white/50 text-xs sm:text-sm mt-1 sm:mt-2">
|
||||
{{ t('console.pleaseWait') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -2618,10 +2590,10 @@ onUnmounted(() => {
|
||||
v-if="videoError && !videoLoading"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/85 text-white gap-4 transition-opacity duration-300 p-4"
|
||||
>
|
||||
<MonitorOff class="h-16 w-16 text-slate-400" />
|
||||
<div class="text-center max-w-md">
|
||||
<p class="font-medium text-lg mb-2">{{ t('console.connectionFailed') }}</p>
|
||||
<p class="text-sm text-slate-300 mb-3">{{ t('console.connectionFailedDesc') }}</p>
|
||||
<MonitorOff class="h-10 w-10 sm:h-16 sm:w-16 text-slate-400" />
|
||||
<div class="text-center max-w-md px-2">
|
||||
<p class="font-medium text-sm sm:text-lg mb-1 sm:mb-2">{{ t('console.connectionFailed') }}</p>
|
||||
<p class="text-xs sm:text-sm text-slate-300 mb-2 sm:mb-3">{{ t('console.connectionFailedDesc') }}</p>
|
||||
<!-- Expandable error details -->
|
||||
<div v-if="videoErrorMessage" class="bg-slate-800/60 rounded-lg p-3 text-left">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('console.errorDetails') }}:</p>
|
||||
@@ -2679,22 +2651,22 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Terminal Dialog -->
|
||||
<Dialog v-model:open="showTerminalDialog">
|
||||
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader class="px-4 py-3 border-b shrink-0">
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<Terminal class="h-5 w-5" />
|
||||
{{ t('extensions.ttyd.title') }}
|
||||
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 mr-8"
|
||||
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
|
||||
@click="openTerminalInNewTab"
|
||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||
:title="t('extensions.ttyd.openInNewTab')"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
<ExternalLink class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -2712,11 +2684,11 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Change Password Dialog -->
|
||||
<Dialog v-model:open="changePasswordDialogOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogContent class="w-[95vw] max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('auth.changePassword') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-3 sm:space-y-4 py-2 sm:py-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="currentPassword">{{ t('auth.currentPassword') }}</Label>
|
||||
<Input
|
||||
|
||||
@@ -7,14 +7,24 @@ 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next'
|
||||
import BrandMark from '@/components/BrandMark.vue'
|
||||
import { Lock, Eye, EyeOff, User, CircleHelp } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/** Map backend English messages to locale strings (API returns fixed English copy). */
|
||||
function localizedLoginError(raw: string | null): string {
|
||||
if (!raw) return t('auth.loginFailed')
|
||||
if (raw.includes('Invalid username or password')) return t('auth.invalidPassword')
|
||||
if (raw.includes('System not initialized')) return t('auth.systemNotInitialized')
|
||||
return raw
|
||||
}
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
@@ -40,7 +50,7 @@ async function handleLogin() {
|
||||
const redirect = route.query.redirect as string
|
||||
router.push(redirect || '/')
|
||||
} else {
|
||||
error.value = authStore.error || t('auth.loginFailed')
|
||||
error.value = localizedLoginError(authStore.error)
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
@@ -55,8 +65,8 @@ async function handleLogin() {
|
||||
</div>
|
||||
|
||||
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
|
||||
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
|
||||
<Monitor class="w-8 h-8 text-primary" />
|
||||
<div class="mx-auto flex justify-center">
|
||||
<BrandMark size="xl" />
|
||||
</div>
|
||||
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
|
||||
<CardDescription>{{ t('auth.login') }}</CardDescription>
|
||||
@@ -107,6 +117,25 @@ async function handleLogin() {
|
||||
<span v-if="loading">{{ t('common.loading') }}</span>
|
||||
<span v-else>{{ t('auth.login') }}</span>
|
||||
</Button>
|
||||
|
||||
<div class="text-right">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ t('auth.forgotPassword') }}
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-80 p-3" align="end">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('auth.forgotPasswordHint') }}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -50,6 +50,8 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -84,6 +86,7 @@ import {
|
||||
Copy,
|
||||
ScreenShare,
|
||||
Radio,
|
||||
Globe,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
@@ -100,7 +103,7 @@ const saved = ref(false)
|
||||
const SETTINGS_SECTION_IDS = new Set([
|
||||
'appearance',
|
||||
'account',
|
||||
'access',
|
||||
'network',
|
||||
'video',
|
||||
'hid',
|
||||
'msd',
|
||||
@@ -120,7 +123,7 @@ const navGroups = computed(() => [
|
||||
items: [
|
||||
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
|
||||
{ id: 'account', label: t('settings.account'), icon: User },
|
||||
{ id: 'access', label: t('settings.access'), icon: Lock },
|
||||
{ id: 'network', label: t('settings.network'), icon: Globe },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -156,7 +159,9 @@ function selectSection(id: string) {
|
||||
}
|
||||
|
||||
function normalizeSettingsSection(value: unknown): string | null {
|
||||
return typeof value === 'string' && SETTINGS_SECTION_IDS.has(value) ? value : null
|
||||
if (typeof value !== 'string') return null
|
||||
if (value === 'access-control') return 'account'
|
||||
return SETTINGS_SECTION_IDS.has(value) ? value : null
|
||||
}
|
||||
|
||||
// Theme
|
||||
@@ -205,7 +210,7 @@ const showTerminalDialog = ref(false)
|
||||
// Extension config (local edit state)
|
||||
const extConfig = ref({
|
||||
ttyd: { enabled: false, shell: '/bin/bash' },
|
||||
gostc: { enabled: false, addr: 'gostc.mofeng.run', key: '', tls: true },
|
||||
gostc: { enabled: false, addr: '', key: '', tls: true },
|
||||
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
||||
})
|
||||
|
||||
@@ -258,10 +263,22 @@ const webServerConfig = ref<WebConfig>({
|
||||
bind_address: '0.0.0.0',
|
||||
bind_addresses: ['0.0.0.0'],
|
||||
https_enabled: false,
|
||||
has_custom_cert: false,
|
||||
})
|
||||
const webServerLoading = ref(false)
|
||||
// SSL certificate state
|
||||
const sslCertPem = ref('')
|
||||
const sslKeyPem = ref('')
|
||||
const certSaving = ref(false)
|
||||
const certClearing = ref(false)
|
||||
const showRestartDialog = ref(false)
|
||||
const restarting = ref(false)
|
||||
// Auto-restart flow (no dialog needed for web-config saves)
|
||||
const autoRestarting = ref(false)
|
||||
const autoRestartFailed = ref(false)
|
||||
// For HTTPS targets: can't poll (self-signed cert), show manual link instead
|
||||
const autoRestartManualUrl = ref<string | null>(null)
|
||||
const autoRestartCountdown = ref(0)
|
||||
const updateChannel = ref<UpdateChannel>('stable')
|
||||
const updateOverview = ref<UpdateOverviewResponse | null>(null)
|
||||
const updateStatus = ref<UpdateStatusResponse | null>(null)
|
||||
@@ -299,6 +316,18 @@ const effectiveBindAddresses = computed(() => {
|
||||
return normalizeBindAddresses(bindAddressList.value)
|
||||
})
|
||||
|
||||
/** 预览当前配置生效后的访问 URL(取第一个非通配地址显示) */
|
||||
const previewAccessUrl = computed(() => {
|
||||
const https = webServerConfig.value.https_enabled
|
||||
const port = https ? webServerConfig.value.https_port : webServerConfig.value.http_port
|
||||
const scheme = https ? 'https' : 'http'
|
||||
// 对通配地址,用当前浏览器 hostname 替代
|
||||
const addrs = effectiveBindAddresses.value
|
||||
const firstAddr = addrs.find(a => a !== '0.0.0.0' && a !== '::') ?? window.location.hostname
|
||||
const host = firstAddr.includes(':') ? `[${firstAddr}]` : firstAddr
|
||||
return `${scheme}://${host}:${port}`
|
||||
})
|
||||
|
||||
// Config
|
||||
interface DeviceConfig {
|
||||
video: Array<{
|
||||
@@ -1435,6 +1464,12 @@ function normalizeRustdeskServer(value: string, defaultPort: number): string | u
|
||||
return `${trimmed}:${defaultPort}`
|
||||
}
|
||||
|
||||
/** Strip line breaks from pasted keys; empty means “do not change” on PATCH. */
|
||||
function normalizeRustdeskRelayKey(value: string): string | undefined {
|
||||
const cleaned = value.replace(/\r?\n/g, '').trim()
|
||||
return cleaned || undefined
|
||||
}
|
||||
|
||||
function normalizeRtspPath(path: string): string {
|
||||
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
||||
}
|
||||
@@ -1496,16 +1531,15 @@ async function saveWebServerConfig() {
|
||||
if (bindAddressError.value) return
|
||||
webServerLoading.value = true
|
||||
try {
|
||||
const update = {
|
||||
const updated = await configStore.updateWeb({
|
||||
http_port: webServerConfig.value.http_port,
|
||||
https_port: webServerConfig.value.https_port,
|
||||
https_enabled: webServerConfig.value.https_enabled,
|
||||
bind_addresses: effectiveBindAddresses.value,
|
||||
}
|
||||
const updated = await configStore.updateWeb(update)
|
||||
})
|
||||
webServerConfig.value = updated
|
||||
applyBindStateFromConfig(updated)
|
||||
showRestartDialog.value = true
|
||||
await triggerAutoRestart()
|
||||
} catch (e) {
|
||||
console.error('Failed to save web server config:', e)
|
||||
} finally {
|
||||
@@ -1513,19 +1547,50 @@ async function saveWebServerConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCertificate() {
|
||||
if (!sslCertPem.value.trim() || !sslKeyPem.value.trim()) return
|
||||
certSaving.value = true
|
||||
try {
|
||||
const updated = await configStore.updateWeb({
|
||||
ssl_cert_pem: sslCertPem.value,
|
||||
ssl_key_pem: sslKeyPem.value,
|
||||
})
|
||||
webServerConfig.value = updated
|
||||
sslCertPem.value = ''
|
||||
sslKeyPem.value = ''
|
||||
await triggerAutoRestart()
|
||||
} catch (e) {
|
||||
console.error('Failed to save certificate:', e)
|
||||
} finally {
|
||||
certSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCertificate() {
|
||||
certClearing.value = true
|
||||
try {
|
||||
const updated = await configStore.updateWeb({ clear_custom_cert: true })
|
||||
webServerConfig.value = updated
|
||||
await triggerAutoRestart()
|
||||
} catch (e) {
|
||||
console.error('Failed to clear certificate:', e)
|
||||
} finally {
|
||||
certClearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动点重启按钮(仅用于弹窗场景,保留兼容) */
|
||||
async function restartServer() {
|
||||
restarting.value = true
|
||||
try {
|
||||
await systemApi.restart()
|
||||
// Wait for server to restart, then reload page
|
||||
setTimeout(() => {
|
||||
const protocol = webServerConfig.value.https_enabled ? 'https' : 'http'
|
||||
const port = webServerConfig.value.https_enabled
|
||||
? webServerConfig.value.https_port
|
||||
: webServerConfig.value.http_port
|
||||
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
||||
const newUrl = `${protocol}://${host}:${port}`
|
||||
window.location.href = newUrl
|
||||
window.location.href = `${protocol}://${host}:${port}`
|
||||
}, 3000)
|
||||
} catch (e) {
|
||||
console.error('Failed to restart server:', e)
|
||||
@@ -1533,6 +1598,75 @@ async function restartServer() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询目标地址 /api/health,最多等待 maxMs 毫秒 */
|
||||
async function pollUntilReady(targetOrigin: string, maxMs = 30000): Promise<boolean> {
|
||||
const deadline = Date.now() + maxMs
|
||||
const healthUrl = targetOrigin.replace(/\/$/, '') + '/api/health'
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const tid = setTimeout(() => ctrl.abort(), 1500)
|
||||
const res = await fetch(healthUrl, { signal: ctrl.signal })
|
||||
clearTimeout(tid)
|
||||
if (res.ok) return true
|
||||
} catch {
|
||||
// server still restarting — keep polling
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存网络配置后自动重启并跳转。
|
||||
*
|
||||
* - HTTP 目标:轮询 /api/health,服务恢复后自动跳转。
|
||||
* - HTTPS 目标:自签名证书导致 fetch 被浏览器拦截(ERR_CERT_AUTHORITY_INVALID),
|
||||
* 无法自动轮询。改为倒计时结束后展示跳转链接,由用户点击并在浏览器中手动接受证书。
|
||||
*/
|
||||
async function triggerAutoRestart() {
|
||||
const https = webServerConfig.value.https_enabled
|
||||
const port = https ? webServerConfig.value.https_port : webServerConfig.value.http_port
|
||||
const protocol = https ? 'https' : 'http'
|
||||
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
||||
const targetOrigin = `${protocol}://${host}:${port}`
|
||||
|
||||
autoRestarting.value = true
|
||||
autoRestartFailed.value = false
|
||||
autoRestartManualUrl.value = null
|
||||
|
||||
try {
|
||||
await systemApi.restart()
|
||||
|
||||
if (https) {
|
||||
// HTTPS:浏览器拒绝自签名证书,无法轮询。
|
||||
// 等待固定时间后展示手动跳转链接。
|
||||
const WAIT_SEC = 6
|
||||
autoRestartCountdown.value = WAIT_SEC
|
||||
for (let i = WAIT_SEC - 1; i >= 0; i--) {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
autoRestartCountdown.value = i
|
||||
}
|
||||
autoRestartManualUrl.value = targetOrigin
|
||||
autoRestarting.value = false
|
||||
} else {
|
||||
// HTTP:可以安全轮询,服务恢复后自动跳转。
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
const ready = await pollUntilReady(targetOrigin)
|
||||
if (ready) {
|
||||
window.location.href = targetOrigin
|
||||
} else {
|
||||
autoRestartFailed.value = true
|
||||
autoRestarting.value = false
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Auto restart failed:', e)
|
||||
autoRestartFailed.value = true
|
||||
autoRestarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUpdateOverview() {
|
||||
updateLoading.value = true
|
||||
try {
|
||||
@@ -1642,7 +1776,7 @@ async function saveRustdeskConfig() {
|
||||
enabled: rustdeskLocalConfig.value.enabled,
|
||||
rendezvous_server: rendezvousServer,
|
||||
relay_server: relayServer,
|
||||
relay_key: rustdeskLocalConfig.value.relay_key || undefined,
|
||||
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
|
||||
})
|
||||
await loadRustdeskConfig()
|
||||
// Clear relay_key input after save (it's a password field)
|
||||
@@ -1919,16 +2053,16 @@ watch(() => route.query.tab, (tab) => {
|
||||
<AppLayout>
|
||||
<div class="flex h-full overflow-hidden">
|
||||
<!-- Mobile Header -->
|
||||
<div class="lg:hidden fixed top-16 left-0 right-0 z-20 flex items-center px-4 py-3 border-b bg-background">
|
||||
<div class="lg:hidden fixed top-11 sm:top-14 left-0 right-0 z-20 flex items-center px-3 sm:px-4 py-2 sm:py-3 border-b bg-background">
|
||||
<Sheet v-model:open="mobileMenuOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
|
||||
<Button variant="ghost" size="icon" class="mr-1.5 sm:mr-2 h-8 w-8 sm:h-9 sm:w-9">
|
||||
<Menu class="h-4 w-4" />
|
||||
<span class="sr-only">{{ t('common.menu') }}</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" class="w-72 p-0">
|
||||
<div class="p-6">
|
||||
<div class="p-4 sm:p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ t('settings.title') }}</h2>
|
||||
<nav class="space-y-6">
|
||||
<div v-for="group in navGroups" :key="group.title" class="space-y-1">
|
||||
@@ -1954,7 +2088,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<h1 class="text-lg font-semibold">{{ t('settings.title') }}</h1>
|
||||
<h1 class="text-base sm:text-lg font-semibold">{{ t('settings.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
@@ -1987,7 +2121,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="max-w-2xl mx-auto p-6 lg:p-8 pt-20 lg:pt-8 space-y-6">
|
||||
<div class="max-w-2xl mx-auto p-3 sm:p-6 lg:p-8 pt-16 sm:pt-20 lg:pt-8 space-y-4 sm:space-y-6">
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div v-show="activeSection === 'appearance'" class="space-y-6">
|
||||
@@ -1997,15 +2131,15 @@ watch(() => route.query.tab, (tab) => {
|
||||
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')">
|
||||
<Sun class="h-4 w-4 mr-2" />{{ t('settings.lightMode') }}
|
||||
<Sun class="h-4 w-4 mr-1.5" />{{ t('settings.lightMode') }}
|
||||
</Button>
|
||||
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" @click="setTheme('dark')">
|
||||
<Moon class="h-4 w-4 mr-2" />{{ t('settings.darkMode') }}
|
||||
<Moon class="h-4 w-4 mr-1.5" />{{ t('settings.darkMode') }}
|
||||
</Button>
|
||||
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" @click="setTheme('system')">
|
||||
<Monitor class="h-4 w-4 mr-2" />{{ t('settings.systemMode') }}
|
||||
<Monitor class="h-4 w-4 mr-1.5" />{{ t('settings.systemMode') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -2079,6 +2213,31 @@ watch(() => route.query.tab, (tab) => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="authConfig.single_user_allow_multiple_sessions"
|
||||
:disabled="authConfigLoading"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Video Section -->
|
||||
@@ -2585,14 +2744,70 @@ watch(() => route.query.tab, (tab) => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Access Section -->
|
||||
<div v-show="activeSection === 'access'" class="space-y-6">
|
||||
<!-- Network Section -->
|
||||
<div v-show="activeSection === 'network'" class="space-y-6">
|
||||
|
||||
<!-- Auto-restart: restarting progress -->
|
||||
<div
|
||||
v-if="autoRestarting"
|
||||
class="flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm shadow-sm"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium">{{ t('settings.autoRestarting') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ webServerConfig.https_enabled
|
||||
? t('settings.autoRestartingHttpsDesc', { sec: autoRestartCountdown })
|
||||
: t('settings.autoRestartingDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="webServerConfig.https_enabled && autoRestartCountdown > 0"
|
||||
class="tabular-nums text-lg font-bold text-primary shrink-0">
|
||||
{{ autoRestartCountdown }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Auto-restart: HTTPS manual redirect (cert must be accepted by user) -->
|
||||
<div
|
||||
v-if="autoRestartManualUrl"
|
||||
class="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-700 px-4 py-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-start gap-2 text-sm text-amber-800 dark:text-amber-300">
|
||||
<Lock class="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">{{ t('settings.httpsManualRedirectTitle') }}</p>
|
||||
<p class="text-xs mt-0.5 opacity-80">{{ t('settings.httpsManualRedirectDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
:href="autoRestartManualUrl"
|
||||
class="flex items-center justify-center gap-2 w-full rounded-md bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium px-4 py-2 transition-colors"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
{{ autoRestartManualUrl }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Auto-restart: failure / timeout -->
|
||||
<div
|
||||
v-if="autoRestartFailed"
|
||||
class="flex items-center justify-between rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
|
||||
>
|
||||
<p class="text-destructive">{{ t('settings.autoRestartFailed') }}</p>
|
||||
<Button variant="outline" size="sm" @click="triggerAutoRestart">
|
||||
<RefreshCw class="h-3 w-3 mr-1" />
|
||||
{{ t('common.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Port Configuration Card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.webServer') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.webServerDesc') }}</CardDescription>
|
||||
<CardTitle>{{ t('settings.portConfig') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.portConfigDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- HTTPS toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.httpsEnabled') }}</Label>
|
||||
@@ -2603,91 +2818,212 @@ watch(() => route.query.tab, (tab) => {
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.httpPort') }}</Label>
|
||||
<Input v-model.number="webServerConfig.http_port" type="number" min="1" max="65535" />
|
||||
<!-- Single active-port input, label follows the HTTPS toggle -->
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="space-y-2 flex-1 max-w-[180px]">
|
||||
<Label>
|
||||
{{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }}
|
||||
</Label>
|
||||
<Input
|
||||
v-if="webServerConfig.https_enabled"
|
||||
v-model.number="webServerConfig.https_port"
|
||||
type="number" min="1" max="65535"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model.number="webServerConfig.http_port"
|
||||
type="number" min="1" max="65535"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.httpsPort') }}</Label>
|
||||
<Input v-model.number="webServerConfig.https_port" type="number" min="1" max="65535" />
|
||||
<!-- Inactive-port reference (read-only hint) -->
|
||||
<div class="space-y-2 flex-1 max-w-[180px]">
|
||||
<Label class="text-muted-foreground text-xs">
|
||||
{{ webServerConfig.https_enabled ? t('settings.httpPortReserved') : t('settings.httpsPortReserved') }}
|
||||
</Label>
|
||||
<Input
|
||||
v-if="webServerConfig.https_enabled"
|
||||
v-model.number="webServerConfig.http_port"
|
||||
type="number" min="1" max="65535"
|
||||
class="opacity-50"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model.number="webServerConfig.https_port"
|
||||
type="number" min="1" max="65535"
|
||||
class="opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.bindMode') }}</Label>
|
||||
<select v-model="bindMode" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="all">{{ t('settings.bindModeAll') }}</option>
|
||||
<option value="loopback">{{ t('settings.bindModeLocal') }}</option>
|
||||
<option value="custom">{{ t('settings.bindModeCustom') }}</option>
|
||||
</select>
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.bindModeDesc') }}</p>
|
||||
<!-- Preview URL -->
|
||||
<div class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||
<span class="text-muted-foreground shrink-0">{{ t('settings.previewUrl') }}:</span>
|
||||
<span class="font-mono text-xs break-all">{{ previewAccessUrl }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="bindMode === 'all'" class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.bindIpv6') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindAllDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="bindAllIpv6" />
|
||||
</div>
|
||||
|
||||
<div v-if="bindMode === 'loopback'" class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.bindIpv6') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindLocalDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="bindLocalIpv6" />
|
||||
</div>
|
||||
|
||||
<div v-if="bindMode === 'custom'" class="space-y-2">
|
||||
<Label>{{ t('settings.bindAddressList') }}</Label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
|
||||
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
|
||||
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeBindAddress(i)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="addBindAddress">
|
||||
<Plus class="h-4 w-4 mr-1" />
|
||||
{{ t('settings.addBindAddress') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindAddressListDesc') }}</p>
|
||||
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
<!-- Save row -->
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting">
|
||||
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Listen Address Card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
|
||||
<CardTitle>{{ t('settings.listenAddress') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.listenAddressDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
|
||||
<RadioGroup v-model="bindMode" class="space-y-3">
|
||||
<!-- All addresses -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-3">
|
||||
<RadioGroupItem value="all" id="bind-all" class="mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<Label for="bind-all" class="cursor-pointer">{{ t('settings.bindModeAll') }}</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeAllDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bindMode === 'all'" class="ml-7 flex items-center justify-between rounded-md border border-dashed px-3 py-2">
|
||||
<Label class="text-sm font-normal">{{ t('settings.bindIpv6') }}</Label>
|
||||
<Switch v-model="bindAllIpv6" />
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="authConfig.single_user_allow_multiple_sessions"
|
||||
:disabled="authConfigLoading"
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Loopback only -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-3">
|
||||
<RadioGroupItem value="loopback" id="bind-loopback" class="mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<Label for="bind-loopback" class="cursor-pointer">{{ t('settings.bindModeLocal') }}</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeLocalDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bindMode === 'loopback'" class="ml-7 flex items-center justify-between rounded-md border border-dashed px-3 py-2">
|
||||
<Label class="text-sm font-normal">{{ t('settings.bindIpv6') }}</Label>
|
||||
<Switch v-model="bindLocalIpv6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Custom addresses -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-3">
|
||||
<RadioGroupItem value="custom" id="bind-custom" class="mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<Label for="bind-custom" class="cursor-pointer">{{ t('settings.bindModeCustom') }}</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeCustomDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bindMode === 'custom'" class="ml-7 space-y-2">
|
||||
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
|
||||
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
|
||||
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeBindAddress(i)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="addBindAddress">
|
||||
<Plus class="h-4 w-4 mr-1" />
|
||||
{{ t('settings.addBindAddress') }}
|
||||
</Button>
|
||||
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<!-- Effective addresses preview -->
|
||||
<div v-if="effectiveBindAddresses.length > 0" class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||
<span class="text-muted-foreground shrink-0">{{ t('settings.effectiveAddresses') }}:</span>
|
||||
<span class="font-mono text-xs break-all">{{ effectiveBindAddresses.join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting">
|
||||
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- SSL Certificate Card -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('settings.sslCertificate') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.sslCertificateDesc') }}</CardDescription>
|
||||
</div>
|
||||
<Badge :variant="webServerConfig.has_custom_cert ? 'default' : 'secondary'" class="mt-1 shrink-0">
|
||||
{{ webServerConfig.has_custom_cert ? t('settings.sslCertCustom') : t('settings.sslCertSelfSigned') }}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Active custom cert notice -->
|
||||
<div
|
||||
v-if="webServerConfig.has_custom_cert"
|
||||
class="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/20 dark:border-emerald-800 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<Check class="h-4 w-4 shrink-0" />
|
||||
{{ t('settings.sslCertActive') }}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive h-7 text-xs"
|
||||
:disabled="certClearing || autoRestarting"
|
||||
@click="clearCertificate"
|
||||
>
|
||||
<RefreshCw v-if="certClearing || autoRestarting" class="h-3 w-3 mr-1 animate-spin" />
|
||||
<Trash2 v-else class="h-3 w-3 mr-1" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertClear') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Certificate textarea -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.sslCertPem') }}</Label>
|
||||
<Textarea
|
||||
v-model="sslCertPem"
|
||||
:placeholder="t('settings.sslCertPemPlaceholder')"
|
||||
class="font-mono text-xs min-h-[110px] resize-y"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.singleUserSessionNote') }}</p>
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
|
||||
<!-- Key textarea -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.sslKeyPem') }}</Label>
|
||||
<Textarea
|
||||
v-model="sslKeyPem"
|
||||
:placeholder="t('settings.sslKeyPemPlaceholder')"
|
||||
class="font-mono text-xs min-h-[110px] resize-y"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||
<Button
|
||||
:disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()"
|
||||
@click="saveCertificate"
|
||||
>
|
||||
<RefreshCw v-if="certSaving || autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -3089,7 +3425,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
v-if="!isExtRunning(extensions?.gostc?.status)"
|
||||
size="sm"
|
||||
@click="startExtension('gostc')"
|
||||
:disabled="extensionsLoading || !extConfig.gostc.key"
|
||||
:disabled="extensionsLoading || !extConfig.gostc.key || !extConfig.gostc.addr?.trim()"
|
||||
>
|
||||
<Play class="h-4 w-4 mr-1" />
|
||||
{{ t('extensions.start') }}
|
||||
@@ -3115,7 +3451,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.gostc.addr') }}</Label>
|
||||
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" placeholder="gostc.mofeng.run" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
|
||||
@@ -3461,7 +3797,11 @@ watch(() => route.query.tab, (tab) => {
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input
|
||||
v-model="rustdeskLocalConfig.relay_key"
|
||||
type="password"
|
||||
type="text"
|
||||
maxlength="44"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="font-mono"
|
||||
:placeholder="rustdeskStatus?.config?.has_relay_key ? t('extensions.rustdesk.relayKeySet') : t('extensions.rustdesk.relayKeyPlaceholder')"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayKeyHint') }}</p>
|
||||
@@ -3636,12 +3976,12 @@ watch(() => route.query.tab, (tab) => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center py-2 border-b">
|
||||
<span class="text-sm text-muted-foreground">{{ t('settings.hostname') }}</span>
|
||||
<span class="text-sm font-medium">{{ systemStore.deviceInfo.hostname }}</span>
|
||||
<div class="flex justify-between items-center py-2 border-b gap-2">
|
||||
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.hostname') }}</span>
|
||||
<span class="text-sm font-medium truncate">{{ systemStore.deviceInfo.hostname }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b">
|
||||
<span class="text-sm text-muted-foreground">{{ t('settings.cpuModel') }}</span>
|
||||
<div class="flex justify-between items-center py-2 border-b gap-2">
|
||||
<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">
|
||||
@@ -3668,20 +4008,18 @@ watch(() => route.query.tab, (tab) => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p class="text-xs text-muted-foreground text-center">{{ t('settings.builtWith') }}</p>
|
||||
<p class="text-xs text-muted-foreground text-center">@2025-2026 SilentWind</p>
|
||||
</div>
|
||||
|
||||
<!-- Save Button (sticky) -->
|
||||
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-4 pb-2 bg-background border-t -mx-6 px-6 lg:-mx-8 lg:px-8">
|
||||
<div class="flex justify-end">
|
||||
<div class="flex items-center gap-3">
|
||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.otgFunctionMinWarning') }}
|
||||
</p>
|
||||
<Button :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
||||
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-2 bg-background border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||
<div class="flex items-center justify-between sm:justify-end gap-2 sm:gap-3">
|
||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex-1 min-w-0">
|
||||
{{ t('settings.otgFunctionMinWarning') }}
|
||||
</p>
|
||||
<Button class="shrink-0" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3691,17 +4029,17 @@ watch(() => route.query.tab, (tab) => {
|
||||
|
||||
<!-- Terminal Dialog -->
|
||||
<Dialog v-model:open="showTerminalDialog">
|
||||
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader class="px-4 py-3 border-b shrink-0">
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<Terminal class="h-5 w-5" />
|
||||
{{ t('extensions.ttyd.title') }}
|
||||
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 mr-8"
|
||||
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
|
||||
@click="openTerminalInNewTab"
|
||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||
:title="t('extensions.ttyd.openInNewTab')"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
|
||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||
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'
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
} from '@/components/ui/hover-card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Monitor,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ChevronRight,
|
||||
@@ -572,10 +572,8 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</div>
|
||||
|
||||
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-16 h-16 mx-auto rounded-full bg-primary/10"
|
||||
>
|
||||
<Monitor class="w-8 h-8 text-primary" />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user