mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +08:00
feat(web): 新增控制台布局切换并完善状态与磁盘交互
新增经典、悬浮和侧栏布局,保存用户偏好,并调整工具栏、状态摘要及窄屏交互。 统一控制台弹层外观、焦点行为和中英文文案。 根据虚拟磁盘文件访问能力显示浏览入口、容量信息和不可用状态。 验证:npm run build 通过(vue-tsc 类型检查和 Vite 生产构建)。
This commit is contained in:
@@ -39,6 +39,10 @@ import {
|
||||
Terminal,
|
||||
MoreHorizontal,
|
||||
Bot,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Keyboard,
|
||||
Scaling,
|
||||
} from 'lucide-vue-next'
|
||||
import PasteModal from '@/components/PasteModal.vue'
|
||||
import AtxPopover from '@/components/AtxPopover.vue'
|
||||
@@ -47,6 +51,7 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
||||
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
||||
import MsdDialog from '@/components/MsdDialog.vue'
|
||||
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
||||
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -60,6 +65,7 @@ const showMsd = computed(() => {
|
||||
return !!systemStore.msd?.available && !isCh9329Backend.value
|
||||
})
|
||||
const props = defineProps<{
|
||||
layout?: ConsoleLayout
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
videoMode?: VideoMode
|
||||
ttydRunning?: boolean
|
||||
@@ -71,6 +77,18 @@ const props = defineProps<{
|
||||
scaleMode?: VideoScaleMode
|
||||
sourceSizeAvailable?: boolean
|
||||
}>()
|
||||
const isSidebarLayout = computed(() => props.layout === 'sidebar')
|
||||
const isFloatingLayout = computed(() => props.layout === 'floating')
|
||||
const floatingCollapsed = ref(false)
|
||||
const expandButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
const collapseButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
async function setFloatingCollapsed(collapsed: boolean) {
|
||||
floatingCollapsed.value = collapsed
|
||||
await nextTick()
|
||||
const target = collapsed ? expandButtonRef.value : collapseButtonRef.value
|
||||
target?.$el?.focus()
|
||||
}
|
||||
const showAtx = computed(() => props.showPower !== false)
|
||||
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
||||
const showPasteText = computed(() => props.showPasteText !== false)
|
||||
@@ -133,13 +151,18 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
const barRef = ref<HTMLElement | null>(null)
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const barWidth = ref(0)
|
||||
const barHeight = ref(0)
|
||||
const coreWidth = ref(0)
|
||||
const coreHeight = ref(0)
|
||||
const fixedHeight = ref(0)
|
||||
const actionHeight = ref(36)
|
||||
const minimalDisplayControls = computed(() => isFloatingLayout.value && barWidth.value < 640)
|
||||
const alwaysRightWidth = ref(152)
|
||||
let layoutResizeObserver: ResizeObserver | null = null
|
||||
|
||||
type CollapsibleItem =
|
||||
| 'video' | 'audio' | 'hid'
|
||||
| 'msd' | 'atx' | 'paste'
|
||||
| 'stats' | 'terminal' | 'settings'
|
||||
| 'stats' | 'terminal' | 'settings' | 'ai'
|
||||
|
||||
interface ItemSpec {
|
||||
id: CollapsibleItem
|
||||
@@ -147,15 +170,13 @@ interface ItemSpec {
|
||||
}
|
||||
|
||||
const ITEM_SPECS: ItemSpec[] = [
|
||||
{ id: 'video', side: 'left' },
|
||||
{ id: 'audio', side: 'left' },
|
||||
{ id: 'hid', side: 'left' },
|
||||
{ id: 'msd', side: 'left' },
|
||||
{ id: 'atx', side: 'left' },
|
||||
{ id: 'paste', side: 'left' },
|
||||
{ id: 'stats', side: 'right' },
|
||||
{ id: 'terminal', side: 'right' },
|
||||
{ id: 'settings', side: 'right' },
|
||||
{ id: 'ai', side: 'right' },
|
||||
]
|
||||
|
||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||
@@ -167,7 +188,13 @@ const measureLayout = async () => {
|
||||
const measureContainer = measureRef.value
|
||||
if (!bar || !measureContainer) return
|
||||
|
||||
barWidth.value = bar.clientWidth
|
||||
const style = window.getComputedStyle(bar)
|
||||
barWidth.value = bar.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
|
||||
barHeight.value = bar.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||
const core = Array.from(bar.querySelectorAll<HTMLElement>('[data-core-action]'))
|
||||
coreWidth.value = core.reduce((sum, element) => sum + element.offsetWidth, 0)
|
||||
coreHeight.value = core.reduce((sum, element) => sum + element.offsetHeight, 0)
|
||||
actionHeight.value = core[0]?.offsetHeight || 36
|
||||
|
||||
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
|
||||
for (const spec of ITEM_SPECS) {
|
||||
@@ -190,7 +217,12 @@ const measureLayout = async () => {
|
||||
+ Number.parseFloat(style.marginLeft || '0')
|
||||
+ Number.parseFloat(style.marginRight || '0')
|
||||
}, 0)
|
||||
if (width > 0) alwaysRightWidth.value = Math.ceil(width)
|
||||
alwaysRightWidth.value = Math.ceil(width)
|
||||
fixedHeight.value = elements.reduce((sum, element) => {
|
||||
const style = window.getComputedStyle(element)
|
||||
return sum + element.getBoundingClientRect().height
|
||||
+ parseFloat(style.marginTop || '0') + parseFloat(style.marginBottom || '0')
|
||||
}, 0)
|
||||
|
||||
measurementReady.value = true
|
||||
}
|
||||
@@ -202,7 +234,7 @@ const observeLayout = async () => {
|
||||
void measureLayout()
|
||||
})
|
||||
if (barRef.value) layoutResizeObserver.observe(barRef.value)
|
||||
barRef.value?.querySelectorAll('[data-fixed-action]').forEach((element) => {
|
||||
barRef.value?.querySelectorAll('[data-fixed-action], [data-core-action]').forEach((element) => {
|
||||
layoutResizeObserver?.observe(element)
|
||||
})
|
||||
}
|
||||
@@ -220,7 +252,7 @@ watch(locale, () => {
|
||||
void measureLayout()
|
||||
})
|
||||
|
||||
watch(() => props.showComputerUse, () => {
|
||||
watch([() => props.layout, () => props.showComputerUse, floatingCollapsed, minimalDisplayControls], () => {
|
||||
void observeLayout()
|
||||
})
|
||||
|
||||
@@ -241,52 +273,55 @@ watch(showPasteText, (visible) => {
|
||||
const OVERFLOW_BUTTON_BUDGET_PX = 36
|
||||
|
||||
const collapsibleItems = computed(() => {
|
||||
const items = ITEM_SPECS.slice(3).filter(item => {
|
||||
const items = ITEM_SPECS.filter(item => {
|
||||
if (isFloatingLayout.value && item.side === 'right') return false
|
||||
if (item.id === 'msd' && !showMsd.value) return false
|
||||
if (item.id === 'atx' && !showAtx.value) return false
|
||||
if (item.id === 'paste' && !showPasteText.value) return false
|
||||
if (item.id === 'stats' && !showStats.value) return false
|
||||
if (item.id === 'terminal' && props.showTerminal === false) return false
|
||||
if (item.id === 'ai' && props.showComputerUse === false) return false
|
||||
return true
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
const visibleSet = computed(() => {
|
||||
if (!measurementReady.value) {
|
||||
return new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
}
|
||||
|
||||
const available = barWidth.value - alwaysRightWidth.value - OVERFLOW_BUTTON_BUDGET_PX
|
||||
|
||||
let used = 0
|
||||
if (barRef.value) {
|
||||
const leftContainer = barRef.value.querySelector('.left-buttons') as HTMLElement
|
||||
if (leftContainer) {
|
||||
const children = Array.from(leftContainer.children).slice(0, 3) as HTMLElement[]
|
||||
used = children.reduce((sum, el) => sum + el.offsetWidth, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (used === 0) used = 330
|
||||
|
||||
const result = new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
if (!measurementReady.value) return result
|
||||
|
||||
if (isSidebarLayout.value) {
|
||||
// Reserve More and a gap between the two groups before assigning vertical slots.
|
||||
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
|
||||
const priority: CollapsibleItem[] = ['paste', 'settings', 'msd', 'atx', 'stats', 'terminal', 'ai']
|
||||
for (const id of priority) {
|
||||
if (!collapsibleItems.value.some(item => item.id === id)) continue
|
||||
if (available < actionHeight.value) break
|
||||
result.set(id, 'icon')
|
||||
available -= actionHeight.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const available = barWidth.value - alwaysRightWidth.value - Math.max(OVERFLOW_BUTTON_BUDGET_PX, actionHeight.value) - (isFloatingLayout.value ? 48 : 12)
|
||||
let used = coreWidth.value
|
||||
// Keep actions reachable before spending the remaining space on labels.
|
||||
for (const item of collapsibleItems.value) {
|
||||
const widths = measuredWidths.value.get(item.id)
|
||||
if (!widths) continue
|
||||
|
||||
if (used + widths.icon <= available) {
|
||||
if (used + widths.label <= available) {
|
||||
result.set(item.id, 'label')
|
||||
used += widths.label
|
||||
} else {
|
||||
result.set(item.id, 'icon')
|
||||
used += widths.icon
|
||||
}
|
||||
if (!widths || used + widths.icon > available) continue
|
||||
result.set(item.id, 'icon')
|
||||
used += widths.icon
|
||||
}
|
||||
if (isFloatingLayout.value && barWidth.value < 640) return result
|
||||
for (const item of collapsibleItems.value) {
|
||||
const widths = measuredWidths.value.get(item.id)
|
||||
if (!widths || !result.has(item.id)) continue
|
||||
const extra = widths.label - widths.icon
|
||||
if (used + extra <= available) {
|
||||
result.set(item.id, 'label')
|
||||
used += extra
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -304,36 +339,85 @@ const hasRightOverflow = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-b bg-background">
|
||||
<div ref="barRef" class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||
<div
|
||||
:class="[
|
||||
'console-action-bar bg-background',
|
||||
props.layout === 'floating' && 'console-action-bar--floating',
|
||||
isFloatingLayout && floatingCollapsed && 'console-action-bar--collapsed',
|
||||
props.layout === 'sidebar' && 'console-action-bar--sidebar',
|
||||
(!props.layout || props.layout === 'current') && 'w-full border-b',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
v-if="isFloatingLayout && floatingCollapsed"
|
||||
ref="expandButtonRef"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="console-action-bar__expand gap-1.5 rounded-xl text-xs"
|
||||
:aria-label="t('actionbar.expandToolbar')"
|
||||
:aria-expanded="false"
|
||||
@click="setFloatingCollapsed(false)"
|
||||
>
|
||||
<ChevronDown class="size-4" />{{ t('actionbar.expandToolbar') }}
|
||||
</Button>
|
||||
<div
|
||||
v-show="!isFloatingLayout || !floatingCollapsed"
|
||||
ref="barRef"
|
||||
class="console-action-bar__inner flex items-center"
|
||||
:class="isSidebarLayout
|
||||
? 'h-full flex-col px-1 py-2 sm:px-1.5'
|
||||
: 'px-2 py-1 sm:px-4 sm:py-1.5'"
|
||||
>
|
||||
<!-- Left side buttons -->
|
||||
<ButtonGroup class="left-buttons flex-1 min-w-0 overflow-hidden">
|
||||
<ButtonGroup
|
||||
class="left-buttons min-w-0"
|
||||
:class="isSidebarLayout
|
||||
? 'flex-none flex-col overflow-visible'
|
||||
: 'flex-1 overflow-hidden'"
|
||||
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
<!-- Video Config - Always visible -->
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
:video-mode="props.videoMode || 'mjpeg'"
|
||||
@update:video-mode="emit('update:videoMode', $event)"
|
||||
/>
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
:video-mode="props.videoMode || 'mjpeg'"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
@update:video-mode="emit('update:videoMode', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Audio Config - Always visible -->
|
||||
<AudioConfigPopover
|
||||
v-model:open="audioPopoverOpen"
|
||||
:microphone-enabled="showMic"
|
||||
/>
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<AudioConfigPopover
|
||||
v-model:open="audioPopoverOpen"
|
||||
:microphone-enabled="showMic"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- HID Config - Always visible -->
|
||||
<HidConfigPopover
|
||||
v-model:open="hidPopoverOpen"
|
||||
:mouse-mode="mouseMode"
|
||||
@update:mouse-mode="emit('toggleMouseMode')"
|
||||
/>
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<HidConfigPopover
|
||||
v-model:open="hidPopoverOpen"
|
||||
:mouse-mode="mouseMode"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
@update:mouse-mode="emit('toggleMouseMode')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Media (MSD) - Adaptive -->
|
||||
<div v-if="showMsd && isVisible('msd')">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.virtualMedia')"
|
||||
:title="t('actionbar.virtualMedia')"
|
||||
@click="msdDialogOpen = true"
|
||||
>
|
||||
<HardDrive class="size-4" />
|
||||
<span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span>
|
||||
</Button>
|
||||
@@ -349,12 +433,22 @@ const hasRightOverflow = computed(() => {
|
||||
<div v-if="showAtx && isVisible('atx')">
|
||||
<Popover v-model:open="atxOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.power')"
|
||||
:title="t('actionbar.power')"
|
||||
>
|
||||
<Power class="size-4" />
|
||||
<span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
|
||||
<PopoverContent
|
||||
class="w-[min(280px,90vw)] p-0"
|
||||
align="start"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
>
|
||||
<AtxPopover
|
||||
@close="atxOpen = false"
|
||||
@power-short="emit('powerShort')"
|
||||
@@ -370,12 +464,24 @@ const hasRightOverflow = computed(() => {
|
||||
<div v-if="showPasteText && isVisible('paste')">
|
||||
<Popover v-model:open="pasteOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.paste')"
|
||||
:title="t('actionbar.paste')"
|
||||
>
|
||||
<ClipboardPaste class="size-4" />
|
||||
<span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
|
||||
<PopoverContent
|
||||
:class="isSidebarLayout
|
||||
? 'w-[min(400px,calc(100vw-4.5rem))] p-0'
|
||||
: 'w-[min(400px,90vw)] p-0'"
|
||||
align="start"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
>
|
||||
<PasteModal v-if="pasteOpen" @close="pasteOpen = false" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -384,8 +490,16 @@ const hasRightOverflow = computed(() => {
|
||||
</ButtonGroup>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<ButtonGroup class="shrink-0 ml-1 sm:ml-2">
|
||||
<ButtonGroup
|
||||
class="shrink-0"
|
||||
:class="isSidebarLayout
|
||||
? 'mt-auto flex-none flex-col'
|
||||
: 'ml-1 sm:ml-2'"
|
||||
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
<VideoDisplayControls
|
||||
:minimal="minimalDisplayControls"
|
||||
:text-only-scale="isSidebarLayout"
|
||||
:scale-mode="props.scaleMode"
|
||||
:source-size-available="props.sourceSizeAvailable"
|
||||
@toggle-fullscreen="emit('toggleFullscreen')"
|
||||
@@ -398,7 +512,7 @@ const hasRightOverflow = computed(() => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" @click="emit('toggleStats')">
|
||||
<BarChart3 class="size-4" />
|
||||
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
@@ -420,6 +534,7 @@ const hasRightOverflow = computed(() => {
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:disabled="!props.ttydRunning"
|
||||
:aria-label="t('actionbar.webTerminal')"
|
||||
@click="emit('openTerminal')"
|
||||
>
|
||||
<Terminal class="size-4" />
|
||||
@@ -434,18 +549,18 @@ const hasRightOverflow = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Computer Use - Optional -->
|
||||
<TooltipProvider v-if="props.showComputerUse !== false">
|
||||
<TooltipProvider v-if="isVisible('ai')">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
data-fixed-action
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('computerUse.title')"
|
||||
@click="emit('openComputerUse')"
|
||||
>
|
||||
<Bot class="size-3.5 sm:size-4" />
|
||||
<span class="hidden xl:inline">AI</span>
|
||||
<span v-if="visibleSet.get('ai') === 'label'">AI</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -459,7 +574,7 @@ const hasRightOverflow = computed(() => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.settings')" @click="router.push('/settings')">
|
||||
<Settings class="size-4" />
|
||||
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
@@ -472,13 +587,25 @@ const hasRightOverflow = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Overflow Menu - Only show if there are overflowed items -->
|
||||
<DropdownMenu v-if="hasOverflow" v-model:open="overflowMenuOpen">
|
||||
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 p-0">
|
||||
<Button variant="ghost" size="sm" class="size-8 p-0" :aria-label="t('actionbar.more')" :title="t('actionbar.more')">
|
||||
<MoreHorizontal class="size-3.5 sm:size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<DropdownMenuContent align="end" :side="isSidebarLayout ? 'right' : 'bottom'" class="w-56 max-h-[70dvh] overflow-y-auto">
|
||||
<template v-if="minimalDisplayControls">
|
||||
<DropdownMenuItem @click="openFromOverflow(() => emit('toggleVirtualKeyboard'))">
|
||||
<Keyboard class="size-4 mr-2" />{{ t('actionbar.keyboard') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
:disabled="!props.sourceSizeAvailable"
|
||||
@click="emit('update:scaleMode', props.scaleMode === 'actual' ? 'fit' : 'actual')"
|
||||
>
|
||||
<Scaling class="size-4 mr-2" />{{ t(props.scaleMode === 'actual' ? 'actionbar.fitSizeAria' : 'actionbar.actualSizeAria') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</template>
|
||||
<!-- MSD -->
|
||||
<DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||
<HardDrive class="size-4 mr-2" />
|
||||
@@ -500,14 +627,14 @@ const hasRightOverflow = computed(() => {
|
||||
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
|
||||
|
||||
<!-- Stats -->
|
||||
<DropdownMenuItem v-if="showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<BarChart3 class="size-4 mr-2" />
|
||||
{{ t('actionbar.stats') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Web Terminal -->
|
||||
<DropdownMenuItem
|
||||
v-if="props.showTerminal !== false && !isVisible('terminal')"
|
||||
v-if="!isFloatingLayout && props.showTerminal !== false && !isVisible('terminal')"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||
>
|
||||
@@ -515,13 +642,30 @@ const hasRightOverflow = computed(() => {
|
||||
{{ t('actionbar.webTerminal') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && props.showComputerUse !== false && !isVisible('ai')" @click="openFromOverflow(() => emit('openComputerUse'))">
|
||||
<Bot class="size-4 mr-2" />{{ t('computerUse.title') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Settings -->
|
||||
<DropdownMenuItem v-if="!isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && !isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<Settings class="size-4 mr-2" />
|
||||
{{ t('actionbar.settings') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
v-if="isFloatingLayout"
|
||||
ref="collapseButtonRef"
|
||||
data-fixed-action
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:aria-label="t('actionbar.collapseToolbar')"
|
||||
:title="t('actionbar.collapseToolbar')"
|
||||
:aria-expanded="true"
|
||||
@click="setFloatingCollapsed(true)"
|
||||
>
|
||||
<ChevronUp class="size-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,13 +732,131 @@ const hasRightOverflow = computed(() => {
|
||||
<!-- Settings -->
|
||||
<Button data-measure="settings-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" /></Button>
|
||||
<Button data-measure="settings-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" />{{ t('actionbar.settings') }}</Button>
|
||||
<!-- Always-visible items (for measuring their actual width) -->
|
||||
<Button data-measure="video-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="video-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="audio-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="audio-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="hid-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="hid-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="ai-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" /></Button>
|
||||
<Button data-measure="ai-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" />AI</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.console-action-bar--floating {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in srgb, var(--background) 96%, transparent);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: 2.5rem;
|
||||
bottom: 1.75rem;
|
||||
left: 0;
|
||||
width: 3.5rem;
|
||||
border-right: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--background) 94%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep([data-slot='button-group']) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep(button) {
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
padding-inline: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep(button > span) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep([data-fixed-action][aria-hidden='true']) {
|
||||
width: 1.5rem;
|
||||
height: 1px;
|
||||
margin: 0.35rem auto;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar .console-action-bar__inner {
|
||||
overflow-y: auto;
|
||||
gap: 1rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding: 4px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating :deep([data-slot='button-group']) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
min-width: 36px;
|
||||
height: 38px;
|
||||
padding-inline: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button[aria-expanded='true']) {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding-inline: 4px;
|
||||
gap: 0;
|
||||
}
|
||||
.console-action-bar--floating :deep([data-slot='button-group']) {
|
||||
gap: 0;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
width: 36px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.console-action-bar--floating.console-action-bar--collapsed {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.console-action-bar__expand {
|
||||
min-height: 44px;
|
||||
}
|
||||
.console-action-bar--sidebar :deep(button) {
|
||||
height: 44px;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding-inline: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.console-action-bar--sidebar {
|
||||
top: 3.5rem;
|
||||
width: 4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user