Hanye官网
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

TheHeader.vue 30KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947
  1. <template>
  2. <!-- Header -->
  3. <header class="fixed top-0 z-50 w-full bg-slate-900/70 backdrop-blur-[50px]">
  4. <!-- 导航容器 -->
  5. <div class="w-full">
  6. <!-- 内容居中容器 -->
  7. <div class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10">
  8. <div class="h-[55px] flex justify-between items-center sm:h-[72px]">
  9. <div class="flex justify-start items-center gap-12 lg:gap-24">
  10. <nuxt-link :to="homePath" class="brand-link mt-[5px] flex-shrink-0">
  11. <i
  12. class="icon-brand text-white text-1xl sm:text-2xl block transition-[transform,filter] duration-500 ease-in-out"
  13. ></i>
  14. </nuxt-link>
  15. <!-- Desktop Menu -->
  16. <nav
  17. class="hidden md:flex justify-start items-start gap-1 lg:gap-7 xl:gap-14"
  18. >
  19. <template v-for="item in menuItems" :key="item.label">
  20. <!-- Regular Link -->
  21. <nuxt-link
  22. v-if="!item.isDropdown"
  23. class="main-nav-link relative inline-block justify-start text-white text-sm opacity-80 hover:opacity-100 transition-opacity py-2 px-3 rounded-md"
  24. :to="item.path"
  25. :class="[
  26. route.path === item.path
  27. ? 'font-bold opacity-100 bg-white/15'
  28. : '',
  29. ]"
  30. >
  31. {{ t(item.label) }}
  32. </nuxt-link>
  33. <!-- Dropdown Container -->
  34. <div
  35. v-else-if="item.isDropdown"
  36. class="relative"
  37. @mouseleave="handleMouseLeave"
  38. >
  39. <!-- Dropdown Trigger -->
  40. <div
  41. @mouseenter="handleMouseEnter(item.label)"
  42. class="justify-start text-white text-sm opacity-80 hover:opacity-100 transition-opacity cursor-pointer flex items-center gap-1 py-2 px-3 rounded-md"
  43. :class="[
  44. route.path.startsWith(item.pathPrefix)
  45. ? 'font-bold opacity-100 bg-white/15'
  46. : '',
  47. ]"
  48. >
  49. <span>{{ t(item.label) }}</span>
  50. </div>
  51. <!-- Dropdown Panel -->
  52. <transition name="fade-down">
  53. <div
  54. v-if="item.isDropdown && openDropdown === item.label"
  55. @mouseenter="handleMouseEnter(item.label)"
  56. class="fixed left-0 top-[70px] w-screen bg-slate-900/95 backdrop-blur-[50px] border-t border-b border-slate-700/20 shadow-2xl z-10"
  57. >
  58. <!-- 内容居中容器 -->
  59. <div
  60. class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10 py-12"
  61. >
  62. <!-- 主内容网格 -->
  63. <div>
  64. <!-- 产品分类列 (原有的分类逻辑和数据) -->
  65. <div>
  66. <h3
  67. class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-4"
  68. >
  69. {{ t("common.productCategories") }}
  70. </h3>
  71. <div class="flex gap-x-40 gap-y-8">
  72. <!-- 企业用户产品组 -->
  73. <div>
  74. <h4
  75. class="text-lg font-semibold text-white mb-6"
  76. >
  77. <nuxt-link
  78. :to="`${homePath}products?audiences=1`"
  79. class="flex items-center gap-2 hover:text-cyan-400 transition-colors"
  80. @click="handleMouseLeave"
  81. >
  82. <span>{{ t("home.business.title") }}</span>
  83. </nuxt-link>
  84. </h4>
  85. <ul class="space-y-2">
  86. <li
  87. v-for="link in getGroupedItems(
  88. item.children[0].items
  89. ).enterprise"
  90. :key="link.path"
  91. >
  92. <nuxt-link
  93. :to="link.path"
  94. @click="handleMouseLeave"
  95. class="block text-[16px] text-gray-300 hover:text-white transition-colors"
  96. >
  97. {{ t(link.label) }}
  98. </nuxt-link>
  99. <ul
  100. v-if="link.tags && link.tags.length"
  101. class="mt-2"
  102. >
  103. <li
  104. v-for="tag in link.tags"
  105. :key="tag"
  106. class="mt-4 mb-4"
  107. >
  108. <nuxt-link
  109. :to="`${link.path}&tag=${tag}`"
  110. @click.stop="handleMouseLeave"
  111. class="text-sm text-gray-400 hover:text-white transition-colors duration-200"
  112. >
  113. {{ tag }}
  114. </nuxt-link>
  115. </li>
  116. </ul>
  117. </li>
  118. </ul>
  119. </div>
  120. <!-- 个人用户产品组 -->
  121. <div class="flex-1">
  122. <h4
  123. class="text-lg font-semibold text-white mb-6"
  124. >
  125. <nuxt-link
  126. :to="`${homePath}products?audiences=0`"
  127. class="flex items-center gap-2 hover:text-cyan-400 transition-colors"
  128. @click="handleMouseLeave"
  129. >
  130. <span>{{ t("home.personal.title") }}</span>
  131. </nuxt-link>
  132. </h4>
  133. <ul class="flex gap-24">
  134. <li
  135. v-for="link in getGroupedItems(
  136. item.children[0].items
  137. ).personal"
  138. :key="link.path"
  139. >
  140. <nuxt-link
  141. :to="link.path"
  142. @click="handleMouseLeave"
  143. class="block text-[16px] text-gray-300 hover:text-white transition-colors"
  144. >
  145. {{ t(link.label) }}
  146. </nuxt-link>
  147. <ul
  148. v-if="link.tags && link.tags.length"
  149. class="mt-2"
  150. >
  151. <li
  152. v-for="tag in link.tags"
  153. :key="tag"
  154. class="mt-4 mb-4"
  155. >
  156. <nuxt-link
  157. :to="`${link.path}&tag=${tag}`"
  158. @click.stop="handleMouseLeave"
  159. class="text-sm text-gray-400 hover:text-white transition-colors duration-200"
  160. >
  161. {{ tag }}
  162. </nuxt-link>
  163. </li>
  164. </ul>
  165. </li>
  166. </ul>
  167. </div>
  168. </div>
  169. </div>
  170. </div>
  171. </div>
  172. </div>
  173. </transition>
  174. </div>
  175. </template>
  176. </nav>
  177. </div>
  178. <div class="flex justify-start items-center gap-4 md:gap-6">
  179. <!-- Search -->
  180. <div
  181. @click="openSearch"
  182. class="w-auto h-8 relative items-center opacity-40 rounded-2xl pr-4 hover:opacity-100 transition-opacity duration-300 hidden md:flex cursor-pointer"
  183. style="border: 0.5px solid rgba(255, 255, 255, 0.4)"
  184. >
  185. <span
  186. class="flex items-center justify-center w-8 h-8 opacity-80 hover:opacity-100 text-white"
  187. >
  188. <i class="icon-search text-sm"></i>
  189. </span>
  190. <span
  191. class="hidden lg:inline-block ml-1 text-white text-sm opacity-80"
  192. >
  193. {{ t("common.search") }}
  194. </span>
  195. <!-- Input overlay could go here if implementing search -->
  196. </div>
  197. <!-- Language -->
  198. <LanguageSwitcher />
  199. <!-- Mobile Menu Button -->
  200. <div class="md:hidden">
  201. <button
  202. @click="toggleMobileMenu"
  203. class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 bg-white/10 hover:text-white hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
  204. >
  205. <span class="sr-only">Open main menu</span>
  206. <!-- Icon when menu is closed. -->
  207. <svg
  208. v-if="!mobileMenuOpen"
  209. class="block h-6 w-6"
  210. xmlns="http://www.w3.org/2000/svg"
  211. fill="none"
  212. viewBox="0 0 24 24"
  213. stroke="currentColor"
  214. aria-hidden="true"
  215. >
  216. <path
  217. stroke-linecap="round"
  218. stroke-linejoin="round"
  219. stroke-width="2"
  220. d="M4 6h16M4 12h16M4 18h16"
  221. />
  222. </svg>
  223. <!-- Icon when menu is open. -->
  224. <svg
  225. v-else
  226. class="block h-6 w-6"
  227. xmlns="http://www.w3.org/2000/svg"
  228. fill="none"
  229. viewBox="0 0 24 24"
  230. stroke="currentColor"
  231. aria-hidden="true"
  232. >
  233. <path
  234. stroke-linecap="round"
  235. stroke-linejoin="round"
  236. stroke-width="2"
  237. d="M6 18L18 6M6 6l12 12"
  238. />
  239. </svg>
  240. </button>
  241. </div>
  242. </div>
  243. </div>
  244. </div>
  245. </div>
  246. <!-- Mobile menu, show/hide based on menu state. -->
  247. <transition name="slide-fade">
  248. <div
  249. v-if="mobileMenuOpen"
  250. class="md:hidden bg-slate-800/90"
  251. id="mobile-menu"
  252. >
  253. <div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
  254. <template v-for="item in menuItems" :key="item.label">
  255. <!-- Mobile Regular Link -->
  256. <nuxt-link
  257. v-if="!item.isDropdown"
  258. :to="item.path"
  259. @click="closeMobileMenu"
  260. class="block px-3 py-2 rounded-md text-base font-medium"
  261. :class="[
  262. route.path === item.path
  263. ? 'bg-gray-900 text-white'
  264. : 'text-gray-300 hover:bg-gray-700 hover:text-white',
  265. ]"
  266. >
  267. {{ t(item.label) }}
  268. </nuxt-link>
  269. <!-- Mobile Dropdown Section -->
  270. <div v-else class="mt-2">
  271. <h3
  272. class="px-3 pt-2 pb-1 text-sm font-semibold text-gray-400 uppercase tracking-wider"
  273. >
  274. {{ t(item.label) }}
  275. </h3>
  276. <div
  277. v-for="section in item.children"
  278. :key="section.title"
  279. class="mt-1"
  280. >
  281. <nuxt-link
  282. v-for="link in section.items"
  283. :key="link.path"
  284. :to="link.path"
  285. @click="closeMobileMenu"
  286. class="block pl-6 pr-3 py-2 rounded-md text-base font-medium text-gray-300 hover:bg-gray-700 hover:text-white"
  287. :class="{
  288. 'bg-gray-900 text-white': route.path === link.path,
  289. }"
  290. >
  291. {{ t(link.label) }}
  292. </nuxt-link>
  293. </div>
  294. </div>
  295. </template>
  296. </div>
  297. </div>
  298. </transition>
  299. </header>
  300. <!-- Search Layer (Moved outside header) -->
  301. <transition name="search-fade-scale">
  302. <div
  303. v-if="isSearchOpen"
  304. class="fixed inset-0 z-[60] bg-black/80 backdrop-blur-md flex items-start justify-center pt-20"
  305. @click.self="closeSearch"
  306. >
  307. <div
  308. class="bg-gradient-to-br from-slate-800 to-slate-900 p-8 rounded-lg relative w-full max-w-2xl mx-4 search-modal-content shadow-xl"
  309. >
  310. <button
  311. @click="closeSearch"
  312. class="absolute top-3 right-3 text-gray-500 hover:text-white hover:bg-white/10 rounded-full p-2 transition-colors"
  313. >
  314. <svg
  315. class="h-5 w-5"
  316. fill="none"
  317. viewBox="0 0 24 24"
  318. stroke="currentColor"
  319. >
  320. <path
  321. stroke-linecap="round"
  322. stroke-linejoin="round"
  323. stroke-width="2"
  324. d="M6 18L18 6M6 6l12 12"
  325. />
  326. </svg>
  327. </button>
  328. <h2 class="text-white text-xl mb-6">{{ t("common.search") }}</h2>
  329. <!-- Input with Icon -->
  330. <div class="relative mb-6">
  331. <span class="absolute inset-y-0 left-0 flex items-center pl-3">
  332. <i class="icon-search text-gray-400 text-sm"></i>
  333. </span>
  334. <input
  335. ref="searchInputRef"
  336. v-model="searchQuery"
  337. type="text"
  338. :placeholder="
  339. t('common.searchPlaceholder') || 'Enter search term...'
  340. "
  341. class="w-full p-3 pl-10 pr-10 rounded bg-slate-700 text-white border border-slate-600 focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
  342. @input="handleSearch"
  343. />
  344. <!-- Clear Icon -->
  345. <span
  346. v-if="searchQuery"
  347. class="absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer group"
  348. @click="clearSearch"
  349. >
  350. <button
  351. class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-700/80 hover:bg-cyan-500/90 text-gray-300 hover:text-white transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500"
  352. :aria-label="t('common.clear')"
  353. tabindex="0"
  354. type="button"
  355. >
  356. <svg
  357. xmlns="http://www.w3.org/2000/svg"
  358. class="h-5 w-5"
  359. fill="none"
  360. viewBox="0 0 24 24"
  361. stroke="currentColor"
  362. stroke-width="2"
  363. >
  364. <path
  365. stroke-linecap="round"
  366. stroke-linejoin="round"
  367. d="M6 18L18 6M6 6l12 12"
  368. />
  369. </svg>
  370. </button>
  371. </span>
  372. </div>
  373. <!-- Search Results -->
  374. <div v-if="isLoading" class="text-gray-400 text-sm">
  375. {{ t("common.searching") }}
  376. </div>
  377. <div v-if="error" class="text-red-400 text-sm">
  378. {{ error }}
  379. </div>
  380. <div
  381. v-if="searchResults.length > 0"
  382. class="mt-4 max-h-[50vh] overflow-y-auto pr-2 space-y-4 search-results"
  383. >
  384. <div
  385. v-for="result in searchResults"
  386. :key="result.id"
  387. class="p-3 bg-slate-700/50 rounded-lg cursor-pointer hover:bg-slate-700 transition-colors group"
  388. >
  389. <nuxt-link
  390. :to="`${homePath}products/${result.name}`"
  391. class="block"
  392. @click="closeSearch"
  393. >
  394. <div
  395. class="text-white font-medium mb-1 group-hover:text-cyan-400 transition-colors"
  396. v-html="highlightText(result.title, searchQuery)"
  397. ></div>
  398. <div
  399. class="text-gray-400 text-sm"
  400. v-html="highlightText(result.description, searchQuery)"
  401. ></div>
  402. <div
  403. class="text-gray-500 text-xs mt-1"
  404. v-html="highlightText(result.summary, searchQuery)"
  405. ></div>
  406. </nuxt-link>
  407. </div>
  408. </div>
  409. <div
  410. v-else-if="searchQuery && !isLoading"
  411. class="text-gray-400 text-sm mt-4"
  412. >
  413. {{ t("common.noResults") }}
  414. </div>
  415. <!-- Hot Keywords Section -->
  416. <div class="mt-6">
  417. <h3 class="text-gray-400 text-sm mb-3">
  418. {{ t("common.hotKeywords") }}
  419. </h3>
  420. <div class="flex flex-wrap gap-3">
  421. <button
  422. v-for="keyword in hotKeywords"
  423. :key="keyword"
  424. @click="searchHotKeyword(keyword)"
  425. class="px-4 py-1.5 bg-slate-700 text-white/80 rounded-full text-sm hover:bg-cyan-600 hover:text-white transition-colors duration-200"
  426. >
  427. {{ keyword }}
  428. </button>
  429. </div>
  430. </div>
  431. </div>
  432. </div>
  433. </transition>
  434. </template>
  435. <script setup lang="ts">
  436. import { ref, computed, watch, nextTick } from "#imports";
  437. import { useI18n } from "vue-i18n";
  438. import { useSearch } from "~/composables/useSearch";
  439. import { defineComponent } from "vue";
  440. /**
  441. * 页面头部组件
  442. * 包含导航菜单、语言切换和移动端响应式设计
  443. */
  444. const { t, locale } = useI18n();
  445. const config = useRuntimeConfig();
  446. const defaultLocale = config.public.i18n?.defaultLocale || "zh";
  447. const mobileMenuOpen = ref(false);
  448. const isSearchOpen = ref(false);
  449. const searchInputRef = ref<HTMLInputElement | null>(null);
  450. const openDropdown = ref<string | null>(null);
  451. let leaveTimeout: ReturnType<typeof setTimeout> | null = null; // Timeout for mouseleave delay
  452. const route = useRoute();
  453. const searchQuery = ref("");
  454. const { searchResults, isLoading, error, searchProducts, highlightText } =
  455. useSearch();
  456. // 添加热门关键字
  457. const hotKeywords = ref(["SSD", "SD", "DDR4"]);
  458. // 获取产品分类数据
  459. const { data: categoryResponse } = await useAsyncData(
  460. `header-categories-${locale.value}`,
  461. async () => {
  462. try {
  463. // 使用queryCollection从content目录获取数据
  464. const content = await queryCollection("content")
  465. .where("path", "LIKE", `/categories/${locale.value}/%`)
  466. .all();
  467. if (!content || !Array.isArray(content)) {
  468. console.log("No category content found for header");
  469. return [];
  470. }
  471. // 转换为需要的格式
  472. return content
  473. .map((item: any) => {
  474. return {
  475. label: item.title,
  476. path: `/products?category=${encodeURIComponent(
  477. item.title
  478. )}&audiences=${item.meta.audiences}`,
  479. id: item.meta.id,
  480. audiences: item.meta.audiences,
  481. tags: item.meta.tags,
  482. };
  483. })
  484. .sort((a, b) => (a.id || 0) - (b.id || 0));
  485. } catch (error) {
  486. console.error("Error loading category data for header:", error);
  487. return [];
  488. }
  489. }
  490. );
  491. // 获取产品用途数据
  492. const { data: usageResponse } = await useAsyncData(
  493. `header-usages-${locale.value}`,
  494. async () => {
  495. try {
  496. // 使用queryCollection从content目录获取数据
  497. const content = await queryCollection("content")
  498. .where("path", "LIKE", `/usages/${locale.value}/%`)
  499. .all();
  500. if (!content || !Array.isArray(content)) {
  501. console.log("No usage content found for header");
  502. return [];
  503. }
  504. // 转换为需要的格式
  505. return content
  506. .map((item: any) => {
  507. // 从路径中提取ID - 文件名就是ID
  508. const pathParts = item.path?.split("/");
  509. const idFile = pathParts?.[pathParts.length - 1] || "";
  510. // 从文件名提取ID,去掉可能的扩展名
  511. const id = parseInt(idFile.replace(".md", "")) || 0;
  512. return {
  513. label: item.title,
  514. path: `/products?usage=${encodeURIComponent(item.title)}`,
  515. id: id,
  516. };
  517. })
  518. .sort((a, b) => (a.id || 0) - (b.id || 0));
  519. } catch (error) {
  520. console.error("Error loading usage data for header:", error);
  521. return [];
  522. }
  523. }
  524. );
  525. // 使用计算属性处理产品分类数据
  526. const productCategories = computed(() => {
  527. return categoryResponse.value || [];
  528. });
  529. // 使用计算属性处理产品用途数据
  530. const productUsages = computed(() => {
  531. return usageResponse.value || [];
  532. });
  533. // 使用 computed 来定义 homePath,根据是否为默认语言调整路径
  534. const homePath = computed(() => {
  535. // 如果是默认语言,路径为根路径 '/'
  536. // 否则,路径为 '/<locale>/'
  537. return locale.value === defaultLocale ? "/" : `/${locale.value}/`;
  538. });
  539. // 使用 computed 来定义 menuItems,根据是否为默认语言调整路径
  540. const menuItems = computed(() => {
  541. // 判断当前是否为默认语言
  542. const isDefaultLocale = locale.value === defaultLocale;
  543. // 如果是默认语言,路径前缀为空字符串,否则为 '/<locale>'
  544. const prefix = isDefaultLocale ? "" : `/${locale.value}`;
  545. return [
  546. // 首页路径特殊处理:默认语言为 '/', 其他语言为 '/<locale>/'
  547. { label: "common.home", path: isDefaultLocale ? "/" : `${prefix}/` },
  548. {
  549. label: "common.products",
  550. isDropdown: true,
  551. pathPrefix: `${prefix}/products`,
  552. children: [
  553. {
  554. title: "common.productCategories",
  555. items: productCategories.value.map((category: any) => ({
  556. ...category,
  557. path: `${prefix}${category.path}`,
  558. })),
  559. },
  560. ],
  561. },
  562. { label: "support.title", path: `${prefix}/support` },
  563. { label: "common.about", path: `${prefix}/about` },
  564. { label: "common.contact", path: `${prefix}/contact` },
  565. ];
  566. });
  567. /**
  568. * 切换移动端菜单显示状态
  569. */
  570. function toggleMobileMenu() {
  571. mobileMenuOpen.value = !mobileMenuOpen.value;
  572. }
  573. /**
  574. * 关闭移动端菜单
  575. */
  576. function closeMobileMenu() {
  577. mobileMenuOpen.value = false;
  578. }
  579. /**
  580. * 打开搜索层
  581. */
  582. function openSearch() {
  583. isSearchOpen.value = true;
  584. }
  585. /**
  586. * 关闭搜索层
  587. */
  588. function closeSearch() {
  589. isSearchOpen.value = false;
  590. }
  591. /**
  592. * 搜索热门关键字
  593. * @param keyword 关键词
  594. */
  595. function searchHotKeyword(keyword: string) {
  596. searchQuery.value = keyword;
  597. handleSearch();
  598. }
  599. // 监听搜索层状态,打开时自动聚焦输入框
  600. watch(isSearchOpen, (newValue: boolean) => {
  601. if (newValue) {
  602. nextTick(() => {
  603. searchInputRef.value?.focus();
  604. });
  605. }
  606. });
  607. // --- Dropdown Logic ---
  608. function handleMouseEnter(label: string) {
  609. if (leaveTimeout) {
  610. clearTimeout(leaveTimeout);
  611. leaveTimeout = null;
  612. }
  613. openDropdown.value = label;
  614. }
  615. function handleMouseLeave() {
  616. // Delay closing the dropdown slightly
  617. leaveTimeout = setTimeout(() => {
  618. openDropdown.value = null;
  619. }, 150); // 150ms delay
  620. }
  621. // --- End Dropdown Logic ---
  622. // 防抖函数
  623. const debounce = (fn: Function, delay: number) => {
  624. let timer: NodeJS.Timeout;
  625. return (...args: any[]) => {
  626. clearTimeout(timer);
  627. timer = setTimeout(() => fn(...args), delay);
  628. };
  629. };
  630. // 处理搜索输入
  631. const handleSearch = debounce(async () => {
  632. await searchProducts(searchQuery.value);
  633. }, 300);
  634. /**
  635. * 清空搜索
  636. */
  637. const clearSearch = () => {
  638. searchQuery.value = "";
  639. searchResults.value = [];
  640. };
  641. /**
  642. * 将产品按照用户类型分组
  643. * @param items 产品列表
  644. * @returns 分组后的产品对象
  645. */
  646. function getGroupedItems(items: any[]) {
  647. return {
  648. personal: items.filter((item) => item.audiences === 0),
  649. enterprise: items.filter((item) => item.audiences === 1),
  650. };
  651. }
  652. </script>
  653. <style lang="scss" scoped>
  654. header {
  655. user-select: none;
  656. }
  657. /* Brand icon hover effect */
  658. .brand-link {
  659. &:hover {
  660. .icon-brand {
  661. transform: scale(1.05);
  662. filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.6));
  663. }
  664. }
  665. }
  666. /* Base style for icon to apply will-change */
  667. .icon-brand {
  668. will-change: transform, filter; /* Hint for browser optimization */
  669. }
  670. /* Transition for mobile menu */
  671. .slide-fade-enter-active {
  672. transition: all 0.3s ease-out;
  673. }
  674. .slide-fade-leave-active {
  675. transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
  676. }
  677. .slide-fade-enter-from,
  678. .slide-fade-leave-to {
  679. transform: translateY(-20px);
  680. opacity: 0;
  681. }
  682. /* Transition for dropdown */
  683. .fade-down-enter-active,
  684. .fade-down-leave-active {
  685. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  686. }
  687. .fade-down-enter-from,
  688. .fade-down-leave-to {
  689. opacity: 0;
  690. transform: translateY(-12px);
  691. }
  692. /* Transition for search overlay */
  693. .search-fade-scale-enter-active,
  694. .search-fade-scale-leave-active {
  695. transition: opacity 0.3s ease-out;
  696. }
  697. .search-fade-scale-enter-from,
  698. .search-fade-scale-leave-to {
  699. opacity: 0;
  700. }
  701. /* 为搜索框内容添加独立的、稍延迟的动画 */
  702. .search-modal-content {
  703. transition: transform 0.3s ease-out 0.05s;
  704. }
  705. .search-fade-scale-enter-from .search-modal-content,
  706. .search-fade-scale-leave-to .search-modal-content {
  707. transform: scale(0.95) translateY(10px);
  708. }
  709. /* Keep the sticky header consistent */
  710. .sticky {
  711. position: sticky;
  712. }
  713. /* 搜索结果高亮样式 */
  714. :deep(.highlight) {
  715. background-color: rgba(59, 130, 246, 0.2);
  716. padding: 0 2px;
  717. border-radius: 2px;
  718. color: #60a5fa;
  719. }
  720. /* 搜索结果动画 */
  721. .search-results-enter-active,
  722. .search-results-leave-active {
  723. transition: all 0.3s ease;
  724. }
  725. .search-results-enter-from,
  726. .search-results-leave-to {
  727. opacity: 0;
  728. transform: translateY(-10px);
  729. }
  730. /* 搜索结果滚动条样式 */
  731. .search-results {
  732. &::-webkit-scrollbar {
  733. width: 6px;
  734. }
  735. &::-webkit-scrollbar-track {
  736. background: rgba(255, 255, 255, 0.1);
  737. border-radius: 3px;
  738. }
  739. &::-webkit-scrollbar-thumb {
  740. background: rgba(255, 255, 255, 0.2);
  741. border-radius: 3px;
  742. &:hover {
  743. background: rgba(255, 255, 255, 0.3);
  744. }
  745. }
  746. }
  747. /* 菜单项激活状态样式 */
  748. .is-active {
  749. position: relative;
  750. &::before {
  751. content: "";
  752. position: absolute;
  753. left: 0;
  754. top: 50%;
  755. transform: translateY(-50%);
  756. width: 2px;
  757. height: 16px;
  758. background: #fff;
  759. border-radius: 0 1px 1px 0;
  760. }
  761. }
  762. /* 菜单组标题样式 */
  763. .group-title {
  764. position: relative;
  765. overflow: hidden;
  766. &::after {
  767. content: "";
  768. position: absolute;
  769. left: 0;
  770. bottom: 0;
  771. width: 100%;
  772. height: 1px;
  773. background: linear-gradient(
  774. 90deg,
  775. transparent,
  776. rgba(255, 255, 255, 0.2),
  777. transparent
  778. );
  779. }
  780. }
  781. /* 添加图标样式 */
  782. .icon-user,
  783. .icon-building {
  784. font-size: 20px;
  785. opacity: 0.8;
  786. transition: opacity 0.2s ease;
  787. }
  788. /* 优化过渡动画 */
  789. .transition-all {
  790. transition-property: all;
  791. transition-timing-function: ease;
  792. }
  793. /* 添加阴影效果 */
  794. .shadow-2xl {
  795. box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
  796. 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  797. }
  798. /* 添加玻璃态效果 */
  799. .backdrop-blur-sm {
  800. backdrop-filter: blur(8px);
  801. }
  802. /* 移除复杂的动画效果 */
  803. .bg-gradient-to-br {
  804. background-size: 100% 100%;
  805. }
  806. /* 移除波纹效果 */
  807. .group\/item:hover {
  808. &::before {
  809. display: none;
  810. }
  811. }
  812. /* 优化下拉菜单动画 */
  813. .fade-down-enter-active,
  814. .fade-down-leave-active {
  815. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  816. }
  817. .fade-down-enter-from,
  818. .fade-down-leave-to {
  819. opacity: 0;
  820. transform: translateY(-12px);
  821. }
  822. /* 添加下拉菜单背景渐变 */
  823. .bg-slate-900\/95 {
  824. background: linear-gradient(
  825. to bottom,
  826. rgba(15, 23, 42, 0.95),
  827. rgba(15, 23, 42, 0.98)
  828. );
  829. }
  830. /* 响应式布局 */
  831. @media (max-width: 1280px) {
  832. /* 调整主网格为 3 列,产品分类占 1 列,帮助/主题占 2 列 */
  833. .md\\:grid-cols-4 {
  834. grid-template-columns: repeat(3, minmax(0, 1fr));
  835. }
  836. .md\\:col-span-2 {
  837. grid-column: span 1 / span 1; /* 产品分类在 1280px 以下只占 1 列 */
  838. }
  839. /* 帮助和实用主题的父容器 */
  840. .md\\:col-span-2.grid.grid-cols-1.sm\\:grid-cols-2 {
  841. grid-column: span 2 / span 2; /* 帮助/主题在 1280px 以下占 2 列 */
  842. }
  843. }
  844. @media (max-width: 1024px) {
  845. /* 主网格在 1024px 以下变为 2 列 */
  846. .md\\:grid-cols-4 {
  847. grid-template-columns: repeat(2, minmax(0, 1fr));
  848. }
  849. /* 产品分类在 1024px 以下仍然占 1 列 */
  850. .md\\:col-span-2 {
  851. grid-column: span 1 / span 1;
  852. }
  853. /* 帮助和实用主题的父容器在 1024px 以下仍然占 1 列 */
  854. .md\\:col-span-2.grid.grid-cols-1.sm\\:grid-cols-2 {
  855. grid-column: span 1 / span 1;
  856. }
  857. /* 产品分类和帮助主题内部的 sm:grid-cols-2 变为单列 */
  858. .sm\\:grid-cols-2 {
  859. grid-template-columns: repeat(1, minmax(0, 1fr));
  860. }
  861. }
  862. @media (max-width: 768px) {
  863. /* 在移动端,所有列都变为单列堆叠 */
  864. .md\\:grid-cols-4,
  865. .md\\:col-span-2,
  866. .md\\:col-span-2.grid,
  867. .sm\\:grid-cols-2 {
  868. grid-template-columns: repeat(1, minmax(0, 1fr));
  869. grid-column: span 1 / span 1;
  870. }
  871. }
  872. </style>