2025-12-04 10:04:21 +08:00

333 lines
8.9 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- 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>