Hanye官网
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

faq.vue 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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="`${homepagePath}/`"
  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
  20. :to="`${homepagePath}/faq`"
  21. class="text-white text-base font-normal"
  22. >{{ t("faq.title") }}</nuxt-link
  23. >
  24. </div>
  25. </div>
  26. <div
  27. class="max-w-full mb-12 md:mb-20 lg:mb-32 xl:px-2 lg:px-2 md:px-4 px-4"
  28. >
  29. <div class="max-w-screen-2xl mx-auto">
  30. <div class="w-full grid grid-cols-1 md:grid-cols-10 gap-8 md:gap-2">
  31. <!-- 左侧分类导航 -->
  32. <div class="col-span-1 md:col-span-2">
  33. <div class="flex flex-col gap-4">
  34. <div class="text-white text-3xl font-medium">
  35. {{ t("faq.category") }}
  36. </div>
  37. <div class="flex flex-col gap-4 w-fit">
  38. <div
  39. v-for="category in categoriesList"
  40. :key="category"
  41. @click="handleCategoryFilter(category)"
  42. 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"
  43. :class="{
  44. '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':
  45. selectedCategory === category,
  46. 'hover:bg-zinc-800/50': selectedCategory !== category,
  47. }"
  48. >
  49. {{ category }}
  50. </div>
  51. </div>
  52. </div>
  53. </div>
  54. <!-- 右侧FAQ列表 -->
  55. <div class="col-span-1 md:col-span-8">
  56. <!-- 搜索框 -->
  57. <div class="mb-8 relative">
  58. <input
  59. v-model="searchTerm"
  60. ref="searchInputRef"
  61. type="search"
  62. :placeholder="t('faq.searchPlaceholder')"
  63. 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"
  64. />
  65. <button
  66. v-if="searchTerm"
  67. @click="clearSearch"
  68. 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"
  69. :aria-label="t('faq.clearSearch')"
  70. tabindex="0"
  71. type="button"
  72. >
  73. <svg
  74. xmlns="http://www.w3.org/2000/svg"
  75. class="h-5 w-5"
  76. fill="none"
  77. viewBox="0 0 24 24"
  78. stroke="currentColor"
  79. stroke-width="2"
  80. >
  81. <path
  82. stroke-linecap="round"
  83. stroke-linejoin="round"
  84. d="M6 18L18 6M6 6l12 12"
  85. />
  86. </svg>
  87. </button>
  88. </div>
  89. <div class="flex flex-col gap-8">
  90. <div
  91. v-for="faq in filteredFaqs"
  92. :key="generateFaqKey(faq)"
  93. class="bg-zinc-900 rounded-lg p-8 transition-all duration-300 hover:bg-zinc-800 hover:shadow-lg"
  94. >
  95. <div
  96. class="flex items-center justify-between cursor-pointer"
  97. @click="toggleFaq(faq)"
  98. >
  99. <div class="text-white text-xl font-medium">
  100. <template
  101. v-for="(part, i) in highlightKeyword(faq.title)"
  102. :key="i"
  103. >
  104. <span v-if="typeof part === 'string'">{{
  105. part
  106. }}</span>
  107. <component v-else :is="part"></component>
  108. </template>
  109. </div>
  110. <div
  111. class="text-white text-2xl transition-transform duration-300"
  112. :class="{ 'rotate-180': isFaqExpanded(faq) }"
  113. >
  114. </div>
  115. </div>
  116. <div
  117. v-if="isFaqExpanded(faq)"
  118. class="mt-4"
  119. >
  120. <ContentRenderer
  121. class="prose w-full max-w-none"
  122. :value="{ body: faq.content }"
  123. />
  124. </div>
  125. </div>
  126. <div
  127. v-if="filteredFaqs.length === 0"
  128. class="text-center text-gray-400 py-8"
  129. >
  130. {{ t("faq.noResults") }}
  131. </div>
  132. </div>
  133. </div>
  134. </div>
  135. </div>
  136. </div>
  137. </div>
  138. </ErrorBoundary>
  139. </div>
  140. </template>
  141. <script setup lang="ts">
  142. /**
  143. * FAQ页面
  144. * 展示常见问题及其答案
  145. */
  146. import { useErrorHandler } from "~/composables/useErrorHandler";
  147. import { queryCollection } from "#imports";
  148. const { error, isLoading, wrapAsync } = useErrorHandler();
  149. const { t, locale } = useI18n();
  150. // FAQ数据
  151. interface FAQ {
  152. category: string;
  153. title: string;
  154. content: any; // 修改为 any 类型,因为可能是对象或字符串
  155. sort: number;
  156. id?: string;
  157. }
  158. const homepagePath = computed(() => {
  159. return locale.value === "zh" ? "" : `/${locale.value}`;
  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(
  168. "faqs",
  169. async () => {
  170. try {
  171. // 使用 queryCollection 加载 FAQ 数据
  172. const content = await queryCollection("content")
  173. .where("path", "LIKE", `/faq/${locale.value}/%`)
  174. .all();
  175. if (!content || !Array.isArray(content)) {
  176. console.error("No FAQ content found or invalid format:", content);
  177. return [];
  178. }
  179. // 转换数据格式
  180. const faqItems = content.map((item: any) => {
  181. return {
  182. category: item.meta?.category || "",
  183. title: item.title || "",
  184. content: item.body || "",
  185. sort: item.meta?.sort || 0,
  186. };
  187. });
  188. return faqItems.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. );
  201. // 处理FAQ数据变化
  202. watchEffect(() => {
  203. if (faqData.value) {
  204. isLoading.value = true;
  205. try {
  206. // 设置分类列表和默认选中的分类
  207. const allOption: string =
  208. locale.value === "en"
  209. ? "All"
  210. : locale.value === "zh"
  211. ? "全部"
  212. : "すべて";
  213. // 从FAQ数据中提取所有不同的分类
  214. const uniqueCategories = [
  215. ...new Set(faqData.value.map((faq: FAQ) => faq.category)),
  216. ]
  217. .filter((category) => category)
  218. .sort(); // 过滤掉空分类并排序
  219. // 设置分类列表和默认选中的分类
  220. categoriesList.value = [allOption, ...uniqueCategories];
  221. selectedCategory.value = categoriesList.value[0];
  222. } catch (err) {
  223. console.error("Error processing FAQ data:", err);
  224. error.value = new Error(t("faq.processError"));
  225. } finally {
  226. isLoading.value = false;
  227. }
  228. }
  229. });
  230. // 展开的FAQ标识
  231. const expandedFaqKey = ref<string | null>(null);
  232. // 搜索关键词
  233. const searchTerm = ref("");
  234. const searchInputRef = ref<HTMLInputElement | null>(null);
  235. // 过滤后的FAQ列表
  236. const filteredFaqs = computed(() => {
  237. if (!faqData.value) {
  238. return [];
  239. }
  240. let result = faqData.value;
  241. // 分类过滤
  242. if (selectedCategory.value !== categoriesList.value[0]) {
  243. result = result.filter(
  244. (faq: FAQ) => faq.category === selectedCategory.value
  245. );
  246. }
  247. // 搜索过滤 - 只搜索标题
  248. if (searchTerm.value.trim()) {
  249. const keyword = searchTerm.value.trim().toLowerCase();
  250. result = result.filter((faq: FAQ) => {
  251. const title = String(faq.title || "").toLowerCase();
  252. return title.includes(keyword);
  253. });
  254. }
  255. return result;
  256. });
  257. /**
  258. * 高亮显示匹配的关键字
  259. * @param text 原始文本
  260. * @returns 高亮后的VNode数组
  261. */
  262. function highlightKeyword(text: any): (string | any)[] {
  263. const keyword = searchTerm.value.trim();
  264. if (!keyword) return [text];
  265. // 确保 text 是字符串
  266. const textStr = typeof text === "string" ? text : String(text?.body || "");
  267. // 构建正则,忽略大小写,转义特殊字符
  268. const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  269. const reg = new RegExp(escaped, "gi");
  270. const parts = textStr.split(reg);
  271. const matches = textStr.match(reg);
  272. if (!matches) return [textStr];
  273. // 组装高亮
  274. const result: (string | any)[] = [];
  275. parts.forEach((part, i) => {
  276. result.push(part);
  277. if (i < matches.length) {
  278. result.push(
  279. h(
  280. "span",
  281. { class: "text-blue-400 bg-blue-400/10 font-bold" },
  282. matches[i]
  283. )
  284. );
  285. }
  286. });
  287. return result;
  288. }
  289. /**
  290. * 生成FAQ的唯一标识
  291. * @param faq FAQ对象
  292. * @returns string 唯一标识
  293. */
  294. function generateFaqKey(faq: FAQ): string {
  295. return `${faq.category}-${faq.title}`;
  296. }
  297. /**
  298. * 切换FAQ展开状态
  299. * @param faq FAQ对象
  300. * @returns void
  301. */
  302. function toggleFaq(faq: FAQ): void {
  303. if (!faq) return;
  304. const faqKey = generateFaqKey(faq);
  305. // 如果点击的是当前展开的FAQ,则关闭它
  306. if (expandedFaqKey.value === faqKey) {
  307. expandedFaqKey.value = null;
  308. } else {
  309. // 否则展开新的FAQ
  310. expandedFaqKey.value = faqKey;
  311. }
  312. }
  313. /**
  314. * 检查FAQ是否处于展开状态
  315. * @param faq FAQ对象
  316. * @returns boolean
  317. */
  318. function isFaqExpanded(faq: FAQ): boolean {
  319. if (!faq) return false;
  320. return expandedFaqKey.value === generateFaqKey(faq);
  321. }
  322. /**
  323. * 处理分类筛选
  324. */
  325. function handleCategoryFilter(category: string) {
  326. selectedCategory.value = category;
  327. }
  328. // 自动展开匹配项
  329. watch(
  330. [filteredFaqs, searchTerm],
  331. ([faqs, keyword]: [FAQ[], string]) => {
  332. if (!faqs?.length) return;
  333. if (keyword.trim()) {
  334. // 搜索时展开第一个匹配项
  335. expandedFaqKey.value = faqs[0] ? generateFaqKey(faqs[0]) : null;
  336. } else {
  337. // 清除搜索时关闭所有展开项
  338. expandedFaqKey.value = null;
  339. }
  340. },
  341. { deep: true }
  342. );
  343. function clearSearch() {
  344. searchTerm.value = "";
  345. // 让输入框重新聚焦
  346. searchInputRef.value?.focus();
  347. }
  348. // SEO优化
  349. useHead({
  350. title: t("faq.title") + " - Hanye",
  351. meta: [
  352. {
  353. name: "description",
  354. content: t("faq.description"),
  355. },
  356. {
  357. name: "keywords",
  358. content: t("faq.keywords"),
  359. },
  360. ],
  361. });
  362. </script>
  363. <style scoped>
  364. /* 添加过渡动画 */
  365. .fade-enter-active,
  366. .fade-leave-active {
  367. transition: opacity 0.3s;
  368. }
  369. .fade-enter-from,
  370. .fade-leave-to {
  371. opacity: 0;
  372. }
  373. </style>