sync: 同步最新呼籁官网代码到仓库

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
刘涛
2026-03-22 21:57:35 +08:00
共同撰写人 Claude Sonnet 4.6
当前提交 a25f3a77ce
共修改 319 个文件,包含 46379 行新增和 0 行删除
+86
查看文件
@@ -0,0 +1,86 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle :title="story.title" align="left" />
<div class="story-layout">
<div class="story-content">
<template v-if="story.paragraphs">
<p v-for="(para, i) in story.paragraphs" :key="i" class="story-para">{{ para }}</p>
</template>
<p v-else>{{ story.content }}</p>
</div>
<div class="story-photo">
<div class="story-photo-card">
<img
:src="images.brandStory.photo"
alt="一家三口穿蒙古族传统服饰在草原上欢笑"
width="600"
height="400"
loading="lazy"
/>
</div>
<p class="story-photo-caption">带孩子回到母亲的故乡</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ story: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.story-layout {
display: grid;
grid-template-columns: 1fr;
gap: @space-xl;
align-items: center;
.respond-md({
grid-template-columns: 1fr 1fr;
gap: @space-3xl;
});
}
.story-content {
p {
font-size: @font-size-md;
line-height: @line-height-loose;
color: @color-text-primary;
}
.story-para {
text-indent: 2em;
margin-bottom: @space-md;
&:last-child { margin-bottom: 0; }
}
}
.story-moment {
margin-top: @space-lg;
font-weight: @font-weight-medium;
}
.story-photo-card {
border-radius: @border-radius-lg;
overflow: hidden;
box-shadow: @shadow-lg;
img {
width: 100%;
display: block;
aspect-ratio: 4 / 3;
object-fit: cover;
object-position: center 35%;
}
}
.story-photo-caption {
text-align: center;
margin-top: @space-md;
font-size: @font-size-sm;
color: @color-text-muted;
letter-spacing: 0.5px;
}
</style>
+194
查看文件
@@ -0,0 +1,194 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="知识产权" subtitle="商标全品类注册 + 4项国家版权登记" />
<p class="copyright-intro">呼籁集团重视品牌知识产权保护。「呼籁」商标已完成全品类注册,核心产品名称、品牌标识和IP形象均已在中国版权保护中心完成作品登记。</p>
<!-- 商标 -->
<div v-if="trademark" class="trademark-card">
<div class="trademark-header">
<span class="trademark-badge">® 商标</span>
<h3 class="trademark-name">「{{ trademark.name }}」{{ trademark.scope }}</h3>
</div>
<p class="trademark-desc">{{ trademark.description }}</p>
<p class="trademark-holder">持有人:{{ trademark.holder }}</p>
</div>
<!-- 版权 -->
<div class="copyright-grid">
<div v-for="c in copyrights" :key="c.regNo" class="copyright-card">
<div class="copyright-header">
<h3 class="copyright-name">{{ c.name }}</h3>
<span class="copyright-category">{{ c.category }}</span>
</div>
<p class="copyright-desc">{{ c.description }}</p>
<div class="copyright-meta">
<div class="copyright-meta-item">
<span class="meta-label">登记号</span>
<span class="meta-value">{{ c.regNo }}</span>
</div>
<div class="copyright-meta-item">
<span class="meta-label">登记日期</span>
<span class="meta-value">{{ c.date }}</span>
</div>
<div class="copyright-meta-item">
<span class="meta-label">著作权人</span>
<span class="meta-value">{{ c.holder }}</span>
</div>
</div>
</div>
</div>
<p class="copyright-note">以上作品均经中国版权保护中心审核,依据《作品自愿登记试行办法》予以登记。</p>
</div>
</section>
</template>
<script setup>
defineProps({
copyrights: { type: Array, required: true },
trademark: { type: Object, default: null }
})
</script>
<style lang="less" scoped>
.copyright-intro {
text-align: center;
max-width: 600px;
margin: 0 auto @space-xl;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
.trademark-card {
max-width: 900px;
margin: 0 auto @space-xl;
padding: @space-xl;
background: @color-primary-bg;
border: 1px solid fade(@color-primary, 20%);
border-radius: @border-radius-md;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
text-align: center;
}
.trademark-header {
margin-bottom: @space-sm;
}
.trademark-badge {
display: inline-block;
font-size: @font-size-xs;
color: @color-bg-white;
background: @color-primary;
padding: 2px 10px;
border-radius: 20px;
margin-bottom: @space-sm;
}
.trademark-name {
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: @space-sm 0 0;
}
.trademark-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-loose;
max-width: 700px;
margin: @space-sm auto @space-sm;
}
.trademark-holder {
font-size: @font-size-xs;
color: @color-text-muted;
margin: 0;
}
.copyright-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-md;
max-width: 900px;
margin: 0 auto;
.respond-md({
grid-template-columns: 1fr 1fr;
});
}
.copyright-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-lg;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
}
.copyright-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: @space-sm;
}
.copyright-name {
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0;
}
.copyright-category {
font-size: @font-size-xs;
color: @color-primary;
background: @color-primary-bg;
padding: 2px 8px;
border-radius: @border-radius-sm;
flex-shrink: 0;
}
.copyright-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
}
.copyright-meta {
border-top: 1px solid @color-bg-gray;
padding-top: @space-sm;
}
.copyright-meta-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 3px 0;
font-size: @font-size-xs;
}
.meta-label {
color: @color-text-muted;
flex-shrink: 0;
margin-right: @space-sm;
}
.meta-value {
color: @color-text-secondary;
text-align: right;
word-break: break-all;
}
.copyright-note {
text-align: center;
margin-top: @space-xl;
font-size: @font-size-xs;
color: @color-text-muted;
}
</style>
+33
查看文件
@@ -0,0 +1,33 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="企业文化" />
<p v-if="culture.core" class="culture-core">{{ culture.core }}</p>
<div class="values-grid">
<div v-for="v in culture.values" :key="v.name" class="value-item">
<h3 class="value-name">{{ v.name }}</h3>
<p class="value-expr">{{ v.expression }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({ culture: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.culture-core {
text-align: center; font-size: @font-size-xl; font-weight: 600; color: @color-primary;
letter-spacing: 0.1em; margin-bottom: @space-2xl;
}
.values-grid {
display: grid; grid-template-columns: 1fr; gap: @space-lg;
.respond-md({ grid-template-columns: repeat(3, 1fr); });
}
.value-item { text-align: center; padding: @space-xl @space-lg; }
.value-name { font-size: @font-size-xl; color: @color-primary; margin-bottom: @space-sm; }
.value-expr { font-size: @font-size-base; color: @color-text-secondary; }
</style>
+195
查看文件
@@ -0,0 +1,195 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="品牌IP · 籁小咩" subtitle="LAI XIAO MIE" />
<div class="mascot-hero">
<div class="mascot-image">
<img
:src="images.mascot.front"
alt="籁小咩 - 呼籁旅行品牌IP吉祥物,一只可爱的小羊形象"
width="280"
height="380"
loading="lazy"
/>
</div>
<div class="mascot-info">
<h3 class="mascot-name">籁小咩</h3>
<p class="mascot-intro">呼籁旅行的品牌IP吉祥物,灵感来自呼伦贝尔草原上自由奔跑的小羊。圆润的羊毛卷发、温暖的大眼睛、招手的小手势——籁小咩代表着呼籁对每一个家庭的热情欢迎。</p>
<div class="mascot-tags">
<span class="mascot-tag">原创IP形象</span>
<span class="mascot-tag">国家版权登记</span>
<span class="mascot-tag">表情包</span>
</div>
<p class="mascot-copyright">国家版权局作品登记:国作登字-2026-F-00020268</p>
</div>
</div>
<!-- 草原场景 -->
<div class="mascot-banner">
<img
:src="images.mascot.banner"
alt="籁小咩在呼伦贝尔草原上向你招手"
width="800"
height="300"
loading="lazy"
/>
</div>
<!-- 表情包二维码 -->
<div class="mascot-sticker">
<div class="mascot-sticker-qr">
<img
:src="images.mascot.stickerQr"
alt="籁小咩微信表情包二维码"
width="160"
height="160"
loading="lazy"
/>
</div>
<div class="mascot-sticker-text">
<p class="mascot-sticker-title">把籁小咩带进你的聊天</p>
<p class="mascot-sticker-desc">微信扫一扫,免费领取「籁小咩」表情包。旅行前发给家人朋友,快乐先行一步。</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
</script>
<style lang="less" scoped>
.mascot-hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: @space-xl;
max-width: 800px;
margin: 0 auto @space-2xl;
.respond-md({
flex-direction: row;
text-align: left;
gap: @space-2xl;
});
}
.mascot-image {
flex-shrink: 0;
img {
width: 200px;
height: auto;
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.1));
}
}
.mascot-info {
flex: 1;
}
.mascot-name {
font-size: 28px;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-md;
}
.mascot-intro {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
margin-bottom: @space-lg;
}
.mascot-tags {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
margin-bottom: @space-md;
justify-content: center;
.respond-md({
justify-content: flex-start;
});
}
.mascot-tag {
font-size: @font-size-xs;
color: @color-primary;
background: @color-primary-bg;
padding: 4px 12px;
border-radius: 20px;
}
.mascot-copyright {
font-size: @font-size-xs;
color: @color-text-muted;
margin: 0;
}
.mascot-banner {
max-width: 800px;
margin: 0 auto @space-2xl;
border-radius: @border-radius-lg;
overflow: hidden;
img {
width: 100%;
height: auto;
display: block;
}
}
.mascot-sticker {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: @space-lg;
max-width: 500px;
margin: 0 auto;
padding: @space-xl;
background: @color-bg-white;
border-radius: @border-radius-lg;
border: 1px solid @color-border;
.respond-md({
flex-direction: row;
text-align: left;
});
}
.mascot-sticker-qr {
flex-shrink: 0;
img {
width: 120px;
height: 120px;
border-radius: @border-radius-md;
}
}
.mascot-sticker-text {
flex: 1;
}
.mascot-sticker-title {
font-size: @font-size-md;
font-weight: @font-weight-medium;
color: @color-text-primary;
margin-bottom: @space-sm;
}
.mascot-sticker-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: 1.6;
margin: 0;
}
</style>
+131
查看文件
@@ -0,0 +1,131 @@
<template>
<section class="section">
<div class="container">
<!-- 资质认证 -->
<CommonSectionTitle title="资质认证" />
<div class="qual-list">
<div v-for="c in certifications" :key="c.title" class="qual-item qual-item--cert">
<div class="qual-icon">🏆</div>
<div class="qual-body">
<h3 class="qual-title">{{ c.title }}</h3>
<p class="qual-detail">{{ c.detail }}</p>
</div>
</div>
</div>
<!-- 小红书WILL大会获奖照片 -->
<div class="award-gallery">
<div class="award-gallery-grid">
<img src="/images/awards/will-award-person.jpg" alt="呼籁旅行创始人在2026小红书WILL商业大会领取年度成长型品牌奖" class="award-img award-img--main" loading="lazy" />
<img src="/images/awards/will-award-trophy.jpg" alt="2026小红书WILL商业大会年度成长型品牌奖杯" class="award-img" loading="lazy" />
<img src="/images/awards/will-award-stage.jpg" alt="2026小红书WILL商业大会年度成长型品牌颁奖典礼" class="award-img" loading="lazy" />
</div>
<p class="award-caption">2026小红书WILL商业大会 · 年度成长型品牌 · 文旅行业仅6家获此殊荣</p>
</div>
<!-- 服务保障 -->
<CommonSectionTitle title="服务保障" style="margin-top: 48px;" />
<div class="qual-list qual-list--grid">
<div v-for="g in guarantees" :key="g.title" class="qual-item qual-item--guarantee">
<div class="qual-icon">✅</div>
<div class="qual-body">
<h3 class="qual-title">{{ g.title }}</h3>
<p class="qual-detail">{{ g.detail }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
certifications: { type: Array, required: true },
guarantees: { type: Array, required: true }
})
</script>
<style lang="less" scoped>
.qual-list {
display: flex;
flex-direction: column;
gap: @space-md;
max-width: 800px;
margin: 0 auto;
}
.qual-list--grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: @space-md;
.respond-md({
grid-template-columns: 1fr;
});
}
.qual-item {
display: flex;
align-items: flex-start;
gap: @space-sm;
padding: @space-lg;
border-radius: @border-radius-md;
transition: transform 0.2s ease;
&:hover { transform: translateY(-2px); }
}
.qual-item--cert {
background: @color-primary-bg;
border-left: 3px solid @color-primary;
}
.qual-item--guarantee {
background: #f8faf5;
border-left: 3px solid @color-primary;
}
.qual-icon {
font-size: 24px;
flex-shrink: 0;
line-height: 1;
}
.qual-body { flex: 1; }
.qual-title {
font-size: @font-size-md;
color: @color-text-primary;
margin-bottom: @space-xs;
font-weight: @font-weight-bold;
}
.qual-detail {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
.award-gallery {
max-width: 800px;
margin: @space-xl auto 0;
}
.award-gallery-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: @space-sm;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.award-img {
width: 100%;
border-radius: @border-radius-md;
object-fit: cover;
aspect-ratio: 3 / 4;
@media (max-width: 768px) {
aspect-ratio: 4 / 3;
}
}
.award-caption {
text-align: center;
margin-top: @space-md;
font-size: @font-size-xs;
color: @color-text-muted;
}
</style>
+104
查看文件
@@ -0,0 +1,104 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle :title="`${subsidiaries.length}家集团企业`" subtitle="各司其职,共同为您的旅行保驾护航" />
<!-- 集团母公司 -->
<div v-if="parent" class="sub-parent">
<span class="sub-parent-tag">集团母公司</span>
<h3 class="sub-parent-name">{{ parent.name }}</h3>
<p class="sub-parent-role">{{ parent.role }}</p>
<p v-if="parent.established" class="sub-parent-date">成立于{{ formatDate(parent.established) }}</p>
</div>
<!-- 子公司网格 -->
<div class="sub-grid">
<div v-for="sub in children" :key="sub.name" class="sub-item">
<h3 class="sub-name">{{ sub.name }}</h3>
<p class="sub-role">{{ sub.role }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
const props = defineProps({ subsidiaries: { type: Array, required: true } })
const parent = props.subsidiaries.find(s => s.role.includes('集团母公司') || s.role.includes('母公司'))
const children = props.subsidiaries.filter(s => s !== parent)
function formatDate(dateStr) {
const [year, month] = dateStr.split('-')
return `${year}年${parseInt(month)}月`
}
</script>
<style lang="less" scoped>
.sub-parent {
max-width: 900px;
margin: 0 auto @space-xl;
padding: @space-xl @space-2xl;
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
text-align: center;
}
.sub-parent-tag {
display: inline-block;
font-size: @font-size-xs;
color: @color-bg-white;
background: @color-primary;
padding: 2px 12px;
border-radius: 20px;
margin-bottom: @space-sm;
}
.sub-parent-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.sub-parent-role {
font-size: @font-size-base;
color: @color-text-secondary;
margin-bottom: @space-xs;
}
.sub-parent-date {
font-size: @font-size-xs;
color: @color-text-muted;
margin: 0;
}
.sub-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-md;
.respond-md({ grid-template-columns: repeat(2, 1fr); });
.respond-lg({ grid-template-columns: repeat(2, 1fr); max-width: 900px; margin-left: auto; margin-right: auto; });
}
.sub-item {
padding: @space-lg;
background: @color-bg-white;
border-radius: @border-radius-md;
border: 1px solid @color-border;
}
.sub-name {
font-size: @font-size-base;
color: @color-primary;
margin-bottom: @space-xs;
}
.sub-role {
font-size: @font-size-sm;
color: @color-text-secondary;
}
</style>
+179
查看文件
@@ -0,0 +1,179 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="我们的团队" />
<p class="team-summary">{{ team.summary }}</p>
<!-- 团队照片墙 -->
<div class="team-gallery">
<!-- 上排:两年踩线对比,体现年年坚持 -->
<div class="team-gallery-row team-gallery-row--survey">
<div class="team-photo-card">
<div class="team-photo-wrap team-photo-wrap--survey">
<img
:src="images.team.guideGroup"
alt="2024呼籁文旅车队踩线全体领队合影"
loading="lazy"
/>
</div>
<div class="team-photo-caption">
<span class="caption-tag">2024踩线</span>
<span class="caption-text">「额吉的故乡」全体车队师傅集体踩线</span>
</div>
</div>
<div class="team-photo-card">
<div class="team-photo-wrap team-photo-wrap--survey">
<img
:src="images.team.routeSurvey"
alt="2025秋呼籁文旅游牧的森林车队踩线全体领队合影"
loading="lazy"
/>
</div>
<div class="team-photo-caption">
<span class="caption-tag">2025踩线</span>
<span class="caption-text">「游牧的森林」新品路线全员实地考察</span>
</div>
</div>
</div>
<!-- 下排:培训 + 牧场 -->
<div class="team-gallery-row team-gallery-row--misc">
<div class="team-photo-card">
<div class="team-photo-wrap team-photo-wrap--misc">
<img
:src="images.team.guideTraining"
alt="呼籁文旅2026年司导线下培训"
loading="lazy"
/>
</div>
<div class="team-photo-caption">
<span class="caption-tag">专业培训</span>
<span class="caption-text">每年冬季集中培训,统一服务标准</span>
</div>
</div>
<div class="team-photo-card">
<div class="team-photo-wrap team-photo-wrap--misc">
<img
:src="images.team.ranchStore"
alt="呼籁牧场线下店开业团队合影"
loading="lazy"
/>
</div>
<div class="team-photo-caption">
<span class="caption-tag">实体布局</span>
<span class="caption-text">呼籁牧场线下体验店正式落成</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ team: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.team-summary {
max-width: 800px;
margin: 0 auto @space-2xl;
text-align: center;
font-size: @font-size-md;
line-height: @line-height-base;
color: @color-text-secondary;
}
.team-gallery {
display: flex;
flex-direction: column;
gap: @space-lg;
.respond-md({
gap: @space-xl;
});
}
.team-gallery-row {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
gap: @space-xl;
});
}
.team-gallery-row--survey {
.respond-md({
grid-template-columns: 1fr 1fr;
});
}
.team-gallery-row--misc {
.respond-md({
grid-template-columns: 1fr 1fr;
});
}
.team-photo-card {
border-radius: @border-radius-lg;
overflow: hidden;
background: @color-bg-white;
box-shadow: @shadow-md;
transition: transform @transition-base, box-shadow @transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: @shadow-lg;
}
}
.team-photo-wrap {
position: relative;
overflow: hidden;
img {
width: 100%;
display: block;
object-fit: cover;
}
}
.team-photo-wrap--survey {
img {
aspect-ratio: 16 / 9;
}
}
.team-photo-wrap--misc {
img {
aspect-ratio: 16 / 10;
}
}
.team-photo-caption {
padding: @space-md @space-lg;
display: flex;
align-items: center;
gap: @space-md;
}
.caption-tag {
flex-shrink: 0;
display: inline-block;
padding: 3px 10px;
background: @color-primary-bg;
color: @color-primary;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
border-radius: @border-radius-sm;
}
.caption-text {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: 1.5;
}
</style>
+211
查看文件
@@ -0,0 +1,211 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="小红书上的呼籁" subtitle="XIAOHONGSHU" />
<div class="xhs-card">
<!-- 顶部:品牌主页截图作为横幅 -->
<div class="xhs-banner">
<img
:src="images.xiaohongshu.storefront"
alt="呼籁旅行小红书品牌主页 - Mr.小雷-呼籁旅行 50万家庭青睐的草原旅行品牌"
width="800"
height="300"
loading="lazy"
/>
</div>
<!-- 底部:数据 + 信息 -->
<div class="xhs-body">
<!-- 账号信息 -->
<div class="xhs-header">
<div class="xhs-account">
<span class="xhs-account-name">{{ xhs.account }}</span>
<span class="xhs-badge">{{ xhs.verifiedType }}</span>
</div>
<p class="xhs-tagline">{{ xhs.tagline }}</p>
</div>
<!-- 数据指标 -->
<div class="xhs-stats">
<div class="xhs-stat">
<span class="xhs-stat-value">{{ xhs.followers }}</span>
<span class="xhs-stat-label">粉丝</span>
</div>
<div class="xhs-stat-divider" />
<div class="xhs-stat">
<span class="xhs-stat-value">{{ xhs.likes }}</span>
<span class="xhs-stat-label">获赞与收藏</span>
</div>
</div>
<!-- 荣誉 + 描述 -->
<div class="xhs-awards">
<span v-for="award in xhs.awards" :key="award" class="xhs-award">{{ award }}</span>
</div>
<p class="xhs-desc">{{ xhs.description }}</p>
<div class="xhs-tags">
<span v-for="tag in xhs.tags" :key="tag" class="xhs-tag">{{ tag }}</span>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ xhs: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.xhs-card {
max-width: 800px;
margin: 0 auto;
border-radius: @border-radius-lg;
overflow: hidden;
background: @color-bg-white;
box-shadow: @shadow-md;
}
.xhs-banner {
position: relative;
overflow: hidden;
img {
width: 100%;
display: block;
}
}
.xhs-body {
padding: @space-xl;
.respond-md({
padding: @space-xl @space-2xl;
});
}
.xhs-header {
margin-bottom: @space-lg;
}
.xhs-account {
display: flex;
align-items: center;
gap: @space-sm;
margin-bottom: @space-xs;
flex-wrap: wrap;
}
.xhs-account-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
}
.xhs-badge {
display: inline-block;
padding: 2px 10px;
background: #ff2442;
color: #fff;
font-size: @font-size-xs;
border-radius: 20px;
font-weight: @font-weight-medium;
}
.xhs-tagline {
font-size: @font-size-base;
color: @color-primary;
font-weight: @font-weight-medium;
margin: 0;
}
.xhs-stats {
display: flex;
align-items: center;
gap: @space-lg;
padding: @space-lg 0;
margin-bottom: @space-lg;
border-top: 1px solid @color-border;
border-bottom: 1px solid @color-border;
}
.xhs-stat {
text-align: center;
flex: 1;
}
.xhs-stat-divider {
width: 1px;
height: 32px;
background: @color-border;
flex-shrink: 0;
}
.xhs-stat-value {
display: block;
font-size: 24px;
font-weight: @font-weight-bold;
color: @color-text-primary;
line-height: 1.2;
.respond-md({
font-size: 28px;
});
}
.xhs-stat-label {
font-size: @font-size-xs;
color: @color-text-muted;
margin-top: 4px;
display: block;
}
.xhs-awards {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
margin-bottom: @space-md;
}
.xhs-award {
display: inline-flex;
align-items: center;
padding: 5px 12px;
background: linear-gradient(135deg, #FFF8E1, #FFF3CD);
color: #B8860B;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
border-radius: @border-radius-sm;
border: 1px solid #F0D58C;
&::before {
content: "🏆";
margin-right: 6px;
}
}
.xhs-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-loose;
margin-bottom: @space-md;
}
.xhs-tags {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
}
.xhs-tag {
font-size: @font-size-xs;
color: @color-text-secondary;
background: @color-bg-gray;
padding: 4px 12px;
border-radius: 20px;
}
</style>
+250
查看文件
@@ -0,0 +1,250 @@
<template>
<article class="autumn-card" :class="{ 'autumn-card--popular': isPopular }">
<!-- 头部 -->
<div class="card-header">
<div v-if="isPopular" class="popular-badge">推荐</div>
<div class="card-duration">
<span class="duration-num">{{ product.days }}</span>
<span class="duration-unit">天</span>
<span class="duration-num">{{ product.nights }}</span>
<span class="duration-unit">晚</span>
</div>
<span class="card-tag">{{ product.tag }}</span>
<span class="card-line">{{ product.line }} · {{ product.route }}</span>
</div>
<!-- 主体 -->
<div class="card-body">
<h3 class="card-name">{{ shortName }}</h3>
<p class="card-audience">适合:{{ product.audience }}</p>
<p class="card-desc">{{ product.description }}</p>
<ul class="card-highlights">
<li v-for="(h, i) in product.highlights" :key="i">
<span class="check" aria-hidden="true">&#10003;</span>
{{ h }}
</li>
</ul>
</div>
<!-- 行程概览 -->
<details v-if="product.itinerary && product.itinerary.length" class="card-itinerary">
<summary class="itinerary-toggle">查看每日行程 ▾</summary>
<ol class="itinerary-list">
<li v-for="(day, i) in product.itinerary" :key="i">{{ day }}</li>
</ol>
</details>
<!-- 底部 CTA -->
<NuxtLink to="/contact" class="card-cta">
咨询该行程 <span aria-hidden="true">→</span>
</NuxtLink>
</article>
</template>
<script setup>
const props = defineProps({ product: { type: Object, required: true } })
const isPopular = computed(() => props.product.id === 'autumn-south-7d6n')
const shortName = computed(() => {
return props.product.name.replace(/^游牧的森林V3·/, '')
})
</script>
<style lang="less" scoped>
.autumn-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow @transition-base, transform @transition-base;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-4px);
}
&--popular {
border: 2px solid #D97706;
box-shadow: @shadow-md;
.card-header {
background: #D97706;
}
.duration-num,
.duration-unit,
.card-tag,
.card-line {
color: #fff;
}
.card-tag {
background: rgba(255, 255, 255, 0.2);
}
}
}
.card-header {
padding: @space-lg @space-lg @space-md;
background: #FEF3C7;
text-align: center;
position: relative;
}
.popular-badge {
position: absolute;
top: @space-sm;
right: @space-sm;
padding: @space-xs @space-sm;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: #fff;
background: @color-accent;
border-radius: @border-radius-sm;
}
.card-duration {
display: flex;
align-items: baseline;
justify-content: center;
gap: 2px;
margin-bottom: @space-sm;
}
.duration-num {
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: #D97706;
line-height: 1;
}
.duration-unit {
font-size: @font-size-sm;
color: #D97706;
font-weight: @font-weight-medium;
}
.card-tag {
display: inline-block;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: #D97706;
background: @color-bg-white;
border-radius: @border-radius-full;
}
.card-line {
display: block;
font-size: @font-size-xs;
color: #64748B;
margin-top: @space-xs;
}
.card-body {
padding: @space-lg;
flex: 1;
display: flex;
flex-direction: column;
}
.card-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.card-audience {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.card-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
.text-clamp(3);
}
.card-highlights {
display: flex;
flex-direction: column;
gap: @space-sm;
margin-top: auto;
li {
display: flex;
align-items: flex-start;
gap: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
}
.check {
flex-shrink: 0;
color: #D97706;
font-size: @font-size-sm;
font-weight: @font-weight-bold;
margin-top: 2px;
}
// 行程概览
.card-itinerary {
border-top: 1px solid @color-border;
}
.itinerary-toggle {
display: block;
padding: @space-md @space-lg;
font-size: @font-size-sm;
color: @color-text-muted;
cursor: pointer;
text-align: center;
transition: background @transition-fast;
&:hover {
background: @color-bg-gray;
color: @color-text-primary;
}
}
.itinerary-list {
padding: 0 @space-lg @space-md;
display: flex;
flex-direction: column;
gap: @space-xs;
li {
font-size: @font-size-xs;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-sm;
}
}
// 底部 CTA
.card-cta {
display: block;
text-align: center;
padding: @space-md @space-lg;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: #D97706;
background: @color-bg-gray;
text-decoration: none;
transition: all @transition-fast;
&:hover {
background: #D97706;
color: #fff;
}
}
</style>
+55
查看文件
@@ -0,0 +1,55 @@
<template>
<nav v-if="items.length" class="breadcrumb" aria-label="面包屑导航">
<ol class="breadcrumb-list">
<li class="breadcrumb-item">
<NuxtLink to="/" class="breadcrumb-link">首页</NuxtLink>
</li>
<li v-for="(item, index) in items" :key="index" class="breadcrumb-item">
<span class="breadcrumb-sep" aria-hidden="true">/</span>
<NuxtLink v-if="item.to" :to="item.to" class="breadcrumb-link">{{ item.text }}</NuxtLink>
<span v-else class="breadcrumb-current" aria-current="page">{{ item.text }}</span>
</li>
</ol>
</nav>
</template>
<script setup>
defineProps({
items: { type: Array, default: () => [] },
})
</script>
<style lang="less" scoped>
.breadcrumb-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: @space-xs;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: @space-xs;
}
.breadcrumb-sep {
color: @color-text-muted;
font-size: @font-size-sm;
}
.breadcrumb-link {
font-size: @font-size-sm;
color: @color-text-muted;
text-decoration: none;
&:hover {
color: @color-primary;
}
}
.breadcrumb-current {
font-size: @font-size-sm;
color: @color-text-secondary;
}
</style>
+26
查看文件
@@ -0,0 +1,26 @@
<template>
<div class="content-card" :class="{ 'content-card--hoverable': hoverable }">
<slot />
</div>
</template>
<script setup>
defineProps({
hoverable: { type: Boolean, default: true },
})
</script>
<style lang="less" scoped>
.content-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
padding: @space-xl;
transition: box-shadow 0.3s cubic-bezier(0.23, 1, 0.32, 1), transform 0.3s cubic-bezier(0.23, 1, 0.32, 1);
&--hoverable:hover {
box-shadow: @shadow-md;
transform: translateY(-3px);
}
}
</style>
+37
查看文件
@@ -0,0 +1,37 @@
<template>
<NuxtLink :to="to" class="internal-link">
<span>{{ text }}</span>
<span v-if="arrow" class="internal-link-arrow" aria-hidden="true">&rarr;</span>
</NuxtLink>
</template>
<script setup>
defineProps({
to: { type: String, required: true },
text: { type: String, required: true },
arrow: { type: Boolean, default: true },
})
</script>
<style lang="less" scoped>
.internal-link {
display: inline-flex;
align-items: center;
gap: @space-sm;
font-size: @font-size-base;
color: @color-primary;
text-decoration: none;
font-weight: @font-weight-medium;
transition: gap @transition-fast;
&:hover {
gap: @space-md;
color: @color-primary-light;
}
}
.internal-link-arrow {
font-size: @font-size-lg;
transition: transform @transition-fast;
}
</style>
+54
查看文件
@@ -0,0 +1,54 @@
<template>
<div class="qr-block" :class="[`qr-block--${size}`]">
<img :src="image" :alt="`${label}二维码`" class="qr-image" loading="lazy" />
<span class="qr-label">{{ label }}</span>
</div>
</template>
<script setup>
defineProps({
image: { type: String, required: true },
label: { type: String, required: true },
size: { type: String, default: 'normal' },
})
</script>
<style lang="less" scoped>
.qr-block {
display: flex;
flex-direction: column;
align-items: center;
gap: @space-xs;
}
.qr-image {
border-radius: @border-radius-sm;
background: @color-bg-gray;
object-fit: contain;
.qr-block--small & {
width: 80px;
height: 80px;
}
.qr-block--normal & {
width: 140px;
height: 140px;
}
.qr-block--large & {
width: 180px;
height: 180px;
}
}
.qr-label {
font-size: @font-size-xs;
color: @color-text-muted;
.qr-block--normal &,
.qr-block--large & {
font-size: @font-size-sm;
}
}
</style>
+160
查看文件
@@ -0,0 +1,160 @@
<template>
<section class="section-cta" :class="`section-cta--${bg}`">
<div class="section-cta-bg" />
<div class="container text-center section-cta-inner">
<h2 class="section-cta-title">{{ title }}</h2>
<p class="section-cta-desc">{{ description }}</p>
<div class="section-cta-links">
<NuxtLink
v-for="(link, index) in links"
:key="link.to"
:to="link.to"
class="cta-btn"
:class="index === 0 ? 'cta-btn--primary' : 'cta-btn--outline'"
>
{{ link.text }}
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
description: { type: String, required: true },
links: { type: Array, required: true },
bg: { type: String, default: 'green' },
})
</script>
<style lang="less" scoped>
.section-cta {
position: relative;
overflow: hidden;
.section-padding();
}
.section-cta--green {
background: linear-gradient(135deg, @color-primary-dark 0%, @color-primary 50%, @color-primary-light 100%);
}
.section-cta--warm {
background: linear-gradient(135deg, @color-bg-warm 0%, @color-accent-bg 100%);
}
.section-cta-bg {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 20% 50%, rgba(255, 255, 255, 0.08) 0%, transparent 60%),
radial-gradient(ellipse at 80% 50%, rgba(255, 255, 255, 0.05) 0%, transparent 60%);
pointer-events: none;
}
.section-cta-inner {
position: relative;
z-index: 1;
}
.section-cta-title {
.section-cta--green & {
color: #fff;
}
&::after {
display: none;
}
}
.section-cta-desc {
margin-top: @space-md;
margin-bottom: @space-xl;
font-size: @font-size-md;
line-height: @line-height-relaxed;
max-width: 560px;
margin-left: auto;
margin-right: auto;
.section-cta--green & {
color: rgba(255, 255, 255, 0.8);
}
.section-cta--warm & {
color: @color-text-secondary;
}
}
.section-cta-links {
display: flex;
justify-content: center;
gap: @space-md;
flex-wrap: wrap;
}
.cta-btn {
display: inline-flex;
align-items: center;
padding: 14px @space-xl;
border-radius: @border-radius-md;
font-size: @font-size-base;
font-weight: @font-weight-medium;
text-decoration: none;
transition: all @transition-base;
.respond-md({
padding: 16px 36px;
font-size: @font-size-md;
});
&--primary {
.section-cta--green & {
background: #fff;
color: @color-primary;
border: 1px solid #fff;
&:hover {
background: rgba(255, 255, 255, 0.9);
color: @color-primary-dark;
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
}
.section-cta--warm & {
background: @color-primary;
color: #fff;
border: 1px solid @color-primary;
&:hover {
background: @color-primary-light;
border-color: @color-primary-light;
color: #fff;
}
}
}
&--outline {
.section-cta--green & {
border: 1px solid rgba(255, 255, 255, 0.5);
color: #fff;
&:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.8);
color: #fff;
}
}
.section-cta--warm & {
background: transparent;
color: @color-primary;
border: 1px solid @color-primary;
&:hover {
background: @color-primary;
color: #fff;
}
}
}
}
</style>
+74
查看文件
@@ -0,0 +1,74 @@
<template>
<div class="section-title" :class="[`section-title--${align}`]">
<span v-if="label" class="section-title-label">{{ label }}</span>
<component :is="tag" class="section-title-text">{{ title }}</component>
<p v-if="subtitle" class="section-title-sub">{{ subtitle }}</p>
</div>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
label: { type: String, default: '' },
tag: { type: String, default: 'h2' },
align: { type: String, default: 'center' },
})
</script>
<style lang="less" scoped>
.section-title {
margin-bottom: @space-xl;
.respond-md({
margin-bottom: @space-2xl;
});
&--center {
text-align: center;
}
&--left {
text-align: left;
}
}
.section-title-label {
display: inline-block;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: @color-primary;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: @space-sm;
}
.section-title-text {
color: @color-text-primary;
// 标题下方品牌色渐变短线装饰
&::after {
content: '';
display: block;
width: 48px;
height: 3px;
background: linear-gradient(90deg, @color-primary, @color-primary-light);
border-radius: 2px;
margin-top: @space-md;
}
.section-title--center & {
&::after {
margin-left: auto;
margin-right: auto;
}
}
}
.section-title-sub {
margin-top: @space-md;
font-size: @font-size-md;
color: @color-text-muted;
line-height: @line-height-relaxed;
}
</style>
+83
查看文件
@@ -0,0 +1,83 @@
<template>
<div class="trust-badge" :class="{ 'trust-badge--dark': dark }">
<div class="trust-badge-value">
<span class="trust-number">{{ value }}</span>
<span v-if="unit" class="trust-unit">{{ unit }}</span>
</div>
<div class="trust-badge-label">{{ label }}</div>
<div v-if="attribution" class="trust-badge-note">{{ attribution }}</div>
</div>
</template>
<script setup>
defineProps({
value: { type: String, required: true },
unit: { type: String, default: '' },
label: { type: String, required: true },
attribution: { type: String, default: '' },
dark: { type: Boolean, default: false },
})
</script>
<style lang="less" scoped>
.trust-badge {
text-align: center;
padding: @space-lg @space-md;
}
.trust-badge-value {
display: flex;
align-items: baseline;
justify-content: center;
gap: @space-xs;
}
.trust-number {
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: @color-primary;
letter-spacing: -1px;
.respond-md({
font-size: @font-size-display;
});
.trust-badge--dark & {
color: #fff;
}
}
.trust-unit {
font-size: @font-size-base;
color: @color-primary-light;
.respond-md({
font-size: @font-size-md;
});
.trust-badge--dark & {
color: @color-primary-lighter;
}
}
.trust-badge-label {
margin-top: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
font-weight: @font-weight-medium;
.trust-badge--dark & {
color: rgba(255, 255, 255, 0.6);
}
}
.trust-badge-note {
margin-top: @space-xs;
font-size: @font-size-xs;
color: @color-text-muted;
.trust-badge--dark & {
color: rgba(255, 255, 255, 0.35);
}
}
</style>
+43
查看文件
@@ -0,0 +1,43 @@
<template>
<CommonContentCard :class="{ 'channel-primary': channel.primary }">
<div class="channel">
<span v-if="channel.primary" class="channel-badge">推荐</span>
<h3 class="channel-label">{{ channel.label }}</h3>
<p class="channel-value">{{ channel.value }}</p>
<p v-if="channel.description" class="channel-desc">{{ channel.description }}</p>
<CommonQRCodeBlock
v-if="channel.qrImage"
:image="channel.qrImage"
:label="channel.label"
size="normal"
/>
</div>
</CommonContentCard>
</template>
<script setup>
defineProps({ channel: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.channel { text-align: center; }
.channel-label { font-size: @font-size-md; color: @color-primary; margin-bottom: @space-sm; }
.channel-value { font-size: @font-size-base; color: @color-text-primary; font-weight: @font-weight-medium; margin-bottom: @space-xs; }
.channel-desc { font-size: @font-size-sm; color: @color-text-muted; margin-bottom: @space-md; }
.channel-primary {
border: 2px solid @color-primary;
position: relative;
}
.channel-badge {
display: inline-block;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: #fff;
background: @color-primary;
border-radius: @border-radius-full;
margin-bottom: @space-md;
}
</style>
+14
查看文件
@@ -0,0 +1,14 @@
<template>
<div class="contact-map">
<p class="map-address">{{ address }}</p>
</div>
</template>
<script setup>
defineProps({ address: { type: String, required: true } })
</script>
<style lang="less" scoped>
.contact-map { padding: @space-lg; background: @color-bg-gray; border-radius: @border-radius-md; text-align: center; }
.map-address { color: @color-text-secondary; }
</style>
+43
查看文件
@@ -0,0 +1,43 @@
<template>
<section :id="category.id" class="faq-category">
<h2 class="faq-category-title">{{ category.name }}</h2>
<div class="faq-category-list">
<FaqItem
v-for="q in category.questions"
:key="q.id"
:question="q.question"
:answer="q.answer"
:related-links="q.relatedLinks"
:initial-open="initialOpen"
/>
</div>
</section>
</template>
<script setup>
defineProps({
category: { type: Object, required: true },
initialOpen: { type: Boolean, default: false },
})
</script>
<style lang="less" scoped>
.faq-category {
margin-bottom: @space-2xl;
scroll-margin-top: 80px;
}
.faq-category-title {
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;
}
</style>
+85
查看文件
@@ -0,0 +1,85 @@
<template>
<nav class="faq-nav" aria-label="问题分类">
<ul class="faq-nav-list">
<li v-for="cat in categories" :key="cat.id">
<a
:href="`#${cat.id}`"
class="faq-nav-item"
:class="{ active: activeCat === cat.id }"
@click.prevent="$emit('select', cat.id)"
>
{{ cat.name }}
<span class="faq-nav-count">{{ cat.questions.length }}</span>
</a>
</li>
</ul>
</nav>
</template>
<script setup>
defineProps({
categories: { type: Array, required: true },
activeCat: { type: String, default: '' },
})
defineEmits(['select'])
</script>
<style lang="less" scoped>
.faq-nav {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
// 移动端右侧渐变提示可横滑
mask-image: linear-gradient(to right, #000 85%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, #000 85%, transparent 100%);
.respond-md({
mask-image: none;
-webkit-mask-image: none;
overflow-x: visible;
});
}
.faq-nav-list {
display: flex;
gap: @space-sm;
min-width: max-content;
.respond-md({
justify-content: center;
flex-wrap: wrap;
});
}
.faq-nav-item {
display: inline-flex;
align-items: center;
gap: @space-xs;
padding: @space-sm @space-lg;
font-size: @font-size-sm;
color: @color-text-secondary;
background: @color-bg-gray;
border-radius: @border-radius-full;
text-decoration: none;
white-space: nowrap;
transition: all @transition-fast;
min-height: 44px;
touch-action: manipulation;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
&.active {
background: @color-primary;
color: #fff;
}
}
.faq-nav-count {
font-size: @font-size-xs;
opacity: 0.7;
}
</style>
+170
查看文件
@@ -0,0 +1,170 @@
<template>
<!--
SEO关键设计:
1. 用<details>/<summary>确保内容在HTML中始终存在(百度蜘蛛可抓取)
2. 问题用<h3>标签,语义化
3. 答案用<p>纯文本,AI直接引用
-->
<details class="faq-item" :open="initialOpen || undefined">
<summary class="faq-item-question">
<h3 class="faq-item-q-text">{{ question }}</h3>
<span class="faq-item-icon" aria-hidden="true"></span>
</summary>
<div class="faq-item-answer">
<p class="faq-item-a-text">{{ answer }}</p>
<div v-if="relatedLinks && relatedLinks.length" class="faq-item-links">
<NuxtLink
v-for="link in relatedLinks"
:key="link.url"
:to="link.url"
class="faq-item-link"
>
{{ link.text }} →
</NuxtLink>
</div>
</div>
</details>
</template>
<script setup>
defineProps({
question: { type: String, required: true },
answer: { type: String, required: true },
relatedLinks: { type: Array, default: () => [] },
initialOpen: { type: Boolean, default: false },
})
</script>
<style lang="less" scoped>
.faq-item {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
overflow: hidden;
transition: border-color @transition-fast;
&[open] {
border-color: @color-primary-lighter;
.faq-item-icon {
transform: rotate(180deg);
}
}
&:hover {
border-color: @color-primary-lighter;
}
}
.faq-item-question {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-lg;
cursor: pointer;
list-style: none;
touch-action: manipulation;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
content: '';
}
&:hover {
background: @color-bg-gray;
}
}
.faq-item-q-text {
font-size: @font-size-md;
font-weight: @font-weight-medium;
color: @color-text-primary;
flex: 1;
padding-right: @space-md;
}
.faq-item-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
position: relative;
transition: transform @transition-base;
&::before,
&::after {
content: '';
position: absolute;
background: @color-text-muted;
transition: opacity @transition-fast;
}
&::before {
top: 50%;
left: 2px;
right: 2px;
height: 2px;
transform: translateY(-50%);
}
&::after {
left: 50%;
top: 2px;
bottom: 2px;
width: 2px;
transform: translateX(-50%);
}
details[open] & {
&::after {
opacity: 0;
}
}
}
// 答案展开动画
.faq-item-answer {
padding: 0 @space-lg @space-lg;
animation: fadeSlideIn @transition-base;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.faq-item-a-text {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-base;
}
.faq-item-links {
display: flex;
flex-wrap: wrap;
gap: @space-md;
margin-top: @space-md;
padding-top: @space-md;
border-top: 1px solid @color-border;
}
.faq-item-link {
font-size: @font-size-sm;
color: @color-primary;
text-decoration: none;
font-weight: @font-weight-medium;
&:hover {
color: @color-primary-light;
}
}
</style>
+67
查看文件
@@ -0,0 +1,67 @@
<template>
<div class="activity-card">
<h4 class="activity-name">
<span class="activity-icon">{{ activity.icon }}</span>
{{ activity.name }}
</h4>
<ul class="activity-points">
<li v-for="(point, i) in activity.points" :key="i" class="activity-point">
{{ point }}
</li>
</ul>
</div>
</template>
<script setup>
defineProps({
activity: { type: Object, required: true },
})
</script>
<style lang="less" scoped>
.activity-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-lg;
}
.activity-name {
display: flex;
align-items: center;
gap: @space-sm;
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-primary;
margin: 0 0 @space-md;
}
.activity-icon {
font-size: @font-size-lg;
}
.activity-points {
display: flex;
flex-direction: column;
gap: @space-sm;
padding: 0;
margin: 0;
list-style: none;
}
.activity-point {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-md;
position: relative;
&::before {
content: '·';
position: absolute;
left: 0;
color: @color-primary;
font-weight: @font-weight-bold;
}
}
</style>
+53
查看文件
@@ -0,0 +1,53 @@
<template>
<div class="guide-checklist">
<div
v-for="(item, i) in items"
:key="i"
class="checklist-item"
:class="{ 'checklist-item--important': item.important }"
>
<span class="checklist-mark">{{ item.important ? '☑' : '☐' }}</span>
<span class="checklist-text">{{ item.text }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
items: { type: Array, required: true },
})
</script>
<style lang="less" scoped>
.guide-checklist {
display: flex;
flex-direction: column;
gap: @space-sm;
}
.checklist-item {
display: flex;
gap: @space-sm;
padding: @space-sm @space-md;
border-left: 2px solid @color-border;
line-height: @line-height-base;
&--important {
border-left-color: @color-primary;
}
}
.checklist-mark {
flex-shrink: 0;
color: @color-primary;
}
.checklist-text {
font-size: @font-size-sm;
color: @color-text-secondary;
.checklist-item--important & {
color: @color-text-primary;
}
}
</style>
+107
查看文件
@@ -0,0 +1,107 @@
<template>
<div class="clothing-card" :class="{ 'clothing-card--highlight': month.highlight }">
<div class="clothing-header">
<h4 class="clothing-month">{{ month.name }}</h4>
<span v-if="month.highlight" class="clothing-tag">大多数家庭选这个月</span>
</div>
<p class="clothing-desc">{{ month.desc }}</p>
<div class="clothing-temps">
<span class="temp-badge"><strong>白天</strong> {{ month.tempDay }}</span>
<span class="temp-badge"><strong>夜间</strong> {{ month.tempNight }}</span>
</div>
<div class="clothing-wear">
<p class="wear-line"><span class="wear-label">大人</span>{{ month.adultWear }}</p>
<p class="wear-line"><span class="wear-label">小朋友</span>{{ month.kidsWear }}</p>
</div>
<p v-if="month.note" class="clothing-note">{{ month.note }}</p>
</div>
</template>
<script setup>
defineProps({
month: { type: Object, required: true },
})
</script>
<style lang="less" scoped>
.clothing-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-lg;
&--highlight {
border-color: @color-primary-lighter;
box-shadow: 0 2px 12px rgba(45, 106, 79, 0.08);
}
}
.clothing-header {
display: flex;
align-items: center;
gap: @space-sm;
margin-bottom: @space-xs;
}
.clothing-month {
font-size: @font-size-base;
font-weight: @font-weight-bold;
color: @color-primary;
margin: 0;
}
.clothing-tag {
font-size: @font-size-xs;
color: @color-accent;
font-weight: @font-weight-medium;
}
.clothing-desc {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-sm;
}
.clothing-temps {
display: flex;
gap: @space-lg;
margin-bottom: @space-md;
font-size: @font-size-sm;
color: @color-text-secondary;
strong {
color: @color-primary;
}
}
.clothing-wear {
display: flex;
flex-direction: column;
gap: @space-xs;
}
.wear-line {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin: 0;
}
.wear-label {
display: inline-block;
font-weight: @font-weight-medium;
color: @color-primary;
margin-right: @space-sm;
min-width: 48px;
}
.clothing-note {
margin-top: @space-md;
padding: @space-sm @space-md;
background: @color-bg-warm;
border-radius: @border-radius-sm;
font-size: @font-size-sm;
color: @color-accent;
line-height: @line-height-base;
}
</style>
+66
查看文件
@@ -0,0 +1,66 @@
<template>
<nav class="guide-nav">
<button
v-for="section in sections"
:key="section.id"
class="guide-nav-btn"
:class="{ active: active === section.id }"
@click="$emit('select', section.id)"
>
<span class="guide-nav-icon">{{ section.icon }}</span>
<span class="guide-nav-text">{{ section.title }}</span>
</button>
</nav>
</template>
<script setup>
defineProps({
sections: { type: Array, required: true },
active: { type: String, default: '' },
})
defineEmits(['select'])
</script>
<style lang="less" scoped>
.guide-nav {
display: flex;
flex-wrap: wrap;
gap: @space-sm;
margin-bottom: @space-2xl;
.respond-md({
justify-content: center;
gap: @space-md;
});
}
.guide-nav-btn {
display: flex;
align-items: center;
gap: 6px;
padding: @space-sm @space-md;
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-full;
cursor: pointer;
font-size: @font-size-sm;
color: @color-text-secondary;
transition: all @transition-fast;
white-space: nowrap;
&:hover {
border-color: @color-primary-light;
color: @color-primary;
}
&.active {
background: @color-primary;
border-color: @color-primary;
color: #fff;
}
}
.guide-nav-icon {
font-size: @font-size-base;
}
</style>
+246
查看文件
@@ -0,0 +1,246 @@
<template>
<div class="toddler-section">
<!-- 推荐项目 -->
<div class="toddler-group">
<h3 class="group-title group-title--green">{{ data.recommended.title }}</h3>
<p class="group-desc">{{ data.recommended.desc }}</p>
<div class="toddler-items">
<div v-for="item in data.recommended.items" :key="item.name" class="toddler-item toddler-item--green">
<span class="toddler-item-icon">{{ item.icon }}</span>
<div>
<strong class="toddler-item-name">{{ item.name }}</strong>
<p class="toddler-item-desc">{{ item.desc }}</p>
</div>
</div>
</div>
</div>
<!-- 视情况选择 -->
<div class="toddler-group">
<h3 class="group-title group-title--amber">{{ data.conditional.title }}</h3>
<p class="group-desc">{{ data.conditional.desc }}</p>
<div class="toddler-items">
<div v-for="item in data.conditional.items" :key="item.name" class="toddler-item toddler-item--amber">
<span class="toddler-item-icon">{{ item.icon }}</span>
<div>
<strong class="toddler-item-name">{{ item.name }}</strong>
<p class="toddler-item-desc">{{ item.desc }}</p>
</div>
</div>
</div>
</div>
<!-- 不建议参与 -->
<div class="toddler-group">
<h3 class="group-title group-title--muted">{{ data.notRecommended.title }}</h3>
<p class="group-desc">{{ data.notRecommended.desc }}</p>
<div class="toddler-not-items">
<div v-for="item in data.notRecommended.items" :key="item.name" class="toddler-not-item">
<strong>{{ item.name }}</strong> — {{ item.reason }}
</div>
</div>
</div>
<!-- 装备 -->
<div class="toddler-group">
<h3 class="group-title group-title--green">宝宝出行装备</h3>
<div class="toddler-equip">
<div v-for="eq in data.equipment" :key="eq.name" class="equip-item" :class="{ 'equip-item--provided': eq.provided }">
<strong class="equip-name">{{ eq.name }}</strong>
<span v-if="eq.provided" class="equip-badge">呼籁提供</span>
<p class="equip-desc">{{ eq.desc }}</p>
</div>
</div>
</div>
<!-- 用餐 -->
<div class="toddler-group">
<h3 class="group-title group-title--green">宝宝用餐小贴士</h3>
<ul class="toddler-dining">
<li v-for="(tip, i) in data.dining" :key="i">{{ tip }}</li>
</ul>
</div>
<!-- 温馨提醒 -->
<div class="toddler-notes">
<div v-for="note in data.notes" :key="note.title" class="toddler-note">
<strong>{{ note.title }}</strong>
<p>{{ note.content }}</p>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
data: { type: Object, required: true },
})
</script>
<style lang="less" scoped>
.toddler-section {
display: flex;
flex-direction: column;
gap: @space-2xl;
}
.toddler-group {
// group container
}
.group-title {
font-size: @font-size-lg;
margin: 0 0 @space-sm;
&--green { color: @color-primary; }
&--amber { color: @color-accent; }
&--muted { color: @color-text-muted; }
}
.group-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
margin-bottom: @space-lg;
}
.toddler-items {
display: flex;
flex-direction: column;
gap: @space-md;
}
.toddler-item {
display: flex;
gap: @space-md;
padding: @space-md @space-lg;
background: @color-bg-white;
border-radius: @border-radius-md;
border-left: 3px solid @color-border;
&--green { border-left-color: @color-primary; }
&--amber { border-left-color: @color-accent; }
}
.toddler-item-icon {
font-size: @font-size-lg;
flex-shrink: 0;
}
.toddler-item-name {
color: @color-text-primary;
font-size: @font-size-base;
}
.toddler-item-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
margin: @space-xs 0 0;
line-height: @line-height-base;
}
.toddler-not-items {
display: flex;
flex-direction: column;
gap: @space-sm;
}
.toddler-not-item {
padding: @space-sm @space-md;
border-left: 2px solid @color-border;
font-size: @font-size-sm;
color: @color-text-muted;
line-height: @line-height-base;
strong {
color: @color-text-secondary;
}
}
.toddler-equip {
display: flex;
flex-direction: column;
gap: @space-md;
}
.equip-item {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-md @space-lg;
&--provided {
border-top: 3px solid @color-primary;
}
}
.equip-name {
font-size: @font-size-base;
color: @color-primary;
}
.equip-badge {
display: inline-block;
margin-left: @space-sm;
padding: 2px @space-sm;
background: @color-primary-bg;
color: @color-primary;
font-size: @font-size-xs;
border-radius: @border-radius-sm;
}
.equip-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
margin: @space-xs 0 0;
line-height: @line-height-base;
}
.toddler-dining {
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: @space-sm;
li {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-md;
position: relative;
&::before {
content: '☑';
position: absolute;
left: 0;
color: @color-primary;
}
}
}
.toddler-notes {
display: flex;
flex-direction: column;
gap: @space-md;
}
.toddler-note {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-md @space-lg;
strong {
color: @color-primary;
font-size: @font-size-base;
}
p {
font-size: @font-size-sm;
color: @color-text-secondary;
margin: @space-xs 0 0;
line-height: @line-height-base;
}
}
</style>
+284
查看文件
@@ -0,0 +1,284 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle :title="data.title" :subtitle="data.subtitle" />
<p class="compare-intro">{{ data.intro }}</p>
<!-- 对比表格 -->
<div class="compare-table">
<!-- 表头:三个目的地 -->
<div class="compare-header">
<div class="compare-label-col"></div>
<div
v-for="dest in data.destinations"
:key="dest.id"
class="compare-dest-col"
:class="{ 'compare-dest-col--highlight': dest.highlight }"
>
<h3 class="dest-name">{{ dest.name }}</h3>
<span class="dest-tag">{{ dest.tag }}</span>
</div>
</div>
<!-- 对比行 -->
<div v-for="dim in data.dimensions" :key="dim.label" class="compare-row">
<div class="compare-label-col">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-label">{{ dim.label }}</span>
</div>
<div
v-for="(val, i) in dim.values"
:key="i"
class="compare-value-col"
:class="{ 'compare-value-col--highlight': data.destinations[i]?.highlight }"
>
<span class="dim-label-mobile">{{ dim.icon }} {{ dim.label }}</span>
{{ val }}
</div>
</div>
</div>
<!-- 坦诚说明 -->
<div class="honest-block">
<h3 class="honest-title">{{ data.honestNote.title }}</h3>
<p class="honest-subtitle">{{ data.honestNote.subtitle }}</p>
<ul class="honest-list">
<li v-for="(item, i) in data.honestNote.items" :key="i">{{ item }}</li>
</ul>
</div>
<!-- 结语 -->
<div class="closing-block">
<h3 class="closing-title">{{ data.closing.title }}</h3>
<p class="closing-text">{{ data.closing.text }}</p>
</div>
<!-- 数据来源 -->
<p class="data-source">{{ data.dataSources }}</p>
</div>
</section>
</template>
<script setup>
import data from '~/data/destinations.json'
</script>
<style lang="less" scoped>
.compare-intro {
max-width: 800px;
margin: 0 auto @space-xl;
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
text-align: center;
}
// 对比表格
.compare-table {
margin-bottom: @space-2xl;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
}
.compare-header {
display: none;
.respond-md({
display: grid;
grid-template-columns: 120px repeat(3, 1fr);
background: @color-bg-gray;
border-bottom: 1px solid @color-border;
});
}
.compare-label-col {
padding: @space-md @space-lg;
display: flex;
align-items: center;
gap: @space-sm;
font-weight: @font-weight-medium;
color: @color-text-muted;
font-size: @font-size-sm;
}
.compare-dest-col {
padding: @space-lg;
text-align: center;
&--highlight {
background: @color-primary-bg;
position: relative;
&::after {
content: '推荐';
position: absolute;
top: @space-xs;
right: @space-sm;
padding: 2px @space-sm;
font-size: 11px;
font-weight: @font-weight-bold;
color: #fff;
background: @color-primary;
border-radius: @border-radius-sm;
}
}
}
.dest-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.dest-tag {
font-size: @font-size-xs;
color: @color-text-muted;
}
// 对比行
.compare-row {
display: flex;
flex-direction: column;
border-bottom: 1px solid @color-border;
&:last-child {
border-bottom: none;
}
.respond-md({
display: grid;
grid-template-columns: 120px repeat(3, 1fr);
flex-direction: row;
});
}
.compare-value-col {
padding: @space-md @space-lg;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
border-bottom: 1px solid fadeout(@color-border, 50%);
.respond-md({
border-bottom: none;
border-left: 1px solid fadeout(@color-border, 50%);
});
&:last-child {
border-bottom: none;
}
&--highlight {
background: fadeout(@color-primary-bg, 30%);
color: @color-text-primary;
font-weight: @font-weight-medium;
}
}
// 移动端:每个值前显示维度标签
.dim-label-mobile {
display: block;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: @color-text-muted;
margin-bottom: @space-xs;
.respond-md({
display: none;
});
}
.dim-icon {
font-size: @font-size-base;
}
.dim-label {
font-size: @font-size-sm;
}
// 移动端隐藏左侧标签列
.compare-label-col {
display: none;
.respond-md({
display: flex;
});
}
// 坦诚说明
.honest-block {
max-width: 800px;
margin: 0 auto @space-xl;
padding: @space-xl;
background: #FFF7ED;
border-radius: @border-radius-lg;
border-left: 4px solid #F59E0B;
}
.honest-title {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: #B45309;
margin-bottom: @space-xs;
}
.honest-subtitle {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.honest-list {
display: flex;
flex-direction: column;
gap: @space-sm;
li {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-md;
position: relative;
&::before {
content: '•';
position: absolute;
left: 0;
color: #F59E0B;
font-weight: bold;
}
}
}
// 结语
.closing-block {
max-width: 800px;
margin: 0 auto @space-xl;
padding: @space-xl;
background: @color-primary-bg;
border-radius: @border-radius-lg;
border-left: 4px solid @color-primary;
}
.closing-title {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-primary;
margin-bottom: @space-md;
}
.closing-text {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
}
// 数据来源
.data-source {
text-align: center;
font-size: @font-size-xs;
color: @color-text-muted;
}
</style>
+129
查看文件
@@ -0,0 +1,129 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="为什么选择呼籁" label="WHY HULAI" subtitle="不只是包车,是一套成熟的旅行服务体系" />
<div class="diff-grid">
<div
v-for="(item, index) in items"
:key="item.title"
class="diff-item"
>
<div
v-if="item.image"
class="diff-bg"
:style="{ backgroundImage: `url(${item.image})` }"
/>
<div class="diff-content">
<div class="diff-icon" v-html="icons[index % icons.length]" />
<h3 class="diff-title">{{ item.title }}</h3>
<p class="diff-desc">{{ item.description }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
items: { type: Array, required: true },
})
// SVG icons for each differentiator
const icons = [
// 营地
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21l9-18 9 18"/><path d="M12 3v18"/><path d="M3 21h18"/></svg>',
// 车队
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13" rx="2"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>',
// 体验
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
// 旅拍
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>',
// 团队
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>',
// 研学
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
]
</script>
<style lang="less" scoped>
.diff-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(2, 1fr);
});
.respond-lg({
grid-template-columns: repeat(3, 1fr);
});
}
.diff-item {
position: relative;
border-radius: @border-radius-lg;
border: 1px solid @color-border;
overflow: hidden;
background: @color-bg-white;
transition: box-shadow 0.35s cubic-bezier(0.23, 1, 0.32, 1), transform 0.35s cubic-bezier(0.23, 1, 0.32, 1);
&:hover {
box-shadow: @shadow-md;
transform: translateY(-3px);
.diff-icon {
color: @color-primary;
background: @color-primary-bg;
}
.diff-bg {
opacity: 0.45;
transform: scale(1.05);
}
}
}
.diff-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
opacity: 0.35;
filter: saturate(0.5) brightness(1.1);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.diff-content {
position: relative;
z-index: 1;
padding: @space-xl @space-lg;
background: linear-gradient(150deg, rgba(255, 255, 255, 0.97) 30%, rgba(255, 255, 255, 0.85) 60%, rgba(255, 255, 255, 0.65) 100%);
}
.diff-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: @border-radius-lg;
background: @color-bg-gray;
color: @color-primary-light;
margin-bottom: @space-lg;
transition: color @transition-base, background @transition-base;
}
.diff-title {
font-size: @font-size-lg;
color: @color-text-primary;
margin-bottom: @space-sm;
}
.diff-desc {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-relaxed;
}
</style>
+206
查看文件
@@ -0,0 +1,206 @@
<template>
<section class="hero">
<div class="hero-bg">
<img
:src="images.hero.background"
alt="呼伦贝尔草原风光 莫日格勒河蜿蜒穿过辽阔草原"
class="hero-bg-img"
width="1600"
height="900"
fetchpriority="high"
/>
<div class="hero-overlay" />
</div>
<div class="container hero-inner">
<h1 class="hero-title">{{ seo.h1 }}</h1>
<p class="hero-emotional">{{ brand.slogan.emotional }}</p>
<p class="hero-functional">{{ brand.slogan.functional }}</p>
<div class="hero-actions">
<NuxtLink to="/products" class="hero-btn hero-btn--primary">
<span>查看产品</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</NuxtLink>
<NuxtLink to="/faq" class="hero-btn hero-btn--outline">常见问答</NuxtLink>
</div>
</div>
<!-- 底部弧形裁切 -->
<div class="hero-curve">
<svg viewBox="0 0 1440 64" fill="none" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 64h1440V32C1200 0 240 0 0 32v32z" fill="currentColor" />
</svg>
</div>
</section>
</template>
<script setup>
import brand from '~/data/brand.json'
import images from '~/data/images.json'
defineProps({
seo: { type: Object, required: true },
})
// 仅在首页 preload Hero 图片,避免全站加载
useHead({
link: [
{ rel: 'preload', href: images.hero.background, as: 'image' },
],
})
</script>
<style lang="less" scoped>
.hero {
position: relative;
overflow: hidden;
min-height: 480px;
display: flex;
align-items: center;
justify-content: center;
.respond-md({
min-height: 580px;
});
.respond-lg({
min-height: 640px;
});
}
.hero-bg {
position: absolute;
inset: 0;
z-index: 0;
}
.hero-bg-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 40%;
display: block;
}
// 更丰富的渐变遮罩
.hero-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(26, 26, 46, 0.35) 0%, rgba(26, 26, 46, 0.15) 40%, rgba(26, 26, 46, 0.5) 100%),
linear-gradient(135deg, rgba(45, 106, 79, 0.3) 0%, transparent 60%);
}
.hero-inner {
position: relative;
z-index: 1;
padding-top: @space-4xl;
padding-bottom: 100px;
text-align: center;
max-width: 720px;
margin: 0 auto;
.respond-md({
padding-top: @space-4xl;
padding-bottom: 120px;
});
}
.hero-title {
color: #FFFFFF;
margin-bottom: @space-lg;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
letter-spacing: 1px;
}
.hero-emotional {
font-size: @font-size-xl;
color: rgba(255, 255, 255, 0.95);
font-weight: @font-weight-medium;
margin-bottom: @space-sm;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
.respond-md({
font-size: @font-size-2xl;
});
}
.hero-functional {
font-size: @font-size-md;
color: rgba(255, 255, 255, 0.75);
margin-bottom: @space-xl;
.respond-md({
font-size: @font-size-lg;
});
}
.hero-actions {
display: flex;
gap: @space-md;
flex-wrap: wrap;
justify-content: center;
}
.hero-btn {
display: inline-flex;
align-items: center;
gap: @space-sm;
padding: 14px 32px;
border-radius: @border-radius-full;
font-size: @font-size-base;
font-weight: @font-weight-medium;
text-decoration: none;
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
.respond-md({
padding: 16px 40px;
font-size: @font-size-md;
});
&--primary {
background: #fff;
color: @color-primary;
border: 2px solid #fff;
&:hover {
background: rgba(255, 255, 255, 0.92);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
color: @color-primary;
}
}
&--outline {
border: 2px solid rgba(255, 255, 255, 0.5);
color: #fff;
&:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.8);
color: #fff;
transform: translateY(-2px);
}
}
}
// 底部弧形
.hero-curve {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
z-index: 2;
line-height: 0;
color: @color-bg-gray;
svg {
width: 100%;
height: 40px;
.respond-md({
height: 64px;
});
}
}
</style>
+218
查看文件
@@ -0,0 +1,218 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="旅行产品" label="PRODUCTS" subtitle="四季不同风景,总有一条线路适合你的家庭" />
<div class="product-grid">
<NuxtLink v-for="line in productLines" :key="line.id" :to="line.to" class="product-line-card">
<div class="line-img-wrap">
<img :src="line.image" :alt="line.name + ' ' + line.season + '旅行'" class="line-img" loading="lazy" width="400" height="240" />
<span class="line-season-tag" :class="`line-season-tag--${line.theme}`">{{ line.season }}</span>
</div>
<div class="line-body">
<div class="line-meta">
<span class="line-duration">{{ line.duration }}</span>
</div>
<h3 class="line-name">{{ line.name }}</h3>
<p class="line-desc">{{ line.description }}</p>
<ul class="line-highlights">
<li v-for="(h, i) in line.highlights" :key="i">{{ h }}</li>
</ul>
<span class="line-cta" :class="`line-cta--${line.theme}`">
了解详情
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</div>
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup>
const productLines = [
{
id: 'summer',
name: '额吉的故乡',
season: '夏季',
theme: 'summer',
duration: '4-7天 · V9系列',
to: '/products',
image: '/images/seasons/summer-hero.jpg',
description: '呼伦贝尔草原家庭定制游,一家一单一车,6个版本适配不同假期',
highlights: ['莫日格勒河草原穿越', '专业骑马体验', '呼籁自有营地住宿'],
},
{
id: 'autumn',
name: '游牧的森林',
season: '秋季',
theme: 'autumn',
duration: '4-7天 · V3系列',
to: '/products/autumn',
image: '/images/seasons/autumn-forest.jpg',
description: '金秋草原+阿尔山/大兴安岭,南北两线专属私家团',
highlights: ['大兴安岭金秋林海', '阿尔山火山天池', '扎罗木得断崖秘境'],
},
{
id: 'winter',
name: '嗨冰雪',
season: '冬季',
theme: 'winter',
duration: '5-7天 · V3系列',
to: '/products/winter',
image: '/images/seasons/winter-hero.jpg',
description: '呼伦贝尔冬季定制游,南北两线,冰雪那达慕+驯鹿部落',
highlights: ['冰雪那达慕体验', '雪地狼群投喂', '大雪原日出日落'],
},
{
id: 'camp',
name: '小蒙马亲子营',
season: '夏+冬',
theme: 'camp',
duration: '夏6天 / 冬7天',
to: '/summer-camp',
image: '/images/summer-camp/grassland-group.jpg',
description: '亲子营模式,每期8组家庭结伴同行,孩子有伙伴大人能放松',
highlights: ['专业研学领队带队', '随团摄影师全程跟拍', '草原写生+非遗研学'],
},
]
</script>
<style lang="less" scoped>
.product-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(2, 1fr);
});
.respond-lg({
grid-template-columns: repeat(4, 1fr);
});
}
.product-line-card {
display: flex;
flex-direction: column;
background: @color-bg-white;
border-radius: @border-radius-lg;
overflow: hidden;
text-decoration: none;
box-shadow: @shadow-card;
transition: box-shadow 0.35s cubic-bezier(0.23, 1, 0.32, 1), transform 0.35s cubic-bezier(0.23, 1, 0.32, 1);
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-6px);
.line-img {
transform: scale(1.05);
}
}
}
.line-img-wrap {
position: relative;
overflow: hidden;
aspect-ratio: 5 / 3;
}
.line-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s cubic-bezier(0.23, 1, 0.32, 1);
}
.line-season-tag {
position: absolute;
top: @space-md;
left: @space-md;
padding: @space-xs @space-sm;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
border-radius: @border-radius-sm;
backdrop-filter: blur(4px);
&--summer { color: #fff; background: rgba(45, 106, 79, 0.85); }
&--autumn { color: #fff; background: rgba(217, 119, 6, 0.85); }
&--winter { color: #fff; background: rgba(59, 130, 246, 0.85); }
&--camp { color: #fff; background: rgba(139, 92, 246, 0.85); }
}
.line-body {
display: flex;
flex-direction: column;
padding: @space-lg;
flex: 1;
}
.line-meta {
margin-bottom: @space-sm;
}
.line-duration {
font-size: @font-size-xs;
color: @color-text-muted;
}
.line-name {
font-size: @font-size-xl;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-sm;
}
.line-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
}
.line-highlights {
display: flex;
flex-direction: column;
gap: @space-xs;
margin-bottom: @space-lg;
flex: 1;
li {
font-size: @font-size-sm;
color: @color-text-secondary;
padding-left: @space-md;
position: relative;
&::before {
content: '';
position: absolute;
left: 2px;
top: 8px;
width: 5px;
height: 5px;
border-radius: 50%;
background: @color-primary-lighter;
}
}
}
.line-cta {
display: inline-flex;
align-items: center;
gap: @space-xs;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
margin-top: auto;
transition: gap @transition-fast;
.product-line-card:hover & {
gap: @space-sm;
}
&--summer { color: @color-primary; }
&--autumn { color: #D97706; }
&--winter { color: #3B82F6; }
&--camp { color: #8B5CF6; }
}
</style>
+129
查看文件
@@ -0,0 +1,129 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle title="客户怎么说" label="REVIEWS" subtitle="来自真实家庭的旅行评价" />
<div class="review-grid">
<div v-for="r in reviews" :key="r.id" class="review-card">
<div class="review-quote-mark">"</div>
<blockquote class="review-text">{{ r.content }}</blockquote>
<div class="review-footer">
<div class="review-avatar">{{ r.nickname.charAt(0) }}</div>
<div class="review-info">
<span class="review-name">{{ r.nickname }}</span>
<span class="review-date">{{ r.travelDate }}</span>
</div>
</div>
<p class="review-product">{{ r.productVersion }}</p>
</div>
</div>
<div class="review-more">
<CommonInternalLink to="/reviews" text="查看更多客户评价" />
</div>
</div>
</section>
</template>
<script setup>
defineProps({
reviews: { type: Array, required: true },
})
</script>
<style lang="less" scoped>
.review-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(2, 1fr);
});
.respond-lg({
grid-template-columns: repeat(3, 1fr);
});
}
.review-card {
background: @color-bg-white;
border-radius: @border-radius-lg;
padding: @space-xl;
border: 1px solid @color-border;
border-left: 3px solid @color-primary-lighter;
position: relative;
transition: box-shadow 0.35s cubic-bezier(0.23, 1, 0.32, 1), transform 0.35s cubic-bezier(0.23, 1, 0.32, 1);
&:hover {
box-shadow: @shadow-md;
transform: translateY(-3px);
border-left-color: @color-primary;
}
}
.review-quote-mark {
font-family: Georgia, 'Times New Roman', serif;
font-size: 64px;
line-height: 1;
color: @color-primary-lighter;
margin-bottom: -@space-md;
user-select: none;
}
.review-text {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-relaxed;
margin-bottom: @space-lg;
.text-clamp(4);
}
.review-footer {
display: flex;
align-items: center;
gap: @space-md;
}
.review-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, @color-primary-bg, @color-primary-lighter);
color: @color-primary;
display: flex;
align-items: center;
justify-content: center;
font-size: @font-size-sm;
font-weight: @font-weight-bold;
flex-shrink: 0;
}
.review-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.review-name {
font-size: @font-size-sm;
color: @color-text-primary;
font-weight: @font-weight-medium;
}
.review-date {
font-size: @font-size-xs;
color: @color-text-muted;
}
.review-product {
font-size: @font-size-xs;
color: @color-text-muted;
margin-top: @space-sm;
padding-top: @space-sm;
border-top: 1px solid @color-divider;
}
.review-more {
text-align: center;
margin-top: @space-2xl;
}
</style>
+45
查看文件
@@ -0,0 +1,45 @@
<template>
<section class="trust-section">
<div class="container">
<div class="trust-grid">
<CommonTrustBadge
v-for="stat in stats"
:key="stat.label"
:value="stat.value"
:unit="stat.unit"
:label="stat.label"
:attribution="stat.attribution"
dark
/>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
stats: { type: Array, required: true },
})
</script>
<style lang="less" scoped>
.trust-section {
background: linear-gradient(135deg, @color-primary-dark 0%, @color-primary 100%);
padding: @space-xl 0;
.respond-md({
padding: @space-2xl 0;
});
}
.trust-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: @space-md;
.respond-md({
grid-template-columns: repeat(4, 1fr);
gap: @space-xl;
});
}
</style>
+204
查看文件
@@ -0,0 +1,204 @@
<template>
<footer class="app-footer">
<div class="footer-container">
<div class="footer-grid">
<!-- 品牌信息 -->
<div class="footer-brand">
<div class="footer-logo">
<img :src="images.logo.main" alt="呼籁旅行" class="footer-logo-img" width="44" height="44" />
<div class="footer-logo-text">
<span class="logo-text">呼籁旅行</span>
<span class="logo-sub">HULAI TRAVEL</span>
</div>
</div>
<p class="footer-desc">{{ brand.slogan.functional }}</p>
<div class="footer-qr-list">
<CommonQRCodeBlock
v-for="ch in qrChannels"
:key="ch.type"
:image="ch.qrImage"
:label="ch.label"
size="small"
/>
</div>
</div>
<!-- 导航链接 -->
<div v-for="group in footerNav" :key="group.title" class="footer-nav-group">
<h4 class="footer-nav-title">{{ group.title }}</h4>
<ul class="footer-nav-list">
<li v-for="link in group.links" :key="link.to">
<NuxtLink :to="link.to" class="footer-nav-link">{{ link.text }}</NuxtLink>
</li>
</ul>
</div>
</div>
<!-- 底部信息 -->
<div class="footer-bottom">
<p class="footer-company">旅行服务:内蒙古呼籁国际旅行社有限公司</p>
<p class="footer-legal">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" class="footer-icp-link">{{ brand.icp }}</a>
<span class="footer-divider">|</span>
{{ brand.icpEntity }}
</p>
<p class="footer-copyright">
© {{ new Date().getFullYear() }} {{ brand.fullName }} 版权所有
</p>
</div>
</div>
</footer>
</template>
<script setup>
import brand from '~/data/brand.json'
import images from '~/data/images.json'
import navData from '~/data/navigation.json'
import contact from '~/data/contact.json'
const footerNav = navData.footer
const qrChannels = contact.channels.filter(c => c.qrImage && !c.hidden)
</script>
<style lang="less" scoped>
.app-footer {
background: linear-gradient(180deg, @color-primary-dark 0%, #0F2419 100%);
color: rgba(255, 255, 255, 0.7);
padding: @space-2xl 0 @space-lg;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, @color-primary, @color-accent, @color-primary);
}
}
.footer-container {
.container();
}
.footer-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-xl;
.respond-md({
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
gap: @space-xl;
});
}
.footer-brand {
margin-bottom: @space-lg;
}
.footer-logo {
display: flex;
align-items: center;
gap: @space-sm;
margin-bottom: @space-md;
}
.footer-logo-img {
width: 44px;
height: 44px;
border-radius: 6px;
flex-shrink: 0;
}
.footer-logo-text {
display: flex;
flex-direction: column;
.logo-text {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: #fff;
}
.logo-sub {
font-size: @font-size-xs;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 2px;
}
}
.footer-desc {
font-size: @font-size-sm;
margin-bottom: @space-lg;
color: rgba(255, 255, 255, 0.6);
}
.footer-qr-list {
display: flex;
gap: @space-lg;
}
.footer-nav-title {
font-size: @font-size-base;
font-weight: @font-weight-medium;
color: #fff;
margin-bottom: @space-md;
}
.footer-nav-list {
display: flex;
flex-direction: column;
gap: @space-sm;
}
.footer-nav-link {
font-size: @font-size-sm;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
transition: color @transition-fast;
&:hover {
color: #fff;
}
}
.footer-bottom {
margin-top: @space-2xl;
padding-top: @space-lg;
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
}
.footer-company {
font-size: @font-size-sm;
color: rgba(255, 255, 255, 0.6);
margin-bottom: @space-xs;
}
.footer-legal {
font-size: @font-size-xs;
color: rgba(255, 255, 255, 0.5);
margin-bottom: @space-xs;
}
.footer-divider {
margin: 0 @space-sm;
opacity: 0.5;
}
.footer-copyright {
font-size: @font-size-xs;
color: rgba(255, 255, 255, 0.45);
}
.footer-icp-link {
color: rgba(255, 255, 255, 0.5);
text-decoration: none;
transition: color @transition-fast;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
</style>
+308
查看文件
@@ -0,0 +1,308 @@
<template>
<header class="app-header">
<div class="header-container">
<NuxtLink to="/" class="header-logo" aria-label="呼籁旅行首页">
<img :src="images.logo.main" alt="呼籁旅行" class="logo-img" width="40" height="40" />
<div class="logo-text-group">
<span class="logo-text">呼籁旅行</span>
<span class="logo-sub">HULAI TRAVEL</span>
</div>
</NuxtLink>
<nav class="header-nav" aria-label="主导航">
<ul class="nav-list">
<li
v-for="item in navItems"
:key="item.to"
class="nav-item"
:class="{ 'has-dropdown': item.children }"
@mouseenter="item.children && (openDropdown = item.to)"
@mouseleave="item.children && (openDropdown = null)"
>
<NuxtLink :to="item.to" class="nav-link" :class="{ active: isActive(item.to) }">
{{ item.text }}
<span v-if="item.children" class="nav-arrow">▾</span>
</NuxtLink>
<!-- 下拉菜单:真实 <a> 链接,SEO 可抓取 -->
<ul v-if="item.children" class="nav-dropdown" :class="{ show: openDropdown === item.to }">
<li v-for="child in item.children" :key="child.to" class="dropdown-item">
<NuxtLink :to="child.to" class="dropdown-link" @click="openDropdown = null">
<span class="dropdown-text">{{ child.text }}</span>
<span v-if="child.season" class="dropdown-season">{{ child.season }}</span>
</NuxtLink>
</li>
</ul>
</li>
</ul>
</nav>
<button
class="mobile-toggle"
:aria-expanded="mobileOpen"
aria-controls="mobile-nav"
aria-label="打开导航菜单"
@click="mobileOpen = !mobileOpen"
>
<span class="toggle-bar" :class="{ open: mobileOpen }"></span>
</button>
</div>
<LayoutMobileNav :is-open="mobileOpen" :items="navItems" @close="mobileOpen = false" />
</header>
</template>
<script setup>
import { ref } from 'vue'
import navData from '~/data/navigation.json'
import images from '~/data/images.json'
const navItems = navData.header
const mobileOpen = ref(false)
const openDropdown = ref(null)
const route = useRoute()
function isActive(path) {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
watch(() => route.path, () => {
mobileOpen.value = false
})
</script>
<style lang="less" scoped>
.app-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
transition: box-shadow @transition-base;
&:hover {
box-shadow: @shadow-xs;
}
}
.header-container {
.container();
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
.respond-lg({
height: 72px;
});
}
.header-logo {
display: flex;
align-items: center;
gap: @space-sm;
text-decoration: none;
line-height: 1.2;
}
.logo-img {
width: 40px;
height: 40px;
border-radius: 6px;
flex-shrink: 0;
.respond-lg({
width: 44px;
height: 44px;
});
}
.logo-text-group {
display: flex;
flex-direction: column;
}
.logo-text {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-primary;
.respond-lg({
font-size: @font-size-xl;
});
}
.logo-sub {
font-size: @font-size-xs;
color: @color-text-muted;
letter-spacing: 2px;
}
.header-nav {
display: none;
.respond-lg({
display: block;
});
}
.nav-list {
display: flex;
gap: @space-xl;
}
.nav-link {
font-size: @font-size-base;
color: @color-text-secondary;
text-decoration: none;
padding: @space-sm 0;
border-bottom: 2px solid transparent;
transition: color @transition-fast, border-color @transition-fast;
&:hover,
&.active {
color: @color-primary;
border-bottom-color: @color-primary;
}
}
// 下拉菜单
.has-dropdown {
position: relative;
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
transition: transform @transition-fast;
.has-dropdown:hover & {
transform: rotate(180deg);
}
}
.nav-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
min-width: 180px;
background: @color-bg-white;
border-radius: @border-radius-md;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
padding: @space-sm 0;
opacity: 0;
visibility: hidden;
transition: opacity @transition-fast, visibility @transition-fast;
z-index: 10;
// 防止鼠标移到下拉菜单过程中菜单消失
&::before {
content: '';
position: absolute;
top: -8px;
left: 0;
right: 0;
height: 8px;
}
&.show {
opacity: 1;
visibility: visible;
}
}
.dropdown-item {
list-style: none;
}
.dropdown-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-sm @space-lg;
text-decoration: none;
color: @color-text-secondary;
font-size: @font-size-sm;
white-space: nowrap;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.dropdown-text {
flex: 1;
}
.dropdown-season {
font-size: @font-size-xs;
color: @color-text-muted;
background: @color-bg-gray;
padding: 2px 6px;
border-radius: @border-radius-sm;
margin-left: @space-sm;
}
.mobile-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: none;
border: none;
cursor: pointer;
.respond-lg({
display: none;
});
}
.toggle-bar {
position: relative;
display: block;
width: 24px;
height: 2px;
background: @color-text-primary;
transition: background @transition-fast;
&::before,
&::after {
content: '';
position: absolute;
left: 0;
width: 24px;
height: 2px;
background: @color-text-primary;
transition: transform @transition-base;
}
&::before {
top: -7px;
}
&::after {
top: 7px;
}
&.open {
background: transparent;
&::before {
top: 0;
transform: rotate(45deg);
}
&::after {
top: 0;
transform: rotate(-45deg);
}
}
}
</style>
+188
查看文件
@@ -0,0 +1,188 @@
<template>
<Transition name="slide">
<div v-if="isOpen" id="mobile-nav" class="mobile-nav" role="dialog" aria-label="导航菜单">
<div class="mobile-nav-backdrop" @click="$emit('close')"></div>
<nav class="mobile-nav-panel">
<ul class="mobile-nav-list">
<li v-for="item in items" :key="item.to" class="mobile-nav-item">
<!-- 有子菜单的导航项 -->
<template v-if="item.children">
<button
class="mobile-nav-link mobile-nav-toggle"
:class="{ expanded: expandedItem === item.to }"
@click="toggleExpand(item.to)"
>
{{ item.text }}
<span class="mobile-nav-arrow">›</span>
</button>
<ul v-show="expandedItem === item.to" class="mobile-sub-list">
<li v-for="child in item.children" :key="child.to" class="mobile-sub-item">
<NuxtLink :to="child.to" class="mobile-sub-link" @click="$emit('close')">
<span>{{ child.text }}</span>
<span v-if="child.season" class="mobile-sub-season">{{ child.season }}</span>
</NuxtLink>
</li>
</ul>
</template>
<!-- 普通导航项 -->
<NuxtLink v-else :to="item.to" class="mobile-nav-link" @click="$emit('close')">
{{ item.text }}
</NuxtLink>
</li>
</ul>
</nav>
</div>
</Transition>
</template>
<script setup>
const props = defineProps({
isOpen: { type: Boolean, default: false },
items: { type: Array, default: () => [] },
})
defineEmits(['close'])
const expandedItem = ref(null)
function toggleExpand(key) {
expandedItem.value = expandedItem.value === key ? null : key
}
// 关闭菜单时重置展开状态
watch(() => props.isOpen, (open) => {
if (import.meta.client) {
document.body.style.overflow = open ? 'hidden' : ''
}
if (!open) {
expandedItem.value = null
}
})
onUnmounted(() => {
if (import.meta.client) {
document.body.style.overflow = ''
}
})
</script>
<style lang="less" scoped>
.mobile-nav {
position: fixed;
inset: 0;
z-index: 99;
}
.mobile-nav-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
.mobile-nav-panel {
position: absolute;
top: 0;
right: 0;
width: min(300px, 85vw);
height: 100%;
background: @color-bg-white;
padding: @space-3xl @space-lg @space-lg;
overflow-y: auto;
}
.mobile-nav-list {
display: flex;
flex-direction: column;
gap: @space-xs;
}
.mobile-nav-link {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: @space-md @space-md;
font-size: @font-size-md;
color: @color-text-primary;
text-decoration: none;
border-radius: @border-radius-md;
border: none;
background: none;
cursor: pointer;
text-align: left;
transition: background @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.mobile-nav-toggle {
font-family: inherit;
}
.mobile-nav-arrow {
font-size: @font-size-lg;
color: @color-text-muted;
transition: transform @transition-fast;
.expanded & {
transform: rotate(90deg);
}
}
.mobile-sub-list {
display: flex;
flex-direction: column;
padding-left: @space-md;
margin-bottom: @space-xs;
}
.mobile-sub-item {
list-style: none;
}
.mobile-sub-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-sm @space-md;
font-size: @font-size-sm;
color: @color-text-secondary;
text-decoration: none;
border-radius: @border-radius-sm;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: @color-primary-bg;
color: @color-primary;
}
}
.mobile-sub-season {
font-size: @font-size-xs;
color: @color-text-muted;
background: @color-bg-gray;
padding: 2px 6px;
border-radius: @border-radius-sm;
}
.slide-enter-active,
.slide-leave-active {
transition: opacity @transition-base;
.mobile-nav-panel {
transition: transform @transition-base;
}
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
.mobile-nav-panel {
transform: translateX(100%);
}
}
</style>
+70
查看文件
@@ -0,0 +1,70 @@
<template>
<section class="page-hero" :class="{ 'page-hero--compact': compact }">
<div class="container">
<CommonBreadcrumbNav :items="breadcrumbs" />
<h1 class="page-hero-title">{{ title }}</h1>
<p v-if="subtitle" class="page-hero-subtitle">{{ subtitle }}</p>
</div>
<div class="page-hero-curve">
<svg viewBox="0 0 1440 48" fill="none" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 48h1440V24C1200 0 240 0 0 24v24z" fill="currentColor" />
</svg>
</div>
</section>
</template>
<script setup>
const props = defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
compact: { type: Boolean, default: false },
breadcrumbs: { type: Array, default: () => [] },
})
</script>
<style lang="less" scoped>
.page-hero {
background: linear-gradient(135deg, @color-primary-bg 0%, #E8F3ED 100%);
padding: @space-2xl 0 @space-3xl;
position: relative;
.respond-md({
padding: @space-3xl 0 @space-4xl;
});
&--compact {
padding: @space-xl 0 @space-2xl;
}
}
.page-hero-title {
margin-top: @space-md;
color: @color-text-primary;
}
.page-hero-subtitle {
margin-top: @space-sm;
font-size: @font-size-md;
color: @color-text-secondary;
max-width: 600px;
line-height: @line-height-relaxed;
}
.page-hero-curve {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
line-height: 0;
color: @color-bg-white;
svg {
width: 100%;
height: 32px;
.respond-md({
height: 48px;
});
}
}
</style>
+78
查看文件
@@ -0,0 +1,78 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="产品进化" subtitle="从V1到V9,每一次更新都是客户反馈驱动" />
<div class="evo-stats">
<div class="evo-stat">
<span class="evo-stat-num">{{ stats.iterations }}</span>
<span class="evo-stat-label">次迭代</span>
</div>
<div class="evo-stat">
<span class="evo-stat-num">{{ stats.years }}</span>
<span class="evo-stat-label">年打磨</span>
</div>
<div class="evo-stat">
<span class="evo-stat-num">{{ stats.guests }}</span>
<span class="evo-stat-label">人次体验</span>
</div>
</div>
<p class="evo-intro">2023年3月,呼籁做了一个"反常识"的决定——只做一条路线。不铺量、不贪多,把所有精力集中在打磨一个产品上。三年9次迭代,每一次版本更新背后,都有真实的客户反馈和一线体验数据。</p>
</div>
</section>
</template>
<script setup>
defineProps({
stats: { type: Object, required: true }
})
</script>
<style lang="less" scoped>
.evo-stats {
display: flex;
justify-content: center;
gap: @space-2xl;
margin-bottom: @space-xl;
}
.evo-stat {
text-align: center;
}
.evo-stat-num {
display: inline-block;
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: @color-primary;
line-height: @line-height-tight;
position: relative;
&::after {
content: '';
position: absolute;
bottom: -2px;
left: 10%;
right: 10%;
height: 3px;
background: @color-primary-lighter;
border-radius: 2px;
}
}
.evo-stat-label {
display: block;
margin-top: @space-sm;
font-size: @font-size-sm;
color: @color-text-muted;
letter-spacing: 1px;
}
.evo-intro {
max-width: 700px;
margin: 0 auto;
text-align: center;
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
}
</style>
+20
查看文件
@@ -0,0 +1,20 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="关于价格" subtitle="透明、清晰、不套路" />
<div class="pricing-content">
<p>{{ content }}</p>
</div>
</div>
</section>
</template>
<script setup>
defineProps({ content: { type: String, required: true } })
</script>
<style lang="less" scoped>
.pricing-content { max-width: 800px; margin: 0 auto; padding: @space-xl; background: @color-bg-warm; border-radius: @border-radius-lg;
p { font-size: @font-size-md; line-height: @line-height-loose; color: @color-text-secondary; }
}
</style>
+217
查看文件
@@ -0,0 +1,217 @@
<template>
<article
class="product-card"
:class="{ 'product-card--popular': isPopular }"
itemscope
itemtype="https://schema.org/TouristTrip"
>
<!-- 头部:天数 + 标签 -->
<div class="product-header">
<div v-if="isPopular" class="popular-badge">推荐</div>
<div class="product-duration">
<span class="duration-num">{{ product.days }}</span>
<span class="duration-unit">天</span>
<span class="duration-num">{{ product.nights }}</span>
<span class="duration-unit">晚</span>
</div>
<span class="product-tag">{{ product.tag }}</span>
</div>
<!-- 主体 -->
<div class="product-body">
<h3 class="product-name" itemprop="name">
<NuxtLink :to="`/products/${product.id}`" class="product-name-link">{{ shortName }}</NuxtLink>
</h3>
<p class="product-audience">
适合:<span itemprop="touristType">{{ product.audience }}</span>
</p>
<p class="product-desc" itemprop="description">{{ product.description }}</p>
<ul class="product-highlights">
<li v-for="(h, i) in product.highlights" :key="i">
<span class="highlight-check" aria-hidden="true">&#10003;</span>
{{ h }}
</li>
</ul>
</div>
<!-- 底部 CTA -->
<NuxtLink to="/contact" class="product-cta">
咨询该行程 <span aria-hidden="true">→</span>
</NuxtLink>
</article>
</template>
<script setup>
const props = defineProps({ product: { type: Object, required: true } })
const isPopular = computed(() => props.product.id === 'v9-6d5n-family')
// 去掉"额吉的故乡V9·"前缀,在标题区域已标明系列
const shortName = computed(() => {
return props.product.name.replace(/^额吉的故乡V9·/, '')
})
</script>
<style lang="less" scoped>
.product-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow @transition-base, transform @transition-base;
position: relative;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-4px);
}
&--popular {
border: 2px solid @color-primary;
box-shadow: @shadow-md;
.product-header {
background: @color-primary;
}
.duration-num,
.duration-unit,
.product-tag {
color: #fff;
}
.product-tag {
background: rgba(255, 255, 255, 0.2);
}
}
}
// 头部
.product-header {
padding: @space-lg @space-lg @space-md;
background: @color-primary-bg;
text-align: center;
position: relative;
}
.popular-badge {
position: absolute;
top: @space-sm;
right: @space-sm;
padding: @space-xs @space-sm;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: #fff;
background: @color-accent;
border-radius: @border-radius-sm;
}
.product-duration {
display: flex;
align-items: baseline;
justify-content: center;
gap: 2px;
margin-bottom: @space-sm;
}
.duration-num {
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: @color-primary;
line-height: 1;
}
.duration-unit {
font-size: @font-size-sm;
color: @color-primary;
font-weight: @font-weight-medium;
}
.product-tag {
display: inline-block;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: @color-primary;
background: @color-bg-white;
border-radius: @border-radius-full;
}
// 主体
.product-body {
padding: @space-lg;
flex: 1;
display: flex;
flex-direction: column;
}
.product-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.product-name-link {
color: inherit;
text-decoration: none;
&:hover { color: @color-primary; }
}
.product-audience {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.product-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
.text-clamp(3);
}
.product-highlights {
display: flex;
flex-direction: column;
gap: @space-sm;
margin-top: auto;
li {
display: flex;
align-items: flex-start;
gap: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
}
.highlight-check {
flex-shrink: 0;
color: @color-primary;
font-size: @font-size-sm;
font-weight: @font-weight-bold;
margin-top: 2px;
}
// 底部 CTA
.product-cta {
display: block;
text-align: center;
padding: @space-md @space-lg;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: @color-primary;
background: @color-bg-gray;
text-decoration: none;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
</style>
+51
查看文件
@@ -0,0 +1,51 @@
<template>
<section class="section section--warm">
<div class="container">
<div class="overview-content">
<div class="overview-quote-mark" aria-hidden="true">"</div>
<p class="overview-text">{{ narrative }}</p>
<div class="overview-line"></div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({ narrative: { type: String, required: true } })
</script>
<style lang="less" scoped>
.overview-content {
max-width: 800px;
margin: 0 auto;
text-align: center;
position: relative;
}
.overview-quote-mark {
font-size: 80px;
font-weight: @font-weight-bold;
color: @color-primary-lighter;
line-height: 0.5;
margin-bottom: @space-md;
font-family: Georgia, serif;
}
.overview-text {
font-size: @font-size-md;
line-height: @line-height-loose;
color: @color-text-secondary;
.respond-md({
font-size: @font-size-lg;
});
}
.overview-line {
width: 60px;
height: 3px;
background: @color-primary;
border-radius: 2px;
margin: @space-xl auto 0;
}
</style>
+113
查看文件
@@ -0,0 +1,113 @@
<template>
<section id="guide" class="section section--gray">
<div class="container">
<CommonSectionTitle title="不知道选哪个?" subtitle="按您的情况推荐" />
<div class="guide-groups">
<div v-for="group in guideGroups" :key="group.title" class="guide-group">
<h3 class="guide-group-title">{{ group.title }}</h3>
<div class="guide-items">
<div v-for="item in group.items" :key="item.condition" class="guide-item">
<div class="guide-item-top">
<span class="guide-condition">{{ item.condition }}</span>
<span class="guide-arrow">→</span>
<span class="guide-rec">{{ getProductName(item.recommendation) }}</span>
</div>
<p class="guide-reason">{{ item.reason }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
const props = defineProps({
guide: { type: Object, required: true },
versions: { type: Array, required: true },
})
const guideGroups = computed(() => [
{ title: '按假期长度', items: props.guide.byVacationLength },
{ title: '按孩子年龄', items: props.guide.byChildAge },
{ title: '按旅行偏好', items: props.guide.byPreference },
])
function getProductName(id) {
if (id === 'summer-camp') return '小蒙马夏令营'
const v = props.versions.find(v => v.id === id)
return v ? v.name : id
}
</script>
<style lang="less" scoped>
.guide-groups {
display: flex;
flex-direction: column;
gap: @space-2xl;
max-width: 800px;
margin: 0 auto;
}
.guide-group-title {
font-size: @font-size-lg;
color: @color-primary;
margin-bottom: @space-md;
padding-left: @space-md;
border-left: 3px solid @color-primary;
}
.guide-items {
display: flex;
flex-direction: column;
gap: @space-md;
}
.guide-item {
padding: @space-lg;
background: @color-bg-white;
border-radius: @border-radius-md;
border: 1px solid @color-border;
transition: border-color @transition-fast, box-shadow @transition-fast;
&:hover {
border-color: @color-primary-lighter;
box-shadow: @shadow-sm;
}
}
.guide-item-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: @space-sm;
margin-bottom: @space-xs;
}
.guide-condition {
font-weight: @font-weight-medium;
color: @color-text-primary;
background: @color-bg-gray;
padding: @space-xs @space-sm;
border-radius: @border-radius-sm;
font-size: @font-size-sm;
}
.guide-arrow {
color: @color-primary;
font-weight: @font-weight-bold;
}
.guide-rec {
color: @color-primary;
font-weight: @font-weight-bold;
font-size: @font-size-base;
}
.guide-reason {
font-size: @font-size-sm;
color: @color-text-muted;
margin: 0;
line-height: @line-height-base;
}
</style>
+82
查看文件
@@ -0,0 +1,82 @@
<template>
<section id="summer-camp" class="section section--green">
<div class="container">
<CommonSectionTitle :title="camp.name" :subtitle="`适合${camp.ageRange}青少年`" />
<!-- 团宠+简介 -->
<div class="camp-hero">
<div class="camp-mascot">
<img
:src="images.mascot.xiaomengma"
alt="小蒙马 - 小蒙马夏令营团宠,一只可爱的蒙古小马形象"
width="200"
height="200"
loading="lazy"
/>
</div>
<div class="camp-intro">
<p class="camp-positioning">{{ camp.positioning }}</p>
</div>
</div>
<div class="camp-content">
<div v-if="camp.coreActivities.length" class="camp-activities">
<h3>核心活动</h3>
<ul>
<li v-for="(a, i) in camp.coreActivities" :key="i">{{ a }}</li>
</ul>
</div>
<p class="camp-diff">{{ camp.differenceFromV9 }}</p>
</div>
</div>
</section>
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ camp: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.camp-hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: @space-lg;
max-width: 800px;
margin: 0 auto @space-xl;
.respond-md({
flex-direction: row;
text-align: left;
gap: @space-2xl;
});
}
.camp-mascot {
flex-shrink: 0;
text-align: center;
img {
width: 160px;
height: auto;
filter: drop-shadow(0 6px 20px rgba(0, 0, 0, 0.08));
}
}
.camp-intro {
flex: 1;
}
.camp-content { max-width: 800px; margin: 0 auto; }
.camp-positioning { font-size: @font-size-base; color: @color-text-secondary; line-height: @line-height-loose; }
.camp-activities { margin-bottom: @space-lg;
h3 { font-size: @font-size-md; margin-bottom: @space-md; }
ul { display: flex; flex-direction: column; gap: @space-sm; }
li { font-size: @font-size-base; color: @color-text-secondary; padding-left: @space-md; position: relative;
&::before { content: '·'; position: absolute; left: 0; color: @color-primary; }
}
}
.camp-diff { font-size: @font-size-base; color: @color-text-muted; padding: @space-md; background: @color-bg-white; border-radius: @border-radius-md; }
</style>
+93
查看文件
@@ -0,0 +1,93 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="V8 → V9 升级对比" subtitle="7项关键升级一目了然" />
<div class="compare-wrapper">
<div class="compare-table">
<div class="compare-head">
<span class="compare-head-old">{{ compare.headers[0] }}</span>
<span class="compare-head-new">{{ compare.headers[1] }}</span>
</div>
<div v-for="(row, i) in compare.rows" :key="i" class="compare-row">
<span class="compare-old">{{ row[0] }}</span>
<span class="compare-new">{{ row[1] }}</span>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
compare: { type: Object, required: true }
})
</script>
<style lang="less" scoped>
.compare-wrapper {
max-width: 800px;
margin: 0 auto;
}
.compare-table {
border-radius: @border-radius-md;
overflow: hidden;
border: 1px solid @color-border;
}
.compare-head {
display: grid;
grid-template-columns: 1fr 1fr;
}
.compare-head-old {
font-weight: @font-weight-medium;
text-align: center;
background: @color-bg-gray;
color: @color-text-muted;
}
.compare-head-new {
font-weight: @font-weight-medium;
text-align: center;
background: @color-primary;
color: #fff;
}
.compare-row {
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid @color-border;
line-height: @line-height-base;
}
.compare-old {
color: @color-text-muted;
background: @color-bg-white;
}
.compare-new {
color: @color-text-primary;
background: @color-primary-bg;
font-weight: @font-weight-medium;
}
// mobile-first: 小屏紧凑,md 以上舒适
.compare-row, .compare-head {
font-size: @font-size-xs;
.respond-md({
font-size: @font-size-sm;
});
}
.compare-old, .compare-new,
.compare-head-old, .compare-head-new {
padding: @space-sm @space-md;
.respond-md({
padding: @space-md @space-lg;
});
}
</style>
+58
查看文件
@@ -0,0 +1,58 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle title="更多V9亮点" subtitle="独家资源 · 自营服务 · 全程无忧" />
<div class="highlights-list">
<div v-for="item in highlights" :key="item.text" class="highlight-item">
<span class="highlight-label">{{ item.label }}</span>
<span class="highlight-text">{{ item.text }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
highlights: { type: Array, required: true }
})
</script>
<style lang="less" scoped>
.highlights-list {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.highlight-item {
display: flex;
align-items: baseline;
gap: @space-md;
padding: @space-md 0;
border-bottom: 1px solid @color-border;
&:last-child {
border-bottom: none;
}
}
.highlight-label {
flex-shrink: 0;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: @color-primary;
background: @color-primary-bg;
padding: @space-xs @space-sm;
border-radius: @border-radius-sm;
min-width: 42px;
text-align: center;
}
.highlight-text {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-base;
}
</style>
+82
查看文件
@@ -0,0 +1,82 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="9次迭代背后的产品哲学" subtitle="不是做加法,而是做取舍" />
<div class="philosophy-wrapper">
<div class="philosophy-list">
<div v-for="item in philosophy" :key="item.label" class="philosophy-item">
<h4 class="philosophy-label">{{ item.label }}</h4>
<p class="philosophy-text">{{ item.text }}</p>
</div>
</div>
<div class="quote-block">
<p class="quote-text">"{{ quote.text }}"</p>
<span class="quote-author">—— {{ quote.author }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
philosophy: { type: Array, required: true },
quote: { type: Object, required: true }
})
</script>
<style lang="less" scoped>
.philosophy-wrapper {
max-width: 800px;
margin: 0 auto;
}
.philosophy-list {
margin-bottom: @space-2xl;
}
.philosophy-item {
padding: @space-lg 0;
border-bottom: 1px solid @color-border;
&:last-child {
border-bottom: none;
}
}
.philosophy-label {
font-size: @font-size-base;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0 0 @space-xs;
}
.philosophy-text {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin: 0;
}
.quote-block {
text-align: center;
padding: @space-2xl;
background: @color-primary;
border-radius: @border-radius-lg;
color: #fff;
}
.quote-text {
font-size: @font-size-md;
line-height: @line-height-loose;
margin: 0 0 @space-lg;
color: #fff;
font-weight: @font-weight-medium;
}
.quote-author {
font-size: @font-size-sm;
color: rgba(255, 255, 255, 0.85);
}
</style>
+175
查看文件
@@ -0,0 +1,175 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle :title="title" :subtitle="subtitle" />
<div class="timeline-wrapper">
<div class="timeline">
<div v-for="v in timeline" :key="v.version" class="version-block">
<div class="version-dot" :class="{ 'version-dot--first': v.version === timeline[0].version }"></div>
<div class="version-header">
<span class="version-tag">{{ v.version }}</span>
<span class="version-date">{{ v.date }}</span>
</div>
<h4 class="version-title">{{ v.title }}</h4>
<ul class="change-list">
<li
v-for="(c, i) in v.changes"
:key="i"
:class="'change-' + c.type"
>{{ c.text }}</li>
</ul>
<div v-if="expandedVersion === v.version" class="version-reason">
{{ v.reason }}
</div>
<button
class="reason-toggle"
@click="expandedVersion = expandedVersion === v.version ? '' : v.version"
>
{{ expandedVersion === v.version ? '收起' : '为什么这样改?' }}
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
timeline: { type: Array, required: true },
title: { type: String, default: '历史版本' },
subtitle: { type: String, default: 'V1到V8的完整迭代记录' },
})
const expandedVersion = ref('')
</script>
<style lang="less" scoped>
.timeline-wrapper {
max-width: 800px;
margin: 0 auto;
}
.timeline {
position: relative;
padding-left: 24px;
&::before {
content: '';
position: absolute;
left: 3px;
top: 8px;
bottom: 8px;
width: 1px;
background: linear-gradient(180deg, @color-primary, @color-border);
}
}
.version-block {
position: relative;
padding-bottom: @space-xl;
&:last-child {
padding-bottom: 0;
}
}
.version-dot {
position: absolute;
left: -24px;
top: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: @color-border;
}
.version-dot--first {
background: @color-primary;
}
.version-header {
display: flex;
align-items: baseline;
gap: @space-sm;
margin-bottom: @space-xs;
}
.version-tag {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
}
.version-date {
font-size: @font-size-sm;
color: @color-text-muted;
}
.version-title {
font-size: @font-size-base;
font-weight: @font-weight-medium;
color: @color-text-secondary;
margin: 0 0 @space-md;
}
.change-list {
list-style: none;
padding: 0;
margin: 0 0 @space-sm;
}
.change-list li {
font-size: @font-size-sm;
line-height: @line-height-loose;
color: @color-text-secondary;
padding-left: 18px;
position: relative;
&::before {
position: absolute;
left: 0;
font-size: 12px;
font-weight: @font-weight-bold;
}
}
.change-add::before {
content: '+';
color: @color-primary;
}
.change-remove::before {
content: '−';
color: @color-text-muted;
}
.change-update::before {
content: '△';
color: @color-accent;
}
.version-reason {
font-size: @font-size-sm;
color: @color-text-muted;
line-height: @line-height-loose;
padding: @space-md @space-lg;
background: @color-bg-white;
border-radius: @border-radius-sm;
border-left: 3px solid @color-primary;
margin-bottom: @space-xs;
}
.reason-toggle {
background: none;
border: none;
color: @color-primary;
font-size: @font-size-xs;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
</style>
+83
查看文件
@@ -0,0 +1,83 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="2026年核心升级" subtitle="基于客户反馈的三大重磅升级" />
<div class="upgrades-list">
<div v-for="item in upgrades" :key="item.name" class="upgrade-item">
<div class="upgrade-header">
<span class="upgrade-tag">{{ item.tag }}</span>
<h3 class="upgrade-name">{{ item.name }}</h3>
</div>
<p class="upgrade-desc">{{ item.description }}</p>
<div class="upgrade-reason">{{ item.reason }}</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({
upgrades: { type: Array, required: true }
})
</script>
<style lang="less" scoped>
.upgrades-list {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: @space-xl;
}
.upgrade-item {
padding-bottom: @space-xl;
border-bottom: 1px solid @color-border;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.upgrade-header {
margin-bottom: @space-md;
}
.upgrade-tag {
display: inline-block;
background: @color-primary;
color: #fff;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
padding: @space-xs @space-md;
border-radius: @border-radius-full;
letter-spacing: 2px;
margin-bottom: @space-sm;
}
.upgrade-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin: 0;
}
.upgrade-desc {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-loose;
margin-bottom: @space-md;
}
.upgrade-reason {
font-size: @font-size-sm;
color: @color-text-muted;
line-height: @line-height-base;
padding: @space-md @space-lg;
background: @color-bg-gray;
border-radius: @border-radius-sm;
border-left: 3px solid @color-primary;
}
</style>
+79
查看文件
@@ -0,0 +1,79 @@
<template>
<div class="review-filter">
<div class="filter-group">
<span class="filter-label">按场景:</span>
<button
class="filter-btn"
:class="{ active: !activeScene }"
@click="$emit('filterScene', '')"
>全部</button>
<button
v-for="s in scenes"
:key="s"
class="filter-btn"
:class="{ active: activeScene === s }"
@click="$emit('filterScene', s)"
>{{ s }}</button>
</div>
<div class="filter-group">
<span class="filter-label">按关心点:</span>
<button
class="filter-btn"
:class="{ active: !activeConcern }"
@click="$emit('filterConcern', '')"
>全部</button>
<button
v-for="c in concerns"
:key="c"
class="filter-btn"
:class="{ active: activeConcern === c }"
@click="$emit('filterConcern', c)"
>{{ c }}</button>
</div>
</div>
</template>
<script setup>
defineProps({
scenes: { type: Array, required: true },
concerns: { type: Array, required: true },
activeScene: { type: String, default: '' },
activeConcern: { type: String, default: '' },
})
defineEmits(['filterScene', 'filterConcern'])
</script>
<style lang="less" scoped>
.review-filter {
margin-bottom: @space-xl;
display: flex;
flex-direction: column;
gap: @space-md;
padding: @space-lg;
background: @color-bg-gray;
border-radius: @border-radius-md;
}
.filter-group { display: flex; align-items: flex-start; flex-wrap: wrap; gap: @space-xs; }
.filter-label {
font-size: @font-size-sm;
color: @color-text-muted;
white-space: nowrap;
line-height: 44px;
margin-right: @space-xs;
}
.filter-btn {
padding: @space-sm @space-md;
font-size: @font-size-sm;
border: 1px solid @color-border;
background: @color-bg-white;
border-radius: @border-radius-full;
cursor: pointer;
color: @color-text-secondary;
transition: all @transition-fast;
min-height: 44px;
display: inline-flex;
align-items: center;
&:hover { border-color: @color-primary; color: @color-primary; }
&.active { background: @color-primary; color: #fff; border-color: @color-primary; }
}
</style>
+225
查看文件
@@ -0,0 +1,225 @@
<template>
<!--
双层设计:
上层:截图(视觉证明真实性)
下层:文字(SEO可抓取)
-->
<article class="review-item">
<!-- 截图层 -->
<div v-if="review.screenshot" class="review-screenshot" :class="{ 'is-expanded': expanded }">
<img
:src="review.screenshot"
:alt="`客户${review.nickname}对呼籁旅行${review.productVersion}的评价`"
loading="lazy"
class="review-img"
@click="expanded = !expanded"
/>
<button
v-if="!expanded"
class="screenshot-expand"
@click="expanded = true"
aria-label="查看完整截图"
>
<span class="expand-icon">&#9662;</span> 查看完整截图
</button>
<button
v-else
class="screenshot-collapse"
@click="expanded = false"
aria-label="收起截图"
>
<span class="expand-icon expand-icon--up">&#9652;</span> 收起截图
</button>
</div>
<!-- 文字层(SEO核心) -->
<div class="review-content">
<div class="review-meta">
<span class="review-name">{{ review.nickname }}</span>
<span class="review-sep">·</span>
<span class="review-date">{{ review.travelDate }}</span>
</div>
<p class="review-product">{{ review.productVersion }}</p>
<blockquote class="review-text" :class="{ 'is-clamped': !textExpanded && isLongText }">
{{ review.content }}
</blockquote>
<button
v-if="isLongText"
class="text-toggle"
@click="textExpanded = !textExpanded"
>
{{ textExpanded ? '收起' : '展开全文' }}
</button>
<div class="review-tags">
<span v-for="s in review.scenes" :key="s" class="tag tag--scene">{{ s }}</span>
<span v-for="c in review.concerns" :key="c" class="tag tag--concern">{{ c }}</span>
</div>
</div>
</article>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({ review: { type: Object, required: true } })
const expanded = ref(false)
const textExpanded = ref(false)
const isLongText = computed(() => props.review.content && props.review.content.length > 150)
</script>
<style lang="less" scoped>
.review-item {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow @transition-base, transform @transition-base;
&:hover {
box-shadow: @shadow-md;
transform: translateY(-2px);
}
}
.review-screenshot {
position: relative;
background: @color-bg-gray;
max-height: 320px;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
/* 底部渐变遮罩,提示可展开 */
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(transparent, rgba(248, 248, 250, 0.95));
pointer-events: none;
transition: opacity @transition-fast;
}
&.is-expanded {
max-height: none;
&::after {
opacity: 0;
}
}
}
.review-img {
width: 100%;
height: auto;
display: block;
}
.screenshot-expand,
.screenshot-collapse {
position: absolute;
bottom: @space-sm;
left: 50%;
transform: translateX(-50%);
padding: @space-xs @space-lg;
font-size: @font-size-sm;
color: @color-primary;
background: @color-bg-white;
border: 1px solid @color-primary;
border-radius: @border-radius-full;
cursor: pointer;
z-index: 1;
white-space: nowrap;
transition: all @transition-fast;
font-weight: @font-weight-medium;
box-shadow: @shadow-sm;
&:hover {
background: @color-primary;
color: #fff;
}
}
.expand-icon {
font-size: @font-size-xs;
margin-right: @space-xs;
}
.screenshot-collapse {
position: relative;
bottom: auto;
left: auto;
transform: none;
display: block;
margin: @space-sm auto;
}
.review-content {
padding: @space-md @space-lg @space-lg;
flex: 1;
display: flex;
flex-direction: column;
}
.review-meta {
display: flex;
align-items: center;
gap: @space-xs;
margin-bottom: @space-xs;
}
.review-name { font-weight: @font-weight-medium; color: @color-text-primary; }
.review-sep { color: @color-text-muted; }
.review-date { font-size: @font-size-sm; color: @color-text-muted; }
.review-product { font-size: @font-size-sm; color: @color-primary; margin-bottom: @space-sm; }
.review-text {
font-size: @font-size-sm;
line-height: 1.7;
color: @color-text-secondary;
margin-bottom: @space-sm;
&.is-clamped {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.text-toggle {
background: none;
border: none;
color: @color-primary;
font-size: @font-size-xs;
cursor: pointer;
padding: 0;
margin-bottom: @space-sm;
align-self: flex-start;
&:hover {
text-decoration: underline;
}
}
.review-tags {
display: flex;
flex-wrap: wrap;
gap: @space-xs;
margin-top: auto;
}
.tag {
padding: @space-xs @space-sm;
font-size: @font-size-xs;
border-radius: @border-radius-full;
&--scene { background: @color-primary-bg; color: @color-primary; }
&--concern { background: @color-bg-warm; color: @color-accent; }
}
</style>
+120
查看文件
@@ -0,0 +1,120 @@
<template>
<div>
<!-- SEO层:全部评价文字内容在HTML中可见,供搜索引擎抓取 -->
<div class="review-list">
<ReviewsReviewItem v-for="r in visibleReviews" :key="r.id" :review="r" />
<div v-if="!reviews.length" class="review-empty">
<p>暂无符合条件的评价</p>
<button class="review-reset" @click="$emit('reset')">清除筛选,查看全部评价</button>
</div>
</div>
<div v-if="hasMore" class="review-loadmore">
<button class="loadmore-btn" @click="loadMore">
查看更多评价(还有{{ remaining }}条)
</button>
</div>
<p v-else-if="reviews.length > pageSize" class="review-end">已展示全部{{ reviews.length }}条评价</p>
<!--
SEO 隐藏层:将未展示的评价文字以语义化HTML输出到页面中
视觉上隐藏但搜索引擎可抓取,确保全部82条评价内容被索引
-->
<div v-if="hiddenReviews.length" class="sr-only" aria-hidden="true">
<div v-for="r in hiddenReviews" :key="'seo-' + r.id">
<p>{{ r.nickname }} · {{ r.travelDate }} · {{ r.productVersion }}</p>
<p>{{ r.content }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({ reviews: { type: Array, required: true } })
defineEmits(['reset'])
const pageSize = 12
const showCount = ref(pageSize)
const visibleReviews = computed(() => props.reviews.slice(0, showCount.value))
const hiddenReviews = computed(() => props.reviews.slice(showCount.value))
const hasMore = computed(() => showCount.value < props.reviews.length)
const remaining = computed(() => props.reviews.length - showCount.value)
function loadMore() {
showCount.value += pageSize
}
// 筛选条件变化时重置
watch(() => props.reviews.length, () => {
showCount.value = pageSize
})
</script>
<style lang="less" scoped>
.review-list {
display: grid;
grid-template-columns: 1fr;
gap: @space-lg;
.respond-md({
grid-template-columns: repeat(2, 1fr);
});
}
.review-empty {
grid-column: 1 / -1;
text-align: center;
padding: @space-2xl;
color: @color-text-muted;
p {
margin-bottom: @space-md;
}
}
.review-reset {
padding: @space-sm @space-lg;
font-size: @font-size-sm;
color: @color-primary;
background: @color-bg-white;
border: 1px solid @color-primary;
border-radius: @border-radius-full;
cursor: pointer;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
.review-loadmore {
text-align: center;
margin-top: @space-xl;
}
.loadmore-btn {
padding: @space-sm @space-xl;
font-size: @font-size-base;
color: @color-primary;
background: @color-bg-white;
border: 1px solid @color-primary;
border-radius: @border-radius-full;
cursor: pointer;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
.review-end {
text-align: center;
margin-top: @space-lg;
font-size: @font-size-sm;
color: @color-text-muted;
}
</style>
+105
查看文件
@@ -0,0 +1,105 @@
<template>
<section class="section section--green">
<div class="container">
<div class="summary-grid">
<div class="summary-stat">
<span class="stat-value">{{ summary.totalCount }}<span class="stat-plus">+</span></span>
<span class="stat-label">组家庭的选择</span>
</div>
<div class="summary-divider"></div>
<div class="summary-stat">
<span class="stat-value">{{ summary.approvalRate }}</span>
<span class="stat-label">好评率</span>
</div>
</div>
<div class="summary-keywords">
<span class="keywords-label">客户高频关键词</span>
<div class="keywords-list">
<span v-for="kw in summary.keywords" :key="kw" class="keyword-tag">{{ kw }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup>
defineProps({ summary: { type: Object, required: true } })
</script>
<style lang="less" scoped>
.summary-grid {
display: flex;
align-items: center;
justify-content: center;
gap: @space-2xl;
margin-bottom: @space-xl;
}
.summary-stat {
text-align: center;
}
.stat-value {
display: block;
font-size: @font-size-hero;
font-weight: @font-weight-bold;
color: @color-primary;
line-height: 1.1;
position: relative;
.respond-md({
font-size: 52px;
});
}
.stat-plus {
font-size: @font-size-xl;
color: @color-primary-light;
}
.stat-label {
display: block;
margin-top: @space-sm;
font-size: @font-size-base;
color: @color-text-secondary;
}
.summary-divider {
width: 1px;
height: 60px;
background: @color-primary-lighter;
}
.summary-keywords {
text-align: center;
}
.keywords-label {
display: block;
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.keywords-list {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: @space-sm;
}
.keyword-tag {
padding: @space-xs @space-md;
background: @color-bg-white;
border-radius: @border-radius-full;
color: @color-primary;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
transition: all @transition-fast;
&:hover {
background: @color-primary;
color: #fff;
}
}
</style>
+250
查看文件
@@ -0,0 +1,250 @@
<template>
<article class="winter-card" :class="{ 'winter-card--popular': isPopular }">
<!-- 头部 -->
<div class="card-header">
<div v-if="isPopular" class="popular-badge">推荐</div>
<div class="card-duration">
<span class="duration-num">{{ product.days }}</span>
<span class="duration-unit">天</span>
<span class="duration-num">{{ product.nights }}</span>
<span class="duration-unit">晚</span>
</div>
<span class="card-tag">{{ product.tag }}</span>
<span class="card-line">{{ product.line }} · {{ product.route }}</span>
</div>
<!-- 主体 -->
<div class="card-body">
<h3 class="card-name">{{ shortName }}</h3>
<p class="card-audience">适合:{{ product.audience }}</p>
<p class="card-desc">{{ product.description }}</p>
<ul class="card-highlights">
<li v-for="(h, i) in product.highlights" :key="i">
<span class="check" aria-hidden="true">&#10003;</span>
{{ h }}
</li>
</ul>
</div>
<!-- 行程概览 -->
<details class="card-itinerary">
<summary class="itinerary-toggle">查看每日行程 ▾</summary>
<ol class="itinerary-list">
<li v-for="(day, i) in product.itinerary" :key="i">{{ day }}</li>
</ol>
</details>
<!-- 底部 CTA -->
<NuxtLink to="/contact" class="card-cta">
咨询该行程 <span aria-hidden="true">→</span>
</NuxtLink>
</article>
</template>
<script setup>
const props = defineProps({ product: { type: Object, required: true } })
const isPopular = computed(() => props.product.id === 'winter-south-6d5n')
const shortName = computed(() => {
return props.product.name.replace(/^嗨冰雪V3·/, '')
})
</script>
<style lang="less" scoped>
.winter-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-lg;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow @transition-base, transform @transition-base;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-4px);
}
&--popular {
border: 2px solid #3B82F6;
box-shadow: @shadow-md;
.card-header {
background: #3B82F6;
}
.duration-num,
.duration-unit,
.card-tag,
.card-line {
color: #fff;
}
.card-tag {
background: rgba(255, 255, 255, 0.2);
}
}
}
.card-header {
padding: @space-lg @space-lg @space-md;
background: #EFF6FF;
text-align: center;
position: relative;
}
.popular-badge {
position: absolute;
top: @space-sm;
right: @space-sm;
padding: @space-xs @space-sm;
font-size: @font-size-xs;
font-weight: @font-weight-bold;
color: #fff;
background: @color-accent;
border-radius: @border-radius-sm;
}
.card-duration {
display: flex;
align-items: baseline;
justify-content: center;
gap: 2px;
margin-bottom: @space-sm;
}
.duration-num {
font-size: @font-size-3xl;
font-weight: @font-weight-bold;
color: #3B82F6;
line-height: 1;
}
.duration-unit {
font-size: @font-size-sm;
color: #3B82F6;
font-weight: @font-weight-medium;
}
.card-tag {
display: inline-block;
padding: @space-xs @space-md;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: #3B82F6;
background: @color-bg-white;
border-radius: @border-radius-full;
}
.card-line {
display: block;
font-size: @font-size-xs;
color: #64748B;
margin-top: @space-xs;
}
.card-body {
padding: @space-lg;
flex: 1;
display: flex;
flex-direction: column;
}
.card-name {
font-size: @font-size-lg;
font-weight: @font-weight-bold;
color: @color-text-primary;
margin-bottom: @space-xs;
}
.card-audience {
font-size: @font-size-sm;
color: @color-text-muted;
margin-bottom: @space-md;
}
.card-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-md;
.text-clamp(3);
}
.card-highlights {
display: flex;
flex-direction: column;
gap: @space-sm;
margin-top: auto;
li {
display: flex;
align-items: flex-start;
gap: @space-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
}
}
.check {
flex-shrink: 0;
color: #3B82F6;
font-size: @font-size-sm;
font-weight: @font-weight-bold;
margin-top: 2px;
}
// 行程概览
.card-itinerary {
border-top: 1px solid @color-border;
}
.itinerary-toggle {
display: block;
padding: @space-md @space-lg;
font-size: @font-size-sm;
color: @color-text-muted;
cursor: pointer;
text-align: center;
transition: background @transition-fast;
&:hover {
background: @color-bg-gray;
color: @color-text-primary;
}
}
.itinerary-list {
padding: 0 @space-lg @space-md;
display: flex;
flex-direction: column;
gap: @space-xs;
li {
font-size: @font-size-xs;
color: @color-text-secondary;
line-height: @line-height-base;
padding-left: @space-sm;
}
}
// 底部 CTA
.card-cta {
display: block;
text-align: center;
padding: @space-md @space-lg;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: #3B82F6;
background: @color-bg-gray;
text-decoration: none;
transition: all @transition-fast;
&:hover {
background: #3B82F6;
color: #fff;
}
}
</style>