feat: 官网代码全量更新 (2026-04-18)

这个提交包含在:
wx
2026-04-18 15:13:28 +08:00
父节点 fcac8f88db
当前提交 f91ecbb093
共修改 135 个文件,包含 10307 行新增和 3349 行删除
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="知识产权" subtitle="商标全品类注册 + 4项国家版权登记" />
<CommonSectionTitle :title="st('aboutPage.copyright', '知识产权')" :subtitle="st('aboutPage.copyright', '商标全品类注册 + 4项国家版权登记', 'subtitle')" />
<p class="copyright-intro">呼籁集团重视品牌知识产权保护。「呼籁」商标已完成全品类注册,核心产品名称、品牌标识和IP形象均已在中国版权保护中心完成作品登记。</p>
<!-- 商标 -->
@@ -48,6 +48,7 @@ defineProps({
copyrights: { type: Array, required: true },
trademark: { type: Object, default: null }
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="企业文化" />
<CommonSectionTitle :title="st('aboutPage.cultureValues', '企业文化')" />
<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">
@@ -15,6 +15,7 @@
<script setup>
defineProps({ culture: { type: Object, required: true } })
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="品牌IP · 籁小咩" subtitle="LAI XIAO MIE" />
<CommonSectionTitle :title="st('aboutPage.mascot', '品牌IP · 籁小咩')" :subtitle="st('aboutPage.mascot', 'LAI XIAO MIE', 'subtitle')" />
<div class="mascot-hero">
<div class="mascot-image">
@@ -61,6 +61,7 @@
defineProps({
images: { type: Object, default: () => ({}) },
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+6 -5
查看文件
@@ -2,7 +2,7 @@
<section class="section">
<div class="container">
<!-- 资质认证 -->
<CommonSectionTitle title="资质认证" />
<CommonSectionTitle :title="st('aboutPage.qualCert', '资质认证')" />
<div class="qual-list">
<div v-for="c in certifications" :key="c.title" class="qual-item qual-item--cert">
<div class="qual-icon">🏆</div>
@@ -16,15 +16,15 @@
<!-- 小红书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" />
<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;" />
<CommonSectionTitle :title="st('aboutPage.qualService', '服务保障')" 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>
@@ -43,6 +43,7 @@ defineProps({
certifications: { type: Array, required: true },
guarantees: { type: Array, required: true }
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="我们的团队" />
<CommonSectionTitle :title="st('aboutPage.team', '我们的团队')" />
<p class="team-summary">{{ team.summary }}</p>
<!-- 团队照片墙 -->
@@ -75,6 +75,7 @@ defineProps({
team: { type: Object, required: true },
images: { type: Object, default: () => ({}) },
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="小红书上的呼籁" subtitle="XIAOHONGSHU" />
<CommonSectionTitle :title="st('aboutPage.xiaohongshu', '小红书上的呼籁')" :subtitle="st('aboutPage.xiaohongshu', 'XIAOHONGSHU', 'subtitle')" />
<div class="xhs-card">
<!-- 顶部:品牌主页截图作为横幅 -->
@@ -60,6 +60,7 @@ defineProps({
xhs: { type: Object, required: true },
images: { type: Object, default: () => ({}) },
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+161
查看文件
@@ -0,0 +1,161 @@
<template>
<div class="rich-editor">
<div v-if="editor" class="rich-editor-toolbar">
<button type="button" :class="btn(editor.isActive('heading', { level: 2 }))" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" title="大标题">H2</button>
<button type="button" :class="btn(editor.isActive('heading', { level: 3 }))" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" title="小标题">H3</button>
<button type="button" :class="btn(editor.isActive('paragraph'))" @click="editor.chain().focus().setParagraph().run()" title="正文"></button>
<span class="divider" />
<button type="button" :class="btn(editor.isActive('bold'))" @click="editor.chain().focus().toggleBold().run()" title="加粗"><b>B</b></button>
<button type="button" :class="btn(editor.isActive('italic'))" @click="editor.chain().focus().toggleItalic().run()" title="斜体"><i>I</i></button>
<button type="button" :class="btn(editor.isActive('strike'))" @click="editor.chain().focus().toggleStrike().run()" title="删除线"><s>S</s></button>
<span class="divider" />
<button type="button" :class="btn(editor.isActive('bulletList'))" @click="editor.chain().focus().toggleBulletList().run()" title="无序列表">• 列表</button>
<button type="button" :class="btn(editor.isActive('orderedList'))" @click="editor.chain().focus().toggleOrderedList().run()" title="有序列表">1. 列表</button>
<button type="button" :class="btn(editor.isActive('blockquote'))" @click="editor.chain().focus().toggleBlockquote().run()" title="引用">" 引用</button>
<span class="divider" />
<button type="button" :class="btn(editor.isActive('link'))" @click="setLink" title="链接">🔗 链接</button>
<button type="button" class="toolbar-btn" @click="addImage" title="插入图片">🖼 图片</button>
<span class="divider" />
<button type="button" class="toolbar-btn" @click="editor.chain().focus().undo().run()" title="撤销">↶</button>
<button type="button" class="toolbar-btn" @click="editor.chain().focus().redo().run()" title="重做">↷</button>
<span class="divider" />
<button type="button" class="toolbar-btn" :class="{ active: showHtml }" @click="showHtml = !showHtml" title="源代码视图">&lt;/&gt; HTML</button>
</div>
<EditorContent v-show="!showHtml" :editor="editor" class="rich-editor-body" />
<textarea
v-show="showHtml"
:value="modelValue"
class="rich-editor-html"
rows="20"
@input="$emit('update:modelValue', $event.target.value); editor?.commands.setContent($event.target.value, false)"
/>
</div>
</template>
<script setup>
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { watch, ref, onBeforeUnmount } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '开始写作...' },
})
const emit = defineEmits(['update:modelValue'])
const showHtml = ref(false)
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit,
Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener', target: '_blank' } }),
Image,
],
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getHTML())
},
})
watch(() => props.modelValue, (val) => {
if (!editor.value) return
const current = editor.value.getHTML()
if (val !== current) editor.value.commands.setContent(val || '', false)
})
onBeforeUnmount(() => editor.value?.destroy())
function btn(active) { return { 'toolbar-btn': true, active } }
function setLink() {
const prev = editor.value.getAttributes('link').href
const url = window.prompt('链接 URL', prev || 'https://')
if (url === null) return
if (url === '') { editor.value.chain().focus().extendMarkRange('link').unsetLink().run(); return }
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
function addImage() {
const url = window.prompt('图片 URL(可填 /uploads/ 或 /images/ 路径)')
if (url) editor.value.chain().focus().setImage({ src: url }).run()
}
</script>
<style scoped>
.rich-editor {
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
}
.rich-editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px;
border-bottom: 1px solid #e5e7eb;
background: #fafafa;
border-radius: 4px 4px 0 0;
}
.toolbar-btn {
padding: 4px 10px;
font-size: 13px;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 4px;
cursor: pointer;
color: #374151;
transition: all 0.15s;
}
.toolbar-btn:hover { background: #f3f4f6; border-color: #3a7d44; }
.toolbar-btn.active { background: #3a7d44; color: #fff; border-color: #3a7d44; }
.divider {
width: 1px;
background: #e5e7eb;
margin: 2px 4px;
}
.rich-editor-body {
padding: 12px 14px;
min-height: 400px;
font-size: 14px;
line-height: 1.7;
color: #111827;
}
.rich-editor-body :deep(.ProseMirror) { outline: none; min-height: 380px; }
.rich-editor-body :deep(.ProseMirror:focus) { outline: none; }
.rich-editor-body :deep(h2) { font-size: 20px; font-weight: 600; margin: 18px 0 10px; color: #111827; }
.rich-editor-body :deep(h3) { font-size: 16px; font-weight: 600; margin: 14px 0 8px; color: #111827; }
.rich-editor-body :deep(p) { margin: 8px 0; }
.rich-editor-body :deep(ul), .rich-editor-body :deep(ol) { padding-left: 24px; margin: 8px 0; }
.rich-editor-body :deep(li) { margin: 4px 0; }
.rich-editor-body :deep(blockquote) {
border-left: 3px solid #3a7d44;
padding-left: 12px;
color: #6b7280;
margin: 12px 0;
}
.rich-editor-body :deep(a) { color: #3a7d44; text-decoration: underline; }
.rich-editor-body :deep(img) { max-width: 100%; height: auto; border-radius: 4px; }
.rich-editor-body :deep(strong) { font-weight: 600; }
.rich-editor-html {
width: 100%;
padding: 12px 14px;
border: none;
outline: none;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
resize: vertical;
min-height: 400px;
background: #1f2937;
color: #d1fae5;
border-radius: 0 0 4px 4px;
}
</style>
+152 -8
查看文件
@@ -21,17 +21,54 @@
<ul class="card-highlights">
<li v-for="(h, i) in product.highlights" :key="i">
<span class="check" aria-hidden="true">&#10003;</span>
{{ h }}
{{ itineraryText(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>
<!-- 价格区间 -->
<div v-if="priceRange" class="card-price">
<div class="price-main">
<span class="price-label">成人价</span>
<span class="price-value">¥{{ priceRange.min }}</span>
<span v-if="priceRange.max > priceRange.min" class="price-range">
&nbsp;起 · 旺季 ¥{{ priceRange.max }}
</span>
<span v-else class="price-range">&nbsp;起</span>
</div>
<div v-if="priceRange.childMin" class="price-sub">
儿童 ¥{{ priceRange.childMin }} 起
</div>
</div>
<!-- 行程 + 报价展开 -->
<details v-if="hasItinerary || hasPricing" class="card-itinerary">
<summary class="itinerary-toggle">
<span v-if="hasItinerary && hasPricing">查看每日行程 + 分日期报价 ▾</span>
<span v-else-if="hasItinerary">查看每日行程 ▾</span>
<span v-else>查看分日期报价 ▾</span>
</summary>
<div v-if="hasItinerary" class="itinerary-section">
<div class="section-head">每日行程</div>
<ol class="itinerary-list">
<li v-for="(day, i) in product.itinerary" :key="i">{{ itineraryText(day) }}</li>
</ol>
</div>
<div v-if="hasPricing" class="pricing-section">
<div class="section-head">分日期报价</div>
<div class="pricing-table">
<div class="pricing-row pricing-head">
<span class="col-date">出行日期</span>
<span class="col-price">成人价</span>
<span class="col-price">儿童价</span>
</div>
<div v-for="(row, i) in product.pricing" :key="i" class="pricing-row">
<span class="col-date">{{ row.date }}</span>
<span class="col-price">¥{{ row.adult }}</span>
<span class="col-price">¥{{ row.child }}</span>
</div>
</div>
</div>
</details>
<!-- 底部 CTA -->
@@ -49,6 +86,27 @@ const isPopular = computed(() => props.product.id === 'autumn-south-5d4n')
const shortName = computed(() => {
return props.product.name.replace(/^游牧的森林V3·/, '')
})
const hasItinerary = computed(() => Array.isArray(props.product.itinerary) && props.product.itinerary.length > 0)
const hasPricing = computed(() => Array.isArray(props.product.pricing) && props.product.pricing.length > 0)
const priceRange = computed(() => {
if (!hasPricing.value) return null
const adults = props.product.pricing.map(p => Number(p.adult)).filter(n => n > 0)
const children = props.product.pricing.map(p => Number(p.child)).filter(n => n > 0)
if (!adults.length) return null
return {
min: Math.min(...adults),
max: Math.max(...adults),
childMin: children.length ? Math.min(...children) : null,
}
})
function itineraryText(item) {
if (typeof item === 'string') return item
if (item && typeof item === 'object') return item.title || ''
return ''
}
</script>
<style lang="less" scoped>
@@ -196,7 +254,47 @@ const shortName = computed(() => {
margin-top: 2px;
}
// 行程概览
// 价格区间
.card-price {
padding: @space-md @space-lg;
background: linear-gradient(135deg, #FFF7ED 0%, #FEF3C7 100%);
border-top: 1px solid rgba(217, 119, 6, 0.15);
border-bottom: 1px solid rgba(217, 119, 6, 0.15);
}
.price-main {
display: flex;
align-items: baseline;
gap: 4px;
flex-wrap: wrap;
}
.price-label {
font-size: @font-size-xs;
color: #92400E;
margin-right: @space-xs;
}
.price-value {
font-size: @font-size-2xl;
font-weight: @font-weight-bold;
color: #B45309;
line-height: 1;
}
.price-range {
font-size: @font-size-xs;
color: #92400E;
}
.price-sub {
margin-top: 4px;
font-size: @font-size-xs;
color: #92400E;
opacity: 0.85;
}
// 行程概览 + 分日期报价
.card-itinerary {
border-top: 1px solid @color-border;
}
@@ -209,6 +307,9 @@ const shortName = computed(() => {
cursor: pointer;
text-align: center;
transition: background @transition-fast;
list-style: none;
&::-webkit-details-marker { display: none; }
&:hover {
background: @color-bg-gray;
@@ -216,6 +317,20 @@ const shortName = computed(() => {
}
}
.section-head {
padding: 0 @space-lg @space-xs;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
color: #D97706;
letter-spacing: 0.5px;
}
.itinerary-section + .pricing-section {
margin-top: @space-md;
padding-top: @space-md;
border-top: 1px dashed @color-border;
}
.itinerary-list {
padding: 0 @space-lg @space-md;
display: flex;
@@ -230,6 +345,35 @@ const shortName = computed(() => {
}
}
.pricing-table {
padding: 0 @space-lg @space-md;
display: flex;
flex-direction: column;
font-size: @font-size-xs;
}
.pricing-row {
display: grid;
grid-template-columns: 1.6fr 1fr 1fr;
gap: @space-sm;
padding: 6px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.06);
color: @color-text-secondary;
&:last-child { border-bottom: none; }
.col-date { color: @color-text-primary; }
.col-price { text-align: right; color: #B45309; font-weight: @font-weight-medium; font-variant-numeric: tabular-nums; }
}
.pricing-head {
font-weight: @font-weight-medium;
color: @color-text-muted;
border-bottom: 1px solid @color-border;
.col-date, .col-price { color: @color-text-muted; font-weight: @font-weight-medium; }
}
// 底部 CTA
.card-cta {
display: block;
+246
查看文件
@@ -0,0 +1,246 @@
<template>
<div v-if="visible" class="floating-contact-wrap">
<!-- 折叠态:小圆点,点击展开 -->
<button
v-if="collapsed"
type="button"
class="floating-contact floating-contact--mini"
aria-label="打开咨询客服"
@click="expand"
>
<span class="fc-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</span>
</button>
<!-- 展开态:胶囊按钮 + 关闭小钮 -->
<NuxtLink
v-else
:to="target"
class="floating-contact"
:aria-label="ariaLabel"
@click="onClick"
>
<span class="fc-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</span>
<span class="fc-label">{{ label }}</span>
<span class="fc-pulse" aria-hidden="true"></span>
</NuxtLink>
<button
v-if="!collapsed"
type="button"
class="floating-contact-close"
aria-label="收起咨询客服"
@click.stop="collapse"
>
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
</template>
<script setup>
const route = useRoute()
const { data: brand } = await usePublicApi('brand')
const STORAGE_KEY = 'hulai:floatingContact:collapsed'
const collapsed = ref(false)
onMounted(() => {
try {
collapsed.value = sessionStorage.getItem(STORAGE_KEY) === '1'
} catch {}
})
function collapse() {
collapsed.value = true
try { sessionStorage.setItem(STORAGE_KEY, '1') } catch {}
track('collapse')
}
function expand() {
collapsed.value = false
try { sessionStorage.removeItem(STORAGE_KEY) } catch {}
track('expand')
}
function track(action) {
try {
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
window.gtag('event', 'floating_contact_' + action, { page_path: route.path })
}
if (typeof window !== 'undefined' && window._hmt && Array.isArray(window._hmt)) {
window._hmt.push(['_trackEvent', 'FloatingContact', action, route.path])
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('floating-contact', { detail: { action, path: route.path } }))
}
} catch {}
}
function onClick() {
track('click')
}
// 后台可配:brand.floatingCTA = { enabled, text, to, hideOnPaths }
const config = computed(() => brand.value?.floatingCTA || {})
const label = computed(() => config.value.text || '咨询客服')
const target = computed(() => config.value.to || '/contact')
const ariaLabel = computed(() => label.value)
const hideOnPaths = computed(() => {
const p = config.value.hideOnPaths
if (Array.isArray(p) && p.length) return p
return ['/contact']
})
const visible = computed(() => {
if (config.value.enabled === false) return false
const cur = route.path
return !hideOnPaths.value.some(p => cur === p || (p.endsWith('/') ? cur.startsWith(p) : cur.startsWith(p + '/')) || cur === p)
})
</script>
<style lang="less" scoped>
.floating-contact-wrap {
position: fixed;
right: 20px;
bottom: 24px;
z-index: 90;
@media (max-width: 767px) {
right: 16px;
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
}
.respond-md({
bottom: calc(28px + env(safe-area-inset-bottom, 0px));
right: 28px;
});
}
.floating-contact {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 18px 0 14px;
height: 52px;
border-radius: 999px;
background: linear-gradient(135deg, @color-primary 0%, @color-primary-light 100%);
color: #fff;
text-decoration: none;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
letter-spacing: 0.5px;
border: none;
cursor: pointer;
box-shadow:
0 10px 24px -6px rgba(45, 106, 79, 0.45),
0 4px 10px -2px rgba(0, 0, 0, 0.15);
transition: transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
will-change: transform;
&:hover {
transform: translateY(-2px);
box-shadow:
0 14px 30px -6px rgba(45, 106, 79, 0.55),
0 6px 14px -2px rgba(0, 0, 0, 0.18);
}
&:active {
transform: translateY(0);
}
&--mini {
width: 44px;
height: 44px;
padding: 0;
justify-content: center;
opacity: 0.85;
&:hover { opacity: 1; }
}
@media (max-width: 767px) {
width: 52px;
height: 52px;
padding: 0;
justify-content: center;
.fc-label { display: none; }
&--mini {
width: 44px;
height: 44px;
}
}
}
.floating-contact-close {
position: absolute;
top: -6px;
right: -6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
color: #374151;
border: 1px solid rgba(0,0,0,0.08);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
transition: background 0.2s ease, transform 0.2s ease;
&:hover {
background: #f3f4f6;
transform: scale(1.08);
}
svg { display: block; }
}
.fc-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.fc-label {
white-space: nowrap;
line-height: 1;
}
.fc-pulse {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: 0 0 0 0 rgba(64, 145, 108, 0.55);
animation: fc-pulse 2.4s ease-out infinite;
}
@keyframes fc-pulse {
0% { box-shadow: 0 0 0 0 rgba(64, 145, 108, 0.5); }
70% { box-shadow: 0 0 0 14px rgba(64, 145, 108, 0); }
100% { box-shadow: 0 0 0 0 rgba(64, 145, 108, 0); }
}
@media (prefers-reduced-motion: reduce) {
.fc-pulse { animation: none; }
.floating-contact { transition: none; }
}
</style>
+2 -1
查看文件
@@ -17,7 +17,7 @@
</div>
</div>
<div class="wechat-cta-qr">
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="wechat-cta-qrimg" />
<img :src="qrSrc" alt="定制师微信二维码" class="wechat-cta-qrimg" />
<span class="wechat-cta-qrlabel">扫码添加定制师微信</span>
</div>
</div>
@@ -32,6 +32,7 @@ defineProps({
links: { type: Array, default: () => [] },
bg: { type: String, default: 'light' },
})
const qrSrc = '/images/contact/qrcode-wechat.jpg'
</script>
<style lang="less" scoped>
+3 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="为什么选择呼籁" label="WHY HULAI" subtitle="不只是包车,是一套成熟的旅行服务体系" />
<CommonSectionTitle :title="st('home.differentiator', '为什么选择呼籁')" :label="st('home.differentiator', 'WHY HULAI', 'label')" :subtitle="st('home.differentiator', '不只是包车,是一套成熟的旅行服务体系', 'subtitle')" />
<div class="diff-grid">
<div v-for="(item, index) in itemsWithBg" :key="item.title" class="diff-item">
<div class="diff-bg">
@@ -23,6 +23,8 @@ const props = defineProps({
items: { type: Array, required: true },
})
const st = await useSectionTitles()
const bgImages = [
'/images/seasons/summer-hero.jpg',
'/images/seasons/summer-car.jpg',
+3 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="旅行产品" subtitle="四季不同风景,总有一条线路适合你的家庭" />
<CommonSectionTitle :title="st('home.productPreview', '旅行产品')" :subtitle="st('home.productPreview', '四季不同风景,总有一条线路适合你的家庭', 'subtitle')" />
<div class="product-grid">
<NuxtLink
v-for="line in productLines"
@@ -36,6 +36,8 @@
</template>
<script setup>
const st = await useSectionTitles()
const productLines = [
{
id: 'summer',
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle title="客户怎么说" label="REVIEWS" subtitle="来自真实家庭的旅行评价" />
<CommonSectionTitle :title="st('home.reviewHighlights', '客户怎么说')" :label="st('home.reviewHighlights', 'REVIEWS', 'label')" :subtitle="st('home.reviewHighlights', '来自真实家庭的旅行评价', 'subtitle')" />
<div class="review-grid">
<div v-for="r in reviews" :key="r.id" class="review-card">
<div class="review-quote-mark">"</div>
@@ -27,6 +27,7 @@
defineProps({
reviews: { type: Array, required: true },
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+109 -33
查看文件
@@ -4,22 +4,15 @@
<div v-if="brand && images" class="footer-grid">
<!-- 品牌信息 -->
<div class="footer-brand">
<div class="footer-logo">
<img v-if="images?.logo?.main" :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 class="footer-brand-main">
<div class="footer-logo">
<img v-if="images?.logo?.main" :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>
</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"
/>
<p class="footer-desc">{{ brand?.slogan?.functional }}</p>
</div>
</div>
@@ -32,11 +25,22 @@
</li>
</ul>
</div>
<!-- 二维码区(桌面端独立列,手机端嵌入品牌区下方) -->
<div v-if="qrChannels.length" class="footer-qr-list">
<CommonQRCodeBlock
v-for="ch in qrChannels"
:key="ch.type"
:image="ch.qrImage"
:label="ch.label"
size="small"
/>
</div>
</div>
<!-- 底部信息 -->
<div class="footer-bottom">
<p class="footer-company">旅行服务:内蒙古呼籁国际旅行社有限公司</p>
<p v-if="brand?.serviceEntity" class="footer-company">旅行服务:{{ brand.serviceEntity }}</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>
@@ -84,19 +88,72 @@ const qrChannels = computed(() => (contact.value?.channels || []).filter(c => c.
.footer-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-xl;
// 手机端:品牌区占满一行,三个导航组 3 列并排,二维码单独一行
grid-template-columns: repeat(3, 1fr);
grid-template-areas:
'brand brand brand'
'nav1 nav2 nav3'
'qr qr qr';
gap: @space-lg @space-md;
.respond-md({
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
gap: @space-xl;
// 桌面端:品牌 | 3 列导航 | 二维码,布局紧凑
grid-template-columns: 1.3fr 1fr 1fr 1fr auto;
grid-template-areas: 'brand nav1 nav2 nav3 qr';
gap: @space-2xl;
align-items: start;
});
}
.footer-qr-list {
grid-area: qr;
display: flex;
gap: @space-md;
flex-shrink: 0;
// 手机端二维码缩到 64px
@media (max-width: 767px) {
:deep(.qr-image) {
width: 64px;
height: 64px;
}
}
.respond-md({
gap: @space-lg;
});
}
.footer-brand {
margin-bottom: @space-lg;
grid-area: brand;
margin-bottom: 0;
// 手机端:logo + 文字 + 二维码横向排列,节省高度
display: flex;
align-items: flex-start;
gap: @space-md;
flex-wrap: wrap;
.respond-md({
// 桌面端:品牌信息紧凑堆叠(logo + slogan),二维码移到右侧独立列
display: block;
margin-bottom: 0;
});
}
.footer-brand-main {
flex: 1;
min-width: 0;
.respond-md({
flex: none;
width: 100%;
});
}
.footer-nav-group:nth-child(2) { grid-area: nav1; }
.footer-nav-group:nth-child(3) { grid-area: nav2; }
.footer-nav-group:nth-child(4) { grid-area: nav3; }
.footer-logo {
display: flex;
align-items: center;
@@ -105,10 +162,15 @@ const qrChannels = computed(() => (contact.value?.channels || []).filter(c => c.
}
.footer-logo-img {
width: 44px;
height: 44px;
width: 36px;
height: 36px;
border-radius: 6px;
flex-shrink: 0;
.respond-md({
width: 44px;
height: 44px;
});
}
.footer-logo-text {
@@ -129,35 +191,49 @@ const qrChannels = computed(() => (contact.value?.channels || []).filter(c => c.
}
.footer-desc {
font-size: @font-size-sm;
margin-bottom: @space-lg;
font-size: 12px;
margin-bottom: 0;
color: rgba(255, 255, 255, 0.6);
}
line-height: 1.6;
.footer-qr-list {
display: flex;
gap: @space-lg;
.respond-md({
font-size: @font-size-sm;
margin-bottom: 0;
});
}
.footer-nav-title {
font-size: @font-size-base;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: #fff;
margin-bottom: @space-md;
margin-bottom: @space-sm;
.respond-md({
font-size: @font-size-base;
margin-bottom: @space-md;
});
}
.footer-nav-list {
display: flex;
flex-direction: column;
gap: @space-sm;
gap: 6px;
.respond-md({
gap: @space-sm;
});
}
.footer-nav-link {
font-size: @font-size-sm;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
transition: color @transition-fast;
.respond-md({
font-size: @font-size-sm;
});
&:hover {
color: #fff;
}
+16 -12
查看文件
@@ -175,9 +175,10 @@ watch(() => route.path, () => {
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
font-size: 13px;
margin-left: 4px;
opacity: 0.85;
color: @color-primary;
transition: transform @transition-fast;
.has-dropdown:hover & {
@@ -256,9 +257,10 @@ watch(() => route.path, () => {
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
font-size: 13px;
margin-left: 4px;
opacity: 0.85;
color: @color-primary;
transition: transform @transition-fast;
.has-dropdown:hover & {
@@ -337,9 +339,10 @@ watch(() => route.path, () => {
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
font-size: 13px;
margin-left: 4px;
opacity: 0.85;
color: @color-primary;
transition: transform @transition-fast;
.has-dropdown:hover & {
@@ -418,9 +421,10 @@ watch(() => route.path, () => {
}
.nav-arrow {
font-size: 10px;
margin-left: 2px;
opacity: 0.5;
font-size: 13px;
margin-left: 4px;
opacity: 0.85;
color: @color-primary;
transition: transform @transition-fast;
.has-dropdown:hover & {
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section section--warm">
<div class="container">
<CommonSectionTitle title="产品进化" subtitle="从V1到V9,每一次更新都是客户反馈驱动" />
<CommonSectionTitle :title="st('products.evolutionStats', '产品进化')" :subtitle="st('products.evolutionStats', '从V1到V9,每一次更新都是客户反馈驱动', 'subtitle')" />
<div class="evo-stats">
<div class="evo-stat">
<span class="evo-stat-num">{{ stats.iterations }}</span>
@@ -25,6 +25,7 @@
defineProps({
stats: { type: Object, required: true }
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="关于价格" subtitle="透明、清晰、不套路" />
<CommonSectionTitle :title="st('products.pricingPhilosophy', '关于价格')" :subtitle="st('products.pricingPhilosophy', '透明、清晰、不套路', 'subtitle')" />
<div class="pricing-content">
<p>{{ content }}</p>
</div>
@@ -11,6 +11,7 @@
<script setup>
defineProps({ content: { type: String, required: true } })
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section id="guide" class="section section--gray">
<div class="container">
<CommonSectionTitle title="不知道选哪个?" subtitle="按您的情况推荐" />
<CommonSectionTitle :title="st('products.selectionGuide', '不知道选哪个?')" :subtitle="st('products.selectionGuide', '按您的情况推荐', '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>
@@ -26,6 +26,7 @@ const props = defineProps({
guide: { type: Object, required: true },
versions: { type: Array, required: true },
})
const st = await useSectionTitles()
const guideGroups = computed(() => [
{ title: '按假期长度', items: props.guide.byVacationLength },
+5 -2
查看文件
@@ -7,7 +7,7 @@
<div class="camp-hero">
<div class="camp-mascot">
<img
:src="images.mascot.xiaomengma"
:src="mascotSrc"
alt="小蒙马 - 小蒙马亲子游学营团宠,一只可爱的蒙古小马形象"
width="200"
height="200"
@@ -33,8 +33,11 @@
</template>
<script setup>
import images from '~/data/images.json'
defineProps({ camp: { type: Object, required: true } })
// 从 API 动态读取图片配置(admin 编辑后即时同步)
const { data: images } = await usePublicApi('images')
const mascotSrc = computed(() => images.value?.mascot?.xiaomengma || '/images/mascot/xiaomengma.png')
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="V8 → V9 升级对比" subtitle="7项关键升级一目了然" />
<CommonSectionTitle :title="st('products.compareV8V9', 'V8 → V9 升级对比')" :subtitle="st('products.compareV8V9', '7项关键升级一目了然', 'subtitle')" />
<div class="compare-wrapper">
<div class="compare-table">
<div class="compare-head">
@@ -22,6 +22,7 @@
defineProps({
compare: { type: Object, required: true }
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section section--gray">
<div class="container">
<CommonSectionTitle title="更多V9亮点" subtitle="独家资源 · 自营服务 · 全程无忧" />
<CommonSectionTitle :title="st('products.highlights', '更多V9亮点')" :subtitle="st('products.highlights', '专属资源 · 自营服务 · 全程无忧', 'subtitle')" />
<div class="highlights-list">
<div v-for="item in highlights" :key="item.text" class="highlight-item">
<span class="highlight-label">{{ item.label }}</span>
@@ -16,6 +16,7 @@
defineProps({
highlights: { type: Array, required: true }
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="9次迭代背后的产品哲学" subtitle="不是做加法,而是做取舍" />
<CommonSectionTitle :title="st('products.philosophy', '9次迭代背后的产品哲学')" :subtitle="st('products.philosophy', '不是做加法,而是做取舍', 'subtitle')" />
<div class="philosophy-wrapper">
<div class="philosophy-list">
<div v-for="item in philosophy" :key="item.label" class="philosophy-item">
@@ -24,6 +24,7 @@ defineProps({
philosophy: { type: Array, required: true },
quote: { type: Object, required: true }
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>
+2 -1
查看文件
@@ -1,7 +1,7 @@
<template>
<section class="section">
<div class="container">
<CommonSectionTitle title="2026年核心升级" subtitle="基于客户反馈的三大重磅升级" />
<CommonSectionTitle :title="st('products.upgrades', '2026年核心升级')" :subtitle="st('products.upgrades', '基于客户反馈的三大重磅升级', 'subtitle')" />
<div class="upgrades-list">
<div v-for="item in upgrades" :key="item.name" class="upgrade-item">
<div class="upgrade-header">
@@ -20,6 +20,7 @@
defineProps({
upgrades: { type: Array, required: true }
})
const st = await useSectionTitles()
</script>
<style lang="less" scoped>