- 在TheHeader组件中新增搜索功能,支持实时搜索和清空搜索框。 - 更新useSearch组合函数,增强搜索逻辑,支持从JSON文件中动态加载产品数据。 - 在FAQ页面中优化内容展示,支持高亮搜索关键词,提升用户体验。 - 删除不再使用的FAQ文档,简化内容结构,确保信息的准确性和一致性。 - 更新多语言支持,确保搜索提示和结果信息的本地化。master
</span> | </span> | ||||
<input | <input | ||||
ref="searchInputRef" | ref="searchInputRef" | ||||
v-model="searchQuery" | |||||
type="text" | type="text" | ||||
:placeholder=" | :placeholder=" | ||||
t('common.searchPlaceholder') || 'Enter search term...' | t('common.searchPlaceholder') || 'Enter search term...' | ||||
" | " | ||||
class="w-full p-3 pl-10 pr-10 rounded bg-slate-700 text-white border border-slate-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" | class="w-full p-3 pl-10 pr-10 rounded bg-slate-700 text-white border border-slate-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" | ||||
@input="handleSearch" | |||||
/> | /> | ||||
<!-- Enter Icon --> | |||||
<!-- Clear Icon --> | |||||
<span | <span | ||||
v-if="searchQuery" | |||||
class="absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer group" | class="absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer group" | ||||
@click="clearSearch" | |||||
> | > | ||||
<i | |||||
class="icon-enter text-gray-400 group-hover:text-blue-400 transition-colors" | |||||
></i> | |||||
<button | |||||
class="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('common.clear')" | |||||
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> | |||||
</span> | </span> | ||||
</div> | </div> | ||||
<!-- Search results could go here --> | |||||
<!-- Search Results --> | |||||
<div v-if="isLoading" class="text-gray-400 text-sm"> | |||||
{{ t("common.searching") }} | |||||
</div> | |||||
<div v-if="error" class="text-red-400 text-sm"> | |||||
{{ error }} | |||||
</div> | |||||
<div | |||||
v-if="searchResults.length > 0" | |||||
class="mt-4 max-h-[60vh] overflow-y-auto pr-2 space-y-4 search-results" | |||||
> | |||||
<div | |||||
v-for="result in searchResults" | |||||
:key="result.id" | |||||
class="p-3 bg-slate-700/50 rounded-lg cursor-pointer hover:bg-slate-700 transition-colors group" | |||||
> | |||||
<nuxt-link | |||||
:to="`${homePath}products/${result.id}`" | |||||
class="block" | |||||
@click="closeSearch" | |||||
> | |||||
<div | |||||
class="text-white font-medium mb-1 group-hover:text-blue-400 transition-colors" | |||||
v-html="highlightText(result.title, searchQuery)" | |||||
></div> | |||||
<div | |||||
class="text-gray-400 text-sm" | |||||
v-html="highlightText(result.description, searchQuery)" | |||||
></div> | |||||
<div | |||||
class="text-gray-500 text-xs mt-1" | |||||
v-html="highlightText(result.summary, searchQuery)" | |||||
></div> | |||||
</nuxt-link> | |||||
</div> | |||||
</div> | |||||
<div | |||||
v-else-if="searchQuery && !isLoading" | |||||
class="text-gray-400 text-sm mt-4" | |||||
> | |||||
{{ t("common.noResults") }} | |||||
</div> | |||||
<!-- Hot Keywords Section --> | <!-- Hot Keywords Section --> | ||||
<div class="mt-6"> | <div class="mt-6"> | ||||
<h3 class="text-gray-400 text-sm mb-3"> | <h3 class="text-gray-400 text-sm mb-3"> | ||||
{{ t("common.hotKeywords") || "热门搜索" }} | |||||
{{ t("common.hotKeywords") }} | |||||
</h3> | </h3> | ||||
<div class="flex flex-wrap gap-3"> | <div class="flex flex-wrap gap-3"> | ||||
<button | <button | ||||
<script setup lang="ts"> | <script setup lang="ts"> | ||||
import { ref, computed, watch, nextTick } from "#imports"; | import { ref, computed, watch, nextTick } from "#imports"; | ||||
import { useI18n } from "vue-i18n"; | import { useI18n } from "vue-i18n"; | ||||
import { useRouter } from "vue-router"; | |||||
import { useSearch } from "~/composables/useSearch"; | |||||
/** | /** | ||||
* 页面头部组件 | * 页面头部组件 | ||||
let leaveTimeout: ReturnType<typeof setTimeout> | null = null; // Timeout for mouseleave delay | let leaveTimeout: ReturnType<typeof setTimeout> | null = null; // Timeout for mouseleave delay | ||||
const route = useRoute(); | const route = useRoute(); | ||||
const router = useRouter(); | |||||
const searchQuery = ref(""); | |||||
const { searchResults, isLoading, error, searchProducts, highlightText } = | |||||
useSearch(); | |||||
// 添加热门关键字 | // 添加热门关键字 | ||||
const hotKeywords = ref(["SSD", "SD", "DDR4"]); | const hotKeywords = ref(["SSD", "SD", "DDR4"]); | ||||
} | } | ||||
/** | /** | ||||
* 搜索热门关键字 (示例) | |||||
* @param keyword | |||||
* 搜索热门关键字 | |||||
* @param keyword 关键词 | |||||
*/ | */ | ||||
function searchHotKeyword(keyword: string) { | function searchHotKeyword(keyword: string) { | ||||
console.log("Searching for hot keyword:", keyword); | |||||
// 可以在这里实现填充输入框或直接执行搜索的逻辑 | |||||
// 例如: searchInputValue.value = keyword; | |||||
closeSearch(); // 点击后可以关闭搜索层 | |||||
searchQuery.value = keyword; | |||||
handleSearch(); | |||||
} | } | ||||
// 监听搜索层状态,打开时自动聚焦输入框 | // 监听搜索层状态,打开时自动聚焦输入框 | ||||
}, 150); // 150ms delay | }, 150); // 150ms delay | ||||
} | } | ||||
// --- End Dropdown Logic --- | // --- End Dropdown Logic --- | ||||
// 防抖函数 | |||||
const debounce = (fn: Function, delay: number) => { | |||||
let timer: NodeJS.Timeout; | |||||
return (...args: any[]) => { | |||||
clearTimeout(timer); | |||||
timer = setTimeout(() => fn(...args), delay); | |||||
}; | |||||
}; | |||||
// 处理搜索输入 | |||||
const handleSearch = debounce(async () => { | |||||
await searchProducts(searchQuery.value); | |||||
}, 300); | |||||
// 导航到产品详情页 | |||||
const navigateToProduct = (productId: string) => { | |||||
const path = | |||||
locale.value === "zh" | |||||
? `/zh/products/${productId}` | |||||
: `/products/${productId}`; | |||||
router.push(path); | |||||
}; | |||||
/** | |||||
* 清空搜索 | |||||
*/ | |||||
const clearSearch = () => { | |||||
searchQuery.value = ''; | |||||
searchResults.value = []; | |||||
}; | |||||
</script> | </script> | ||||
<style lang="scss" scoped> | <style lang="scss" scoped> | ||||
.sticky { | .sticky { | ||||
position: sticky; | position: sticky; | ||||
} | } | ||||
/* 搜索结果高亮样式 */ | |||||
:deep(.highlight) { | |||||
background-color: rgba(59, 130, 246, 0.2); | |||||
padding: 0 2px; | |||||
border-radius: 2px; | |||||
color: #60a5fa; | |||||
} | |||||
/* 搜索结果动画 */ | |||||
.search-results-enter-active, | |||||
.search-results-leave-active { | |||||
transition: all 0.3s ease; | |||||
} | |||||
.search-results-enter-from, | |||||
.search-results-leave-to { | |||||
opacity: 0; | |||||
transform: translateY(-10px); | |||||
} | |||||
/* 搜索结果滚动条样式 */ | |||||
.search-results { | |||||
&::-webkit-scrollbar { | |||||
width: 6px; | |||||
} | |||||
&::-webkit-scrollbar-track { | |||||
background: rgba(255, 255, 255, 0.1); | |||||
border-radius: 3px; | |||||
} | |||||
&::-webkit-scrollbar-thumb { | |||||
background: rgba(255, 255, 255, 0.2); | |||||
border-radius: 3px; | |||||
&:hover { | |||||
background: rgba(255, 255, 255, 0.3); | |||||
} | |||||
} | |||||
} | |||||
</style> | </style> |
import { ref, computed } from 'vue'; | import { ref, computed } from 'vue'; | ||||
import { useErrorHandler } from './useErrorHandler'; | import { useErrorHandler } from './useErrorHandler'; | ||||
import { useI18n } from 'vue-i18n'; | |||||
/** | |||||
* 产品数据接口 | |||||
*/ | |||||
interface Product { | |||||
id: string; | |||||
title: string; | |||||
description: string; | |||||
summary: string; | |||||
[key: string]: any; | |||||
} | |||||
/** | /** | ||||
* 搜索结果项接口 | * 搜索结果项接口 | ||||
*/ | */ | ||||
interface SearchResult { | interface SearchResult { | ||||
id: number; | |||||
id: string; | |||||
title: string; | title: string; | ||||
description: string; | description: string; | ||||
type: 'product' | 'faq' | 'page'; | |||||
url: string; | |||||
summary: string; | |||||
matchedField: string; | |||||
matchedText: string; | |||||
} | } | ||||
/** | /** | ||||
* @returns 搜索相关状态和方法 | * @returns 搜索相关状态和方法 | ||||
*/ | */ | ||||
export function useSearch() { | export function useSearch() { | ||||
const searchQuery = ref(''); | |||||
const searchResults = ref<SearchResult[]>([]); | const searchResults = ref<SearchResult[]>([]); | ||||
const { error, isLoading, wrapAsync } = useErrorHandler(); | |||||
const isLoading = ref(false); | |||||
const error = ref<string | null>(null); | |||||
const { wrapAsync } = useErrorHandler(); | |||||
const { locale } = useI18n(); | |||||
/** | /** | ||||
* 根据查询条件过滤搜索结果 | |||||
* 搜索产品数据 | |||||
* @param keyword 搜索关键词 | |||||
* @returns Promise<void> | |||||
*/ | */ | ||||
const filteredResults = computed(() => { | |||||
if (!searchQuery.value.trim()) return []; | |||||
// 简单的模拟搜索实现,实际项目中应替换为真实API | |||||
return mockSearchResults.filter(item => | |||||
item.title.toLowerCase().includes(searchQuery.value.toLowerCase()) || | |||||
item.description.toLowerCase().includes(searchQuery.value.toLowerCase()) | |||||
); | |||||
}); | |||||
const searchProducts = async (keyword: string) => { | |||||
if (!keyword.trim()) { | |||||
searchResults.value = []; | |||||
return; | |||||
} | |||||
/** | |||||
* 执行搜索 | |||||
* @param query - 搜索关键词 | |||||
*/ | |||||
async function search(query: string) { | |||||
searchQuery.value = query; | |||||
// 在实际项目中,这里应该调用搜索API | |||||
await wrapAsync(async () => { | |||||
await new Promise(resolve => setTimeout(resolve, 300)); // 模拟API延迟 | |||||
searchResults.value = filteredResults.value; | |||||
return searchResults.value; | |||||
}); | |||||
} | |||||
isLoading.value = true; | |||||
error.value = null; | |||||
try { | |||||
// 根据当前语言选择对应的JSON文件 | |||||
const lang = locale.value; | |||||
const products = await fetch(`/data/products-${lang}.json`).then(res => res.json()) as Product[]; | |||||
const results: SearchResult[] = []; | |||||
// 搜索数据 | |||||
products.forEach(product => { | |||||
const fields = [ | |||||
{ name: 'title', value: product.title }, | |||||
{ name: 'description', value: product.description }, | |||||
{ name: 'summary', value: product.summary } | |||||
]; | |||||
fields.forEach(field => { | |||||
if (field.value && field.value.toLowerCase().includes(keyword.toLowerCase())) { | |||||
results.push({ | |||||
id: product.id, | |||||
title: product.title, | |||||
description: product.description, | |||||
summary: product.summary, | |||||
matchedField: field.name, | |||||
matchedText: field.value | |||||
}); | |||||
} | |||||
}); | |||||
}); | |||||
// 去重 | |||||
searchResults.value = Array.from(new Map(results.map(item => [item.id, item])).values()); | |||||
} catch (err) { | |||||
error.value = '搜索失败,请稍后重试'; | |||||
console.error('Search error:', err); | |||||
} finally { | |||||
isLoading.value = false; | |||||
} | |||||
}; | |||||
/** | /** | ||||
* 清空搜索 | |||||
* 高亮显示匹配的文本 | |||||
* @param text 原始文本 | |||||
* @param keyword 关键词 | |||||
* @returns 高亮后的HTML字符串 | |||||
*/ | */ | ||||
function clearSearch() { | |||||
searchQuery.value = ''; | |||||
searchResults.value = []; | |||||
} | |||||
const highlightText = (text: string, keyword: string) => { | |||||
if (!keyword || !text) return text; | |||||
const regex = new RegExp(`(${keyword})`, 'gi'); | |||||
return text.replace(regex, '<span class="highlight">$1</span>'); | |||||
}; | |||||
return { | return { | ||||
searchQuery, | |||||
searchResults: computed(() => searchResults.value), | |||||
searchResults, | |||||
isLoading, | isLoading, | ||||
error, | error, | ||||
search, | |||||
clearSearch | |||||
searchProducts, | |||||
highlightText | |||||
}; | }; | ||||
} | } | ||||
// 模拟数据,实际项目中应替换为真实API数据 | // 模拟数据,实际项目中应替换为真实API数据 | ||||
const mockSearchResults: SearchResult[] = [ | const mockSearchResults: SearchResult[] = [ | ||||
{ | { | ||||
id: 1, | |||||
id: '1', | |||||
title: '产品一', | title: '产品一', | ||||
description: '这是产品一的详细描述', | description: '这是产品一的详细描述', | ||||
type: 'product', | |||||
url: '/products/1' | |||||
summary: '这是产品一的详细描述', | |||||
matchedField: 'title', | |||||
matchedText: '产品一' | |||||
}, | }, | ||||
{ | { | ||||
id: 2, | |||||
id: '2', | |||||
title: '产品二', | title: '产品二', | ||||
description: '这是产品二的详细描述', | description: '这是产品二的详细描述', | ||||
type: 'product', | |||||
url: '/products/2' | |||||
summary: '这是产品二的详细描述', | |||||
matchedField: 'title', | |||||
matchedText: '产品二' | |||||
}, | }, | ||||
{ | { | ||||
id: 3, | |||||
id: '3', | |||||
title: '如何使用产品?', | title: '如何使用产品?', | ||||
description: '详细介绍产品的使用方法', | description: '详细介绍产品的使用方法', | ||||
type: 'faq', | |||||
url: '/faq/1' | |||||
summary: '详细介绍产品的使用方法', | |||||
matchedField: 'description', | |||||
matchedText: '使用方法' | |||||
}, | }, | ||||
{ | { | ||||
id: 4, | |||||
id: '4', | |||||
title: '关于我们', | title: '关于我们', | ||||
description: '公司简介和历史', | description: '公司简介和历史', | ||||
type: 'page', | |||||
url: '/about' | |||||
summary: '公司简介和历史', | |||||
matchedField: 'description', | |||||
matchedText: '公司简介' | |||||
} | } | ||||
]; | ]; |
--- | |||||
id: 1 | |||||
category: Products | |||||
question: How long is the warranty period for your products? | |||||
answer: Our products come with a 1-year warranty from the date of purchase. If any issues occur within the warranty period, we'll repair or replace the product free of charge. | |||||
--- |
--- | |||||
id: 2 | |||||
category: Products | |||||
question: Where can I find the product manual? | |||||
answer: Product manuals can be downloaded from our website on the product page. They are also included in the product packaging. | |||||
--- |
--- | |||||
id: 3 | |||||
category: Purchase | |||||
question: What payment methods do you accept? | |||||
answer: We accept various payment methods including credit cards, bank transfers, and convenience store payments. Please check our payment page for details. | |||||
--- |
--- | --- | ||||
id: 4 | |||||
category: Purchase | |||||
question: Can I return or exchange products? | |||||
answer: Unused and unopened products can be returned or exchanged within 7 days of receipt. Please refer to our return and exchange policy for details. | |||||
--- | |||||
title: 可以退货或更换商品吗? | |||||
category: 技术支持 | |||||
sort: 1 | |||||
--- | |||||
--- | --- | ||||
id: 5 | |||||
category: Support | |||||
question: How can I access technical support? | |||||
answer: We offer technical support through email, phone, and online chat. During business hours, we provide immediate assistance. | |||||
--- | |||||
title: 了解如何获取我们的技术支持服务 | |||||
category: 技术支持1 | |||||
sort: 1 | |||||
--- | |||||
# 如何获取技术支持? | |||||
- 您可以通过以下方式获取技术支持: | |||||
1. 电子邮件:`support@example.com` | |||||
2. 电话:400-123-4567 | |||||
3. 在线聊天:工作日 `9:00-18:00` | |||||
在工作时间内,我们将提供即时响应。对于非工作时间提交的问题,我们会在下一个工作日尽快回复。 |
--- | --- | ||||
id: 6 | |||||
category: Support | |||||
question: How do I request repairs? | |||||
answer: Please submit a repair request through the repair request form on our website. You can check the repair status on your account page. | |||||
title: 了解如何获取我们的技术支持服务 | |||||
category: 技术支持 | |||||
sort: 1 | |||||
--- | --- |
--- | |||||
id: 1 | |||||
category: 製品について | |||||
question: 製品の保証期間はどのくらいですか? | |||||
answer: 当社の製品は購入日から1年間の保証期間があります。保証期間内に製品に問題が発生した場合は、無償で修理または交換いたします。 | |||||
--- |
--- | |||||
id: 2 | |||||
category: 製品について | |||||
question: 製品の取扱説明書はどこで入手できますか? | |||||
answer: 製品の取扱説明書は、当社ウェブサイトの製品ページからダウンロードできます。また、製品パッケージにも同梱されています。 | |||||
--- |
--- | |||||
id: 3 | |||||
category: 購入について | |||||
question: 支払い方法は何がありますか? | |||||
answer: クレジットカード、銀行振込、コンビニ決済など、様々な支払い方法をご利用いただけます。詳細はお支払いページをご確認ください。 | |||||
--- |
--- | --- | ||||
id: 4 | |||||
category: 購入について | |||||
question: 返品・交換は可能ですか? | |||||
answer: 商品到着後7日以内であれば、未使用・未開封の商品に限り返品・交換が可能です。詳細は返品・交換ポリシーをご確認ください。 | |||||
--- | |||||
title: 可以退货或更换商品吗? | |||||
category: 技术支持 | |||||
sort: 1 | |||||
--- | |||||
--- | --- | ||||
id: 5 | |||||
category: サポートについて | |||||
question: 技術サポートはどのように受けられますか? | |||||
answer: メール、電話、オンラインチャットなど、様々な方法で技術サポートをご利用いただけます。営業時間内であれば、即時対応いたします。 | |||||
title: 了解如何获取我们的技术支持服务 | |||||
category: 技术支持1 | |||||
sort: 1 | |||||
--- | --- | ||||
# 如何获取技术支持? | |||||
- 您可以通过以下方式获取技术支持: | |||||
1. 电子邮件:`support@example.com` | |||||
2. 电话:400-123-4567 | |||||
3. 在线聊天:工作日 `9:00-18:00` | |||||
在工作时间内,我们将提供即时响应。对于非工作时间提交的问题,我们会在下一个工作日尽快回复。 |
--- | --- | ||||
id: 6 | |||||
category: サポートについて | |||||
question: 修理依頼はどのように行えばよいですか? | |||||
answer: 修理依頼は、当社ウェブサイトの修理依頼フォームからお申し込みください。修理状況はマイページから確認できます。 | |||||
--- | |||||
title: 了解如何获取我们的技术支持服务 | |||||
category: 技术支持 | |||||
sort: 1 | |||||
--- |
--- | |||||
id: 1 | |||||
category: 产品相关 | |||||
question: 产品的保修期是多长时间? | |||||
answer: 我们的产品自购买之日起提供1年保修期。如果产品在保修期内出现问题,我们将免费修理或更换。 | |||||
--- |
--- | |||||
id: 2 | |||||
category: 产品相关 | |||||
question: 在哪里可以获取产品的使用说明书? | |||||
answer: 产品说明书可以从我们的网站产品页面下载。此外,产品包装中也附带有纸质说明书。 | |||||
--- |
--- | |||||
id: 3 | |||||
category: 购买相关 | |||||
question: 有哪些支付方式可供选择? | |||||
answer: 我们接受信用卡、银行转账、便利店支付等多种支付方式。详细信息请查看支付页面。 | |||||
--- |
--- | --- | ||||
id: 4 | |||||
category: 购买相关 | |||||
question: 可以退货或更换商品吗? | |||||
answer: 在收到商品后7天内,未使用和未拆封的商品可以退货或更换。详情请参阅我们的退换货政策。 | |||||
--- | |||||
title: 可以退货或更换商品吗? | |||||
category: 技术支持 | |||||
sort: 1 | |||||
--- | |||||
--- | --- | ||||
title: 如何获取技术支持? | |||||
description: 了解如何获取我们的技术支持服务 | |||||
category: 技术支持 | |||||
title: 了解如何获取我们的技术支持服务 | |||||
category: 技术支持1 | |||||
sort: 1 | sort: 1 | ||||
--- | --- | ||||
# 如何获取技术支持? | # 如何获取技术支持? | ||||
您可以通过以下方式获取技术支持: | |||||
- 您可以通过以下方式获取技术支持: | |||||
1. 电子邮件:support@example.com | |||||
1. 电子邮件:`support@example.com` | |||||
2. 电话:400-123-4567 | 2. 电话:400-123-4567 | ||||
3. 在线聊天:工作日 9:00-18:00 | |||||
3. 在线聊天:工作日 `9:00-18:00` | |||||
在工作时间内,我们将提供即时响应。对于非工作时间提交的问题,我们会在下一个工作日尽快回复。 | 在工作时间内,我们将提供即时响应。对于非工作时间提交的问题,我们会在下一个工作日尽快回复。 |
--- | --- | ||||
id: 6 | |||||
title: 了解如何获取我们的技术支持服务 | |||||
category: 技术支持 | category: 技术支持 | ||||
question: 如何申请维修服务? | |||||
answer: 请通过我们网站上的维修申请表格提交维修请求。您可以在个人账户页面查看维修进度。 | |||||
sort: 1 | |||||
--- | --- |
contact: "Contact", | contact: "Contact", | ||||
search: "Search", | search: "Search", | ||||
language: "Language", | language: "Language", | ||||
searchPlaceholder: "Search products, FAQ, etc.", | |||||
searchPlaceholder: "Search product keywords", | |||||
hotKeywords: "Hot Keywords", | hotKeywords: "Hot Keywords", | ||||
productCategories: "Product Categories", | productCategories: "Product Categories", | ||||
byUsage: "By Usage", | byUsage: "By Usage", | ||||
clear: "Clear", | clear: "Clear", | ||||
noResults: "No results found", | |||||
searching: "Searching...", | |||||
footer: { | footer: { | ||||
productsLinks: { | productsLinks: { | ||||
title: "Products", | title: "Products", |
contact: "お問い合わせ", | contact: "お問い合わせ", | ||||
search: "検索", | search: "検索", | ||||
language: "言語", | language: "言語", | ||||
searchPlaceholder: "製品、FAQ などを検索", | |||||
searchPlaceholder: "製品キーワードを検索", | |||||
hotKeywords: "人気のキーワード", | hotKeywords: "人気のキーワード", | ||||
productCategories: "製品カテゴリー", | productCategories: "製品カテゴリー", | ||||
byUsage: "用途で選ぶ", | byUsage: "用途で選ぶ", | ||||
clear: "クリア", | clear: "クリア", | ||||
noResults: "関連する結果はありません", | |||||
searching: "検索中...", | |||||
footer: { | footer: { | ||||
productsLinks: { | productsLinks: { | ||||
title: "製品", | title: "製品", |
contact: "联系我们", | contact: "联系我们", | ||||
search: "搜索", | search: "搜索", | ||||
language: "语言", | language: "语言", | ||||
searchPlaceholder: "搜索关键字, 产品、FAQ 等", | |||||
searchPlaceholder: "搜索产品关键字", | |||||
hotKeywords: "热门搜索", | hotKeywords: "热门搜索", | ||||
productCategories: "产品分类", | productCategories: "产品分类", | ||||
byUsage: "按用途", | byUsage: "按用途", | ||||
clear: "清除", | clear: "清除", | ||||
noResults: "没有找到相关结果", | |||||
searching: "搜索中...", | |||||
footer: { | footer: { | ||||
productsLinks: { | productsLinks: { | ||||
title: "产品", | title: "产品", |
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
<!-- 右侧FAQ列表 --> | <!-- 右侧FAQ列表 --> | ||||
<div class="col-span-1 md:col-span-8"> | <div class="col-span-1 md:col-span-8"> | ||||
<!-- 搜索框 --> | <!-- 搜索框 --> | ||||
> | > | ||||
<div class="text-white text-xl font-medium"> | <div class="text-white text-xl font-medium"> | ||||
<template | <template | ||||
v-for="(part, i) in highlightKeyword(faq.question)" | |||||
v-for="(part, i) in highlightKeyword(faq.title)" | |||||
:key="i" | :key="i" | ||||
> | > | ||||
<span v-if="typeof part === 'string'">{{ | <span v-if="typeof part === 'string'">{{ | ||||
v-if="isFaqExpanded(faq)" | v-if="isFaqExpanded(faq)" | ||||
class="mt-4 text-white/80 text-base font-normal leading-relaxed" | class="mt-4 text-white/80 text-base font-normal leading-relaxed" | ||||
> | > | ||||
<template | |||||
v-for="(part, i) in highlightKeyword(faq.answer)" | |||||
:key="i" | |||||
> | |||||
<span v-if="typeof part === 'string'">{{ part }}</span> | |||||
<component v-else :is="part"></component> | |||||
</template> | |||||
<ContentRenderer class="text-white" :value="{ body: faq.content }" /> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
<div | <div | ||||
* 展示常见问题及其答案 | * 展示常见问题及其答案 | ||||
*/ | */ | ||||
import { useErrorHandler } from "~/composables/useErrorHandler"; | import { useErrorHandler } from "~/composables/useErrorHandler"; | ||||
import { queryCollection } from '#imports' | |||||
import { queryCollection } from "#imports"; | |||||
const { error, isLoading, wrapAsync } = useErrorHandler(); | const { error, isLoading, wrapAsync } = useErrorHandler(); | ||||
const { t, locale } = useI18n(); | const { t, locale } = useI18n(); | ||||
// FAQ数据 | // FAQ数据 | ||||
interface FAQ { | interface FAQ { | ||||
id: string; | |||||
category: string; | category: string; | ||||
question: string; | |||||
answer: string; | |||||
title: string; | title: string; | ||||
description: string; | |||||
content: any; // 修改为 any 类型,因为可能是对象或字符串 | |||||
sort: number; | sort: number; | ||||
id?: string; | |||||
} | } | ||||
// 从content目录读取FAQ数据 | // 从content目录读取FAQ数据 | ||||
const selectedCategory = ref(""); | const selectedCategory = ref(""); | ||||
// 使用 queryCollection 加载FAQ数据 | // 使用 queryCollection 加载FAQ数据 | ||||
const { data: faqData } = await useAsyncData('faqs', async () => { | |||||
console.log('Loading FAQ data for locale:', locale.value); | |||||
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); | |||||
const { data: faqData } = await useAsyncData( | |||||
"faqs", | |||||
async () => { | |||||
try { | |||||
// 使用 queryCollection 加载 FAQ 数据 | |||||
const content = await queryCollection("content") | |||||
.where("path", "LIKE", `/faq/${locale.value}/%`) | |||||
.all(); | |||||
console.log("Raw FAQ content:", content); | |||||
if (!content || !Array.isArray(content)) { | |||||
console.error("No FAQ content found or invalid format:", content); | |||||
return []; | |||||
} | |||||
// 转换数据格式 | |||||
const faqItems = content.map((item: any) => { | |||||
console.log("Processing FAQ item:", item); | |||||
return { | |||||
category: item.meta?.category || "", | |||||
title: item.title || "", | |||||
content: item.body || "", | |||||
sort: item.meta?.sort || 0, | |||||
}; | |||||
}); | |||||
console.log("Processed FAQ items:", faqItems); | |||||
return faqItems.sort((a, b) => a.sort - b.sort); | |||||
} catch (error) { | |||||
console.error("Error loading FAQ content:", error); | |||||
return []; | return []; | ||||
} | } | ||||
console.log('Raw FAQ data:', content); | |||||
// 转换数据格式 | |||||
const faqItems = content.map((item: any) => { | |||||
// 提取路径中的ID | |||||
const pathParts = item.path?.split('/'); | |||||
const id = pathParts?.[pathParts.length - 1] || ''; | |||||
return { | |||||
id: id, | |||||
category: item.category || '', | |||||
question: item.title || '', | |||||
answer: item.body || '', | |||||
title: item.title || '', | |||||
description: item.description || '', | |||||
sort: item.sort || 0 | |||||
}; | |||||
}); | |||||
console.log('Processed FAQ items:', faqItems); | |||||
// 按 sort 字段排序 | |||||
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], | |||||
} | } | ||||
}, { | |||||
// 确保数据在构建时生成 | |||||
server: true, | |||||
lazy: false, | |||||
immediate: true, | |||||
watch: [locale] | |||||
}); | |||||
); | |||||
// 处理FAQ数据变化 | // 处理FAQ数据变化 | ||||
watchEffect(() => { | watchEffect(() => { | ||||
if (faqData.value) { | if (faqData.value) { | ||||
isLoading.value = true; | isLoading.value = true; | ||||
try { | try { | ||||
console.log('Processing FAQ data:', faqData.value); | |||||
console.log("Processing FAQ data:", faqData.value); | |||||
// 设置分类列表和默认选中的分类 | // 设置分类列表和默认选中的分类 | ||||
const allOption: string = | const allOption: string = | ||||
locale.value === "en" ? "All" : locale.value === "zh" ? "全部" : "すべて"; | |||||
locale.value === "en" | |||||
? "All" | |||||
: locale.value === "zh" | |||||
? "全部" | |||||
: "すべて"; | |||||
// 从FAQ数据中提取所有不同的分类 | // 从FAQ数据中提取所有不同的分类 | ||||
const uniqueCategories = [ | const uniqueCategories = [ | ||||
...new Set(faqData.value.map((faq: FAQ) => faq.category)), | ...new Set(faqData.value.map((faq: FAQ) => faq.category)), | ||||
].filter(category => category).sort(); // 过滤掉空分类并排序 | |||||
console.log('Unique categories:', uniqueCategories); | |||||
] | |||||
.filter((category) => category) | |||||
.sort(); // 过滤掉空分类并排序 | |||||
console.log("Unique categories:", uniqueCategories); | |||||
// 设置分类列表和默认选中的分类 | // 设置分类列表和默认选中的分类 | ||||
categoriesList.value = [allOption, ...uniqueCategories]; | categoriesList.value = [allOption, ...uniqueCategories]; | ||||
selectedCategory.value = categoriesList.value[0]; | selectedCategory.value = categoriesList.value[0]; | ||||
} catch (err) { | } catch (err) { | ||||
console.error("Error processing FAQ data:", err); | console.error("Error processing FAQ data:", err); | ||||
error.value = new Error(t('faq.processError')); | |||||
error.value = new Error(t("faq.processError")); | |||||
} finally { | } finally { | ||||
isLoading.value = false; | isLoading.value = false; | ||||
} | } | ||||
// 过滤后的FAQ列表 | // 过滤后的FAQ列表 | ||||
const filteredFaqs = computed(() => { | const filteredFaqs = computed(() => { | ||||
if (!faqData.value) return []; | |||||
if (!faqData.value) { | |||||
console.log("No FAQ data available"); | |||||
return []; | |||||
} | |||||
let result = faqData.value; | let result = faqData.value; | ||||
// 分类过滤 | |||||
if (selectedCategory.value !== categoriesList.value[0]) { | if (selectedCategory.value !== categoriesList.value[0]) { | ||||
result = result.filter( | result = result.filter( | ||||
(faq: FAQ) => faq.category === selectedCategory.value | (faq: FAQ) => faq.category === selectedCategory.value | ||||
); | ); | ||||
} | } | ||||
// 搜索过滤 - 只搜索标题 | |||||
if (searchTerm.value.trim()) { | if (searchTerm.value.trim()) { | ||||
const keyword = searchTerm.value.trim().toLowerCase(); | const keyword = searchTerm.value.trim().toLowerCase(); | ||||
result = result.filter( | |||||
(faq: FAQ) => | |||||
faq.question.toLowerCase().includes(keyword) || | |||||
faq.answer.toLowerCase().includes(keyword) | |||||
); | |||||
result = result.filter((faq: FAQ) => { | |||||
const title = String(faq.title || "").toLowerCase(); | |||||
return title.includes(keyword); | |||||
}); | |||||
} | } | ||||
return result; | return result; | ||||
}); | }); | ||||
* @param text 原始文本 | * @param text 原始文本 | ||||
* @returns 高亮后的VNode数组 | * @returns 高亮后的VNode数组 | ||||
*/ | */ | ||||
function highlightKeyword(text: string): (string | any)[] { | |||||
function highlightKeyword(text: any): (string | any)[] { | |||||
const keyword = searchTerm.value.trim(); | const keyword = searchTerm.value.trim(); | ||||
if (!keyword) return [text]; | if (!keyword) return [text]; | ||||
// 确保 text 是字符串 | |||||
const textStr = typeof text === "string" ? text : String(text?.body || ""); | |||||
// 构建正则,忽略大小写,转义特殊字符 | // 构建正则,忽略大小写,转义特殊字符 | ||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||||
const reg = new RegExp(escaped, "gi"); | const reg = new RegExp(escaped, "gi"); | ||||
const parts = text.split(reg); | |||||
const matches = text.match(reg); | |||||
if (!matches) return [text]; | |||||
const parts = textStr.split(reg); | |||||
const matches = textStr.match(reg); | |||||
if (!matches) return [textStr]; | |||||
// 组装高亮 | // 组装高亮 | ||||
const result: (string | any)[] = []; | const result: (string | any)[] = []; | ||||
parts.forEach((part, i) => { | parts.forEach((part, i) => { | ||||
* @returns string 唯一标识 | * @returns string 唯一标识 | ||||
*/ | */ | ||||
function generateFaqKey(faq: FAQ): string { | function generateFaqKey(faq: FAQ): string { | ||||
return `${faq.category}-${faq.id}`; | |||||
return `${faq.category}-${faq.title}`; | |||||
} | } | ||||
/** | /** | ||||
*/ | */ | ||||
function toggleFaq(faq: FAQ): void { | function toggleFaq(faq: FAQ): void { | ||||
if (!faq) return; | if (!faq) return; | ||||
const faqKey = generateFaqKey(faq); | const faqKey = generateFaqKey(faq); | ||||
// 如果点击的是当前展开的FAQ,则关闭它 | // 如果点击的是当前展开的FAQ,则关闭它 | ||||
if (expandedFaqKey.value === faqKey) { | if (expandedFaqKey.value === faqKey) { | ||||
} | } | ||||
// 自动展开匹配项 | // 自动展开匹配项 | ||||
watch([filteredFaqs, searchTerm], ([faqs, keyword]: [FAQ[], string]) => { | |||||
if (!faqs?.length) return; | |||||
if (keyword.trim()) { | |||||
// 搜索时展开第一个匹配项 | |||||
expandedFaqKey.value = faqs[0] ? generateFaqKey(faqs[0]) : null; | |||||
} else { | |||||
// 清除搜索时关闭所有展开项 | |||||
expandedFaqKey.value = null; | |||||
} | |||||
}, { deep: true }); | |||||
watch( | |||||
[filteredFaqs, searchTerm], | |||||
([faqs, keyword]: [FAQ[], string]) => { | |||||
if (!faqs?.length) return; | |||||
if (keyword.trim()) { | |||||
// 搜索时展开第一个匹配项 | |||||
expandedFaqKey.value = faqs[0] ? generateFaqKey(faqs[0]) : null; | |||||
} else { | |||||
// 清除搜索时关闭所有展开项 | |||||
expandedFaqKey.value = null; | |||||
} | |||||
}, | |||||
{ deep: true } | |||||
); | |||||
function clearSearch() { | function clearSearch() { | ||||
searchTerm.value = ""; | searchTerm.value = ""; |