Hanye官网
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  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
  35. 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"
  36. >
  37. <div
  38. class="flex flex-col gap-2 sm:gap-3 md:gap-4 lg:gap-5 xl:gap-6"
  39. >
  40. <div class="flex justify-between items-center">
  41. <div
  42. class="text-white text-base sm:text-lg md:text-xl lg:text-2xl xl:text-3xl font-medium"
  43. >
  44. {{ t("faq.category") }}
  45. </div>
  46. </div>
  47. <div
  48. 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"
  49. >
  50. <div
  51. v-for="category in categoriesList"
  52. :key="category"
  53. @click="handleCategoryFilter(category)"
  54. 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"
  55. :class="{
  56. 'font-bold bg-cyan-400 text-zinc-900 border-0 shadow-lg scale-105 transition-all duration-300':
  57. selectedCategory === category,
  58. 'hover:bg-zinc-800/50': selectedCategory !== category,
  59. }"
  60. >
  61. {{ category }}
  62. </div>
  63. </div>
  64. </div>
  65. </div>
  66. <!-- 右侧FAQ列表 -->
  67. <div class="col-span-1 md:col-span-9">
  68. <!-- 搜索框 -->
  69. <div class="mb-8 relative">
  70. <input
  71. v-model="searchTerm"
  72. ref="searchInputRef"
  73. type="search"
  74. :placeholder="t('faq.searchPlaceholder')"
  75. 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"
  76. />
  77. <button
  78. v-if="searchTerm"
  79. @click="clearSearch"
  80. 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"
  81. :aria-label="t('faq.clearSearch')"
  82. tabindex="0"
  83. type="button"
  84. >
  85. <svg
  86. xmlns="http://www.w3.org/2000/svg"
  87. class="h-5 w-5"
  88. fill="none"
  89. viewBox="0 0 24 24"
  90. stroke="currentColor"
  91. stroke-width="2"
  92. >
  93. <path
  94. stroke-linecap="round"
  95. stroke-linejoin="round"
  96. d="M6 18L18 6M6 6l12 12"
  97. />
  98. </svg>
  99. </button>
  100. </div>
  101. <div class="flex flex-col gap-8">
  102. <div
  103. v-for="faq in filteredFaqs"
  104. :key="generateFaqKey(faq)"
  105. class="bg-zinc-900 rounded-lg p-8 transition-all duration-300 hover:bg-zinc-800 hover:shadow-lg"
  106. >
  107. <div
  108. class="flex items-center justify-between cursor-pointer"
  109. @click="toggleFaq(faq)"
  110. >
  111. <div class="text-white text-xl font-medium">
  112. <template
  113. v-for="(part, i) in highlightKeyword(faq.title)"
  114. :key="i"
  115. >
  116. <span v-if="typeof part === 'string'">{{
  117. part
  118. }}</span>
  119. <component v-else :is="part"></component>
  120. </template>
  121. </div>
  122. <div
  123. class="text-white text-2xl transition-transform duration-300"
  124. :class="{ 'rotate-180': isFaqExpanded(faq) }"
  125. >
  126. </div>
  127. </div>
  128. <div v-if="isFaqExpanded(faq)" class="mt-4">
  129. <ContentRenderer
  130. class="prose prose-invert w-full max-w-none faq-content"
  131. :value="{ body: faq.content }"
  132. v-highlight="searchTerm.trim()"
  133. />
  134. </div>
  135. </div>
  136. <div
  137. v-if="filteredFaqs.length === 0"
  138. class="text-center text-gray-400 py-8"
  139. >
  140. {{ t("faq.noResults") }}
  141. </div>
  142. </div>
  143. </div>
  144. </div>
  145. </div>
  146. </div>
  147. </div>
  148. </ErrorBoundary>
  149. </div>
  150. </template>
  151. <script setup lang="ts">
  152. /**
  153. * FAQ页面
  154. * 展示常见问题及其答案
  155. */
  156. import { useErrorHandler } from "~/composables/useErrorHandler";
  157. import { queryCollection } from "#imports";
  158. const { error, isLoading, wrapAsync } = useErrorHandler();
  159. const { t, locale } = useI18n();
  160. // FAQ数据
  161. interface FAQ {
  162. category: string;
  163. title: string;
  164. content: any; // 修改为 any 类型,因为可能是对象或字符串
  165. sort: number;
  166. id?: string;
  167. }
  168. const homepagePath = computed(() => {
  169. return locale.value === "zh" ? "" : `/${locale.value}`;
  170. });
  171. // 从content目录读取FAQ数据
  172. const faqs = ref<FAQ[]>([]);
  173. const categoriesList = ref<string[]>([]);
  174. // 选中的分类
  175. const selectedCategory = ref("");
  176. // 使用 queryCollection 加载FAQ数据
  177. const { data: faqData } = await useAsyncData(
  178. "faqs",
  179. async () => {
  180. try {
  181. // 使用 queryCollection 加载 FAQ 数据
  182. const content = await queryCollection("content")
  183. .where("path", "LIKE", `/faq/${locale.value}/%`)
  184. .all();
  185. if (!content || !Array.isArray(content)) {
  186. console.error("No FAQ content found or invalid format:", content);
  187. return [];
  188. }
  189. // 转换数据格式
  190. const faqItems = content.map((item: any) => {
  191. return {
  192. category: item.meta?.category || "",
  193. title: item.title || "",
  194. content: item.body || "",
  195. sort: item.meta?.sort || 0,
  196. };
  197. });
  198. return faqItems.sort((a, b) => a.sort - b.sort);
  199. } catch (error) {
  200. console.error("Error loading FAQ content:", error);
  201. return [];
  202. }
  203. },
  204. {
  205. server: true,
  206. lazy: false,
  207. immediate: true,
  208. watch: [locale],
  209. }
  210. );
  211. // 处理FAQ数据变化
  212. watchEffect(() => {
  213. if (faqData.value) {
  214. isLoading.value = true;
  215. try {
  216. // 设置分类列表和默认选中的分类
  217. const allOption: string =
  218. locale.value === "en"
  219. ? "All"
  220. : locale.value === "zh"
  221. ? "全部"
  222. : "すべて";
  223. // 从FAQ数据中提取所有不同的分类
  224. const uniqueCategories = [
  225. ...new Set(faqData.value.map((faq: FAQ) => faq.category)),
  226. ]
  227. .filter((category) => category)
  228. .sort(); // 过滤掉空分类并排序
  229. // 设置分类列表和默认选中的分类
  230. categoriesList.value = [allOption, ...uniqueCategories];
  231. selectedCategory.value = categoriesList.value[0];
  232. } catch (err) {
  233. console.error("Error processing FAQ data:", err);
  234. error.value = new Error(t("faq.processError"));
  235. } finally {
  236. isLoading.value = false;
  237. }
  238. }
  239. });
  240. // 展开的FAQ标识列表
  241. const expandedFaqKeys = ref<Set<string>>(new Set());
  242. // 搜索关键词
  243. const searchTerm = ref("");
  244. const searchInputRef = ref<HTMLInputElement | null>(null);
  245. // 过滤后的FAQ列表
  246. const filteredFaqs = computed(() => {
  247. if (!faqData.value) {
  248. return [];
  249. }
  250. let result = faqData.value;
  251. // 分类过滤
  252. if (selectedCategory.value !== categoriesList.value[0]) {
  253. result = result.filter(
  254. (faq: FAQ) => faq.category === selectedCategory.value
  255. );
  256. }
  257. // 搜索过滤 - 同时搜索标题和内容
  258. if (searchTerm.value.trim()) {
  259. const keyword = searchTerm.value.trim().toLowerCase();
  260. result = result.filter((faq: FAQ) => {
  261. const title = String(faq.title || "").toLowerCase();
  262. // 处理内容可能是对象或字符串的情况
  263. let contentText = "";
  264. if (faq.content) {
  265. if (typeof faq.content === "string") {
  266. contentText = faq.content.toLowerCase();
  267. } else if (typeof faq.content === "object") {
  268. // 如果是对象,尝试提取内容
  269. const contentObj = faq.content;
  270. if (contentObj.children && Array.isArray(contentObj.children)) {
  271. // 如果有children数组,遍历提取文本
  272. contentText = JSON.stringify(contentObj);
  273. } else {
  274. // 其他情况,尝试转换整个对象为字符串
  275. contentText = JSON.stringify(contentObj).toLowerCase();
  276. }
  277. }
  278. }
  279. return title.includes(keyword) || contentText.includes(keyword);
  280. });
  281. }
  282. return result;
  283. });
  284. /**
  285. * 高亮显示匹配的关键字
  286. * @param text 原始文本
  287. * @returns 高亮后的VNode数组
  288. */
  289. function highlightKeyword(text: any): (string | any)[] {
  290. const keyword = searchTerm.value.trim();
  291. if (!keyword) return [text];
  292. // 确保 text 是字符串
  293. const textStr = typeof text === "string" ? text : String(text?.body || "");
  294. // 构建正则,忽略大小写,转义特殊字符
  295. const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  296. const reg = new RegExp(escaped, "gi");
  297. const parts = textStr.split(reg);
  298. const matches = textStr.match(reg);
  299. if (!matches) return [textStr];
  300. // 组装高亮
  301. const result: (string | any)[] = [];
  302. parts.forEach((part, i) => {
  303. result.push(part);
  304. if (i < matches.length) {
  305. result.push(
  306. h(
  307. "span",
  308. { class: "text-blue-400 bg-blue-400/10 font-bold" },
  309. matches[i]
  310. )
  311. );
  312. }
  313. });
  314. return result;
  315. }
  316. /**
  317. * 生成FAQ的唯一标识
  318. * @param faq FAQ对象
  319. * @returns string 唯一标识
  320. */
  321. function generateFaqKey(faq: FAQ): string {
  322. return `${faq.category}-${faq.title}`;
  323. }
  324. /**
  325. * 切换FAQ展开状态
  326. * @param faq FAQ对象
  327. * @returns void
  328. */
  329. function toggleFaq(faq: FAQ): void {
  330. if (!faq) return;
  331. const faqKey = generateFaqKey(faq);
  332. // 如果当前FAQ已展开,则关闭它
  333. if (expandedFaqKeys.value.has(faqKey)) {
  334. expandedFaqKeys.value.delete(faqKey);
  335. } else {
  336. // 否则展开它(不会关闭其他FAQ)
  337. expandedFaqKeys.value.add(faqKey);
  338. }
  339. }
  340. /**
  341. * 检查FAQ是否处于展开状态
  342. * @param faq FAQ对象
  343. * @returns boolean
  344. */
  345. function isFaqExpanded(faq: FAQ): boolean {
  346. if (!faq) return false;
  347. return expandedFaqKeys.value.has(generateFaqKey(faq));
  348. }
  349. /**
  350. * 处理分类筛选
  351. */
  352. function handleCategoryFilter(category: string) {
  353. selectedCategory.value = category;
  354. }
  355. // 自动展开匹配项
  356. watch(
  357. [filteredFaqs, searchTerm],
  358. ([faqs, keyword]: [FAQ[], string]) => {
  359. if (!faqs?.length) return;
  360. if (keyword.trim()) {
  361. // 搜索时展开所有匹配项
  362. expandedFaqKeys.value = new Set(faqs.map((faq) => generateFaqKey(faq)));
  363. } else {
  364. // 清除搜索时关闭所有展开项
  365. expandedFaqKeys.value.clear();
  366. }
  367. },
  368. { deep: true }
  369. );
  370. function clearSearch() {
  371. searchTerm.value = "";
  372. // 让输入框重新聚焦
  373. searchInputRef.value?.focus();
  374. }
  375. // SEO优化
  376. useHead({
  377. title: t("faq.title") + " - Hanye",
  378. meta: [
  379. {
  380. name: "description",
  381. content: t("faq.description"),
  382. },
  383. {
  384. name: "keywords",
  385. content: t("faq.keywords"),
  386. },
  387. ],
  388. });
  389. // 添加自定义指令,用于在不破坏HTML结构的情况下高亮内容
  390. const vHighlight = {
  391. mounted(el: HTMLElement, binding: { value: string }) {
  392. highlight(el, binding.value);
  393. },
  394. updated(el: HTMLElement, binding: { value: string }) {
  395. highlight(el, binding.value);
  396. },
  397. };
  398. // 注册指令
  399. const app = useNuxtApp();
  400. app.vueApp.directive("highlight", vHighlight);
  401. // 高亮处理函数
  402. function highlight(el: HTMLElement, keyword: string) {
  403. if (!keyword || typeof keyword !== "string" || !keyword.trim()) {
  404. return;
  405. }
  406. // 递归处理节点
  407. function highlightNode(node: Node): boolean {
  408. if (node.nodeType === Node.TEXT_NODE) {
  409. // 文本节点,可以高亮
  410. const text = node.textContent || "";
  411. const lowerText = text.toLowerCase();
  412. const lowerKeyword = keyword.toLowerCase();
  413. if (lowerText.includes(lowerKeyword)) {
  414. const fragment = document.createDocumentFragment();
  415. let lastIndex = 0;
  416. // 查找所有匹配项
  417. const regex = new RegExp(escapeRegExp(keyword), "gi");
  418. let match;
  419. while ((match = regex.exec(text)) !== null) {
  420. // 添加匹配前的文本
  421. if (match.index > lastIndex) {
  422. fragment.appendChild(
  423. document.createTextNode(text.substring(lastIndex, match.index))
  424. );
  425. }
  426. // 创建高亮的span
  427. const highlightSpan = document.createElement("span");
  428. highlightSpan.className = "text-blue-400 bg-blue-400/10 font-bold";
  429. highlightSpan.textContent = match[0];
  430. fragment.appendChild(highlightSpan);
  431. lastIndex = regex.lastIndex;
  432. }
  433. // 添加匹配后的剩余文本
  434. if (lastIndex < text.length) {
  435. fragment.appendChild(
  436. document.createTextNode(text.substring(lastIndex))
  437. );
  438. }
  439. // 替换原节点
  440. if (node.parentNode) {
  441. node.parentNode.replaceChild(fragment, node);
  442. }
  443. return true;
  444. }
  445. } else if (node.nodeType === Node.ELEMENT_NODE) {
  446. // 元素节点,递归处理子节点
  447. // 避免处理这些标签内的内容
  448. const element = node as HTMLElement;
  449. if (
  450. ["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "SELECT", "OPTION"].includes(
  451. element.tagName
  452. )
  453. ) {
  454. return false;
  455. }
  456. // 创建节点的副本避免在迭代过程中修改节点列表
  457. const childNodes = Array.from(node.childNodes);
  458. childNodes.forEach((child) => highlightNode(child));
  459. }
  460. return false;
  461. }
  462. highlightNode(el);
  463. }
  464. // 转义正则表达式中的特殊字符
  465. function escapeRegExp(string: string): string {
  466. return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$0");
  467. }
  468. </script>
  469. <style scoped>
  470. /* 添加过渡动画 */
  471. .fade-enter-active,
  472. .fade-leave-active {
  473. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  474. }
  475. .fade-enter-from,
  476. .fade-leave-to {
  477. opacity: 0;
  478. transform: translateY(20px);
  479. }
  480. /* 分类悬停效果 */
  481. .active\:scale-95:active {
  482. transform: scale(0.95);
  483. }
  484. /* 优化横向滚动 */
  485. .overflow-x-auto {
  486. -webkit-overflow-scrolling: touch;
  487. scrollbar-width: none; /* Firefox */
  488. -ms-overflow-style: none; /* IE and Edge */
  489. }
  490. .overflow-x-auto::-webkit-scrollbar {
  491. display: none; /* Chrome, Safari, Opera */
  492. }
  493. /* 清除按钮动画 */
  494. button {
  495. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  496. }
  497. button:hover {
  498. transform: translateY(-2px);
  499. box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
  500. 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  501. }
  502. button:active {
  503. transform: translateY(0);
  504. }
  505. /* FAQ项目悬停效果 */
  506. .bg-zinc-900 {
  507. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  508. }
  509. .bg-zinc-900:hover {
  510. transform: translateY(-4px);
  511. }
  512. /* 响应式调整 */
  513. @media (max-width: 768px) {
  514. button:hover {
  515. transform: translateY(-1px);
  516. }
  517. .bg-zinc-900:hover {
  518. transform: translateY(-2px);
  519. }
  520. }
  521. @media (min-width: 1024px) {
  522. .bg-zinc-900:hover {
  523. transform: translateY(-4px);
  524. }
  525. }
  526. /* 高亮内容样式 */
  527. .faq-content {
  528. white-space: pre-line;
  529. line-height: 1.6;
  530. color: #e2e8f0;
  531. }
  532. .faq-content :deep(ul),
  533. .faq-content :deep(ol) {
  534. padding-left: 1.5rem;
  535. margin: 1rem 0;
  536. }
  537. .faq-content :deep(li) {
  538. margin: 0.5rem 0;
  539. }
  540. .faq-content :deep(p) {
  541. margin: 0.75rem 0;
  542. }
  543. .faq-content :deep(h1),
  544. .faq-content :deep(h2),
  545. .faq-content :deep(h3),
  546. .faq-content :deep(h4),
  547. .faq-content :deep(h5),
  548. .faq-content :deep(h6) {
  549. margin: 1.5rem 0 0.75rem;
  550. font-weight: 600;
  551. color: #f8fafc;
  552. }
  553. </style>