333 lines
8.9 KiB
Vue
Raw Permalink Normal View History

2025-12-04 10:04:21 +08:00
<!-- components/TopNav.vue -->
<template>
<header class="topNav">
<div class="topNav-container">
<!-- LOGO -->
<div class="topNav-logo">
<NuxtLink to="/" class="logo-link" aria-label="Aivise">
<!-- 这里用 :style 绑定最终的背景图 URL -->
<span class="logo-dot" :style="logoStyle" />
<!-- <span class="logo-text">Aivise</span> -->
</NuxtLink>
</div>
<!-- 菜单 -->
<nav class="topNav-menu" aria-label="主菜单">
<ul>
<li v-for="item in menusToUse" :key="item.to">
<NuxtLink
:to="item.to"
class="menu-link"
:class="{ active: isActive(item) }"
:aria-current="isActive(item) ? 'page' : undefined"
>
{{ item.label }}
</NuxtLink>
</li>
</ul>
</nav>
<!-- 搜索 + API 入口 -->
<div class="topNav-other">
<form class="search" @submit.prevent="onSearch">
<input
v-model.trim="q"
:placeholder="placeholder"
type="search"
inputmode="search"
autocomplete="off"
class="search-input"
/>
<button class="search-btn" type="submit" aria-label="搜索"></button>
</form>
<a
class="cta-link"
href="https://api.wgetai.com"
target="_blank"
rel="noopener noreferrer"
aria-label="由此进入 API 平台(新窗口打开)"
>
由此进入 API 平台
</a>
<button
v-if="showAdminButton"
type="button"
class="admin-console-btn"
@click="goAdminConsole"
>
Console
</button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useApi } from '@/composables/useApi'
defineOptions({ name: 'TopNav' })
/**
* 解析 assets 中的图片为构建后的真实 URL
* 方式一Vite 查询参数 ?url最通用
* 方式二new URL(..., import.meta.url).href
* 二选一就行我这里用方式一
*/
import logoUrl from '~/assets/logo.png?url'
const logoStyle = computed(() => ({
backgroundImage: `url(${logoUrl})`,
backgroundPosition: 'center',
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat'
}))
type MenuItem = { label: string; to: string }
type AuthUser = {
username?: string | null
email?: string | null
bio?: string | null
image?: string | null
roles?: string[]
} | null
const props = withDefaults(defineProps<{ menus?: MenuItem[]; placeholder?: string }>(), {
menus: () => [],
placeholder: '搜索你想要的文章…',
})
const defaultMenus: MenuItem[] = [
{ label: '首页', to: '/' },
{ label: '资讯广场', to: '/market' },
// { label: '工具', to: '/tools' },
{ label: '社区', to: '/community' },
{ label: '使用教程', to: '/docs' },
{ label: '我的收藏', to: '/favorites' },
{ label: '我上架的', to: '/my-articles' },
]
const menusToUse = computed<MenuItem[]>(() =>
props.menus && props.menus.length ? props.menus : defaultMenus,
)
const route = useRoute()
const router = useRouter()
const { user: authUser, token, fetchMe } = useAuth() as {
user: Ref<AuthUser>
token: Ref<string | null>
fetchMe: () => Promise<unknown>
}
const api = useApi()
const q = ref('')
const isAdmin = computed(() => {
const roles = authUser.value?.roles
return Array.isArray(roles) && roles.includes('admin')
})
const hasAdminAccess = ref(false)
const showAdminButton = computed(() => hasAdminAccess.value || isAdmin.value)
async function ensureAdminAccess(forceFetch = false): Promise<void> {
if (!token.value) {
hasAdminAccess.value = false
return
}
if (isAdmin.value && !forceFetch) {
hasAdminAccess.value = true
return
}
if (!authUser.value || forceFetch) {
try {
await fetchMe()
} catch (err) {
console.warn('[TopNav] fetchMe failed:', err)
}
}
if (isAdmin.value) {
hasAdminAccess.value = true
return
}
try {
await api.get('/admin/dashboard')
hasAdminAccess.value = true
} catch {
hasAdminAccess.value = false
}
}
onMounted(async () => {
await ensureAdminAccess()
})
watch(
() => token.value,
async () => {
await ensureAdminAccess(true)
},
)
watch(
() => authUser.value?.roles,
(roles) => {
if (Array.isArray(roles) && roles.includes('admin')) {
hasAdminAccess.value = true
}
},
)
function isActive(item: MenuItem) {
return item.to === '/' ? route.path === '/' : route.path.startsWith(item.to)
}
function onSearch() {
if (!q.value) return
router.push({ path: '/search', query: { q: q.value } })
}
function goAdminConsole() {
router.push('/admin')
}
</script>
<style scoped>
/* ===== 顶部容器 ===== */
.topNav {
position: sticky;
top: 0;
z-index: 50;
/* backdrop-filter: saturate(160%) blur(12px); */
background: rgba(255, 255, 255, 0);
border-bottom: 1px solid rgba(17, 24, 39, 0.06);
margin-left: 5px;
}
.topNav-container {
--rainbow: linear-gradient(90deg, #7c3aed 0%, #6366f1 33%, #60a5fa 66%, #22d3ee 100%);
max-width: 1300px;
margin: 0 auto;
padding: 10px 16px;
height: 64px;
display: grid;
grid-template-columns: 180px 1fr auto;
align-items: center;
gap: 14px;
}
/* ===== LOGO ===== */
.logo-link { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; }
.logo-dot {
width: 150px;
height: 50px;
border-radius: 8px;
/* 背景图由 :style 绑定,不在这里写 background */
/* box-shadow: 0 4px 14px rgba(119, 51, 255, 0.25); */
}
.logo-text { font-weight: 800; font-size: 18px; letter-spacing: 0.5px; color: #111827; }
/* ===== 菜单 ===== */
.topNav-menu ul { display: inline-flex; gap: 18px; list-style: none; padding: 0; margin: 0; font-weight: bold;}
.menu-link {
display: inline-flex; align-items: center; height: 36px; padding: 0 10px;
border-radius: 10px; color: #111827; text-decoration: none; font-size: 16px;
opacity: .75; transition: all .18s ease;
}
.menu-link:hover { opacity: 1; background: rgba(17, 24, 39, 0.06); }
.menu-link.active { opacity: 1; background: rgba(17, 24, 39, 0.1); }
/* ===== 右侧:搜索 + 按钮 ===== */
.topNav-other { display: flex; justify-content: flex-end; align-items: center; gap: 10px; }
/* 搜索框 */
.search {
height: 40px; width: 100%; max-width: 260px; display: grid; grid-template-columns: 1fr 44px;
background: rgba(255, 255, 255, 0.85); border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 12px; overflow: hidden; transition: box-shadow .18s ease, border-color .18s ease;
}
.search:focus-within { border-color: rgba(99, 102, 241, 0.45); box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15); }
.search-input { padding: 0 12px; border: none; outline: none; background: transparent; font-size: 14px; color: #111827; }
.search-input::placeholder { color: #9ca3af; }
.search-btn { border: none; background: #111827; color: #fff; font-size: 14px; cursor: pointer; }
/* ===== 炫彩流动 CTA 按钮(无阴影/无边框,悬停加速) ===== */
.cta-link {
position: relative;
white-space: nowrap;
display: inline-flex;
align-items: center;
height: 40px;
padding: 0 16px;
border: none;
border-radius: 12px;
text-decoration: none;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.2px;
color: #fff;
background-image: var(--rainbow);
background-size: 240% 240%;
animation: ctaFlow 3s linear infinite;
box-shadow: none;
}
.cta-link:hover {
transform: translateY(-1px);
filter: brightness(1.03);
animation: ctaFlow 1.3s linear infinite;
}
.cta-link:active { transform: translateY(0); }
.cta-link:focus-visible { outline: 2px solid rgba(99,102,241,.45); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce){
.cta-link { animation: none; background-size: 100% 100%; }
}
@keyframes ctaFlow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.admin-console-btn {
height: 40px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(17, 24, 39, 0.15);
background: rgba(17, 24, 39, 0.85);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.admin-console-btn:hover {
background: #111827;
transform: translateY(-1px);
}
.admin-console-btn:active {
transform: translateY(0);
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.topNav-container { grid-template-columns: 140px 1fr auto; }
.cta-link { padding: 0 12px; font-size: 13px; }
}
@media (max-width: 720px) {
.topNav-container { grid-template-columns: 1fr auto; gap: 10px; }
.topNav-menu { display: none; }
}
@media (max-width: 640px) {
.cta-link { display: none; }
.search { max-width: 160px; }
.topNav-container { padding: 10px 12px; }
}
</style>