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.

FaqContent.vue 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. <template>
  2. <div class="faq-content">
  3. <div v-if="isLoading" class="flex justify-center py-12">
  4. <div class="animate-spin h-8 w-8 border-2 border-cyan-400 border-t-transparent rounded-full"></div>
  5. </div>
  6. <div v-else class="space-y-8">
  7. <!-- 搜索和筛选区域 -->
  8. <div class="rounded-xl p-6">
  9. <div class="text-center mb-6">
  10. <h2 class="text-xl font-bold text-white mb-2">{{ t("support.faq.searchTitle") }}</h2>
  11. <p class="text-gray-400 text-sm">{{ t("support.faq.searchDescription") }}</p>
  12. </div>
  13. <!-- 搜索框 -->
  14. <div class="relative max-w-xl mx-auto mb-6">
  15. <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
  16. <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  17. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
  18. </svg>
  19. </div>
  20. <input
  21. v-model="searchTerm"
  22. type="text"
  23. class="w-full pl-12 pr-12 py-3 bg-zinc-900 border border-zinc-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-400/50 focus:border-cyan-400 transition-all duration-200"
  24. :placeholder="t('support.faq.searchPlaceholder')"
  25. />
  26. <button
  27. v-if="searchTerm"
  28. @click="clearSearch"
  29. class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-cyan-400 transition-colors duration-200"
  30. >
  31. <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  32. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
  33. </svg>
  34. </button>
  35. </div>
  36. <!-- 分类选择 -->
  37. <div>
  38. <div class="flex flex-wrap justify-center gap-3">
  39. <button
  40. v-for="category in categoriesList"
  41. :key="category"
  42. @click="handleCategoryFilter(category)"
  43. :class="[
  44. 'px-4 py-2 rounded-lg font-medium text-sm transition-all duration-200 border',
  45. selectedCategory === category
  46. ? 'bg-cyan-400 text-zinc-900 border-cyan-400 shadow-lg shadow-cyan-400/25'
  47. : 'bg-zinc-900 text-gray-300 border-zinc-600 hover:bg-zinc-800 hover:border-cyan-400/40 hover:text-white'
  48. ]"
  49. >
  50. {{ category }}
  51. </button>
  52. </div>
  53. </div>
  54. </div>
  55. <!-- 搜索结果统计 -->
  56. <div v-if="filteredFaqs.length > 0" class="flex items-center justify-between px-2">
  57. <p class="text-gray-400 text-sm">
  58. {{ locale === 'zh' ? '找到' : locale === 'en' ? 'Found' : '見つかりました' }} <span class="text-cyan-400 font-semibold">{{ filteredFaqs.length }}</span> {{ t("support.faq.questionCount") }}
  59. </p>
  60. <div class="text-xs text-gray-500">
  61. {{ selectedCategory !== categoriesList[0] ? `${locale === 'zh' ? '分类' : locale === 'en' ? 'Category' : 'カテゴリー'}: ${selectedCategory}` : (locale === 'zh' ? '显示全部分类' : locale === 'en' ? 'Show all categories' : '全てのカテゴリーを表示') }}
  62. </div>
  63. </div>
  64. <!-- FAQ列表 -->
  65. <div class="space-y-4">
  66. <div v-if="filteredFaqs.length === 0" class="text-center py-16">
  67. <div class="w-16 h-16 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-4">
  68. <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  69. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
  70. </svg>
  71. </div>
  72. <h3 class="text-lg font-semibold text-white mb-2">{{ t("support.faq.noResults") }}</h3>
  73. <p class="text-gray-400 mb-6">{{ t("support.faq.noResultsDesc") }}</p>
  74. <button
  75. @click="resetFilters"
  76. class="px-6 py-2 bg-cyan-400/20 text-cyan-400 rounded-lg hover:bg-cyan-400/30 transition-colors duration-200"
  77. >
  78. {{ locale === 'zh' ? '重置筛选条件' : locale === 'en' ? 'Reset Filters' : 'フィルターをリセット' }}
  79. </button>
  80. </div>
  81. <div
  82. v-for="faq in filteredFaqs"
  83. :key="generateFaqKey(faq)"
  84. class="bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden hover:border-cyan-400/30 transition-all duration-200"
  85. >
  86. <button
  87. @click="toggleFaq(faq)"
  88. class="w-full px-6 py-5 text-left focus:outline-none focus:ring-2 focus:ring-cyan-400/30 focus:ring-inset"
  89. >
  90. <div class="flex items-center justify-between">
  91. <div class="flex-1 pr-4">
  92. <div class="flex items-center mb-2">
  93. <span class="px-2 py-1 bg-cyan-400/20 text-cyan-400 text-xs font-medium rounded-full mr-3">
  94. {{ faq.category }}
  95. </span>
  96. </div>
  97. <h3 class="text-lg font-semibold text-white group-hover:text-cyan-300 transition-colors text-left">
  98. <template
  99. v-for="(part, i) in highlightKeyword(faq.title)"
  100. :key="i"
  101. >
  102. <span v-if="typeof part === 'string'">{{ part }}</span>
  103. <component v-else :is="part"></component>
  104. </template>
  105. </h3>
  106. </div>
  107. <div class="flex-shrink-0 ml-4">
  108. <svg
  109. :class="[
  110. 'w-5 h-5 text-gray-400 transition-transform duration-200',
  111. isFaqExpanded(faq) ? 'rotate-180' : ''
  112. ]"
  113. fill="none"
  114. stroke="currentColor"
  115. viewBox="0 0 24 24"
  116. >
  117. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
  118. </svg>
  119. </div>
  120. </div>
  121. </button>
  122. <transition
  123. enter-active-class="transition-all duration-300 ease-out"
  124. enter-from-class="opacity-0 max-h-0"
  125. enter-to-class="opacity-100 max-h-screen"
  126. leave-active-class="transition-all duration-300 ease-in"
  127. leave-from-class="opacity-100 max-h-screen"
  128. leave-to-class="opacity-0 max-h-0"
  129. >
  130. <div v-if="isFaqExpanded(faq)" class="px-6 pb-6 border-t border-zinc-700">
  131. <div class="pt-6">
  132. <ContentRenderer
  133. class="prose prose-invert prose-sm max-w-none"
  134. :value="{ body: faq.content }"
  135. v-highlight="searchTerm.trim()"
  136. />
  137. <!-- 有用性评价 -->
  138. <div class="mt-8 pt-6 border-t border-zinc-700/50">
  139. <div class="flex items-center justify-between">
  140. <p class="text-sm text-gray-400">{{ t("support.faq.helpful") }}</p>
  141. <div class="flex items-center space-x-4">
  142. <button
  143. class="flex items-center px-3 py-1 text-sm text-gray-400 hover:text-green-400 transition-colors duration-200"
  144. >
  145. <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  146. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20M7 20V8m0 12v-5a2 2 0 012-2h2.5" />
  147. </svg>
  148. {{ t("support.faq.yes") }}
  149. </button>
  150. <button
  151. class="flex items-center px-3 py-1 text-sm text-gray-400 hover:text-red-400 transition-colors duration-200"
  152. >
  153. <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  154. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018c.163 0 .326.02.485.60L17 4m0 0v12m0-12l-5 2v8" />
  155. </svg>
  156. {{ t("support.faq.no") }}
  157. </button>
  158. </div>
  159. </div>
  160. </div>
  161. </div>
  162. </div>
  163. </transition>
  164. </div>
  165. </div>
  166. </div>
  167. </div>
  168. </template>
  169. <script setup lang="ts">
  170. /**
  171. * FAQ内容组件
  172. * 专业的常见问题解答展示组件
  173. */
  174. import { useErrorHandler } from "~/composables/useErrorHandler";
  175. import { queryCollection } from "#imports";
  176. const { error, isLoading, wrapAsync } = useErrorHandler();
  177. const { t, locale } = useI18n();
  178. // FAQ数据接口
  179. interface FAQ {
  180. category: string;
  181. title: string;
  182. content: any;
  183. sort: number;
  184. id?: string;
  185. }
  186. // 响应式数据
  187. const faqs = ref<FAQ[]>([]);
  188. const categoriesList = ref<string[]>([]);
  189. const selectedCategory = ref("");
  190. const expandedFaqKeys = ref<Set<string>>(new Set());
  191. const searchTerm = ref("");
  192. // 使用 queryCollection 加载FAQ数据
  193. const { data: faqData } = await useAsyncData(
  194. "faqs",
  195. async () => {
  196. try {
  197. const content = await queryCollection("content")
  198. .where("path", "LIKE", `/faq/${locale.value}/%`)
  199. .all();
  200. if (!content || !Array.isArray(content)) {
  201. console.error("No FAQ content found or invalid format:", content);
  202. return [];
  203. }
  204. const faqItems = content.map((item: any) => {
  205. return {
  206. category: item.meta?.category || "",
  207. title: item.title || "",
  208. content: item.body || "",
  209. sort: item.meta?.sort || 0,
  210. };
  211. });
  212. return faqItems.sort((a, b) => a.sort - b.sort);
  213. } catch (error) {
  214. console.error("Error loading FAQ content:", error);
  215. return [];
  216. }
  217. },
  218. {
  219. server: true,
  220. lazy: false,
  221. immediate: true,
  222. watch: [locale],
  223. }
  224. );
  225. // 处理FAQ数据变化
  226. watchEffect(() => {
  227. if (faqData.value) {
  228. isLoading.value = true;
  229. try {
  230. const allOption: string = t("support.faq.categoryAll");
  231. const uniqueCategories = [
  232. ...new Set(faqData.value.map((faq: FAQ) => faq.category)),
  233. ]
  234. .filter((category) => category)
  235. .sort();
  236. categoriesList.value = [allOption, ...uniqueCategories];
  237. selectedCategory.value = categoriesList.value[0];
  238. } catch (err) {
  239. console.error("Error processing FAQ data:", err);
  240. error.value = new Error(t("faq.processError"));
  241. } finally {
  242. isLoading.value = false;
  243. }
  244. }
  245. });
  246. // 过滤后的FAQ列表
  247. const filteredFaqs = computed(() => {
  248. if (!faqData.value) {
  249. return [];
  250. }
  251. let filtered = [...faqData.value];
  252. // 按分类过滤
  253. const allOption: string = t("support.faq.categoryAll");
  254. if (selectedCategory.value && selectedCategory.value !== allOption) {
  255. filtered = filtered.filter((faq) => faq.category === selectedCategory.value);
  256. }
  257. // 按搜索词过滤
  258. if (searchTerm.value.trim()) {
  259. const search = searchTerm.value.trim().toLowerCase();
  260. filtered = filtered.filter((faq) =>
  261. faq.title.toLowerCase().includes(search)
  262. );
  263. }
  264. return filtered;
  265. });
  266. /**
  267. * 生成FAQ的唯一标识
  268. */
  269. const generateFaqKey = (faq: FAQ): string => {
  270. return `${faq.category}-${faq.title}-${faq.sort}`;
  271. };
  272. /**
  273. * 检查FAQ是否展开
  274. */
  275. const isFaqExpanded = (faq: FAQ): boolean => {
  276. return expandedFaqKeys.value.has(generateFaqKey(faq));
  277. };
  278. /**
  279. * 切换FAQ展开状态
  280. */
  281. const toggleFaq = (faq: FAQ): void => {
  282. const key = generateFaqKey(faq);
  283. if (expandedFaqKeys.value.has(key)) {
  284. expandedFaqKeys.value.delete(key);
  285. } else {
  286. expandedFaqKeys.value.add(key);
  287. }
  288. };
  289. /**
  290. * 处理分类过滤
  291. */
  292. const handleCategoryFilter = (category: string): void => {
  293. selectedCategory.value = category;
  294. // 清空展开状态
  295. expandedFaqKeys.value.clear();
  296. };
  297. /**
  298. * 清除搜索
  299. */
  300. const clearSearch = (): void => {
  301. searchTerm.value = "";
  302. };
  303. /**
  304. * 重置所有筛选条件
  305. */
  306. const resetFilters = (): void => {
  307. searchTerm.value = "";
  308. selectedCategory.value = categoriesList.value[0];
  309. expandedFaqKeys.value.clear();
  310. };
  311. /**
  312. * 高亮关键词
  313. */
  314. const highlightKeyword = (text: string) => {
  315. if (!searchTerm.value.trim()) {
  316. return [text];
  317. }
  318. const keyword = searchTerm.value.trim();
  319. const regex = new RegExp(`(${keyword})`, "gi");
  320. const parts = text.split(regex);
  321. return parts.map((part) => {
  322. if (part.toLowerCase() === keyword.toLowerCase()) {
  323. return h("mark", { class: "bg-yellow-500/30 text-yellow-200 px-1 rounded" }, part);
  324. }
  325. return part;
  326. });
  327. };
  328. // 自定义指令:高亮搜索结果
  329. const vHighlight = {
  330. mounted(el: HTMLElement, binding: { value: string }) {
  331. highlightText(el, binding.value);
  332. },
  333. updated(el: HTMLElement, binding: { value: string }) {
  334. highlightText(el, binding.value);
  335. },
  336. };
  337. /**
  338. * 高亮文本中的关键词
  339. */
  340. const highlightText = (el: HTMLElement, keyword: string): void => {
  341. if (!keyword || !keyword.trim()) {
  342. return;
  343. }
  344. const walker = document.createTreeWalker(
  345. el,
  346. NodeFilter.SHOW_TEXT,
  347. null
  348. );
  349. const nodes: Text[] = [];
  350. let node: Text | null;
  351. while ((node = walker.nextNode() as Text)) {
  352. if (node.textContent && node.textContent.trim()) {
  353. nodes.push(node);
  354. }
  355. }
  356. nodes.forEach((textNode) => {
  357. const parent = textNode.parentNode;
  358. if (!parent) return;
  359. const text = textNode.textContent || "";
  360. const regex = new RegExp(`(${keyword})`, "gi");
  361. if (regex.test(text)) {
  362. const fragment = document.createDocumentFragment();
  363. const parts = text.split(regex);
  364. parts.forEach((part) => {
  365. if (part.toLowerCase() === keyword.toLowerCase()) {
  366. const mark = document.createElement("mark");
  367. mark.className = "bg-yellow-500/30 text-yellow-200 px-1 rounded";
  368. mark.textContent = part;
  369. fragment.appendChild(mark);
  370. } else {
  371. fragment.appendChild(document.createTextNode(part));
  372. }
  373. });
  374. parent.replaceChild(fragment, textNode);
  375. }
  376. });
  377. };
  378. </script>
  379. <style scoped>
  380. .line-clamp-2 {
  381. display: -webkit-box;
  382. -webkit-line-clamp: 2;
  383. -webkit-box-orient: vertical;
  384. overflow: hidden;
  385. }
  386. .prose {
  387. max-width: none;
  388. }
  389. .prose h1,
  390. .prose h2,
  391. .prose h3,
  392. .prose h4,
  393. .prose h5,
  394. .prose h6 {
  395. color: #ffffff;
  396. margin-top: 1.5em;
  397. margin-bottom: 0.5em;
  398. }
  399. .prose p {
  400. color: #d4d4d8;
  401. line-height: 1.7;
  402. margin-bottom: 1em;
  403. }
  404. .prose ul,
  405. .prose ol {
  406. color: #d4d4d8;
  407. margin-bottom: 1em;
  408. }
  409. .prose li {
  410. margin-bottom: 0.5em;
  411. }
  412. .prose code {
  413. background-color: #27272a;
  414. color: #22d3ee;
  415. padding: 0.2em 0.4em;
  416. border-radius: 0.25rem;
  417. font-size: 0.875em;
  418. }
  419. .prose pre {
  420. background-color: #18181b;
  421. color: #d4d4d8;
  422. padding: 1rem;
  423. border-radius: 0.5rem;
  424. overflow-x: auto;
  425. margin-bottom: 1em;
  426. }
  427. .prose a {
  428. color: #22d3ee;
  429. text-decoration: underline;
  430. }
  431. .prose a:hover {
  432. color: #06b6d4;
  433. }
  434. .prose blockquote {
  435. border-left: 4px solid #22d3ee;
  436. padding-left: 1rem;
  437. margin-left: 0;
  438. font-style: italic;
  439. color: #a1a1aa;
  440. }
  441. .prose strong {
  442. color: #ffffff;
  443. font-weight: 600;
  444. }
  445. .prose table {
  446. width: 100%;
  447. border-collapse: collapse;
  448. margin-bottom: 1em;
  449. }
  450. .prose th,
  451. .prose td {
  452. border: 1px solid #3f3f46;
  453. padding: 0.5rem;
  454. text-align: left;
  455. }
  456. .prose th {
  457. background-color: #27272a;
  458. color: #ffffff;
  459. font-weight: 600;
  460. }
  461. .prose td {
  462. color: #d4d4d8;
  463. }
  464. </style>