123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524 |
- <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-cyan-400 border-t-transparent rounded-full"></div>
- </div>
-
- <div v-else class="space-y-8">
- <!-- 搜索和筛选区域 -->
- <div class="rounded-xl p-6">
- <div class="text-center mb-6">
- <h2 class="text-xl font-bold text-white mb-2">{{ t("support.faq.searchTitle") }}</h2>
- <p class="text-gray-400 text-sm">{{ t("support.faq.searchDescription") }}</p>
- </div>
-
- <!-- 搜索框 -->
- <div class="relative max-w-xl mx-auto mb-6">
- <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
- <svg class="h-5 w-5 text-gray-400" 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" />
- </svg>
- </div>
- <input
- v-model="searchTerm"
- type="text"
- 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"
- :placeholder="t('support.faq.searchPlaceholder')"
- />
- <button
- v-if="searchTerm"
- @click="clearSearch"
- class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-cyan-400 transition-colors duration-200"
- >
- <svg class="h-5 w-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" />
- </svg>
- </button>
- </div>
-
- <!-- 分类选择 -->
- <div>
- <div class="flex flex-wrap justify-center gap-3">
- <button
- v-for="category in categoriesList"
- :key="category"
- @click="handleCategoryFilter(category)"
- :class="[
- 'px-4 py-2 rounded-lg font-medium text-sm transition-all duration-200 border',
- selectedCategory === category
- ? 'bg-cyan-400 text-zinc-900 border-cyan-400 shadow-lg shadow-cyan-400/25'
- : 'bg-zinc-900 text-gray-300 border-zinc-600 hover:bg-zinc-800 hover:border-cyan-400/40 hover:text-white'
- ]"
- >
- {{ category }}
- </button>
- </div>
- </div>
- </div>
-
- <!-- 搜索结果统计 -->
- <div v-if="filteredFaqs.length > 0" class="flex items-center justify-between px-2">
- <p class="text-gray-400 text-sm">
- {{ locale === 'zh' ? '找到' : locale === 'en' ? 'Found' : '見つかりました' }} <span class="text-cyan-400 font-semibold">{{ filteredFaqs.length }}</span> {{ t("support.faq.questionCount") }}
- </p>
- <div class="text-xs text-gray-500">
- {{ selectedCategory !== categoriesList[0] ? `${locale === 'zh' ? '分类' : locale === 'en' ? 'Category' : 'カテゴリー'}: ${selectedCategory}` : (locale === 'zh' ? '显示全部分类' : locale === 'en' ? 'Show all categories' : '全てのカテゴリーを表示') }}
- </div>
- </div>
-
- <!-- FAQ列表 -->
- <div class="space-y-4">
- <div v-if="filteredFaqs.length === 0" class="text-center py-16">
- <div class="w-16 h-16 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-4">
- <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <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" />
- </svg>
- </div>
- <h3 class="text-lg font-semibold text-white mb-2">{{ t("support.faq.noResults") }}</h3>
- <p class="text-gray-400 mb-6">{{ t("support.faq.noResultsDesc") }}</p>
- <button
- @click="resetFilters"
- class="px-6 py-2 bg-cyan-400/20 text-cyan-400 rounded-lg hover:bg-cyan-400/30 transition-colors duration-200"
- >
- {{ locale === 'zh' ? '重置筛选条件' : locale === 'en' ? 'Reset Filters' : 'フィルターをリセット' }}
- </button>
- </div>
-
- <div
- v-for="faq in filteredFaqs"
- :key="generateFaqKey(faq)"
- class="bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden hover:border-cyan-400/30 transition-all duration-200"
- >
- <button
- @click="toggleFaq(faq)"
- class="w-full px-6 py-5 text-left focus:outline-none focus:ring-2 focus:ring-cyan-400/30 focus:ring-inset"
- >
- <div class="flex items-center justify-between">
- <div class="flex-1 pr-4">
- <div class="flex items-center mb-2">
- <span class="px-2 py-1 bg-cyan-400/20 text-cyan-400 text-xs font-medium rounded-full mr-3">
- {{ faq.category }}
- </span>
- </div>
- <h3 class="text-lg font-semibold text-white group-hover:text-cyan-300 transition-colors 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>
- </h3>
- </div>
- <div class="flex-shrink-0 ml-4">
- <svg
- :class="[
- 'w-5 h-5 text-gray-400 transition-transform duration-200',
- isFaqExpanded(faq) ? 'rotate-180' : ''
- ]"
- 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" />
- </svg>
- </div>
- </div>
- </button>
-
- <transition
- enter-active-class="transition-all duration-300 ease-out"
- enter-from-class="opacity-0 max-h-0"
- enter-to-class="opacity-100 max-h-screen"
- leave-active-class="transition-all duration-300 ease-in"
- leave-from-class="opacity-100 max-h-screen"
- leave-to-class="opacity-0 max-h-0"
- >
- <div v-if="isFaqExpanded(faq)" class="px-6 pb-6 border-t border-zinc-700">
- <div class="pt-6">
- <ContentRenderer
- class="prose prose-invert prose-sm max-w-none"
- :value="{ body: faq.content }"
- v-highlight="searchTerm.trim()"
- />
-
- <!-- 有用性评价 -->
- <div class="mt-8 pt-6 border-t border-zinc-700/50">
- <div class="flex items-center justify-between">
- <p class="text-sm text-gray-400">{{ t("support.faq.helpful") }}</p>
- <div class="flex items-center space-x-4">
- <button
- class="flex items-center px-3 py-1 text-sm text-gray-400 hover:text-green-400 transition-colors duration-200"
- >
- <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <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" />
- </svg>
- {{ t("support.faq.yes") }}
- </button>
- <button
- class="flex items-center px-3 py-1 text-sm text-gray-400 hover:text-red-400 transition-colors duration-200"
- >
- <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <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" />
- </svg>
- {{ t("support.faq.no") }}
- </button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </transition>
- </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 = t("support.faq.categoryAll");
-
- 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 = t("support.faq.categoryAll");
-
- 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 resetFilters = (): void => {
- searchTerm.value = "";
- selectedCategory.value = categoriesList.value[0];
- expandedFaqKeys.value.clear();
- };
-
- /**
- * 高亮关键词
- */
- 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)) {
- if (node.textContent && node.textContent.trim()) {
- nodes.push(node);
- }
- }
-
- nodes.forEach((textNode) => {
- const parent = textNode.parentNode;
- if (!parent) return;
-
- const text = textNode.textContent || "";
- const regex = new RegExp(`(${keyword})`, "gi");
-
- if (regex.test(text)) {
- const fragment = document.createDocumentFragment();
- const parts = text.split(regex);
-
- parts.forEach((part) => {
- if (part.toLowerCase() === keyword.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 {
- fragment.appendChild(document.createTextNode(part));
- }
- });
-
- parent.replaceChild(fragment, textNode);
- }
- });
- };
- </script>
-
- <style scoped>
- .line-clamp-2 {
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
-
- .prose {
- max-width: none;
- }
-
- .prose h1,
- .prose h2,
- .prose h3,
- .prose h4,
- .prose h5,
- .prose h6 {
- color: #ffffff;
- margin-top: 1.5em;
- margin-bottom: 0.5em;
- }
-
- .prose p {
- color: #d4d4d8;
- line-height: 1.7;
- margin-bottom: 1em;
- }
-
- .prose ul,
- .prose ol {
- color: #d4d4d8;
- margin-bottom: 1em;
- }
-
- .prose li {
- margin-bottom: 0.5em;
- }
-
- .prose code {
- background-color: #27272a;
- color: #22d3ee;
- padding: 0.2em 0.4em;
- border-radius: 0.25rem;
- font-size: 0.875em;
- }
-
- .prose pre {
- background-color: #18181b;
- color: #d4d4d8;
- padding: 1rem;
- border-radius: 0.5rem;
- overflow-x: auto;
- margin-bottom: 1em;
- }
-
- .prose a {
- color: #22d3ee;
- text-decoration: underline;
- }
-
- .prose a:hover {
- color: #06b6d4;
- }
-
- .prose blockquote {
- border-left: 4px solid #22d3ee;
- padding-left: 1rem;
- margin-left: 0;
- font-style: italic;
- color: #a1a1aa;
- }
-
- .prose strong {
- color: #ffffff;
- font-weight: 600;
- }
-
- .prose table {
- width: 100%;
- border-collapse: collapse;
- margin-bottom: 1em;
- }
-
- .prose th,
- .prose td {
- border: 1px solid #3f3f46;
- padding: 0.5rem;
- text-align: left;
- }
-
- .prose th {
- background-color: #27272a;
- color: #ffffff;
- font-weight: 600;
- }
-
- .prose td {
- color: #d4d4d8;
- }
- </style>
|