feat(web): 新增控制台布局切换并完善状态与磁盘交互

新增经典、悬浮和侧栏布局,保存用户偏好,并调整工具栏、状态摘要及窄屏交互。
统一控制台弹层外观、焦点行为和中英文文案。
根据虚拟磁盘文件访问能力显示浏览入口、容量信息和不可用状态。

验证:npm run build 通过(vue-tsc 类型检查和 Vite 生产构建)。
This commit is contained in:
mofeng-git
2026-09-05 20:59:15 +08:00
parent db9d79554a
commit ece4f303ce
29 changed files with 1027 additions and 191 deletions

View File

@@ -583,6 +583,16 @@ export interface DriveFile {
size: number 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 DiskMode = 'single' | 'multi'
export type MountedMediaKind = 'drive' | 'image' export type MountedMediaKind = 'drive' | 'image'
@@ -604,12 +614,7 @@ export const msdApi = {
slot_capacity: number slot_capacity: number
mounted_count: number mounted_count: number
mounted_media: MountedMedia[] mounted_media: MountedMedia[]
drive_info: { drive_info: DriveInfo | null
size: number
used: number
free: number
initialized: boolean
} | null
usb_reenumerating: boolean usb_reenumerating: boolean
} }
}>('/msd/status', {}, { toastOnError: false }), }>('/msd/status', {}, { toastOnError: false }),
@@ -650,20 +655,10 @@ export const msdApi = {
request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }), request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }),
driveInfo: () => driveInfo: () =>
request<{ request<DriveInfo>('/msd/drive', {}, { toastOnError: false }),
size: number
used: number
free: number
initialized: boolean
}>('/msd/drive', {}, { toastOnError: false }),
initDrive: (sizeMb?: number) => initDrive: (sizeMb?: number) =>
request<{ request<DriveInfo>(
size: number
used: number
free: number
initialized: boolean
}>(
'/msd/drive/init', '/msd/drive/init',
{ {
method: 'POST', method: 'POST',

View File

@@ -39,6 +39,10 @@ import {
Terminal, Terminal,
MoreHorizontal, MoreHorizontal,
Bot, Bot,
ChevronUp,
ChevronDown,
Keyboard,
Scaling,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import PasteModal from '@/components/PasteModal.vue' import PasteModal from '@/components/PasteModal.vue'
import AtxPopover from '@/components/AtxPopover.vue' import AtxPopover from '@/components/AtxPopover.vue'
@@ -47,6 +51,7 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
import AudioConfigPopover from '@/components/AudioConfigPopover.vue' import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
import MsdDialog from '@/components/MsdDialog.vue' import MsdDialog from '@/components/MsdDialog.vue'
import VideoDisplayControls from '@/components/VideoDisplayControls.vue' import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
const { t, locale } = useI18n() const { t, locale } = useI18n()
const router = useRouter() const router = useRouter()
@@ -60,6 +65,7 @@ const showMsd = computed(() => {
return !!systemStore.msd?.available && !isCh9329Backend.value return !!systemStore.msd?.available && !isCh9329Backend.value
}) })
const props = defineProps<{ const props = defineProps<{
layout?: ConsoleLayout
mouseMode?: 'absolute' | 'relative' mouseMode?: 'absolute' | 'relative'
videoMode?: VideoMode videoMode?: VideoMode
ttydRunning?: boolean ttydRunning?: boolean
@@ -71,6 +77,18 @@ const props = defineProps<{
scaleMode?: VideoScaleMode scaleMode?: VideoScaleMode
sourceSizeAvailable?: boolean 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 showAtx = computed(() => props.showPower !== false)
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg') const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
const showPasteText = computed(() => props.showPasteText !== false) const showPasteText = computed(() => props.showPasteText !== false)
@@ -133,13 +151,18 @@ const openMobilePaste = () => openFromOverflow(() => {
const barRef = ref<HTMLElement | null>(null) const barRef = ref<HTMLElement | null>(null)
const measureRef = ref<HTMLElement | null>(null) const measureRef = ref<HTMLElement | null>(null)
const barWidth = ref(0) 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) const alwaysRightWidth = ref(152)
let layoutResizeObserver: ResizeObserver | null = null let layoutResizeObserver: ResizeObserver | null = null
type CollapsibleItem = type CollapsibleItem =
| 'video' | 'audio' | 'hid'
| 'msd' | 'atx' | 'paste' | 'msd' | 'atx' | 'paste'
| 'stats' | 'terminal' | 'settings' | 'stats' | 'terminal' | 'settings' | 'ai'
interface ItemSpec { interface ItemSpec {
id: CollapsibleItem id: CollapsibleItem
@@ -147,15 +170,13 @@ interface ItemSpec {
} }
const ITEM_SPECS: ItemSpec[] = [ const ITEM_SPECS: ItemSpec[] = [
{ id: 'video', side: 'left' },
{ id: 'audio', side: 'left' },
{ id: 'hid', side: 'left' },
{ id: 'msd', side: 'left' }, { id: 'msd', side: 'left' },
{ id: 'atx', side: 'left' }, { id: 'atx', side: 'left' },
{ id: 'paste', side: 'left' }, { id: 'paste', side: 'left' },
{ id: 'stats', side: 'right' }, { id: 'stats', side: 'right' },
{ id: 'terminal', side: 'right' }, { id: 'terminal', side: 'right' },
{ id: 'settings', side: 'right' }, { id: 'settings', side: 'right' },
{ id: 'ai', side: 'right' },
] ]
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map()) const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
@@ -167,7 +188,13 @@ const measureLayout = async () => {
const measureContainer = measureRef.value const measureContainer = measureRef.value
if (!bar || !measureContainer) return 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 }>() const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
for (const spec of ITEM_SPECS) { for (const spec of ITEM_SPECS) {
@@ -190,7 +217,12 @@ const measureLayout = async () => {
+ Number.parseFloat(style.marginLeft || '0') + Number.parseFloat(style.marginLeft || '0')
+ Number.parseFloat(style.marginRight || '0') + Number.parseFloat(style.marginRight || '0')
}, 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 measurementReady.value = true
} }
@@ -202,7 +234,7 @@ const observeLayout = async () => {
void measureLayout() void measureLayout()
}) })
if (barRef.value) layoutResizeObserver.observe(barRef.value) 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) layoutResizeObserver?.observe(element)
}) })
} }
@@ -220,7 +252,7 @@ watch(locale, () => {
void measureLayout() void measureLayout()
}) })
watch(() => props.showComputerUse, () => { watch([() => props.layout, () => props.showComputerUse, floatingCollapsed, minimalDisplayControls], () => {
void observeLayout() void observeLayout()
}) })
@@ -241,52 +273,55 @@ watch(showPasteText, (visible) => {
const OVERFLOW_BUTTON_BUDGET_PX = 36 const OVERFLOW_BUTTON_BUDGET_PX = 36
const collapsibleItems = computed(() => { 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 === 'msd' && !showMsd.value) return false
if (item.id === 'atx' && !showAtx.value) return false if (item.id === 'atx' && !showAtx.value) return false
if (item.id === 'paste' && !showPasteText.value) return false if (item.id === 'paste' && !showPasteText.value) return false
if (item.id === 'stats' && !showStats.value) return false if (item.id === 'stats' && !showStats.value) return false
if (item.id === 'terminal' && props.showTerminal === false) return false if (item.id === 'terminal' && props.showTerminal === false) return false
if (item.id === 'ai' && props.showComputerUse === false) return false
return true return true
}) })
return items return items
}) })
const visibleSet = computed(() => { 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'>() 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) { for (const item of collapsibleItems.value) {
const widths = measuredWidths.value.get(item.id) const widths = measuredWidths.value.get(item.id)
if (!widths) continue if (!widths || used + widths.icon > available) continue
result.set(item.id, 'icon')
if (used + widths.icon <= available) { used += widths.icon
if (used + widths.label <= available) { }
result.set(item.id, 'label') if (isFloatingLayout.value && barWidth.value < 640) return result
used += widths.label for (const item of collapsibleItems.value) {
} else { const widths = measuredWidths.value.get(item.id)
result.set(item.id, 'icon') if (!widths || !result.has(item.id)) continue
used += widths.icon const extra = widths.label - widths.icon
} if (used + extra <= available) {
result.set(item.id, 'label')
used += extra
} }
} }
return result return result
}) })
@@ -304,36 +339,85 @@ const hasRightOverflow = computed(() => {
</script> </script>
<template> <template>
<div class="w-full border-b bg-background"> <div
<div ref="barRef" class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5"> :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 --> <!-- 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 --> <!-- Video Config - Always visible -->
<VideoConfigPopover <div data-core-action class="flex shrink-0">
v-model:open="videoPopoverOpen" <VideoConfigPopover
:video-mode="props.videoMode || 'mjpeg'" v-model:open="videoPopoverOpen"
@update:video-mode="emit('update:videoMode', $event)" :video-mode="props.videoMode || 'mjpeg'"
/> :side="isSidebarLayout ? 'right' : 'bottom'"
@update:video-mode="emit('update:videoMode', $event)"
/>
</div>
<!-- Audio Config - Always visible --> <!-- Audio Config - Always visible -->
<AudioConfigPopover <div data-core-action class="flex shrink-0">
v-model:open="audioPopoverOpen" <AudioConfigPopover
:microphone-enabled="showMic" v-model:open="audioPopoverOpen"
/> :microphone-enabled="showMic"
:side="isSidebarLayout ? 'right' : 'bottom'"
/>
</div>
<!-- HID Config - Always visible --> <!-- HID Config - Always visible -->
<HidConfigPopover <div data-core-action class="flex shrink-0">
v-model:open="hidPopoverOpen" <HidConfigPopover
:mouse-mode="mouseMode" v-model:open="hidPopoverOpen"
@update:mouse-mode="emit('toggleMouseMode')" :mouse-mode="mouseMode"
/> :side="isSidebarLayout ? 'right' : 'bottom'"
@update:mouse-mode="emit('toggleMouseMode')"
/>
</div>
<!-- Virtual Media (MSD) - Adaptive --> <!-- Virtual Media (MSD) - Adaptive -->
<div v-if="showMsd && isVisible('msd')"> <div v-if="showMsd && isVisible('msd')">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <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" /> <HardDrive class="size-4" />
<span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span> <span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span>
</Button> </Button>
@@ -349,12 +433,22 @@ const hasRightOverflow = computed(() => {
<div v-if="showAtx && isVisible('atx')"> <div v-if="showAtx && isVisible('atx')">
<Popover v-model:open="atxOpen"> <Popover v-model:open="atxOpen">
<PopoverTrigger as-child> <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" /> <Power class="size-4" />
<span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span> <span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span>
</Button> </Button>
</PopoverTrigger> </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 <AtxPopover
@close="atxOpen = false" @close="atxOpen = false"
@power-short="emit('powerShort')" @power-short="emit('powerShort')"
@@ -370,12 +464,24 @@ const hasRightOverflow = computed(() => {
<div v-if="showPasteText && isVisible('paste')"> <div v-if="showPasteText && isVisible('paste')">
<Popover v-model:open="pasteOpen"> <Popover v-model:open="pasteOpen">
<PopoverTrigger as-child> <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" /> <ClipboardPaste class="size-4" />
<span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span> <span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span>
</Button> </Button>
</PopoverTrigger> </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" /> <PasteModal v-if="pasteOpen" @close="pasteOpen = false" />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -384,8 +490,16 @@ const hasRightOverflow = computed(() => {
</ButtonGroup> </ButtonGroup>
<!-- Right side buttons --> <!-- 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 <VideoDisplayControls
:minimal="minimalDisplayControls"
:text-only-scale="isSidebarLayout"
:scale-mode="props.scaleMode" :scale-mode="props.scaleMode"
:source-size-available="props.sourceSizeAvailable" :source-size-available="props.sourceSizeAvailable"
@toggle-fullscreen="emit('toggleFullscreen')" @toggle-fullscreen="emit('toggleFullscreen')"
@@ -398,7 +512,7 @@ const hasRightOverflow = computed(() => {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <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" /> <BarChart3 class="size-4" />
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span> <span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
</Button> </Button>
@@ -420,6 +534,7 @@ const hasRightOverflow = computed(() => {
size="sm" size="sm"
class="h-8 gap-1.5 text-xs" class="h-8 gap-1.5 text-xs"
:disabled="!props.ttydRunning" :disabled="!props.ttydRunning"
:aria-label="t('actionbar.webTerminal')"
@click="emit('openTerminal')" @click="emit('openTerminal')"
> >
<Terminal class="size-4" /> <Terminal class="size-4" />
@@ -434,18 +549,18 @@ const hasRightOverflow = computed(() => {
</div> </div>
<!-- Computer Use - Optional --> <!-- Computer Use - Optional -->
<TooltipProvider v-if="props.showComputerUse !== false"> <TooltipProvider v-if="isVisible('ai')">
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
data-fixed-action
variant="ghost" variant="ghost"
size="sm" 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')" @click="emit('openComputerUse')"
> >
<Bot class="size-3.5 sm:size-4" /> <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> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -459,7 +574,7 @@ const hasRightOverflow = computed(() => {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <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" /> <Settings class="size-4" />
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span> <span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
</Button> </Button>
@@ -472,13 +587,25 @@ const hasRightOverflow = computed(() => {
</div> </div>
<!-- Overflow Menu - Only show if there are overflowed items --> <!-- 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> <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" /> <MoreHorizontal class="size-3.5 sm:size-4" />
</Button> </Button>
</DropdownMenuTrigger> </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 --> <!-- MSD -->
<DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)"> <DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)">
<HardDrive class="size-4 mr-2" /> <HardDrive class="size-4 mr-2" />
@@ -500,14 +627,14 @@ const hasRightOverflow = computed(() => {
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" /> <DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
<!-- Stats --> <!-- 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" /> <BarChart3 class="size-4 mr-2" />
{{ t('actionbar.stats') }} {{ t('actionbar.stats') }}
</DropdownMenuItem> </DropdownMenuItem>
<!-- Web Terminal --> <!-- Web Terminal -->
<DropdownMenuItem <DropdownMenuItem
v-if="props.showTerminal !== false && !isVisible('terminal')" v-if="!isFloatingLayout && props.showTerminal !== false && !isVisible('terminal')"
:disabled="!props.ttydRunning" :disabled="!props.ttydRunning"
@click="openFromOverflow(() => emit('openTerminal'))" @click="openFromOverflow(() => emit('openTerminal'))"
> >
@@ -515,13 +642,30 @@ const hasRightOverflow = computed(() => {
{{ t('actionbar.webTerminal') }} {{ t('actionbar.webTerminal') }}
</DropdownMenuItem> </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 --> <!-- 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" /> <Settings class="size-4 mr-2" />
{{ t('actionbar.settings') }} {{ t('actionbar.settings') }}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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> </ButtonGroup>
</div> </div>
</div> </div>
@@ -588,13 +732,131 @@ const hasRightOverflow = computed(() => {
<!-- Settings --> <!-- 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-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> <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="ai-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" /></Button>
<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="ai-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" />AI</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>
</div> </div>
</div> </div>
</template> </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>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' 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 { audioApi, configApi } from '@/api'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -29,6 +30,7 @@ interface AudioDevice {
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
microphoneEnabled?: boolean microphoneEnabled?: boolean
side?: 'top' | 'right' | 'bottom' | 'left'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -40,6 +42,7 @@ const configStore = useConfigStore()
const systemStore = useSystemStore() const systemStore = useSystemStore()
const unifiedAudio = getUnifiedAudio() const unifiedAudio = getUnifiedAudio()
const microphone = getMicrophone() const microphone = getMicrophone()
const playbackMuted = computed(() => unifiedAudio.muted.value || unifiedAudio.volume.value === 0)
const localVolume = ref([unifiedAudio.volume.value * 100]) const localVolume = ref([unifiedAudio.volume.value * 100])
const devices = ref<AudioDevice[]>([]) const devices = ref<AudioDevice[]>([])
@@ -150,13 +153,21 @@ onUnmounted(() => {
variant="ghost" variant="ghost"
size="sm" size="sm"
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2" 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> <span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
</Button> </Button>
</PopoverTrigger> </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"> <div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4> <h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import {
@@ -42,6 +43,7 @@ const props = defineProps<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const consoleAppearance = useConsoleAppearance()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:open', value: boolean): void (e: 'update:open', value: boolean): void
@@ -215,6 +217,8 @@ onMounted(loadConfig)
<template> <template>
<aside <aside
data-slot="computer-use-panel"
:data-console-layout="consoleAppearance"
v-show="open" 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]" 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]"
> >

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

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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -29,6 +30,7 @@ import type { HidConfigUpdate } from '@/types/generated'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
mouseMode?: 'absolute' | 'relative' mouseMode?: 'absolute' | 'relative'
side?: 'top' | 'right' | 'bottom' | 'left'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -231,13 +233,24 @@ watch(() => props.open, (isOpen) => {
<template> <template>
<Popover :open="open" @update:open="emit('update:open', $event)"> <Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child> <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" /> <MousePointer v-if="mouseMode === 'absolute'" class="size-3.5 sm:size-4" />
<Move v-else class="size-3.5 sm:size-4" /> <Move v-else class="size-3.5 sm:size-4" />
<span class="hidden sm:inline">{{ buttonText }}</span> <span class="hidden sm:inline">{{ buttonText }}</span>
</Button> </Button>
</PopoverTrigger> </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"> <div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4> <h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>

View File

@@ -15,6 +15,8 @@ const props = defineProps<{
mousePosition?: { x: number; y: number } mousePosition?: { x: number; y: number }
debugMode?: boolean debugMode?: boolean
compact?: boolean compact?: boolean
captured?: boolean
minimal?: boolean
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -45,22 +47,23 @@ const keysDisplay = computed(() => {
<div class="w-full border-t bg-background"> <div class="w-full border-t bg-background">
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) --> <!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
<div :class="compact ? '' : 'sm:hidden'"> <div :class="compact ? '' : 'sm:hidden'">
<div class="flex items-center justify-between text-xs px-2 py-0.5"> <div class="flex items-center justify-between gap-3 text-xs px-2 h-7">
<div v-if="keyboardLedEnabled" class="flex items-center gap-1"> <div v-if="keyboardLedEnabled && !minimal" class="flex items-center gap-1">
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground"> <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-warning'" />C <span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Caps
</Badge> </Badge>
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground"> <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-warning'" />N <span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Num
</Badge> </Badge>
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground"> <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-warning'" />S <span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Scroll
</Badge> </Badge>
</div> </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') }} {{ t('infobar.keyboardLedUnavailable') }}
</div> </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 }} {{ keysDisplay }}
</div> </div>
</div> </div>
@@ -87,15 +90,15 @@ const keysDisplay = computed(() => {
<Separator orientation="vertical" class="h-5" /> <Separator orientation="vertical" class="h-5" />
<template v-if="keyboardLedEnabled"> <template v-if="keyboardLedEnabled">
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground"> <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') }} {{ t('infobar.caps') }}
</Badge> </Badge>
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground"> <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') }} {{ t('infobar.num') }}
</Badge> </Badge>
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground"> <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') }} {{ t('infobar.scroll') }}
</Badge> </Badge>
</template> </template>

View File

@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system' 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 { ApiError, localizeMsdErrorCode } from '@/api/request'
import { useWebSocket } from '@/composables/useWebSocket' import { useWebSocket } from '@/composables/useWebSocket'
import { import {
@@ -92,13 +92,12 @@ const showMountOptionsDialog = ref(false)
const driveFiles = ref<DriveFile[]>([]) const driveFiles = ref<DriveFile[]>([])
const currentPath = ref('/') const currentPath = ref('/')
const loadingDrive = ref(false) 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 driveInitialized = ref(false)
const uploadingFile = ref(false) const uploadingFile = ref(false)
const fileUploadProgress = ref(0) const fileUploadProgress = ref(0)
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format) const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
const driveErrorCode = ref<string | null>(null) const driveErrorCode = ref<string | null>(null)
const driveFilesystemUnsupported = computed(() => driveErrorCode.value === 'MSD_DRIVE_FILESYSTEM_UNSUPPORTED')
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null) 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) 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 // Drive is currently mounted on the target machine via USB — file ops are blocked
const driveConnectedToTarget = computed(() => !!driveMedia.value) 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 refreshMsdState()
await loadImages() await loadImages()
await loadDriveInfo() await loadDriveInfo()
if (driveInitialized.value) { if (driveFilesAvailable.value) {
await loadDriveFiles() await loadDriveFiles()
} }
} }
@@ -369,7 +376,6 @@ async function unmountMedia(media: MountedMedia) {
try { try {
if (media.kind === 'drive') { if (media.kind === 'drive') {
await msdApi.unmountDrive() await msdApi.unmountDrive()
await refreshDriveBrowser()
} else { } else {
await msdApi.unmountImage(media.id) await msdApi.unmountImage(media.id)
} }
@@ -444,6 +450,7 @@ async function loadDriveInfo() {
try { try {
driveInfo.value = await msdApi.driveInfo() driveInfo.value = await msdApi.driveInfo()
driveInitialized.value = true driveInitialized.value = true
driveFiles.value = driveFilesAvailable.value ? driveFiles.value : []
} catch (e: any) { } catch (e: any) {
if (e instanceof ApiError) { if (e instanceof ApiError) {
if (e.code === 'MSD_DRIVE_NOT_INITIALIZED' || e.status === 404) { if (e.code === 'MSD_DRIVE_NOT_INITIALIZED' || e.status === 404) {
@@ -486,7 +493,7 @@ async function createDrive() {
const sizeMb = finalDriveSize.value const sizeMb = finalDriveSize.value
await msdApi.initDrive(sizeMb) await msdApi.initDrive(sizeMb)
await loadDriveInfo() await loadDriveInfo()
await loadDriveFiles() if (driveFilesAvailable.value) await loadDriveFiles()
await refreshDiskSpace() await refreshDiskSpace()
showDriveInitDialog.value = false showDriveInitDialog.value = false
} catch (e) { } catch (e) {
@@ -516,7 +523,7 @@ async function deleteDrive() {
async function loadDriveFiles() { async function loadDriveFiles() {
// Do not read image file while it is mounted on the target machine: // Do not read image file while it is mounted on the target machine:
// concurrent access causes filesystem corruption (Windows error 0x80070570) // concurrent access causes filesystem corruption (Windows error 0x80070570)
if (driveConnectedToTarget.value) { if (driveConnectedToTarget.value || !driveFilesAvailable.value) {
driveFiles.value = [] driveFiles.value = []
return return
} }
@@ -538,7 +545,7 @@ async function loadDriveFiles() {
async function refreshDriveBrowser() { async function refreshDriveBrowser() {
await loadDriveInfo() await loadDriveInfo()
if (driveInitialized.value) { if (driveFilesAvailable.value) {
await loadDriveFiles() await loadDriveFiles()
} else { } else {
driveFiles.value = [] driveFiles.value = []
@@ -965,20 +972,27 @@ onUnmounted(() => {
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: driveError : driveError
? 'border-destructive/40 bg-destructive/5' ? 'border-destructive/40 bg-destructive/5'
: driveFilesystemUnsupported
? 'border-warning/40 bg-warning/5'
: 'bg-muted/50'" : 'bg-muted/50'"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<HardDrive class="size-4 text-muted-foreground" /> <HardDrive class="size-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('msd.drive') }}</span> <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"> <Badge v-if="driveInfo" variant="outline" class="text-xs">
{{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB {{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB
</Badge> </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"> <template v-else-if="driveError">
<Badge variant="outline" class="text-xs border-destructive/50 text-destructive"> <Badge variant="outline" class="text-xs border-destructive/50 text-destructive">
{{ driveFilesystemUnsupported ? t('msd.driveUnreadable') : t('common.error') }} {{ t('common.error') }}
</Badge> </Badge>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@@ -993,19 +1007,7 @@ onUnmounted(() => {
</template> </template>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<!-- When drive format is unrecognized, only offer re-initialization --> <template v-if="driveConnectedToTarget">
<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">
<Badge variant="default" class="h-8 px-2 text-xs"> <Badge variant="default" class="h-8 px-2 text-xs">
<span class="relative flex size-1.5 mr-1.5"> <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> <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" variant="default"
size="sm" size="sm"
class="h-8 text-xs" class="h-8 text-xs"
:disabled="operationInProgress || mediaSlotsFull || !!driveError" :disabled="operationInProgress || mediaSlotsFull || !driveInfo || !!driveError"
@click="connectDrive" @click="connectDrive"
> >
<Link v-if="!connecting" class="size-3.5 mr-1" /> <Link v-if="!connecting" class="size-3.5 mr-1" />
<span v-if="connecting">{{ t('common.connecting') }}...</span> <span v-if="connecting">{{ t('common.connecting') }}...</span>
<span v-else>{{ t('msd.connect') }}</span> <span v-else>{{ t('msd.connect') }}</span>
</Button> </Button>
<Button
v-if="driveFilesystemUnsupported"
variant="outline"
size="sm"
class="h-8 text-xs"
:disabled="operationInProgress"
@click="initializeDrive"
>
{{ t('msd.reinitializeDrive') }}
</Button>
</template> </template>
<Button <Button
variant="ghost" variant="ghost"
@@ -1049,19 +1061,29 @@ onUnmounted(() => {
</Button> </Button>
</div> </div>
</div> </div>
<!-- Storage usage bar hidden when format is unrecognized --> <div
<div v-if="driveInfo" class="space-y-1.5"> v-if="driveFilesAvailable && driveInfo?.used !== null && driveInfo?.free !== null"
class="space-y-1.5"
>
<Progress <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" class="h-2"
/> />
<div class="flex items-center justify-between text-xs text-muted-foreground"> <div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ formatBytes(driveInfo?.used || 0) }} {{ t('msd.usedSpace') }}</span> <span>{{ formatBytes(driveInfo?.used ?? 0) }} {{ t('msd.usedSpace') }}</span>
<span>{{ formatBytes(driveInfo?.free || 0) }} {{ t('msd.freeSpace') }}</span> <span>{{ formatBytes(driveInfo?.free ?? 0) }} {{ t('msd.freeSpace') }}</span>
</div> </div>
</div> </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 <div
v-if="driveError" v-if="driveError"
class="flex shrink-0 items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3" 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 --> <!-- 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 --> <!-- Toolbar -->
<div class="shrink-0 flex items-center justify-between gap-2"> <div class="shrink-0 flex items-center justify-between gap-2">
@@ -1163,21 +1185,13 @@ onUnmounted(() => {
<!-- File List --> <!-- File List -->
<Skeleton v-if="loadingDrive" class="h-24 w-full" /> <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> <EmptyHeader>
<EmptyMedia variant="icon"><Folder /></EmptyMedia> <EmptyMedia variant="icon"><Folder /></EmptyMedia>
<EmptyDescription>{{ t('msd.emptyFolder') }}</EmptyDescription> <EmptyDescription>{{ t('msd.emptyFolder') }}</EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </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 v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
<div class="space-y-1"> <div class="space-y-1">
<div <div
@@ -1232,6 +1246,12 @@ onUnmounted(() => {
</div> </div>
</div> </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> </template>
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -408,17 +408,17 @@ onUnmounted(() => {
<Sheet :open="props.open" @update:open="emit('update:open', $event)"> <Sheet :open="props.open" @update:open="emit('update:open', $event)">
<SheetContent <SheetContent
side="right" 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 --> <!-- 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"> <div class="flex items-center gap-2">
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle> <SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
<Badge variant="secondary">WebRTC</Badge> <Badge variant="secondary">WebRTC</Badge>
</div> </div>
</SheetHeader> </SheetHeader>
<ScrollArea class="h-[calc(100dvh-60px)]"> <ScrollArea class="min-h-0 flex-1">
<div class="px-6 py-4 space-y-6"> <div class="px-6 py-4 space-y-6">
<!-- Video Information --> <!-- Video Information -->
<div class="space-y-3"> <div class="space-y-3">

View File

@@ -15,10 +15,12 @@ import {
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' 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() const { t } = useI18n()
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error' | 'no_signal' | 'busy'
export interface StatusDetail { export interface StatusDetail {
label: string label: string
value: string value: string
@@ -28,7 +30,7 @@ export interface StatusDetail {
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
title: string title: string
type: 'device' | 'video' | 'hid' | 'audio' | 'msd' type: 'device' | 'video' | 'hid' | 'audio' | 'msd'
status: 'connected' | 'connecting' | 'disconnected' | 'error' status: ConnectionStatus
quickInfo?: string // Quick info displayed on trigger (e.g., "1920x1080 30fps") quickInfo?: string // Quick info displayed on trigger (e.g., "1920x1080 30fps")
subtitle?: string subtitle?: string
errorMessage?: string errorMessage?: string
@@ -54,6 +56,9 @@ const statusColor = computed(() => {
return 'bg-status-active' return 'bg-status-active'
case 'connecting': case 'connecting':
return 'bg-warning animate-pulse' return 'bg-warning animate-pulse'
case 'no_signal':
case 'busy':
return 'bg-warning'
case 'disconnected': case 'disconnected':
return 'bg-muted-foreground' return 'bg-muted-foreground'
case 'error': case 'error':
@@ -86,6 +91,10 @@ const statusIcon = computed(() => {
return CheckCircle return CheckCircle
case 'connecting': case 'connecting':
return Loader2 return Loader2
case 'no_signal':
return MonitorOff
case 'busy':
return Clock
case 'error': case 'error':
return AlertCircle return AlertCircle
default: default:
@@ -93,6 +102,8 @@ const statusIcon = computed(() => {
} }
}) })
const isNotice = computed(() => ['no_signal', 'busy'].includes(props.status))
const statusText = computed(() => { const statusText = computed(() => {
switch (props.status) { switch (props.status) {
case 'connected': case 'connected':
@@ -103,6 +114,9 @@ const statusText = computed(() => {
return t('status.disconnected') return t('status.disconnected')
case 'error': case 'error':
return t('status.error') return t('status.error')
case 'no_signal':
case 'busy':
return t(`status.${props.status}`)
default: default:
return props.status return props.status
} }
@@ -118,6 +132,9 @@ const statusBadgeText = computed(() => {
return t('statusCard.offline') return t('statusCard.offline')
case 'error': case 'error':
return t('status.error') return t('status.error')
case 'no_signal':
case 'busy':
return t(`status.${props.status}`)
default: default:
return props.status return props.status
} }
@@ -142,7 +159,7 @@ const statusBadgeText = computed(() => {
<!-- Compact: single row with dot + abbreviated title --> <!-- Compact: single row with dot + abbreviated title -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" /> <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> </div>
</template> </template>
<template v-else> <template v-else>
@@ -182,11 +199,12 @@ const statusBadgeText = computed(() => {
status === 'connected' ? 'text-success' : status === 'connected' ? 'text-success' :
status === 'connecting' ? 'text-warning animate-spin' : status === 'connecting' ? 'text-warning animate-spin' :
status === 'error' ? 'text-destructive' : status === 'error' ? 'text-destructive' :
isNotice ? 'text-warning' :
'text-muted-foreground' 'text-muted-foreground'
)" )"
/> />
<Badge <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" class="text-[10px] px-1.5 py-0"
> >
{{ statusBadgeText }} {{ statusBadgeText }}
@@ -230,7 +248,7 @@ const statusBadgeText = computed(() => {
<!-- Compact: single row with dot + abbreviated title --> <!-- Compact: single row with dot + abbreviated title -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" /> <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> </div>
</template> </template>
<template v-else> <template v-else>
@@ -270,11 +288,12 @@ const statusBadgeText = computed(() => {
status === 'connected' ? 'text-success' : status === 'connected' ? 'text-success' :
status === 'connecting' ? 'text-warning animate-spin' : status === 'connecting' ? 'text-warning animate-spin' :
status === 'error' ? 'text-destructive' : status === 'error' ? 'text-destructive' :
isNotice ? 'text-warning' :
'text-muted-foreground' 'text-muted-foreground'
)" )"
/> />
<Badge <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" class="text-[10px] px-1.5 py-0"
> >
{{ statusBadgeText }} {{ statusBadgeText }}

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -39,6 +40,7 @@ export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
videoMode: VideoMode videoMode: VideoMode
side?: 'top' | 'right' | 'bottom' | 'left'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -538,12 +540,23 @@ watch(
<template> <template>
<Popover :open="open" @update:open="emit('update:open', $event)"> <Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child> <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" /> <Monitor class="size-3.5 sm:size-4" />
<span class="hidden sm:inline">{{ buttonText }}</span> <span class="hidden sm:inline">{{ buttonText }}</span>
</Button> </Button>
</PopoverTrigger> </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"> <div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4> <h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>

View File

@@ -11,6 +11,8 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
const props = defineProps<{ const props = defineProps<{
minimal?: boolean
textOnlyScale?: boolean
scaleMode?: VideoScaleMode scaleMode?: VideoScaleMode
sourceSizeAvailable?: boolean sourceSizeAvailable?: boolean
}>() }>()
@@ -51,7 +53,7 @@ function toggleScaleMode() {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<TooltipProvider> <TooltipProvider v-if="!minimal">
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<span data-fixed-action class="inline-flex"> <span data-fixed-action class="inline-flex">
@@ -64,7 +66,7 @@ function toggleScaleMode() {
:aria-pressed="props.scaleMode === 'actual'" :aria-pressed="props.scaleMode === 'actual'"
@click="toggleScaleMode" @click="toggleScaleMode"
> >
<Scaling class="size-3.5" /> <Scaling v-if="!textOnlyScale" class="size-3.5" />
1:1 1:1
</Button> </Button>
</span> </span>
@@ -75,7 +77,7 @@ function toggleScaleMode() {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<TooltipProvider> <TooltipProvider v-if="!minimal">
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
@@ -97,6 +99,7 @@ function toggleScaleMode() {
</TooltipProvider> </TooltipProvider>
<div <div
v-if="!minimal"
data-fixed-action data-fixed-action
aria-hidden="true" aria-hidden="true"
class="mx-2 h-5 w-px shrink-0 self-center bg-border" class="mx-2 h-5 w-px shrink-0 self-center bg-border"

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Keyboard from 'simple-keyboard' import Keyboard from 'simple-keyboard'
@@ -38,6 +39,7 @@ const emit = defineEmits<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const consoleAppearance = useConsoleAppearance()
const isAttached = ref(props.attached ?? true) const isAttached = ref(props.attached ?? true)
const selectedOs = ref<KeyboardOsType>('windows') const selectedOs = ref<KeyboardOsType>('windows')
@@ -594,6 +596,7 @@ onUnmounted(() => {
v-if="visible" v-if="visible"
:id="keyboardId" :id="keyboardId"
ref="keyboardRef" ref="keyboardRef"
:data-console-layout="consoleAppearance"
class="vkb" class="vkb"
:class="{ :class="{
'vkb--attached': isAttached, 'vkb--attached': isAttached,
@@ -909,7 +912,7 @@ html.dark .hg-theme-default .hg-button.down-key,
min-width: 1200px; min-width: 1200px;
max-width: 1600px; max-width: 1600px;
width: auto; 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); 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 { .vkb--floating .vkb-header {
cursor: move; 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 { .vkb-header-left {

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next"
@@ -31,6 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogOverlay /> <DialogOverlay />
<DialogContent <DialogContent
data-slot="dialog-content" data-slot="dialog-content"
:data-console-layout="consoleAppearance"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class=" :class="
cn( cn(

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui" import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core"
@@ -21,7 +23,7 @@ const props = withDefaults(
) )
const emits = defineEmits<DropdownMenuContentEmits>() const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "sideOffset")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@@ -30,6 +32,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
:data-console-layout="consoleAppearance"
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
v-bind="{ ...$attrs, ...forwarded }" 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)" :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)"
> >

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui" import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core"
@@ -19,6 +21,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template> <template>
<DropdownMenuSubContent <DropdownMenuSubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
:data-console-layout="consoleAppearance"
v-bind="forwarded" 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)" :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)"
> >

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { HoverCardContentProps } from "reka-ui" import type { HoverCardContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" 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) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
@@ -29,6 +31,8 @@ const forwardedProps = useForwardProps(delegatedProps)
<HoverCardPortal> <HoverCardPortal>
<HoverCardContent <HoverCardContent
data-slot="hover-card-content" data-slot="hover-card-content"
:data-console-layout="consoleAppearance"
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
v-bind="{ ...$attrs, ...forwardedProps }" v-bind="{ ...$attrs, ...forwardedProps }"
:class=" :class="
cn( cn(

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui" import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core"
@@ -22,7 +24,7 @@ const props = withDefaults(
) )
const emits = defineEmits<PopoverContentEmits>() const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "sideOffset")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@@ -31,6 +33,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<PopoverPortal> <PopoverPortal>
<PopoverContent <PopoverContent
data-slot="popover-content" data-slot="popover-content"
:data-console-layout="consoleAppearance"
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class=" :class="
cn( cn(

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { SelectContentEmits, SelectContentProps } from "reka-ui" import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core"
@@ -32,6 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<SelectPortal> <SelectPortal>
<SelectContent <SelectContent
data-slot="select-content" data-slot="select-content"
:data-console-layout="consoleAppearance"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class="cn( :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', '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',

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next"
@@ -36,6 +38,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<SheetOverlay /> <SheetOverlay />
<DialogContent <DialogContent
data-slot="sheet-content" data-slot="sheet-content"
:data-console-layout="consoleAppearance"
:data-edge="side"
:class="cn( :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', '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' side === 'right'

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { TooltipContentEmits, TooltipContentProps } from "reka-ui" import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core"
@@ -23,6 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<TooltipPortal> <TooltipPortal>
<TooltipContent <TooltipContent
data-slot="tooltip-content" data-slot="tooltip-content"
:data-console-layout="consoleAppearance"
v-bind="{ ...forwarded, ...$attrs }" 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)" :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)"
> >

View 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 })
}
}

View 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,
}
}

View File

@@ -96,12 +96,19 @@ export default {
backToPassword: 'Back to password', backToPassword: 'Back to password',
}, },
status: { status: {
no_signal: 'No signal',
busy: 'Device busy',
connected: 'Connected', connected: 'Connected',
connecting: 'Connecting', connecting: 'Connecting',
disconnected: 'Disconnected', disconnected: 'Disconnected',
error: 'Error', error: 'Error',
}, },
actionbar: { actionbar: {
muted: 'Muted',
more: 'More actions',
collapseToolbar: 'Collapse toolbar',
expandToolbar: 'Expand toolbar',
paste: 'Paste Text', paste: 'Paste Text',
micStart: 'Start Transfer', micStart: 'Start Transfer',
micStop: 'Stop Transfer', micStop: 'Stop Transfer',
@@ -190,6 +197,8 @@ export default {
selectAudioDevice: 'Select device...', selectAudioDevice: 'Select device...',
}, },
infobar: { infobar: {
pointerCaptured: 'Mouse captured · Esc to release',
keys: 'Keys', keys: 'Keys',
pointer: 'Pointer', pointer: 'Pointer',
caps: 'Caps', caps: 'Caps',
@@ -439,6 +448,7 @@ export default {
uploadFailed: 'File upload failed', uploadFailed: 'File upload failed',
driveUnreadable: 'Format unsupported', driveUnreadable: 'Format unsupported',
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.', 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', reinitializeDrive: 'Re-initialize',
diskMode: 'Drive Mode', diskMode: 'Drive Mode',
singleDiskMode: 'Single', singleDiskMode: 'Single',
@@ -484,7 +494,7 @@ export default {
downloadIncomplete: 'The remote image download was incomplete.', downloadIncomplete: 'The remote image download was incomplete.',
driveNotInitialized: 'The virtual drive is not initialized.', driveNotInitialized: 'The virtual drive is not initialized.',
driveConnected: 'The virtual drive is connected to the controlled computer. Disconnect it before editing files.', 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.', driveSizeInvalid: 'The virtual drive size is invalid.',
storageSpaceUnavailable: 'Available virtual media storage space could not be determined.', storageSpaceUnavailable: 'Available virtual media storage space could not be determined.',
storageFull: 'Virtual media storage does not have enough free space.', storageFull: 'Virtual media storage does not have enough free space.',
@@ -551,6 +561,18 @@ export default {
networkAddresses: 'Network Addresses', networkAddresses: 'Network Addresses',
language: 'Language', language: 'Language',
theme: 'Theme', 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', lightMode: 'Light',
darkMode: 'Dark', darkMode: 'Dark',
systemMode: 'System', systemMode: 'System',
@@ -948,6 +970,8 @@ export default {
}, },
}, },
statusCard: { statusCard: {
connectionDetails: 'Connection details',
device: 'Device', device: 'Device',
video: 'Video', video: 'Video',
hid: 'HID', hid: 'HID',

View File

@@ -96,12 +96,19 @@ export default {
backToPassword: '返回密码登录', backToPassword: '返回密码登录',
}, },
status: { status: {
no_signal: '无信号',
busy: '设备占用中',
connected: '已连接', connected: '已连接',
connecting: '连接中', connecting: '连接中',
disconnected: '已断开', disconnected: '已断开',
error: '错误', error: '错误',
}, },
actionbar: { actionbar: {
muted: '已静音',
more: '更多操作',
collapseToolbar: '收起工具栏',
expandToolbar: '展开工具栏',
paste: '粘贴文本', paste: '粘贴文本',
micStart: '开始传声', micStart: '开始传声',
micStop: '停止传声', micStop: '停止传声',
@@ -190,6 +197,8 @@ export default {
selectAudioDevice: '选择设备...', selectAudioDevice: '选择设备...',
}, },
infobar: { infobar: {
pointerCaptured: '鼠标已捕获 · Esc 释放',
keys: '按键', keys: '按键',
pointer: '指针', pointer: '指针',
caps: 'Caps', caps: 'Caps',
@@ -438,6 +447,7 @@ export default {
uploadFailed: '文件上传失败', uploadFailed: '文件上传失败',
driveUnreadable: '格式不支持', driveUnreadable: '格式不支持',
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。', driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
driveFilesystemUnsupportedHint: '网页暂不支持浏览此文件系统,但仍可连接到被控机使用。',
reinitializeDrive: '重新初始化', reinitializeDrive: '重新初始化',
diskMode: '驱动器模式', diskMode: '驱动器模式',
singleDiskMode: '单驱动器', singleDiskMode: '单驱动器',
@@ -483,7 +493,7 @@ export default {
downloadIncomplete: '远程镜像下载不完整。', downloadIncomplete: '远程镜像下载不完整。',
driveNotInitialized: '虚拟盘尚未初始化。', driveNotInitialized: '虚拟盘尚未初始化。',
driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。', driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。',
driveFilesystemUnsupported: '虚拟盘文件系统不受支持,请重新初始化后再操作。', driveFilesystemUnsupported: '网页文件管理不支持此格式,但仍可挂载到被控机使用。',
driveSizeInvalid: '虚拟盘大小无效。', driveSizeInvalid: '虚拟盘大小无效。',
storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。', storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。',
storageFull: '虚拟媒体存储空间不足。', storageFull: '虚拟媒体存储空间不足。',
@@ -550,6 +560,18 @@ export default {
networkAddresses: '网络地址', networkAddresses: '网络地址',
language: '语言', language: '语言',
theme: '主题', theme: '主题',
consoleLayout: '控制台布局',
consoleLayoutDesc: '选择控制台操作区的排列方式,设置将保存在当前浏览器。',
consoleLayoutHints: {
current: '完整状态与文字工具栏,适合配置和排障。',
sidebar: '精简状态与固定图标入口,适合频繁操作。',
floating: '常用操作悬浮显示,可收起以专注画面。',
},
consoleLayoutOptions: {
current: '顶栏',
floating: '浮动栏',
sidebar: '侧边栏',
},
lightMode: '浅色模式', lightMode: '浅色模式',
darkMode: '深色模式', darkMode: '深色模式',
systemMode: '跟随系统', systemMode: '跟随系统',
@@ -947,6 +969,8 @@ export default {
}, },
}, },
statusCard: { statusCard: {
connectionDetails: '连接详情',
device: '设备', device: '设备',
video: '视频', video: '视频',
hid: 'HID', hid: 'HID',

View File

@@ -185,3 +185,79 @@ body {
.settings-dense [data-slot="card-footer"].border-t { .settings-dense [data-slot="card-footer"].border-t {
padding-top: 1rem; 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);
}

View File

@@ -14,6 +14,7 @@ import { useVideoScaling } from '@/composables/useVideoScaling'
import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket' import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
import { useFeatureVisibility } from '@/composables/useFeatureVisibility' import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { useConsoleLayout } from '@/composables/useConsoleLayout'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio' import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { getMicrophone } from '@/composables/useMicrophone' import { getMicrophone } from '@/composables/useMicrophone'
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api' 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 { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
import type { VideoMode } from '@/components/VideoConfigPopover.vue' 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 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 InfoBar from '@/components/InfoBar.vue'
import VirtualKeyboard from '@/components/VirtualKeyboard.vue' import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
import StatsSheet from '@/components/StatsSheet.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 showTerminalDialog = ref(false)
const featureVisibility = useFeatureVisibility() const featureVisibility = useFeatureVisibility()
const { isDark, toggleTheme } = useTheme() const { isDark, toggleTheme } = useTheme()
const { consoleLayout } = useConsoleLayout()
provideConsoleAppearance(consoleLayout)
const terminalAvailable = computed(() => ttydStatus.value?.available !== false) const terminalAvailable = computed(() => ttydStatus.value?.available !== false)
const showPower = computed(() => featureVisibility.value.power) const showPower = computed(() => featureVisibility.value.power)
const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal) const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal)
const showComputerUse = computed(() => featureVisibility.value.computerUse) const showComputerUse = computed(() => featureVisibility.value.computerUse)
const showPasteText = computed(() => featureVisibility.value.pasteText) const showPasteText = computed(() => featureVisibility.value.pasteText)
const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => { const videoStatus = computed<ConnectionStatus>(() => {
if (wsNetworkError.value) return 'connecting' 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 (videoError.value) return 'error'
if (videoLoading.value) return 'connecting' if (videoLoading.value) return 'connecting'
@@ -256,6 +265,24 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
return 'disconnected' 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() { function openStatsSheet() {
if (showConnectionStats.value) { if (showConnectionStats.value) {
statsSheetOpen.value = true statsSheetOpen.value = true
@@ -288,6 +315,7 @@ const isMjpegPaused = computed(() => {
}) })
const videoQuickInfo = computed(() => { const videoQuickInfo = computed(() => {
if (videoStatus.value === 'no_signal' || videoStatus.value === 'busy') return t(`status.${videoStatus.value}`)
const stream = systemStore.stream const stream = systemStore.stream
if (!stream?.resolution) return '' if (!stream?.resolution) return ''
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1]) 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 formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt}${outputFmt}`
const targetFpsValue = formatFpsValue(stream.targetFps ?? 0) const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps) const signalUnavailable = streamSignalState.value !== 'ok'
const actualStatus: StatusDetail['status'] = paused const actualFpsValue = signalUnavailable ? videoQuickInfo.value : paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
const actualStatus: StatusDetail['status'] = signalUnavailable ? 'warning' : paused
? undefined ? undefined
: receivedFps > 5 ? 'ok' : receivedFps > 5 ? 'ok'
: receivedFps > 0 ? 'warning' : receivedFps > 0 ? 'warning'
@@ -3157,17 +3186,22 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="h-screen h-dvh flex flex-col bg-background"> <div class="relative h-screen h-dvh flex flex-col bg-background">
<header class="shrink-0 border-b 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="px-2 sm:px-4">
<div class="h-10 sm:h-14 flex items-center justify-between"> <div class="console-header__row h-10 sm:h-14 flex items-center justify-between">
<div class="flex items-center gap-2 sm:gap-6"> <div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-6">
<div class="flex items-center gap-1.5 sm:gap-2"> <div class="flex shrink-0 items-center gap-1.5 whitespace-nowrap sm:gap-2">
<BrandMark size="md" class="hidden sm:block" /> <BrandMark size="md" class="hidden sm:block" />
<BrandMark size="sm" class="sm:hidden" /> <BrandMark size="sm" class="sm:hidden" />
<span class="font-bold text-sm sm:text-lg">One-KVM</span> <span class="font-bold text-sm sm:text-lg">One-KVM</span>
</div> </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 <StatusCard
:title="t('statusCard.video')" :title="t('statusCard.video')"
type="video" type="video"
@@ -3189,8 +3223,19 @@ onUnmounted(() => {
/> />
</div> </div>
</div> </div>
<div class="flex items-center gap-1 sm:gap-2"> <div v-if="consoleLayout === 'floating'" id="console-header-toolbar" class="console-header__toolbar" />
<div class="hidden md:flex items-center gap-2"> <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 <StatusCard
:title="t('statusCard.video')" :title="t('statusCard.video')"
type="video" type="video"
@@ -3270,7 +3315,9 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</header> </header>
<Teleport defer :to="consoleLayout === 'floating' ? '#console-header-toolbar' : 'body'" :disabled="consoleLayout !== 'floating'">
<ActionBar <ActionBar
:layout="consoleLayout"
:mouse-mode="mouseMode" :mouse-mode="mouseMode"
:video-mode="videoMode" :video-mode="videoMode"
:ttyd-running="ttydStatus?.running" :ttyd-running="ttydStatus?.running"
@@ -3294,7 +3341,11 @@ onUnmounted(() => {
@open-terminal="openTerminal" @open-terminal="openTerminal"
@open-computer-use="openComputerUse" @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="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 class="relative flex h-full w-full min-w-0 items-stretch gap-3 p-1 sm:p-4">
<div <div
@@ -3534,6 +3585,13 @@ onUnmounted(() => {
</Teleport> </Teleport>
<div id="keyboard-anchor"></div> <div id="keyboard-anchor"></div>
<InfoBar <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" :pressed-keys="pressedKeys"
:caps-lock="keyboardLed.capsLock" :caps-lock="keyboardLed.capsLock"
:num-lock="keyboardLed.numLock" :num-lock="keyboardLed.numLock"
@@ -3601,6 +3659,27 @@ onUnmounted(() => {
</template> </template>
<style scoped> <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-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.3s ease; transition: opacity 0.3s ease;

View File

@@ -59,6 +59,7 @@ import { toConfigFps } from '@/lib/fps'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { useFeatureVisibility } from '@/composables/useFeatureVisibility' import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { useConsoleLayout, type ConsoleLayout } from '@/composables/useConsoleLayout'
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration' import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
import { getVideoFormatState } from '@/lib/video-format-support' import { getVideoFormatState } from '@/lib/video-format-support'
import { formatVideoDeviceLabel } from '@/lib/video-device-label' import { formatVideoDeviceLabel } from '@/lib/video-device-label'
@@ -143,6 +144,9 @@ import {
Bot, Bot,
ClipboardPaste, ClipboardPaste,
Wrench, Wrench,
PanelTop,
PanelLeft,
GalleryHorizontalEnd,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const { t, te } = useI18n() const { t, te } = useI18n()
@@ -153,6 +157,15 @@ const configStore = useConfigStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const featureVisibility = useFeatureVisibility() const featureVisibility = useFeatureVisibility()
const { theme, setTheme } = useTheme() 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 EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
const isWindows = computed(() => systemStore.platform?.mode === 'windows') const isWindows = computed(() => systemStore.platform?.mode === 'windows')
@@ -2797,6 +2810,55 @@ watch(isWindows, () => {
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle>{{ t('settings.language') }}</CardTitle> <CardTitle>{{ t('settings.language') }}</CardTitle>