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.

faq.vue 15KB

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