mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +08:00
feat(web): 改版设置页与控制台界面
统一登录页、控制台和设置页的视觉样式,固定控制台设置入口并补充控件展开状态。 设置页改为按卡片保存,分别处理采集、编码、连接、HID 功能和设备标识,保留相邻卡片草稿。 增加共享帮助提示和移动端导航,固定前端构建依赖。 验证:独立暂存快照前端构建通过;控制台浏览器测试 5 组通过,覆盖登录、三种布局、窄屏、主题和语言切换。
This commit is contained in:
2747
web/package-lock.json
generated
Normal file
2747
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
10
web/src/components/SettingsCardTitle.vue
Normal file
10
web/src/components/SettingsCardTitle.vue
Normal 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>
|
||||
66
web/src/components/SettingsHelp.vue
Normal file
66
web/src/components/SettingsHelp.vue
Normal 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>
|
||||
18
web/src/components/SettingsSidebarButton.vue
Normal file
18
web/src/components/SettingsSidebarButton.vue
Normal 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>
|
||||
@@ -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'
|
||||
)"
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
46
web/src/i18n/settings-ui.ts
Normal file
46
web/src/i18n/settings-ui.ts
Normal 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',
|
||||
}
|
||||
@@ -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: '运行中',
|
||||
|
||||
3
web/src/lib/settings-help.ts
Normal file
3
web/src/lib/settings-help.ts
Normal 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)
|
||||
@@ -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
139
web/src/styles/console.css
Normal 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
500
web/src/styles/settings.css
Normal 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}}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user