Hanye官网
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261
  1. <template>
  2. <div>
  3. <div class="w-full h-[72px]"></div>
  4. <ErrorBoundary :error="error">
  5. <div v-if="isLoading" class="flex justify-center py-8 md:py-12">
  6. <!-- 加载中 -->
  7. <div
  8. class="animate-spin h-6 w-6 md:h-8 md:w-8 border-4 border-cyan-500 rounded-full border-t-transparent"
  9. ></div>
  10. </div>
  11. <div v-else>
  12. <!-- Business -->
  13. <div
  14. v-if="route.query.audiences === '1'"
  15. class="w-full mb-8 md:mb-12 lg:mb-16 [background:linear-gradient(180deg,#444B55_0%,#98A3B4_95%)] select-none hidden md:block"
  16. >
  17. <div class="max-w-screen-2xl h-[420px] mx-auto relative">
  18. <div
  19. class="absolute top-[40%] translate-y-[-50%] left-[70%] translate-x-[-50%] flex flex-col gap-2 md:gap-4 p-4 md:p-6 lg:p-8 z-10"
  20. >
  21. <div
  22. class="text-white text-xl sm:text-2xl md:text-3xl lg:text-4xl xl:text-6xl font-normal"
  23. >
  24. {{ t("home.business.title") }}
  25. </div>
  26. <div
  27. class="text-white text-xs sm:text-sm md:text-base lg:text-lg font-normal leading-relaxed md:leading-loose"
  28. >
  29. {{ t("home.business.description") }}
  30. </div>
  31. </div>
  32. <img
  33. src="~assets/images/business.webp"
  34. alt="products-banner"
  35. class="h-full pt-10 ml-20"
  36. />
  37. </div>
  38. </div>
  39. <!-- Personal -->
  40. <div
  41. v-if="route.query.audiences === '0'"
  42. class="w-full mb-8 md:mb-12 lg:mb-16 [background:linear-gradient(180deg,#0086f4_0%,#88d5fa_80%)] select-none hidden md:block"
  43. >
  44. <div
  45. class="max-w-screen-2xl h-[420px] mx-auto relative flex justify-end"
  46. >
  47. <div
  48. class="absolute top-[40%] translate-y-[-50%] left-[0] flex flex-col gap-2 md:gap-4 p-4 md:p-6 lg:p-8 z-10"
  49. >
  50. <div
  51. class="text-white text-xl sm:text-2xl md:text-3xl lg:text-4xl xl:text-6xl font-normal"
  52. >
  53. {{ t("home.personal.title") }}
  54. </div>
  55. <div
  56. class="text-white text-xs sm:text-sm md:text-base lg:text-lg font-normal leading-relaxed md:leading-loose"
  57. >
  58. {{ t("home.personal.description") }}
  59. </div>
  60. </div>
  61. <img
  62. src="~assets/images/personal.webp"
  63. alt="products-banner"
  64. class="h-full pt-10 mr-20"
  65. />
  66. </div>
  67. </div>
  68. <div
  69. class="max-w-full mb-4 md:mb-6 lg:mb-8 xl:px-8 lg:px-6 md:px-4 px-4"
  70. >
  71. <div class="max-w-screen-2xl mx-auto">
  72. <nuxt-link
  73. :to="`${homepagePath}/`"
  74. class="text-white/60 text-sm md:text-base font-normal hover:text-white transition-colors duration-300"
  75. >{{ t("common.home") }}</nuxt-link
  76. >
  77. <span class="text-white/60 text-sm md:text-base font-normal px-2">
  78. /
  79. </span>
  80. <nuxt-link
  81. :to="`${homepagePath}/products?audiences=${route.query.audiences}`"
  82. class="text-white text-sm md:text-base font-normal"
  83. >
  84. {{ route.query.audiences === '1' ? t("home.business.title") : t("home.personal.title") }}
  85. </nuxt-link>
  86. </div>
  87. </div>
  88. <div
  89. class="max-w-full mb-8 md:mb-12 lg:mb-16 xl:mb-20 xl:px-8 lg:px-6 md:px-4 px-4"
  90. >
  91. <div class="max-w-screen-2xl mx-auto">
  92. <div class="w-full grid grid-cols-1 md:grid-cols-12 gap-4">
  93. <div
  94. class="col-span-1 md:col-span-3 flex flex-col gap-4 sm:gap-6 md:gap-8 lg:gap-10 xl:gap-12 2xl:gap-16 mb-4 sm:mb-6 md:mb-8 lg:mb-10 xl:mb-12 2xl:mb-16 pr-4"
  95. >
  96. <div
  97. class="flex flex-col gap-2 sm:gap-3 md:gap-4 lg:gap-5 xl:gap-6"
  98. >
  99. <div class="flex justify-between items-center">
  100. <div
  101. class="text-white text-xl lg:text-2xl xl:text-3xl font-medium"
  102. >
  103. {{ t("products.product_categories_title") }}
  104. </div>
  105. <button
  106. v-if="selectedCategory"
  107. @click="clearCategory"
  108. class="flex items-center gap-1 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 text-xs sm:text-sm md:text-base text-white/60 hover:text-white bg-zinc-800/50 hover:bg-zinc-700/50 rounded-lg transition-all duration-300 active:scale-95"
  109. v-show="false"
  110. >
  111. <svg
  112. xmlns="http://www.w3.org/2000/svg"
  113. class="h-2.5 w-2.5 sm:h-3 sm:w-3 md:h-4 md:w-4"
  114. viewBox="0 0 20 20"
  115. fill="currentColor"
  116. >
  117. <path
  118. fill-rule="evenodd"
  119. d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
  120. clip-rule="evenodd"
  121. />
  122. </svg>
  123. </button>
  124. </div>
  125. <div
  126. class="flex flex-row md:flex-col gap-1.5 sm:gap-2 md:gap-2.5 lg:gap-3 xl:gap-4 w-full md:w-fit overflow-x-auto md:overflow-x-visible pb-2 md:pb-0 whitespace-nowrap md:whitespace-normal"
  127. >
  128. <div
  129. v-for="category in categories"
  130. :key="category"
  131. @click="handleCategoryFilter(category)"
  132. class="select-none text-white text-xs sm:text-sm md:text-base lg:text-lg font-normal cursor-pointer hover:opacity-100 transition-all duration-300 px-2 py-1 sm:px-2.5 sm:py-1.5 md:px-3 md:py-2 lg:px-4 lg:py-2.5 rounded-lg inline-block active:scale-95 whitespace-nowrap"
  133. :class="{
  134. 'font-bold bg-cyan-400 text-zinc-900 border-0 shadow-lg scale-105 transition-all duration-300':
  135. selectedCategory === category,
  136. 'hover:bg-zinc-800/50': selectedCategory !== category,
  137. }"
  138. >
  139. {{ category }}
  140. </div>
  141. </div>
  142. </div>
  143. <div
  144. class="flex-col gap-2 sm:gap-3 md:gap-4 lg:gap-5 xl:gap-6 hidden"
  145. >
  146. <div class="flex justify-between items-center">
  147. <div
  148. class="text-white text-base sm:text-lg md:text-xl lg:text-2xl xl:text-3xl font-medium"
  149. >
  150. {{ t("products.product_categories_usage") }}
  151. </div>
  152. <button
  153. v-if="selectedUsage"
  154. @click="clearUsage"
  155. class="flex items-center gap-1 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 text-xs sm:text-sm md:text-base text-white/60 hover:text-white bg-zinc-800/50 hover:bg-zinc-700/50 rounded-lg transition-all duration-300 active:scale-95"
  156. >
  157. <svg
  158. xmlns="http://www.w3.org/2000/svg"
  159. class="h-2.5 w-2.5 sm:h-3 sm:w-3 md:h-4 md:w-4"
  160. viewBox="0 0 20 20"
  161. fill="currentColor"
  162. >
  163. <path
  164. fill-rule="evenodd"
  165. d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
  166. clip-rule="evenodd"
  167. />
  168. </svg>
  169. </button>
  170. </div>
  171. <div
  172. class="flex flex-row md:flex-col gap-1.5 sm:gap-2 md:gap-2.5 lg:gap-3 xl:gap-4 w-full md:w-fit overflow-x-auto md:overflow-x-visible pb-2 md:pb-0 whitespace-nowrap md:whitespace-normal"
  173. >
  174. <div
  175. v-for="usage in usages"
  176. :key="usage"
  177. @click="handleUsageFilter(usage)"
  178. class="opacity-80 select-none text-white text-xs sm:text-sm md:text-base lg:text-lg font-normal cursor-pointer hover:opacity-100 transition-all duration-300 px-2 py-1 sm:px-2.5 sm:py-1.5 md:px-3 md:py-2 lg:px-4 lg:py-2.5 rounded-lg inline-block active:scale-95"
  179. :class="{
  180. 'opacity-100 font-bold bg-cyan-400 text-white border-0 shadow-lg scale-105 transition-all duration-300':
  181. selectedUsage === usage,
  182. 'hover:bg-zinc-800/50': selectedUsage !== usage,
  183. }"
  184. >
  185. {{ usage }}
  186. </div>
  187. </div>
  188. </div>
  189. </div>
  190. <div class="col-span-1 md:col-span-9">
  191. <div class="flex flex-col gap-8 md:gap-12 lg:gap-16">
  192. <template v-for="category in categories" :key="category">
  193. <div
  194. v-if="
  195. filteredProducts.filter((p) => {
  196. const categoryObj = allCategories.find(
  197. (c) => c.title === category
  198. );
  199. return categoryObj && p.categoryId === categoryObj.id;
  200. }).length > 0
  201. "
  202. class="flex flex-col gap-4 md:gap-6"
  203. >
  204. <div
  205. class="w-full text-white text-2xl md:text-3xl lg:text-4xl font-normal mb-4 md:mb-6"
  206. >
  207. {{ category }}
  208. </div>
  209. <div
  210. v-if="
  211. getCategoryTags(category)?.length > 0 &&
  212. selectedCategory
  213. "
  214. class="flex flex-wrap gap-4"
  215. >
  216. <span
  217. @click="selectTag('all')"
  218. class="text-white text-sm md:text-lg font-normal px-4 py-2 bg-zinc-800/50 rounded-lg cursor-pointer hover:bg-zinc-700/50 transition-all duration-300"
  219. :class="{
  220. '!bg-cyan-400 text-zinc-900 font-medium':
  221. selectedTag === 'all',
  222. }"
  223. >
  224. {{ t("common.all") }}
  225. </span>
  226. <span
  227. v-for="tag in getCategoryTags(category)"
  228. :key="tag"
  229. @click="selectTag(tag)"
  230. class="text-white text-sm md:text-lg font-normal px-4 py-2 bg-zinc-800/50 rounded-lg cursor-pointer hover:bg-zinc-700/50 transition-all duration-300"
  231. :class="{
  232. '!bg-cyan-400 text-zinc-900 font-medium':
  233. selectedTag === tag,
  234. }"
  235. >
  236. {{ tag }}
  237. </span>
  238. </div>
  239. <!-- 添加系列分组 -->
  240. <template
  241. v-for="[seriesName, seriesProducts] in Array.from(
  242. productsBySeries.entries()
  243. )"
  244. :key="seriesName"
  245. >
  246. <div
  247. v-if="
  248. seriesProducts.some((p) => {
  249. const categoryObj = allCategories.find(
  250. (c) => c.title === category
  251. );
  252. return (
  253. categoryObj &&
  254. p.categoryId === categoryObj.id &&
  255. (selectedTag === 'all' || p.tag === selectedTag)
  256. );
  257. })
  258. "
  259. class="mb-8 md:mb-12"
  260. >
  261. <div class="relative">
  262. <!-- 系列标题背景 -->
  263. <div
  264. class="absolute inset-0 bg-gradient-to-r from-cyan-900/20 to-cyan-500/20 rounded-lg transform -skew-x-6"
  265. ></div>
  266. <!-- 系列标题内容 -->
  267. <div
  268. class="relative flex items-center gap-3 md:gap-4 py-3 md:py-4 px-4 md:px-6"
  269. >
  270. <!-- 装饰线 -->
  271. <div
  272. class="w-1 h-6 md:h-8 bg-cyan-400 text-zinc-900 rounded-full"
  273. ></div>
  274. <!-- 系列名称 -->
  275. <h3
  276. class="text-white text-lg md:text-xl lg:text-2xl font-medium tracking-wide"
  277. >
  278. {{ seriesName }}
  279. </h3>
  280. <!-- 产品数量标签 -->
  281. <div
  282. class="ml-auto px-2 py-0.5 md:px-3 md:py-1 bg-cyan-500/20 rounded-full text-cyan-300 text-xs md:text-sm"
  283. >
  284. {{ seriesProducts.length }}
  285. {{ t("products.product_count") }}
  286. </div>
  287. </div>
  288. </div>
  289. <!-- 产品网格 -->
  290. <div class="mt-4 md:mt-6">
  291. <transition-group
  292. name="fade"
  293. tag="div"
  294. class="w-full grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 gap-3 md:gap-4"
  295. >
  296. <nuxt-link
  297. v-for="product in seriesProducts.filter((p) => {
  298. const categoryObj = allCategories.find(
  299. (c) => c.title === category
  300. );
  301. return (
  302. categoryObj &&
  303. p.categoryId === categoryObj.id &&
  304. (selectedTag === 'all' ||
  305. p.tag === selectedTag)
  306. );
  307. })"
  308. :key="product.id"
  309. :to="`${homepagePath}/products/${product.name}`"
  310. class="group bg-zinc-900 rounded-lg transition-all duration-300 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg hover:ring-2 hover:ring-cyan-400 relative overflow-hidden"
  311. >
  312. <div class="w-full p-4 md:p-6 lg:p-8">
  313. <div
  314. class="relative w-full aspect-square mb-3 md:mb-4"
  315. >
  316. <img
  317. v-if="!isImageError(product.id)"
  318. :src="product.image"
  319. :alt="product.name"
  320. class="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-110"
  321. @error="handleImageError($event, product)"
  322. @load="handleImageLoad($event, product)"
  323. loading="lazy"
  324. />
  325. <div
  326. v-if="isImageLoading(product.id)"
  327. class="absolute inset-0 flex items-center justify-center bg-zinc-800/50 rounded-lg"
  328. >
  329. <div
  330. class="animate-spin h-6 w-6 md:h-8 md:w-8 border-4 border-cyan-500 rounded-full border-t-transparent"
  331. ></div>
  332. </div>
  333. <div
  334. v-if="
  335. !product.image ||
  336. product.image === '' ||
  337. isImageError(product.id)
  338. "
  339. class="absolute inset-0 flex items-center justify-center bg-zinc-800/50 rounded-lg"
  340. >
  341. <svg
  342. xmlns="http://www.w3.org/2000/svg"
  343. class="h-12 w-12 md:h-16 md:w-16 text-white/40"
  344. fill="none"
  345. viewBox="0 0 24 24"
  346. stroke="currentColor"
  347. >
  348. <path
  349. stroke-linecap="round"
  350. stroke-linejoin="round"
  351. stroke-width="2"
  352. d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
  353. />
  354. </svg>
  355. </div>
  356. </div>
  357. <div
  358. class="text-center text-white text-base md:text-lg lg:text-xl font-normal mb-2"
  359. >
  360. {{ product.name }}
  361. </div>
  362. <div
  363. class="text-center text-[#71717A] text-sm md:text-base font-normal leading-normal"
  364. >
  365. {{ product.capacities.join(" / ") }}
  366. </div>
  367. <!-- Summary 悬浮层 -->
  368. <div
  369. class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-end"
  370. >
  371. <div class="p-4 md:p-6 w-full">
  372. <div
  373. class="text-white text-sm md:text-base line-clamp-3"
  374. >
  375. {{ product.summary }}
  376. </div>
  377. </div>
  378. </div>
  379. </div>
  380. </nuxt-link>
  381. </transition-group>
  382. </div>
  383. </div>
  384. </template>
  385. <!-- 展示没有系列的产品 -->
  386. <transition-group
  387. v-if="
  388. filteredProducts.filter((p) => {
  389. const categoryObj = allCategories.find(
  390. (c) => c.title === category
  391. );
  392. return (
  393. categoryObj &&
  394. p.categoryId === categoryObj.id &&
  395. (!p.series || p.series.length === 0) &&
  396. (selectedTag === 'all' || p.tag === selectedTag)
  397. );
  398. }).length > 0
  399. "
  400. name="fade"
  401. tag="div"
  402. class="w-full grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 gap-3 md:gap-4"
  403. >
  404. <nuxt-link
  405. v-for="product in filteredProducts.filter((p) => {
  406. const categoryObj = allCategories.find(
  407. (c) => c.title === category
  408. );
  409. return (
  410. categoryObj &&
  411. p.categoryId === categoryObj.id &&
  412. (!p.series || p.series.length === 0) &&
  413. (selectedTag === 'all' || p.tag === selectedTag)
  414. );
  415. })"
  416. :key="product.id"
  417. :to="`${homepagePath}/products/${product.name}`"
  418. class="group bg-zinc-900 rounded-lg transition-all duration-300 hover:bg-zinc-800 hover:shadow-lg hover:ring-2 hover:ring-cyan-400 relative overflow-hidden"
  419. >
  420. <div class="w-full p-4 md:p-6 lg:p-8">
  421. <div
  422. class="relative w-full aspect-square mb-3 md:mb-4"
  423. >
  424. <img
  425. v-if="!isImageError(product.id)"
  426. :src="product.image"
  427. :alt="product.name"
  428. class="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-110"
  429. @error="handleImageError($event, product)"
  430. @load="handleImageLoad($event, product)"
  431. loading="lazy"
  432. />
  433. <div
  434. v-if="isImageLoading(product.id)"
  435. class="absolute inset-0 flex items-center justify-center bg-zinc-800/50 rounded-lg"
  436. >
  437. <div
  438. class="animate-spin h-6 w-6 md:h-8 md:w-8 border-4 border-cyan-500 rounded-full border-t-transparent"
  439. ></div>
  440. </div>
  441. <div
  442. v-if="
  443. !product.image ||
  444. product.image === '' ||
  445. isImageError(product.id)
  446. "
  447. class="absolute inset-0 flex items-center justify-center bg-zinc-800/50 rounded-lg"
  448. >
  449. <svg
  450. xmlns="http://www.w3.org/2000/svg"
  451. class="h-12 w-12 md:h-16 md:w-16 text-white/40"
  452. fill="none"
  453. viewBox="0 0 24 24"
  454. stroke="currentColor"
  455. >
  456. <path
  457. stroke-linecap="round"
  458. stroke-linejoin="round"
  459. stroke-width="2"
  460. d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
  461. />
  462. </svg>
  463. </div>
  464. </div>
  465. <div
  466. class="text-center text-white text-base md:text-lg lg:text-xl font-normal mb-2"
  467. >
  468. {{ product.name }}
  469. </div>
  470. <div
  471. class="text-center text-[#71717A] text-sm md:text-base font-normal leading-normal"
  472. >
  473. {{ product.capacities.join(" / ") }}
  474. </div>
  475. <!-- Summary 悬浮层 -->
  476. <div
  477. class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-end"
  478. >
  479. <div class="p-4 md:p-6 w-full">
  480. <div
  481. class="text-white text-sm md:text-base line-clamp-3"
  482. >
  483. {{ product.summary }}
  484. </div>
  485. </div>
  486. </div>
  487. </div>
  488. </nuxt-link>
  489. </transition-group>
  490. </div>
  491. </template>
  492. </div>
  493. </div>
  494. </div>
  495. </div>
  496. </div>
  497. </div>
  498. </ErrorBoundary>
  499. </div>
  500. </template>
  501. <script setup lang="ts">
  502. /**
  503. * 产品列表页面
  504. * 展示所有产品,支持按分类和用途筛选
  505. */
  506. import { useErrorHandler } from "~/composables/useErrorHandler";
  507. import banner from "@/assets/images/product-banner.webp";
  508. import { useI18n } from "vue-i18n";
  509. import { queryCollection } from "#imports";
  510. import { useRoute, useRouter } from "vue-router";
  511. import { useDebounceFn } from "@vueuse/core";
  512. const { t, locale } = useI18n();
  513. const route = useRoute();
  514. const router = useRouter();
  515. const homepagePath = computed(() => {
  516. return locale.value === "zh" ? "" : `/${locale.value}`;
  517. });
  518. // 产品接口定义
  519. interface Product {
  520. id: number;
  521. name: string;
  522. title: string;
  523. categoryId: number;
  524. usage: string[];
  525. series: string[];
  526. capacities: string[];
  527. image: string;
  528. description?: string;
  529. summary: string;
  530. gallery: string[];
  531. sort: number;
  532. tag: string;
  533. }
  534. interface Category {
  535. id: number;
  536. title: string;
  537. description?: string;
  538. image: string;
  539. summary: string;
  540. capacities: string[];
  541. sort: number;
  542. tags: string[];
  543. audiences: number;
  544. }
  545. // 分页配置
  546. const PAGE_SIZE = 100;
  547. const currentPage = ref(1);
  548. const totalPages = ref(1);
  549. // 使用防抖优化路由更新
  550. const debouncedRouterPush = useDebounceFn(
  551. (query: Record<string, string | undefined>) => {
  552. router.push({ query });
  553. },
  554. 300
  555. );
  556. const { error, isLoading, wrapAsync } = useErrorHandler();
  557. const allProducts = ref<Product[]>([]);
  558. const allCategories = ref<Category[]>([]);
  559. const filteredProducts = ref<Product[]>([]);
  560. const categories = ref<string[]>([]);
  561. const usages = ref<string[]>([]);
  562. const selectedCategory = ref(route.query.category?.toString() || "");
  563. const selectedUsage = ref(route.query.usage?.toString() || "");
  564. const selectedTag = ref(route.query.tag?.toString() || "all");
  565. const getCategoryTags = (category: string) => {
  566. const categoryObj = allCategories.value.find((c) => c.title === category);
  567. return categoryObj?.tags || [];
  568. };
  569. // 使用缓存优化数据加载
  570. const { data: productData } = await useAsyncData("products", async () => {
  571. try {
  572. const content = await queryCollection("content").all();
  573. const products = content
  574. .filter((item: any) => {
  575. const isProduct =
  576. item.path?.includes("/products/") &&
  577. !item.path?.includes("/categories/") &&
  578. item.path?.includes(`/${locale.value}/`);
  579. return isProduct;
  580. })
  581. .map((item: any, index: number) => {
  582. const meta = item.meta || item.frontmatter || {};
  583. const pathId = item.path?.match(/\/products\/(\d+)/)?.[1];
  584. return {
  585. id: pathId ? parseInt(pathId) : index + 1,
  586. name: meta.name || item.title || "",
  587. title: item.title || "",
  588. categoryId: parseInt(meta.categoryId?.toString() || "0"),
  589. usage: meta.usage || [],
  590. series: meta.series || [],
  591. capacities: meta.capacities || [],
  592. image: meta.image || "",
  593. description: meta.description || "",
  594. summary: meta.summary || "",
  595. gallery: meta.gallery || [],
  596. sort: meta.sort || 0,
  597. tag: meta.tag || "",
  598. };
  599. })
  600. .sort((a, b) => a.sort - b.sort);
  601. // 计算总页数
  602. totalPages.value = Math.ceil(products.length / PAGE_SIZE);
  603. return products;
  604. } catch (error) {
  605. console.error("Error loading product content:", error);
  606. return [];
  607. }
  608. });
  609. // 使用缓存优化类别数据加载
  610. const { data: categoryData } = await useAsyncData("categories", async () => {
  611. try {
  612. const content = await queryCollection("content").all();
  613. return content
  614. .filter((item: any) => {
  615. const isCategory =
  616. item.path?.includes("/categories/") &&
  617. item.path?.includes(`/${locale.value}/`);
  618. return isCategory;
  619. })
  620. .map((item: any, index: number) => {
  621. const meta = item.meta || item.frontmatter || {};
  622. const pathId = item.path?.match(/\/categories\/(\d+)/)?.[1];
  623. return {
  624. id: pathId ? parseInt(pathId) : index + 1,
  625. title: meta.title || item.title || "",
  626. description: meta.description || "",
  627. image: meta.image || "",
  628. summary: meta.summary || "",
  629. capacities: meta.capacities || [],
  630. sort: meta.sort || 0,
  631. tags: meta.tags || [],
  632. audiences: meta.audiences,
  633. };
  634. })
  635. .sort((a, b) => a.sort - b.sort);
  636. } catch (error) {
  637. console.error("Error loading category content:", error);
  638. return [];
  639. }
  640. });
  641. // 使用计算属性优化数据过滤
  642. const filteredProductsByCategory = computed(() => {
  643. if (!selectedCategory.value) return allProducts.value;
  644. const category = allCategories.value.find(
  645. (c: Category) => c.title === selectedCategory.value
  646. );
  647. if (!category) return allProducts.value;
  648. return allProducts.value.filter((p: Product) => p.categoryId === category.id);
  649. });
  650. const filteredProductsByUsage = computed(() => {
  651. if (!selectedUsage.value) return filteredProductsByCategory.value;
  652. return filteredProductsByCategory.value.filter((p: Product) =>
  653. p.usage.includes(selectedUsage.value)
  654. );
  655. });
  656. // 分页后的产品列表
  657. const paginatedProducts = computed(() => {
  658. const start = (currentPage.value - 1) * PAGE_SIZE;
  659. const end = start + PAGE_SIZE;
  660. return filteredProductsByUsage.value.slice(start, end);
  661. });
  662. // 使用计算属性优化用途列表
  663. const uniqueUsages = computed(() => {
  664. const usageSet = new Set<string>();
  665. allProducts.value.forEach((product: Product) => {
  666. product.usage.forEach((u: string) => usageSet.add(u));
  667. });
  668. return Array.from(usageSet);
  669. });
  670. // 使用计算属性优化类别列表
  671. const categoryTitles = computed(() => {
  672. if (route.query.audiences) {
  673. return allCategories.value
  674. .filter(
  675. (c: Category) =>
  676. c.audiences === parseInt(route.query.audiences as string)
  677. )
  678. .map((c: Category) => c.title);
  679. }
  680. return allCategories.value.map((c: Category) => c.title);
  681. });
  682. // 使用计算属性优化系列分组
  683. const productsBySeries = computed(() => {
  684. const seriesMap = new Map<string, Product[]>();
  685. filteredProducts.value.forEach((product: Product) => {
  686. if (product.series && product.series.length > 0) {
  687. const series = product.series[0]; // 取第一个系列作为分组依据
  688. if (!seriesMap.has(series)) {
  689. seriesMap.set(series, []);
  690. }
  691. seriesMap.get(series)?.push(product);
  692. }
  693. });
  694. return seriesMap;
  695. });
  696. // 处理数据变化
  697. watchEffect(() => {
  698. if (!productData.value || !categoryData.value) return;
  699. isLoading.value = true;
  700. try {
  701. allProducts.value = productData.value;
  702. allCategories.value = categoryData.value;
  703. usages.value = uniqueUsages.value;
  704. categories.value = categoryTitles.value;
  705. filteredProducts.value = paginatedProducts.value;
  706. // 验证URL参数中的分类和标签是否有效
  707. if (selectedCategory.value) {
  708. const categoryExists = allCategories.value.some(
  709. (c: Category) => c.title === selectedCategory.value
  710. );
  711. if (!categoryExists) {
  712. // 如果分类不存在于当前语言环境,清除分类和标签参数
  713. selectedCategory.value = "";
  714. selectedTag.value = "all";
  715. // 更新路由,移除无效的参数
  716. router.replace({
  717. query: {
  718. ...route.query,
  719. category: undefined,
  720. tag: undefined,
  721. },
  722. });
  723. } else if (selectedTag.value !== "all") {
  724. // 检查标签是否在当前分类中存在
  725. const categoryTags = getCategoryTags(selectedCategory.value);
  726. if (!categoryTags.includes(selectedTag.value)) {
  727. selectedTag.value = "all";
  728. // 更新路由,移除无效的标签参数
  729. router.replace({
  730. query: {
  731. ...route.query,
  732. tag: undefined,
  733. },
  734. });
  735. }
  736. }
  737. }
  738. } catch (err) {
  739. console.error("Error processing data:", err);
  740. error.value = new Error(t("products.processError"));
  741. } finally {
  742. isLoading.value = false;
  743. }
  744. });
  745. /**
  746. * 处理分类筛选
  747. */
  748. function handleCategoryFilter(category: string) {
  749. // 不能取消选择
  750. selectedCategory.value = category;
  751. // 切换分类时,重置tag选择为'all'
  752. selectedTag.value = "all";
  753. currentPage.value = 1; // 重置页码
  754. filteredProducts.value = paginatedProducts.value;
  755. const categoryObj = allCategories.value.find(
  756. (c: Category) => c.title === category
  757. );
  758. // 更新路由,移除tag参数
  759. router.push({
  760. query: {
  761. ...route.query,
  762. category: category,
  763. tag: undefined, // 清除tag参数
  764. page: currentPage.value > 1 ? currentPage.value.toString() : undefined,
  765. audiences: categoryObj?.audiences,
  766. },
  767. });
  768. }
  769. /**
  770. * 处理用途筛选
  771. */
  772. function handleUsageFilter(usage: string) {
  773. selectedUsage.value = selectedUsage.value === usage ? "" : usage;
  774. currentPage.value = 1; // 重置页码
  775. filteredProducts.value = paginatedProducts.value;
  776. }
  777. /**
  778. * 处理页码变化
  779. */
  780. function handlePageChange(page: number) {
  781. currentPage.value = page;
  782. filteredProducts.value = paginatedProducts.value;
  783. }
  784. // 监听分类变化 - 注释掉这个监听,防止干扰直接路由更新
  785. /*
  786. watch(selectedCategory, (newValue) => {
  787. debouncedRouterPush({
  788. ...route.query,
  789. category: newValue || undefined,
  790. });
  791. });
  792. */
  793. // 监听用途变化
  794. watch(selectedUsage, (newValue) => {
  795. debouncedRouterPush({
  796. ...route.query,
  797. usage: newValue || undefined,
  798. });
  799. });
  800. // 监听路由变化
  801. watch(
  802. () => route.query,
  803. (newQuery) => {
  804. // 先保存当前的值,方便后续比较变化
  805. const prevCategory = selectedCategory.value;
  806. const prevTag = selectedTag.value;
  807. // 更新本地状态
  808. selectedCategory.value = newQuery.category?.toString() || "";
  809. selectedUsage.value = newQuery.usage?.toString() || "";
  810. selectedTag.value = newQuery.tag?.toString() || "all";
  811. const page = parseInt(newQuery.page?.toString() || "1");
  812. if (page !== currentPage.value) {
  813. currentPage.value = page;
  814. }
  815. // 如果数据已加载完成,则验证URL参数的有效性
  816. if (allCategories.value.length > 0 && allProducts.value.length > 0) {
  817. // 检查分类是否有效
  818. if (selectedCategory.value && prevCategory !== selectedCategory.value) {
  819. const categoryExists = allCategories.value.some(
  820. (c: Category) => c.title === selectedCategory.value
  821. );
  822. if (!categoryExists) {
  823. // 如果分类不存在,重置选择并更新路由
  824. selectedCategory.value = "";
  825. selectedTag.value = "all";
  826. // 使用replace而不是push,避免在历史记录中添加新条目
  827. router.replace({
  828. query: {
  829. ...route.query,
  830. category: undefined,
  831. tag: undefined,
  832. },
  833. });
  834. return; // 提前退出,因为后续更新将由新的路由触发
  835. }
  836. }
  837. // 检查标签是否有效(仅当有选中分类时)
  838. if (
  839. selectedCategory.value &&
  840. selectedTag.value !== "all" &&
  841. prevTag !== selectedTag.value
  842. ) {
  843. const categoryTags = getCategoryTags(selectedCategory.value);
  844. if (!categoryTags.includes(selectedTag.value)) {
  845. selectedTag.value = "all";
  846. router.replace({
  847. query: {
  848. ...route.query,
  849. tag: undefined,
  850. },
  851. });
  852. }
  853. }
  854. }
  855. },
  856. { deep: true }
  857. );
  858. // 重置筛选条件
  859. const resetFilters = () => {
  860. selectedCategory.value = "";
  861. selectedUsage.value = "";
  862. selectedTag.value = "all";
  863. currentPage.value = 1;
  864. debouncedRouterPush({});
  865. };
  866. // SEO优化
  867. useHead({
  868. title: `${t("products.title")} - Hanye`,
  869. meta: [
  870. {
  871. name: "description",
  872. content: `${t("products.description")}`,
  873. },
  874. ],
  875. });
  876. const fallbackImage = "/images/placeholder.jpg";
  877. // 图片加载状态管理
  878. const loadingImages = ref<Set<number>>(new Set());
  879. const errorImages = ref<Set<number>>(new Set());
  880. // 检查图片是否正在加载
  881. const isImageLoading = (productId: number) => {
  882. return loadingImages.value.has(productId);
  883. };
  884. // 检查图片是否加载错误
  885. const isImageError = (productId: number) => {
  886. return errorImages.value.has(productId);
  887. };
  888. // 处理图片加载开始
  889. const handleImageLoadStart = (productId: number) => {
  890. loadingImages.value.add(productId);
  891. errorImages.value.delete(productId);
  892. };
  893. // 处理图片加载完成
  894. const handleImageLoad = (event: Event, product: Product) => {
  895. loadingImages.value.delete(product.id);
  896. errorImages.value.delete(product.id);
  897. };
  898. // 处理图片加载错误
  899. const handleImageError = (event: Event, product: Product) => {
  900. console.warn(`Image load failed for product: ${product.title}`);
  901. if (event.target instanceof HTMLImageElement) {
  902. loadingImages.value.delete(product.id);
  903. errorImages.value.add(product.id);
  904. }
  905. };
  906. // 清除分类筛选 (已经隐藏按钮)
  907. function clearCategory() {
  908. selectedCategory.value = "";
  909. currentPage.value = 1;
  910. filteredProducts.value = paginatedProducts.value;
  911. debouncedRouterPush({
  912. ...route.query,
  913. category: undefined,
  914. });
  915. }
  916. // 清除用途筛选
  917. const clearUsage = () => {
  918. selectedUsage.value = "";
  919. currentPage.value = 1;
  920. filteredProducts.value = paginatedProducts.value;
  921. debouncedRouterPush({
  922. ...route.query,
  923. usage: undefined,
  924. });
  925. };
  926. // 选择标签进行筛选
  927. function selectTag(tag: string) {
  928. selectedTag.value = tag;
  929. // 更新路由查询参数
  930. router.push({
  931. query: {
  932. ...route.query,
  933. tag: tag === "all" ? undefined : tag,
  934. },
  935. });
  936. }
  937. // 组件卸载时清理资源
  938. onBeforeUnmount(() => {
  939. allProducts.value = [];
  940. allCategories.value = [];
  941. filteredProducts.value = [];
  942. categories.value = [];
  943. usages.value = [];
  944. loadingImages.value.clear();
  945. errorImages.value.clear();
  946. });
  947. </script>
  948. <style scoped>
  949. .fade-enter-active,
  950. .fade-leave-active {
  951. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  952. }
  953. .fade-enter-from,
  954. .fade-leave-to {
  955. opacity: 0;
  956. transform: translateY(20px);
  957. }
  958. /* 系列标题悬停效果 */
  959. .relative:hover .bg-gradient-to-r {
  960. @apply from-cyan-900/30 to-cyan-500/30;
  961. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  962. }
  963. /* 产品数量标签悬停效果 */
  964. .relative:hover .bg-cyan-500\/20 {
  965. @apply bg-cyan-500/30;
  966. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  967. }
  968. /* 装饰线动画效果 */
  969. .relative:hover .w-1 {
  970. @apply h-10;
  971. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  972. }
  973. .products-page {
  974. min-height: 100vh;
  975. background-color: #f9fafb;
  976. }
  977. /* 添加过渡动画 */
  978. .bg-primary {
  979. @apply bg-cyan-600;
  980. }
  981. button {
  982. transition: all 0.3s ease;
  983. }
  984. .rounded-full {
  985. @apply transition-all duration-300 ease-in-out;
  986. }
  987. .rounded-full:hover {
  988. @apply bg-gray-200;
  989. }
  990. /* 添加清除按钮悬停效果 */
  991. button:hover {
  992. transform: translateY(-1px);
  993. }
  994. /* 添加图片容器过渡效果 */
  995. .aspect-square {
  996. transition: all 0.3s ease;
  997. background-color: rgb(39, 39, 42); /* 添加背景色 */
  998. }
  999. .aspect-square:hover {
  1000. transform: scale(1.02);
  1001. }
  1002. /* 加载动画优化 */
  1003. .animate-spin {
  1004. animation: spin 1s linear infinite;
  1005. }
  1006. @keyframes spin {
  1007. from {
  1008. transform: rotate(0deg);
  1009. }
  1010. to {
  1011. transform: rotate(360deg);
  1012. }
  1013. }
  1014. /* 响应式间距调整 */
  1015. @media (max-width: 640px) {
  1016. .gap-4 {
  1017. gap: 0.75rem;
  1018. }
  1019. .p-4 {
  1020. padding: 0.75rem;
  1021. }
  1022. }
  1023. @media (min-width: 768px) {
  1024. .gap-4 {
  1025. gap: 1rem;
  1026. }
  1027. .p-4 {
  1028. padding: 1rem;
  1029. }
  1030. }
  1031. @media (min-width: 1024px) {
  1032. .gap-4 {
  1033. gap: 1.25rem;
  1034. }
  1035. .p-4 {
  1036. padding: 1.5rem;
  1037. }
  1038. }
  1039. /* 添加新的样式 */
  1040. .line-clamp-3 {
  1041. display: -webkit-box;
  1042. -webkit-line-clamp: 3;
  1043. -webkit-box-orient: vertical;
  1044. overflow: hidden;
  1045. }
  1046. /* 优化过渡动画 */
  1047. .group {
  1048. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  1049. }
  1050. .group:hover {
  1051. transform: translateY(-4px);
  1052. }
  1053. /* 图片缩放效果 */
  1054. .group img {
  1055. transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
  1056. }
  1057. /* Summary 悬浮层动画 */
  1058. .group .absolute {
  1059. transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  1060. }
  1061. /* 确保文字在悬浮层上清晰可见 */
  1062. .group .text-white {
  1063. text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
  1064. }
  1065. /* 响应式调整 */
  1066. @media (max-width: 640px) {
  1067. .group .absolute {
  1068. padding: 0.75rem;
  1069. }
  1070. }
  1071. @media (min-width: 768px) {
  1072. .group .absolute {
  1073. padding: 1rem;
  1074. }
  1075. }
  1076. @media (min-width: 1024px) {
  1077. .group .absolute {
  1078. padding: 1.5rem;
  1079. }
  1080. }
  1081. /* 导航栏样式优化 */
  1082. .bg-zinc-900\/50 {
  1083. backdrop-filter: blur(8px);
  1084. -webkit-backdrop-filter: blur(8px);
  1085. }
  1086. /* 导航项悬停效果 */
  1087. .group:hover {
  1088. transform: translateX(4px);
  1089. }
  1090. /* 清除按钮动画 */
  1091. button {
  1092. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  1093. }
  1094. button:hover {
  1095. transform: translateY(-2px);
  1096. box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
  1097. 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  1098. }
  1099. button:active {
  1100. transform: translateY(0);
  1101. }
  1102. /* 响应式调整 */
  1103. @media (max-width: 768px) {
  1104. .bg-zinc-900\/50 {
  1105. padding: 1rem;
  1106. }
  1107. .group:hover {
  1108. transform: translateX(2px);
  1109. }
  1110. }
  1111. @media (min-width: 768px) {
  1112. .bg-zinc-900\/50 {
  1113. padding: 1.5rem;
  1114. }
  1115. }
  1116. @media (min-width: 1024px) {
  1117. .bg-zinc-900\/50 {
  1118. padding: 2rem;
  1119. }
  1120. }
  1121. /* 优化点击反馈 */
  1122. .active\:scale-95:active {
  1123. transform: scale(0.95);
  1124. }
  1125. /* 优化过渡动画 */
  1126. .transition-all {
  1127. transition-property: all;
  1128. transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  1129. transition-duration: 150ms;
  1130. }
  1131. /* 优化横向滚动 */
  1132. .overflow-x-auto {
  1133. -webkit-overflow-scrolling: touch;
  1134. scrollbar-width: none; /* Firefox */
  1135. -ms-overflow-style: none; /* IE and Edge */
  1136. }
  1137. .overflow-x-auto::-webkit-scrollbar {
  1138. display: none; /* Chrome, Safari, Opera */
  1139. }
  1140. /* 响应式断点说明:
  1141. sm: 640px
  1142. md: 768px
  1143. lg: 1024px
  1144. xl: 1280px
  1145. 2xl: 1536px
  1146. */
  1147. </style>