|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472 |
- <template>
- <div class="faq-content">
- <div v-if="isLoading" class="flex justify-center py-12">
- <div class="animate-spin h-8 w-8 border-2 border-blue-400 border-t-transparent rounded-full"></div>
- </div>
-
- <div v-else class="p-8">
- <!-- 分类和搜索区域 -->
- <div class="mb-8 space-y-6">
- <!-- 分类选择 -->
- <div class="flex flex-wrap gap-2">
- <button
- v-for="category in categoriesList"
- :key="category"
- @click="handleCategoryFilter(category)"
- class="px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 border"
- :class="{
- 'bg-blue-600 text-white border-blue-600': selectedCategory === category,
- 'bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600': selectedCategory !== category,
- }"
- >
- {{ category }}
- </button>
- </div>
-
- <!-- 搜索框 -->
- <div class="relative max-w-md">
- <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
- <svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <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"></path>
- </svg>
- </div>
- <input
- v-model="searchTerm"
- type="search"
- :placeholder="t('faq.searchPlaceholder')"
- class="w-full pl-10 pr-10 py-3 border border-gray-600 rounded-md text-white bg-gray-700 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
- />
- <button
- v-if="searchTerm"
- @click="clearSearch"
- class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-300"
- >
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
- </svg>
- </button>
- </div>
- </div>
-
- <!-- FAQ列表 -->
- <div class="space-y-3">
- <div
- v-for="faq in filteredFaqs"
- :key="generateFaqKey(faq)"
- class="border border-gray-600 rounded-lg overflow-hidden"
- >
- <div
- class="flex items-center justify-between p-6 cursor-pointer hover:bg-gray-700 transition-colors duration-200"
- @click="toggleFaq(faq)"
- >
- <div class="text-white font-medium flex-1 text-left">
- <template
- v-for="(part, i) in highlightKeyword(faq.title)"
- :key="i"
- >
- <span v-if="typeof part === 'string'">{{ part }}</span>
- <component v-else :is="part"></component>
- </template>
- </div>
- <div
- class="text-gray-400 transition-transform duration-200 ml-4 flex-shrink-0"
- :class="{ 'rotate-180': isFaqExpanded(faq) }"
- >
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
- </svg>
- </div>
- </div>
-
- <div
- v-if="isFaqExpanded(faq)"
- class="px-6 pb-6 border-t border-gray-600 bg-gray-750"
- >
- <div class="pt-6">
- <ContentRenderer
- class="prose prose-invert prose-sm max-w-none"
- :value="{ body: faq.content }"
- v-highlight="searchTerm.trim()"
- />
- </div>
- </div>
- </div>
-
- <div
- v-if="filteredFaqs.length === 0"
- class="text-center text-gray-400 py-16"
- >
- <div class="mb-4">
- <svg class="mx-auto h-12 w-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 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"></path>
- </svg>
- </div>
- <p class="text-lg font-medium text-white mb-2">{{ t("faq.noResults") }}</p>
- <p class="text-sm text-gray-500">尝试调整搜索条件或选择不同的分类</p>
- </div>
- </div>
- </div>
- </div>
- </template>
-
- <script setup lang="ts">
- /**
- * FAQ内容组件
- * 专业的常见问题解答展示组件
- */
- import { useErrorHandler } from "~/composables/useErrorHandler";
- import { queryCollection } from "#imports";
-
- const { error, isLoading, wrapAsync } = useErrorHandler();
- const { t, locale } = useI18n();
-
- // FAQ数据接口
- interface FAQ {
- category: string;
- title: string;
- content: any;
- sort: number;
- id?: string;
- }
-
- // 响应式数据
- const faqs = ref<FAQ[]>([]);
- const categoriesList = ref<string[]>([]);
- const selectedCategory = ref("");
- const expandedFaqKeys = ref<Set<string>>(new Set());
- const searchTerm = ref("");
-
- // 使用 queryCollection 加载FAQ数据
- const { data: faqData } = await useAsyncData(
- "faqs",
- async () => {
- try {
- const content = await queryCollection("content")
- .where("path", "LIKE", `/faq/${locale.value}/%`)
- .all();
-
- if (!content || !Array.isArray(content)) {
- console.error("No FAQ content found or invalid format:", content);
- return [];
- }
-
- const faqItems = content.map((item: any) => {
- return {
- category: item.meta?.category || "",
- title: item.title || "",
- content: item.body || "",
- sort: item.meta?.sort || 0,
- };
- });
-
- return faqItems.sort((a, b) => a.sort - b.sort);
- } catch (error) {
- console.error("Error loading FAQ content:", error);
- return [];
- }
- },
- {
- server: true,
- lazy: false,
- immediate: true,
- watch: [locale],
- }
- );
-
- // 处理FAQ数据变化
- watchEffect(() => {
- if (faqData.value) {
- isLoading.value = true;
- try {
- const allOption: string =
- locale.value === "en"
- ? "All"
- : locale.value === "zh"
- ? "全部"
- : "すべて";
-
- const uniqueCategories = [
- ...new Set(faqData.value.map((faq: FAQ) => faq.category)),
- ]
- .filter((category) => category)
- .sort();
-
- categoriesList.value = [allOption, ...uniqueCategories];
- selectedCategory.value = categoriesList.value[0];
- } catch (err) {
- console.error("Error processing FAQ data:", err);
- error.value = new Error(t("faq.processError"));
- } finally {
- isLoading.value = false;
- }
- }
- });
-
- // 过滤后的FAQ列表
- const filteredFaqs = computed(() => {
- if (!faqData.value) {
- return [];
- }
-
- let filtered = [...faqData.value];
-
- // 按分类过滤
- const allOption: string =
- locale.value === "en"
- ? "All"
- : locale.value === "zh"
- ? "全部"
- : "すべて";
-
- if (selectedCategory.value && selectedCategory.value !== allOption) {
- filtered = filtered.filter((faq) => faq.category === selectedCategory.value);
- }
-
- // 按搜索词过滤
- if (searchTerm.value.trim()) {
- const search = searchTerm.value.trim().toLowerCase();
- filtered = filtered.filter((faq) =>
- faq.title.toLowerCase().includes(search)
- );
- }
-
- return filtered;
- });
-
- /**
- * 生成FAQ的唯一标识
- */
- const generateFaqKey = (faq: FAQ): string => {
- return `${faq.category}-${faq.title}-${faq.sort}`;
- };
-
- /**
- * 检查FAQ是否展开
- */
- const isFaqExpanded = (faq: FAQ): boolean => {
- return expandedFaqKeys.value.has(generateFaqKey(faq));
- };
-
- /**
- * 切换FAQ展开状态
- */
- const toggleFaq = (faq: FAQ): void => {
- const key = generateFaqKey(faq);
- if (expandedFaqKeys.value.has(key)) {
- expandedFaqKeys.value.delete(key);
- } else {
- expandedFaqKeys.value.add(key);
- }
- };
-
- /**
- * 处理分类过滤
- */
- const handleCategoryFilter = (category: string): void => {
- selectedCategory.value = category;
- // 清空展开状态
- expandedFaqKeys.value.clear();
- };
-
- /**
- * 清除搜索
- */
- const clearSearch = (): void => {
- searchTerm.value = "";
- };
-
- /**
- * 高亮关键词
- */
- const highlightKeyword = (text: string) => {
- if (!searchTerm.value.trim()) {
- return [text];
- }
-
- const keyword = searchTerm.value.trim();
- const regex = new RegExp(`(${keyword})`, "gi");
- const parts = text.split(regex);
-
- return parts.map((part) => {
- if (part.toLowerCase() === keyword.toLowerCase()) {
- return h("mark", { class: "bg-yellow-500/30 text-yellow-200 px-1 rounded" }, part);
- }
- return part;
- });
- };
-
- // 自定义指令:高亮搜索结果
- const vHighlight = {
- mounted(el: HTMLElement, binding: { value: string }) {
- highlightText(el, binding.value);
- },
- updated(el: HTMLElement, binding: { value: string }) {
- highlightText(el, binding.value);
- },
- };
-
- /**
- * 高亮文本中的关键词
- */
- const highlightText = (el: HTMLElement, keyword: string): void => {
- if (!keyword || !keyword.trim()) {
- return;
- }
-
- const walker = document.createTreeWalker(
- el,
- NodeFilter.SHOW_TEXT,
- null
- );
-
- const nodes: Text[] = [];
- let node: Text | null;
-
- while ((node = walker.nextNode() as Text)) {
- nodes.push(node);
- }
-
- nodes.forEach((textNode) => {
- const parent = textNode.parentNode;
- if (!parent || parent.nodeName === "MARK") return;
-
- const text = textNode.textContent || "";
- const regex = new RegExp(`(${keyword.trim()})`, "gi");
-
- if (regex.test(text)) {
- const fragment = document.createDocumentFragment();
- const parts = text.split(regex);
-
- parts.forEach((part) => {
- if (part.toLowerCase() === keyword.trim().toLowerCase()) {
- const mark = document.createElement("mark");
- mark.className = "bg-yellow-500/30 text-yellow-200 px-1 rounded";
- mark.textContent = part;
- fragment.appendChild(mark);
- } else if (part) {
- fragment.appendChild(document.createTextNode(part));
- }
- });
-
- parent.replaceChild(fragment, textNode);
- }
- });
- };
- </script>
-
- <style scoped>
- /* 过渡效果 */
- .transition-colors {
- transition-property: color, background-color, border-color;
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
- }
-
- .transition-transform {
- transition-property: transform;
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
- }
-
- .duration-200 {
- transition-duration: 200ms;
- }
-
- /* 悬停效果 */
- .hover\:bg-gray-700:hover {
- background-color: #374151;
- }
-
- .hover\:bg-gray-600:hover {
- background-color: #4b5563;
- }
-
- .hover\:text-gray-300:hover {
- color: #d1d5db;
- }
-
- /* 焦点样式 */
- input:focus {
- box-shadow: 0 0 0 1px #3b82f6;
- }
-
- /* 高亮样式 */
- mark {
- background-color: rgba(234, 179, 8, 0.3);
- color: #fef3c7;
- padding: 2px 4px;
- border-radius: 4px;
- }
-
- /* Prose样式覆盖 - 深色主题 */
- :deep(.prose) {
- color: #d1d5db;
- max-width: none;
- }
-
- :deep(.prose h1),
- :deep(.prose h2),
- :deep(.prose h3),
- :deep(.prose h4),
- :deep(.prose h5),
- :deep(.prose h6) {
- color: #f9fafb;
- font-weight: 600;
- }
-
- :deep(.prose p) {
- margin-top: 1rem;
- margin-bottom: 1rem;
- line-height: 1.625;
- color: #d1d5db;
- }
-
- :deep(.prose ul),
- :deep(.prose ol) {
- margin-top: 1rem;
- margin-bottom: 1rem;
- }
-
- :deep(.prose li) {
- margin-top: 0.5rem;
- margin-bottom: 0.5rem;
- color: #d1d5db;
- }
-
- :deep(.prose strong) {
- color: #f9fafb;
- font-weight: 600;
- }
-
- :deep(.prose code) {
- color: #f9fafb;
- background-color: #374151;
- padding: 0.125rem 0.25rem;
- border-radius: 0.25rem;
- font-size: 0.875em;
- }
-
- :deep(.prose a) {
- color: #60a5fa;
- text-decoration: underline;
- }
-
- :deep(.prose a:hover) {
- color: #93c5fd;
- }
-
- /* 自定义gray-750背景色 */
- .bg-gray-750 {
- background-color: #2d3748;
- }
-
- /* 响应式优化 */
- @media (max-width: 768px) {
- .flex-wrap {
- gap: 0.5rem;
- }
-
- .px-6 {
- padding-left: 1rem;
- padding-right: 1rem;
- }
- }
- </style>
|