Hanye官网
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

faq.vue 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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")
  21. }}</nuxt-link>
  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 categoriesList"
  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
  72. xmlns="http://www.w3.org/2000/svg"
  73. class="h-5 w-5"
  74. fill="none"
  75. viewBox="0 0 24 24"
  76. stroke="currentColor"
  77. stroke-width="2"
  78. >
  79. <path
  80. stroke-linecap="round"
  81. stroke-linejoin="round"
  82. d="M6 18L18 6M6 6l12 12"
  83. />
  84. </svg>
  85. </button>
  86. </div>
  87. <div class="flex flex-col gap-8">
  88. <div
  89. v-for="faq in filteredFaqs"
  90. :key="generateFaqKey(faq)"
  91. class="bg-zinc-900 rounded-lg p-8 transition-all duration-300 hover:bg-zinc-800 hover:shadow-lg"
  92. >
  93. <div
  94. class="flex items-center justify-between cursor-pointer"
  95. @click="toggleFaq(faq)"
  96. >
  97. <div class="text-white text-xl font-medium">
  98. <template
  99. v-for="(part, i) in highlightKeyword(faq.question)"
  100. :key="i"
  101. >
  102. <span v-if="typeof part === 'string'">{{
  103. part
  104. }}</span>
  105. <component v-else :is="part"></component>
  106. </template>
  107. </div>
  108. <div
  109. class="text-white text-2xl transition-transform duration-300"
  110. :class="{ 'rotate-180': isFaqExpanded(faq) }"
  111. >
  112. </div>
  113. </div>
  114. <div
  115. v-if="isFaqExpanded(faq)"
  116. class="mt-4 text-white/80 text-base font-normal leading-relaxed"
  117. >
  118. <template
  119. v-for="(part, i) in highlightKeyword(faq.answer)"
  120. :key="i"
  121. >
  122. <span v-if="typeof part === 'string'">{{ part }}</span>
  123. <component v-else :is="part"></component>
  124. </template>
  125. </div>
  126. </div>
  127. <div
  128. v-if="filteredFaqs.length === 0"
  129. class="text-center text-gray-400 py-8"
  130. >
  131. {{ t("faq.noResults") }}
  132. </div>
  133. </div>
  134. </div>
  135. </div>
  136. </div>
  137. </div>
  138. </div>
  139. </ErrorBoundary>
  140. </div>
  141. </template>
  142. <script setup lang="ts">
  143. /**
  144. * FAQ页面
  145. * 展示常见问题及其答案
  146. */
  147. import { useErrorHandler } from "~/composables/useErrorHandler";
  148. import { queryCollection } from '#imports'
  149. const { error, isLoading, wrapAsync } = useErrorHandler();
  150. const { t, locale } = useI18n();
  151. // FAQ数据
  152. interface FAQ {
  153. id: number;
  154. category: string;
  155. question: string;
  156. answer: string;
  157. title: string;
  158. description: string;
  159. sort: number;
  160. }
  161. // 从content目录读取FAQ数据
  162. const faqs = ref<FAQ[]>([]);
  163. const categoriesList = ref<string[]>([]);
  164. // 选中的分类
  165. const selectedCategory = ref("");
  166. // 使用 queryCollection 加载FAQ数据
  167. const { data: faqData } = await useAsyncData('faqs', async () => {
  168. console.log('Loading FAQ data for locale:', locale.value);
  169. try {
  170. // 使用 queryCollection 加载 FAQ 数据
  171. const content = await queryCollection('content').all();
  172. // 在代码中过滤内容
  173. const filteredContent = content.filter((item: any) => {
  174. // 检查路径是否包含当前语言
  175. return item._path && item._path.includes(`/faq/${locale.value}/`);
  176. });
  177. // 转换数据格式
  178. const faqs = filteredContent.map((item: any) => ({
  179. id: item._id || '',
  180. category: item.category || '',
  181. question: item.title || '',
  182. answer: item.body?.value || '',
  183. title: item.title || '',
  184. description: item.description || '',
  185. sort: item.sort || 0
  186. }));
  187. // 按 sort 字段排序
  188. return faqs.sort((a, b) => a.sort - b.sort);
  189. } catch (error) {
  190. console.error('Error loading FAQ content:', error);
  191. return [];
  192. }
  193. }, {
  194. // 确保数据在构建时生成
  195. server: true,
  196. lazy: false,
  197. immediate: true,
  198. watch: [locale]
  199. });
  200. console.log('FAQ data:', faqData.value);
  201. // 处理FAQ数据变化
  202. watchEffect(() => {
  203. if (faqData.value) {
  204. isLoading.value = true;
  205. try {
  206. console.log('Processing FAQ data:', faqData.value);
  207. // 设置分类列表和默认选中的分类
  208. const allOption: string =
  209. locale.value === "en" ? "All" : locale.value === "zh" ? "全部" : "すべて";
  210. // 从FAQ数据中提取所有不同的分类
  211. const uniqueCategories = [
  212. ...new Set(faqData.value.map((faq: FAQ) => faq.category)),
  213. ].sort(); // 对分类进行排序
  214. // 设置分类列表和默认选中的分类
  215. categoriesList.value = [allOption, ...uniqueCategories];
  216. selectedCategory.value = categoriesList.value[0];
  217. } catch (err) {
  218. console.error("Error processing FAQ data:", err);
  219. error.value = new Error(t('faq.processError'));
  220. } finally {
  221. isLoading.value = false;
  222. }
  223. }
  224. });
  225. // 展开的FAQ标识
  226. const expandedFaqKey = ref<string | null>(null);
  227. // 搜索关键词
  228. const searchTerm = ref("");
  229. const searchInputRef = ref<HTMLInputElement | null>(null);
  230. // 过滤后的FAQ列表
  231. const filteredFaqs = computed(() => {
  232. if (!faqData.value) return [];
  233. let result = faqData.value;
  234. if (selectedCategory.value !== categoriesList.value[0]) {
  235. result = result.filter(
  236. (faq: FAQ) => faq.category === selectedCategory.value
  237. );
  238. }
  239. if (searchTerm.value.trim()) {
  240. const keyword = searchTerm.value.trim().toLowerCase();
  241. result = result.filter(
  242. (faq: FAQ) =>
  243. faq.question.toLowerCase().includes(keyword) ||
  244. faq.answer.toLowerCase().includes(keyword)
  245. );
  246. }
  247. return result;
  248. });
  249. /**
  250. * 高亮显示匹配的关键字
  251. * @param text 原始文本
  252. * @returns 高亮后的VNode数组
  253. */
  254. function highlightKeyword(text: string): (string | any)[] {
  255. const keyword = searchTerm.value.trim();
  256. if (!keyword) return [text];
  257. // 构建正则,忽略大小写,转义特殊字符
  258. const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  259. const reg = new RegExp(escaped, "gi");
  260. const parts = text.split(reg);
  261. const matches = text.match(reg);
  262. if (!matches) return [text];
  263. // 组装高亮
  264. const result: (string | any)[] = [];
  265. parts.forEach((part, i) => {
  266. result.push(part);
  267. if (i < matches.length) {
  268. result.push(
  269. h(
  270. "span",
  271. { class: "text-blue-400 bg-blue-400/10 font-bold" },
  272. matches[i]
  273. )
  274. );
  275. }
  276. });
  277. return result;
  278. }
  279. /**
  280. * 生成FAQ的唯一标识
  281. * @param faq FAQ对象
  282. * @returns string 唯一标识
  283. */
  284. function generateFaqKey(faq: FAQ): string {
  285. return `${faq.category}-${faq.id}`;
  286. }
  287. /**
  288. * 切换FAQ展开状态
  289. * @param faq FAQ对象
  290. * @returns void
  291. */
  292. function toggleFaq(faq: FAQ): void {
  293. if (!faq) return;
  294. const faqKey = generateFaqKey(faq);
  295. // 如果点击的是当前展开的FAQ,则关闭它
  296. if (expandedFaqKey.value === faqKey) {
  297. expandedFaqKey.value = null;
  298. } else {
  299. // 否则展开新的FAQ
  300. expandedFaqKey.value = faqKey;
  301. }
  302. }
  303. /**
  304. * 检查FAQ是否处于展开状态
  305. * @param faq FAQ对象
  306. * @returns boolean
  307. */
  308. function isFaqExpanded(faq: FAQ): boolean {
  309. if (!faq) return false;
  310. return expandedFaqKey.value === generateFaqKey(faq);
  311. }
  312. /**
  313. * 处理分类筛选
  314. */
  315. function handleCategoryFilter(category: string) {
  316. selectedCategory.value = category;
  317. }
  318. // 自动展开匹配项
  319. watch([filteredFaqs, searchTerm], ([faqs, keyword]: [FAQ[], string]) => {
  320. if (!faqs?.length) return;
  321. if (keyword.trim()) {
  322. // 搜索时展开第一个匹配项
  323. expandedFaqKey.value = faqs[0] ? generateFaqKey(faqs[0]) : null;
  324. } else {
  325. // 清除搜索时关闭所有展开项
  326. expandedFaqKey.value = null;
  327. }
  328. }, { deep: true });
  329. function clearSearch() {
  330. searchTerm.value = "";
  331. // 让输入框重新聚焦
  332. searchInputRef.value?.focus();
  333. }
  334. // SEO优化
  335. useHead({
  336. title: t("faq.title") + " - Hanye",
  337. meta: [
  338. {
  339. name: "description",
  340. content: t("faq.description"),
  341. },
  342. {
  343. name: "keywords",
  344. content: t("faq.keywords"),
  345. },
  346. ],
  347. });
  348. </script>
  349. <style scoped>
  350. /* 添加过渡动画 */
  351. .fade-enter-active,
  352. .fade-leave-active {
  353. transition: opacity 0.3s;
  354. }
  355. .fade-enter-from,
  356. .fade-leave-to {
  357. opacity: 0;
  358. }
  359. </style>