hulai-website/pages/reviews.vue

236 行
6.8 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<div class="page-reviews">
<LayoutPageHero
:title="seo.h1"
:subtitle="heroes?.reviews || '来自真实家庭的旅行评价'"
:breadcrumbs="[{ text: '客户评价' }]"
background-image="/images/summer-camp/camp-graduation.jpg"
/>
<!-- TL;DR告诉 AI/搜索引擎这个页面回答了什么 -->
<section class="page-tldr" aria-label="客户评价摘要">
<div class="container">
<div class="page-tldr-inner">
<p>呼籁旅行已服务近万组家庭本页收录来自真实客户的旅行评价均为未经修改的原始反馈可按出行场景亲子摄影蜜月等和关注维度安全性性价比服务质量等筛选找到与自己情况最相似的参考评价</p>
</div>
</div>
</section>
<!-- 数据汇总 -->
<ReviewsReviewSummary v-if="reviews" :summary="reviews.summary" class="reveal" />
<!-- 筛选+列表 -->
<section v-if="reviews" class="section">
<div class="container">
<!-- sticky 筛选栏滚动时收起为紧凑模式 -->
<div class="filter-sticky" :class="{ 'is-compact': isCompact && !filterExpanded }">
<!-- 紧凑模式一行摘要 -->
<div v-if="isCompact && !filterExpanded" class="filter-compact" @click="filterExpanded = true">
<div class="filter-compact-info">
<span class="filter-compact-label">筛选</span>
<span v-if="activeScene" class="filter-compact-tag">{{ activeScene }}</span>
<span v-if="activeConcern" class="filter-compact-tag">{{ activeConcern }}</span>
<span v-if="!activeScene && !activeConcern" class="filter-compact-hint">全部评价</span>
<span class="filter-compact-count">{{ filteredReviews.length }} </span>
</div>
<span class="filter-compact-toggle">展开筛选 &#9662;</span>
</div>
<!-- 完整模式 -->
<div v-else>
<ReviewsReviewFilter
:scenes="allScenes"
:concerns="allConcerns"
:active-scene="activeScene"
:active-concern="activeConcern"
@filter-scene="activeScene = $event"
@filter-concern="activeConcern = $event"
/>
<div class="filter-bottom">
<span class="filter-count">
<template v-if="activeScene || activeConcern">
筛选结果<strong>{{ filteredReviews.length }}</strong> 条评价
</template>
<template v-else>
<strong>{{ reviews.items.length }}</strong> 条评价
</template>
</span>
<button v-if="isCompact" class="filter-collapse-btn" @click="filterExpanded = false">
收起 &#9652;
</button>
</div>
</div>
</div>
<ReviewsReviewList :reviews="filteredReviews" @reset="activeScene = ''; activeConcern = ''" />
</div>
</section>
<!-- 内链 -->
<CommonSectionCTA
title="准备好了吗?"
description="联系定制师,开始规划您的呼伦贝尔之旅"
:links="[{ to: '/contact', text: '联系定制师,获取专属行程方案' }]"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useBreadcrumbSchema, useAggregateRatingSchema } from '~/composables/useJsonLd'
const { data: reviews } = await usePublicApi('reviews')
const { data: heroes } = await usePublicApi('heroes')
const seo = useSEO('reviews')
useScrollReveal()
useAggregateRatingSchema(reviews.value?.summary, reviews.value?.items)
useBreadcrumbSchema([
{ text: '首页', to: '/' },
{ text: '客户评价' },
])
const activeScene = ref('')
const activeConcern = ref('')
const filterExpanded = ref(false)
const isCompact = ref(false)
const allScenes = computed(() => [...new Set((reviews.value?.items || []).flatMap(r => r.scenes))])
const allConcerns = computed(() => [...new Set((reviews.value?.items || []).flatMap(r => r.concerns))])
const filteredReviews = computed(() => {
return (reviews.value?.items || []).filter(r => {
if (activeScene.value && !r.scenes.includes(activeScene.value)) return false
if (activeConcern.value && !r.concerns.includes(activeConcern.value)) return false
return true
})
})
// 监听滚动:向下滚过 200px 后进入紧凑模式
onMounted(() => {
const onScroll = () => {
isCompact.value = window.scrollY > 200
// 切回紧凑时自动收起
if (!isCompact.value) {
filterExpanded.value = false
}
}
window.addEventListener('scroll', onScroll, { passive: true })
onUnmounted(() => window.removeEventListener('scroll', onScroll))
})
</script>
<style lang="less" scoped>
.filter-sticky {
position: sticky;
top: 64px;
z-index: 10;
background: @color-bg-white;
padding-top: @space-sm;
padding-bottom: @space-sm;
margin-bottom: @space-lg;
transition: box-shadow @transition-fast;
.respond-lg({
top: 72px;
});
&.is-compact {
box-shadow: @shadow-sm;
border-bottom: 1px solid @color-border;
}
}
// 紧凑模式:单行
.filter-compact {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-sm @space-md;
background: @color-bg-gray;
border-radius: @border-radius-md;
cursor: pointer;
min-height: 44px;
transition: background @transition-fast;
&:hover {
background: @color-primary-bg;
}
}
.filter-compact-info {
display: flex;
align-items: center;
gap: @space-sm;
flex-wrap: wrap;
flex: 1;
}
.filter-compact-label {
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: @color-text-primary;
}
.filter-compact-tag {
padding: @space-xs @space-sm;
font-size: @font-size-xs;
color: #fff;
background: @color-primary;
border-radius: @border-radius-full;
}
.filter-compact-hint {
font-size: @font-size-sm;
color: @color-text-muted;
}
.filter-compact-count {
font-size: @font-size-sm;
color: @color-primary;
font-weight: @font-weight-medium;
}
.filter-compact-toggle {
font-size: @font-size-xs;
color: @color-primary;
white-space: nowrap;
margin-left: @space-md;
}
// 完整模式底部
.filter-bottom {
display: flex;
align-items: center;
justify-content: center;
gap: @space-lg;
padding-top: @space-sm;
}
.filter-count {
font-size: @font-size-sm;
color: @color-text-muted;
strong {
color: @color-primary;
font-weight: @font-weight-bold;
}
}
.filter-collapse-btn {
padding: @space-xs @space-md;
font-size: @font-size-xs;
color: @color-primary;
background: none;
border: 1px solid @color-primary;
border-radius: @border-radius-full;
cursor: pointer;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
</style>