hulai-website/pages/selector.vue

822 行
20 KiB
Vue

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

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

<template>
<div class="page-selector">
<LayoutPageHero
:title="seo.h1"
:subtitle="heroes?.selector || '5个问题 · 1分钟 · 找到适合你的版本'"
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '选版工具' }]"
background-image="/images/seasons/summer-hero.jpg"
/>
<!-- TL;DR -->
<section class="page-tldr" aria-label="选版工具摘要">
<div class="container">
<div class="page-tldr-inner">
<p>呼伦贝尔行程选版工具回答5个简单问题1分钟内为你推荐合适的产品版本根据出行天数人群偏好智能匹配</p>
</div>
</div>
</section>
<section v-if="selectorData" class="selector-section">
<div class="container">
<div class="selector-wrap">
<!-- 问答流程 -->
<template v-if="!showResult">
<!-- 进度条 -->
<div class="progress-bar-wrap">
<div class="progress-bar-track">
<div class="progress-bar-fill" :style="{ width: progressPercent + '%' }" />
</div>
<span class="progress-label">{{ currentStepIndex + 1 }} / {{ visibleQuestions.length }}</span>
</div>
<!-- 问题卡片 -->
<Transition name="step" mode="out-in">
<div :key="currentQuestion.id" class="question-card">
<p class="question-hint">{{ currentQuestion.hint }}</p>
<h2 class="question-text">{{ currentQuestion.text }}</h2>
<div class="options-grid" :class="{ 'options-grid--3': currentQuestion.options.length === 3 }">
<button
v-for="opt in currentQuestion.options"
:key="opt.value"
class="option-btn"
:class="{ 'option-btn--selected': answers[currentQuestion.id] === opt.value }"
@click="selectAnswer(currentQuestion.id, opt.value)"
>
<span class="option-label">{{ opt.label }}</span>
<span class="option-desc">{{ opt.desc }}</span>
</button>
</div>
<!-- 导航按钮非第一步才显示返回 -->
<div class="step-nav">
<button v-if="currentStepIndex > 0" class="btn-back" @click="goBack">
上一步
</button>
<span v-else />
<button
v-if="answers[currentQuestion.id]"
class="btn-next"
@click="goNext"
>
{{ isLastQuestion ? '查看推荐' : '下一步 →' }}
</button>
</div>
</div>
</Transition>
</template>
<!-- 推荐结果 -->
<Transition name="step" mode="out-in">
<div v-if="showResult" key="result" class="result-wrap">
<div class="result-header">
<p class="result-eyebrow">根据你的回答我们推荐</p>
<div class="result-tag">{{ recommended.tag }}</div>
<h2 class="result-name">{{ recommended.name }}</h2>
<p class="result-audience">{{ recommended.audience }}</p>
</div>
<p class="result-desc">{{ recommended.description }}</p>
<!-- 推荐理由 -->
<div class="match-reasons">
<h3 class="match-reasons-title">为什么推荐这个版本</h3>
<ul class="match-reasons-list">
<li v-for="(reason, i) in matchReasons" :key="i" class="match-reason-item">
<span class="match-reason-icon"></span>
<span>{{ reason }}</span>
</li>
</ul>
</div>
<!-- 产品亮点 -->
<div class="result-highlights">
<h3 class="result-highlights-title">行程亮点</h3>
<ul class="highlights-list">
<li v-for="(h, i) in recommended.highlights" :key="i" class="highlight-item">
<span class="highlight-dot" />
<span>{{ h }}</span>
</li>
</ul>
</div>
<!-- 操作区 -->
<div class="result-actions">
<NuxtLink :to="`/products/${recommended.id}`" class="btn-primary">
查看详细行程
</NuxtLink>
<NuxtLink to="/customize" class="btn-cta">
在线定制这条行程
</NuxtLink>
</div>
<!-- 换一个 -->
<div class="alternate-wrap">
<p class="alternate-label">不太满意</p>
<button class="btn-alternate" @click="showAlternate">
换一个版本看看
</button>
<button class="btn-restart" @click="restart">
重新回答
</button>
</div>
<!-- 备选结果展示 -->
<Transition name="fade">
<div v-if="alternateIndex > 0 && rankedProducts[alternateIndex]" class="alternate-result">
<p class="alternate-eyebrow">备选推荐</p>
<div class="alternate-tag">{{ rankedProducts[alternateIndex].tag }}</div>
<h3 class="alternate-name">{{ rankedProducts[alternateIndex].name }}</h3>
<p class="alternate-desc">{{ rankedProducts[alternateIndex].description }}</p>
<div class="alternate-actions">
<NuxtLink :to="`/products/${rankedProducts[alternateIndex].id}`" class="btn-outline">
查看这个版本
</NuxtLink>
</div>
</div>
</Transition>
<!-- 全系列入口 -->
<div class="all-versions">
<p>想自己对比所有版本</p>
<NuxtLink to="/products" class="btn-link">查看全系列6个版本 </NuxtLink>
</div>
</div>
</Transition>
</div>
</div>
</section>
<!-- 底部CTA -->
<CommonSectionCTA
title="还有疑问?直接问定制师"
description="告诉我们你的家庭情况,定制师为你1对1选版本、排行程"
:links="[{ to: '/customize', text: '在线定制' }, { to: '/contact', text: '联系定制师' }]"
/>
</div>
</template>
<script setup>
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: selectorData } = await usePublicApi('selector')
const { data: heroes } = await usePublicApi('heroes')
const { data: productsData } = await usePublicApi('products')
const seo = useSEO('selector')
useScrollReveal()
// JSON-LD: Quiz + ItemList schema
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Quiz',
name: '呼伦贝尔行程版本选择工具',
description: '5个问题帮你找到合适的呼伦贝尔行程版本。根据出行天数、人群、孩子年龄、预算和偏好,从额吉的故乡V9系列6个版本中精准推荐。',
url: 'https://1814.love/selector',
about: {
'@type': 'TouristTrip',
name: '呼伦贝尔家庭定制游',
touristType: '家庭亲子游客',
},
numberOfQuestions: 5,
provider: {
'@type': 'TravelAgency',
name: '呼籁旅行',
url: 'https://1814.love',
},
}),
},
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'ItemList',
name: '额吉的故乡V9系列行程版本',
description: '呼籁旅行呼伦贝尔亲子游6个版本,5天4晚至7天6晚,覆盖森林版、草原版、亲子版、探索版、旷野版',
url: 'https://1814.love/selector',
numberOfItems: productsData.value?.versions?.length || 0,
itemListElement: (productsData.value?.versions || []).map((v, i) => ({
'@type': 'ListItem',
position: i + 1,
name: v.name,
description: v.audience,
url: `https://1814.love/products/${v.id}`,
})),
}),
},
],
})
useBreadcrumbSchema([
{ text: '首页', to: '/' },
{ text: '产品线', to: '/products' },
{ text: '帮我选行程' },
])
const questions = computed(() => selectorData.value?.questions || [])
const scoring = computed(() => selectorData.value?.scoring || {})
const reasonsMap = computed(() => selectorData.value?.matchReasons || {})
const allProducts = computed(() => productsData.value?.versions || [])
// 状态
const answers = ref({})
const currentStepIndex = ref(0)
const showResult = ref(false)
const alternateIndex = ref(0)
// 过滤出当前应显示的问题(根据条件)
const visibleQuestions = computed(() => {
return questions.value.filter((q) => {
if (!q.condition) return true
return answers.value[q.condition.key] === q.condition.value
})
})
const currentQuestion = computed(() => visibleQuestions.value[currentStepIndex.value])
const isLastQuestion = computed(() => currentStepIndex.value === visibleQuestions.value.length - 1)
const progressPercent = computed(() =>
Math.round(((currentStepIndex.value) / visibleQuestions.value.length) * 100)
)
// 计分逻辑
const scores = computed(() => {
const totals = {}
for (const [questionId, questionScores] of Object.entries(scoring.value)) {
const answer = answers.value[questionId]
if (!answer) continue
const productScores = questionScores[answer] || {}
for (const [productId, score] of Object.entries(productScores)) {
totals[productId] = (totals[productId] || 0) + score
}
}
return totals
})
// 按得分排序
const rankedProducts = computed(() => {
return [...allProducts.value].sort(
(a, b) => (scores.value[b.id] || 0) - (scores.value[a.id] || 0)
)
})
const recommended = computed(() => rankedProducts.value[0])
// 生成匹配原因
const matchReasons = computed(() => {
if (!recommended.value) return []
const pid = recommended.value.id
const reasonMap = reasonsMap.value[pid] || {}
const result = []
// 找匹配答案的原因
for (const [qid, answer] of Object.entries(answers.value)) {
const key = `${qid}:${answer}`
if (reasonMap[key] && result.length < 3) {
result.push(reasonMap[key])
}
}
// 补充默认原因
if (result.length === 0 && reasonMap.default) {
result.push(reasonMap.default)
}
return result.slice(0, 3)
})
// 操作方法
function selectAnswer(questionId, value) {
answers.value = { ...answers.value, [questionId]: value }
// 移动端自动前进
if (window.innerWidth < 768) {
setTimeout(() => goNext(), 200)
}
}
function goNext() {
if (isLastQuestion.value) {
showResult.value = true
} else {
currentStepIndex.value++
}
}
function goBack() {
if (currentStepIndex.value > 0) {
currentStepIndex.value--
}
}
function showAlternate() {
if (alternateIndex.value < rankedProducts.value.length - 1) {
alternateIndex.value++
}
}
function restart() {
answers.value = {}
currentStepIndex.value = 0
showResult.value = false
alternateIndex.value = 0
}
</script>
<style lang="less" scoped>
.selector-section {
padding: @space-3xl 0;
background: @color-bg-warm;
}
.selector-wrap {
max-width: 680px;
margin: 0 auto;
}
// 进度条
.progress-bar-wrap {
display: flex;
align-items: center;
gap: @space-md;
margin-bottom: @space-2xl;
}
.progress-bar-track {
flex: 1;
height: 4px;
background: @color-border;
border-radius: 2px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: @color-primary;
border-radius: 2px;
transition: width 0.4s ease;
}
.progress-label {
font-size: @font-size-sm;
color: @color-text-muted;
white-space: nowrap;
}
// 问题卡片
.question-card {
background: @color-bg-white;
border-radius: 16px;
padding: @space-2xl;
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.06);
.respond-md({
padding: @space-3xl;
});
}
.question-hint {
font-size: @font-size-sm;
color: @color-text-muted;
margin: 0 0 @space-sm;
}
.question-text {
font-size: @font-size-xl;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-xl;
line-height: @line-height-tight;
.respond-md({
font-size: @font-size-2xl;
});
}
// 选项网格
.options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: @space-md;
margin-bottom: @space-xl;
&--3 {
grid-template-columns: repeat(3, 1fr);
}
}
.option-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: @space-xs;
padding: @space-md @space-lg;
background: @color-bg-gray;
border: 2px solid @color-border;
border-radius: 12px;
cursor: pointer;
transition: all @transition-base;
text-align: left;
width: 100%;
&:hover {
border-color: @color-primary-light;
background: @color-primary-lighter;
}
&--selected {
border-color: @color-primary;
background: @color-primary-lighter;
.option-label {
color: @color-primary;
}
}
}
.option-label {
font-size: @font-size-base;
font-weight: @font-weight-medium;
color: @color-text-primary;
transition: color @transition-base;
}
.option-desc {
font-size: @font-size-xs;
color: @color-text-muted;
line-height: @line-height-normal;
}
// 步骤导航
.step-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-back {
font-size: @font-size-sm;
color: @color-text-muted;
background: none;
border: none;
cursor: pointer;
padding: @space-sm 0;
transition: color @transition-base;
&:hover {
color: @color-text-primary;
}
}
.btn-next {
background: @color-primary;
color: white;
border: none;
border-radius: 8px;
padding: @space-sm @space-xl;
font-size: @font-size-base;
font-weight: @font-weight-medium;
cursor: pointer;
transition: background @transition-base;
&:hover {
background: @color-primary-light;
}
}
// 结果区域
.result-wrap {
background: @color-bg-white;
border-radius: 16px;
padding: @space-2xl;
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.06);
.respond-md({
padding: @space-3xl;
});
}
.result-header {
margin-bottom: @space-xl;
}
.result-eyebrow {
font-size: @font-size-sm;
color: @color-text-muted;
margin: 0 0 @space-sm;
}
.result-tag {
display: inline-block;
background: @color-primary;
color: white;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
padding: 3px 10px;
border-radius: 20px;
margin-bottom: @space-sm;
}
.result-name {
font-size: @font-size-2xl;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-xs;
line-height: @line-height-tight;
.respond-md({
font-size: @font-size-3xl;
});
}
.result-audience {
font-size: @font-size-sm;
color: @color-text-secondary;
margin: 0;
}
.result-desc {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
margin: 0 0 @space-xl;
padding-bottom: @space-xl;
border-bottom: 1px solid @color-border;
}
// 匹配原因
.match-reasons {
margin-bottom: @space-xl;
}
.match-reasons-title,
.result-highlights-title {
font-size: @font-size-base;
font-weight: @font-weight-medium;
color: @color-text-primary;
margin: 0 0 @space-md;
}
.match-reasons-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: @space-sm;
}
.match-reason-item {
display: flex;
gap: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-normal;
}
.match-reason-icon {
color: @color-primary;
font-weight: @font-weight-bold;
flex-shrink: 0;
}
// 亮点
.result-highlights {
margin-bottom: @space-xl;
padding-bottom: @space-xl;
border-bottom: 1px solid @color-border;
}
.highlights-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: @space-sm;
}
.highlight-item {
display: flex;
gap: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-normal;
}
.highlight-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: @color-accent;
flex-shrink: 0;
margin-top: 6px;
}
// 操作按钮
.result-actions {
display: flex;
flex-direction: column;
gap: @space-md;
margin-bottom: @space-xl;
.respond-md({
flex-direction: row;
});
}
.btn-primary {
display: inline-block;
background: @color-bg-white;
color: @color-primary;
border: 2px solid @color-primary;
border-radius: 8px;
padding: @space-md @space-xl;
font-size: @font-size-base;
font-weight: @font-weight-medium;
text-decoration: none;
text-align: center;
transition: all @transition-base;
&:hover {
background: @color-primary-lighter;
}
}
.btn-cta {
display: inline-block;
background: @color-primary;
color: white;
border-radius: 8px;
padding: @space-md @space-xl;
font-size: @font-size-base;
font-weight: @font-weight-medium;
text-decoration: none;
text-align: center;
transition: background @transition-base;
&:hover {
background: @color-primary-light;
}
}
// 换一个
.alternate-wrap {
display: flex;
align-items: center;
gap: @space-md;
flex-wrap: wrap;
margin-bottom: @space-xl;
}
.alternate-label {
font-size: @font-size-sm;
color: @color-text-muted;
margin: 0;
}
.btn-alternate {
font-size: @font-size-sm;
color: @color-primary;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
transition: color @transition-base;
&:hover {
color: @color-primary-light;
}
}
.btn-restart {
font-size: @font-size-sm;
color: @color-text-muted;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color @transition-base;
&:hover {
color: @color-text-secondary;
}
}
// 备选结果
.alternate-result {
background: @color-bg-gray;
border-radius: 12px;
padding: @space-lg;
margin-bottom: @space-xl;
}
.alternate-eyebrow {
font-size: @font-size-xs;
color: @color-text-muted;
margin: 0 0 @space-xs;
}
.alternate-tag {
display: inline-block;
background: @color-accent;
color: white;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
padding: 2px 8px;
border-radius: 20px;
margin-bottom: @space-xs;
}
.alternate-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-sm;
}
.alternate-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-normal;
margin: 0 0 @space-md;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.alternate-actions {
display: flex;
}
.btn-outline {
display: inline-block;
background: transparent;
color: @color-primary;
border: 1px solid @color-primary;
border-radius: 6px;
padding: @space-sm @space-lg;
font-size: @font-size-sm;
text-decoration: none;
transition: all @transition-base;
&:hover {
background: @color-primary-lighter;
}
}
// 全系列入口
.all-versions {
text-align: center;
padding-top: @space-lg;
border-top: 1px solid @color-border;
p {
font-size: @font-size-sm;
color: @color-text-muted;
margin: 0 0 @space-xs;
}
}
.btn-link {
font-size: @font-size-sm;
color: @color-primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// 过渡动画
.step-enter-active,
.step-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.step-enter-from {
opacity: 0;
transform: translateY(16px);
}
.step-leave-to {
opacity: 0;
transform: translateY(-16px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>