- 后台全面引入 Naive UI 组件库,统一 UI 规范 - 前台 usePublicApi 切换到 API 调用(GET /api/content/[key]) - 前后台数据源统一(site_content 表) - 产品线统一管理(tour/camp/course 三套编辑器) - 产品数据归一化(itinerary/faq/pricing 格式统一) - 表单提交 API 改写入 submissions 表 - 新增 submissions 标记已读 API - site-content PUT 支持 upsert - 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb) - 消除 PageHero 类型警告(useSEO reactive 修复) - 25 个前台页面全部 HTTP 200,展示效果不变 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
324 行
8.3 KiB
Vue
324 行
8.3 KiB
Vue
<template>
|
||
<div class="page-faq">
|
||
<!-- 页面标题 -->
|
||
<LayoutPageHero
|
||
:title="seo.h1"
|
||
:subtitle="`${totalFAQ}个高频问题详细解答,帮您做出最好的选择`"
|
||
:breadcrumbs="[{ text: '常见问答' }]"
|
||
background-image="/images/summer-camp/grassland-group.jpg"
|
||
/>
|
||
|
||
<!-- TL;DR:告诉 AI/搜索引擎这个页面回答了什么 -->
|
||
<section class="page-tldr" aria-label="常见问答摘要">
|
||
<div class="container">
|
||
<div class="page-tldr-inner">
|
||
<p>本页汇总呼籁旅行被问到最多的{{ totalFAQ }}个问题,涵盖价格区间、最佳出行时间、适合人群、行程安排、安全保障、退改条款等核心关切,按分类整理并支持关键词搜索。还有疑问可直接联系定制师。</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="faqData" class="section">
|
||
<div class="container">
|
||
<!-- 搜索框 -->
|
||
<div class="faq-search-wrap">
|
||
<div class="faq-search-box">
|
||
<span class="faq-search-icon" aria-hidden="true">🔍</span>
|
||
<input
|
||
v-model="searchQuery"
|
||
type="search"
|
||
class="faq-search-input"
|
||
placeholder="搜索问题,如:多少钱、几月份去、穿什么…"
|
||
aria-label="搜索常见问题"
|
||
@input="onSearchInput"
|
||
/>
|
||
<button v-if="searchQuery" class="faq-search-clear" aria-label="清除搜索" @click="clearSearch">✕</button>
|
||
</div>
|
||
<p v-if="searchQuery" class="faq-search-summary">
|
||
<template v-if="searchResultCount > 0">
|
||
找到 <strong>{{ searchResultCount }}</strong> 条相关问题
|
||
</template>
|
||
<template v-else>
|
||
未找到相关问题,试试其他关键词
|
||
</template>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 搜索结果视图 -->
|
||
<div v-if="searchQuery" class="faq-content">
|
||
<template v-if="searchResultCount > 0">
|
||
<div v-for="cat in filteredCategories" :key="cat.id" class="faq-search-group">
|
||
<h2 class="faq-category-label">{{ cat.name }}</h2>
|
||
<div class="faq-category-list">
|
||
<FaqItem
|
||
v-for="q in cat.questions"
|
||
:key="q.id"
|
||
:question="q.question"
|
||
:answer="q.answer"
|
||
:related-links="q.relatedLinks"
|
||
:initial-open="true"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="faq-no-result">
|
||
<p>没有找到包含"{{ searchQuery }}"的问题</p>
|
||
<button class="faq-no-result-btn" @click="clearSearch">查看全部问题</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 默认分类视图 -->
|
||
<template v-else>
|
||
<div class="faq-nav-sticky">
|
||
<FaqCategoryNav
|
||
:categories="categories"
|
||
:active-cat="activeCat"
|
||
@select="scrollToCategory"
|
||
/>
|
||
</div>
|
||
|
||
<div class="faq-content">
|
||
<FaqCategory
|
||
v-for="(cat, index) in categories"
|
||
:key="cat.id"
|
||
:category="cat"
|
||
:initial-open="index === 0"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 内链闭环:FAQ→产品 -->
|
||
<CommonSectionCTA
|
||
title="还有疑问?"
|
||
description="查看我们的产品详情,或直接联系定制师获取专属方案"
|
||
:links="[
|
||
{ to: '/products', text: '查看4-7天亲子游产品' },
|
||
{ to: '/contact', text: '联系定制师获取方案' },
|
||
]"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { useFAQPageSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
|
||
|
||
const { data: faqData } = await usePublicApi('faq')
|
||
|
||
// SEO - 最重要的页面
|
||
const seo = useSEO('faq')
|
||
|
||
// JSON-LD: FAQPage schema(核心SEO资产)
|
||
const categories = computed(() => faqData.value?.categories || [])
|
||
const totalFAQ = computed(() => categories.value.reduce((sum, c) => sum + c.questions.length, 0))
|
||
useFAQPageSchema(categories.value)
|
||
useBreadcrumbSchema([
|
||
{ text: '首页', to: '/' },
|
||
{ text: '常见问答' },
|
||
])
|
||
|
||
// 搜索功能 — 支持 URL ?q= 参数初始化(对应 WebSite SearchAction)
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const searchQuery = ref(route.query.q ? String(route.query.q) : '')
|
||
|
||
const filteredCategories = computed(() => {
|
||
const q = searchQuery.value.trim().toLowerCase()
|
||
if (!q) return categories.value
|
||
return categories.value
|
||
.map(cat => ({
|
||
...cat,
|
||
questions: cat.questions.filter(
|
||
item =>
|
||
item.question.toLowerCase().includes(q) ||
|
||
item.answer.toLowerCase().includes(q)
|
||
),
|
||
}))
|
||
.filter(cat => cat.questions.length > 0)
|
||
})
|
||
|
||
const searchResultCount = computed(() =>
|
||
filteredCategories.value.reduce((sum, c) => sum + c.questions.length, 0)
|
||
)
|
||
|
||
function onSearchInput() {
|
||
const q = searchQuery.value.trim()
|
||
router.replace({ query: q ? { q } : {} })
|
||
}
|
||
|
||
function clearSearch() {
|
||
searchQuery.value = ''
|
||
router.replace({ query: {} })
|
||
}
|
||
|
||
// 分类锚点导航 + 滚动联动
|
||
const activeCat = ref(categories.value?.[0]?.id || '')
|
||
|
||
function scrollToCategory(id) {
|
||
activeCat.value = id
|
||
if (import.meta.client) {
|
||
const el = document.getElementById(id)
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
}
|
||
}
|
||
|
||
// 滚动时自动高亮当前可见分类
|
||
onMounted(() => {
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) {
|
||
activeCat.value = entry.target.id
|
||
}
|
||
}
|
||
},
|
||
{ rootMargin: '-100px 0px -60% 0px', threshold: 0 }
|
||
)
|
||
|
||
categories.value.forEach((cat) => {
|
||
const el = document.getElementById(cat.id)
|
||
if (el) observer.observe(el)
|
||
})
|
||
|
||
onUnmounted(() => observer.disconnect())
|
||
})
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
// 搜索框
|
||
.faq-search-wrap {
|
||
max-width: 800px;
|
||
margin: 0 auto @space-xl;
|
||
}
|
||
|
||
.faq-search-box {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
background: @color-bg-white;
|
||
border: 2px solid @color-border;
|
||
border-radius: @border-radius-lg;
|
||
padding: @space-sm @space-lg;
|
||
transition: border-color @transition-fast, box-shadow @transition-fast;
|
||
|
||
&:focus-within {
|
||
border-color: @color-primary;
|
||
box-shadow: 0 0 0 3px rgba(58, 125, 68, 0.12);
|
||
}
|
||
}
|
||
|
||
.faq-search-icon {
|
||
font-size: @font-size-base;
|
||
margin-right: @space-sm;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.faq-search-input {
|
||
flex: 1;
|
||
border: none;
|
||
outline: none;
|
||
font-size: @font-size-base;
|
||
color: @color-text-primary;
|
||
background: transparent;
|
||
padding: 0;
|
||
|
||
&::placeholder {
|
||
color: @color-text-muted;
|
||
}
|
||
}
|
||
|
||
.faq-search-clear {
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
color: @color-text-muted;
|
||
font-size: @font-size-sm;
|
||
padding: 0 0 0 @space-sm;
|
||
flex-shrink: 0;
|
||
|
||
&:hover {
|
||
color: @color-text-primary;
|
||
}
|
||
}
|
||
|
||
.faq-search-summary {
|
||
margin-top: @space-sm;
|
||
font-size: @font-size-sm;
|
||
color: @color-text-secondary;
|
||
|
||
strong {
|
||
color: @color-primary;
|
||
}
|
||
}
|
||
|
||
// 搜索结果
|
||
.faq-search-group {
|
||
margin-bottom: @space-2xl;
|
||
}
|
||
|
||
.faq-category-label {
|
||
font-size: @font-size-xl;
|
||
color: @color-primary;
|
||
margin-bottom: @space-lg;
|
||
padding-bottom: @space-sm;
|
||
border-bottom: 2px solid @color-primary-lighter;
|
||
}
|
||
|
||
.faq-category-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: @space-md;
|
||
}
|
||
|
||
.faq-no-result {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
text-align: center;
|
||
padding: @space-2xl;
|
||
color: @color-text-secondary;
|
||
|
||
p {
|
||
margin-bottom: @space-lg;
|
||
font-size: @font-size-base;
|
||
}
|
||
}
|
||
|
||
.faq-no-result-btn {
|
||
display: inline-block;
|
||
padding: @space-sm @space-xl;
|
||
background: @color-primary;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: @border-radius-md;
|
||
cursor: pointer;
|
||
font-size: @font-size-sm;
|
||
transition: background @transition-fast;
|
||
|
||
&:hover {
|
||
background: @color-primary-light;
|
||
}
|
||
}
|
||
|
||
// 默认视图
|
||
.faq-nav-sticky {
|
||
position: sticky;
|
||
top: 64px;
|
||
z-index: 10;
|
||
background: @color-bg-white;
|
||
padding-top: @space-sm;
|
||
padding-bottom: @space-sm;
|
||
margin-bottom: @space-lg;
|
||
|
||
@media (min-width: 1024px) {
|
||
top: 72px;
|
||
}
|
||
}
|
||
|
||
.faq-content {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
}
|
||
</style>
|