Files
dticket.tootaio.com/app/layouts/default.vue
xiaomai 227c64d346 refactor(ui): standardize page layouts and component styling
Introduce structural CSS classes for page shells, headers, and surface cards
Update primary theme color to red and neutral to zinc across the application
2026-05-08 16:25:42 +08:00

467 lines
16 KiB
Vue

<template>
<div class="relative min-h-dvh bg-muted text-default">
<template v-if="auth.user.value">
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<button
v-if="mobileMenuOpen"
type="button"
class="fixed inset-0 z-40 bg-black/45 backdrop-blur-[2px] lg:hidden"
aria-label="Close navigation"
@click="mobileMenuOpen = false"
/>
</Transition>
<Transition
enter-active-class="transform transition duration-300 ease-out"
enter-from-class="-translate-x-6 opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="transform transition duration-200 ease-in"
leave-from-class="translate-x-0 opacity-100"
leave-to-class="-translate-x-6 opacity-0"
>
<aside
v-if="mobileMenuOpen"
class="fixed inset-y-2 left-2 z-50 flex w-[min(20rem,calc(100vw-1rem))] flex-col rounded-lg border border-default/80 bg-default/96 p-3 shadow-2xl backdrop-blur-xl lg:hidden"
>
<div class="flex items-start justify-between gap-3 rounded-lg border border-default bg-elevated px-4 py-4">
<div class="flex min-w-0 items-center gap-3">
<div class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary">
<UIcon name="i-lucide-grid-2x2" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
{{ t('layout.brand') }}
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
</div>
</div>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
class="rounded-full"
aria-label="Close navigation"
@click="mobileMenuOpen = false"
/>
</div>
<nav class="mt-4 grid gap-2">
<NuxtLink
v-for="item in systemMenuItems"
:key="item.to"
:to="item.to"
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
class="group flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-sm font-medium transition-all duration-200"
:class="mobileMenuItemClasses(item)"
>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors duration-200"
:class="mobileMenuIconClasses(item)"
>
<UIcon :name="item.icon" class="size-5" />
</div>
<span class="min-w-0 truncate">{{ item.label }}</span>
<UIcon name="i-lucide-chevron-right" class="ml-auto size-4 opacity-50 transition-opacity duration-200 group-hover:opacity-100" />
</NuxtLink>
</nav>
<div class="mt-auto space-y-3 border-t border-default pt-3">
<div class="rounded-lg border border-default bg-elevated px-4 py-4 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted text-highlighted">
<UIcon name="i-lucide-user-round" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
<div class="truncate text-sm text-muted">
@{{ auth.user.value.username }}
</div>
</div>
</div>
<UBadge
:label="userRoleLabel"
color="primary"
variant="soft"
class="mt-3 rounded-full"
/>
</div>
<UButton
to="/"
:label="t('common.public')"
color="neutral"
variant="ghost"
icon="i-lucide-house"
class="w-full justify-start rounded-lg"
@click="mobileMenuOpen = false"
/>
<UButton
:label="currentLocaleOption.label"
color="neutral"
variant="ghost"
icon="i-lucide-languages"
class="w-full justify-start rounded-lg"
:aria-label="t('common.switchLanguage')"
@click="toggleLocale"
/>
<UButton
:loading="logoutPending"
:label="t('common.logout')"
color="neutral"
variant="outline"
icon="i-lucide-log-out"
class="w-full justify-start rounded-lg"
@click="logout"
/>
</div>
</aside>
</Transition>
<div class="relative lg:grid lg:min-h-dvh lg:grid-cols-[17.5rem_minmax(0,1fr)]">
<aside class="sticky top-0 hidden h-dvh flex-col border-r border-default/80 bg-default/92 px-5 py-5 backdrop-blur-xl lg:flex">
<div class="rounded-lg border border-default bg-elevated px-4 py-4 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex size-12 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary">
<UIcon name="i-lucide-grid-2x2" class="size-6" />
</div>
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
{{ t('layout.brand') }}
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
</div>
</div>
</div>
<nav class="mt-6 grid gap-2">
<NuxtLink
v-for="item in systemMenuItems"
:key="item.to"
:to="item.to"
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
class="group flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-sm font-medium transition-all duration-200"
:class="desktopMenuItemClasses(item)"
>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors duration-200"
:class="desktopMenuIconClasses(item)"
>
<UIcon :name="item.icon" class="size-5" />
</div>
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
<div
class="h-2.5 w-2.5 rounded-full transition-all duration-200"
:class="isMenuItemActive(item) ? 'bg-primary shadow-[0_0_0_4px_rgba(var(--ui-primary-rgb),0.14)]' : 'bg-default group-hover:bg-primary/30'"
/>
</NuxtLink>
</nav>
<div class="mt-auto space-y-3">
<div class="rounded-lg border border-default bg-elevated px-4 py-4 shadow-sm">
<div class="flex items-center gap-3">
<div class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted text-highlighted">
<UIcon name="i-lucide-user-round" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
<div class="truncate text-sm text-muted">
@{{ auth.user.value.username }}
</div>
</div>
</div>
<UBadge
:label="userRoleLabel"
color="primary"
variant="soft"
class="mt-3 rounded-full"
/>
</div>
<div class="grid gap-2">
<UButton
to="/"
:label="t('common.public')"
color="neutral"
variant="ghost"
icon="i-lucide-house"
class="justify-start rounded-lg"
/>
<UButton
:label="currentLocaleOption.label"
color="neutral"
variant="ghost"
icon="i-lucide-languages"
class="justify-start rounded-lg"
:aria-label="t('common.switchLanguage')"
@click="toggleLocale"
/>
<UButton
:loading="logoutPending"
:label="t('common.logout')"
color="neutral"
variant="outline"
icon="i-lucide-log-out"
class="justify-start rounded-lg"
@click="logout"
/>
</div>
</div>
</aside>
<div class="min-w-0 bg-muted">
<header class="sticky top-0 z-30 border-b border-default/80 bg-default/92 backdrop-blur-xl lg:hidden">
<UContainer class="py-3">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3 rounded-lg border border-default bg-elevated px-3 py-2 shadow-sm">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary">
<UIcon name="i-lucide-grid-2x2" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
{{ t('layout.brand') }}
</div>
<div class="truncate text-sm font-semibold text-highlighted">
{{ auth.user.value.fullName }}
</div>
</div>
</div>
<UButton
color="neutral"
variant="outline"
class="shrink-0 rounded-full"
:label="mobileMenuOpen ? t('common.close') : t('common.menu')"
:icon="mobileMenuOpen ? 'i-lucide-x' : 'i-lucide-menu'"
:aria-expanded="mobileMenuOpen"
aria-label="Toggle navigation"
@click="mobileMenuOpen = !mobileMenuOpen"
/>
</div>
</UContainer>
</header>
<UMain class="relative z-10">
<slot />
</UMain>
<footer class="border-t border-default bg-default/96">
<UContainer class="py-5 text-center text-sm text-muted">
{{ t('layout.footer') }}
</UContainer>
</footer>
</div>
</div>
</template>
<template v-else>
<header class="relative border-b border-default/80 bg-default/92 backdrop-blur-xl">
<UContainer class="py-4 md:py-5">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3 rounded-lg border border-default bg-elevated px-3 py-2 shadow-sm">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon name="i-lucide-grid-2x2" class="size-5" />
</div>
<div class="min-w-0">
<div class="truncate text-xs font-semibold uppercase tracking-[0.24em] text-muted">
{{ t('layout.brand') }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-languages"
:label="currentLocaleOption.shortLabel"
class="rounded-full"
:aria-label="t('common.switchLanguage')"
@click="toggleLocale"
/>
<UButton
id="loginBtn"
:to="route.path.startsWith('/login') ? '/' : '/login'"
:label="route.path.startsWith('/login') ? t('common.back') : t('common.login')"
color="neutral"
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
class="rounded-full"
/>
</div>
</div>
</UContainer>
</header>
<UMain class="relative z-10">
<slot />
</UMain>
<footer class="border-t border-default bg-default/96">
<UContainer class="py-5 text-center text-sm text-muted">
{{ t('layout.footer') }}
</UContainer>
</footer>
</template>
</div>
</template>
<script lang="ts" setup>
import { getErrorMessage } from '../utils/errors'
interface SystemMenuItem {
labelKey: 'nav.bookings' | 'nav.security' | 'nav.users'
to: string
icon: string
requiresSuperAdmin?: boolean
matches: (path: string) => boolean
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const auth = useAuth()
const apiClient = useApiClient()
const { currentLocaleOption, locale, t, toggleLocale } = useLocale()
const logoutPending = ref(false)
const mobileMenuOpen = ref(false)
await auth.fetchSession()
useHead(() => ({
htmlAttrs: {
lang: locale.value === 'zh' ? 'zh-Hans' : 'en'
}
}))
const allSystemMenuItems: SystemMenuItem[] = [
{
labelKey: 'nav.bookings',
to: '/bookings',
icon: 'i-lucide-clipboard-list',
matches: (path) => path.startsWith('/bookings')
},
{
labelKey: 'nav.security',
to: '/security',
icon: 'i-lucide-shield-check',
matches: (path) => path.startsWith('/security')
},
{
labelKey: 'nav.users',
to: '/management/users',
icon: 'i-lucide-users',
requiresSuperAdmin: true,
matches: (path) => path.startsWith('/management/users')
}
]
const systemMenuItems = computed(() => {
return allSystemMenuItems
.filter((item) => {
return !item.requiresSuperAdmin || auth.isSuperAdmin.value
})
.map((item) => ({
...item,
label: t(item.labelKey)
}))
})
const userRoleLabel = computed(() => {
return auth.isSuperAdmin.value ? t('role.superAdmin') : t('role.staff')
})
function isMenuItemActive(item: SystemMenuItem) {
return item.matches(route.path)
}
function desktopMenuItemClasses(item: SystemMenuItem) {
return isMenuItemActive(item)
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
: 'border-default bg-default/80 text-default hover:border-primary/20 hover:bg-muted/60 hover:text-highlighted'
}
function desktopMenuIconClasses(item: SystemMenuItem) {
return isMenuItemActive(item)
? 'bg-primary/15 text-primary'
: 'bg-muted text-muted group-hover:bg-primary/10 group-hover:text-primary'
}
function mobileMenuItemClasses(item: SystemMenuItem) {
return isMenuItemActive(item)
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
: 'border-default bg-default/88 text-default'
}
function mobileMenuIconClasses(item: SystemMenuItem) {
return isMenuItemActive(item)
? 'bg-primary/15 text-primary'
: 'bg-muted text-muted'
}
watch(() => route.path, () => {
mobileMenuOpen.value = false
})
async function logout() {
if (logoutPending.value) {
return
}
logoutPending.value = true
try {
await apiClient('/api/auth/logout', {
method: 'POST'
})
auth.clearUser()
await router.push('/login')
toast.add({
title: t('layout.signedOut'),
description: t('layout.sessionCleared'),
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: t('layout.logoutFailed'),
description: getErrorMessage(error, t('layout.logoutFailedDescription')),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
logoutPending.value = false
}
}
</script>