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:
mofeng-git
2026-04-12 19:26:52 +08:00
parent d0c0852fbb
commit 9653e16a68
27 changed files with 1527 additions and 629 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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')"

View File

@@ -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>