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

@@ -14,6 +14,7 @@ import { useVideoScaling } from '@/composables/useVideoScaling'
import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme'
import { useConsoleLayout } from '@/composables/useConsoleLayout'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { getMicrophone } from '@/composables/useMicrophone'
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api'
@@ -30,8 +31,11 @@ import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/
import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
import StatusCard, { type StatusDetail, type ConnectionStatus } from '@/components/StatusCard.vue'
import ActionBar from '@/components/ActionBar.vue'
import ConsoleHeaderActions from '@/components/ConsoleHeaderActions.vue'
import { provideConsoleAppearance } from '@/composables/useConsoleAppearance'
import ConsoleStatusSummary, { type ConsoleStatusItem } from '@/components/ConsoleStatusSummary.vue'
import InfoBar from '@/components/InfoBar.vue'
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
import StatsSheet from '@/components/StatsSheet.vue'
@@ -236,14 +240,19 @@ const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
const showTerminalDialog = ref(false)
const featureVisibility = useFeatureVisibility()
const { isDark, toggleTheme } = useTheme()
const { consoleLayout } = useConsoleLayout()
provideConsoleAppearance(consoleLayout)
const terminalAvailable = computed(() => ttydStatus.value?.available !== false)
const showPower = computed(() => featureVisibility.value.power)
const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal)
const showComputerUse = computed(() => featureVisibility.value.computerUse)
const showPasteText = computed(() => featureVisibility.value.pasteText)
const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const videoStatus = computed<ConnectionStatus>(() => {
if (wsNetworkError.value) return 'connecting'
if (streamSignalState.value === 'no_signal') return 'no_signal'
if (streamSignalState.value === 'device_busy') return 'busy'
if (streamSignalState.value === 'device_lost') return 'error'
if (videoError.value) return 'error'
if (videoLoading.value) return 'connecting'
@@ -256,6 +265,24 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
return 'disconnected'
})
// Optional idle devices are neutral; video and HID must stay visible when unavailable.
const consoleStatusItems = computed<ConsoleStatusItem[]>(() => {
const items: ConsoleStatusItem[] = [
{ id: 'video', title: t('statusCard.video'), status: videoStatus.value,
quickInfo: videoQuickInfo.value, details: videoDetails.value, required: true,
errorMessage: showSignalOverlay.value ? signalOverlayInfo.value.title : videoErrorMessage.value },
{ id: 'audio', title: t('statusCard.audio'), status: audioStatus.value,
quickInfo: audioQuickInfo.value, details: audioDetails.value, errorMessage: audioErrorMessage.value },
{ id: 'hid', title: t('statusCard.hid'), status: hidStatus.value,
quickInfo: hidQuickInfo.value, details: hidDetails.value, errorMessage: hidErrorMessage.value, required: true },
]
if (showMsdStatusCard.value) items.push({
id: 'msd', title: t('statusCard.msd'), status: msdStatus.value,
quickInfo: msdQuickInfo.value, details: msdDetails.value, errorMessage: msdErrorMessage.value,
})
return items
})
function openStatsSheet() {
if (showConnectionStats.value) {
statsSheetOpen.value = true
@@ -288,6 +315,7 @@ const isMjpegPaused = computed(() => {
})
const videoQuickInfo = computed(() => {
if (videoStatus.value === 'no_signal' || videoStatus.value === 'busy') return t(`status.${videoStatus.value}`)
const stream = systemStore.stream
if (!stream?.resolution) return ''
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
@@ -306,8 +334,9 @@ const videoDetails = computed<StatusDetail[]>(() => {
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
const actualStatus: StatusDetail['status'] = paused
const signalUnavailable = streamSignalState.value !== 'ok'
const actualFpsValue = signalUnavailable ? videoQuickInfo.value : paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
const actualStatus: StatusDetail['status'] = signalUnavailable ? 'warning' : paused
? undefined
: receivedFps > 5 ? 'ok'
: receivedFps > 0 ? 'warning'
@@ -3157,17 +3186,22 @@ onUnmounted(() => {
</script>
<template>
<div class="h-screen h-dvh flex flex-col bg-background">
<header class="shrink-0 border-b bg-background">
<div class="relative h-screen h-dvh flex flex-col bg-background">
<header class="shrink-0 border-b bg-background" :class="consoleLayout === 'floating' && 'console-header--floating'">
<div class="px-2 sm:px-4">
<div class="h-10 sm:h-14 flex items-center justify-between">
<div class="flex items-center gap-2 sm:gap-6">
<div class="flex items-center gap-1.5 sm:gap-2">
<div class="console-header__row h-10 sm:h-14 flex items-center justify-between">
<div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-6">
<div class="flex shrink-0 items-center gap-1.5 whitespace-nowrap sm:gap-2">
<BrandMark size="md" class="hidden sm:block" />
<BrandMark size="sm" class="sm:hidden" />
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
</div>
<div class="flex md:hidden items-center gap-1">
<ConsoleStatusSummary
v-if="consoleLayout !== 'current'"
:items="consoleStatusItems"
:show-video-info="consoleLayout === 'sidebar'"
/>
<div v-else class="flex md:hidden items-center gap-1">
<StatusCard
:title="t('statusCard.video')"
type="video"
@@ -3189,8 +3223,19 @@ onUnmounted(() => {
/>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2">
<div class="hidden md:flex items-center gap-2">
<div v-if="consoleLayout === 'floating'" id="console-header-toolbar" class="console-header__toolbar" />
<div class="console-header__account flex shrink-0 items-center gap-1 sm:gap-2">
<ConsoleHeaderActions
v-if="consoleLayout === 'floating'"
:show-stats="showConnectionStats"
:show-terminal="showTerminal"
:terminal-running="!!ttydStatus?.running"
:show-computer-use="showComputerUse"
@open-stats="openStatsSheet"
@open-terminal="openTerminal"
@open-computer-use="openComputerUse"
/>
<div v-if="consoleLayout === 'current'" class="hidden md:flex items-center gap-2">
<StatusCard
:title="t('statusCard.video')"
type="video"
@@ -3270,7 +3315,9 @@ onUnmounted(() => {
</div>
</div>
</header>
<Teleport defer :to="consoleLayout === 'floating' ? '#console-header-toolbar' : 'body'" :disabled="consoleLayout !== 'floating'">
<ActionBar
:layout="consoleLayout"
:mouse-mode="mouseMode"
:video-mode="videoMode"
:ttyd-running="ttydStatus?.running"
@@ -3294,7 +3341,11 @@ onUnmounted(() => {
@open-terminal="openTerminal"
@open-computer-use="openComputerUse"
/>
<div class="flex-1 overflow-hidden relative">
</Teleport>
<div
class="flex-1 overflow-hidden relative transition-[padding] duration-300"
:class="consoleLayout === 'sidebar' && 'pl-14 sm:pl-16'"
>
<div class="absolute inset-0 dot-grid-bg" />
<div class="relative flex h-full w-full min-w-0 items-stretch gap-3 p-1 sm:p-4">
<div
@@ -3534,6 +3585,13 @@ onUnmounted(() => {
</Teleport>
<div id="keyboard-anchor"></div>
<InfoBar
v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked"
:compact="consoleLayout !== 'current'"
:captured="isPointerLocked"
:minimal="consoleLayout === 'floating'"
:class="consoleLayout === 'floating'
? 'pointer-events-none absolute bottom-2 left-1/2 z-30 max-w-[calc(100%-1rem)] -translate-x-1/2 rounded-lg border shadow-sm !w-auto'
: 'shrink-0'"
:pressed-keys="pressedKeys"
:caps-lock="keyboardLed.capsLock"
:num-lock="keyboardLed.numLock"
@@ -3601,6 +3659,27 @@ onUnmounted(() => {
</template>
<style scoped>
/* Expanded controls may overlap header actions, but stay outside the video stage. */
.console-header--floating .console-header__row {
position: relative;
height: 60px;
}
.console-header__toolbar {
position: absolute;
z-index: 40;
top: 50%;
left: 50%;
width: 100%;
max-width: 64rem;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
pointer-events: none;
}
.console-header__toolbar :deep(.console-action-bar) {
pointer-events: auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;