Quellcode durchsuchen

refactor: 优化FAQ页面结构和搜索功能

本次提交主要进行了以下修改:
1. 在 `faq.vue` 中重构了FAQ内容的渲染结构,提升了可读性和维护性。
2. 更新了搜索功能,支持同时搜索FAQ标题和内容,增强了用户体验。
3. 添加了自定义指令用于高亮搜索关键词,提升了搜索结果的可视化效果。
4. 修改了展开FAQ的逻辑,支持同时展开多个FAQ,提升了交互性。

这些改动旨在提升FAQ页面的可用性和用户体验,同时优化代码结构。
master
lizhuang vor 1 Monat
Ursprung
Commit
55e4a9986c
2 geänderte Dateien mit 175 neuen und 44 gelöschten Zeilen
  1. 173
    42
      pages/faq.vue
  2. 2
    2
      pages/index.vue

+ 173
- 42
pages/faq.vue Datei anzeigen

@@ -32,32 +32,22 @@
<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="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">
<div
class="text-white text-base sm:text-lg md:text-xl lg:text-2xl xl:text-3xl font-medium"
>
{{ t("faq.category") }}
</div>
<button
v-if="selectedCategory !== categoriesList[0]"
@click="handleCategoryFilter(categoriesList[0])"
class="flex items-center gap-1 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 text-xs sm:text-sm md:text-base text-white/60 hover:text-white bg-zinc-800/50 hover:bg-zinc-700/50 rounded-lg transition-all duration-300 active:scale-95"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-2.5 w-2.5 sm:h-3 sm:w-3 md:h-4 md:w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</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
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"
@@ -74,7 +64,7 @@
</div>
</div>
</div>
<!-- 右侧FAQ列表 -->
<div class="col-span-1 md:col-span-9">
<!-- 搜索框 -->
@@ -138,13 +128,11 @@
</div>
</div>
<div
v-if="isFaqExpanded(faq)"
class="mt-4"
>
<div v-if="isFaqExpanded(faq)" class="mt-4">
<ContentRenderer
class="prose w-full max-w-none"
class="prose prose-invert w-full max-w-none faq-content"
:value="{ body: faq.content }"
v-highlight="searchTerm.trim()"
/>
</div>
</div>
@@ -266,8 +254,8 @@ watchEffect(() => {
}
});

// 展开的FAQ标识
const expandedFaqKey = ref<string | null>(null);
// 展开的FAQ标识列表
const expandedFaqKeys = ref<Set<string>>(new Set());

// 搜索关键词
const searchTerm = ref("");
@@ -288,12 +276,31 @@ const filteredFaqs = computed(() => {
);
}

// 搜索过滤 - 只搜索标题
// 搜索过滤 - 同时搜索标题和内容
if (searchTerm.value.trim()) {
const keyword = searchTerm.value.trim().toLowerCase();
result = result.filter((faq: FAQ) => {
const title = String(faq.title || "").toLowerCase();
return title.includes(keyword);

// 处理内容可能是对象或字符串的情况
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);
});
}

@@ -354,12 +361,12 @@ function toggleFaq(faq: FAQ): void {
if (!faq) return;

const faqKey = generateFaqKey(faq);
// 如果点击的是当前展开的FAQ,则关闭它
if (expandedFaqKey.value === faqKey) {
expandedFaqKey.value = null;
// 如果当前FAQ已展开,则关闭它
if (expandedFaqKeys.value.has(faqKey)) {
expandedFaqKeys.value.delete(faqKey);
} else {
// 否则展开新的FAQ
expandedFaqKey.value = faqKey;
// 否则展开它(不会关闭其他FAQ)
expandedFaqKeys.value.add(faqKey);
}
}

@@ -370,7 +377,7 @@ function toggleFaq(faq: FAQ): void {
*/
function isFaqExpanded(faq: FAQ): boolean {
if (!faq) return false;
return expandedFaqKey.value === generateFaqKey(faq);
return expandedFaqKeys.value.has(generateFaqKey(faq));
}

/**
@@ -387,11 +394,11 @@ watch(
if (!faqs?.length) return;

if (keyword.trim()) {
// 搜索时展开第一个匹配项
expandedFaqKey.value = faqs[0] ? generateFaqKey(faqs[0]) : null;
// 搜索时展开所有匹配项
expandedFaqKeys.value = new Set(faqs.map((faq) => generateFaqKey(faq)));
} else {
// 清除搜索时关闭所有展开项
expandedFaqKey.value = null;
expandedFaqKeys.value.clear();
}
},
{ deep: true }
@@ -417,6 +424,98 @@ useHead({
},
],
});

// 添加自定义指令,用于在不破坏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>
@@ -477,7 +576,7 @@ button:active {
button:hover {
transform: translateY(-1px);
}
.bg-zinc-900:hover {
transform: translateY(-2px);
}
@@ -488,4 +587,36 @@ button:active {
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>

+ 2
- 2
pages/index.vue Datei anzeigen

@@ -221,7 +221,7 @@
</div>
<div class="max-w-screen-2xl mx-auto">
<div
class="w-full h-[380px] sm:h-[300px] md:h-[350px] lg:h-[360px] xl:h-[380px] 2xl:h-[460px] relative"
class="w-full h-[420px] sm:h-[300px] md:h-[350px] lg:h-[360px] xl:h-[380px] 2xl:h-[460px] relative"
>
<TransitionGroup name="slide-fade" tag="div" class="relative h-full">
<div :key="activeUsageId" class="w-full h-full">
@@ -498,7 +498,7 @@
prevEl: '.swiper-button-prev-3',
nextEl: '.swiper-button-next-3',
}"
class="h-[320px] sm:h-[320px] md:h-[480px] lg:h-[720px] max-w-full"
class="h-[360px] sm:h-[360px] md:h-[480px] lg:h-[720px] max-w-full"
>
<SwiperSlide class="!max-w-screen-2xl !w-full">
<div

Laden…
Abbrechen
Speichern