Hanye官网
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

faq.vue 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. <template>
  2. <div>
  3. <div class="w-full h-[55px] sm:h-[72px]"></div>
  4. <ErrorBoundary :error="error">
  5. <div v-if="isLoading" class="flex justify-center py-12">
  6. <div
  7. class="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"
  8. ></div>
  9. </div>
  10. <div v-else>
  11. <div class="max-w-full xl:px-2 lg:px-2 md:px-4 px-4 mt-6 mb-20">
  12. <div class="max-w-screen-2xl mx-auto">
  13. <nuxt-link
  14. to="/"
  15. class="justify-start text-white/60 text-base font-normal"
  16. >{{ $t("common.home") }}</nuxt-link
  17. >
  18. <span class="text-white/60 text-base font-normal px-2"> / </span>
  19. <nuxt-link to="/faq" class="text-white text-base font-normal"
  20. >{{ $t("faq.title") }}</nuxt-link
  21. >
  22. </div>
  23. </div>
  24. <div
  25. class="max-w-full mb-12 md:mb-20 lg:mb-32 xl:px-2 lg:px-2 md:px-4 px-4"
  26. >
  27. <div class="max-w-screen-2xl mx-auto">
  28. <div class="w-full grid grid-cols-1 md:grid-cols-10 gap-8 md:gap-2">
  29. <!-- 左侧分类导航 -->
  30. <div class="col-span-1 md:col-span-2">
  31. <div class="flex flex-col gap-4">
  32. <div class="text-white text-3xl font-medium">
  33. {{ $t("faq.category") }}
  34. </div>
  35. <div class="flex flex-col gap-4 w-fit">
  36. <div
  37. v-for="category in categories"
  38. :key="category"
  39. @click="handleCategoryFilter(category)"
  40. class="opacity-80 justify-start text-white text-base font-normal cursor-pointer hover:opacity-100 transition-all duration-300 px-4 py-2 rounded-lg inline-block"
  41. :class="{
  42. 'opacity-100 font-bold bg-gradient-to-r from-blue-700 to-blue-400 text-white border-0 shadow-lg scale-105 transition-all duration-300':
  43. selectedCategory === category,
  44. 'hover:bg-zinc-800/50': selectedCategory !== category,
  45. }"
  46. >
  47. {{ category }}
  48. </div>
  49. </div>
  50. </div>
  51. </div>
  52. <!-- 右侧FAQ列表 -->
  53. <div class="col-span-1 md:col-span-8">
  54. <!-- 搜索框 -->
  55. <div class="mb-8 relative">
  56. <input
  57. v-model="searchTerm"
  58. ref="searchInputRef"
  59. type="search"
  60. :placeholder="$t('faq.searchPlaceholder')"
  61. class="block w-full appearance-none rounded-lg border border-gray-600 bg-zinc-800/70 px-4 py-3 text-base text-gray-100 placeholder-gray-400 shadow-inner transition duration-200 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:ring-offset-2 focus:ring-offset-zinc-900"
  62. />
  63. <button
  64. v-if="searchTerm"
  65. @click="clearSearch"
  66. class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full bg-zinc-700/80 hover:bg-blue-500/90 text-gray-300 hover:text-white transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
  67. :aria-label="$t('faq.clearSearch')"
  68. tabindex="0"
  69. type="button"
  70. >
  71. <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  72. <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
  73. </svg>
  74. </button>
  75. </div>
  76. <div class="flex flex-col gap-8">
  77. <div
  78. v-for="faq in filteredFaqs"
  79. :key="faq.id"
  80. class="bg-zinc-900 rounded-lg p-8 transition-all duration-300 hover:bg-zinc-800 hover:shadow-lg"
  81. >
  82. <div
  83. class="flex items-center justify-between cursor-pointer"
  84. @click="toggleFaq(faq.id)"
  85. >
  86. <div class="text-white text-xl font-medium">
  87. <template
  88. v-for="(part, i) in highlightKeyword(faq.question)"
  89. :key="i"
  90. >
  91. <span v-if="typeof part === 'string'">{{
  92. part
  93. }}</span>
  94. <component v-else :is="part"></component>
  95. </template>
  96. </div>
  97. <div
  98. class="text-white text-2xl transition-transform duration-300"
  99. :class="{ 'rotate-180': expandedFaqs.includes(faq.id) }"
  100. >
  101. </div>
  102. </div>
  103. <div
  104. v-if="expandedFaqs.includes(faq.id)"
  105. class="mt-4 text-white/80 text-base font-normal leading-relaxed"
  106. >
  107. <template
  108. v-for="(part, i) in highlightKeyword(faq.answer)"
  109. :key="i"
  110. >
  111. <span v-if="typeof part === 'string'">{{ part }}</span>
  112. <component v-else :is="part"></component>
  113. </template>
  114. </div>
  115. </div>
  116. <div
  117. v-if="filteredFaqs.length === 0"
  118. class="text-center text-gray-400 py-8"
  119. >
  120. {{ $t("faq.noResults") }}
  121. </div>
  122. </div>
  123. </div>
  124. </div>
  125. </div>
  126. </div>
  127. </div>
  128. </ErrorBoundary>
  129. </div>
  130. </template>
  131. <script setup lang="ts">
  132. /**
  133. * FAQ页面
  134. * 展示常见问题及其答案
  135. */
  136. import { useErrorHandler } from "~/composables/useErrorHandler";
  137. const { error, isLoading, wrapAsync } = useErrorHandler();
  138. const { t, locale } = useI18n();
  139. // FAQ数据
  140. interface FAQ {
  141. id: number;
  142. category: string;
  143. question: string;
  144. answer: string;
  145. }
  146. const faqs = ref<FAQ[]>([
  147. {
  148. id: 1,
  149. category: "製品について",
  150. question: "製品の保証期間はどのくらいですか?",
  151. answer:
  152. "当社の製品は購入日から1年間の保証期間があります。保証期間内に製品に問題が発生した場合は、無償で修理または交換いたします。",
  153. },
  154. {
  155. id: 2,
  156. category: "製品について",
  157. question: "製品の取扱説明書はどこで入手できますか?",
  158. answer:
  159. "製品の取扱説明書は、当社ウェブサイトの製品ページからダウンロードできます。また、製品パッケージにも同梱されています。",
  160. },
  161. {
  162. id: 3,
  163. category: "購入について",
  164. question: "支払い方法は何がありますか?",
  165. answer:
  166. "クレジットカード、銀行振込、コンビニ決済など、様々な支払い方法をご利用いただけます。詳細はお支払いページをご確認ください。",
  167. },
  168. {
  169. id: 4,
  170. category: "購入について",
  171. question: "返品・交換は可能ですか?",
  172. answer:
  173. "商品到着後7日以内であれば、未使用・未開封の商品に限り返品・交換が可能です。詳細は返品・交換ポリシーをご確認ください。",
  174. },
  175. {
  176. id: 5,
  177. category: "サポートについて",
  178. question: "技術サポートはどのように受けられますか?",
  179. answer:
  180. "メール、電話、オンラインチャットなど、様々な方法で技術サポートをご利用いただけます。営業時間内であれば、即時対応いたします。",
  181. },
  182. {
  183. id: 6,
  184. category: "サポートについて",
  185. question: "修理依頼はどのように行えばよいですか?",
  186. answer:
  187. "修理依頼は、当社ウェブサイトの修理依頼フォームからお申し込みください。修理状況はマイページから確認できます。",
  188. },
  189. ]);
  190. // 分类列表
  191. const categories = ref([
  192. "すべて",
  193. "製品について",
  194. "購入について",
  195. "サポートについて",
  196. ]);
  197. // 选中的分类
  198. const selectedCategory = ref("すべて");
  199. // 展开的FAQ ID列表
  200. const expandedFaqs = ref<number[]>([]);
  201. // 搜索关键词
  202. const searchTerm = ref("");
  203. const searchInputRef = ref<HTMLInputElement | null>(null);
  204. // 过滤后的FAQ列表
  205. const filteredFaqs = computed(() => {
  206. let result = faqs.value;
  207. if (selectedCategory.value !== "すべて") {
  208. result = result.filter(
  209. (faq: FAQ) => faq.category === selectedCategory.value
  210. );
  211. }
  212. if (searchTerm.value.trim()) {
  213. const keyword = searchTerm.value.trim().toLowerCase();
  214. result = result.filter(
  215. (faq: FAQ) =>
  216. faq.question.toLowerCase().includes(keyword) ||
  217. faq.answer.toLowerCase().includes(keyword)
  218. );
  219. }
  220. return result;
  221. });
  222. /**
  223. * 高亮显示匹配的关键字
  224. * @param text 原始文本
  225. * @returns 高亮后的VNode数组
  226. */
  227. function highlightKeyword(text: string): (string | any)[] {
  228. const keyword = searchTerm.value.trim();
  229. if (!keyword) return [text];
  230. // 构建正则,忽略大小写,转义特殊字符
  231. const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  232. const reg = new RegExp(escaped, "gi");
  233. const parts = text.split(reg);
  234. const matches = text.match(reg);
  235. if (!matches) return [text];
  236. // 组装高亮
  237. const result: (string | any)[] = [];
  238. parts.forEach((part, i) => {
  239. result.push(part);
  240. if (i < matches.length) {
  241. result.push(
  242. h(
  243. "span",
  244. { class: "text-blue-400 bg-blue-400/10 font-bold" },
  245. matches[i]
  246. )
  247. );
  248. }
  249. });
  250. return result;
  251. }
  252. /**
  253. * 切换FAQ展开状态
  254. */
  255. function toggleFaq(id: number) {
  256. const index = expandedFaqs.value.indexOf(id);
  257. if (index === -1) {
  258. expandedFaqs.value.push(id);
  259. } else {
  260. expandedFaqs.value.splice(index, 1);
  261. }
  262. }
  263. /**
  264. * 处理分类筛选
  265. */
  266. function handleCategoryFilter(category: string) {
  267. selectedCategory.value = category;
  268. }
  269. // 自动展开匹配项
  270. watch([
  271. filteredFaqs,
  272. searchTerm
  273. ], ([faqs, keyword]: [FAQ[], string]) => {
  274. if (keyword.trim()) {
  275. expandedFaqs.value = faqs.map((faq: FAQ) => faq.id);
  276. } else {
  277. expandedFaqs.value = [];
  278. }
  279. });
  280. function clearSearch() {
  281. searchTerm.value = "";
  282. // 让输入框重新聚焦
  283. searchInputRef.value?.focus();
  284. }
  285. // SEO优化
  286. useHead({
  287. title: t("faq.title") + " - Hanye",
  288. meta: [
  289. {
  290. name: "description",
  291. content: t("faq.description"),
  292. },
  293. {
  294. name: "keywords",
  295. content: t("faq.keywords"),
  296. },
  297. ],
  298. });
  299. </script>
  300. <style scoped>
  301. /* 添加过渡动画 */
  302. .fade-enter-active,
  303. .fade-leave-active {
  304. transition: opacity 0.3s;
  305. }
  306. .fade-enter-from,
  307. .fade-leave-to {
  308. opacity: 0;
  309. }
  310. </style>