From ece4f303ceb88f56caef48100de846ba23906599 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 5 Sep 2026 20:59:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E5=B8=83=E5=B1=80=E5=88=87=E6=8D=A2=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=8A=B6=E6=80=81=E4=B8=8E=E7=A3=81=E7=9B=98?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增经典、悬浮和侧栏布局,保存用户偏好,并调整工具栏、状态摘要及窄屏交互。 统一控制台弹层外观、焦点行为和中英文文案。 根据虚拟磁盘文件访问能力显示浏览入口、容量信息和不可用状态。 验证:npm run build 通过(vue-tsc 类型检查和 Vite 生产构建)。 --- web/src/api/index.ts | 31 +- web/src/components/ActionBar.vue | 422 ++++++++++++++---- web/src/components/AudioConfigPopover.vue | 19 +- web/src/components/ComputerUseSheet.vue | 4 + web/src/components/ConsoleHeaderActions.vue | 56 +++ web/src/components/ConsoleStatusSummary.vue | 87 ++++ web/src/components/HidConfigPopover.vue | 17 +- web/src/components/InfoBar.vue | 29 +- web/src/components/MsdDialog.vue | 100 +++-- web/src/components/StatsSheet.vue | 6 +- web/src/components/StatusCard.vue | 31 +- web/src/components/VideoConfigPopover.vue | 17 +- web/src/components/VideoDisplayControls.vue | 9 +- web/src/components/VirtualKeyboard.vue | 7 +- .../components/ui/dialog/DialogContent.vue | 3 + .../ui/dropdown-menu/DropdownMenuContent.vue | 6 +- .../dropdown-menu/DropdownMenuSubContent.vue | 3 + .../ui/hover-card/HoverCardContent.vue | 6 +- .../components/ui/popover/PopoverContent.vue | 6 +- .../components/ui/select/SelectContent.vue | 3 + web/src/components/ui/sheet/SheetContent.vue | 4 + .../components/ui/tooltip/TooltipContent.vue | 3 + web/src/composables/useConsoleAppearance.ts | 22 + web/src/composables/useConsoleLayout.ts | 32 ++ web/src/i18n/en-US.ts | 26 +- web/src/i18n/zh-CN.ts | 26 +- web/src/style.css | 76 ++++ web/src/views/ConsoleView.vue | 105 ++++- web/src/views/SettingsView.vue | 62 +++ 29 files changed, 1027 insertions(+), 191 deletions(-) create mode 100644 web/src/components/ConsoleHeaderActions.vue create mode 100644 web/src/components/ConsoleStatusSummary.vue create mode 100644 web/src/composables/useConsoleAppearance.ts create mode 100644 web/src/composables/useConsoleLayout.ts diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a9d70165..f955a426 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -583,6 +583,16 @@ export interface DriveFile { size: number } +export type DriveFileAccess = 'available' | 'unsupported' | 'blocked_while_connected' | 'unknown' + +export interface DriveInfo { + size: number + used: number | null + free: number | null + initialized: boolean + file_access: DriveFileAccess +} + export type DiskMode = 'single' | 'multi' export type MountedMediaKind = 'drive' | 'image' @@ -604,12 +614,7 @@ export const msdApi = { slot_capacity: number mounted_count: number mounted_media: MountedMedia[] - drive_info: { - size: number - used: number - free: number - initialized: boolean - } | null + drive_info: DriveInfo | null usb_reenumerating: boolean } }>('/msd/status', {}, { toastOnError: false }), @@ -650,20 +655,10 @@ export const msdApi = { request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }), driveInfo: () => - request<{ - size: number - used: number - free: number - initialized: boolean - }>('/msd/drive', {}, { toastOnError: false }), + request('/msd/drive', {}, { toastOnError: false }), initDrive: (sizeMb?: number) => - request<{ - size: number - used: number - free: number - initialized: boolean - }>( + request( '/msd/drive/init', { method: 'POST', diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index c3869b26..d8ccaa08 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -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 | null>(null) +const collapseButtonRef = ref | 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(null) const measureRef = ref(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>(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('[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() 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() - } - - 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() + 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(() => { + + diff --git a/web/src/components/AudioConfigPopover.vue b/web/src/components/AudioConfigPopover.vue index 045103b1..dba31ddb 100644 --- a/web/src/components/AudioConfigPopover.vue +++ b/web/src/components/AudioConfigPopover.vue @@ -1,8 +1,9 @@ + + diff --git a/web/src/components/ConsoleStatusSummary.vue b/web/src/components/ConsoleStatusSummary.vue new file mode 100644 index 00000000..2e5cab61 --- /dev/null +++ b/web/src/components/ConsoleStatusSummary.vue @@ -0,0 +1,87 @@ + + + diff --git a/web/src/components/HidConfigPopover.vue b/web/src/components/HidConfigPopover.vue index ca881b13..4f097771 100644 --- a/web/src/components/HidConfigPopover.vue +++ b/web/src/components/HidConfigPopover.vue @@ -1,4 +1,5 @@ @@ -30,6 +32,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue index 5f6da9b8..40a8422a 100644 --- a/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +++ b/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue @@ -1,4 +1,6 @@ @@ -29,6 +31,8 @@ const forwardedProps = useForwardProps(delegatedProps) +import { useConsoleAppearance } from "@/composables/useConsoleAppearance" +const consoleAppearance = useConsoleAppearance() import type { PopoverContentEmits, PopoverContentProps } from "reka-ui" import type { HTMLAttributes } from "vue" import { reactiveOmit } from "@vueuse/core" @@ -22,7 +24,7 @@ const props = withDefaults( ) const emits = defineEmits() -const delegatedProps = reactiveOmit(props, "class") +const delegatedProps = reactiveOmit(props, "class", "sideOffset") const forwarded = useForwardPropsEmits(delegatedProps, emits) @@ -31,6 +33,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) +import { useConsoleAppearance } from "@/composables/useConsoleAppearance" +const consoleAppearance = useConsoleAppearance() import type { SelectContentEmits, SelectContentProps } from "reka-ui" import type { HTMLAttributes } from "vue" import { reactiveOmit } from "@vueuse/core" @@ -32,6 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) +import { useConsoleAppearance } from "@/composables/useConsoleAppearance" +const consoleAppearance = useConsoleAppearance() import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { HTMLAttributes } from "vue" import { X } from "lucide-vue-next" @@ -36,6 +38,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) +import { useConsoleAppearance } from "@/composables/useConsoleAppearance" +const consoleAppearance = useConsoleAppearance() import type { TooltipContentEmits, TooltipContentProps } from "reka-ui" import type { HTMLAttributes } from "vue" import { reactiveOmit } from "@vueuse/core" @@ -23,6 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) diff --git a/web/src/composables/useConsoleAppearance.ts b/web/src/composables/useConsoleAppearance.ts new file mode 100644 index 00000000..8f3a94ba --- /dev/null +++ b/web/src/composables/useConsoleAppearance.ts @@ -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>> = Symbol('consoleAppearance') + +export function provideConsoleAppearance(layout: Readonly>) { + 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 }) + } +} diff --git a/web/src/composables/useConsoleLayout.ts b/web/src/composables/useConsoleLayout.ts new file mode 100644 index 00000000..16c371ee --- /dev/null +++ b/web/src/composables/useConsoleLayout.ts @@ -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(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, + } +} diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 726450bb..ae9e517b 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -96,12 +96,19 @@ export default { backToPassword: 'Back to password', }, status: { + no_signal: 'No signal', + busy: 'Device busy', connected: 'Connected', connecting: 'Connecting', disconnected: 'Disconnected', error: 'Error', }, actionbar: { + muted: 'Muted', + more: 'More actions', + collapseToolbar: 'Collapse toolbar', + expandToolbar: 'Expand toolbar', + paste: 'Paste Text', micStart: 'Start Transfer', micStop: 'Stop Transfer', @@ -190,6 +197,8 @@ export default { selectAudioDevice: 'Select device...', }, infobar: { + pointerCaptured: 'Mouse captured · Esc to release', + keys: 'Keys', pointer: 'Pointer', caps: 'Caps', @@ -439,6 +448,7 @@ export default { uploadFailed: 'File upload failed', driveUnreadable: 'Format unsupported', driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.', + driveFilesystemUnsupportedHint: 'Web file browsing does not support this filesystem, but you can still connect the drive to the target machine.', reinitializeDrive: 'Re-initialize', diskMode: 'Drive Mode', singleDiskMode: 'Single', @@ -484,7 +494,7 @@ export default { downloadIncomplete: 'The remote image download was incomplete.', driveNotInitialized: 'The virtual drive is not initialized.', driveConnected: 'The virtual drive is connected to the controlled computer. Disconnect it before editing files.', - driveFilesystemUnsupported: 'The virtual drive filesystem is unsupported. Reinitialize it to continue.', + driveFilesystemUnsupported: 'Web file management does not support this format, but the drive can still be mounted on the controlled computer.', driveSizeInvalid: 'The virtual drive size is invalid.', storageSpaceUnavailable: 'Available virtual media storage space could not be determined.', storageFull: 'Virtual media storage does not have enough free space.', @@ -551,6 +561,18 @@ export default { networkAddresses: 'Network Addresses', language: 'Language', theme: 'Theme', + consoleLayout: 'Console layout', + consoleLayoutDesc: 'Choose how console controls are arranged. The preference is saved in this browser.', + consoleLayoutHints: { + current: 'Full status and labeled tools for configuration and troubleshooting.', + sidebar: 'Compact status and fixed icons for frequent actions.', + floating: 'Essential floating controls that collapse to keep the video clear.', + }, + consoleLayoutOptions: { + current: 'Top bar', + floating: 'Floating bar', + sidebar: 'Sidebar', + }, lightMode: 'Light', darkMode: 'Dark', systemMode: 'System', @@ -948,6 +970,8 @@ export default { }, }, statusCard: { + connectionDetails: 'Connection details', + device: 'Device', video: 'Video', hid: 'HID', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 7f1e0188..e136c888 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -96,12 +96,19 @@ export default { backToPassword: '返回密码登录', }, status: { + no_signal: '无信号', + busy: '设备占用中', connected: '已连接', connecting: '连接中', disconnected: '已断开', error: '错误', }, actionbar: { + muted: '已静音', + more: '更多操作', + collapseToolbar: '收起工具栏', + expandToolbar: '展开工具栏', + paste: '粘贴文本', micStart: '开始传声', micStop: '停止传声', @@ -190,6 +197,8 @@ export default { selectAudioDevice: '选择设备...', }, infobar: { + pointerCaptured: '鼠标已捕获 · Esc 释放', + keys: '按键', pointer: '指针', caps: 'Caps', @@ -438,6 +447,7 @@ export default { uploadFailed: '文件上传失败', driveUnreadable: '格式不支持', driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。', + driveFilesystemUnsupportedHint: '网页暂不支持浏览此文件系统,但仍可连接到被控机使用。', reinitializeDrive: '重新初始化', diskMode: '驱动器模式', singleDiskMode: '单驱动器', @@ -483,7 +493,7 @@ export default { downloadIncomplete: '远程镜像下载不完整。', driveNotInitialized: '虚拟盘尚未初始化。', driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。', - driveFilesystemUnsupported: '虚拟盘文件系统不受支持,请重新初始化后再操作。', + driveFilesystemUnsupported: '网页文件管理不支持此格式,但仍可挂载到被控机使用。', driveSizeInvalid: '虚拟盘大小无效。', storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。', storageFull: '虚拟媒体存储空间不足。', @@ -550,6 +560,18 @@ export default { networkAddresses: '网络地址', language: '语言', theme: '主题', + consoleLayout: '控制台布局', + consoleLayoutDesc: '选择控制台操作区的排列方式,设置将保存在当前浏览器。', + consoleLayoutHints: { + current: '完整状态与文字工具栏,适合配置和排障。', + sidebar: '精简状态与固定图标入口,适合频繁操作。', + floating: '常用操作悬浮显示,可收起以专注画面。', + }, + consoleLayoutOptions: { + current: '顶栏', + floating: '浮动栏', + sidebar: '侧边栏', + }, lightMode: '浅色模式', darkMode: '深色模式', systemMode: '跟随系统', @@ -947,6 +969,8 @@ export default { }, }, statusCard: { + connectionDetails: '连接详情', + device: '设备', video: '视频', hid: 'HID', diff --git a/web/src/style.css b/web/src/style.css index 10cce14f..d9813034 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -185,3 +185,79 @@ body { .settings-dense [data-slot="card-footer"].border-t { padding-top: 1rem; } + +/* Console surfaces follow their owning layout, including content rendered in portals. */ +[data-console-layout="current"] { + --console-surface-radius: 8px; + --console-control-radius: 5px; + --console-menu-row: 32px; + --console-surface-shadow: 0 4px 14px rgb(0 0 0 / 12%); +} +[data-console-layout="floating"] { + --console-surface-radius: 16px; + --console-control-radius: 10px; + --console-menu-row: 38px; + --console-surface-shadow: 0 12px 36px rgb(0 0 0 / 20%); +} +[data-console-layout="sidebar"] { + --console-surface-radius: 10px; + --console-control-radius: 7px; + --console-menu-row: 36px; + --console-surface-shadow: 4px 6px 20px rgb(0 0 0 / 14%); +} +[data-console-layout]:is( + [data-slot="popover-content"], [data-slot="hover-card-content"], + [data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"], + [data-slot="select-content"], [data-slot="dialog-content"] +) { + border-radius: var(--console-surface-radius); + box-shadow: var(--console-surface-shadow); +} +[data-console-layout][data-slot="popover-content"] { + max-height: min(var(--reka-popover-content-available-height, 80dvh), 80dvh); + overflow-y: auto; +} +[data-console-layout] :is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"], [data-slot="button"]) { + border-radius: var(--console-control-radius); +} +[data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-sub-trigger"], [data-slot="select-item"]) { + min-height: var(--console-menu-row); + border-radius: var(--console-control-radius); +} +[data-console-layout="floating"]:is([data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"]) { + padding: 6px; +} +.console-config-panel[data-console-layout="floating"] { + width: min(360px, calc(100vw - 24px)); + padding: 16px; +} +.console-config-panel[data-console-layout="sidebar"] { + padding: 14px; +} +[data-console-layout="floating"][data-slot="tooltip-content"] { + border-radius: 8px; +} +[data-console-layout="floating"][data-slot="sheet-content"] { + margin: 12px; + border: 1px solid var(--border); + border-radius: 16px; +} +[data-console-layout="floating"][data-slot="sheet-content"]:is([data-edge="left"], [data-edge="right"]) { + height: calc(100% - 24px); + max-width: min(24rem, calc(100vw - 24px)); +} +[data-console-layout="sidebar"][data-slot="sheet-content"][data-edge="right"] { + border-radius: 12px 0 0 12px; +} +@media (pointer: coarse) { + [data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="select-item"]) { + min-height: 44px; + } +} + +[data-console-layout][data-slot="computer-use-panel"] { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--console-surface-radius); + box-shadow: var(--console-surface-shadow); +} diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index 1714f7f9..e774fbfb 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -14,6 +14,7 @@ import { useVideoScaling } from '@/composables/useVideoScaling' import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket' import { useFeatureVisibility } from '@/composables/useFeatureVisibility' import { useTheme } from '@/composables/useTheme' +import { useConsoleLayout } from '@/composables/useConsoleLayout' import { getUnifiedAudio } from '@/composables/useUnifiedAudio' import { getMicrophone } from '@/composables/useMicrophone' import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api' @@ -30,8 +31,11 @@ import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/ import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket' import type { VideoMode } from '@/components/VideoConfigPopover.vue' -import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue' +import StatusCard, { type StatusDetail, type ConnectionStatus } from '@/components/StatusCard.vue' import ActionBar from '@/components/ActionBar.vue' +import ConsoleHeaderActions from '@/components/ConsoleHeaderActions.vue' +import { provideConsoleAppearance } from '@/composables/useConsoleAppearance' +import ConsoleStatusSummary, { type ConsoleStatusItem } from '@/components/ConsoleStatusSummary.vue' import InfoBar from '@/components/InfoBar.vue' import VirtualKeyboard from '@/components/VirtualKeyboard.vue' import StatsSheet from '@/components/StatsSheet.vue' @@ -236,14 +240,19 @@ const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null) const showTerminalDialog = ref(false) const featureVisibility = useFeatureVisibility() const { isDark, toggleTheme } = useTheme() +const { consoleLayout } = useConsoleLayout() +provideConsoleAppearance(consoleLayout) const terminalAvailable = computed(() => ttydStatus.value?.available !== false) const showPower = computed(() => featureVisibility.value.power) const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal) const showComputerUse = computed(() => featureVisibility.value.computerUse) const showPasteText = computed(() => featureVisibility.value.pasteText) -const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => { +const videoStatus = computed(() => { if (wsNetworkError.value) return 'connecting' + if (streamSignalState.value === 'no_signal') return 'no_signal' + if (streamSignalState.value === 'device_busy') return 'busy' + if (streamSignalState.value === 'device_lost') return 'error' if (videoError.value) return 'error' if (videoLoading.value) return 'connecting' @@ -256,6 +265,24 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro return 'disconnected' }) +// Optional idle devices are neutral; video and HID must stay visible when unavailable. +const consoleStatusItems = computed(() => { + const items: ConsoleStatusItem[] = [ + { id: 'video', title: t('statusCard.video'), status: videoStatus.value, + quickInfo: videoQuickInfo.value, details: videoDetails.value, required: true, + errorMessage: showSignalOverlay.value ? signalOverlayInfo.value.title : videoErrorMessage.value }, + { id: 'audio', title: t('statusCard.audio'), status: audioStatus.value, + quickInfo: audioQuickInfo.value, details: audioDetails.value, errorMessage: audioErrorMessage.value }, + { id: 'hid', title: t('statusCard.hid'), status: hidStatus.value, + quickInfo: hidQuickInfo.value, details: hidDetails.value, errorMessage: hidErrorMessage.value, required: true }, + ] + if (showMsdStatusCard.value) items.push({ + id: 'msd', title: t('statusCard.msd'), status: msdStatus.value, + quickInfo: msdQuickInfo.value, details: msdDetails.value, errorMessage: msdErrorMessage.value, + }) + return items +}) + function openStatsSheet() { if (showConnectionStats.value) { statsSheetOpen.value = true @@ -288,6 +315,7 @@ const isMjpegPaused = computed(() => { }) const videoQuickInfo = computed(() => { + if (videoStatus.value === 'no_signal' || videoStatus.value === 'busy') return t(`status.${videoStatus.value}`) const stream = systemStore.stream if (!stream?.resolution) return '' const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1]) @@ -306,8 +334,9 @@ const videoDetails = computed(() => { const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}` const targetFpsValue = formatFpsValue(stream.targetFps ?? 0) - const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps) - const actualStatus: StatusDetail['status'] = paused + const signalUnavailable = streamSignalState.value !== 'ok' + const actualFpsValue = signalUnavailable ? videoQuickInfo.value : paused ? t('statusCard.paused') : formatFpsValue(receivedFps) + const actualStatus: StatusDetail['status'] = signalUnavailable ? 'warning' : paused ? undefined : receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' @@ -3157,17 +3186,22 @@ onUnmounted(() => {