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
.env
/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",
"typescript": "~5.9.3",
"vite": "^7.3.6",
"vue-tsc": "^3.3.7"
"vue-tsc": "3.3.6"
},
"overrides": {
"defu": "^6.1.5",

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system'
import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling'
import { Button } from '@/components/ui/button'
@@ -33,7 +32,6 @@ import {
import {
ClipboardPaste,
HardDrive,
Settings,
Power,
BarChart3,
Terminal,
@@ -53,7 +51,6 @@ import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
const { t, locale } = useI18n()
const router = useRouter()
const systemStore = useSystemStore()
const overflowMenuOpen = ref(false)
@@ -72,6 +69,10 @@ const props = defineProps<{
showMic?: boolean
scaleMode?: VideoScaleMode
sourceSizeAvailable?: boolean
keyboardVisible?: boolean
fullscreen?: boolean
statsOpen?: boolean
terminalOpen?: boolean
}>()
const isSidebarLayout = computed(() => props.layout === 'sidebar')
const isFloatingLayout = computed(() => props.layout === 'floating')
@@ -159,7 +160,7 @@ let layoutResizeObserver: ResizeObserver | null = null
type CollapsibleItem =
| 'msd' | 'atx' | 'paste'
| 'stats' | 'terminal' | 'settings'
| 'stats' | 'terminal'
interface ItemSpec {
id: CollapsibleItem
@@ -172,7 +173,6 @@ const ITEM_SPECS: ItemSpec[] = [
{ id: 'paste', side: 'left' },
{ id: 'stats', side: 'right' },
{ id: 'terminal', side: 'right' },
{ id: 'settings', side: 'right' },
]
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
@@ -300,7 +300,7 @@ const visibleSet = computed(() => {
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']
const priority: CollapsibleItem[] = ['paste', 'msd', 'atx', 'stats', 'terminal']
for (const id of priority) {
if (!collapsibleItems.value.some(item => item.id === id)) continue
if (available < actionHeight.value) break
@@ -365,7 +365,7 @@ const hasRightOverflow = computed(() => {
:aria-expanded="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>
<div
v-show="!isFloatingLayout || !floatingCollapsed"
@@ -424,6 +424,8 @@ const hasRightOverflow = computed(() => {
size="sm"
class="h-8 gap-1.5 text-xs"
:aria-label="t('actionbar.virtualMedia')"
aria-haspopup="dialog"
:aria-expanded="msdDialogOpen"
:title="t('actionbar.virtualMedia')"
@click="msdDialogOpen = true"
>
@@ -512,6 +514,8 @@ const hasRightOverflow = computed(() => {
:text-only-scale="isSidebarLayout"
:scale-mode="props.scaleMode"
:source-size-available="props.sourceSizeAvailable"
:keyboard-visible="props.keyboardVisible"
:fullscreen="props.fullscreen"
@toggle-fullscreen="emit('toggleFullscreen')"
@update:scale-mode="emit('update:scaleMode', $event)"
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
@@ -522,7 +526,7 @@ const hasRightOverflow = computed(() => {
<TooltipProvider>
<Tooltip>
<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" />
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
</Button>
@@ -545,6 +549,7 @@ const hasRightOverflow = computed(() => {
class="h-8 gap-1.5 text-xs"
:disabled="!props.ttydRunning"
:aria-label="t('actionbar.webTerminal')"
:aria-pressed="!!props.terminalOpen"
@click="emit('openTerminal')"
>
<Terminal class="size-4" />
@@ -558,25 +563,6 @@ const hasRightOverflow = computed(() => {
</TooltipProvider>
</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 -->
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
<DropdownMenuTrigger as-child>
@@ -632,12 +618,6 @@ const hasRightOverflow = computed(() => {
<Terminal class="size-4 mr-2" />
{{ t('actionbar.webTerminal') }}
</DropdownMenuItem>
<!-- Settings -->
<DropdownMenuItem v-if="!isFloatingLayout && !isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
<Settings class="size-4 mr-2" />
{{ t('actionbar.settings') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
@@ -700,7 +680,7 @@ const hasRightOverflow = computed(() => {
<!-- Hidden measurement container: renders each collapsible button in both
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">
<!-- MSD -->
<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 -->
<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>
<!-- 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>
</template>
@@ -765,19 +742,13 @@ const hasRightOverflow = computed(() => {
gap: 0;
overflow: hidden;
padding-inline: 0.5rem;
border-radius: 0.5rem;
border-radius: 10px;
}
.console-action-bar--sidebar :deep(button > span) {
display: none;
}
.console-action-bar--sidebar :deep([data-fixed-action][aria-hidden='true']) {
width: 1.5rem;
height: 1px;
margin: 0.35rem auto;
}
.console-action-bar--sidebar .console-action-bar__inner {
overflow-y: auto;
gap: 1rem;
@@ -801,10 +772,6 @@ const hasRightOverflow = computed(() => {
border-radius: 10px;
}
.console-action-bar--floating .console-action-bar__inner :deep(button[aria-expanded='true']) {
background: var(--accent);
}
@media (max-width: 639px) {
.console-action-bar--floating .console-action-bar__inner {
padding-inline: 4px;

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -10,12 +9,13 @@ defineProps<{
showStats: boolean
showTerminal: boolean
terminalRunning: boolean
statsOpen?: boolean
terminalOpen?: boolean
}>()
const emit = defineEmits<{
(e: 'openStats'): void
(e: 'openTerminal'): void
}>()
const router = useRouter()
const { t } = useI18n()
const menuOpen = ref(false)
function openFromMenu(action: () => void) {
@@ -27,10 +27,10 @@ function openFromMenu(action: () => void) {
<template>
<div class="console-header-actions flex shrink-0 items-center gap-1">
<div class="hidden items-center gap-1 md:flex">
<Button v-if="showStats" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.stats')" :title="t('actionbar.stats')" @click="emit('openStats')">
<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>
</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>
</Button>
</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>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="sm" class="gap-1.5 rounded-lg px-2 text-xs" :aria-label="t('actionbar.settings')" :title="t('actionbar.settings')" @click="router.push('/settings')">
<Settings class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.settings') }}</span>
</Button>
</div>
</template>

View File

@@ -48,7 +48,7 @@ function dotClass(status: ConsoleStatusItem['status']) {
<Button
variant="ghost"
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' : ''"
:aria-label="`${t('statusCard.connectionDetails')}: ${summary}`"
:title="summary"
@@ -56,7 +56,7 @@ function dotClass(status: ConsoleStatusItem['status']) {
<AlertCircle v-if="hasError" class="size-3.5 shrink-0" />
<Info v-else-if="issues.length" class="size-3.5 shrink-0" />
<span v-else class="size-2 shrink-0 rounded-full bg-status-active" />
<span class="truncate" role="status">{{ summary }}</span>
<span 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>
<ChevronDown class="size-3 shrink-0" />
</Button>

View File

@@ -1,28 +1,165 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Loader2, Save, RefreshCw } from 'lucide-vue-next'
import { useConfigStore } from '@/stores/config'
import { useSystemStore } from '@/stores/system'
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 HidDeviceOverview from './HidDeviceOverview.vue'
import CardTitle from './SettingsCardTitle.vue'
import HidDriverForm from './HidDriverForm.vue'
import HidDriverDialog from './HidDriverDialog.vue'
import BluetoothHidSettings from './BluetoothHidSettings.vue'
const props = defineProps<{ active: boolean; dirty?: boolean }>()
const emit = defineEmits<{ applied: []; discard: [] }>()
const { t } = useI18n(), store = useConfigStore(), open = ref(false)
const active = computed(() => props.active && !open.value)
const { t } = useI18n()
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 { 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>
<template>
<section class="rounded-lg border p-5 space-y-4">
<h3 class="font-semibold">{{ t('hidGuide.deviceTitle') }}</h3>
<HidDeviceOverview :hid="store.hid" :status="status" :bluetooth="bluetooth" :error="error" />
<Button variant="outline" @click="open = true">{{ t(store.hid ? 'hidGuide.reconfigure' : 'hidGuide.configure') }}</Button>
</section>
<section v-if="store.hid?.backend === 'bluetooth'" class="rounded-lg border p-5 space-y-4">
<h3 class="font-semibold">{{ t('hidGuide.features') }}</h3>
<BluetoothHidSettings :bluetooth="bluetooth" @refresh="restart" @reconfigure="open = true" />
</section>
<HidDriverDialog v-if="open" :dirty="dirty" @close="open = false" @applied="emit('applied')" @discard="emit('discard')" />
<Card>
<CardHeader class="settings-card-heading"
><CardTitle :help="t('settingsUi.driverHelp')">{{ t('hidGuide.deviceTitle') }}</CardTitle>
<div class="settings-card-actions">
<Button :disabled="busy || !valid || dirty || uncertain" @click="apply"
><Loader2 v-if="busy" class="size-4 animate-spin" /><Save v-else class="size-4" />{{
t('settingsUi.applyDriver')
}}</Button
>
</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>

View File

@@ -2,13 +2,14 @@
import { computed, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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'
const clipboard = useClipboard()
@@ -18,7 +19,9 @@ const enabled = ref(false)
const rate = ref(5)
const apiKey = ref('')
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) {
config.value = value
@@ -29,9 +32,13 @@ function apply(value: McpConfig) {
async function load() {
busy.value = true
config.value = null
try { apply(await mcpApi.config()) }
catch (error) { toast.error(error instanceof Error ? error.message : t('mcp.loadFailed')) }
finally { busy.value = false }
try {
apply(await mcpApi.config())
} catch (error) {
toast.error(error instanceof Error ? error.message : t('mcp.loadFailed'))
} finally {
busy.value = false
}
}
async function save(regenerate = false) {
if (!Number.isInteger(rate.value) || rate.value < 1 || rate.value > 100) {
@@ -40,10 +47,19 @@ async function save(regenerate = false) {
}
busy.value = true
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'))
} catch (error) { toast.error(error instanceof Error ? error.message : t('mcp.saveFailed')) }
finally { busy.value = false }
} catch (error) {
toast.error(error instanceof Error ? error.message : t('mcp.saveFailed'))
} finally {
busy.value = false
}
}
async function copy(value: string) {
if (await clipboard.copy(value)) toast.success(t('mcp.copied'))
@@ -54,40 +70,79 @@ onMounted(load)
<template>
<Card>
<CardHeader>
<CardTitle>{{ t('mcp.title') }}</CardTitle>
<CardDescription>{{ t('mcp.description') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div class="flex items-center justify-between gap-4">
<Label for="mcp-enabled">{{ t('mcp.enabled') }}</Label>
<Switch id="mcp-enabled" v-model="enabled" :disabled="busy || !config" />
<CardHeader class="settings-card-heading">
<CardTitle :help="t('mcp.description') + ' ' + t('mcp.connectHint')">{{
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>
<CardContent class="space-y-5">
<div class="settings-toggle">
<Label for="mcp-enabled">{{ t('mcp.enabled') }}</Label>
<Switch id="mcp-enabled" v-model="enabled" :disabled="busy || !config" />
</div>
<div class="settings-field space-y-2">
<Label for="mcp-rate">{{ t('settingsUi.mcpRate') }}</Label>
<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 class="settings-field space-y-2">
<Label>{{ t('mcp.endpoint') }}</Label>
<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
>
<Button
variant="ghost"
size="icon"
:aria-label="t('mcp.copyEndpoint')"
@click="copy(endpoint)"
><Copy class="size-4"
/></Button>
</div>
<div class="space-y-2">
<Label for="mcp-rate">{{ t('mcp.rate') }}</Label>
<Input id="mcp-rate" v-model.number="rate" type="number" min="1" max="100" step="1" :disabled="busy || !config" />
</div>
<div class="settings-field space-y-2">
<Label>API Key</Label>
<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
>
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyKey')" @click="copy(apiKey)"
><Copy class="size-4"
/></Button>
</div>
<div class="space-y-2">
<Label>{{ t('mcp.endpoint') }}</Label>
<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>
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyEndpoint')" @click="copy(endpoint)"><Copy class="size-4" /></Button>
</div>
</div>
<div class="space-y-2">
<Label>API Key</Label>
<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>
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyKey')" @click="copy(apiKey)"><Copy class="size-4" /></Button>
</div>
<p v-if="!apiKey" class="text-sm text-muted-foreground">{{ 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>
<p class="text-sm text-muted-foreground">{{ t('mcp.connectHint') }}</p>
<div class="flex justify-end gap-2">
<Button v-if="!config && !busy" variant="outline" @click="load">{{ t('mcp.retry') }}</Button>
<Button :disabled="busy || !config" @click="save()">{{ t('mcp.save') }}</Button>
</div>
</CardContent>
<p v-if="!apiKey" class="text-sm text-muted-foreground">
{{ 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 class="flex justify-end gap-2">
<Button v-if="!config && !busy" variant="outline" @click="load">{{
t('mcp.retry')
}}</Button>
</div>
</CardContent>
</Card>
</template>

View File

@@ -181,7 +181,7 @@ function handleKeydown(e: KeyboardEvent) {
<template>
<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
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"
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
: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]',
status === 'error' && 'border-destructive/50'
)"
@@ -239,7 +240,8 @@ const statusBadgeText = computed(() => {
variant="outline"
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
: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]',
status === 'error' && 'border-destructive/50'
)"

View File

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

View File

@@ -15,6 +15,8 @@ const props = defineProps<{
textOnlyScale?: boolean
scaleMode?: VideoScaleMode
sourceSizeAvailable?: boolean
keyboardVisible?: boolean
fullscreen?: boolean
}>()
const emit = defineEmits<{
@@ -41,6 +43,7 @@ function toggleScaleMode() {
size="sm"
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
:aria-label="t('actionbar.fullscreen')"
:aria-pressed="!!props.fullscreen"
@click="emit('toggleFullscreen')"
>
<Maximize class="size-3.5 sm:size-4" />
@@ -86,6 +89,7 @@ function toggleScaleMode() {
size="sm"
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
:aria-label="t('actionbar.keyboard')"
:aria-pressed="!!props.keyboardVisible"
@click="emit('toggleVirtualKeyboard')"
>
<Keyboard class="size-3.5 sm:size-4" />
@@ -98,10 +102,4 @@ function toggleScaleMode() {
</Tooltip>
</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>

View File

@@ -5,7 +5,13 @@ import type { VideoDevice, VideoFormat, VideoResolution } from '@/api'
import { formatFpsLabel } from '@/lib/fps'
import { Button } from '@/components/ui/button'
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<{
device?: VideoDevice
@@ -66,7 +72,10 @@ const { t } = useI18n()
</div>
<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" />
<span>{{ t('videoInput.unavailable') }}</span>
</div>
@@ -85,14 +94,28 @@ const { t } = useI18n()
</template>
<template v-else-if="device">
<div class="space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.format') }}</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">
<div class="settings-field space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
t('videoInput.format')
}}</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')" />
</SelectTrigger>
<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 }}
</SelectItem>
</SelectContent>
@@ -100,24 +123,46 @@ const { t } = useI18n()
</div>
<div :class="resolutionFpsInline ? 'grid grid-cols-2 gap-3' : 'space-y-4'">
<div class="space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.resolution') }}</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">
<div class="settings-field space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
t('videoInput.resolution')
}}</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')" />
</SelectTrigger>
<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 }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.frameRate') }}</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">
<div class="settings-field space-y-2">
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
t('videoInput.frameRate')
}}</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')" />
</SelectTrigger>
<SelectContent>

View File

@@ -11,13 +11,14 @@ defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(), {
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"]; showArrow?: boolean }>(), {
sideOffset: 4,
showArrow: true,
})
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class", "showArrow")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -31,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
>
<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>
</TooltipPortal>
</template>

View File

@@ -1,4 +1,6 @@
import { settingsUiEn } from './settings-ui'
export default {
settingsUi: settingsUiEn,
hidGuide: {
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.',
@@ -12,6 +14,7 @@ export default {
otgInactive: 'Not enabled',
otgConfigured: 'USB configured by host',
otgWaiting: 'Bound, waiting for host',
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.',
"deviceTitle": "Driver and device",
@@ -634,7 +637,7 @@ export default {
videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers',
hidSubtitle: 'Configure the HID backend, device, descriptors, and USB gadget functions',
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',
aboutSubtitle: 'Online upgrade and version information',
extTtydSubtitle: 'Open a host Shell terminal in the browser',
@@ -642,7 +645,7 @@ export default {
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
extFrpcSubtitle: 'NAT traversal through the FRP client',
watchdog: {
title: 'Hardware Watchdog',
title: 'Hardware watchdog',
description: 'The hardware watchdog can trigger a hardware reset if the system hangs or the process exits',
status: {
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 {
settingsUi: settingsUiZh,
hidGuide: {
driver_ch9329_otg: 'CH9329 + OTG 复合模式',
compositeHelp: 'CH9329 负责键盘和鼠标,OTG 负责虚拟磁盘、USB 网络和 USB 音频。高级功能需在各自设置中启用。',
@@ -12,6 +14,7 @@ export default {
otgInactive: '未启用',
otgConfigured: '主机已配置 USB',
otgWaiting: '已绑定,等待主机连接',
missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
"deviceTitle": "驱动与设备",
@@ -633,7 +636,7 @@ export default {
videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器',
hidSubtitle: '配置 HID 后端、设备、描述符与 USB Gadget 功能',
atxSubtitle: '配置远程电源控制硬件与网络唤醒',
environmentSubtitle: '查看设备信息与系统级运行环境',
environmentSubtitle: '诊断 USB OTG 与视频编码环境',
otherSubtitle: '其他系统硬件控制与 USB 设备维护',
aboutSubtitle: '在线升级与版本信息',
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
@@ -641,7 +644,7 @@ export default {
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
watchdog: {
title: '硬件 Watchdog',
title: '硬件看门狗',
description: '硬件 Watchdog 可在系统死机或程序退出时自动触发硬件复位信号',
status: {
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 i18n from './i18n'
import './style.css'
import './styles/console.css'
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,
KeyRound,
Loader2,
Settings,
} from 'lucide-vue-next'
const { t, te } = useI18n()
@@ -3103,8 +3104,8 @@ onUnmounted(() => {
</script>
<template>
<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="console-page relative h-screen h-dvh flex flex-col bg-background">
<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="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">
@@ -3146,6 +3147,8 @@ onUnmounted(() => {
:show-stats="showConnectionStats"
:show-terminal="showTerminal"
:terminal-running="!!ttydStatus?.running"
:stats-open="statsSheetOpen"
:terminal-open="showTerminalDialog"
@open-stats="openStatsSheet"
@open-terminal="openTerminal"
@@ -3194,6 +3197,16 @@ onUnmounted(() => {
<Moon v-else class="size-4" />
</Button>
<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>
<DropdownMenuTrigger as-child>
<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"
:scale-mode="videoScaleMode"
:source-size-available="sourceSizeAvailable"
:keyboard-visible="virtualKeyboardVisible"
:fullscreen="isFullscreen"
:stats-open="statsSheetOpen"
:terminal-open="showTerminalDialog"
@toggle-fullscreen="toggleFullscreen"
@update:scale-mode="setVideoScaleMode"
@toggle-stats="openStatsSheet"
@@ -3498,6 +3515,7 @@ onUnmounted(() => {
</Teleport>
<div id="keyboard-anchor"></div>
<InfoBar
class="console-info-bar"
v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked"
:compact="consoleLayout !== 'current'"
:captured="isPointerLocked"

View File

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

File diff suppressed because it is too large Load Diff