feat: 优化网页消息提醒样式

This commit is contained in:
mofeng-git
2026-05-01 21:46:32 +08:00
parent e51d243324
commit 52754c862b
12 changed files with 245 additions and 73 deletions

View File

@@ -1,11 +1,24 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
import { KeepAlive, onMounted, watch } from 'vue'
import '@/sonner-overrides.css'
import { computed, KeepAlive, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSystemStore } from '@/stores/system'
import { Toaster } from '@/components/ui/sonner'
const { t } = useI18n()
/** Defaults merged into every toast; duration also set on `<Toaster>` for clarity */
const toasterToastOptions = computed(() => ({
closeButtonAriaLabel: t('toast.closeNotification'),
classes: {
title: 'text-sm font-semibold leading-snug tracking-tight text-popover-foreground',
description: 'text-sm leading-relaxed text-muted-foreground',
},
}))
const router = useRouter()
const authStore = useAuthStore()
const systemStore = useSystemStore()
@@ -56,5 +69,18 @@ watch(
</KeepAlive>
<component :is="Component" v-if="route.name !== 'Console' || !authStore.isAuthenticated" />
</RouterView>
<Toaster rich-colors close-button position="top-center" />
<Toaster
rich-colors
close-button
position="top-center"
close-button-position="top-right"
theme="system"
:duration="4000"
:gap="14"
:visible-toasts="3"
:offset="{ top: '1rem', right: '1rem', left: '1rem', bottom: '1rem' }"
:mobile-offset="{ top: 'max(1rem, env(safe-area-inset-top))', bottom: 'max(1rem, env(safe-area-inset-bottom))', left: '1rem', right: '1rem' }"
:toast-options="toasterToastOptions"
:container-aria-label="t('toast.notificationsRegion')"
/>
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
@@ -122,8 +121,6 @@ async function applyConfig() {
}
unifiedAudio.disconnect()
}
toast.success(t('config.applied'))
} catch (e) {
console.info('[AudioConfig] Failed to apply config:', e)
} finally {

View File

@@ -202,8 +202,6 @@ async function applyHidConfig() {
await configStore.updateHid(config)
toast.success(t('config.applied'))
// HID state will be updated via WebSocket device_info event
} catch (e) {
console.info('[HidConfig] Failed to apply config:', e)

View File

@@ -207,7 +207,6 @@ async function connectImage(image: MsdImage) {
try {
await msdApi.connect('image', image.id, cdromMode.value, readOnly.value)
await systemStore.fetchMsdState()
toast.success(t('msd.imageMounted', { name: image.name }))
} catch (e) {
console.error('Failed to connect image:', e)
} finally {
@@ -225,7 +224,6 @@ async function connectDrive() {
try {
await msdApi.connect('drive')
await systemStore.fetchMsdState()
toast.success(t('common.connected'))
} catch (e) {
console.error('Failed to connect drive:', e)
} finally {
@@ -242,7 +240,6 @@ async function disconnect() {
try {
await msdApi.disconnect()
await systemStore.fetchMsdState()
toast.success(t('msd.disconnected'))
} catch (e) {
console.error('Failed to disconnect:', e)
} finally {
@@ -263,11 +260,9 @@ async function executeDelete() {
if (deleteTarget.value.type === 'image') {
await msdApi.deleteImage(deleteTarget.value.id)
images.value = images.value.filter(i => i.id !== deleteTarget.value!.id)
toast.success(t('common.success'))
} else {
await msdApi.deleteDriveFile(deleteTarget.value.id)
await loadDriveFiles()
toast.success(t('common.success'))
}
} catch (e) {
console.error('Failed to delete:', e)
@@ -313,7 +308,6 @@ async function createDrive() {
await loadDriveInfo()
await loadDriveFiles()
showDriveInitDialog.value = false
toast.success(t('common.success'))
} catch (e) {
console.error('Failed to initialize drive:', e)
} finally {
@@ -330,7 +324,6 @@ async function deleteDrive() {
driveFiles.value = []
currentPath.value = '/'
showDeleteDriveDialog.value = false
toast.success(t('msd.driveDeleted'))
} catch (e) {
console.error('Failed to delete drive:', e)
} finally {

View File

@@ -525,7 +525,6 @@ async function applyVideoConfig() {
fps: toConfigFps(selectedFps.value),
})
toast.success(t('config.applied'))
isDirty.value = false
// Stream state will be updated via WebSocket system.device_info event
} catch (e) {

View File

@@ -14,7 +14,7 @@ const props = defineProps<ToasterProps>()
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
'--border-radius': 'calc(var(--radius) + 0.1875rem)',
}"
v-bind="props"
>
@@ -36,7 +36,7 @@ const props = defineProps<ToasterProps>()
</div>
</template>
<template #close-icon>
<XIcon class="size-4" />
<XIcon class="size-3 shrink-0" />
</template>
</Sonner>
</template>

View File

@@ -1,7 +1,5 @@
import { ref, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
export interface UseConfigPopoverOptions {
/** Reactive open state from props */
@@ -13,8 +11,6 @@ export interface UseConfigPopoverOptions {
}
export function useConfigPopover(options: UseConfigPopoverOptions) {
const { t } = useI18n()
const applying = ref(false)
const loadingDevices = ref(false)
@@ -36,7 +32,6 @@ export function useConfigPopover(options: UseConfigPopoverOptions) {
applying.value = true
try {
await applyFn()
toast.success(t('config.applied'))
} catch (e) {
console.info('[ConfigPopover] Apply failed:', e)
} finally {

View File

@@ -43,12 +43,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
}
function handleStreamReconnecting(data: { device: string; attempt: number }) {
if (data.attempt === 1 || data.attempt % 5 === 0) {
toast.info(t('console.deviceRecovering'), {
description: t('console.deviceRecoveringDesc', { attempt: data.attempt }),
duration: 3000,
})
}
handlers.onStreamReconnecting?.(data)
}
@@ -56,10 +50,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
if (systemStore.stream) {
systemStore.stream.online = true
}
toast.success(t('console.deviceRecovered'), {
description: t('console.deviceRecoveredDesc'),
duration: 3000,
})
handlers.onStreamRecovered?.(_data)
}

View File

@@ -43,6 +43,10 @@ export default {
toggleLanguage: 'Toggle language',
retry: 'Retry',
},
toast: {
closeNotification: 'Dismiss notification',
notificationsRegion: 'Notifications',
},
api: {
operationFailed: 'Operation Failed',
operationFailedDesc: 'Operation failed',

View File

@@ -43,6 +43,10 @@ export default {
toggleLanguage: '切换语言',
retry: '重试',
},
toast: {
closeNotification: '关闭通知',
notificationsRegion: '通知',
},
api: {
operationFailed: '操作失败',
operationFailedDesc: '操作失败',

View File

@@ -0,0 +1,205 @@
/* Must load after vue-sonner/style.css — overrides Sonner defaults to match shadcn-style tokens */
[data-sonner-toaster] {
font-family: inherit;
/* Inline default is 356px — flatten bar reads better a bit wider */
--width: min(30rem, calc(100vw - 2rem)) !important;
}
/* Top-right preset: tuck close control inside (+ vertically center) instead of protruding outside */
[data-sonner-toaster] [data-close-button-position='top-right'] {
--toast-close-button-right: 0.5rem;
--toast-close-button-left: unset;
--toast-close-button-top: 50%;
--toast-close-button-bottom: unset;
--toast-close-button-transform: translateY(-50%);
}
[data-sonner-toast][data-styled='true'] {
font-size: 0.875rem;
line-height: 1.35;
gap: 0.5rem;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.06),
0 1px 2px rgb(0 0 0 / 0.04);
padding: 0.5625rem 2.125rem 0.5625rem 0.75rem;
align-items: center;
}
[data-sonner-toast][data-styled='true'] [data-content] {
gap: 0.0625rem;
}
:is(.dark *) [data-sonner-toast][data-styled='true'] {
box-shadow:
0 1px 3px rgb(0 0 0 / 0.28),
0 1px 2px rgb(0 0 0 / 0.2);
}
[data-sonner-toast]:focus-visible {
outline: none;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.06),
0 1px 2px rgb(0 0 0 / 0.04),
0 0 0 2px var(--background),
0 0 0 4px color-mix(in oklch, var(--ring) 55%, transparent);
}
:is(.dark *) [data-sonner-toast]:focus-visible {
box-shadow:
0 1px 3px rgb(0 0 0 / 0.28),
0 1px 2px rgb(0 0 0 / 0.2),
0 0 0 2px var(--background),
0 0 0 4px color-mix(in oklch, var(--ring) 55%, transparent);
}
html body [data-sonner-toast][data-styled='true'] [data-title] {
font-weight: 600;
}
html body [data-sonner-toast][data-styled='true'] [data-description] {
color: var(--muted-foreground);
}
/* Only semantic variants keep description tied to tinted foreground */
html body [data-rich-colors='true'][data-sonner-toast][data-type='error'][data-styled='true'] [data-description],
html body [data-rich-colors='true'][data-sonner-toast][data-type='warning'][data-styled='true'] [data-description],
html body [data-rich-colors='true'][data-sonner-toast][data-type='success'][data-styled='true'] [data-description],
html body [data-rich-colors='true'][data-sonner-toast][data-type='info'][data-styled='true'] [data-description] {
color: inherit;
opacity: 0.92;
}
/* Compact ghost close (inside strip, smaller hit target) */
[data-sonner-toast][data-styled='true'] [data-close-button] {
height: 1.5rem;
width: 1.5rem;
min-height: 1.5rem;
min-width: 1.5rem;
border-radius: calc(var(--radius) - 4px);
border: none;
background: transparent;
color: var(--muted-foreground);
}
[data-sonner-toast][data-styled='true'] [data-close-button]:hover {
background: color-mix(in oklch, var(--muted) 65%, transparent);
color: var(--foreground);
}
[data-sonner-toast][data-styled='true']:hover [data-close-button]:hover {
background: color-mix(in oklch, var(--muted) 65%, transparent);
}
[data-sonner-toast][data-styled='true'] [data-close-button]:focus-visible {
box-shadow:
0 0 0 2px var(--background),
0 0 0 4px color-mix(in oklch, var(--ring) 50%, transparent);
}
/* Semantic: soft tinted surface + theme tokens */
[data-rich-colors='true'][data-sonner-toast][data-type='error'][data-styled='true'] {
background: color-mix(in oklch, var(--destructive) 14%, var(--popover));
border-color: color-mix(in oklch, var(--destructive) 38%, var(--border));
color: var(--destructive);
}
[data-rich-colors='true'][data-sonner-toast][data-type='error'][data-styled='true'] [data-close-button] {
background: transparent;
border: none;
color: inherit;
opacity: 0.88;
}
[data-rich-colors='true'][data-sonner-toast][data-type='error'][data-styled='true'] [data-close-button]:hover {
background: color-mix(in oklch, var(--destructive) 16%, transparent);
opacity: 1;
}
[data-rich-colors='true'][data-sonner-toast][data-type='warning'][data-styled='true'] {
background: color-mix(in oklch, var(--chart-4) 16%, var(--popover));
border-color: color-mix(in oklch, var(--chart-4) 36%, var(--border));
color: color-mix(in oklch, var(--chart-4) 48%, var(--foreground));
}
[data-rich-colors='true'][data-sonner-toast][data-type='warning'][data-styled='true'] [data-close-button] {
background: transparent;
border: none;
color: inherit;
opacity: 0.88;
}
[data-rich-colors='true'][data-sonner-toast][data-type='warning'][data-styled='true'] [data-close-button]:hover {
background: color-mix(in oklch, var(--chart-4) 18%, transparent);
opacity: 1;
}
[data-rich-colors='true'][data-sonner-toast][data-type='success'][data-styled='true'] {
background: color-mix(in oklch, var(--chart-2) 14%, var(--popover));
border-color: color-mix(in oklch, var(--chart-2) 34%, var(--border));
color: color-mix(in oklch, var(--chart-2) 42%, var(--foreground));
}
[data-rich-colors='true'][data-sonner-toast][data-type='success'][data-styled='true'] [data-close-button] {
background: transparent;
border: none;
color: inherit;
opacity: 0.88;
}
[data-rich-colors='true'][data-sonner-toast][data-type='success'][data-styled='true'] [data-close-button]:hover {
background: color-mix(in oklch, var(--chart-2) 16%, transparent);
opacity: 1;
}
[data-rich-colors='true'][data-sonner-toast][data-type='info'][data-styled='true'] {
background: color-mix(in oklch, var(--chart-1) 14%, var(--popover));
border-color: color-mix(in oklch, var(--chart-1) 34%, var(--border));
color: color-mix(in oklch, var(--chart-1) 42%, var(--foreground));
}
[data-rich-colors='true'][data-sonner-toast][data-type='info'][data-styled='true'] [data-close-button] {
background: transparent;
border: none;
color: inherit;
opacity: 0.88;
}
[data-rich-colors='true'][data-sonner-toast][data-type='info'][data-styled='true'] [data-close-button]:hover {
background: color-mix(in oklch, var(--chart-1) 16%, transparent);
opacity: 1;
}
[data-sonner-toast][data-styled='true'] [data-button]:not([data-cancel]) {
border-radius: calc(var(--radius) - 2px);
height: 2rem;
padding-inline: 0.75rem;
font-size: 0.75rem;
font-weight: 500;
background: var(--primary);
color: var(--primary-foreground);
}
[data-sonner-toast][data-styled='true'] [data-button]:not([data-cancel]):focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring);
}
[data-sonner-toast][data-styled='true'] [data-cancel] {
border-radius: calc(var(--radius) - 2px);
height: 2rem;
padding-inline: 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--foreground);
background: color-mix(in oklch, var(--muted) 88%, transparent);
border: 1px solid var(--border);
}
[data-sonner-toast][data-styled='true'] [data-cancel]:hover {
background: var(--muted);
}
.sonner-loading-bar {
background: var(--muted-foreground);
}

View File

@@ -835,7 +835,7 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
await unifiedAudio.connect()
}
function handleStreamConfigChanging(data: any) {
function handleStreamConfigChanging(_data: any) {
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
@@ -853,14 +853,9 @@ function handleStreamConfigChanging(data: any) {
consecutiveErrors = 0
backendFps.value = 0
toast.info(t('console.videoRestarting'), {
description: data.reason === 'device_switch' ? t('console.deviceSwitching') : t('console.configChanging'),
duration: 5000,
})
}
async function handleStreamConfigApplied(data: any) {
async function handleStreamConfigApplied(_data: any) {
consecutiveErrors = 0
gracePeriodTimeoutId = window.setTimeout(() => {
@@ -882,11 +877,6 @@ async function handleStreamConfigApplied(data: any) {
}
videoRestarting.value = false
toast.success(t('console.videoRestarted'), {
description: `${data.device} - ${data.resolution[0]}x${data.resolution[1]} @ ${formatFpsValue(data.fps)}fps`,
duration: 3000,
})
}
function handleWebRTCReady(data: { codec: string; hardware: boolean; transition_id?: string }) {
@@ -1158,11 +1148,6 @@ function handleStreamModeChanged(data: { mode: string; previous_mode: string })
return
}
toast.info(t('console.streamModeChanged'), {
description: t('console.streamModeChangedDesc', { mode: data.mode.toUpperCase() }),
duration: 5000,
})
if (newMode !== videoMode.value) {
syncToServerMode(newMode)
}
@@ -1238,11 +1223,6 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
try {
const success = await connectWebRTCSerial('connectWebRTCOnly')
if (success) {
toast.success(t('console.webrtcConnected'), {
description: t('console.webrtcConnectedDesc'),
duration: 3000,
})
// Force video rebind even when the track already exists
// This fixes missing video after returning to the page
await rebindWebRTCVideo()
@@ -1338,11 +1318,6 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
success = await connectWebRTCSerial('switchToWebRTC')
}
if (success) {
toast.success(t('console.webrtcConnected'), {
description: t('console.webrtcConnectedDesc'),
duration: 3000,
})
await rebindWebRTCVideo()
videoLoading.value = false
@@ -1570,7 +1545,6 @@ async function handleChangePassword() {
changingPassword.value = true
try {
await authApi.changePassword(currentPassword.value, newPassword.value)
toast.success(t('auth.passwordChanged'))
currentPassword.value = ''
newPassword.value = ''
@@ -1619,7 +1593,6 @@ async function handleReset() {
async function handleWol(mac: string) {
try {
await atxConfigApi.sendWol(mac)
toast.success(t('atx.wolSent'))
} catch (e) {
toast.error(t('atx.wolFailed'))
}
@@ -1684,10 +1657,6 @@ function handleKeyDown(e: KeyboardEvent) {
}
if (!isFullscreen.value && (e.metaKey || e.key === 'Meta')) {
toast.info(t('console.metaKeyHint'), {
description: t('console.metaKeyHintDesc'),
duration: 3000,
})
}
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
@@ -2039,10 +2008,6 @@ function handlePointerLockChange() {
localCrosshairPos.value = { x: r.width / 2, y: r.height / 2 }
}
}
toast.info(t('console.pointerLocked'), {
description: t('console.pointerLockedDesc'),
duration: 3000,
})
}
}
@@ -2207,10 +2172,6 @@ function handleToggleMouseMode() {
mousePosition.value = { x: 0, y: 0 }
if (mouseMode.value === 'relative') {
toast.info(t('console.relativeModeHint'), {
description: t('console.relativeModeHintDesc'),
duration: 5000,
})
}
}