333 lines
8.9 KiB
Vue
333 lines
8.9 KiB
Vue
<!-- 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>
|