137 lines
3.9 KiB
Vue
137 lines
3.9 KiB
Vue
<!-- app/components/layout/AppHeader.vue - 顶部导航和页面切换入口 -->
|
||
<script lang="ts" setup>
|
||
import {
|
||
Activity,
|
||
LayoutDashboard,
|
||
LogIn,
|
||
LogOut,
|
||
Moon,
|
||
Search,
|
||
Sun
|
||
} from "lucide-vue-next";
|
||
|
||
import SettingsDialog from "@/components/layout/SettingsDialog.vue";
|
||
import { Button } from "@/components/ui/button";
|
||
import { useAuthStore } from "@/stores/auth";
|
||
import { useGlobalStore } from "@/stores/global";
|
||
|
||
/** 主题状态由 themeStore 统一管理,持久化由 @nuxtjs/color-mode 处理 */
|
||
const themeStore = useGlobalStore();
|
||
|
||
const authStore = useAuthStore();
|
||
|
||
/** 顶部导航项,icon 使用 lucide,和项目里 shadcn 的图标体系保持一致 */
|
||
const navItems = [
|
||
{
|
||
label: "答题",
|
||
to: "/",
|
||
icon: Search
|
||
},
|
||
{
|
||
label: "Dashboard",
|
||
to: "/dashboard",
|
||
icon: LayoutDashboard
|
||
}
|
||
];
|
||
|
||
const route = useRoute();
|
||
|
||
/** 当前路由高亮,Dashboard 的子路径也归到 Dashboard 导航项 */
|
||
const isActive = (to: string) => {
|
||
if (to === "/") return route.path === "/";
|
||
return route.path.startsWith(to);
|
||
};
|
||
|
||
// 退出登录
|
||
const handleSignOut = async () => {
|
||
await authStore.signOut();
|
||
await navigateTo("/login");
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<header
|
||
class="border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/80"
|
||
>
|
||
<div
|
||
class="mx-auto flex min-h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 md:px-6"
|
||
>
|
||
<NuxtLink to="/" class="flex min-w-0 items-center gap-3">
|
||
<span
|
||
class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
|
||
>
|
||
<Activity class="size-5" />
|
||
</span>
|
||
<span class="min-w-0">
|
||
<span class="block truncate text-base font-semibold text-foreground">
|
||
OCS AI 答题服务
|
||
</span>
|
||
<span class="block truncate text-xs text-muted-foreground">
|
||
Nuxt API Runtime
|
||
</span>
|
||
</span>
|
||
</NuxtLink>
|
||
|
||
<nav class="flex shrink-0 items-center gap-1 overflow-x-auto">
|
||
<Button
|
||
v-for="item in navItems"
|
||
:key="item.to"
|
||
as-child
|
||
:variant="isActive(item.to) ? 'secondary' : 'ghost'"
|
||
size="sm"
|
||
>
|
||
<NuxtLink :to="item.to" class="gap-2">
|
||
<component :is="item.icon" class="size-4" />
|
||
<span>{{ item.label }}</span>
|
||
</NuxtLink>
|
||
</Button>
|
||
|
||
<!-- 深色/浅色主题切换按钮 -->
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
aria-label="切换主题"
|
||
@click="themeStore.toggle"
|
||
>
|
||
<Sun v-if="themeStore.isDark" class="size-4" />
|
||
<Moon v-else class="size-4" />
|
||
</Button>
|
||
|
||
<!--
|
||
已登录/未登录状态由客户端 auth 插件决定(.client.ts),
|
||
SSR 阶段 isOnline 始终为 false;
|
||
整个认证区块必须放在 ClientOnly 内避免水合不匹配。
|
||
-->
|
||
<ClientOnly>
|
||
<template v-if="authStore.isOnline">
|
||
<span class="max-sm:hidden text-xs text-muted-foreground">
|
||
{{ authStore.user!.email }}
|
||
</span>
|
||
<SettingsDialog />
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
aria-label="退出登录"
|
||
@click="handleSignOut"
|
||
>
|
||
<LogOut class="size-4" />
|
||
</Button>
|
||
</template>
|
||
<template v-else>
|
||
<Button as-child variant="ghost" size="sm">
|
||
<NuxtLink to="/login" class="gap-2">
|
||
<LogIn class="size-4" />
|
||
<span>登录</span>
|
||
</NuxtLink>
|
||
</Button>
|
||
</template>
|
||
<!-- SSR 阶段占位,避免布局闪烁 -->
|
||
<template #fallback>
|
||
<div class="size-8 rounded-md" />
|
||
</template>
|
||
</ClientOnly>
|
||
</nav>
|
||
</div>
|
||
</header>
|
||
</template>
|