123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622 |
- <template>
- <div>
- <div class="w-full h-[55px] sm:h-[72px]"></div>
- <ErrorBoundary :error="error">
- <div v-if="isLoading" class="flex justify-center py-12">
- <div
- class="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"
- ></div>
- </div>
-
- <div v-else>
- <div class="max-w-full xl:px-2 lg:px-2 md:px-4 px-4 mt-6 mb-12">
- <div class="max-w-screen-2xl mx-auto">
- <nuxt-link
- :to="`${homepagePath}/`"
- class="justify-start text-white/60 text-sm md:text-base font-normal hover:text-white transition-colors duration-300"
- >{{ t("common.home") }}</nuxt-link
- >
- <span class="text-white/60 text-sm md:text-base font-normal px-2">
- /
- </span>
- <nuxt-link
- :to="`${homepagePath}/faq`"
- class="text-white text-sm md:text-base font-normal"
- >{{ t("faq.title") }}</nuxt-link
- >
- </div>
- </div>
- <div
- 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"
- >
- <div class="max-w-screen-2xl mx-auto">
- <div class="w-full grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-4">
- <!-- 左侧分类导航 -->
- <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"
- >
- <div
- class="flex flex-col gap-2 sm:gap-3 md:gap-4 lg:gap-5 xl:gap-6"
- >
- <div class="flex justify-between items-center">
- <div
- class="text-white text-base sm:text-lg md:text-xl lg:text-2xl xl:text-3xl font-medium"
- >
- {{ t("faq.category") }}
- </div>
- </div>
- <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"
- >
- <div
- v-for="category in categoriesList"
- :key="category"
- @click="handleCategoryFilter(category)"
- 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"
- :class="{
- 'font-bold bg-cyan-400 text-zinc-900 border-0 shadow-lg scale-105 transition-all duration-300':
- selectedCategory === category,
- 'hover:bg-zinc-800/50': selectedCategory !== category,
- }"
- >
- {{ category }}
- </div>
- </div>
- </div>
- </div>
-
- <!-- 右侧FAQ列表 -->
- <div class="col-span-1 md:col-span-9">
- <!-- 搜索框 -->
- <div class="mb-8 relative">
- <input
- v-model="searchTerm"
- ref="searchInputRef"
- type="search"
- :placeholder="t('faq.searchPlaceholder')"
- 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"
- />
- <button
- v-if="searchTerm"
- @click="clearSearch"
- 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"
- :aria-label="t('faq.clearSearch')"
- tabindex="0"
- type="button"
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- class="h-5 w-5"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M6 18L18 6M6 6l12 12"
- />
- </svg>
- </button>
- </div>
- <div class="flex flex-col gap-8">
- <div
- v-for="faq in filteredFaqs"
- :key="generateFaqKey(faq)"
- class="bg-zinc-900 rounded-lg p-8 transition-all duration-300 hover:bg-zinc-800 hover:shadow-lg"
- >
- <div
- class="flex items-center justify-between cursor-pointer"
- @click="toggleFaq(faq)"
- >
- <div class="text-white text-xl font-medium">
- <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-white text-2xl transition-transform duration-300"
- :class="{ 'rotate-180': isFaqExpanded(faq) }"
- >
- ▼
- </div>
- </div>
- <div v-if="isFaqExpanded(faq)" class="mt-4">
- <ContentRenderer
- class="prose prose-invert w-full max-w-none faq-content"
- :value="{ body: faq.content }"
- v-highlight="searchTerm.trim()"
- />
- </div>
- </div>
- <div
- v-if="filteredFaqs.length === 0"
- class="text-center text-gray-400 py-8"
- >
- {{ t("faq.noResults") }}
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </ErrorBoundary>
- </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; // 修改为 any 类型,因为可能是对象或字符串
- sort: number;
- id?: string;
- }
-
- const homepagePath = computed(() => {
- return locale.value === "zh" ? "" : `/${locale.value}`;
- });
-
- // 从content目录读取FAQ数据
- const faqs = ref<FAQ[]>([]);
- const categoriesList = ref<string[]>([]);
-
- // 选中的分类
- const selectedCategory = ref("");
-
- // 使用 queryCollection 加载FAQ数据
- const { data: faqData } = await useAsyncData(
- "faqs",
- async () => {
- try {
- // 使用 queryCollection 加载 FAQ 数据
- 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"
- ? "全部"
- : "すべて";
-
- // 从FAQ数据中提取所有不同的分类
- 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 expandedFaqKeys = ref<Set<string>>(new Set());
-
- // 搜索关键词
- const searchTerm = ref("");
- const searchInputRef = ref<HTMLInputElement | null>(null);
-
- // 过滤后的FAQ列表
- const filteredFaqs = computed(() => {
- if (!faqData.value) {
- return [];
- }
-
- let result = faqData.value;
-
- // 分类过滤
- if (selectedCategory.value !== categoriesList.value[0]) {
- result = result.filter(
- (faq: FAQ) => faq.category === selectedCategory.value
- );
- }
-
- // 搜索过滤 - 同时搜索标题和内容
- if (searchTerm.value.trim()) {
- const keyword = searchTerm.value.trim().toLowerCase();
- result = result.filter((faq: FAQ) => {
- const title = String(faq.title || "").toLowerCase();
-
- // 处理内容可能是对象或字符串的情况
- let contentText = "";
- if (faq.content) {
- if (typeof faq.content === "string") {
- contentText = faq.content.toLowerCase();
- } else if (typeof faq.content === "object") {
- // 如果是对象,尝试提取内容
- const contentObj = faq.content;
- if (contentObj.children && Array.isArray(contentObj.children)) {
- // 如果有children数组,遍历提取文本
- contentText = JSON.stringify(contentObj);
- } else {
- // 其他情况,尝试转换整个对象为字符串
- contentText = JSON.stringify(contentObj).toLowerCase();
- }
- }
- }
-
- return title.includes(keyword) || contentText.includes(keyword);
- });
- }
-
- return result;
- });
-
- /**
- * 高亮显示匹配的关键字
- * @param text 原始文本
- * @returns 高亮后的VNode数组
- */
- function highlightKeyword(text: any): (string | any)[] {
- const keyword = searchTerm.value.trim();
- if (!keyword) return [text];
-
- // 确保 text 是字符串
- const textStr = typeof text === "string" ? text : String(text?.body || "");
-
- // 构建正则,忽略大小写,转义特殊字符
- const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const reg = new RegExp(escaped, "gi");
- const parts = textStr.split(reg);
- const matches = textStr.match(reg);
- if (!matches) return [textStr];
-
- // 组装高亮
- const result: (string | any)[] = [];
- parts.forEach((part, i) => {
- result.push(part);
- if (i < matches.length) {
- result.push(
- h(
- "span",
- { class: "text-blue-400 bg-blue-400/10 font-bold" },
- matches[i]
- )
- );
- }
- });
- return result;
- }
-
- /**
- * 生成FAQ的唯一标识
- * @param faq FAQ对象
- * @returns string 唯一标识
- */
- function generateFaqKey(faq: FAQ): string {
- return `${faq.category}-${faq.title}`;
- }
-
- /**
- * 切换FAQ展开状态
- * @param faq FAQ对象
- * @returns void
- */
- function toggleFaq(faq: FAQ): void {
- if (!faq) return;
-
- const faqKey = generateFaqKey(faq);
- // 如果当前FAQ已展开,则关闭它
- if (expandedFaqKeys.value.has(faqKey)) {
- expandedFaqKeys.value.delete(faqKey);
- } else {
- // 否则展开它(不会关闭其他FAQ)
- expandedFaqKeys.value.add(faqKey);
- }
- }
-
- /**
- * 检查FAQ是否处于展开状态
- * @param faq FAQ对象
- * @returns boolean
- */
- function isFaqExpanded(faq: FAQ): boolean {
- if (!faq) return false;
- return expandedFaqKeys.value.has(generateFaqKey(faq));
- }
-
- /**
- * 处理分类筛选
- */
- function handleCategoryFilter(category: string) {
- selectedCategory.value = category;
- }
-
- // 自动展开匹配项
- watch(
- [filteredFaqs, searchTerm],
- ([faqs, keyword]: [FAQ[], string]) => {
- if (!faqs?.length) return;
-
- if (keyword.trim()) {
- // 搜索时展开所有匹配项
- expandedFaqKeys.value = new Set(faqs.map((faq) => generateFaqKey(faq)));
- } else {
- // 清除搜索时关闭所有展开项
- expandedFaqKeys.value.clear();
- }
- },
- { deep: true }
- );
-
- function clearSearch() {
- searchTerm.value = "";
- // 让输入框重新聚焦
- searchInputRef.value?.focus();
- }
-
- // SEO优化
- useHead({
- title: t("faq.title") + " - Hanye",
- meta: [
- {
- name: "description",
- content: t("faq.description"),
- },
- {
- name: "keywords",
- content: t("faq.keywords"),
- },
- ],
- });
-
- // 添加自定义指令,用于在不破坏HTML结构的情况下高亮内容
- const vHighlight = {
- mounted(el: HTMLElement, binding: { value: string }) {
- highlight(el, binding.value);
- },
- updated(el: HTMLElement, binding: { value: string }) {
- highlight(el, binding.value);
- },
- };
-
- // 注册指令
- const app = useNuxtApp();
- app.vueApp.directive("highlight", vHighlight);
-
- // 高亮处理函数
- function highlight(el: HTMLElement, keyword: string) {
- if (!keyword || typeof keyword !== "string" || !keyword.trim()) {
- return;
- }
-
- // 递归处理节点
- function highlightNode(node: Node): boolean {
- if (node.nodeType === Node.TEXT_NODE) {
- // 文本节点,可以高亮
- const text = node.textContent || "";
- const lowerText = text.toLowerCase();
- const lowerKeyword = keyword.toLowerCase();
-
- if (lowerText.includes(lowerKeyword)) {
- const fragment = document.createDocumentFragment();
- let lastIndex = 0;
-
- // 查找所有匹配项
- const regex = new RegExp(escapeRegExp(keyword), "gi");
- let match;
- while ((match = regex.exec(text)) !== null) {
- // 添加匹配前的文本
- if (match.index > lastIndex) {
- fragment.appendChild(
- document.createTextNode(text.substring(lastIndex, match.index))
- );
- }
-
- // 创建高亮的span
- const highlightSpan = document.createElement("span");
- highlightSpan.className = "text-blue-400 bg-blue-400/10 font-bold";
- highlightSpan.textContent = match[0];
- fragment.appendChild(highlightSpan);
-
- lastIndex = regex.lastIndex;
- }
-
- // 添加匹配后的剩余文本
- if (lastIndex < text.length) {
- fragment.appendChild(
- document.createTextNode(text.substring(lastIndex))
- );
- }
-
- // 替换原节点
- if (node.parentNode) {
- node.parentNode.replaceChild(fragment, node);
- }
- return true;
- }
- } else if (node.nodeType === Node.ELEMENT_NODE) {
- // 元素节点,递归处理子节点
- // 避免处理这些标签内的内容
- const element = node as HTMLElement;
- if (
- ["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "SELECT", "OPTION"].includes(
- element.tagName
- )
- ) {
- return false;
- }
-
- // 创建节点的副本避免在迭代过程中修改节点列表
- const childNodes = Array.from(node.childNodes);
- childNodes.forEach((child) => highlightNode(child));
- }
- return false;
- }
-
- highlightNode(el);
- }
-
- // 转义正则表达式中的特殊字符
- function escapeRegExp(string: string): string {
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$0");
- }
- </script>
-
- <style scoped>
- /* 添加过渡动画 */
- .fade-enter-active,
- .fade-leave-active {
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
- }
-
- .fade-enter-from,
- .fade-leave-to {
- opacity: 0;
- transform: translateY(20px);
- }
-
- /* 分类悬停效果 */
- .active\:scale-95:active {
- transform: scale(0.95);
- }
-
- /* 优化横向滚动 */
- .overflow-x-auto {
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none; /* Firefox */
- -ms-overflow-style: none; /* IE and Edge */
- }
-
- .overflow-x-auto::-webkit-scrollbar {
- display: none; /* Chrome, Safari, Opera */
- }
-
- /* 清除按钮动画 */
- button {
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
- }
-
- button:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
- 0 2px 4px -1px rgba(0, 0, 0, 0.06);
- }
-
- button:active {
- transform: translateY(0);
- }
-
- /* FAQ项目悬停效果 */
- .bg-zinc-900 {
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
- }
-
- .bg-zinc-900:hover {
- transform: translateY(-4px);
- }
-
- /* 响应式调整 */
- @media (max-width: 768px) {
- button:hover {
- transform: translateY(-1px);
- }
-
- .bg-zinc-900:hover {
- transform: translateY(-2px);
- }
- }
-
- @media (min-width: 1024px) {
- .bg-zinc-900:hover {
- transform: translateY(-4px);
- }
- }
-
- /* 高亮内容样式 */
- .faq-content {
- white-space: pre-line;
- line-height: 1.6;
- color: #e2e8f0;
- }
-
- .faq-content :deep(ul),
- .faq-content :deep(ol) {
- padding-left: 1.5rem;
- margin: 1rem 0;
- }
-
- .faq-content :deep(li) {
- margin: 0.5rem 0;
- }
-
- .faq-content :deep(p) {
- margin: 0.75rem 0;
- }
-
- .faq-content :deep(h1),
- .faq-content :deep(h2),
- .faq-content :deep(h3),
- .faq-content :deep(h4),
- .faq-content :deep(h5),
- .faq-content :deep(h6) {
- margin: 1.5rem 0 0.75rem;
- font-weight: 600;
- color: #f8fafc;
- }
- </style>
|