|
- <template>
- <div class="flex min-h-screen flex-col bg-[var(--c-f3f7fb)]">
- <!-- ==================== 顶部导航 ==================== -->
- <header class="sticky top-0 z-50 border-b border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/85 backdrop-blur-md">
- <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
- <div class="flex h-16 items-center gap-4">
- <!-- Logo + Slogan -->
- <NuxtLink to="/" class="flex shrink-0 items-center gap-2.5">
- <img
- src="/logo.png"
- alt="AI在线"
- class="h-9 w-9 rounded-xl shadow-[0_6px_16px_var(--sh-11-140-255-220)]"
- >
- <div class="hidden leading-tight sm:block">
- <div class="text-[17px] font-extrabold tracking-tight text-[var(--c-172033)]">AI 在线</div>
- <div class="text-[11px] font-medium text-[var(--c-8a97a8)]">解放你的生产力</div>
- </div>
- </NuxtLink>
-
- <!-- 主导航(AI 绘画为一级入口,hover 展开二级菜单,直接点击进动漫头像) -->
- <nav class="ml-2 hidden items-center gap-1 md:flex">
- <template v-for="item in navItems" :key="item.label">
- <div
- v-if="item.children"
- class="relative"
- @mouseenter="drawMenuOpen = true"
- @mouseleave="drawMenuOpen = false"
- @focusin="drawMenuOpen = true"
- @focusout="onDrawMenuFocusOut"
- >
- <NuxtLink
- :to="item.to"
- class="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]"
- :class="isGroupActive(item)
- ? 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]'
- : 'text-[var(--c-5b6b80)]'"
- :aria-expanded="drawMenuOpen"
- >
- {{ item.label }}
- <UIcon
- name="i-lucide-chevron-down"
- class="size-3.5 transition-transform"
- :class="drawMenuOpen && 'rotate-180'"
- />
- </NuxtLink>
-
- <Transition name="menu">
- <!-- 外层留 pt-2 把触发区和面板连起来,避免鼠标移动时菜单闪退 -->
- <div v-if="drawMenuOpen" class="absolute left-0 top-full z-50 pt-2">
- <div class="w-64 overflow-hidden rounded-xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] py-1.5 shadow-[0_12px_32px_var(--sh-23-32-51-140)]">
- <NuxtLink
- v-for="child in item.children"
- :key="child.to"
- :to="child.to"
- class="block px-4 py-2.5 transition-colors hover:bg-[var(--c-f7fbff)]"
- active-class="bg-[var(--c-f7fbff)]"
- @click="drawMenuOpen = false"
- >
- <span class="block text-sm font-bold text-[var(--c-172033)]">{{ child.label }}</span>
- <span class="mt-0.5 block text-xs text-[var(--c-8a97a8)]">{{ child.desc }}</span>
- </NuxtLink>
- </div>
- </div>
- </Transition>
- </div>
-
- <NuxtLink
- v-else
- :to="item.to"
- class="rounded-lg px-3 py-2 text-sm font-medium text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]"
- active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]"
- >
- {{ item.label }}
- </NuxtLink>
- </template>
- </nav>
-
- <!-- 右侧操作 -->
- <div class="ml-auto flex items-center gap-2 sm:gap-3">
- <!-- 亮色 / 暗色切换 -->
- <ThemeToggle />
-
- <template v-if="isLogin">
- <!-- 点数余额 -->
- <NuxtLink
- to="/bill"
- class="hidden items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-3 py-1.5 text-xs font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)] sm:flex"
- >
- <UIcon name="i-lucide-coins" class="size-3.5" />
- <span>{{ userInfo.balance || 0 }} 点</span>
- </NuxtLink>
- <NuxtLink
- to="/recharge"
- class="hidden rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-4 py-1.5 text-xs font-bold text-white shadow-[0_6px_14px_var(--sh-11-140-255-250)] transition-opacity hover:opacity-90 sm:block"
- >
- 充值
- </NuxtLink>
-
- <!-- 用户菜单(自定义面板,保证背景与排版可控) -->
- <div ref="userMenuRef" class="relative">
- <button
- aria-label="用户菜单"
- :aria-expanded="userMenuOpen"
- class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full bg-[var(--c-eef7ff)] ring-1 ring-[var(--c-dbeafe)] transition-shadow hover:ring-[var(--c-0b8cff)]/40"
- @click="userMenuOpen = !userMenuOpen"
- >
- <img v-if="userInfo.avatar" :src="userInfo.avatar" class="h-full w-full object-cover" alt="avatar">
- <span v-else class="text-xs font-extrabold text-[var(--c-0b8cff)]">
- {{ (userInfo.nickname || userInfo.user_name || '用')[0] }}
- </span>
- </button>
-
- <Transition name="menu">
- <div
- v-if="userMenuOpen"
- class="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] py-1.5 shadow-[0_12px_32px_var(--sh-23-32-51-140)]"
- >
- <!-- 用户信息 -->
- <div class="border-b border-[var(--c-f1f5fa)] px-4 pb-2.5 pt-1.5">
- <div class="truncate text-sm font-bold text-[var(--c-172033)]">
- {{ userInfo.nickname || userInfo.user_name || '我的账号' }}
- </div>
- <div class="mt-0.5 text-xs text-[var(--c-8a97a8)]">余额 {{ userInfo.balance || 0 }} 点</div>
- </div>
-
- <NuxtLink
- v-for="item in userMenuLinks"
- :key="item.to"
- :to="item.to"
- class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--c-4b5b70)] transition-colors hover:bg-[var(--c-f7fbff)] hover:text-[var(--c-0b8cff)]"
- @click="userMenuOpen = false"
- >
- <UIcon :name="item.icon" class="size-4 shrink-0" />
- <span>{{ item.label }}</span>
- </NuxtLink>
-
- <div class="my-1 border-t border-[var(--c-f1f5fa)]" />
-
- <button
- class="flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm text-[var(--c-f04438)] transition-colors hover:bg-[var(--c-fff5f5)]"
- @click="onLogout"
- >
- <UIcon name="i-lucide-log-out" class="size-4 shrink-0" />
- <span>退出登录</span>
- </button>
- </div>
- </Transition>
- </div>
- </template>
-
- <button
- v-else
- class="cursor-pointer rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-5 py-2 text-sm font-bold text-white shadow-[0_8px_18px_var(--sh-11-140-255-250)] transition-opacity hover:opacity-90"
- @click="showLogin = true"
- >
- 登录
- </button>
-
- <!-- 移动端菜单按钮 -->
- <button
- class="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] md:hidden"
- @click="mobileOpen = !mobileOpen"
- >
- <UIcon :name="mobileOpen ? 'i-lucide-x' : 'i-lucide-menu'" class="size-5" />
- </button>
- </div>
- </div>
- </div>
-
- <!-- 移动端下拉导航 -->
- <Transition name="slide">
- <nav v-if="mobileOpen" class="border-t border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] md:hidden">
- <div class="mx-auto max-w-7xl px-4 py-2 sm:px-6">
- <template v-for="item in navItems" :key="item.label">
- <!-- 移动端没有 hover,二级菜单直接平铺缩进展示 -->
- <div v-if="item.children">
- <div class="px-3 pb-1 pt-2.5 text-xs font-bold text-[var(--c-8a97a8)]">{{ item.label }}</div>
- <NuxtLink
- v-for="child in item.children"
- :key="child.to"
- :to="child.to"
- class="block rounded-lg px-3 py-2.5 pl-6 text-sm font-medium text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]"
- active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]"
- @click="mobileOpen = false"
- >
- {{ child.label }}
- </NuxtLink>
- </div>
- <NuxtLink
- v-else
- :to="item.to"
- class="block rounded-lg px-3 py-2.5 text-sm font-medium text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]"
- active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]"
- @click="mobileOpen = false"
- >
- {{ item.label }}
- </NuxtLink>
- </template>
- </div>
- </nav>
- </Transition>
- </header>
-
- <!-- ==================== 主体内容 ==================== -->
- <main class="flex-1">
- <slot />
- </main>
-
- <!-- ==================== 页脚(白色系、紧凑,二维码与内容同行) ==================== -->
- <footer class="mt-auto border-t border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]">
- <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
- <div class="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
- <!-- 品牌 -->
- <div class="max-w-xs">
- <div class="flex items-center gap-2.5">
- <img src="/logo.png" alt="AI在线" class="h-8 w-8 rounded-lg">
- <span class="text-base font-extrabold text-[var(--c-172033)]">AI 在线</span>
- </div>
- <p class="mt-2.5 text-sm leading-relaxed text-[var(--c-8a97a8)]">
- 解放你的生产力。AI 取名、动漫头像、证件照,一站式智能创作平台。
- </p>
- </div>
-
- <!-- 链接 + 二维码同行 -->
- <div class="flex flex-wrap items-start gap-10 sm:gap-14">
- <div>
- <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">产品</div>
- <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]">
- <li v-for="item in productLinks" :key="item.to">
- <NuxtLink :to="item.to" class="transition-colors hover:text-[var(--c-0b8cff)]">{{ item.label }}</NuxtLink>
- </li>
- </ul>
- </div>
- <div>
- <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">资讯</div>
- <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]">
- <li v-for="item in infoLinks" :key="item.to">
- <NuxtLink :to="item.to" class="transition-colors hover:text-[var(--c-0b8cff)]">{{ item.label }}</NuxtLink>
- </li>
- </ul>
- </div>
- <div>
- <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">我的</div>
- <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]">
- <li><NuxtLink to="/works" class="transition-colors hover:text-[var(--c-0b8cff)]">我的作品</NuxtLink></li>
- <li v-if="earnEnabled"><NuxtLink to="/earn-points" class="transition-colors hover:text-[var(--c-0b8cff)]">获得点数</NuxtLink></li>
- <li v-if="inviteEnabled"><NuxtLink to="/invite" class="transition-colors hover:text-[var(--c-0b8cff)]">邀请好友</NuxtLink></li>
- <li><NuxtLink to="/bill" class="transition-colors hover:text-[var(--c-0b8cff)]">账单明细</NuxtLink></li>
- <li><NuxtLink to="/recharge" class="transition-colors hover:text-[var(--c-0b8cff)]">充值中心</NuxtLink></li>
- </ul>
- </div>
- <!-- 公众号二维码 -->
- <div class="flex flex-col items-center">
- <img
- src="https://cdn.aionline.cc/ai/aionline_qr.jpg"
- alt="AI在线公众号"
- class="h-20 w-20 rounded-lg border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] p-1"
- loading="lazy"
- >
- <div class="mt-2 text-xs text-[var(--c-8a97a8)]">扫码关注我们</div>
- </div>
- </div>
- </div>
-
- <!-- 备案与协议(对齐 pc 站点) -->
- <div class="mt-6 flex flex-col items-center gap-2 border-t border-[var(--c-eef2f7)] pt-5 text-xs text-[var(--c-9aa5b5)] sm:flex-row sm:justify-center sm:gap-5">
- <NuxtLink to="/terms" class="transition-colors hover:text-[var(--c-0b8cff)]">服务条款</NuxtLink>
- <NuxtLink to="/privacy" class="transition-colors hover:text-[var(--c-0b8cff)]">隐私政策</NuxtLink>
- <span>四川内宇科技有限公司</span>
- <a
- href="https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=51010702042692"
- target="_blank"
- rel="noopener"
- class="transition-colors hover:text-[var(--c-0b8cff)]"
- >川公网安备51010702042692号</a>
- <a
- href="https://beian.miit.gov.cn/#/Integrated/index"
- target="_blank"
- rel="noopener"
- class="transition-colors hover:text-[var(--c-0b8cff)]"
- >蜀ICP备2023026929号-1</a>
- <span>© {{ new Date().getFullYear() }} AI在线</span>
- </div>
- </div>
- </footer>
-
- <!-- 全局回到顶部(所有页面共用,滚动超过一屏才出现) -->
- <BackToTop />
-
- <!-- 登录弹窗 -->
- <LoginModal
- v-if="showLogin"
- @close="showLogin = false"
- @login-success="onLoginSuccess"
- />
- </div>
- </template>
-
- <script setup>
- // 顶部导航。图像类能力收进「AI 绘画」二级菜单,一级入口本身指向动漫头像。
- const drawChildren = [
- { label: '动漫头像', to: '/draw/anime/', desc: '自拍一键变动漫风头像' },
- { label: '形象照 / 写真', to: '/portrait-photo', desc: '商务、职业照与艺术写真' },
- { label: '老照片修复', to: '/old-photo', desc: '修复破损并自然上色' },
- { label: 'AI 证件照', to: '/id-photo', desc: '换底色、标准尺寸' },
- ]
- const navItems = [
- { label: '首页', to: '/' },
- { label: 'AI 取名', to: '/writing/naming/' },
- { label: 'AI 绘画', to: '/draw/anime/', children: drawChildren },
- { label: 'AI 资讯', to: '/info' },
- { label: '我的作品', to: '/works' },
- ]
- // 注:AI 图片(/pic)页面保留,只是不放顶部导航和页脚入口
-
- // 页脚「产品」列:只放真正的产品能力,个人相关的放「我的」列
- const productLinks = [
- { label: 'AI 取名', to: '/writing/naming/' },
- { label: '动漫头像', to: '/draw/anime/' },
- { label: '形象照 / 写真', to: '/portrait-photo' },
- { label: '老照片修复', to: '/old-photo' },
- { label: 'AI 证件照', to: '/id-photo' },
- ]
-
- // 页脚「资讯」列
- const infoLinks = [
- { label: 'AI 资讯', to: '/info' },
- { label: '前沿资讯', to: '/info/new' },
- { label: '使用教程', to: '/info/course' },
- { label: 'AI 问答', to: '/info/what' },
- ]
-
- const { userInfo, isLogin, showLogin, setLogin, logout, refreshBalance } = useUser()
- const { post } = useApi()
- const mobileOpen = ref(false)
- const userMenuOpen = ref(false)
- const drawMenuOpen = ref(false)
- const userMenuRef = ref(null)
- const route = useRoute()
-
- /** 二级菜单里任一页面命中时,一级「AI 绘画」也保持选中态 */
- function isGroupActive(item) {
- const path = route.path.replace(/\/+$/, '') || '/'
- return (item.children || []).some((child) => {
- const target = child.to.replace(/\/+$/, '') || '/'
- return path === target || path.startsWith(`${target}/`)
- })
- }
-
- /** 焦点移出整个下拉区域才收起,键盘 Tab 浏览时菜单不会跳掉 */
- function onDrawMenuFocusOut(event) {
- if (!event.currentTarget.contains(event.relatedTarget)) drawMenuOpen.value = false
- }
-
- // 后台未启用邀请活动时,入口默认不渲染;请求失败同样按未启用处理。
- const { data: inviteConfigResponse } = await useAsyncData(
- 'global-invite-entry-config',
- () => post('/invite/config'),
- )
- const inviteEnabled = computed(
- () => Number(inviteConfigResponse.value?.data?.config?.status) === 1,
- )
-
- // 获得点数:签到、邀请与看广告任一开启才有入口,全部关闭时整条不渲染
- const { entryEnabled: earnEnabled, load: loadEarnConfig } = useEarnPoints()
- await loadEarnConfig()
-
- const allUserMenuLinks = [
- { label: '我的作品', icon: 'i-lucide-image', to: '/works' },
- { label: '获得点数', icon: 'i-lucide-gift', to: '/earn-points', earnOnly: true },
- { label: '邀请好友', icon: 'i-lucide-user-plus', to: '/invite', inviteOnly: true },
- { label: '账单明细', icon: 'i-lucide-receipt', to: '/bill' },
- { label: '充值中心', icon: 'i-lucide-coins', to: '/recharge' },
- ]
- const userMenuLinks = computed(
- () => allUserMenuLinks.filter((item) => {
- if (item.inviteOnly && !inviteEnabled.value) return false
- if (item.earnOnly && !earnEnabled.value) return false
- return true
- }),
- )
-
- // 路由切换时收起菜单
- watch(() => route.fullPath, () => {
- mobileOpen.value = false
- userMenuOpen.value = false
- drawMenuOpen.value = false
- })
-
- // 点击外部关闭用户菜单
- function onDocClick(e) {
- if (userMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(e.target)) {
- userMenuOpen.value = false
- }
- }
- onMounted(() => {
- document.addEventListener('click', onDocClick)
- // 已登录时刷新点数,保证 header 显示的是最新余额
- if (isLogin.value) refreshBalance()
- })
- onUnmounted(() => document.removeEventListener('click', onDocClick))
-
- // 登录状态变化时同步点数
- watch(isLogin, (v) => { if (v) refreshBalance() })
-
- function onLogout() {
- userMenuOpen.value = false
- logout()
- }
-
- function onLoginSuccess(data) {
- setLogin(data)
- showLogin.value = false
- refreshBalance()
- }
- </script>
-
- <style scoped>
- .slide-enter-active,
- .slide-leave-active {
- transition: opacity 0.18s ease;
- }
- .slide-enter-from,
- .slide-leave-to {
- opacity: 0;
- }
-
- .menu-enter-active,
- .menu-leave-active {
- transition: opacity 0.15s ease, transform 0.15s ease;
- }
- .menu-enter-from,
- .menu-leave-to {
- opacity: 0;
- transform: translateY(-4px);
- }
- </style>
|