mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 19:51:58 +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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
39
web/src/components/BrandMark.vue
Normal file
39
web/src/components/BrandMark.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' :
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user