|
|
@@ -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> |