feat(web): 改版设置页与控制台界面

统一登录页、控制台和设置页的视觉样式,固定控制台设置入口并补充控件展开状态。
设置页改为按卡片保存,分别处理采集、编码、连接、HID 功能和设备标识,保留相邻卡片草稿。
增加共享帮助提示和移动端导航,固定前端构建依赖。

验证:独立暂存快照前端构建通过;控制台浏览器测试 5 组通过,覆盖登录、三种布局、窄屏、主题和语言切换。
This commit is contained in:
mofeng-git
2026-10-28 07:18:59 +08:00
parent fc99c6f274
commit fed753261b
27 changed files with 6837 additions and 1961 deletions

1
.gitignore vendored
View File

@@ -42,4 +42,3 @@ CLAUDE.md
secrets.toml secrets.toml
.env .env
/docs/ /docs/
web/package-lock.json

2747
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.6", "vite": "^7.3.6",
"vue-tsc": "^3.3.7" "vue-tsc": "3.3.6"
}, },
"overrides": { "overrides": {
"defu": "^6.1.5", "defu": "^6.1.5",

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling' import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -33,7 +32,6 @@ import {
import { import {
ClipboardPaste, ClipboardPaste,
HardDrive, HardDrive,
Settings,
Power, Power,
BarChart3, BarChart3,
Terminal, Terminal,
@@ -53,7 +51,6 @@ import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
import type { ConsoleLayout } from '@/composables/useConsoleLayout' import type { ConsoleLayout } from '@/composables/useConsoleLayout'
const { t, locale } = useI18n() const { t, locale } = useI18n()
const router = useRouter()
const systemStore = useSystemStore() const systemStore = useSystemStore()
const overflowMenuOpen = ref(false) const overflowMenuOpen = ref(false)
@@ -72,6 +69,10 @@ const props = defineProps<{
showMic?: boolean showMic?: boolean
scaleMode?: VideoScaleMode scaleMode?: VideoScaleMode
sourceSizeAvailable?: boolean sourceSizeAvailable?: boolean
keyboardVisible?: boolean
fullscreen?: boolean
statsOpen?: boolean
terminalOpen?: boolean
}>() }>()
const isSidebarLayout = computed(() => props.layout === 'sidebar') const isSidebarLayout = computed(() => props.layout === 'sidebar')
const isFloatingLayout = computed(() => props.layout === 'floating') const isFloatingLayout = computed(() => props.layout === 'floating')
@@ -159,7 +160,7 @@ let layoutResizeObserver: ResizeObserver | null = null
type CollapsibleItem = type CollapsibleItem =
| 'msd' | 'atx' | 'paste' | 'msd' | 'atx' | 'paste'
| 'stats' | 'terminal' | 'settings' | 'stats' | 'terminal'
interface ItemSpec { interface ItemSpec {
id: CollapsibleItem id: CollapsibleItem
@@ -172,7 +173,6 @@ const ITEM_SPECS: ItemSpec[] = [
{ 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' },
] ]
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map()) const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
@@ -300,7 +300,7 @@ const visibleSet = computed(() => {
if (isSidebarLayout.value) { if (isSidebarLayout.value) {
// Reserve More and a gap between the two groups before assigning vertical slots. // Reserve More and a gap between the two groups before assigning vertical slots.
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16 let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
const priority: CollapsibleItem[] = ['paste', 'settings', 'msd', 'atx', 'stats', 'terminal'] const priority: CollapsibleItem[] = ['paste', 'msd', 'atx', 'stats', 'terminal']
for (const id of priority) { for (const id of priority) {
if (!collapsibleItems.value.some(item => item.id === id)) continue if (!collapsibleItems.value.some(item => item.id === id)) continue
if (available < actionHeight.value) break if (available < actionHeight.value) break
@@ -365,7 +365,7 @@ const hasRightOverflow = computed(() => {
:aria-expanded="false" :aria-expanded="false"
@click="setFloatingCollapsed(false)" @click="setFloatingCollapsed(false)"
> >
<ChevronDown class="size-4" />{{ t('actionbar.expandToolbar') }} <ChevronDown class="size-4" /><span class="hidden sm:inline">{{ t('actionbar.expandToolbar') }}</span>
</Button> </Button>
<div <div
v-show="!isFloatingLayout || !floatingCollapsed" v-show="!isFloatingLayout || !floatingCollapsed"
@@ -424,6 +424,8 @@ const hasRightOverflow = computed(() => {
size="sm" size="sm"
class="h-8 gap-1.5 text-xs" class="h-8 gap-1.5 text-xs"
:aria-label="t('actionbar.virtualMedia')" :aria-label="t('actionbar.virtualMedia')"
aria-haspopup="dialog"
:aria-expanded="msdDialogOpen"
:title="t('actionbar.virtualMedia')" :title="t('actionbar.virtualMedia')"
@click="msdDialogOpen = true" @click="msdDialogOpen = true"
> >
@@ -512,6 +514,8 @@ const hasRightOverflow = computed(() => {
:text-only-scale="isSidebarLayout" :text-only-scale="isSidebarLayout"
:scale-mode="props.scaleMode" :scale-mode="props.scaleMode"
:source-size-available="props.sourceSizeAvailable" :source-size-available="props.sourceSizeAvailable"
:keyboard-visible="props.keyboardVisible"
:fullscreen="props.fullscreen"
@toggle-fullscreen="emit('toggleFullscreen')" @toggle-fullscreen="emit('toggleFullscreen')"
@update:scale-mode="emit('update:scaleMode', $event)" @update:scale-mode="emit('update:scaleMode', $event)"
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')" @toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
@@ -522,7 +526,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" :aria-label="t('actionbar.stats')" @click="emit('toggleStats')"> <Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" :aria-pressed="!!props.statsOpen" @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>
@@ -545,6 +549,7 @@ const hasRightOverflow = computed(() => {
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')" :aria-label="t('actionbar.webTerminal')"
:aria-pressed="!!props.terminalOpen"
@click="emit('openTerminal')" @click="emit('openTerminal')"
> >
<Terminal class="size-4" /> <Terminal class="size-4" />
@@ -558,25 +563,6 @@ const hasRightOverflow = computed(() => {
</TooltipProvider> </TooltipProvider>
</div> </div>
<!-- Settings - Adaptive -->
<div v-if="isVisible('settings')">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<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>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.settingsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Overflow Menu - Only show if there are overflowed items --> <!-- Overflow Menu - Only show if there are overflowed items -->
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen"> <DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
@@ -632,12 +618,6 @@ const hasRightOverflow = computed(() => {
<Terminal class="size-4 mr-2" /> <Terminal class="size-4 mr-2" />
{{ t('actionbar.webTerminal') }} {{ t('actionbar.webTerminal') }}
</DropdownMenuItem> </DropdownMenuItem>
<!-- Settings -->
<DropdownMenuItem v-if="!isFloatingLayout && !isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
<Settings class="size-4 mr-2" />
{{ t('actionbar.settings') }}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button <Button
@@ -700,7 +680,7 @@ const hasRightOverflow = computed(() => {
<!-- Hidden measurement container: renders each collapsible button in both <!-- Hidden measurement container: renders each collapsible button in both
icon-only and with-label forms so we can read their real offsetWidth. --> icon-only and with-label forms so we can read their real offsetWidth. -->
<div ref="measureRef" aria-hidden="true" class="fixed pointer-events-none" style="visibility: hidden; top: -9999px; left: -9999px; white-space: nowrap;"> <div ref="measureRef" aria-hidden="true" class="console-action-measure fixed pointer-events-none" style="visibility: hidden; top: -9999px; left: -9999px; white-space: nowrap;">
<div class="flex items-center gap-0.5 sm:gap-1.5 px-2 sm:px-4 py-1 sm:py-1.5"> <div class="flex items-center gap-0.5 sm:gap-1.5 px-2 sm:px-4 py-1 sm:py-1.5">
<!-- MSD --> <!-- MSD -->
<Button data-measure="msd-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button> <Button data-measure="msd-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
@@ -717,9 +697,6 @@ const hasRightOverflow = computed(() => {
<!-- Web Terminal --> <!-- Web Terminal -->
<Button data-measure="terminal-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" /></Button> <Button data-measure="terminal-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" /></Button>
<Button data-measure="terminal-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</Button> <Button data-measure="terminal-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</Button>
<!-- 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>
</div> </div>
</div> </div>
</template> </template>
@@ -765,19 +742,13 @@ const hasRightOverflow = computed(() => {
gap: 0; gap: 0;
overflow: hidden; overflow: hidden;
padding-inline: 0.5rem; padding-inline: 0.5rem;
border-radius: 0.5rem; border-radius: 10px;
} }
.console-action-bar--sidebar :deep(button > span) { .console-action-bar--sidebar :deep(button > span) {
display: none; 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 { .console-action-bar--sidebar .console-action-bar__inner {
overflow-y: auto; overflow-y: auto;
gap: 1rem; gap: 1rem;
@@ -801,10 +772,6 @@ const hasRightOverflow = computed(() => {
border-radius: 10px; border-radius: 10px;
} }
.console-action-bar--floating .console-action-bar__inner :deep(button[aria-expanded='true']) {
background: var(--accent);
}
@media (max-width: 639px) { @media (max-width: 639px) {
.console-action-bar--floating .console-action-bar__inner { .console-action-bar--floating .console-action-bar__inner {
padding-inline: 4px; padding-inline: 4px;

View File

@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { BarChart3, Settings, Terminal, MoreHorizontal } from 'lucide-vue-next' import { BarChart3, Terminal, MoreHorizontal } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -10,12 +9,13 @@ defineProps<{
showStats: boolean showStats: boolean
showTerminal: boolean showTerminal: boolean
terminalRunning: boolean terminalRunning: boolean
statsOpen?: boolean
terminalOpen?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'openStats'): void (e: 'openStats'): void
(e: 'openTerminal'): void (e: 'openTerminal'): void
}>() }>()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const menuOpen = ref(false) const menuOpen = ref(false)
function openFromMenu(action: () => void) { function openFromMenu(action: () => void) {
@@ -27,10 +27,10 @@ function openFromMenu(action: () => void) {
<template> <template>
<div class="console-header-actions flex shrink-0 items-center gap-1"> <div class="console-header-actions flex shrink-0 items-center gap-1">
<div class="hidden items-center gap-1 md:flex"> <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')"> <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')" :aria-pressed="!!statsOpen" @click="emit('openStats')">
<BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span> <BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span>
</Button> </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')"> <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" :aria-pressed="!!terminalOpen" @click="emit('openTerminal')">
<Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span> <Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span>
</Button> </Button>
</div> </div>
@@ -43,8 +43,5 @@ function openFromMenu(action: () => void) {
<DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem> <DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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> </div>
</template> </template>

View File

@@ -48,7 +48,7 @@ function dotClass(status: ConsoleStatusItem['status']) {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class="min-w-0 shrink gap-1.5 px-2 text-xs" class="console-status-summary min-w-0 shrink gap-1.5 px-2 text-xs"
:class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''" :class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''"
:aria-label="`${t('statusCard.connectionDetails')}: ${summary}`" :aria-label="`${t('statusCard.connectionDetails')}: ${summary}`"
:title="summary" :title="summary"
@@ -56,7 +56,7 @@ function dotClass(status: ConsoleStatusItem['status']) {
<AlertCircle v-if="hasError" class="size-3.5 shrink-0" /> <AlertCircle v-if="hasError" class="size-3.5 shrink-0" />
<Info v-else-if="issues.length" 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 v-else class="size-2 shrink-0 rounded-full bg-status-active" />
<span class="truncate" role="status">{{ summary }}</span> <span class="console-status-summary__label truncate" role="status">{{ summary }}</span>
<span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span> <span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span>
<ChevronDown class="size-3 shrink-0" /> <ChevronDown class="size-3 shrink-0" />
</Button> </Button>

View File

@@ -1,28 +1,165 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Loader2, Save, RefreshCw } from 'lucide-vue-next'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { useSystemStore } from '@/stores/system'
import { useHidConnection } from '@/composables/useHidConnection' import { useHidConnection } from '@/composables/useHidConnection'
import { deviceRequest, matchesSelection, selectionFrom, supportsOtgFunctions, type HidSelection } from '@/lib/hidGuide'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import HidDeviceOverview from './HidDeviceOverview.vue' import CardTitle from './SettingsCardTitle.vue'
import HidDriverForm from './HidDriverForm.vue'
import HidDriverDialog from './HidDriverDialog.vue' import HidDriverDialog from './HidDriverDialog.vue'
import BluetoothHidSettings from './BluetoothHidSettings.vue' import BluetoothHidSettings from './BluetoothHidSettings.vue'
const props = defineProps<{ active: boolean; dirty?: boolean }>() const props = defineProps<{ active: boolean; dirty?: boolean }>()
const emit = defineEmits<{ applied: []; discard: [] }>() const emit = defineEmits<{ applied: []; discard: [] }>()
const { t } = useI18n(), store = useConfigStore(), open = ref(false) const { t } = useI18n()
const active = computed(() => props.active && !open.value) const store = useConfigStore(),
system = useSystemStore()
const draft = ref<HidSelection>(selectionFrom(store.hid))
let baseline = JSON.stringify(draft.value)
const valid = ref(false),
busy = ref(false),
error = ref(''),
uncertain = ref(false),
repairOpen = ref(false)
const polling = computed(
() => props.active && store.hid?.backend === 'bluetooth' && !repairOpen.value,
)
const backend = computed(() => store.hid?.backend) const backend = computed(() => store.hid?.backend)
const { status, bluetooth, error, restart } = useHidConnection(active, backend) const { bluetooth, restart } = useHidConnection(polling, backend)
watch(
() => store.hid,
(hid) => {
if (JSON.stringify(draft.value) === baseline) draft.value = selectionFrom(hid)
baseline = JSON.stringify(selectionFrom(hid))
},
{ immediate: true },
)
watch(
draft,
() => {
uncertain.value = false
},
{ deep: true },
)
async function apply() {
if (busy.value || props.dirty || !valid.value || uncertain.value) return
busy.value = true
error.value = ''
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
await store.updateHid(deviceRequest(draft.value), controller.signal)
baseline = JSON.stringify(selectionFrom(store.hid))
draft.value = selectionFrom(store.hid)
emit('applied')
restart()
toast.success(t('settingsUi.driverApplied'))
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
// A timed-out reset may already have applied. Re-read, never repeat it.
try {
const current = await store.refreshHid()
if (matchesSelection(current, draft.value)) {
uncertain.value = true
error.value += ` ${t('hidGuide.uncertain')}`
}
} catch {
uncertain.value = true
}
} finally {
clearTimeout(timeout)
busy.value = false
}
}
async function reload() {
busy.value = true
error.value = ''
try {
await store.refreshHid()
draft.value = selectionFrom(store.hid)
baseline = JSON.stringify(draft.value)
uncertain.value = false
emit('applied')
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
busy.value = false
}
}
</script> </script>
<template> <template>
<section class="rounded-lg border p-5 space-y-4"> <Card>
<h3 class="font-semibold">{{ t('hidGuide.deviceTitle') }}</h3> <CardHeader class="settings-card-heading"
<HidDeviceOverview :hid="store.hid" :status="status" :bluetooth="bluetooth" :error="error" /> ><CardTitle :help="t('settingsUi.driverHelp')">{{ t('hidGuide.deviceTitle') }}</CardTitle>
<Button variant="outline" @click="open = true">{{ t(store.hid ? 'hidGuide.reconfigure' : 'hidGuide.configure') }}</Button> <div class="settings-card-actions">
</section> <Button :disabled="busy || !valid || dirty || uncertain" @click="apply"
<section v-if="store.hid?.backend === 'bluetooth'" class="rounded-lg border p-5 space-y-4"> ><Loader2 v-if="busy" class="size-4 animate-spin" /><Save v-else class="size-4" />{{
<h3 class="font-semibold">{{ t('hidGuide.features') }}</h3> t('settingsUi.applyDriver')
<BluetoothHidSettings :bluetooth="bluetooth" @refresh="restart" @reconfigure="open = true" /> }}</Button
</section> >
<HidDriverDialog v-if="open" :dirty="dirty" @close="open = false" @applied="emit('applied')" @discard="emit('discard')" /> </div>
</CardHeader>
<CardContent class="space-y-4">
<HidDriverForm
v-model="draft"
class="hid-settings-form"
:platform="system.platform"
:locked="busy"
@valid="valid = $event"
/>
<p
v-if="supportsOtgFunctions(store.hid?.backend) && !supportsOtgFunctions(draft.backend)"
class="text-sm text-warning"
>
{{
t('hidGuide.disableUsb', {
functions: [t('hidGuide.msd'), t('hidGuide.audio'), t('hidGuide.network')].join(' / '),
})
}}
</p>
<p v-if="draft.backend === 'bluetooth'" class="text-sm text-warning">
{{ t('hidGuide.resetWarning') }}
</p>
<div v-if="dirty" class="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>{{ t('settingsUi.saveFeaturesFirst') }}</span
><Button variant="outline" :disabled="busy" @click="emit('discard')">{{
t('settingsUi.discardFeatures')
}}</Button>
</div>
<div v-if="error" class="space-y-2">
<p role="alert" class="text-sm text-destructive">{{ error }}</p>
<Button v-if="uncertain" variant="outline" :disabled="busy" @click="reload"
><RefreshCw class="size-4" />{{ t('common.refresh') }}</Button
>
</div>
</CardContent>
</Card>
<Card v-if="store.hid?.backend === 'bluetooth'">
<CardHeader
><CardTitle :help="t('hidGuide.pairInstructions', { name: store.hid.bluetooth.name })">{{
t('hidGuide.features')
}}</CardTitle></CardHeader
>
<CardContent class="space-y-4"
><p class="text-sm text-muted-foreground">
{{ t('hidGuide.pairInstructions', { name: store.hid.bluetooth.name }) }}
</p>
<BluetoothHidSettings
:bluetooth="bluetooth"
@refresh="restart"
@reconfigure="repairOpen = true"
/></CardContent>
</Card>
<HidDriverDialog
v-if="repairOpen"
:dirty="dirty"
@close="repairOpen = false"
@applied="emit('applied')"
@discard="emit('discard')"
/>
</template> </template>

View File

@@ -2,13 +2,14 @@
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { Copy } from 'lucide-vue-next' import { Copy, Save, Loader2 } from 'lucide-vue-next'
import { mcpApi, type McpConfig } from '@/api' import { mcpApi, type McpConfig } from '@/api'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import CardTitle from './SettingsCardTitle.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
const clipboard = useClipboard() const clipboard = useClipboard()
@@ -18,7 +19,9 @@ const enabled = ref(false)
const rate = ref(5) const rate = ref(5)
const apiKey = ref('') const apiKey = ref('')
const busy = ref(false) const busy = ref(false)
const endpoint = computed(() => new URL(config.value?.endpoint ?? '/mcp', window.location.origin).href) const endpoint = computed(
() => new URL(config.value?.endpoint ?? '/mcp', window.location.origin).href,
)
function apply(value: McpConfig) { function apply(value: McpConfig) {
config.value = value config.value = value
@@ -29,9 +32,13 @@ function apply(value: McpConfig) {
async function load() { async function load() {
busy.value = true busy.value = true
config.value = null config.value = null
try { apply(await mcpApi.config()) } try {
catch (error) { toast.error(error instanceof Error ? error.message : t('mcp.loadFailed')) } apply(await mcpApi.config())
finally { busy.value = false } } catch (error) {
toast.error(error instanceof Error ? error.message : t('mcp.loadFailed'))
} finally {
busy.value = false
}
} }
async function save(regenerate = false) { async function save(regenerate = false) {
if (!Number.isInteger(rate.value) || rate.value < 1 || rate.value > 100) { if (!Number.isInteger(rate.value) || rate.value < 1 || rate.value > 100) {
@@ -40,10 +47,19 @@ async function save(regenerate = false) {
} }
busy.value = true busy.value = true
try { try {
apply(await mcpApi.updateConfig({ enabled: enabled.value, requests_per_second: rate.value, regenerate_api_key: regenerate })) apply(
await mcpApi.updateConfig({
enabled: enabled.value,
requests_per_second: rate.value,
regenerate_api_key: regenerate,
}),
)
toast.success(t('mcp.saved')) toast.success(t('mcp.saved'))
} catch (error) { toast.error(error instanceof Error ? error.message : t('mcp.saveFailed')) } } catch (error) {
finally { busy.value = false } toast.error(error instanceof Error ? error.message : t('mcp.saveFailed'))
} finally {
busy.value = false
}
} }
async function copy(value: string) { async function copy(value: string) {
if (await clipboard.copy(value)) toast.success(t('mcp.copied')) if (await clipboard.copy(value)) toast.success(t('mcp.copied'))
@@ -54,39 +70,78 @@ onMounted(load)
<template> <template>
<Card> <Card>
<CardHeader> <CardHeader class="settings-card-heading">
<CardTitle>{{ t('mcp.title') }}</CardTitle> <CardTitle :help="t('mcp.description') + ' ' + t('mcp.connectHint')">{{
<CardDescription>{{ t('mcp.description') }}</CardDescription> t('mcp.title')
}}</CardTitle>
<div class="settings-card-actions">
<Button :disabled="busy || !config" @click="save()"
><Loader2 v-if="busy" class="size-4 animate-spin" /><Save v-else class="size-4" />{{
t('mcp.save')
}}</Button
>
</div>
</CardHeader> </CardHeader>
<CardContent class="space-y-5"> <CardContent class="space-y-5">
<div class="flex items-center justify-between gap-4"> <div class="settings-toggle">
<Label for="mcp-enabled">{{ t('mcp.enabled') }}</Label> <Label for="mcp-enabled">{{ t('mcp.enabled') }}</Label>
<Switch id="mcp-enabled" v-model="enabled" :disabled="busy || !config" /> <Switch id="mcp-enabled" v-model="enabled" :disabled="busy || !config" />
</div> </div>
<div class="space-y-2"> <div class="settings-field space-y-2">
<Label for="mcp-rate">{{ t('mcp.rate') }}</Label> <Label for="mcp-rate">{{ t('settingsUi.mcpRate') }}</Label>
<Input id="mcp-rate" v-model.number="rate" type="number" min="1" max="100" step="1" :disabled="busy || !config" /> <Input
id="mcp-rate"
:aria-description="t('mcp.rate')"
placeholder="1–100"
v-model.number="rate"
type="number"
min="1"
max="100"
step="1"
:disabled="busy || !config"
/>
</div> </div>
<div class="space-y-2"> <div class="settings-field space-y-2">
<Label>{{ t('mcp.endpoint') }}</Label> <Label>{{ t('mcp.endpoint') }}</Label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<code id="mcp-endpoint" class="min-w-0 flex-1 break-all rounded bg-muted px-3 py-2 font-mono text-sm">{{ endpoint }}</code> <code
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyEndpoint')" @click="copy(endpoint)"><Copy class="size-4" /></Button> id="mcp-endpoint"
class="min-w-0 flex-1 break-all rounded bg-muted px-3 py-2 font-mono text-sm"
>{{ endpoint }}</code
>
<Button
variant="ghost"
size="icon"
:aria-label="t('mcp.copyEndpoint')"
@click="copy(endpoint)"
><Copy class="size-4"
/></Button>
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="settings-field space-y-2">
<Label>API Key</Label> <Label>API Key</Label>
<div v-if="apiKey" class="flex items-center gap-2"> <div v-if="apiKey" class="flex items-center gap-2">
<code id="mcp-key" class="min-w-0 flex-1 break-all rounded bg-muted px-3 py-2 font-mono text-sm">{{ apiKey }}</code> <code
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyKey')" @click="copy(apiKey)"><Copy class="size-4" /></Button> id="mcp-key"
class="min-w-0 flex-1 break-all rounded bg-muted px-3 py-2 font-mono text-sm"
>{{ apiKey }}</code
>
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyKey')" @click="copy(apiKey)"
><Copy class="size-4"
/></Button>
</div> </div>
<p v-if="!apiKey" class="text-sm text-muted-foreground">{{ config?.api_key_configured ? t('mcp.keyConfigured') : t('mcp.keyMissing') }}</p> <p v-if="!apiKey" class="text-sm text-muted-foreground">
<Button variant="outline" :disabled="busy || !config" @click="save(true)">{{ t('mcp.regenerate') }}</Button> {{ config?.api_key_configured ? t('mcp.keyConfigured') : t('mcp.keyMissing') }}
</p>
<Button variant="outline" :disabled="busy || !config" @click="save(true)">{{
t('mcp.regenerate')
}}</Button>
</div> </div>
<p class="text-sm text-muted-foreground">{{ t('mcp.connectHint') }}</p>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button v-if="!config && !busy" variant="outline" @click="load">{{ t('mcp.retry') }}</Button> <Button v-if="!config && !busy" variant="outline" @click="load">{{
<Button :disabled="busy || !config" @click="save()">{{ t('mcp.save') }}</Button> t('mcp.retry')
}}</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -181,7 +181,7 @@ function handleKeydown(e: KeyboardEvent) {
<template> <template>
<div class="p-4 space-y-4"> <div class="p-4 space-y-4">
<h3 class="font-semibold text-sm">{{ t('paste.title') }}</h3> <h3 class="text-sm font-medium">{{ t('paste.title') }}</h3>
<Textarea <Textarea
id="paste-text" id="paste-text"

View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
import SettingsHelp from './SettingsHelp.vue'
defineProps<{ help?: string }>()
</script>
<template>
<h2 data-slot="card-title" class="settings-card-title">
<slot />
<SettingsHelp v-if="help" :content="help" />
</h2>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { onClickOutside, onKeyStroke } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { activeSettingsHelp } from '@/lib/settings-help'
import { HelpCircle } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
defineProps<{ content: string }>()
const { t } = useI18n()
const trigger = ref<HTMLElement>()
const open = ref(false)
const pinned = ref(false)
const identity = Symbol('settings-help')
function close() {
pinned.value = false
open.value = false
if (activeSettingsHelp.value === identity) activeSettingsHelp.value = null
}
function show(value: boolean) {
if (value) {
activeSettingsHelp.value = identity
open.value = true
} else if (!pinned.value) close()
}
function toggle() {
if (pinned.value) close()
else {
pinned.value = true
show(true)
}
}
watch(activeSettingsHelp, (value) => {
if (value !== identity) close()
})
onUnmounted(close)
onClickOutside(trigger, close)
onKeyStroke('Escape', close)
</script>
<template>
<span ref="trigger" class="settings-help">
<TooltipProvider :delay-duration="150">
<Tooltip :open="open" @update:open="show">
<TooltipTrigger as-child>
<button
type="button"
:aria-label="t('settingsUi.help')"
:aria-expanded="open"
@click.stop="toggle"
>
<HelpCircle aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
:show-arrow="false"
side="bottom"
align="start"
class="settings-help-content"
>
{{ content }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { SidebarMenuButton, useSidebar } from '@/components/ui/sidebar'
defineOptions({ inheritAttrs: false })
defineProps<{ isActive?: boolean }>()
const { isMobile, setOpenMobile } = useSidebar()
function closeMobile() {
if (isMobile.value) setOpenMobile(false)
}
</script>
<template>
<SidebarMenuButton
v-bind="$attrs"
:is-active="isActive"
:aria-current="isActive ? 'page' : undefined"
@click="closeMobile"
><slot
/></SidebarMenuButton>
</template>

View File

@@ -150,7 +150,8 @@ const statusBadgeText = computed(() => {
variant="outline" variant="outline"
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`" :aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn( :class="cn(
'h-auto flex-col items-start gap-0.5 text-left', 'console-status-card h-auto flex-col items-start gap-0.5 text-left',
compact && 'console-status-card--compact',
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]', compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
status === 'error' && 'border-destructive/50' status === 'error' && 'border-destructive/50'
)" )"
@@ -239,7 +240,8 @@ const statusBadgeText = computed(() => {
variant="outline" variant="outline"
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`" :aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn( :class="cn(
'h-auto flex-col items-start gap-0.5 text-left', 'console-status-card h-auto flex-col items-start gap-0.5 text-left',
compact && 'console-status-card--compact',
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]', compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
status === 'error' && 'border-destructive/50' status === 'error' && 'border-destructive/50'
)" )"

View File

@@ -4,12 +4,18 @@ import { useI18n } from 'vue-i18n'
import QrcodeVue from 'qrcode.vue' import QrcodeVue from 'qrcode.vue'
import { authApi } from '@/api' import { authApi } from '@/api'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import CardTitle from './SettingsCardTitle.vue'
import { AlertTriangle, KeyRound, Loader2, ShieldCheck, ShieldOff } from 'lucide-vue-next' import { AlertTriangle, KeyRound, Loader2, ShieldCheck, ShieldOff } from 'lucide-vue-next'
const { t, locale } = useI18n() const { t, locale } = useI18n()
@@ -84,7 +90,8 @@ function normalizeCode(value: string) {
function localizedError(error: unknown) { function localizedError(error: unknown) {
const raw = error instanceof Error ? error.message : '' const raw = error instanceof Error ? error.message : ''
if (raw.includes('Current password is incorrect')) return t('settings.totp.currentPasswordIncorrect') if (raw.includes('Current password is incorrect'))
return t('settings.totp.currentPasswordIncorrect')
if (raw.includes('Invalid TOTP code')) return t('settings.totp.invalidCode') if (raw.includes('Invalid TOTP code')) return t('settings.totp.invalidCode')
if (raw.includes('enrollment expired')) return t('settings.totp.enrollmentExpired') if (raw.includes('enrollment expired')) return t('settings.totp.enrollmentExpired')
if (raw.includes('Too many attempts')) return t('settings.totp.rateLimited') if (raw.includes('Too many attempts')) return t('settings.totp.rateLimited')
@@ -215,29 +222,25 @@ onUnmounted(() => {
<template> <template>
<Card> <Card>
<CardHeader class="flex flex-row items-start justify-between gap-4 space-y-0"> <CardHeader class="settings-card-heading"
<div class="space-y-1.5"> ><CardTitle :help="t('settings.totp.description')">{{ t('settings.totp.title') }}</CardTitle>
<CardTitle>{{ t('settings.totp.title') }}</CardTitle> <div class="settings-card-actions">
<CardDescription>{{ t('settings.totp.description') }}</CardDescription> <Button v-if="!enabled" :disabled="statusLoading" @click="enrollmentOpen = true">
</div> <ShieldCheck class="size-4" />
<Badge :variant="enabled ? 'default' : 'secondary'"> {{ t('settings.totp.enable') }}
{{ enabled ? t('common.enabled') : t('common.disabled') }} </Button>
</Badge> <Button v-else variant="destructive" :disabled="statusLoading" @click="disableOpen = true">
</CardHeader> <ShieldOff class="size-4" />
{{ t('settings.totp.disable') }}
</Button>
</div></CardHeader
>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="grid gap-3 text-sm sm:grid-cols-2"> <p class="text-sm text-muted-foreground">{{ t('settings.totp.description') }}</p>
<div class="space-y-1"> <div v-if="clockDriftWarning" class="space-y-1 text-sm text-muted-foreground">
<p class="text-xs text-muted-foreground">{{ t('settings.totp.serverTime') }}</p> <p>{{ serverTime }} UTC · {{ localTime }}</p>
<p class="font-mono">{{ serverTime }} UTC</p> <p>{{ t('settings.totp.clockOffset', { seconds: clockOffsetSeconds }) }}</p>
</div> </div>
<div class="space-y-1">
<p class="text-xs text-muted-foreground">{{ t('settings.totp.localTime') }}</p>
<p class="font-mono">{{ localTime }}</p>
</div>
</div>
<p class="text-xs text-muted-foreground">
{{ t('settings.totp.clockOffset', { seconds: clockOffsetSeconds }) }}
</p>
<Alert v-if="clockDriftWarning" variant="destructive"> <Alert v-if="clockDriftWarning" variant="destructive">
<AlertTriangle /> <AlertTriangle />
<AlertDescription>{{ t('settings.totp.clockDriftWarning') }}</AlertDescription> <AlertDescription>{{ t('settings.totp.clockDriftWarning') }}</AlertDescription>
@@ -247,16 +250,6 @@ onUnmounted(() => {
<AlertDescription>{{ statusError }}</AlertDescription> <AlertDescription>{{ statusError }}</AlertDescription>
</Alert> </Alert>
</CardContent> </CardContent>
<CardFooter class="border-t pt-4 justify-end">
<Button v-if="!enabled" :disabled="statusLoading" @click="enrollmentOpen = true">
<ShieldCheck class="size-4" />
{{ t('settings.totp.enable') }}
</Button>
<Button v-else variant="destructive" :disabled="statusLoading" @click="disableOpen = true">
<ShieldOff class="size-4" />
{{ t('settings.totp.disable') }}
</Button>
</CardFooter>
</Card> </Card>
<Dialog v-model:open="enrollmentOpen"> <Dialog v-model:open="enrollmentOpen">
@@ -268,7 +261,12 @@ onUnmounted(() => {
<div v-if="enrollmentStep === 'password'" class="space-y-4"> <div v-if="enrollmentStep === 'password'" class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="totp-enrollment-password">{{ t('settings.currentPassword') }}</Label> <Label for="totp-enrollment-password">{{ t('settings.currentPassword') }}</Label>
<Input id="totp-enrollment-password" v-model="enrollmentPassword" type="password" autocomplete="current-password" /> <Input
id="totp-enrollment-password"
v-model="enrollmentPassword"
type="password"
autocomplete="current-password"
/>
</div> </div>
</div> </div>
<div v-else class="space-y-4"> <div v-else class="space-y-4">
@@ -283,8 +281,18 @@ onUnmounted(() => {
<div class="space-y-2"> <div class="space-y-2">
<Label for="totp-enrollment-code">{{ t('auth.totpCode') }}</Label> <Label for="totp-enrollment-code">{{ t('auth.totpCode') }}</Label>
<div class="relative"> <div class="relative">
<KeyRound class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <KeyRound
<Input id="totp-enrollment-code" v-model="enrollmentCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="pl-10 font-mono" @input="enrollmentCode = normalizeCode(enrollmentCode)" /> class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="totp-enrollment-code"
v-model="enrollmentCode"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
class="pl-10 font-mono"
@input="enrollmentCode = normalizeCode(enrollmentCode)"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -294,8 +302,14 @@ onUnmounted(() => {
<AlertDescription>{{ enrollmentError }}</AlertDescription> <AlertDescription>{{ enrollmentError }}</AlertDescription>
</Alert> </Alert>
<DialogFooter> <DialogFooter>
<Button variant="outline" :disabled="enrollmentLoading" @click="enrollmentOpen = false">{{ t('common.cancel') }}</Button> <Button variant="outline" :disabled="enrollmentLoading" @click="enrollmentOpen = false">{{
<Button v-if="enrollmentStep === 'password'" :disabled="enrollmentLoading" @click="beginEnrollment"> t('common.cancel')
}}</Button>
<Button
v-if="enrollmentStep === 'password'"
:disabled="enrollmentLoading"
@click="beginEnrollment"
>
<Loader2 v-if="enrollmentLoading" class="size-4 animate-spin" /> <Loader2 v-if="enrollmentLoading" class="size-4 animate-spin" />
{{ t('common.next') }} {{ t('common.next') }}
</Button> </Button>
@@ -315,11 +329,24 @@ onUnmounted(() => {
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="totp-disable-password">{{ t('settings.currentPassword') }}</Label> <Label for="totp-disable-password">{{ t('settings.currentPassword') }}</Label>
<Input id="totp-disable-password" v-model="disablePassword" type="password" autocomplete="current-password" /> <Input
id="totp-disable-password"
v-model="disablePassword"
type="password"
autocomplete="current-password"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="totp-disable-code">{{ t('auth.totpCode') }}</Label> <Label for="totp-disable-code">{{ t('auth.totpCode') }}</Label>
<Input id="totp-disable-code" v-model="disableCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="font-mono" @input="disableCode = normalizeCode(disableCode)" /> <Input
id="totp-disable-code"
v-model="disableCode"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
class="font-mono"
@input="disableCode = normalizeCode(disableCode)"
/>
</div> </div>
<Alert v-if="disableError" variant="destructive"> <Alert v-if="disableError" variant="destructive">
<AlertTriangle /> <AlertTriangle />
@@ -327,7 +354,9 @@ onUnmounted(() => {
</Alert> </Alert>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" :disabled="disableLoading" @click="disableOpen = false">{{ t('common.cancel') }}</Button> <Button variant="outline" :disabled="disableLoading" @click="disableOpen = false">{{
t('common.cancel')
}}</Button>
<Button variant="destructive" :disabled="disableLoading" @click="disableTotp"> <Button variant="destructive" :disabled="disableLoading" @click="disableTotp">
<Loader2 v-if="disableLoading" class="size-4 animate-spin" /> <Loader2 v-if="disableLoading" class="size-4 animate-spin" />
{{ t('settings.totp.disable') }} {{ t('settings.totp.disable') }}

View File

@@ -15,6 +15,8 @@ const props = defineProps<{
textOnlyScale?: boolean textOnlyScale?: boolean
scaleMode?: VideoScaleMode scaleMode?: VideoScaleMode
sourceSizeAvailable?: boolean sourceSizeAvailable?: boolean
keyboardVisible?: boolean
fullscreen?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -41,6 +43,7 @@ function toggleScaleMode() {
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="t('actionbar.fullscreen')" :aria-label="t('actionbar.fullscreen')"
:aria-pressed="!!props.fullscreen"
@click="emit('toggleFullscreen')" @click="emit('toggleFullscreen')"
> >
<Maximize class="size-3.5 sm:size-4" /> <Maximize class="size-3.5 sm:size-4" />
@@ -86,6 +89,7 @@ function toggleScaleMode() {
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="t('actionbar.keyboard')" :aria-label="t('actionbar.keyboard')"
:aria-pressed="!!props.keyboardVisible"
@click="emit('toggleVirtualKeyboard')" @click="emit('toggleVirtualKeyboard')"
> >
<Keyboard class="size-3.5 sm:size-4" /> <Keyboard class="size-3.5 sm:size-4" />
@@ -98,10 +102,4 @@ function toggleScaleMode() {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<div
v-if="!minimal"
data-fixed-action
aria-hidden="true"
class="mx-2 h-5 w-px shrink-0 self-center bg-border"
/>
</template> </template>

View File

@@ -5,7 +5,13 @@ import type { VideoDevice, VideoFormat, VideoResolution } from '@/api'
import { formatFpsLabel } from '@/lib/fps' import { formatFpsLabel } from '@/lib/fps'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const props = defineProps<{ const props = defineProps<{
device?: VideoDevice device?: VideoDevice
@@ -66,7 +72,10 @@ const { t } = useI18n()
</div> </div>
<div v-else class="flex items-center justify-between gap-3" role="status"> <div v-else class="flex items-center justify-between gap-3" role="status">
<div class="flex min-w-0 items-center gap-2 text-muted-foreground" :class="compact ? 'text-xs' : 'text-sm'"> <div
class="flex min-w-0 items-center gap-2 text-muted-foreground"
:class="compact ? 'text-xs' : 'text-sm'"
>
<AlertTriangle class="size-4 shrink-0" /> <AlertTriangle class="size-4 shrink-0" />
<span>{{ t('videoInput.unavailable') }}</span> <span>{{ t('videoInput.unavailable') }}</span>
</div> </div>
@@ -85,14 +94,28 @@ const { t } = useI18n()
</template> </template>
<template v-else-if="device"> <template v-else-if="device">
<div class="space-y-2"> <div class="settings-field space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.format') }}</Label> <Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
<Select :model-value="format" @update:model-value="value => emit('update:format', String(value))"> t('videoInput.format')
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined"> }}</Label>
<Select
:model-value="format"
@update:model-value="(value) => emit('update:format', String(value))"
>
<SelectTrigger
:size="compact ? 'sm' : 'default'"
class="w-full"
:class="compact ? 'text-xs' : undefined"
>
<SelectValue :placeholder="t('videoInput.selectFormat')" /> <SelectValue :placeholder="t('videoInput.selectFormat')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem v-for="item in formats" :key="item.format" :value="item.format" :disabled="item.disabled"> <SelectItem
v-for="item in formats"
:key="item.format"
:value="item.format"
:disabled="item.disabled"
>
{{ item.description || item.format }} {{ item.description || item.format }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -100,24 +123,46 @@ const { t } = useI18n()
</div> </div>
<div :class="resolutionFpsInline ? 'grid grid-cols-2 gap-3' : 'space-y-4'"> <div :class="resolutionFpsInline ? 'grid grid-cols-2 gap-3' : 'space-y-4'">
<div class="space-y-2"> <div class="settings-field space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.resolution') }}</Label> <Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
<Select :model-value="resolution" @update:model-value="value => emit('update:resolution', String(value))"> t('videoInput.resolution')
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined"> }}</Label>
<Select
:model-value="resolution"
@update:model-value="(value) => emit('update:resolution', String(value))"
>
<SelectTrigger
:size="compact ? 'sm' : 'default'"
class="w-full"
:class="compact ? 'text-xs' : undefined"
>
<SelectValue :placeholder="t('videoInput.selectResolution')" /> <SelectValue :placeholder="t('videoInput.selectResolution')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem v-for="item in resolutions" :key="`${item.width}x${item.height}`" :value="`${item.width}x${item.height}`"> <SelectItem
v-for="item in resolutions"
:key="`${item.width}x${item.height}`"
:value="`${item.width}x${item.height}`"
>
{{ item.width }}x{{ item.height }} {{ item.width }}x{{ item.height }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="space-y-2"> <div class="settings-field space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.frameRate') }}</Label> <Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
<Select :model-value="fps === null ? '' : String(fps)" @update:model-value="value => emit('update:fps', Number(value))"> t('videoInput.frameRate')
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined"> }}</Label>
<Select
:model-value="fps === null ? '' : String(fps)"
@update:model-value="(value) => emit('update:fps', Number(value))"
>
<SelectTrigger
:size="compact ? 'sm' : 'default'"
class="w-full"
:class="compact ? 'text-xs' : undefined"
>
<SelectValue :placeholder="t('videoInput.selectFps')" /> <SelectValue :placeholder="t('videoInput.selectFps')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -11,13 +11,14 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(), { const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"]; showArrow?: boolean }>(), {
sideOffset: 4, sideOffset: 4,
showArrow: true,
}) })
const emits = defineEmits<TooltipContentEmits>() const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "showArrow")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@@ -31,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
> >
<slot /> <slot />
<TooltipArrow class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs" /> <TooltipArrow v-if="showArrow" class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs" />
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>
</template> </template>

View File

@@ -1,4 +1,6 @@
import { settingsUiEn } from './settings-ui'
export default { export default {
settingsUi: settingsUiEn,
hidGuide: { hidGuide: {
driver_ch9329_otg: 'CH9329 + OTG composite mode', driver_ch9329_otg: 'CH9329 + OTG composite mode',
compositeHelp: 'CH9329 handles keyboard and mouse. OTG provides virtual media, USB networking and USB audio. Enable advanced functions in their settings.', compositeHelp: 'CH9329 handles keyboard and mouse. OTG provides virtual media, USB networking and USB audio. Enable advanced functions in their settings.',
@@ -12,6 +14,7 @@ export default {
otgInactive: 'Not enabled', otgInactive: 'Not enabled',
otgConfigured: 'USB configured by host', otgConfigured: 'USB configured by host',
otgWaiting: 'Bound, waiting for host', otgWaiting: 'Bound, waiting for host',
missingDevice: 'The previous device {device} is unavailable. Check the current selection.', missingDevice: 'The previous device {device} is unavailable. Check the current selection.',
connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.', connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.',
"deviceTitle": "Driver and device", "deviceTitle": "Driver and device",
@@ -634,7 +637,7 @@ export default {
videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers', videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers',
hidSubtitle: 'Configure the HID backend, device, descriptors, and USB gadget functions', hidSubtitle: 'Configure the HID backend, device, descriptors, and USB gadget functions',
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN', atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
environmentSubtitle: 'View device information and system runtime diagnostics', environmentSubtitle: 'Diagnose USB OTG and video encoding',
otherSubtitle: 'Additional hardware controls and USB device maintenance', otherSubtitle: 'Additional hardware controls and USB device maintenance',
aboutSubtitle: 'Online upgrade and version information', aboutSubtitle: 'Online upgrade and version information',
extTtydSubtitle: 'Open a host Shell terminal in the browser', extTtydSubtitle: 'Open a host Shell terminal in the browser',
@@ -642,7 +645,7 @@ export default {
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services', extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
extFrpcSubtitle: 'NAT traversal through the FRP client', extFrpcSubtitle: 'NAT traversal through the FRP client',
watchdog: { watchdog: {
title: 'Hardware Watchdog', title: 'Hardware watchdog',
description: 'The hardware watchdog can trigger a hardware reset if the system hangs or the process exits', description: 'The hardware watchdog can trigger a hardware reset if the system hangs or the process exits',
status: { status: {
running: 'Running', running: 'Running',

View File

@@ -0,0 +1,46 @@
export const settingsUiZh = {
watchdogAutoReset: '启用自动复位',
mcpRate: '请求频率(次/秒)',
help: '查看提示',
back: '返回控制台',
saveCapture: '保存采集设置',
saveEncoder: '保存编码设置',
saveConnection: '保存连接设置',
project: '项目主页',
featuresHelp:
'USB 功能会按当前驱动与设备能力提供。修改后只保存本卡片配置,设备可能重新识别 USB 连接。',
identifiers: '高级设备标识',
identifiersHelp: 'VID、PID、厂商和产品名称决定被控机识别到的设备信息。修改后可能需要重新连接。',
applyDriver: '应用驱动',
saveFeaturesFirst: '请先保存或放弃功能配置,再切换驱动。',
discardFeatures: '放弃功能更改',
driverHelp: '选择驱动及对应设备,然后应用。应用驱动会重新建立键鼠连接。',
driverApplied: '驱动配置已应用',
saveFailed: '保存失败,输入已保留,请检查后重试。',
saveDone: '设置已保存',
checkUpdates: '检查更新',
}
export const settingsUiEn = {
watchdogAutoReset: 'Enable automatic recovery',
mcpRate: 'Requests per second',
help: 'Show help',
back: 'Back to console',
saveCapture: 'Save capture',
saveEncoder: 'Save encoder',
saveConnection: 'Save connection',
project: 'Project',
featuresHelp:
'Available USB functions depend on the driver and hardware. Saving applies only this card and may reconnect the USB device.',
identifiers: 'Advanced device identity',
identifiersHelp:
'VID, PID, manufacturer and product names identify the device to the target computer. Changes may require reconnection.',
applyDriver: 'Apply driver',
saveFeaturesFirst: 'Save or discard function changes before switching drivers.',
discardFeatures: 'Discard function changes',
driverHelp:
'Select a driver and its device, then apply. Applying re-establishes the keyboard and mouse connection.',
driverApplied: 'Driver configuration applied',
saveFailed: 'Could not save. Your input is preserved; check it and try again.',
saveDone: 'Settings saved',
checkUpdates: 'Check for updates',
}

View File

@@ -1,4 +1,6 @@
import { settingsUiZh } from './settings-ui'
export default { export default {
settingsUi: settingsUiZh,
hidGuide: { hidGuide: {
driver_ch9329_otg: 'CH9329 + OTG 复合模式', driver_ch9329_otg: 'CH9329 + OTG 复合模式',
compositeHelp: 'CH9329 负责键盘和鼠标,OTG 负责虚拟磁盘、USB 网络和 USB 音频。高级功能需在各自设置中启用。', compositeHelp: 'CH9329 负责键盘和鼠标,OTG 负责虚拟磁盘、USB 网络和 USB 音频。高级功能需在各自设置中启用。',
@@ -12,6 +14,7 @@ export default {
otgInactive: '未启用', otgInactive: '未启用',
otgConfigured: '主机已配置 USB', otgConfigured: '主机已配置 USB',
otgWaiting: '已绑定,等待主机连接', otgWaiting: '已绑定,等待主机连接',
missingDevice: '原设备 {device} 已不可用,请检查当前选择。', missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。', connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
"deviceTitle": "驱动与设备", "deviceTitle": "驱动与设备",
@@ -633,7 +636,7 @@ export default {
videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器', videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器',
hidSubtitle: '配置 HID 后端、设备、描述符与 USB Gadget 功能', hidSubtitle: '配置 HID 后端、设备、描述符与 USB Gadget 功能',
atxSubtitle: '配置远程电源控制硬件与网络唤醒', atxSubtitle: '配置远程电源控制硬件与网络唤醒',
environmentSubtitle: '查看设备信息与系统级运行环境', environmentSubtitle: '诊断 USB OTG 与视频编码环境',
otherSubtitle: '其他系统硬件控制与 USB 设备维护', otherSubtitle: '其他系统硬件控制与 USB 设备维护',
aboutSubtitle: '在线升级与版本信息', aboutSubtitle: '在线升级与版本信息',
extTtydSubtitle: '在浏览器中打开本机 Shell 终端', extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
@@ -641,7 +644,7 @@ export default {
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问', extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透', extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
watchdog: { watchdog: {
title: '硬件 Watchdog', title: '硬件看门狗',
description: '硬件 Watchdog 可在系统死机或程序退出时自动触发硬件复位信号', description: '硬件 Watchdog 可在系统死机或程序退出时自动触发硬件复位信号',
status: { status: {
running: '运行中', running: '运行中',

View File

@@ -0,0 +1,3 @@
import { ref } from 'vue'
// A pinned explanation yields when another card's help is opened.
export const activeSettingsHelp = ref<symbol | null>(null)

View File

@@ -4,6 +4,7 @@ import App from './App.vue'
import router from './router' import router from './router'
import i18n from './i18n' import i18n from './i18n'
import './style.css' import './style.css'
import './styles/console.css'
const app = createApp(App) const app = createApp(App)

139
web/src/styles/console.css Normal file
View File

@@ -0,0 +1,139 @@
/* Login and console share scheme B surfaces; settings keep their own styles. */
.login-page,
.console-page {
--console-control-active: color-mix(in srgb, var(--background) 93%, var(--foreground) 7%);
--console-workspace: color-mix(in srgb, var(--background) 97%, var(--foreground) 3%);
}
.login-page.dot-grid-bg,
.console-page .dot-grid-bg {
background-color: var(--console-workspace);
background-image: none;
}
.login-card {
border-radius: 16px;
box-shadow: 0 4px 18px rgb(0 0 0 / 4%), 0 1px 3px rgb(0 0 0 / 3%);
}
.login-card [data-slot="input"] {
border-radius: 10px;
background: var(--background);
box-shadow: none;
}
.login-card button {
border-radius: 10px;
}
.login-card button:not([type="submit"]) {
background: transparent;
box-shadow: none;
}
.login-card button:not([type="submit"]):is(:active, [aria-haspopup][aria-expanded="true"]) {
background: var(--console-control-active);
}
.login-card [data-slot="card-title"] {
font-weight: 600;
}
.login-card [data-slot="field-label"] {
font-weight: 500;
}
.login-card .text-xs,
.console-page :is(.console-header, .console-action-bar, .console-action-measure, .console-info-bar) .text-xs,
.console-page :is(.console-header, .console-action-bar, .console-action-measure) button {
font-size: 13px;
}
.console-page .console-status-card {
gap: 0;
border-radius: 10px;
box-shadow: none;
}
.console-page .console-status-card:not(.console-status-card--compact) :is(span, div) {
font-size: 13px;
line-height: 16px;
}
.console-page :is(.console-header, .console-action-bar) button {
border-radius: 10px;
background: transparent;
box-shadow: none;
font-variant-numeric: tabular-nums;
}
/* Hover tooltips do not select a tool. Only pressed or genuinely open controls do. */
.console-page :is(.console-header, .console-action-bar) button:is(
:active, [aria-pressed="true"], [aria-haspopup][aria-expanded="true"]
) {
background: var(--console-control-active);
}
.console-page :is(.console-header, .console-action-bar) button svg {
stroke-width: 1.75;
}
.console-page .console-action-bar--floating {
border-radius: 14px;
background: var(--background);
box-shadow: 0 2px 7px rgb(0 0 0 / 5%);
backdrop-filter: none;
}
.console-page .console-action-bar--sidebar {
background: var(--background);
backdrop-filter: none;
}
@media (max-width: 639px) {
.console-page .console-header--floating .console-status-summary__label {
display: none;
}
}
.console-page .console-info-bar [data-slot="badge"] {
border-radius: 7px;
background: transparent;
color: var(--muted-foreground);
}
.console-page .console-info-bar [data-slot="badge"][aria-pressed="true"] {
background: var(--console-control-active);
color: var(--foreground);
}
/* Portal surfaces inherit the same visual grammar through their console owner. */
[data-console-layout] {
--console-surface-radius: 14px;
--console-control-radius: 10px;
--console-surface-shadow: 0 6px 22px rgb(0 0 0 / 9%);
--console-control-active: color-mix(in srgb, var(--background) 93%, var(--foreground) 7%);
}
[data-console-layout] .text-xs {
font-size: 13px;
}
[data-console-layout] button[data-variant="ghost"],
[data-console-layout] button[data-variant="outline"] {
background: transparent;
box-shadow: none;
}
[data-console-layout] button:is([data-variant="ghost"], [data-variant="outline"]):is(
:active, [aria-pressed="true"], [aria-haspopup][aria-expanded="true"]
) {
background: var(--console-control-active);
}
.login-card :is(button, [data-slot="input"]):focus-visible,
.console-page :is(.console-header, .console-action-bar) button:focus-visible,
[data-console-layout] button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}

500
web/src/styles/settings.css Normal file
View File

@@ -0,0 +1,500 @@
/* Settings-specific surfaces share the product theme, including dark mode. */
.settings-page {
width: min(1088px, calc(100% - 112px));
margin: 0 48px 0 64px;
padding: 26px 0 44px;
font-size: 14px;
line-height: 1.6;
}
.settings-section-heading {
margin-bottom: 26px;
}
.settings-section-heading h1 {
font-size: 24px;
font-weight: 650;
line-height: 32px;
letter-spacing: 0;
}
.settings-section-heading p {
font-size: 14px;
margin-top: 5px;
color: var(--muted-foreground);
}
.settings-page > div > [data-slot='card'],
.settings-page > [data-slot='card'] {
margin-top: 20px;
}
.settings-page [data-slot='card'] {
min-width: 0;
gap: 22px;
padding: 23px 24px 22px;
border-radius: 14px;
box-shadow: none;
overflow: visible;
}
.settings-page [data-slot='card-header'],
.settings-page [data-slot='card-content'],
.settings-page [data-slot='card-footer'] {
padding-inline: 0;
}
.settings-page [data-slot='card-header'] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-width: 0;
grid-template-rows: none;
}
.settings-page .settings-card-heading:has(.settings-card-actions) {
position: sticky;
top: 0;
z-index: 15;
background: var(--card);
margin-top: -12px;
padding-top: 12px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.settings-card-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 16px;
line-height: 24px;
font-weight: 650;
min-width: 0;
margin: 0;
}
.settings-help {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.settings-help button {
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 5px;
color: var(--muted-foreground);
cursor: help;
}
.settings-help button:hover,
.settings-help button:focus-visible {
background: var(--muted);
color: var(--foreground);
}
.settings-help button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.settings-help svg {
width: 16px;
height: 16px;
stroke-width: 1.6;
}
[data-slot='tooltip-content'].settings-help-content {
z-index: 80;
max-width: min(290px, calc(100vw - 24px));
padding: 11px 13px;
font-size: 13px;
line-height: 1.7;
color: var(--popover-foreground);
background: var(--popover);
border: 1px solid var(--border);
box-shadow: 0 4px 16px #00000013;
border-radius: 7px;
text-wrap: pretty;
}
.settings-help-content [data-slot='tooltip-arrow'] {
display: none;
}
.settings-card-actions,
.settings-service-actions,
.settings-save-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
}
.settings-service-actions + .settings-save-action {
border-left: 1px solid var(--border);
padding-left: 16px;
margin-left: 6px;
}
.settings-page [data-slot='button'] {
font-size: 14px;
border-radius: 7px;
min-height: 36px;
white-space: nowrap;
}
.settings-page [data-slot='card-footer'] {
gap: 10px;
flex-wrap: wrap;
padding-top: 16px;
}
.settings-page [data-slot='card-content'] {
min-width: 0;
}
.settings-page [data-slot='input'],
.settings-page [data-slot='select-trigger'],
.settings-page textarea {
font-size: 14px;
border-radius: 7px;
min-height: 36px;
box-shadow: 0 1px 2px #00000004;
}
.settings-page [data-slot='label'] {
font-size: 14px;
font-weight: 500;
line-height: 21px;
}
.settings-page .settings-field {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
align-items: start;
gap: 8px 24px;
min-width: 0;
}
.settings-page .settings-field > [data-slot='label'] {
grid-column: 1;
grid-row: 1;
padding-top: 7px;
margin: 0;
text-align: left;
}
.settings-page .settings-field > :not([data-slot='label']) {
grid-column: 2;
min-width: 0;
margin-top: 0;
}
.settings-page .settings-field > [data-slot='label'] ~ [data-slot='label'] {
grid-column: 1;
grid-row: auto;
}
.settings-page .settings-field > [data-slot='select-trigger'],
.settings-page .settings-field [data-slot='select-trigger'] {
width: 100%;
}
.settings-page .settings-field-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
.settings-page .settings-toggle {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 24px;
min-height: 36px;
}
.settings-page .settings-toggle > :first-child:not([data-slot='switch']) {
flex: 0 1 180px;
min-width: 0;
}
.settings-page .settings-field .settings-toggle > :first-child:not([data-slot='switch']) {
flex: 1;
}
.settings-page .settings-compare-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.settings-page .settings-gpio-group {
border: 0;
border-radius: 0;
padding: 0;
min-width: 0;
}
.settings-page .settings-gpio-group .settings-field {
grid-template-columns: 120px minmax(0, 1fr);
column-gap: 16px;
}
.settings-page .text-xs {
font-size: 13px;
line-height: 1.7;
}
.settings-page .settings-field-list > [data-slot='separator'] {
margin-block: 2px;
}
.settings-page .settings-section-services {
display: flex;
flex-direction: column;
gap: 36px;
}
.settings-page .settings-section-services > [data-slot='card'] {
margin-top: 0;
}
.settings-page .settings-section-services > div[style*='display: none'] {
margin: 0;
}
.settings-back {
margin-top: auto;
border-top: 1px solid var(--border);
padding-top: 12px;
}
.load-metrics {
display: grid;
grid-template-columns: repeat(var(--metric-count, 4), minmax(0, 1fr));
gap: 24px;
padding: 3px 0 5px;
}
.load-metric {
text-align: center;
min-width: 0;
}
.usage-ring {
position: relative;
width: 114px;
height: 114px;
margin: 0 auto 16px;
display: grid;
place-items: center;
}
.usage-ring > svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
transform: rotate(-90deg);
}
.usage-ring circle {
fill: none;
stroke-width: 8;
}
.ring-track {
stroke: var(--muted);
}
.ring-value {
stroke: var(--foreground);
stroke-linecap: round;
}
.usage-value {
font-size: 27px;
line-height: 1;
font-weight: 600;
letter-spacing: -0.7px;
font-variant-numeric: tabular-nums;
}
.usage-value small {
font-size: 14px;
font-weight: 400;
color: var(--muted-foreground);
letter-spacing: 0;
margin-left: 2px;
}
.metric-label {
font-size: 14px;
line-height: 22px;
font-weight: 500;
}
.metric-detail {
font-size: 13px;
line-height: 21px;
color: var(--muted-foreground);
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.system-info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 44px;
margin: 0;
font-size: 14px;
line-height: 22px;
}
.system-info-grid > div {
display: grid;
grid-template-columns: 100px minmax(0, 1fr);
column-gap: 20px;
align-items: baseline;
padding: 11px 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
.system-info-grid > div:nth-last-child(-n + 2) {
border-bottom: 0;
}
.system-info-grid dt {
color: var(--muted-foreground);
}
.system-info-grid dd {
margin: 0;
overflow-wrap: anywhere;
}
.project-links {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 20px 28px;
border-top: 1px solid var(--border);
margin-top: 13px;
padding-top: 16px;
}
.project-links a {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted-foreground);
text-decoration: none;
font-size: 13px;
line-height: 22px;
}
.project-links a:hover {
text-decoration: underline;
color: var(--foreground);
}
.project-links svg {
width: 14px;
height: 14px;
}
@media (max-width: 1199px) {
.settings-page {
width: calc(100% - 48px);
margin-inline: 24px;
}
.settings-page .settings-field {
grid-template-columns: 150px minmax(0, 1fr);
}
.settings-page .settings-compare-grid {
grid-template-columns: 1fr;
}
.settings-card-actions {
flex-wrap: wrap;
}
.usage-ring {
width: 96px;
height: 96px;
}
.load-metrics {
gap: 12px;
}
.system-info-grid {
column-gap: 24px;
}
}
@media (max-width: 767px) {
.settings-page {
width: calc(100% - 32px);
margin-inline: 16px;
padding-top: 20px;
}
.settings-page [data-slot='card'] {
padding: 18px 16px;
gap: 18px;
}
.settings-page .settings-card-heading {
flex-wrap: wrap;
gap: 14px;
}
.settings-card-actions {
width: 100%;
justify-content: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.settings-card-actions [data-slot='button'] {
padding-inline: 10px;
}
.settings-service-actions + .settings-save-action {
padding-left: 10px;
margin-left: 0;
}
.settings-page .settings-field {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 7px;
}
.settings-page .settings-field > [data-slot='label'] {
padding-top: 0;
}
.settings-page .settings-toggle > :first-child:not([data-slot='switch']) {
flex: 1;
}
.settings-page .settings-field-list {
gap: 18px;
}
.load-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px 12px;
}
.system-info-grid {
grid-template-columns: 1fr;
}
.system-info-grid > div:nth-last-child(2) {
border-bottom: 1px solid var(--border);
}
.usage-ring {
width: 100px;
height: 100px;
}
.settings-section-heading h1 {
font-size: 22px;
}
.settings-page .settings-compare-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.settings-page * {
scroll-behavior: auto !important;
}
}
.settings-page > .settings-section-services + .settings-section-services {
margin-top: 36px;
}
.update-release-notes {
border-top: 1px solid var(--border);
padding-top: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.settings-page .hid-feature-card [data-slot='card-content'] .rounded-md.border:not([data-slot]) {
padding: 0;
border: 0;
border-radius: 0;
border-bottom: 1px solid var(--border);
padding-bottom: 20px;
}
.settings-page .hid-feature-card [data-slot='card-content'] .md\:grid-cols-2 {
grid-template-columns: 1fr;
}
.settings-page .hid-feature-card [data-slot='card-content'] > .space-y-4 > div:first-child {
display: none;
}
.settings-page .hid-settings-form > label {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 8px 24px;
align-items: center;
}
.settings-page .hid-settings-form > label > :not(span) {
grid-column: 2;
margin: 0;
}
.settings-page .hid-settings-form > label > span {
margin: 0;
}
@media (max-width: 767px) {
.settings-page .hid-settings-form > label {
display: flex;
flex-direction: column;
align-items: stretch;
}
}
.settings-page [data-slot='switch'] {
width: 34px;
height: 20px;
flex: none;
justify-self: start;
}
.settings-page .settings-environment{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;align-items:start}
.settings-page .settings-environment>[data-slot=card]{margin-top:0;min-width:0}
.settings-page .settings-environment>.settings-wide{grid-column:1/-1}
@media(max-width:1199px){.settings-page .settings-environment{grid-template-columns:1fr}}

View File

@@ -68,6 +68,7 @@ import {
ChevronDown, ChevronDown,
KeyRound, KeyRound,
Loader2, Loader2,
Settings,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const { t, te } = useI18n() const { t, te } = useI18n()
@@ -3103,8 +3104,8 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="relative h-screen h-dvh flex flex-col bg-background"> <div class="console-page relative h-screen h-dvh flex flex-col bg-background">
<header class="shrink-0 border-b bg-background" :class="consoleLayout === 'floating' && 'console-header--floating'"> <header class="console-header 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="console-header__row 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 min-w-0 flex-1 items-center gap-2 sm:gap-6"> <div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-6">
@@ -3146,6 +3147,8 @@ onUnmounted(() => {
:show-stats="showConnectionStats" :show-stats="showConnectionStats"
:show-terminal="showTerminal" :show-terminal="showTerminal"
:terminal-running="!!ttydStatus?.running" :terminal-running="!!ttydStatus?.running"
:stats-open="statsSheetOpen"
:terminal-open="showTerminalDialog"
@open-stats="openStatsSheet" @open-stats="openStatsSheet"
@open-terminal="openTerminal" @open-terminal="openTerminal"
@@ -3194,6 +3197,16 @@ onUnmounted(() => {
<Moon v-else class="size-4" /> <Moon v-else class="size-4" />
</Button> </Button>
<LanguageToggleButton class="size-8 hidden md:flex" /> <LanguageToggleButton class="size-8 hidden md:flex" />
<Button
variant="ghost"
size="icon-sm"
class="console-settings-button"
:aria-label="t('actionbar.settings')"
:title="t('actionbar.settings')"
@click="router.push('/settings')"
>
<Settings class="size-4" />
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 px-2 sm:px-3"> <Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 px-2 sm:px-3">
@@ -3249,6 +3262,10 @@ onUnmounted(() => {
:show-mic="microphoneTransferEnabled" :show-mic="microphoneTransferEnabled"
:scale-mode="videoScaleMode" :scale-mode="videoScaleMode"
:source-size-available="sourceSizeAvailable" :source-size-available="sourceSizeAvailable"
:keyboard-visible="virtualKeyboardVisible"
:fullscreen="isFullscreen"
:stats-open="statsSheetOpen"
:terminal-open="showTerminalDialog"
@toggle-fullscreen="toggleFullscreen" @toggle-fullscreen="toggleFullscreen"
@update:scale-mode="setVideoScaleMode" @update:scale-mode="setVideoScaleMode"
@toggle-stats="openStatsSheet" @toggle-stats="openStatsSheet"
@@ -3498,6 +3515,7 @@ onUnmounted(() => {
</Teleport> </Teleport>
<div id="keyboard-anchor"></div> <div id="keyboard-anchor"></div>
<InfoBar <InfoBar
class="console-info-bar"
v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked" v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked"
:compact="consoleLayout !== 'current'" :compact="consoleLayout !== 'current'"
:captured="isPointerLocked" :captured="isPointerLocked"

View File

@@ -120,8 +120,8 @@ function returnToPassword(keepError = false) {
</script> </script>
<template> <template>
<div class="min-h-screen min-h-dvh flex items-center justify-center dot-grid-bg p-4"> <div class="login-page min-h-screen min-h-dvh flex items-center justify-center dot-grid-bg p-4">
<Card class="relative w-full max-w-sm"> <Card class="login-card relative w-full max-w-sm">
<div class="absolute top-4 right-4"> <div class="absolute top-4 right-4">
<LanguageToggleButton /> <LanguageToggleButton />
</div> </div>

File diff suppressed because it is too large Load Diff