You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

225 lines
7.1 KiB

<script setup lang="ts">
import { request, unwrapApiBody, type ApiResponse } from '../utils/http/factory'
import { useAuthSession } from '../composables/useAuthSession'
withDefaults(
defineProps<{
/** @deprecated 保留以兼容旧布局 */
showAuthActions?: boolean
}>(),
{ showAuthActions: false },
)
const route = useRoute()
const { loggedIn, user, refresh, clear, pending } = useAuthSession()
const { allowRegister } = useGlobalConfig()
const logoutLoading = ref(false)
onMounted(() => {
refresh(true).catch(() => {})
})
const displayName = computed(() => {
const u = user.value
if (!u) {
return ''
}
const nick = u.nickname?.trim()
return nick || u.username
})
const primaryNav = [
{ label: '首页', to: '/', icon: 'i-lucide-house' },
{ label: '控制台', to: '/me', icon: 'i-lucide-layout-dashboard' },
{ label: '资料', to: '/me/profile', icon: 'i-lucide-user-round' },
{ label: '文章', to: '/me/posts', icon: 'i-lucide-file-text' },
{ label: '时光机', to: '/me/timeline', icon: 'i-lucide-history' },
{ label: 'RSS', to: '/me/rss', icon: 'i-lucide-rss' },
] as const
function navActive(to: string) {
if (to === '/') {
return route.path === '/'
}
return route.path === to || route.path.startsWith(`${to}/`)
}
const accountMenuItems = computed(() => {
const u = user.value
if (!u) {
return [] as { label: string; icon: string; to?: string; target?: string }[][]
}
const groups: { label: string; icon: string; to?: string; target?: string }[][] = [
[
{ label: '控制台', icon: 'i-lucide-layout-dashboard', to: '/me' },
{ label: '个人资料', icon: 'i-lucide-user-round', to: '/me/profile' },
],
]
if (u.publicSlug) {
groups.push([
{
label: '公开主页',
icon: 'i-lucide-external-link',
to: `/@${u.publicSlug}`,
target: '_blank',
},
])
}
if (u.role === 'admin') {
groups.push([{ label: '用户管理', icon: 'i-lucide-users', to: '/me/admin/users' }])
}
return groups
})
async function logout() {
logoutLoading.value = true
try {
unwrapApiBody(await request<ApiResponse<{ success: boolean }>>('/api/auth/logout', { method: 'POST' }))
clear()
await navigateTo('/login')
} finally {
logoutLoading.value = false
}
}
</script>
<template>
<UApp>
<div class="min-h-screen bg-default text-default flex flex-col">
<header class="sticky top-0 z-40 border-b border-default/80 bg-default/95 backdrop-blur supports-[backdrop-filter]:bg-default/80">
<UContainer class="flex h-16 items-center justify-between gap-4">
<div class="flex min-w-0 flex-1 items-center gap-6">
<NuxtLink
to="/"
class="flex shrink-0 items-center gap-2 font-semibold tracking-tight text-highlighted"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon name="i-lucide-orbit" class="size-4" />
</span>
<span class="hidden sm:inline">Person Panel</span>
</NuxtLink>
<nav
v-if="loggedIn"
class="hidden min-w-0 flex-1 items-center gap-0.5 overflow-x-auto md:flex"
aria-label="主导航"
>
<UButton
v-for="item in primaryNav"
:key="item.to"
:to="item.to"
:icon="item.icon"
:label="item.label"
color="neutral"
variant="ghost"
size="sm"
:class="[
'shrink-0 rounded-md',
navActive(item.to) ? 'bg-elevated text-highlighted' : 'text-muted',
]"
/>
</nav>
<UDropdownMenu
v-if="loggedIn"
class="md:hidden"
:items="[
primaryNav.map((i) => ({
label: i.label,
icon: i.icon,
to: i.to,
})),
]"
:content="{ align: 'start' }"
>
<UButton color="neutral" variant="outline" icon="i-lucide-menu" size="sm" label="菜单" />
</UDropdownMenu>
</div>
<div class="flex shrink-0 items-center gap-2">
<template v-if="pending && !loggedIn">
<USkeleton class="h-9 w-24 rounded-md" />
</template>
<template v-else-if="loggedIn && user">
<div class="hidden items-center gap-2 sm:flex">
<UBadge
v-if="user.role === 'admin'"
color="primary"
variant="subtle"
size="xs"
>
管理员
</UBadge>
<UBadge
v-else
color="neutral"
variant="subtle"
size="xs"
>
用户
</UBadge>
</div>
<UDropdownMenu :items="accountMenuItems" :content="{ align: 'end' }">
<UButton color="neutral" variant="ghost" class="gap-2 px-2">
<UAvatar
:src="user.avatar || undefined"
:alt="displayName"
size="sm"
class="ring-1 ring-default"
/>
<span class="max-w-[8rem] truncate text-sm font-medium text-highlighted">
{{ displayName }}
</span>
<span class="hidden text-xs text-muted lg:inline">@{{ user.username }}</span>
<UIcon name="i-lucide-chevrons-up-down" class="size-4 text-muted" />
</UButton>
</UDropdownMenu>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-log-out"
:loading="logoutLoading"
class="hidden sm:inline-flex"
aria-label="退出登录"
@click="logout"
/>
</template>
<template v-else>
<UButton to="/login" color="neutral" variant="ghost" label="登录" />
<UButton
v-if="allowRegister"
to="/register"
color="neutral"
variant="outline"
label="注册"
/>
</template>
</div>
</UContainer>
</header>
<main class="flex-1">
<slot />
</main>
<footer class="border-t border-default/80 bg-elevated/30">
<UContainer class="flex flex-col gap-2 py-6 text-sm text-muted sm:flex-row sm:items-center sm:justify-between">
<span>Person Panel — 个人资料、文章、时光机与 RSS</span>
<div class="flex gap-4">
<NuxtLink to="/" class="hover:text-default">
首页
</NuxtLink>
<NuxtLink v-if="loggedIn" to="/me" class="hover:text-default">
控制台
</NuxtLink>
</div>
</UContainer>
</footer>
</div>
</UApp>
</template>