Hanye官网
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

TheHeader.vue 29KB

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