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.

index.vue 42KB

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