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

@@ -22,6 +22,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
ClipboardPaste,
HardDrive,
@@ -74,6 +80,7 @@ const emit = defineEmits<{
(e: 'openTerminal'): void
}>()
// Desktop toolbar popover/dialog state
const pasteOpen = ref(false)
const atxOpen = ref(false)
const videoPopoverOpen = ref(false)
@@ -81,13 +88,52 @@ const hidPopoverOpen = ref(false)
const audioPopoverOpen = ref(false)
const msdDialogOpen = ref(false)
const extensionOpen = ref(false)
// Mobile Sheet state — opened from the overflow menu.
// We use Sheet (bottom drawer) instead of Popover because Popover relies on an
// anchor element that is hidden / clipped on small screens, causing it to
// immediately close after opening.
const mobileAtxOpen = ref(false)
const mobilePasteOpen = ref(false)
// Timestamps used to suppress spurious "interact-outside" events that arrive
// within ~300 ms of the Sheet opening (e.g. delayed synthetic pointer events
// from the same touch gesture that opened the overflow menu).
const mobileAtxOpenTime = ref(0)
const mobilePasteOpenTime = ref(0)
const OPEN_GUARD_MS = 350
const guardOutside = (openTime: number, e: Event) => {
if (Date.now() - openTime < OPEN_GUARD_MS) {
e.preventDefault()
}
}
// On mobile, clicking a DropdownMenuItem generates pointer events that can
// immediately dismiss any overlay opened in the same tick. Close the dropdown
// first, then open the target after a short delay.
const openFromOverflow = (setter: () => void) => {
overflowMenuOpen.value = false
setTimeout(setter, 50)
}
const openMobileAtx = () => openFromOverflow(() => {
mobileAtxOpen.value = true
mobileAtxOpenTime.value = Date.now()
})
const openMobilePaste = () => openFromOverflow(() => {
mobilePasteOpen.value = true
mobilePasteOpenTime.value = Date.now()
})
</script>
<template>
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 px-4 py-1.5">
<!-- Left side buttons -->
<div class="flex flex-wrap items-center gap-1.5 w-full sm:flex-1 sm:min-w-0">
<div class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
<!-- Left side buttons overflow hidden so it never pushes into right side -->
<div class="flex items-center gap-0.5 sm:gap-1.5 flex-1 min-w-0 overflow-hidden">
<!-- Video Config - Always visible -->
<VideoConfigPopover
v-model:open="videoPopoverOpen"
@@ -95,7 +141,7 @@ const extensionOpen = ref(false)
@update:video-mode="emit('update:videoMode', $event)"
/>
<!-- Audio Config - Always visible -->
<!-- Audio Config - Always visible (xs shows icon only) -->
<AudioConfigPopover v-model:open="audioPopoverOpen" />
<!-- HID Config - Always visible -->
@@ -105,112 +151,123 @@ const extensionOpen = ref(false)
@update:mouse-mode="emit('toggleMouseMode')"
/>
<!-- Virtual Media (MSD) - Hidden on small screens, shown in overflow -->
<!-- Also hidden when HID backend is CH9329 (no USB gadget support) -->
<TooltipProvider v-if="showMsd" class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
<HardDrive class="h-4 w-4" />
<span class="hidden md:inline">{{ t('actionbar.virtualMedia') }}</span>
<!-- Virtual Media (MSD) - Hidden below md, shown in overflow -->
<div v-if="showMsd" class="hidden md:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
<HardDrive class="h-4 w-4" />
<span class="hidden lg:inline">{{ t('actionbar.virtualMedia') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.virtualMediaTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- ATX Power Control - Hidden below md; shown as Sheet on mobile -->
<div class="hidden md:block">
<Popover v-model:open="atxOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Power class="h-4 w-4" />
<span class="hidden lg:inline">{{ t('actionbar.power') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.virtualMediaTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</PopoverTrigger>
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
<AtxPopover
@close="atxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</PopoverContent>
</Popover>
</div>
<!-- ATX Power Control - Hidden on small screens -->
<Popover v-model:open="atxOpen" class="hidden sm:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Power class="h-4 w-4" />
<span class="hidden md:inline">{{ t('actionbar.power') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[280px] p-0" align="start">
<AtxPopover
@close="atxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</PopoverContent>
</Popover>
<!-- Paste Text - Hidden on small screens -->
<Popover v-model:open="pasteOpen" class="hidden md:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<ClipboardPaste class="h-4 w-4" />
<span class="hidden lg:inline">{{ t('actionbar.paste') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[400px] p-0" align="start">
<PasteModal @close="pasteOpen = false" />
</PopoverContent>
</Popover>
<!-- Paste Text - Hidden below lg; shown as Sheet on mobile -->
<div class="hidden lg:block">
<Popover v-model:open="pasteOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<ClipboardPaste class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.paste') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
<PasteModal @close="pasteOpen = false" />
</PopoverContent>
</Popover>
</div>
</div>
<!-- Right side buttons -->
<div class="flex items-center gap-1.5 w-full justify-end sm:w-auto sm:ml-auto shrink-0">
<!-- Extension Menu - Hidden on small screens -->
<Popover v-model:open="extensionOpen" class="hidden lg:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Cable class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.extension') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-1" align="start">
<div class="space-y-0.5">
<Button
variant="ghost"
size="sm"
class="w-full justify-start gap-2 h-8"
:disabled="!props.ttydRunning"
@click="extensionOpen = false; emit('openTerminal')"
>
<Terminal class="h-4 w-4" />
{{ t('extensions.ttyd.title') }}
<!-- Right side buttons always shrink-0, never compressed -->
<div class="flex items-center gap-0.5 sm:gap-1.5 shrink-0 ml-1 sm:ml-2">
<!-- Extension Menu - Hidden below xl -->
<div class="hidden xl:block">
<Popover v-model:open="extensionOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Cable class="h-4 w-4" />
{{ t('actionbar.extension') }}
</Button>
</div>
</PopoverContent>
</Popover>
</PopoverTrigger>
<PopoverContent class="w-48 p-1" align="start">
<div class="space-y-0.5">
<Button
variant="ghost"
size="sm"
class="w-full justify-start gap-2 h-8"
:disabled="!props.ttydRunning"
@click="extensionOpen = false; emit('openTerminal')"
>
<Terminal class="h-4 w-4" />
{{ t('extensions.ttyd.title') }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Settings - Hidden on small screens -->
<TooltipProvider class="hidden lg:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
<Settings class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.settings') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.settingsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Settings - Hidden below xl -->
<div class="hidden xl:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
<Settings class="h-4 w-4" />
{{ t('actionbar.settings') }}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.settingsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Connection Stats - Hidden on very small screens -->
<TooltipProvider class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
<BarChart3 class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.stats') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.statsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Connection Stats - Hidden below md -->
<div class="hidden md:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
<BarChart3 class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.stats') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.statsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block" />
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden md:block" />
<!-- Virtual Keyboard - Always visible (important for mobile) -->
<TooltipProvider>
@@ -219,10 +276,10 @@ const extensionOpen = ref(false)
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
@click="emit('toggleVirtualKeyboard')"
>
<Keyboard class="h-4 w-4" />
<Keyboard class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
</Button>
</TooltipTrigger>
@@ -239,10 +296,10 @@ const extensionOpen = ref(false)
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
@click="emit('toggleFullscreen')"
>
<Maximize class="h-4 w-4" />
<Maximize class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
</Button>
</TooltipTrigger>
@@ -252,52 +309,52 @@ const extensionOpen = ref(false)
</Tooltip>
</TooltipProvider>
<!-- Overflow Menu - Shows hidden items on small screens -->
<!-- Overflow Menu - Shows hidden items on smaller screens -->
<DropdownMenu v-model:open="overflowMenuOpen">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 lg:hidden">
<MoreHorizontal class="h-4 w-4" />
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-8 p-0 xl:hidden">
<MoreHorizontal class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<!-- MSD - Mobile only, hidden when CH9329 backend -->
<DropdownMenuItem v-if="showMsd" class="sm:hidden" @click="msdDialogOpen = true; overflowMenuOpen = false">
<!-- MSD - Below md, hidden when CH9329 backend -->
<DropdownMenuItem v-if="showMsd" class="md:hidden" @click="openFromOverflow(() => msdDialogOpen = true)">
<HardDrive class="h-4 w-4 mr-2" />
{{ t('actionbar.virtualMedia') }}
</DropdownMenuItem>
<!-- ATX - Mobile only -->
<DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
<!-- ATX - Opens a Sheet on mobile (below md) -->
<DropdownMenuItem class="md:hidden" @click="openMobileAtx">
<Power class="h-4 w-4 mr-2" />
{{ t('actionbar.power') }}
</DropdownMenuItem>
<!-- Paste - Tablet and below -->
<DropdownMenuItem class="md:hidden" @click="pasteOpen = true; overflowMenuOpen = false">
<!-- Paste - Opens a Sheet on mobile (below lg) -->
<DropdownMenuItem class="lg:hidden" @click="openMobilePaste">
<ClipboardPaste class="h-4 w-4 mr-2" />
{{ t('actionbar.paste') }}
</DropdownMenuItem>
<DropdownMenuSeparator class="lg:hidden" />
<DropdownMenuSeparator />
<!-- Stats - Mobile only -->
<DropdownMenuItem class="sm:hidden" @click="emit('toggleStats'); overflowMenuOpen = false">
<!-- Stats - Below md -->
<DropdownMenuItem class="md:hidden" @click="openFromOverflow(() => emit('toggleStats'))">
<BarChart3 class="h-4 w-4 mr-2" />
{{ t('actionbar.stats') }}
</DropdownMenuItem>
<!-- Extension - Tablet and below -->
<!-- Extension - Below xl -->
<DropdownMenuItem
class="lg:hidden"
class="xl:hidden"
:disabled="!props.ttydRunning"
@click="emit('openTerminal'); overflowMenuOpen = false"
@click="openFromOverflow(() => emit('openTerminal'))"
>
<Terminal class="h-4 w-4 mr-2" />
{{ t('extensions.ttyd.title') }}
</DropdownMenuItem>
<!-- Settings - Tablet and below -->
<DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
<!-- Settings - Below xl -->
<DropdownMenuItem class="xl:hidden" @click="openFromOverflow(() => router.push('/settings'))">
<Settings class="h-4 w-4 mr-2" />
{{ t('actionbar.settings') }}
</DropdownMenuItem>
@@ -309,4 +366,41 @@ const extensionOpen = ref(false)
<!-- MSD Dialog -->
<MsdDialog v-if="showMsd" v-model:open="msdDialogOpen" />
<!-- Mobile ATX Sheet used when ATX is opened from the overflow menu.
A Sheet avoids the Popover anchor-positioning issues on mobile. -->
<Sheet v-model:open="mobileAtxOpen">
<SheetContent
side="bottom"
class="max-h-[90dvh] overflow-y-auto"
@pointer-down-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
@interact-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
>
<SheetHeader class="mb-2">
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
</SheetHeader>
<AtxPopover
@close="mobileAtxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</SheetContent>
</Sheet>
<!-- Mobile Paste Sheet — used when Paste is opened from the overflow menu. -->
<Sheet v-model:open="mobilePasteOpen">
<SheetContent
side="bottom"
class="max-h-[90dvh] overflow-y-auto"
@pointer-down-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
@interact-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
>
<SheetHeader class="mb-2">
<SheetTitle>{{ t('actionbar.paste') }}</SheetTitle>
</SheetHeader>
<PasteModal @close="mobilePasteOpen = false" />
</SheetContent>
</Sheet>
</template>

View File

@@ -1,21 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useSystemStore } from '@/stores/system'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Monitor,
Settings,
LogOut,
Sun,
Moon,
@@ -23,16 +20,10 @@ import {
} from 'lucide-vue-next'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const systemStore = useSystemStore()
const navItems = computed(() => [
{ path: '/', name: 'Console', icon: Monitor, label: t('nav.console') },
{ path: '/settings', name: 'Settings', icon: Settings, label: t('nav.settings') },
])
function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark')
document.documentElement.classList.toggle('dark', !isDark)
@@ -49,38 +40,22 @@ async function handleLogout() {
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
<!-- Header -->
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex h-14 items-center px-4 max-w-full">
<div class="flex h-11 sm:h-14 items-center px-3 sm:px-4 max-w-full">
<!-- Logo -->
<RouterLink to="/" class="flex items-center gap-2 font-semibold">
<Monitor class="h-5 w-5" />
<RouterLink to="/" class="flex items-center gap-1.5 sm:gap-2 font-semibold">
<BrandMark size="sm" />
<span class="hidden sm:inline">One-KVM</span>
</RouterLink>
<!-- Navigation -->
<nav class="hidden md:flex items-center gap-1 ml-6">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors"
:class="route.path === item.path
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'"
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.label }}
</RouterLink>
</nav>
<!-- Right Side -->
<div class="flex items-center gap-2 ml-auto">
<div class="flex items-center gap-1 sm:gap-2 ml-auto">
<!-- Version Badge -->
<span v-if="systemStore.version" class="hidden sm:inline text-xs text-muted-foreground">
v{{ systemStore.version }}
</span>
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
@@ -92,16 +67,11 @@ async function handleLogout() {
<!-- Mobile Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child class="md:hidden">
<Button variant="ghost" size="icon" :aria-label="t('common.menu')">
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.menu')">
<Menu class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-for="item in navItems" :key="item.path" @click="router.push(item.path)">
<component :is="item.icon" class="h-4 w-4 mr-2" />
{{ item.label }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleLogout">
<LogOut class="h-4 w-4 mr-2" />
{{ t('nav.logout') }}
@@ -110,7 +80,7 @@ async function handleLogout() {
</DropdownMenu>
<!-- Logout Button (Desktop) -->
<Button variant="ghost" size="icon" class="hidden md:flex" :aria-label="t('nav.logout')" @click="handleLogout">
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8" :aria-label="t('nav.logout')" @click="handleLogout">
<LogOut class="h-4 w-4" />
<span class="sr-only">{{ t('nav.logout') }}</span>
</Button>

View File

@@ -169,12 +169,12 @@ watch(() => props.open, (isOpen) => {
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Volume2 class="h-4 w-4" />
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
<Volume2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
class?: string
}>(),
{ size: 'md' },
)
const dim = computed(() => {
switch (props.size) {
case 'xs':
return 'h-4 w-4'
case 'sm':
return 'h-5 w-5'
case 'md':
return 'h-6 w-6'
case 'lg':
return 'h-10 w-10'
case 'xl':
return 'h-14 w-14'
default:
return 'h-6 w-6'
}
})
</script>
<template>
<img
src="/favicon.png"
alt=""
:class="cn('shrink-0 object-contain select-none', dim, props.class)"
decoding="async"
draggable="false"
/>
</template>

View File

@@ -247,13 +247,13 @@ watch(() => props.open, (isOpen) => {
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<MousePointer v-if="mouseMode === 'absolute'" class="h-4 w-4" />
<Move v-else class="h-4 w-4" />
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
<MousePointer v-if="mouseMode === 'absolute'" class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<Move v-else class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">{{ buttonText }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>

View File

@@ -42,81 +42,70 @@ const keysDisplay = computed(() => {
<template>
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<!-- Compact mode for small screens -->
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
<!-- LED indicator only in compact mode -->
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
<span
v-if="capsLock"
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
>C</span>
<span v-else class="text-muted-foreground/40 text-[10px]">C</span>
<span
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>N</span>
<span
:class="scrollLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>S</span>
</div>
<div v-else class="text-[10px] text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
<!-- Keys in compact mode -->
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
{{ keysDisplay }}
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
<div :class="compact ? '' : 'sm:hidden'">
<div class="flex items-center justify-between text-xs px-2 py-0.5">
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
<span
:class="capsLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>C</span>
<span
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>N</span>
<span
:class="scrollLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>S</span>
</div>
<div v-else class="text-[10px] text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[200px]">
{{ keysDisplay }}
</div>
</div>
</div>
<!-- Normal mode -->
<div v-else class="flex flex-wrap items-center justify-between text-xs">
<!-- Left side: Debug info and pressed keys -->
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
<!-- Pressed Keys -->
<div class="flex items-center gap-1.5 min-w-0">
<span class="font-medium text-muted-foreground shrink-0 hidden sm:inline">{{ t('infobar.keys') }}:</span>
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
<!-- Normal mode (hidden on small screens unless compact is explicitly set) -->
<div :class="compact ? 'hidden' : 'hidden sm:block'">
<div class="flex flex-wrap items-center justify-between text-xs">
<!-- Left side: Debug info and pressed keys -->
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
<div class="flex items-center gap-1.5 min-w-0">
<span class="font-medium text-muted-foreground shrink-0">{{ t('infobar.keys') }}:</span>
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
</div>
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
</div>
</div>
<!-- Debug: Mouse Position -->
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
</div>
</div>
<!-- Right side: Keyboard LED states -->
<div class="flex items-center shrink-0">
<template v-if="keyboardLedEnabled">
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
<span class="sm:hidden">C</span>
<!-- Right side: Keyboard LED states -->
<div class="flex items-center shrink-0">
<template v-if="keyboardLedEnabled">
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>{{ t('infobar.caps') }}</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>{{ t('infobar.num') }}</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>{{ t('infobar.scroll') }}</div>
</template>
<div v-else class="px-3 py-1 text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
<span class="sm:hidden">N</span>
</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
<span class="sm:hidden">S</span>
</div>
</template>
<div v-else class="px-3 py-1 text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
</div>
</div>

View File

@@ -134,21 +134,30 @@ const statusBadgeText = computed(() => {
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
<template v-if="compact">
<!-- Compact: single row with dot + abbreviated title -->
<div class="flex items-center gap-1">
<span :class="cn('h-1.5 w-1.5 rounded-full shrink-0', statusColor)" />
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
</div>
</template>
<template v-else>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</template>
</button>
</HoverCardTrigger>
@@ -188,17 +197,6 @@ const statusBadgeText = computed(() => {
</div>
</div>
<!-- Error Message -->
<div
v-if="status === 'error' && errorMessage"
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<p class="text-xs text-red-600 dark:text-red-400">
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
{{ errorMessage }}
</p>
</div>
<!-- Details -->
<div v-if="details && details.length > 0" class="space-y-2">
<Separator />
@@ -206,12 +204,12 @@ const statusBadgeText = computed(() => {
<div
v-for="(detail, index) in details"
:key="index"
class="flex items-center justify-between text-xs"
class="flex items-start justify-between gap-3 text-xs"
>
<span class="text-muted-foreground">{{ detail.label }}</span>
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
<span
:class="cn(
'font-medium',
'font-medium text-right break-words min-w-0',
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
@@ -235,25 +233,34 @@ const statusBadgeText = computed(() => {
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
<template v-if="compact">
<!-- Compact: single row with dot + abbreviated title -->
<div class="flex items-center gap-1">
<span :class="cn('h-1.5 w-1.5 rounded-full shrink-0', statusColor)" />
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
</div>
</template>
<template v-else>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</template>
</button>
</PopoverTrigger>
<PopoverContent class="w-80" :align="hoverAlign">
<PopoverContent class="w-[min(320px,90vw)]" :align="hoverAlign">
<div class="space-y-3">
<!-- Header -->
<div class="flex items-center gap-3">
@@ -289,17 +296,6 @@ const statusBadgeText = computed(() => {
</div>
</div>
<!-- Error Message -->
<div
v-if="status === 'error' && errorMessage"
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<p class="text-xs text-red-600 dark:text-red-400">
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
{{ errorMessage }}
</p>
</div>
<!-- Details -->
<div v-if="details && details.length > 0" class="space-y-2">
<Separator />
@@ -307,12 +303,12 @@ const statusBadgeText = computed(() => {
<div
v-for="(detail, index) in details"
:key="index"
class="flex items-center justify-between text-xs"
class="flex items-start justify-between gap-3 text-xs"
>
<span class="text-muted-foreground">{{ detail.label }}</span>
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
<span
:class="cn(
'font-medium',
'font-medium text-right break-words min-w-0',
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :

View File

@@ -632,12 +632,12 @@ watch(
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Monitor class="h-4 w-4" />
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
<Monitor class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">{{ buttonText }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>

View File

@@ -1202,12 +1202,17 @@ html.dark .hg-theme-default .hg-button.down-key,
}
@media (max-width: 640px) {
.vkb-body {
padding: 4px;
gap: 4px;
}
.vkb .simple-keyboard .hg-button {
height: 30px;
height: 28px;
font-size: 10px;
padding: 0 4px;
margin: 0 1px 3px 0;
min-width: 26px;
padding: 0 3px;
margin: 0 1px 2px 0;
min-width: 24px;
}
.vkb .simple-keyboard .hg-button.combination-key {
@@ -1275,6 +1280,85 @@ html.dark .hg-theme-default .hg-button.down-key,
}
}
@media (max-width: 400px) {
.vkb .simple-keyboard .hg-button {
height: 26px;
font-size: 9px;
padding: 0 2px;
margin: 0 1px 2px 0;
min-width: 20px;
border-radius: 4px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 100px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
min-width: 44px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
min-width: 50px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 34px;
}
.vkb .simple-keyboard .hg-button.combination-key {
font-size: 8px;
height: 22px;
padding: 0 4px;
}
.kb-control-container .hg-button {
min-width: 34px !important;
}
.kb-arrows-container .hg-button {
min-width: 30px !important;
width: 30px !important;
}
.vkb-media-btn {
padding: 3px 6px;
font-size: 12px;
min-width: 28px;
}
.vkb-header {
padding: 2px 6px;
min-height: 24px;
}
.vkb-btn {
padding: 1px 6px;
font-size: 10px;
}
.vkb-os-btn {
padding: 1px 6px;
font-size: 9px;
}
.vkb-title {
font-size: 10px;
}
}
/* Floating mode - slightly smaller keys but still readable */
.vkb--floating .vkb-body {
padding: 8px;