mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
feat(web): 新增控制台布局切换并完善状态与磁盘交互
新增经典、悬浮和侧栏布局,保存用户偏好,并调整工具栏、状态摘要及窄屏交互。 统一控制台弹层外观、焦点行为和中英文文案。 根据虚拟磁盘文件访问能力显示浏览入口、容量信息和不可用状态。 验证:npm run build 通过(vue-tsc 类型检查和 Vite 生产构建)。
This commit is contained in:
@@ -583,6 +583,16 @@ export interface DriveFile {
|
||||
size: number
|
||||
}
|
||||
|
||||
export type DriveFileAccess = 'available' | 'unsupported' | 'blocked_while_connected' | 'unknown'
|
||||
|
||||
export interface DriveInfo {
|
||||
size: number
|
||||
used: number | null
|
||||
free: number | null
|
||||
initialized: boolean
|
||||
file_access: DriveFileAccess
|
||||
}
|
||||
|
||||
export type DiskMode = 'single' | 'multi'
|
||||
export type MountedMediaKind = 'drive' | 'image'
|
||||
|
||||
@@ -604,12 +614,7 @@ export const msdApi = {
|
||||
slot_capacity: number
|
||||
mounted_count: number
|
||||
mounted_media: MountedMedia[]
|
||||
drive_info: {
|
||||
size: number
|
||||
used: number
|
||||
free: number
|
||||
initialized: boolean
|
||||
} | null
|
||||
drive_info: DriveInfo | null
|
||||
usb_reenumerating: boolean
|
||||
}
|
||||
}>('/msd/status', {}, { toastOnError: false }),
|
||||
@@ -650,20 +655,10 @@ export const msdApi = {
|
||||
request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }),
|
||||
|
||||
driveInfo: () =>
|
||||
request<{
|
||||
size: number
|
||||
used: number
|
||||
free: number
|
||||
initialized: boolean
|
||||
}>('/msd/drive', {}, { toastOnError: false }),
|
||||
request<DriveInfo>('/msd/drive', {}, { toastOnError: false }),
|
||||
|
||||
initDrive: (sizeMb?: number) =>
|
||||
request<{
|
||||
size: number
|
||||
used: number
|
||||
free: number
|
||||
initialized: boolean
|
||||
}>(
|
||||
request<DriveInfo>(
|
||||
'/msd/drive/init',
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -39,6 +39,10 @@ import {
|
||||
Terminal,
|
||||
MoreHorizontal,
|
||||
Bot,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Keyboard,
|
||||
Scaling,
|
||||
} from 'lucide-vue-next'
|
||||
import PasteModal from '@/components/PasteModal.vue'
|
||||
import AtxPopover from '@/components/AtxPopover.vue'
|
||||
@@ -47,6 +51,7 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
||||
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
||||
import MsdDialog from '@/components/MsdDialog.vue'
|
||||
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
||||
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -60,6 +65,7 @@ const showMsd = computed(() => {
|
||||
return !!systemStore.msd?.available && !isCh9329Backend.value
|
||||
})
|
||||
const props = defineProps<{
|
||||
layout?: ConsoleLayout
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
videoMode?: VideoMode
|
||||
ttydRunning?: boolean
|
||||
@@ -71,6 +77,18 @@ const props = defineProps<{
|
||||
scaleMode?: VideoScaleMode
|
||||
sourceSizeAvailable?: boolean
|
||||
}>()
|
||||
const isSidebarLayout = computed(() => props.layout === 'sidebar')
|
||||
const isFloatingLayout = computed(() => props.layout === 'floating')
|
||||
const floatingCollapsed = ref(false)
|
||||
const expandButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
const collapseButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
async function setFloatingCollapsed(collapsed: boolean) {
|
||||
floatingCollapsed.value = collapsed
|
||||
await nextTick()
|
||||
const target = collapsed ? expandButtonRef.value : collapseButtonRef.value
|
||||
target?.$el?.focus()
|
||||
}
|
||||
const showAtx = computed(() => props.showPower !== false)
|
||||
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
||||
const showPasteText = computed(() => props.showPasteText !== false)
|
||||
@@ -133,13 +151,18 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
const barRef = ref<HTMLElement | null>(null)
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const barWidth = ref(0)
|
||||
const barHeight = ref(0)
|
||||
const coreWidth = ref(0)
|
||||
const coreHeight = ref(0)
|
||||
const fixedHeight = ref(0)
|
||||
const actionHeight = ref(36)
|
||||
const minimalDisplayControls = computed(() => isFloatingLayout.value && barWidth.value < 640)
|
||||
const alwaysRightWidth = ref(152)
|
||||
let layoutResizeObserver: ResizeObserver | null = null
|
||||
|
||||
type CollapsibleItem =
|
||||
| 'video' | 'audio' | 'hid'
|
||||
| 'msd' | 'atx' | 'paste'
|
||||
| 'stats' | 'terminal' | 'settings'
|
||||
| 'stats' | 'terminal' | 'settings' | 'ai'
|
||||
|
||||
interface ItemSpec {
|
||||
id: CollapsibleItem
|
||||
@@ -147,15 +170,13 @@ interface ItemSpec {
|
||||
}
|
||||
|
||||
const ITEM_SPECS: ItemSpec[] = [
|
||||
{ id: 'video', side: 'left' },
|
||||
{ id: 'audio', side: 'left' },
|
||||
{ id: 'hid', side: 'left' },
|
||||
{ id: 'msd', side: 'left' },
|
||||
{ id: 'atx', side: 'left' },
|
||||
{ id: 'paste', side: 'left' },
|
||||
{ id: 'stats', side: 'right' },
|
||||
{ id: 'terminal', side: 'right' },
|
||||
{ id: 'settings', side: 'right' },
|
||||
{ id: 'ai', side: 'right' },
|
||||
]
|
||||
|
||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||
@@ -167,7 +188,13 @@ const measureLayout = async () => {
|
||||
const measureContainer = measureRef.value
|
||||
if (!bar || !measureContainer) return
|
||||
|
||||
barWidth.value = bar.clientWidth
|
||||
const style = window.getComputedStyle(bar)
|
||||
barWidth.value = bar.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
|
||||
barHeight.value = bar.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||
const core = Array.from(bar.querySelectorAll<HTMLElement>('[data-core-action]'))
|
||||
coreWidth.value = core.reduce((sum, element) => sum + element.offsetWidth, 0)
|
||||
coreHeight.value = core.reduce((sum, element) => sum + element.offsetHeight, 0)
|
||||
actionHeight.value = core[0]?.offsetHeight || 36
|
||||
|
||||
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
|
||||
for (const spec of ITEM_SPECS) {
|
||||
@@ -190,7 +217,12 @@ const measureLayout = async () => {
|
||||
+ Number.parseFloat(style.marginLeft || '0')
|
||||
+ Number.parseFloat(style.marginRight || '0')
|
||||
}, 0)
|
||||
if (width > 0) alwaysRightWidth.value = Math.ceil(width)
|
||||
alwaysRightWidth.value = Math.ceil(width)
|
||||
fixedHeight.value = elements.reduce((sum, element) => {
|
||||
const style = window.getComputedStyle(element)
|
||||
return sum + element.getBoundingClientRect().height
|
||||
+ parseFloat(style.marginTop || '0') + parseFloat(style.marginBottom || '0')
|
||||
}, 0)
|
||||
|
||||
measurementReady.value = true
|
||||
}
|
||||
@@ -202,7 +234,7 @@ const observeLayout = async () => {
|
||||
void measureLayout()
|
||||
})
|
||||
if (barRef.value) layoutResizeObserver.observe(barRef.value)
|
||||
barRef.value?.querySelectorAll('[data-fixed-action]').forEach((element) => {
|
||||
barRef.value?.querySelectorAll('[data-fixed-action], [data-core-action]').forEach((element) => {
|
||||
layoutResizeObserver?.observe(element)
|
||||
})
|
||||
}
|
||||
@@ -220,7 +252,7 @@ watch(locale, () => {
|
||||
void measureLayout()
|
||||
})
|
||||
|
||||
watch(() => props.showComputerUse, () => {
|
||||
watch([() => props.layout, () => props.showComputerUse, floatingCollapsed, minimalDisplayControls], () => {
|
||||
void observeLayout()
|
||||
})
|
||||
|
||||
@@ -241,52 +273,55 @@ watch(showPasteText, (visible) => {
|
||||
const OVERFLOW_BUTTON_BUDGET_PX = 36
|
||||
|
||||
const collapsibleItems = computed(() => {
|
||||
const items = ITEM_SPECS.slice(3).filter(item => {
|
||||
const items = ITEM_SPECS.filter(item => {
|
||||
if (isFloatingLayout.value && item.side === 'right') return false
|
||||
if (item.id === 'msd' && !showMsd.value) return false
|
||||
if (item.id === 'atx' && !showAtx.value) return false
|
||||
if (item.id === 'paste' && !showPasteText.value) return false
|
||||
if (item.id === 'stats' && !showStats.value) return false
|
||||
if (item.id === 'terminal' && props.showTerminal === false) return false
|
||||
if (item.id === 'ai' && props.showComputerUse === false) return false
|
||||
return true
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
const visibleSet = computed(() => {
|
||||
if (!measurementReady.value) {
|
||||
return new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
}
|
||||
|
||||
const available = barWidth.value - alwaysRightWidth.value - OVERFLOW_BUTTON_BUDGET_PX
|
||||
|
||||
let used = 0
|
||||
if (barRef.value) {
|
||||
const leftContainer = barRef.value.querySelector('.left-buttons') as HTMLElement
|
||||
if (leftContainer) {
|
||||
const children = Array.from(leftContainer.children).slice(0, 3) as HTMLElement[]
|
||||
used = children.reduce((sum, el) => sum + el.offsetWidth, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (used === 0) used = 330
|
||||
|
||||
const result = new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
if (!measurementReady.value) return result
|
||||
|
||||
if (isSidebarLayout.value) {
|
||||
// Reserve More and a gap between the two groups before assigning vertical slots.
|
||||
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
|
||||
const priority: CollapsibleItem[] = ['paste', 'settings', 'msd', 'atx', 'stats', 'terminal', 'ai']
|
||||
for (const id of priority) {
|
||||
if (!collapsibleItems.value.some(item => item.id === id)) continue
|
||||
if (available < actionHeight.value) break
|
||||
result.set(id, 'icon')
|
||||
available -= actionHeight.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const available = barWidth.value - alwaysRightWidth.value - Math.max(OVERFLOW_BUTTON_BUDGET_PX, actionHeight.value) - (isFloatingLayout.value ? 48 : 12)
|
||||
let used = coreWidth.value
|
||||
// Keep actions reachable before spending the remaining space on labels.
|
||||
for (const item of collapsibleItems.value) {
|
||||
const widths = measuredWidths.value.get(item.id)
|
||||
if (!widths) continue
|
||||
|
||||
if (used + widths.icon <= available) {
|
||||
if (used + widths.label <= available) {
|
||||
result.set(item.id, 'label')
|
||||
used += widths.label
|
||||
} else {
|
||||
if (!widths || used + widths.icon > available) continue
|
||||
result.set(item.id, 'icon')
|
||||
used += widths.icon
|
||||
}
|
||||
if (isFloatingLayout.value && barWidth.value < 640) return result
|
||||
for (const item of collapsibleItems.value) {
|
||||
const widths = measuredWidths.value.get(item.id)
|
||||
if (!widths || !result.has(item.id)) continue
|
||||
const extra = widths.label - widths.icon
|
||||
if (used + extra <= available) {
|
||||
result.set(item.id, 'label')
|
||||
used += extra
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -304,36 +339,85 @@ const hasRightOverflow = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-b bg-background">
|
||||
<div ref="barRef" class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||
<div
|
||||
:class="[
|
||||
'console-action-bar bg-background',
|
||||
props.layout === 'floating' && 'console-action-bar--floating',
|
||||
isFloatingLayout && floatingCollapsed && 'console-action-bar--collapsed',
|
||||
props.layout === 'sidebar' && 'console-action-bar--sidebar',
|
||||
(!props.layout || props.layout === 'current') && 'w-full border-b',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
v-if="isFloatingLayout && floatingCollapsed"
|
||||
ref="expandButtonRef"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="console-action-bar__expand gap-1.5 rounded-xl text-xs"
|
||||
:aria-label="t('actionbar.expandToolbar')"
|
||||
:aria-expanded="false"
|
||||
@click="setFloatingCollapsed(false)"
|
||||
>
|
||||
<ChevronDown class="size-4" />{{ t('actionbar.expandToolbar') }}
|
||||
</Button>
|
||||
<div
|
||||
v-show="!isFloatingLayout || !floatingCollapsed"
|
||||
ref="barRef"
|
||||
class="console-action-bar__inner flex items-center"
|
||||
:class="isSidebarLayout
|
||||
? 'h-full flex-col px-1 py-2 sm:px-1.5'
|
||||
: 'px-2 py-1 sm:px-4 sm:py-1.5'"
|
||||
>
|
||||
<!-- Left side buttons -->
|
||||
<ButtonGroup class="left-buttons flex-1 min-w-0 overflow-hidden">
|
||||
<ButtonGroup
|
||||
class="left-buttons min-w-0"
|
||||
:class="isSidebarLayout
|
||||
? 'flex-none flex-col overflow-visible'
|
||||
: 'flex-1 overflow-hidden'"
|
||||
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
<!-- Video Config - Always visible -->
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
:video-mode="props.videoMode || 'mjpeg'"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
@update:video-mode="emit('update:videoMode', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Audio Config - Always visible -->
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<AudioConfigPopover
|
||||
v-model:open="audioPopoverOpen"
|
||||
:microphone-enabled="showMic"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- HID Config - Always visible -->
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<HidConfigPopover
|
||||
v-model:open="hidPopoverOpen"
|
||||
:mouse-mode="mouseMode"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
@update:mouse-mode="emit('toggleMouseMode')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Media (MSD) - Adaptive -->
|
||||
<div v-if="showMsd && isVisible('msd')">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.virtualMedia')"
|
||||
:title="t('actionbar.virtualMedia')"
|
||||
@click="msdDialogOpen = true"
|
||||
>
|
||||
<HardDrive class="size-4" />
|
||||
<span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span>
|
||||
</Button>
|
||||
@@ -349,12 +433,22 @@ const hasRightOverflow = computed(() => {
|
||||
<div v-if="showAtx && isVisible('atx')">
|
||||
<Popover v-model:open="atxOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.power')"
|
||||
:title="t('actionbar.power')"
|
||||
>
|
||||
<Power class="size-4" />
|
||||
<span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
|
||||
<PopoverContent
|
||||
class="w-[min(280px,90vw)] p-0"
|
||||
align="start"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
>
|
||||
<AtxPopover
|
||||
@close="atxOpen = false"
|
||||
@power-short="emit('powerShort')"
|
||||
@@ -370,12 +464,24 @@ const hasRightOverflow = computed(() => {
|
||||
<div v-if="showPasteText && isVisible('paste')">
|
||||
<Popover v-model:open="pasteOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.paste')"
|
||||
:title="t('actionbar.paste')"
|
||||
>
|
||||
<ClipboardPaste class="size-4" />
|
||||
<span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
|
||||
<PopoverContent
|
||||
:class="isSidebarLayout
|
||||
? 'w-[min(400px,calc(100vw-4.5rem))] p-0'
|
||||
: 'w-[min(400px,90vw)] p-0'"
|
||||
align="start"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
>
|
||||
<PasteModal v-if="pasteOpen" @close="pasteOpen = false" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -384,8 +490,16 @@ const hasRightOverflow = computed(() => {
|
||||
</ButtonGroup>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<ButtonGroup class="shrink-0 ml-1 sm:ml-2">
|
||||
<ButtonGroup
|
||||
class="shrink-0"
|
||||
:class="isSidebarLayout
|
||||
? 'mt-auto flex-none flex-col'
|
||||
: 'ml-1 sm:ml-2'"
|
||||
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
<VideoDisplayControls
|
||||
:minimal="minimalDisplayControls"
|
||||
:text-only-scale="isSidebarLayout"
|
||||
:scale-mode="props.scaleMode"
|
||||
:source-size-available="props.sourceSizeAvailable"
|
||||
@toggle-fullscreen="emit('toggleFullscreen')"
|
||||
@@ -398,7 +512,7 @@ const hasRightOverflow = computed(() => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" @click="emit('toggleStats')">
|
||||
<BarChart3 class="size-4" />
|
||||
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
@@ -420,6 +534,7 @@ const hasRightOverflow = computed(() => {
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:disabled="!props.ttydRunning"
|
||||
:aria-label="t('actionbar.webTerminal')"
|
||||
@click="emit('openTerminal')"
|
||||
>
|
||||
<Terminal class="size-4" />
|
||||
@@ -434,18 +549,18 @@ const hasRightOverflow = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Computer Use - Optional -->
|
||||
<TooltipProvider v-if="props.showComputerUse !== false">
|
||||
<TooltipProvider v-if="isVisible('ai')">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
data-fixed-action
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('computerUse.title')"
|
||||
@click="emit('openComputerUse')"
|
||||
>
|
||||
<Bot class="size-3.5 sm:size-4" />
|
||||
<span class="hidden xl:inline">AI</span>
|
||||
<span v-if="visibleSet.get('ai') === 'label'">AI</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -459,7 +574,7 @@ const hasRightOverflow = computed(() => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.settings')" @click="router.push('/settings')">
|
||||
<Settings class="size-4" />
|
||||
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
@@ -472,13 +587,25 @@ const hasRightOverflow = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Overflow Menu - Only show if there are overflowed items -->
|
||||
<DropdownMenu v-if="hasOverflow" v-model:open="overflowMenuOpen">
|
||||
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 p-0">
|
||||
<Button variant="ghost" size="sm" class="size-8 p-0" :aria-label="t('actionbar.more')" :title="t('actionbar.more')">
|
||||
<MoreHorizontal class="size-3.5 sm:size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<DropdownMenuContent align="end" :side="isSidebarLayout ? 'right' : 'bottom'" class="w-56 max-h-[70dvh] overflow-y-auto">
|
||||
<template v-if="minimalDisplayControls">
|
||||
<DropdownMenuItem @click="openFromOverflow(() => emit('toggleVirtualKeyboard'))">
|
||||
<Keyboard class="size-4 mr-2" />{{ t('actionbar.keyboard') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
:disabled="!props.sourceSizeAvailable"
|
||||
@click="emit('update:scaleMode', props.scaleMode === 'actual' ? 'fit' : 'actual')"
|
||||
>
|
||||
<Scaling class="size-4 mr-2" />{{ t(props.scaleMode === 'actual' ? 'actionbar.fitSizeAria' : 'actionbar.actualSizeAria') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</template>
|
||||
<!-- MSD -->
|
||||
<DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||
<HardDrive class="size-4 mr-2" />
|
||||
@@ -500,14 +627,14 @@ const hasRightOverflow = computed(() => {
|
||||
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
|
||||
|
||||
<!-- Stats -->
|
||||
<DropdownMenuItem v-if="showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<BarChart3 class="size-4 mr-2" />
|
||||
{{ t('actionbar.stats') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Web Terminal -->
|
||||
<DropdownMenuItem
|
||||
v-if="props.showTerminal !== false && !isVisible('terminal')"
|
||||
v-if="!isFloatingLayout && props.showTerminal !== false && !isVisible('terminal')"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||
>
|
||||
@@ -515,13 +642,30 @@ const hasRightOverflow = computed(() => {
|
||||
{{ t('actionbar.webTerminal') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && props.showComputerUse !== false && !isVisible('ai')" @click="openFromOverflow(() => emit('openComputerUse'))">
|
||||
<Bot class="size-4 mr-2" />{{ t('computerUse.title') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Settings -->
|
||||
<DropdownMenuItem v-if="!isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && !isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<Settings class="size-4 mr-2" />
|
||||
{{ t('actionbar.settings') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
v-if="isFloatingLayout"
|
||||
ref="collapseButtonRef"
|
||||
data-fixed-action
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:aria-label="t('actionbar.collapseToolbar')"
|
||||
:title="t('actionbar.collapseToolbar')"
|
||||
:aria-expanded="true"
|
||||
@click="setFloatingCollapsed(true)"
|
||||
>
|
||||
<ChevronUp class="size-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,13 +732,131 @@ const hasRightOverflow = computed(() => {
|
||||
<!-- Settings -->
|
||||
<Button data-measure="settings-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" /></Button>
|
||||
<Button data-measure="settings-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" />{{ t('actionbar.settings') }}</Button>
|
||||
<!-- Always-visible items (for measuring their actual width) -->
|
||||
<Button data-measure="video-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="video-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="audio-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="audio-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="hid-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="hid-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="ai-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" /></Button>
|
||||
<Button data-measure="ai-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" />AI</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.console-action-bar--floating {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in srgb, var(--background) 96%, transparent);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: 2.5rem;
|
||||
bottom: 1.75rem;
|
||||
left: 0;
|
||||
width: 3.5rem;
|
||||
border-right: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--background) 94%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep([data-slot='button-group']) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep(button) {
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
padding-inline: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep(button > span) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep([data-fixed-action][aria-hidden='true']) {
|
||||
width: 1.5rem;
|
||||
height: 1px;
|
||||
margin: 0.35rem auto;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar .console-action-bar__inner {
|
||||
overflow-y: auto;
|
||||
gap: 1rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding: 4px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating :deep([data-slot='button-group']) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
min-width: 36px;
|
||||
height: 38px;
|
||||
padding-inline: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button[aria-expanded='true']) {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding-inline: 4px;
|
||||
gap: 0;
|
||||
}
|
||||
.console-action-bar--floating :deep([data-slot='button-group']) {
|
||||
gap: 0;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
width: 36px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.console-action-bar--floating.console-action-bar--collapsed {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.console-action-bar__expand {
|
||||
min-height: 44px;
|
||||
}
|
||||
.console-action-bar--sidebar :deep(button) {
|
||||
height: 44px;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding-inline: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.console-action-bar--sidebar {
|
||||
top: 3.5rem;
|
||||
width: 4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Loader2, RefreshCw, Volume2 } from 'lucide-vue-next'
|
||||
import { Loader2, RefreshCw, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
|
||||
import { audioApi, configApi } from '@/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -29,6 +30,7 @@ interface AudioDevice {
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
microphoneEnabled?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,6 +42,7 @@ const configStore = useConfigStore()
|
||||
const systemStore = useSystemStore()
|
||||
const unifiedAudio = getUnifiedAudio()
|
||||
const microphone = getMicrophone()
|
||||
const playbackMuted = computed(() => unifiedAudio.muted.value || unifiedAudio.volume.value === 0)
|
||||
|
||||
const localVolume = ref([unifiedAudio.volume.value * 100])
|
||||
const devices = ref<AudioDevice[]>([])
|
||||
@@ -150,13 +153,21 @@ onUnmounted(() => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||
:aria-label="playbackMuted ? `${t('actionbar.audioConfig')} · ${t('actionbar.muted')}` : t('actionbar.audioConfig')"
|
||||
:title="playbackMuted ? `${t('actionbar.audioConfig')} · ${t('actionbar.muted')}` : t('actionbar.audioConfig')"
|
||||
>
|
||||
<Volume2 class="size-3.5 sm:size-4" />
|
||||
<VolumeX v-if="playbackMuted" class="size-3.5 sm:size-4" />
|
||||
<Volume2 v-else class="size-3.5 sm:size-4" />
|
||||
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||
<PopoverContent
|
||||
@open-auto-focus="focusConsolePanel"
|
||||
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||
align="start"
|
||||
:side="props.side ?? 'bottom'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
@@ -215,6 +217,8 @@ onMounted(loadConfig)
|
||||
|
||||
<template>
|
||||
<aside
|
||||
data-slot="computer-use-panel"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-show="open"
|
||||
class="absolute inset-y-0 right-0 z-30 h-full min-h-0 w-full border-l bg-background shadow-xl sm:w-[420px] md:relative md:z-auto xl:w-[460px]"
|
||||
>
|
||||
|
||||
56
web/src/components/ConsoleHeaderActions.vue
Normal file
56
web/src/components/ConsoleHeaderActions.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BarChart3, Bot, Settings, Terminal, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
defineProps<{
|
||||
showStats: boolean
|
||||
showTerminal: boolean
|
||||
terminalRunning: boolean
|
||||
showComputerUse: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'openStats'): void
|
||||
(e: 'openTerminal'): void
|
||||
(e: 'openComputerUse'): void
|
||||
}>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const menuOpen = ref(false)
|
||||
function openFromMenu(action: () => void) {
|
||||
menuOpen.value = false
|
||||
window.setTimeout(action, 50)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="console-header-actions flex shrink-0 items-center gap-1">
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
<Button v-if="showStats" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.stats')" :title="t('actionbar.stats')" @click="emit('openStats')">
|
||||
<BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
<Button v-if="showTerminal" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.webTerminal')" :title="t('actionbar.webTerminal')" :disabled="!terminalRunning" @click="emit('openTerminal')">
|
||||
<Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span>
|
||||
</Button>
|
||||
<Button v-if="showComputerUse" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('computerUse.title')" :title="t('computerUse.title')" @click="emit('openComputerUse')">
|
||||
<Bot class="size-4" /><span class="hidden lg:inline">AI</span>
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenu v-if="showStats || showTerminal || showComputerUse" v-model:open="menuOpen">
|
||||
<DropdownMenuTrigger as-child class="md:hidden">
|
||||
<Button variant="ghost" size="icon-sm" :aria-label="t('actionbar.more')"><MoreHorizontal class="size-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<DropdownMenuItem v-if="showStats" @select="openFromMenu(() => emit('openStats'))"><BarChart3 class="size-4" />{{ t('actionbar.stats') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="showComputerUse" @select="openFromMenu(() => emit('openComputerUse'))"><Bot class="size-4" />{{ t('computerUse.title') }}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="ghost" size="sm" class="gap-1.5 rounded-lg px-2 text-xs" :aria-label="t('actionbar.settings')" :title="t('actionbar.settings')" @click="router.push('/settings')">
|
||||
<Settings class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
87
web/src/components/ConsoleStatusSummary.vue
Normal file
87
web/src/components/ConsoleStatusSummary.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDown, AlertCircle, Info } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import type { StatusDetail, ConnectionStatus } from '@/components/StatusCard.vue'
|
||||
|
||||
export interface ConsoleStatusItem {
|
||||
id: string
|
||||
title: string
|
||||
status: ConnectionStatus
|
||||
quickInfo: string
|
||||
details: StatusDetail[]
|
||||
errorMessage?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: ConsoleStatusItem[]
|
||||
showVideoInfo?: boolean
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const issues = computed(() => props.items.filter(item =>
|
||||
['error', 'no_signal', 'busy'].includes(item.status) || (item.required && item.status !== 'connected'),
|
||||
))
|
||||
const summary = computed(() => issues.value.length
|
||||
? issues.value.map(item => `${item.title}: ${t(`status.${item.status}`)}`).join(' · ')
|
||||
: t('status.connected'))
|
||||
const videoInfo = computed(() => props.items.find(item => item.id === 'video')?.quickInfo)
|
||||
const hasError = computed(() => issues.value.some(item => item.status === 'error'))
|
||||
|
||||
function dotClass(status: ConsoleStatusItem['status']) {
|
||||
return {
|
||||
connected: 'bg-status-active',
|
||||
connecting: 'bg-warning animate-pulse',
|
||||
disconnected: 'bg-muted-foreground',
|
||||
error: 'bg-destructive',
|
||||
no_signal: 'bg-warning',
|
||||
busy: 'bg-warning',
|
||||
}[status]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="min-w-0 shrink gap-1.5 px-2 text-xs"
|
||||
:class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''"
|
||||
:aria-label="`${t('statusCard.connectionDetails')}: ${summary}`"
|
||||
:title="summary"
|
||||
>
|
||||
<AlertCircle v-if="hasError" class="size-3.5 shrink-0" />
|
||||
<Info v-else-if="issues.length" class="size-3.5 shrink-0" />
|
||||
<span v-else class="size-2 shrink-0 rounded-full bg-status-active" />
|
||||
<span class="truncate" role="status">{{ summary }}</span>
|
||||
<span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span>
|
||||
<ChevronDown class="size-3 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-[min(360px,calc(100vw-1rem))] max-h-[70dvh] overflow-y-auto">
|
||||
<h2 class="mb-3 text-sm font-semibold">{{ t('statusCard.connectionDetails') }}</h2>
|
||||
<div class="divide-y">
|
||||
<section v-for="item in items" :key="item.id" class="space-y-2 py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="font-medium">{{ item.title }}</span>
|
||||
<span class="flex items-center gap-1.5 text-xs">
|
||||
<span class="size-1.5 rounded-full" :class="dotClass(item.status)" />
|
||||
{{ t(`status.${item.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="item.quickInfo" class="text-xs text-muted-foreground">{{ item.quickInfo }}</p>
|
||||
<p v-if="item.errorMessage" class="break-words text-xs" :class="item.status === 'error' ? 'text-destructive' : 'text-muted-foreground'">{{ item.errorMessage }}</p>
|
||||
<dl class="space-y-1 text-xs">
|
||||
<div v-for="(detail, index) in item.details" :key="index" class="flex justify-between gap-4">
|
||||
<dt class="shrink-0 text-muted-foreground">{{ detail.label }}</dt>
|
||||
<dd class="min-w-0 break-words text-right" :class="detail.status === 'error' ? 'text-destructive' : detail.status === 'warning' ? 'text-warning' : ''">{{ detail.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
@@ -29,6 +30,7 @@ import type { HidConfigUpdate } from '@/types/generated'
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -231,13 +233,24 @@ watch(() => props.open, (isOpen) => {
|
||||
<template>
|
||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
:aria-label="buttonText"
|
||||
:title="buttonText"
|
||||
>
|
||||
<MousePointer v-if="mouseMode === 'absolute'" class="size-3.5 sm:size-4" />
|
||||
<Move v-else class="size-3.5 sm:size-4" />
|
||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||
<PopoverContent
|
||||
@open-auto-focus="focusConsolePanel"
|
||||
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||
align="start"
|
||||
:side="props.side ?? 'bottom'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ const props = defineProps<{
|
||||
mousePosition?: { x: number; y: number }
|
||||
debugMode?: boolean
|
||||
compact?: boolean
|
||||
captured?: boolean
|
||||
minimal?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -45,22 +47,23 @@ const keysDisplay = computed(() => {
|
||||
<div class="w-full border-t bg-background">
|
||||
<!-- 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">
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-warning'" />C
|
||||
<div class="flex items-center justify-between gap-3 text-xs px-2 h-7">
|
||||
<div v-if="keyboardLedEnabled && !minimal" class="flex items-center gap-1">
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Caps
|
||||
</Badge>
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-warning'" />N
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Num
|
||||
</Badge>
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-warning'" />S
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Scroll
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else class="text-[10px] text-muted-foreground/60">
|
||||
<div v-else-if="!minimal" class="text-xs text-muted-foreground">
|
||||
{{ t('infobar.keyboardLedUnavailable') }}
|
||||
</div>
|
||||
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[200px]">
|
||||
<span v-if="captured && minimal" class="min-w-0 truncate text-xs text-muted-foreground" :title="t('infobar.pointerCaptured')">{{ t('infobar.pointerCaptured') }}</span>
|
||||
<div v-if="keysDisplay" class="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{{ keysDisplay }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,15 +90,15 @@ const keysDisplay = computed(() => {
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<template v-if="keyboardLedEnabled">
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-warning'" />
|
||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||
{{ t('infobar.caps') }}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-warning'" />
|
||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||
{{ t('infobar.num') }}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-warning'" />
|
||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||
{{ t('infobar.scroll') }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { msdApi, type MsdImage, type DriveFile, type MountedMedia, type DiskMode } from '@/api'
|
||||
import { msdApi, type MsdImage, type DriveFile, type DriveInfo, type MountedMedia, type DiskMode } from '@/api'
|
||||
import { ApiError, localizeMsdErrorCode } from '@/api/request'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import {
|
||||
@@ -92,13 +92,12 @@ const showMountOptionsDialog = ref(false)
|
||||
const driveFiles = ref<DriveFile[]>([])
|
||||
const currentPath = ref('/')
|
||||
const loadingDrive = ref(false)
|
||||
const driveInfo = ref<{ size: number; used: number; free: number; initialized: boolean } | null>(null)
|
||||
const driveInfo = ref<DriveInfo | null>(null)
|
||||
const driveInitialized = ref(false)
|
||||
const uploadingFile = ref(false)
|
||||
const fileUploadProgress = ref(0)
|
||||
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
|
||||
const driveErrorCode = ref<string | null>(null)
|
||||
const driveFilesystemUnsupported = computed(() => driveErrorCode.value === 'MSD_DRIVE_FILESYSTEM_UNSUPPORTED')
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
||||
@@ -175,6 +174,14 @@ const mediaSlotsFull = computed(() => mountedCount.value >= slotCapacity.value)
|
||||
const driveMedia = computed(() => mountedMedia.value.find(media => media.kind === 'drive') ?? null)
|
||||
// Drive is currently mounted on the target machine via USB — file ops are blocked
|
||||
const driveConnectedToTarget = computed(() => !!driveMedia.value)
|
||||
const driveFileAccess = computed(() => {
|
||||
if (driveConnectedToTarget.value) return 'blocked_while_connected'
|
||||
return driveInfo.value?.file_access ?? 'unknown'
|
||||
})
|
||||
const driveFilesAvailable = computed(() => driveFileAccess.value === 'available')
|
||||
const driveFilesystemUnsupported = computed(() =>
|
||||
driveFileAccess.value === 'unsupported' || driveErrorCode.value === 'MSD_DRIVE_FILESYSTEM_UNSUPPORTED',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -253,7 +260,7 @@ async function loadData() {
|
||||
await refreshMsdState()
|
||||
await loadImages()
|
||||
await loadDriveInfo()
|
||||
if (driveInitialized.value) {
|
||||
if (driveFilesAvailable.value) {
|
||||
await loadDriveFiles()
|
||||
}
|
||||
}
|
||||
@@ -369,7 +376,6 @@ async function unmountMedia(media: MountedMedia) {
|
||||
try {
|
||||
if (media.kind === 'drive') {
|
||||
await msdApi.unmountDrive()
|
||||
await refreshDriveBrowser()
|
||||
} else {
|
||||
await msdApi.unmountImage(media.id)
|
||||
}
|
||||
@@ -444,6 +450,7 @@ async function loadDriveInfo() {
|
||||
try {
|
||||
driveInfo.value = await msdApi.driveInfo()
|
||||
driveInitialized.value = true
|
||||
driveFiles.value = driveFilesAvailable.value ? driveFiles.value : []
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === 'MSD_DRIVE_NOT_INITIALIZED' || e.status === 404) {
|
||||
@@ -486,7 +493,7 @@ async function createDrive() {
|
||||
const sizeMb = finalDriveSize.value
|
||||
await msdApi.initDrive(sizeMb)
|
||||
await loadDriveInfo()
|
||||
await loadDriveFiles()
|
||||
if (driveFilesAvailable.value) await loadDriveFiles()
|
||||
await refreshDiskSpace()
|
||||
showDriveInitDialog.value = false
|
||||
} catch (e) {
|
||||
@@ -516,7 +523,7 @@ async function deleteDrive() {
|
||||
async function loadDriveFiles() {
|
||||
// Do not read image file while it is mounted on the target machine:
|
||||
// concurrent access causes filesystem corruption (Windows error 0x80070570)
|
||||
if (driveConnectedToTarget.value) {
|
||||
if (driveConnectedToTarget.value || !driveFilesAvailable.value) {
|
||||
driveFiles.value = []
|
||||
return
|
||||
}
|
||||
@@ -538,7 +545,7 @@ async function loadDriveFiles() {
|
||||
|
||||
async function refreshDriveBrowser() {
|
||||
await loadDriveInfo()
|
||||
if (driveInitialized.value) {
|
||||
if (driveFilesAvailable.value) {
|
||||
await loadDriveFiles()
|
||||
} else {
|
||||
driveFiles.value = []
|
||||
@@ -965,20 +972,27 @@ onUnmounted(() => {
|
||||
? 'border-primary bg-primary/5'
|
||||
: driveError
|
||||
? 'border-destructive/40 bg-destructive/5'
|
||||
: driveFilesystemUnsupported
|
||||
? 'border-warning/40 bg-warning/5'
|
||||
: 'bg-muted/50'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<HardDrive class="size-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{{ t('msd.drive') }}</span>
|
||||
<!-- Show size badge only when info is available -->
|
||||
<Badge v-if="driveInfo" variant="outline" class="text-xs">
|
||||
{{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB
|
||||
</Badge>
|
||||
<!-- Show unreadable badge when format is wrong -->
|
||||
<Badge
|
||||
v-if="driveFilesystemUnsupported"
|
||||
variant="outline"
|
||||
class="border-warning/50 text-xs text-warning"
|
||||
>
|
||||
{{ t('msd.driveUnreadable') }}
|
||||
</Badge>
|
||||
<template v-else-if="driveError">
|
||||
<Badge variant="outline" class="text-xs border-destructive/50 text-destructive">
|
||||
{{ driveFilesystemUnsupported ? t('msd.driveUnreadable') : t('common.error') }}
|
||||
{{ t('common.error') }}
|
||||
</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -993,19 +1007,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- When drive format is unrecognized, only offer re-initialization -->
|
||||
<template v-if="driveFilesystemUnsupported && !msdConnected">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
@click="initializeDrive"
|
||||
>
|
||||
{{ t('msd.reinitializeDrive') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="driveConnectedToTarget">
|
||||
<template v-if="driveConnectedToTarget">
|
||||
<Badge variant="default" class="h-8 px-2 text-xs">
|
||||
<span class="relative flex size-1.5 mr-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-foreground opacity-75"></span>
|
||||
@@ -1030,13 +1032,23 @@ onUnmounted(() => {
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress || mediaSlotsFull || !!driveError"
|
||||
:disabled="operationInProgress || mediaSlotsFull || !driveInfo || !!driveError"
|
||||
@click="connectDrive"
|
||||
>
|
||||
<Link v-if="!connecting" class="size-3.5 mr-1" />
|
||||
<span v-if="connecting">{{ t('common.connecting') }}...</span>
|
||||
<span v-else>{{ t('msd.connect') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="driveFilesystemUnsupported"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
@click="initializeDrive"
|
||||
>
|
||||
{{ t('msd.reinitializeDrive') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1049,19 +1061,29 @@ onUnmounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Storage usage bar — hidden when format is unrecognized -->
|
||||
<div v-if="driveInfo" class="space-y-1.5">
|
||||
<div
|
||||
v-if="driveFilesAvailable && driveInfo?.used !== null && driveInfo?.free !== null"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Progress
|
||||
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
|
||||
:model-value="driveInfo && driveInfo.size > 0 ? ((driveInfo.used ?? 0) / driveInfo.size) * 100 : 0"
|
||||
class="h-2"
|
||||
/>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ formatBytes(driveInfo?.used || 0) }} {{ t('msd.usedSpace') }}</span>
|
||||
<span>{{ formatBytes(driveInfo?.free || 0) }} {{ t('msd.freeSpace') }}</span>
|
||||
<span>{{ formatBytes(driveInfo?.used ?? 0) }} {{ t('msd.usedSpace') }}</span>
|
||||
<span>{{ formatBytes(driveInfo?.free ?? 0) }} {{ t('msd.freeSpace') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="driveFilesystemUnsupported"
|
||||
class="flex shrink-0 items-start gap-2 rounded-md border border-warning/40 bg-warning/5 p-3"
|
||||
>
|
||||
<Info class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('msd.driveFilesystemUnsupportedHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="driveError"
|
||||
class="flex shrink-0 items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3"
|
||||
@@ -1075,7 +1097,7 @@ onUnmounted(() => {
|
||||
|
||||
|
||||
<!-- File Browser -->
|
||||
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||
<div v-if="driveFilesAvailable" class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 flex items-center justify-between gap-2">
|
||||
@@ -1163,21 +1185,13 @@ onUnmounted(() => {
|
||||
|
||||
<!-- File List -->
|
||||
<Skeleton v-if="loadingDrive" class="h-24 w-full" />
|
||||
<Empty v-else-if="driveFiles.length === 0 && !driveConnectedToTarget && !driveError" class="shrink-0 py-6">
|
||||
<Empty v-else-if="driveFiles.length === 0 && !driveError" class="shrink-0 py-6">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><Folder /></EmptyMedia>
|
||||
<EmptyDescription>{{ t('msd.emptyFolder') }}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
|
||||
<!-- Connected placeholder: file list hidden while drive mounted on target -->
|
||||
<div
|
||||
v-else-if="driveConnectedToTarget"
|
||||
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('msd.driveConnectedFilesHidden') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
@@ -1232,6 +1246,12 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="driveConnectedToTarget"
|
||||
class="shrink-0 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('msd.driveConnectedFilesHidden') }}
|
||||
</div>
|
||||
</template>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -408,17 +408,17 @@ onUnmounted(() => {
|
||||
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<SheetContent
|
||||
side="right"
|
||||
class="w-[90vw] max-w-[440px] border-l bg-background p-0"
|
||||
class="w-[90vw] max-w-[440px] gap-0 overflow-hidden border-l bg-background p-0"
|
||||
>
|
||||
<!-- Header -->
|
||||
<SheetHeader class="border-b px-6 py-3">
|
||||
<SheetHeader class="shrink-0 border-b px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
|
||||
<Badge variant="secondary">WebRTC</Badge>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea class="h-[calc(100dvh-60px)]">
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div class="px-6 py-4 space-y-6">
|
||||
<!-- Video Information -->
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
|
||||
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive, MonitorOff, Clock } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error' | 'no_signal' | 'busy'
|
||||
|
||||
export interface StatusDetail {
|
||||
label: string
|
||||
value: string
|
||||
@@ -28,7 +30,7 @@ export interface StatusDetail {
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
type: 'device' | 'video' | 'hid' | 'audio' | 'msd'
|
||||
status: 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
status: ConnectionStatus
|
||||
quickInfo?: string // Quick info displayed on trigger (e.g., "1920x1080 30fps")
|
||||
subtitle?: string
|
||||
errorMessage?: string
|
||||
@@ -54,6 +56,9 @@ const statusColor = computed(() => {
|
||||
return 'bg-status-active'
|
||||
case 'connecting':
|
||||
return 'bg-warning animate-pulse'
|
||||
case 'no_signal':
|
||||
case 'busy':
|
||||
return 'bg-warning'
|
||||
case 'disconnected':
|
||||
return 'bg-muted-foreground'
|
||||
case 'error':
|
||||
@@ -86,6 +91,10 @@ const statusIcon = computed(() => {
|
||||
return CheckCircle
|
||||
case 'connecting':
|
||||
return Loader2
|
||||
case 'no_signal':
|
||||
return MonitorOff
|
||||
case 'busy':
|
||||
return Clock
|
||||
case 'error':
|
||||
return AlertCircle
|
||||
default:
|
||||
@@ -93,6 +102,8 @@ const statusIcon = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isNotice = computed(() => ['no_signal', 'busy'].includes(props.status))
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'connected':
|
||||
@@ -103,6 +114,9 @@ const statusText = computed(() => {
|
||||
return t('status.disconnected')
|
||||
case 'error':
|
||||
return t('status.error')
|
||||
case 'no_signal':
|
||||
case 'busy':
|
||||
return t(`status.${props.status}`)
|
||||
default:
|
||||
return props.status
|
||||
}
|
||||
@@ -118,6 +132,9 @@ const statusBadgeText = computed(() => {
|
||||
return t('statusCard.offline')
|
||||
case 'error':
|
||||
return t('status.error')
|
||||
case 'no_signal':
|
||||
case 'busy':
|
||||
return t(`status.${props.status}`)
|
||||
default:
|
||||
return props.status
|
||||
}
|
||||
@@ -142,7 +159,7 @@ const statusBadgeText = computed(() => {
|
||||
<!-- Compact: single row with dot + abbreviated title -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" />
|
||||
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||
<span class="text-xs text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -182,11 +199,12 @@ const statusBadgeText = computed(() => {
|
||||
status === 'connected' ? 'text-success' :
|
||||
status === 'connecting' ? 'text-warning animate-spin' :
|
||||
status === 'error' ? 'text-destructive' :
|
||||
isNotice ? 'text-warning' :
|
||||
'text-muted-foreground'
|
||||
)"
|
||||
/>
|
||||
<Badge
|
||||
:variant="status === 'connected' ? 'success' : status === 'connecting' ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
:variant="status === 'connected' ? 'success' : (status === 'connecting' || isNotice) ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ statusBadgeText }}
|
||||
@@ -230,7 +248,7 @@ const statusBadgeText = computed(() => {
|
||||
<!-- Compact: single row with dot + abbreviated title -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" />
|
||||
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||
<span class="text-xs text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -270,11 +288,12 @@ const statusBadgeText = computed(() => {
|
||||
status === 'connected' ? 'text-success' :
|
||||
status === 'connecting' ? 'text-warning animate-spin' :
|
||||
status === 'error' ? 'text-destructive' :
|
||||
isNotice ? 'text-warning' :
|
||||
'text-muted-foreground'
|
||||
)"
|
||||
/>
|
||||
<Badge
|
||||
:variant="status === 'connected' ? 'success' : status === 'connecting' ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
:variant="status === 'connected' ? 'success' : (status === 'connecting' || isNotice) ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ statusBadgeText }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
@@ -39,6 +40,7 @@ export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
videoMode: VideoMode
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -538,12 +540,23 @@ watch(
|
||||
<template>
|
||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
:aria-label="buttonText"
|
||||
:title="buttonText"
|
||||
>
|
||||
<Monitor class="size-3.5 sm:size-4" />
|
||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||
<PopoverContent
|
||||
@open-auto-focus="focusConsolePanel"
|
||||
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||
align="start"
|
||||
:side="props.side ?? 'bottom'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
const props = defineProps<{
|
||||
minimal?: boolean
|
||||
textOnlyScale?: boolean
|
||||
scaleMode?: VideoScaleMode
|
||||
sourceSizeAvailable?: boolean
|
||||
}>()
|
||||
@@ -51,7 +53,7 @@ function toggleScaleMode() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<TooltipProvider v-if="!minimal">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span data-fixed-action class="inline-flex">
|
||||
@@ -64,7 +66,7 @@ function toggleScaleMode() {
|
||||
:aria-pressed="props.scaleMode === 'actual'"
|
||||
@click="toggleScaleMode"
|
||||
>
|
||||
<Scaling class="size-3.5" />
|
||||
<Scaling v-if="!textOnlyScale" class="size-3.5" />
|
||||
1:1
|
||||
</Button>
|
||||
</span>
|
||||
@@ -75,7 +77,7 @@ function toggleScaleMode() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<TooltipProvider v-if="!minimal">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
@@ -97,6 +99,7 @@ function toggleScaleMode() {
|
||||
</TooltipProvider>
|
||||
|
||||
<div
|
||||
v-if="!minimal"
|
||||
data-fixed-action
|
||||
aria-hidden="true"
|
||||
class="mx-2 h-5 w-px shrink-0 self-center bg-border"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Keyboard from 'simple-keyboard'
|
||||
@@ -38,6 +39,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
|
||||
const isAttached = ref(props.attached ?? true)
|
||||
const selectedOs = ref<KeyboardOsType>('windows')
|
||||
@@ -594,6 +596,7 @@ onUnmounted(() => {
|
||||
v-if="visible"
|
||||
:id="keyboardId"
|
||||
ref="keyboardRef"
|
||||
:data-console-layout="consoleAppearance"
|
||||
class="vkb"
|
||||
:class="{
|
||||
'vkb--attached': isAttached,
|
||||
@@ -909,7 +912,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
min-width: 1200px;
|
||||
max-width: 1600px;
|
||||
width: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--console-surface-radius, var(--radius-lg));
|
||||
box-shadow: 0 25px 50px -12px color-mix(in oklch, var(--foreground) 25%, transparent);
|
||||
}
|
||||
|
||||
@@ -936,7 +939,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
|
||||
.vkb--floating .vkb-header {
|
||||
cursor: move;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
border-radius: var(--console-surface-radius, var(--radius-lg)) var(--console-surface-radius, var(--radius-lg)) 0 0;
|
||||
}
|
||||
|
||||
.vkb-header-left {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { X } from "lucide-vue-next"
|
||||
@@ -31,6 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -21,7 +23,7 @@ const props = withDefaults(
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -30,6 +32,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -19,6 +21,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] max-w-(--reka-dropdown-menu-content-available-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { HoverCardContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -20,7 +22,7 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
@@ -29,6 +31,8 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
data-slot="hover-card-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||
v-bind="{ ...$attrs, ...forwardedProps }"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -22,7 +24,7 @@ const props = withDefaults(
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -31,6 +33,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-slot="popover-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -32,6 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { X } from "lucide-vue-next"
|
||||
@@ -36,6 +38,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
:data-edge="side"
|
||||
:class="cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -23,6 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
data-slot="tooltip-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)"
|
||||
>
|
||||
|
||||
22
web/src/composables/useConsoleAppearance.ts
Normal file
22
web/src/composables/useConsoleAppearance.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { inject, provide, type InjectionKey, type Ref } from 'vue'
|
||||
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
|
||||
// Injection follows component ownership through portals, without styling settings/login.
|
||||
const consoleAppearanceKey: InjectionKey<Readonly<Ref<ConsoleLayout>>> = Symbol('consoleAppearance')
|
||||
|
||||
export function provideConsoleAppearance(layout: Readonly<Ref<ConsoleLayout>>) {
|
||||
provide(consoleAppearanceKey, layout)
|
||||
}
|
||||
|
||||
export function useConsoleAppearance() {
|
||||
return inject(consoleAppearanceKey, undefined)
|
||||
}
|
||||
|
||||
// Focus the panel itself so the first help tooltip does not obscure its controls on open.
|
||||
// Keyboard users can then Tab into the panel's controls in their normal order.
|
||||
export function focusConsolePanel(event: Event) {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
event.preventDefault()
|
||||
event.target.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
32
web/src/composables/useConsoleLayout.ts
Normal file
32
web/src/composables/useConsoleLayout.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export type ConsoleLayout = 'current' | 'floating' | 'sidebar'
|
||||
|
||||
const STORAGE_KEY = 'consoleLayout'
|
||||
const VALID_LAYOUTS: ConsoleLayout[] = ['current', 'floating', 'sidebar']
|
||||
|
||||
function readStoredLayout(): ConsoleLayout {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return VALID_LAYOUTS.includes(stored as ConsoleLayout)
|
||||
? stored as ConsoleLayout
|
||||
: 'current'
|
||||
}
|
||||
|
||||
const consoleLayout = ref<ConsoleLayout>(readStoredLayout())
|
||||
|
||||
watch(consoleLayout, layout => {
|
||||
localStorage.setItem(STORAGE_KEY, layout)
|
||||
})
|
||||
|
||||
export function useConsoleLayout() {
|
||||
function setConsoleLayout(layout: ConsoleLayout) {
|
||||
if (VALID_LAYOUTS.includes(layout)) {
|
||||
consoleLayout.value = layout
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
consoleLayout,
|
||||
setConsoleLayout,
|
||||
}
|
||||
}
|
||||
@@ -96,12 +96,19 @@ export default {
|
||||
backToPassword: 'Back to password',
|
||||
},
|
||||
status: {
|
||||
no_signal: 'No signal',
|
||||
busy: 'Device busy',
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
disconnected: 'Disconnected',
|
||||
error: 'Error',
|
||||
},
|
||||
actionbar: {
|
||||
muted: 'Muted',
|
||||
more: 'More actions',
|
||||
collapseToolbar: 'Collapse toolbar',
|
||||
expandToolbar: 'Expand toolbar',
|
||||
|
||||
paste: 'Paste Text',
|
||||
micStart: 'Start Transfer',
|
||||
micStop: 'Stop Transfer',
|
||||
@@ -190,6 +197,8 @@ export default {
|
||||
selectAudioDevice: 'Select device...',
|
||||
},
|
||||
infobar: {
|
||||
pointerCaptured: 'Mouse captured · Esc to release',
|
||||
|
||||
keys: 'Keys',
|
||||
pointer: 'Pointer',
|
||||
caps: 'Caps',
|
||||
@@ -439,6 +448,7 @@ export default {
|
||||
uploadFailed: 'File upload failed',
|
||||
driveUnreadable: 'Format unsupported',
|
||||
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
|
||||
driveFilesystemUnsupportedHint: 'Web file browsing does not support this filesystem, but you can still connect the drive to the target machine.',
|
||||
reinitializeDrive: 'Re-initialize',
|
||||
diskMode: 'Drive Mode',
|
||||
singleDiskMode: 'Single',
|
||||
@@ -484,7 +494,7 @@ export default {
|
||||
downloadIncomplete: 'The remote image download was incomplete.',
|
||||
driveNotInitialized: 'The virtual drive is not initialized.',
|
||||
driveConnected: 'The virtual drive is connected to the controlled computer. Disconnect it before editing files.',
|
||||
driveFilesystemUnsupported: 'The virtual drive filesystem is unsupported. Reinitialize it to continue.',
|
||||
driveFilesystemUnsupported: 'Web file management does not support this format, but the drive can still be mounted on the controlled computer.',
|
||||
driveSizeInvalid: 'The virtual drive size is invalid.',
|
||||
storageSpaceUnavailable: 'Available virtual media storage space could not be determined.',
|
||||
storageFull: 'Virtual media storage does not have enough free space.',
|
||||
@@ -551,6 +561,18 @@ export default {
|
||||
networkAddresses: 'Network Addresses',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
consoleLayout: 'Console layout',
|
||||
consoleLayoutDesc: 'Choose how console controls are arranged. The preference is saved in this browser.',
|
||||
consoleLayoutHints: {
|
||||
current: 'Full status and labeled tools for configuration and troubleshooting.',
|
||||
sidebar: 'Compact status and fixed icons for frequent actions.',
|
||||
floating: 'Essential floating controls that collapse to keep the video clear.',
|
||||
},
|
||||
consoleLayoutOptions: {
|
||||
current: 'Top bar',
|
||||
floating: 'Floating bar',
|
||||
sidebar: 'Sidebar',
|
||||
},
|
||||
lightMode: 'Light',
|
||||
darkMode: 'Dark',
|
||||
systemMode: 'System',
|
||||
@@ -948,6 +970,8 @@ export default {
|
||||
},
|
||||
},
|
||||
statusCard: {
|
||||
connectionDetails: 'Connection details',
|
||||
|
||||
device: 'Device',
|
||||
video: 'Video',
|
||||
hid: 'HID',
|
||||
|
||||
@@ -96,12 +96,19 @@ export default {
|
||||
backToPassword: '返回密码登录',
|
||||
},
|
||||
status: {
|
||||
no_signal: '无信号',
|
||||
busy: '设备占用中',
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
disconnected: '已断开',
|
||||
error: '错误',
|
||||
},
|
||||
actionbar: {
|
||||
muted: '已静音',
|
||||
more: '更多操作',
|
||||
collapseToolbar: '收起工具栏',
|
||||
expandToolbar: '展开工具栏',
|
||||
|
||||
paste: '粘贴文本',
|
||||
micStart: '开始传声',
|
||||
micStop: '停止传声',
|
||||
@@ -190,6 +197,8 @@ export default {
|
||||
selectAudioDevice: '选择设备...',
|
||||
},
|
||||
infobar: {
|
||||
pointerCaptured: '鼠标已捕获 · Esc 释放',
|
||||
|
||||
keys: '按键',
|
||||
pointer: '指针',
|
||||
caps: 'Caps',
|
||||
@@ -438,6 +447,7 @@ export default {
|
||||
uploadFailed: '文件上传失败',
|
||||
driveUnreadable: '格式不支持',
|
||||
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
|
||||
driveFilesystemUnsupportedHint: '网页暂不支持浏览此文件系统,但仍可连接到被控机使用。',
|
||||
reinitializeDrive: '重新初始化',
|
||||
diskMode: '驱动器模式',
|
||||
singleDiskMode: '单驱动器',
|
||||
@@ -483,7 +493,7 @@ export default {
|
||||
downloadIncomplete: '远程镜像下载不完整。',
|
||||
driveNotInitialized: '虚拟盘尚未初始化。',
|
||||
driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。',
|
||||
driveFilesystemUnsupported: '虚拟盘文件系统不受支持,请重新初始化后再操作。',
|
||||
driveFilesystemUnsupported: '网页文件管理不支持此格式,但仍可挂载到被控机使用。',
|
||||
driveSizeInvalid: '虚拟盘大小无效。',
|
||||
storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。',
|
||||
storageFull: '虚拟媒体存储空间不足。',
|
||||
@@ -550,6 +560,18 @@ export default {
|
||||
networkAddresses: '网络地址',
|
||||
language: '语言',
|
||||
theme: '主题',
|
||||
consoleLayout: '控制台布局',
|
||||
consoleLayoutDesc: '选择控制台操作区的排列方式,设置将保存在当前浏览器。',
|
||||
consoleLayoutHints: {
|
||||
current: '完整状态与文字工具栏,适合配置和排障。',
|
||||
sidebar: '精简状态与固定图标入口,适合频繁操作。',
|
||||
floating: '常用操作悬浮显示,可收起以专注画面。',
|
||||
},
|
||||
consoleLayoutOptions: {
|
||||
current: '顶栏',
|
||||
floating: '浮动栏',
|
||||
sidebar: '侧边栏',
|
||||
},
|
||||
lightMode: '浅色模式',
|
||||
darkMode: '深色模式',
|
||||
systemMode: '跟随系统',
|
||||
@@ -947,6 +969,8 @@ export default {
|
||||
},
|
||||
},
|
||||
statusCard: {
|
||||
connectionDetails: '连接详情',
|
||||
|
||||
device: '设备',
|
||||
video: '视频',
|
||||
hid: 'HID',
|
||||
|
||||
@@ -185,3 +185,79 @@ body {
|
||||
.settings-dense [data-slot="card-footer"].border-t {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
/* Console surfaces follow their owning layout, including content rendered in portals. */
|
||||
[data-console-layout="current"] {
|
||||
--console-surface-radius: 8px;
|
||||
--console-control-radius: 5px;
|
||||
--console-menu-row: 32px;
|
||||
--console-surface-shadow: 0 4px 14px rgb(0 0 0 / 12%);
|
||||
}
|
||||
[data-console-layout="floating"] {
|
||||
--console-surface-radius: 16px;
|
||||
--console-control-radius: 10px;
|
||||
--console-menu-row: 38px;
|
||||
--console-surface-shadow: 0 12px 36px rgb(0 0 0 / 20%);
|
||||
}
|
||||
[data-console-layout="sidebar"] {
|
||||
--console-surface-radius: 10px;
|
||||
--console-control-radius: 7px;
|
||||
--console-menu-row: 36px;
|
||||
--console-surface-shadow: 4px 6px 20px rgb(0 0 0 / 14%);
|
||||
}
|
||||
[data-console-layout]:is(
|
||||
[data-slot="popover-content"], [data-slot="hover-card-content"],
|
||||
[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"],
|
||||
[data-slot="select-content"], [data-slot="dialog-content"]
|
||||
) {
|
||||
border-radius: var(--console-surface-radius);
|
||||
box-shadow: var(--console-surface-shadow);
|
||||
}
|
||||
[data-console-layout][data-slot="popover-content"] {
|
||||
max-height: min(var(--reka-popover-content-available-height, 80dvh), 80dvh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
[data-console-layout] :is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"], [data-slot="button"]) {
|
||||
border-radius: var(--console-control-radius);
|
||||
}
|
||||
[data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-sub-trigger"], [data-slot="select-item"]) {
|
||||
min-height: var(--console-menu-row);
|
||||
border-radius: var(--console-control-radius);
|
||||
}
|
||||
[data-console-layout="floating"]:is([data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"]) {
|
||||
padding: 6px;
|
||||
}
|
||||
.console-config-panel[data-console-layout="floating"] {
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
padding: 16px;
|
||||
}
|
||||
.console-config-panel[data-console-layout="sidebar"] {
|
||||
padding: 14px;
|
||||
}
|
||||
[data-console-layout="floating"][data-slot="tooltip-content"] {
|
||||
border-radius: 8px;
|
||||
}
|
||||
[data-console-layout="floating"][data-slot="sheet-content"] {
|
||||
margin: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
[data-console-layout="floating"][data-slot="sheet-content"]:is([data-edge="left"], [data-edge="right"]) {
|
||||
height: calc(100% - 24px);
|
||||
max-width: min(24rem, calc(100vw - 24px));
|
||||
}
|
||||
[data-console-layout="sidebar"][data-slot="sheet-content"][data-edge="right"] {
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
[data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="select-item"]) {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-console-layout][data-slot="computer-use-panel"] {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--console-surface-radius);
|
||||
box-shadow: var(--console-surface-shadow);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useVideoScaling } from '@/composables/useVideoScaling'
|
||||
import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
|
||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||
import { getMicrophone } from '@/composables/useMicrophone'
|
||||
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api'
|
||||
@@ -30,8 +31,11 @@ import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/
|
||||
import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
|
||||
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
||||
|
||||
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
|
||||
import StatusCard, { type StatusDetail, type ConnectionStatus } from '@/components/StatusCard.vue'
|
||||
import ActionBar from '@/components/ActionBar.vue'
|
||||
import ConsoleHeaderActions from '@/components/ConsoleHeaderActions.vue'
|
||||
import { provideConsoleAppearance } from '@/composables/useConsoleAppearance'
|
||||
import ConsoleStatusSummary, { type ConsoleStatusItem } from '@/components/ConsoleStatusSummary.vue'
|
||||
import InfoBar from '@/components/InfoBar.vue'
|
||||
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
||||
import StatsSheet from '@/components/StatsSheet.vue'
|
||||
@@ -236,14 +240,19 @@ const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
|
||||
const showTerminalDialog = ref(false)
|
||||
const featureVisibility = useFeatureVisibility()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const { consoleLayout } = useConsoleLayout()
|
||||
provideConsoleAppearance(consoleLayout)
|
||||
const terminalAvailable = computed(() => ttydStatus.value?.available !== false)
|
||||
const showPower = computed(() => featureVisibility.value.power)
|
||||
const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal)
|
||||
const showComputerUse = computed(() => featureVisibility.value.computerUse)
|
||||
const showPasteText = computed(() => featureVisibility.value.pasteText)
|
||||
|
||||
const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||
const videoStatus = computed<ConnectionStatus>(() => {
|
||||
if (wsNetworkError.value) return 'connecting'
|
||||
if (streamSignalState.value === 'no_signal') return 'no_signal'
|
||||
if (streamSignalState.value === 'device_busy') return 'busy'
|
||||
if (streamSignalState.value === 'device_lost') return 'error'
|
||||
|
||||
if (videoError.value) return 'error'
|
||||
if (videoLoading.value) return 'connecting'
|
||||
@@ -256,6 +265,24 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
// Optional idle devices are neutral; video and HID must stay visible when unavailable.
|
||||
const consoleStatusItems = computed<ConsoleStatusItem[]>(() => {
|
||||
const items: ConsoleStatusItem[] = [
|
||||
{ id: 'video', title: t('statusCard.video'), status: videoStatus.value,
|
||||
quickInfo: videoQuickInfo.value, details: videoDetails.value, required: true,
|
||||
errorMessage: showSignalOverlay.value ? signalOverlayInfo.value.title : videoErrorMessage.value },
|
||||
{ id: 'audio', title: t('statusCard.audio'), status: audioStatus.value,
|
||||
quickInfo: audioQuickInfo.value, details: audioDetails.value, errorMessage: audioErrorMessage.value },
|
||||
{ id: 'hid', title: t('statusCard.hid'), status: hidStatus.value,
|
||||
quickInfo: hidQuickInfo.value, details: hidDetails.value, errorMessage: hidErrorMessage.value, required: true },
|
||||
]
|
||||
if (showMsdStatusCard.value) items.push({
|
||||
id: 'msd', title: t('statusCard.msd'), status: msdStatus.value,
|
||||
quickInfo: msdQuickInfo.value, details: msdDetails.value, errorMessage: msdErrorMessage.value,
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
function openStatsSheet() {
|
||||
if (showConnectionStats.value) {
|
||||
statsSheetOpen.value = true
|
||||
@@ -288,6 +315,7 @@ const isMjpegPaused = computed(() => {
|
||||
})
|
||||
|
||||
const videoQuickInfo = computed(() => {
|
||||
if (videoStatus.value === 'no_signal' || videoStatus.value === 'busy') return t(`status.${videoStatus.value}`)
|
||||
const stream = systemStore.stream
|
||||
if (!stream?.resolution) return ''
|
||||
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
|
||||
@@ -306,8 +334,9 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
||||
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
||||
|
||||
const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
|
||||
const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
|
||||
const actualStatus: StatusDetail['status'] = paused
|
||||
const signalUnavailable = streamSignalState.value !== 'ok'
|
||||
const actualFpsValue = signalUnavailable ? videoQuickInfo.value : paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
|
||||
const actualStatus: StatusDetail['status'] = signalUnavailable ? 'warning' : paused
|
||||
? undefined
|
||||
: receivedFps > 5 ? 'ok'
|
||||
: receivedFps > 0 ? 'warning'
|
||||
@@ -3157,17 +3186,22 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen h-dvh flex flex-col bg-background">
|
||||
<header class="shrink-0 border-b bg-background">
|
||||
<div class="relative h-screen h-dvh flex flex-col bg-background">
|
||||
<header class="shrink-0 border-b bg-background" :class="consoleLayout === 'floating' && 'console-header--floating'">
|
||||
<div class="px-2 sm:px-4">
|
||||
<div class="h-10 sm:h-14 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 sm:gap-6">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2">
|
||||
<div class="console-header__row h-10 sm:h-14 flex items-center justify-between">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-6">
|
||||
<div class="flex shrink-0 items-center gap-1.5 whitespace-nowrap 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>
|
||||
<div class="flex md:hidden items-center gap-1">
|
||||
<ConsoleStatusSummary
|
||||
v-if="consoleLayout !== 'current'"
|
||||
:items="consoleStatusItems"
|
||||
:show-video-info="consoleLayout === 'sidebar'"
|
||||
/>
|
||||
<div v-else class="flex md:hidden items-center gap-1">
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
@@ -3189,8 +3223,19 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div v-if="consoleLayout === 'floating'" id="console-header-toolbar" class="console-header__toolbar" />
|
||||
<div class="console-header__account flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<ConsoleHeaderActions
|
||||
v-if="consoleLayout === 'floating'"
|
||||
:show-stats="showConnectionStats"
|
||||
:show-terminal="showTerminal"
|
||||
:terminal-running="!!ttydStatus?.running"
|
||||
:show-computer-use="showComputerUse"
|
||||
@open-stats="openStatsSheet"
|
||||
@open-terminal="openTerminal"
|
||||
@open-computer-use="openComputerUse"
|
||||
/>
|
||||
<div v-if="consoleLayout === 'current'" class="hidden md:flex items-center gap-2">
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
@@ -3270,7 +3315,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Teleport defer :to="consoleLayout === 'floating' ? '#console-header-toolbar' : 'body'" :disabled="consoleLayout !== 'floating'">
|
||||
<ActionBar
|
||||
:layout="consoleLayout"
|
||||
:mouse-mode="mouseMode"
|
||||
:video-mode="videoMode"
|
||||
:ttyd-running="ttydStatus?.running"
|
||||
@@ -3294,7 +3341,11 @@ onUnmounted(() => {
|
||||
@open-terminal="openTerminal"
|
||||
@open-computer-use="openComputerUse"
|
||||
/>
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
</Teleport>
|
||||
<div
|
||||
class="flex-1 overflow-hidden relative transition-[padding] duration-300"
|
||||
:class="consoleLayout === 'sidebar' && 'pl-14 sm:pl-16'"
|
||||
>
|
||||
<div class="absolute inset-0 dot-grid-bg" />
|
||||
<div class="relative flex h-full w-full min-w-0 items-stretch gap-3 p-1 sm:p-4">
|
||||
<div
|
||||
@@ -3534,6 +3585,13 @@ onUnmounted(() => {
|
||||
</Teleport>
|
||||
<div id="keyboard-anchor"></div>
|
||||
<InfoBar
|
||||
v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked"
|
||||
:compact="consoleLayout !== 'current'"
|
||||
:captured="isPointerLocked"
|
||||
:minimal="consoleLayout === 'floating'"
|
||||
:class="consoleLayout === 'floating'
|
||||
? 'pointer-events-none absolute bottom-2 left-1/2 z-30 max-w-[calc(100%-1rem)] -translate-x-1/2 rounded-lg border shadow-sm !w-auto'
|
||||
: 'shrink-0'"
|
||||
:pressed-keys="pressedKeys"
|
||||
:caps-lock="keyboardLed.capsLock"
|
||||
:num-lock="keyboardLed.numLock"
|
||||
@@ -3601,6 +3659,27 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Expanded controls may overlap header actions, but stay outside the video stage. */
|
||||
.console-header--floating .console-header__row {
|
||||
position: relative;
|
||||
height: 60px;
|
||||
}
|
||||
.console-header__toolbar {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.console-header__toolbar :deep(.console-action-bar) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@@ -59,6 +59,7 @@ import { toConfigFps } from '@/lib/fps'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useConsoleLayout, type ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
|
||||
import { getVideoFormatState } from '@/lib/video-format-support'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
@@ -143,6 +144,9 @@ import {
|
||||
Bot,
|
||||
ClipboardPaste,
|
||||
Wrench,
|
||||
PanelTop,
|
||||
PanelLeft,
|
||||
GalleryHorizontalEnd,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
@@ -153,6 +157,15 @@ const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
const featureVisibility = useFeatureVisibility()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { consoleLayout, setConsoleLayout } = useConsoleLayout()
|
||||
const consoleLayoutOptions: Array<{
|
||||
value: ConsoleLayout
|
||||
icon: typeof PanelTop
|
||||
}> = [
|
||||
{ value: 'current', icon: PanelTop },
|
||||
{ value: 'floating', icon: GalleryHorizontalEnd },
|
||||
{ value: 'sidebar', icon: PanelLeft },
|
||||
]
|
||||
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
||||
|
||||
const isWindows = computed(() => systemStore.platform?.mode === 'windows')
|
||||
@@ -2797,6 +2810,55 @@ watch(isWindows, () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.consoleLayout') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.consoleLayoutDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="option in consoleLayoutOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="group rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||
:class="consoleLayout === option.value ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border'"
|
||||
:aria-pressed="consoleLayout === option.value"
|
||||
@click="setConsoleLayout(option.value)"
|
||||
>
|
||||
<div class="mb-3 flex h-20 overflow-hidden rounded-md border bg-muted/40">
|
||||
<div
|
||||
v-if="option.value === 'sidebar'"
|
||||
class="flex w-4 flex-col items-center gap-1 border-r bg-background p-1"
|
||||
>
|
||||
<span v-for="i in 4" :key="i" class="size-1.5 rounded-sm bg-muted-foreground/50" />
|
||||
</div>
|
||||
<div class="relative flex-1 bg-zinc-950">
|
||||
<div
|
||||
v-if="option.value === 'current'"
|
||||
class="absolute inset-x-0 top-0 flex h-3 items-center gap-1 border-b bg-background px-1"
|
||||
>
|
||||
<span v-for="i in 5" :key="i" class="h-1 w-2 rounded-full bg-muted-foreground/50" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="option.value === 'floating'"
|
||||
class="absolute inset-x-2 top-2 flex h-3 items-center gap-1 rounded border bg-background/90 px-1 shadow"
|
||||
>
|
||||
<span v-for="i in 5" :key="i" class="h-1 w-2 rounded-full bg-muted-foreground/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="option.icon" class="size-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{{ t(`settings.consoleLayoutOptions.${option.value}`) }}</span>
|
||||
<Check v-if="consoleLayout === option.value" class="ml-auto size-4 text-primary" />
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">{{ t(`settings.consoleLayoutHints.${option.value}`) }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.language') }}</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user