25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

438 satır
19 KiB

  1. <template>
  2. <div class="flex min-h-screen flex-col bg-[var(--c-f3f7fb)]">
  3. <!-- ==================== 顶部导航 ==================== -->
  4. <header class="sticky top-0 z-50 border-b border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/85 backdrop-blur-md">
  5. <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
  6. <div class="flex h-16 items-center gap-4">
  7. <!-- Logo + Slogan -->
  8. <NuxtLink to="/" class="flex shrink-0 items-center gap-2.5">
  9. <img
  10. src="/logo.png"
  11. alt="AI在线"
  12. class="h-9 w-9 rounded-xl shadow-[0_6px_16px_var(--sh-11-140-255-220)]"
  13. >
  14. <div class="hidden leading-tight sm:block">
  15. <div class="text-[17px] font-extrabold tracking-tight text-[var(--c-172033)]">AI 在线</div>
  16. <div class="text-[11px] font-medium text-[var(--c-8a97a8)]">解放你的生产力</div>
  17. </div>
  18. </NuxtLink>
  19. <!-- 主导航(AI 绘画为一级入口,hover 展开二级菜单,直接点击进动漫头像) -->
  20. <nav class="ml-2 hidden items-center gap-1 md:flex">
  21. <template v-for="item in navItems" :key="item.label">
  22. <div
  23. v-if="item.children"
  24. class="relative"
  25. @mouseenter="drawMenuOpen = true"
  26. @mouseleave="drawMenuOpen = false"
  27. @focusin="drawMenuOpen = true"
  28. @focusout="onDrawMenuFocusOut"
  29. >
  30. <NuxtLink
  31. :to="item.to"
  32. 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)]"
  33. :class="isGroupActive(item)
  34. ? 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]'
  35. : 'text-[var(--c-5b6b80)]'"
  36. :aria-expanded="drawMenuOpen"
  37. >
  38. {{ item.label }}
  39. <UIcon
  40. name="i-lucide-chevron-down"
  41. class="size-3.5 transition-transform"
  42. :class="drawMenuOpen && 'rotate-180'"
  43. />
  44. </NuxtLink>
  45. <Transition name="menu">
  46. <!-- 外层留 pt-2 把触发区和面板连起来,避免鼠标移动时菜单闪退 -->
  47. <div v-if="drawMenuOpen" class="absolute left-0 top-full z-50 pt-2">
  48. <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)]">
  49. <NuxtLink
  50. v-for="child in item.children"
  51. :key="child.to"
  52. :to="child.to"
  53. class="block px-4 py-2.5 transition-colors hover:bg-[var(--c-f7fbff)]"
  54. active-class="bg-[var(--c-f7fbff)]"
  55. @click="drawMenuOpen = false"
  56. >
  57. <span class="block text-sm font-bold text-[var(--c-172033)]">{{ child.label }}</span>
  58. <span class="mt-0.5 block text-xs text-[var(--c-8a97a8)]">{{ child.desc }}</span>
  59. </NuxtLink>
  60. </div>
  61. </div>
  62. </Transition>
  63. </div>
  64. <NuxtLink
  65. v-else
  66. :to="item.to"
  67. 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)]"
  68. active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]"
  69. >
  70. {{ item.label }}
  71. </NuxtLink>
  72. </template>
  73. </nav>
  74. <!-- 右侧操作 -->
  75. <div class="ml-auto flex items-center gap-2 sm:gap-3">
  76. <!-- 亮色 / 暗色切换 -->
  77. <ThemeToggle />
  78. <template v-if="isLogin">
  79. <!-- 点数余额 -->
  80. <NuxtLink
  81. to="/bill"
  82. 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"
  83. >
  84. <UIcon name="i-lucide-coins" class="size-3.5" />
  85. <span>{{ userInfo.balance || 0 }} 点</span>
  86. </NuxtLink>
  87. <NuxtLink
  88. to="/recharge"
  89. 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"
  90. >
  91. 充值
  92. </NuxtLink>
  93. <!-- 用户菜单(自定义面板,保证背景与排版可控) -->
  94. <div ref="userMenuRef" class="relative">
  95. <button
  96. aria-label="用户菜单"
  97. :aria-expanded="userMenuOpen"
  98. 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"
  99. @click="userMenuOpen = !userMenuOpen"
  100. >
  101. <img v-if="userInfo.avatar" :src="userInfo.avatar" class="h-full w-full object-cover" alt="avatar">
  102. <span v-else class="text-xs font-extrabold text-[var(--c-0b8cff)]">
  103. {{ (userInfo.nickname || userInfo.user_name || '用')[0] }}
  104. </span>
  105. </button>
  106. <Transition name="menu">
  107. <div
  108. v-if="userMenuOpen"
  109. 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)]"
  110. >
  111. <!-- 用户信息 -->
  112. <div class="border-b border-[var(--c-f1f5fa)] px-4 pb-2.5 pt-1.5">
  113. <div class="truncate text-sm font-bold text-[var(--c-172033)]">
  114. {{ userInfo.nickname || userInfo.user_name || '我的账号' }}
  115. </div>
  116. <div class="mt-0.5 text-xs text-[var(--c-8a97a8)]">余额 {{ userInfo.balance || 0 }} 点</div>
  117. </div>
  118. <NuxtLink
  119. v-for="item in userMenuLinks"
  120. :key="item.to"
  121. :to="item.to"
  122. 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)]"
  123. @click="userMenuOpen = false"
  124. >
  125. <UIcon :name="item.icon" class="size-4 shrink-0" />
  126. <span>{{ item.label }}</span>
  127. </NuxtLink>
  128. <div class="my-1 border-t border-[var(--c-f1f5fa)]" />
  129. <button
  130. 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)]"
  131. @click="onLogout"
  132. >
  133. <UIcon name="i-lucide-log-out" class="size-4 shrink-0" />
  134. <span>退出登录</span>
  135. </button>
  136. </div>
  137. </Transition>
  138. </div>
  139. </template>
  140. <button
  141. v-else
  142. 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"
  143. @click="showLogin = true"
  144. >
  145. 登录
  146. </button>
  147. <!-- 移动端菜单按钮 -->
  148. <button
  149. 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"
  150. @click="mobileOpen = !mobileOpen"
  151. >
  152. <UIcon :name="mobileOpen ? 'i-lucide-x' : 'i-lucide-menu'" class="size-5" />
  153. </button>
  154. </div>
  155. </div>
  156. </div>
  157. <!-- 移动端下拉导航 -->
  158. <Transition name="slide">
  159. <nav v-if="mobileOpen" class="border-t border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] md:hidden">
  160. <div class="mx-auto max-w-7xl px-4 py-2 sm:px-6">
  161. <template v-for="item in navItems" :key="item.label">
  162. <!-- 移动端没有 hover,二级菜单直接平铺缩进展示 -->
  163. <div v-if="item.children">
  164. <div class="px-3 pb-1 pt-2.5 text-xs font-bold text-[var(--c-8a97a8)]">{{ item.label }}</div>
  165. <NuxtLink
  166. v-for="child in item.children"
  167. :key="child.to"
  168. :to="child.to"
  169. 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)]"
  170. active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]"
  171. @click="mobileOpen = false"
  172. >
  173. {{ child.label }}
  174. </NuxtLink>
  175. </div>
  176. <NuxtLink
  177. v-else
  178. :to="item.to"
  179. 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)]"
  180. active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]"
  181. @click="mobileOpen = false"
  182. >
  183. {{ item.label }}
  184. </NuxtLink>
  185. </template>
  186. </div>
  187. </nav>
  188. </Transition>
  189. </header>
  190. <!-- ==================== 主体内容 ==================== -->
  191. <main class="flex-1">
  192. <slot />
  193. </main>
  194. <!-- ==================== 页脚(白色系、紧凑,二维码与内容同行) ==================== -->
  195. <footer class="mt-auto border-t border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]">
  196. <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
  197. <div class="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
  198. <!-- 品牌 -->
  199. <div class="max-w-xs">
  200. <div class="flex items-center gap-2.5">
  201. <img src="/logo.png" alt="AI在线" class="h-8 w-8 rounded-lg">
  202. <span class="text-base font-extrabold text-[var(--c-172033)]">AI 在线</span>
  203. </div>
  204. <p class="mt-2.5 text-sm leading-relaxed text-[var(--c-8a97a8)]">
  205. 解放你的生产力。AI 取名、动漫头像、证件照,一站式智能创作平台。
  206. </p>
  207. </div>
  208. <!-- 链接 + 二维码同行 -->
  209. <div class="flex flex-wrap items-start gap-10 sm:gap-14">
  210. <div>
  211. <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">产品</div>
  212. <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]">
  213. <li v-for="item in productLinks" :key="item.to">
  214. <NuxtLink :to="item.to" class="transition-colors hover:text-[var(--c-0b8cff)]">{{ item.label }}</NuxtLink>
  215. </li>
  216. </ul>
  217. </div>
  218. <div>
  219. <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">资讯</div>
  220. <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]">
  221. <li v-for="item in infoLinks" :key="item.to">
  222. <NuxtLink :to="item.to" class="transition-colors hover:text-[var(--c-0b8cff)]">{{ item.label }}</NuxtLink>
  223. </li>
  224. </ul>
  225. </div>
  226. <div>
  227. <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">我的</div>
  228. <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]">
  229. <li><NuxtLink to="/works" class="transition-colors hover:text-[var(--c-0b8cff)]">我的作品</NuxtLink></li>
  230. <li v-if="earnEnabled"><NuxtLink to="/earn-points" class="transition-colors hover:text-[var(--c-0b8cff)]">获得点数</NuxtLink></li>
  231. <li v-if="inviteEnabled"><NuxtLink to="/invite" class="transition-colors hover:text-[var(--c-0b8cff)]">邀请好友</NuxtLink></li>
  232. <li><NuxtLink to="/bill" class="transition-colors hover:text-[var(--c-0b8cff)]">账单明细</NuxtLink></li>
  233. <li><NuxtLink to="/recharge" class="transition-colors hover:text-[var(--c-0b8cff)]">充值中心</NuxtLink></li>
  234. </ul>
  235. </div>
  236. <!-- 公众号二维码 -->
  237. <div class="flex flex-col items-center">
  238. <img
  239. src="https://cdn.aionline.cc/ai/aionline_qr.jpg"
  240. alt="AI在线公众号"
  241. class="h-20 w-20 rounded-lg border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] p-1"
  242. loading="lazy"
  243. >
  244. <div class="mt-2 text-xs text-[var(--c-8a97a8)]">扫码关注我们</div>
  245. </div>
  246. </div>
  247. </div>
  248. <!-- 备案与协议(对齐 pc 站点) -->
  249. <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">
  250. <NuxtLink to="/terms" class="transition-colors hover:text-[var(--c-0b8cff)]">服务条款</NuxtLink>
  251. <NuxtLink to="/privacy" class="transition-colors hover:text-[var(--c-0b8cff)]">隐私政策</NuxtLink>
  252. <span>四川内宇科技有限公司</span>
  253. <a
  254. href="https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=51010702042692"
  255. target="_blank"
  256. rel="noopener"
  257. class="transition-colors hover:text-[var(--c-0b8cff)]"
  258. >川公网安备51010702042692号</a>
  259. <a
  260. href="https://beian.miit.gov.cn/#/Integrated/index"
  261. target="_blank"
  262. rel="noopener"
  263. class="transition-colors hover:text-[var(--c-0b8cff)]"
  264. >蜀ICP备2023026929号-1</a>
  265. <span>&copy; {{ new Date().getFullYear() }} AI在线</span>
  266. </div>
  267. </div>
  268. </footer>
  269. <!-- 全局回到顶部(所有页面共用,滚动超过一屏才出现) -->
  270. <BackToTop />
  271. <!-- 登录弹窗 -->
  272. <LoginModal
  273. v-if="showLogin"
  274. @close="showLogin = false"
  275. @login-success="onLoginSuccess"
  276. />
  277. </div>
  278. </template>
  279. <script setup>
  280. // 顶部导航。图像类能力收进「AI 绘画」二级菜单,一级入口本身指向动漫头像。
  281. const drawChildren = [
  282. { label: '动漫头像', to: '/draw/anime/', desc: '自拍一键变动漫风头像' },
  283. { label: '形象照 / 写真', to: '/portrait-photo', desc: '商务、职业照与艺术写真' },
  284. { label: '老照片修复', to: '/old-photo', desc: '修复破损并自然上色' },
  285. { label: 'AI 证件照', to: '/id-photo', desc: '换底色、标准尺寸' },
  286. ]
  287. const navItems = [
  288. { label: '首页', to: '/' },
  289. { label: 'AI 取名', to: '/writing/naming/' },
  290. { label: 'AI 绘画', to: '/draw/anime/', children: drawChildren },
  291. { label: 'AI 资讯', to: '/info' },
  292. { label: '我的作品', to: '/works' },
  293. ]
  294. // 注:AI 图片(/pic)页面保留,只是不放顶部导航和页脚入口
  295. // 页脚「产品」列:只放真正的产品能力,个人相关的放「我的」列
  296. const productLinks = [
  297. { label: 'AI 取名', to: '/writing/naming/' },
  298. { label: '动漫头像', to: '/draw/anime/' },
  299. { label: '形象照 / 写真', to: '/portrait-photo' },
  300. { label: '老照片修复', to: '/old-photo' },
  301. { label: 'AI 证件照', to: '/id-photo' },
  302. ]
  303. // 页脚「资讯」列
  304. const infoLinks = [
  305. { label: 'AI 资讯', to: '/info' },
  306. { label: '前沿资讯', to: '/info/new' },
  307. { label: '使用教程', to: '/info/course' },
  308. { label: 'AI 问答', to: '/info/what' },
  309. ]
  310. const { userInfo, isLogin, showLogin, setLogin, logout, refreshBalance } = useUser()
  311. const { post } = useApi()
  312. const mobileOpen = ref(false)
  313. const userMenuOpen = ref(false)
  314. const drawMenuOpen = ref(false)
  315. const userMenuRef = ref(null)
  316. const route = useRoute()
  317. /** 二级菜单里任一页面命中时,一级「AI 绘画」也保持选中态 */
  318. function isGroupActive(item) {
  319. const path = route.path.replace(/\/+$/, '') || '/'
  320. return (item.children || []).some((child) => {
  321. const target = child.to.replace(/\/+$/, '') || '/'
  322. return path === target || path.startsWith(`${target}/`)
  323. })
  324. }
  325. /** 焦点移出整个下拉区域才收起,键盘 Tab 浏览时菜单不会跳掉 */
  326. function onDrawMenuFocusOut(event) {
  327. if (!event.currentTarget.contains(event.relatedTarget)) drawMenuOpen.value = false
  328. }
  329. // 后台未启用邀请活动时,入口默认不渲染;请求失败同样按未启用处理。
  330. const { data: inviteConfigResponse } = await useAsyncData(
  331. 'global-invite-entry-config',
  332. () => post('/invite/config'),
  333. )
  334. const inviteEnabled = computed(
  335. () => Number(inviteConfigResponse.value?.data?.config?.status) === 1,
  336. )
  337. // 获得点数:签到、邀请与看广告任一开启才有入口,全部关闭时整条不渲染
  338. const { entryEnabled: earnEnabled, load: loadEarnConfig } = useEarnPoints()
  339. await loadEarnConfig()
  340. const allUserMenuLinks = [
  341. { label: '我的作品', icon: 'i-lucide-image', to: '/works' },
  342. { label: '获得点数', icon: 'i-lucide-gift', to: '/earn-points', earnOnly: true },
  343. { label: '邀请好友', icon: 'i-lucide-user-plus', to: '/invite', inviteOnly: true },
  344. { label: '账单明细', icon: 'i-lucide-receipt', to: '/bill' },
  345. { label: '充值中心', icon: 'i-lucide-coins', to: '/recharge' },
  346. ]
  347. const userMenuLinks = computed(
  348. () => allUserMenuLinks.filter((item) => {
  349. if (item.inviteOnly && !inviteEnabled.value) return false
  350. if (item.earnOnly && !earnEnabled.value) return false
  351. return true
  352. }),
  353. )
  354. // 路由切换时收起菜单
  355. watch(() => route.fullPath, () => {
  356. mobileOpen.value = false
  357. userMenuOpen.value = false
  358. drawMenuOpen.value = false
  359. })
  360. // 点击外部关闭用户菜单
  361. function onDocClick(e) {
  362. if (userMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(e.target)) {
  363. userMenuOpen.value = false
  364. }
  365. }
  366. onMounted(() => {
  367. document.addEventListener('click', onDocClick)
  368. // 已登录时刷新点数,保证 header 显示的是最新余额
  369. if (isLogin.value) refreshBalance()
  370. })
  371. onUnmounted(() => document.removeEventListener('click', onDocClick))
  372. // 登录状态变化时同步点数
  373. watch(isLogin, (v) => { if (v) refreshBalance() })
  374. function onLogout() {
  375. userMenuOpen.value = false
  376. logout()
  377. }
  378. function onLoginSuccess(data) {
  379. setLogin(data)
  380. showLogin.value = false
  381. refreshBalance()
  382. }
  383. </script>
  384. <style scoped>
  385. .slide-enter-active,
  386. .slide-leave-active {
  387. transition: opacity 0.18s ease;
  388. }
  389. .slide-enter-from,
  390. .slide-leave-to {
  391. opacity: 0;
  392. }
  393. .menu-enter-active,
  394. .menu-leave-active {
  395. transition: opacity 0.15s ease, transform 0.15s ease;
  396. }
  397. .menu-enter-from,
  398. .menu-leave-to {
  399. opacity: 0;
  400. transform: translateY(-4px);
  401. }
  402. </style>