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

这个提交包含在:
wx 2026-04-18 15:13:28 +08:00
父节点 fcac8f88db
当前提交 f91ecbb093
共有 135 个文件被更改,包括 10307 次插入3349 次删除

查看文件

@ -6,7 +6,7 @@
"runtimeExecutable": "npx",
"runtimeArgs": ["nuxt", "dev", "--port", "3000"],
"port": 3000,
"cwd": "/Users/liutao/Desktop/呼籁智库/hulai-website"
"cwd": "/Volumes/Mac wz/hulai-website"
}
]
}

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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;

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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',

查看文件

@ -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',

查看文件

@ -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>

查看文件

@ -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;
}

查看文件

@ -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 & {

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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 },

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

查看文件

@ -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>

40
composables/useAdminSort.js 普通文件
查看文件

@ -0,0 +1,40 @@
// 通用排序工具:在分页列表内与相邻行交换 sortOrder
// 用法:
// const { swapAdjacent } = useAdminSort('/api/admin/faqs')
// await swapAdjacent(currentRow, neighborRow) // 把两条的 sortOrder 字段互换并落库
export function useAdminSort(apiBase) {
const { adminFetch } = useAdmin()
async function swapAdjacent(a, b) {
if (!a || !b) return
const orderA = a.sortOrder ?? 0
const orderB = b.sortOrder ?? 0
// 同时发两次 PUT并行
await Promise.all([
adminFetch(`${apiBase}/${a.id}`, { method: 'PUT', body: { sortOrder: orderB } }),
adminFetch(`${apiBase}/${b.id}`, { method: 'PUT', body: { sortOrder: orderA } }),
])
a.sortOrder = orderB
b.sortOrder = orderA
}
// 在列表内按 UI 位置上移/下移(仅与相邻行交换)
async function moveUp(list, index) {
if (index <= 0) return
const a = list[index]
const b = list[index - 1]
await swapAdjacent(a, b)
// UI 顺序也要立即交换,让人看见结果
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
}
async function moveDown(list, index) {
if (index >= list.length - 1) return
const a = list[index]
const b = list[index + 1]
await swapAdjacent(a, b)
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
}
return { swapAdjacent, moveUp, moveDown }
}

查看文件

@ -300,7 +300,7 @@ export function useHowToSchema(sections) {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: '呼伦贝尔出行准备指南',
description: '呼籁旅行根据11年带队经验整理的呼伦贝尔出行指南证件准备、各月穿搭建议含气温数据、行李清单、骑马/ATV/滑草安全须知、0-3岁幼童活动推荐、实用信息汇总。',
description: '呼籁旅行根据11年带队经验整理的呼伦贝尔出行指南证件准备、各月穿搭建议含气温数据、行李清单、骑马/全地形越野车/滑草安全须知、0-3岁幼童活动推荐、实用信息汇总。',
step: steps,
totalTime: 'PT30M',
estimatedCost: { '@type': 'MonetaryAmount', currency: 'CNY', value: '0' },

查看文件

@ -0,0 +1,33 @@
/**
* 栏目标题CommonSectionTitle统一读取器
*
* 用法
* const st = await useSectionTitles()
* <CommonSectionTitle
* :title="st('summerCamp.principles', '行程设计四大原则')"
* :subtitle="st('summerCamp.principles', '每一处安排都经过精心考量', 'subtitle')"
* />
*
* st(path, fallback, field = 'title') 三个参数都是字符串
* field 可选 'title' | 'subtitle' | 'label'
*/
export async function useSectionTitles() {
const { data } = await usePublicApi('section-titles')
return (path, fallback = '', field = 'title') => {
const parts = (path || '').split('.')
let cursor = data.value
for (const p of parts) {
if (cursor && typeof cursor === 'object' && p in cursor) {
cursor = cursor[p]
} else {
return fallback
}
}
if (cursor && typeof cursor === 'object') {
return cursor[field] || fallback
}
return fallback
}
}

查看文件

@ -223,7 +223,7 @@
},
{
"name": "内蒙古呼籁山河国际旅行社有限公司",
"role": "持有旅游经营许可证,工商注册于2005年,2015年由创始团队接手经营,是集团早的旅行社资质主体",
"role": "持有旅游经营许可证,工商注册于2005年,2015年由创始团队接手经营,是集团早的旅行社资质主体",
"established": "2005-04"
},
{

查看文件

@ -2,53 +2,20 @@
"brand": "游牧的森林",
"version": "V3",
"season": "秋季9月中旬-10月中旬",
"narrative": "呼伦贝尔+阿尔山,是很多人一生的梦想。这里是每年中国境内最早入秋的地方之一——森林、草原、湿地、公路,绝美、震撼、火山天池、林区民俗……但很多人一生也只能来一次而已!期待通过大兴安岭秋季南北线之旅,让您和家人也爱上我的家乡,留下一生美好的回忆。",
"narrative": "呼伦贝尔+阿尔山,是很多人一生的梦想。这里是每年国内较早入秋的地方之一——森林、草原、湿地、公路,动人、震撼、火山天池、林区民俗……但很多人一生也只能来一次而已!期待通过大兴安岭秋季南北线之旅,让您和家人也爱上我的家乡,留下一生美好的回忆。",
"style": "独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景",
"highlights": [
{
"title": "《语文》课本中的大兴安岭",
"description": "中国境内最早入秋的地方,每年9-10月变成一片金黄色的海洋。"
},
{
"title": "童话之城阿尔山",
"description": "不是山,风景却美过任何一座山,大兴安岭腹地,有人说这里是「东方瑞士」。"
},
{
"title": "扎罗木得断崖",
"description": "呼伦贝尔秋季最亮丽的名片,兼顾草原、湿地和白桦林等多种风景于一体,24年才被发掘的私密小众景点。"
},
{
"title": "秋野素拍",
"description": "专业户外人像摄影师提供素拍服务,在草原、湿地和白桦林中定格秋日美好。"
},
{
"title": "黑山头骑马赏日落",
"description": "呼伦贝尔旅游的一张名片,秋天的这里绝美,脚下就是壮观的额尔古纳湿地五彩斑斓。"
},
{
"title": "呼伦贝尔大草原",
"description": "秋季的草原别有一番韵味,满眼金黄色的牧草,牛羊成群,草原人少可以独享宁静。"
},
{
"title": "牧云山顶赏莫日格勒河最佳视角",
"description": "九曲十八弯尽收眼底,同时享用精致下午茶。"
},
{
"title": "ATV卡丁车穿越湿地",
"description": "穿越亚洲最大的原生草本湿地,在黑山头圣地开展穿越草原、湿地、河流、森林的极致之旅。"
},
{
"title": "白桦林里喂驯鹿",
"description": "敖鲁古雅使鹿鄂温克,零距离喂苔藓,与森林中的驯鹿精灵合拍限10.7日前)。"
},
{
"title": "一半茫茫林海一半火山天池",
"description": "阿尔山-柴河火山带,兴安天池(状如心形)、驼峰岭天池(上帝留在大兴安岭的蓝眼泪)。"
},
{
"title": "阿尔山国家森林公园",
"description": "内蒙古东部地区唯一的国家AAAAA级景区,森林覆盖率高达80%,秋季是阿尔山最美的时节。"
}
{ "title": "《语文》课本中的大兴安岭", "description": "国内较早入秋的地方之一,每年9-10月变成一片金黄色的海洋。" },
{ "title": "童话之城阿尔山", "description": "不是山,风景却别具一格,大兴安岭腹地,有人说这里是「东方瑞士」。" },
{ "title": "扎罗木得断崖", "description": "呼伦贝尔秋季的亮丽名片之一,兼顾草原、湿地和白桦林等多种风景于一体,24年才被发掘的小众景点。" },
{ "title": "秋野素拍", "description": "专业户外人像摄影师提供素拍服务,在草原、湿地和白桦林中定格秋日美好。" },
{ "title": "黑山头骑马赏日落", "description": "呼伦贝尔旅游的一张名片,秋天的这里景致动人,脚下就是壮观的额尔古纳湿地五彩斑斓。" },
{ "title": "呼伦贝尔大草原", "description": "秋季的草原别有一番韵味,满眼金黄色的牧草,牛羊成群,草原人少可以独享宁静。" },
{ "title": "牧云山顶赏莫日格勒河全景视角", "description": "九曲十八弯尽收眼底,同时享用精致下午茶。" },
{ "title": "ATV卡丁车森林穿越", "description": "在阿尔山森林中开展约50分钟的ATV穿越之旅,充分感受秋日森林的旖旎风光。" },
{ "title": "白桦林里喂驯鹿", "description": "敖鲁古雅使鹿鄂温克,零距离喂苔藓,与森林中的驯鹿精灵合拍。" },
{ "title": "一半茫茫林海一半火山天池", "description": "阿尔山-柴河火山带,兴安天池(状如心形)、驼峰岭天池(上帝留在大兴安岭的蓝眼泪)。" },
{ "title": "阿尔山国家森林公园", "description": "国家AAAAA级景区,森林覆盖率高达80%,秋季是阿尔山色彩浓郁的时节。" }
],
"versions": [
{
@ -58,15 +25,30 @@
"route": "呼伦贝尔+阿尔山",
"days": 4,
"nights": 3,
"audience": "假期极短但想看秋色的旅行者",
"description": "秋季南线短途版,4天时间串联呼伦贝尔草原与阿尔山秋色精华。牧云山顶赏莫日格勒河、扎罗木得断崖秋色、阿尔山国家森林公园,核心秋景一个不少。行程紧凑高效,适合假期极短但不想错过大兴安岭秋色的旅行者。",
"audience": "假期较短但想深度感受阿尔山秋色的旅行者",
"description": "秋季南线短途版,4天3晚聚焦阿尔山国家森林公园精华。抵达海拉尔后直奔大兴安岭腹地,从兴安天池、驼峰岭天池到白狼峰日落,火山天池与金色林海一次看全。行程紧凑高效,也适合作为北线结束后的延伸体验。",
"highlights": [
"牧云山顶赏莫日格勒河九曲十八弯",
"扎罗木得断崖秋色,24年新发掘的小众秘境",
"阿尔山国家森林公园AAAAA级景区"
"阿尔山国家森林公园AAAAA级深度游览",
"兴安天池+驼峰岭天池,一半林海一半火山天池",
"白狼峰日落+日式小火车站打卡"
],
"tag": "短途版",
"itinerary": []
"itinerary": [
"D1 各地→海拉尔(接机+自由活动)",
"D2 海拉尔-森林草原过渡带-兴安天池-三潭峡-不冻河-阿尔山国家森林公园340公里",
"D3 乌苏浪子湖-驼峰岭天池-石塘林-杜鹃湖-白狼峰日落-阿尔山100公里",
"D4 阿尔山-日式车站-伊敏河湿地-海拉尔散团300公里"
],
"pricing": [
{ "date": "9月1日-9月9日", "adult": 2050, "child": 1250 },
{ "date": "9月10日-9月13日", "adult": 2150, "child": 1250 },
{ "date": "9月14日-9月27日", "adult": 2299, "child": 1250 },
{ "date": "9月28日", "adult": 2499, "child": 1350 },
{ "date": "9月29日-10月4日", "adult": 2750, "child": 1350 },
{ "date": "10月5日", "adult": 2599, "child": 1350 },
{ "date": "10月6日-10月7日", "adult": 2199, "child": 1350 },
{ "date": "10月8日-10月15日", "adult": 2050, "child": 1250 }
]
},
{
"id": "autumn-south-5d4n",
@ -76,19 +58,29 @@
"days": 5,
"nights": 4,
"audience": "想深度体验阿尔山秋色的家庭",
"description": "秋季南线经典版,5天4晚从容走遍呼伦贝尔草原与阿尔山秋色。扎罗木得断崖秋野素拍、童话之城阿尔山、阿尔山国家森林公园AAAAA级、ATV卡丁车森林穿越、白狼峰日落,核心秋景与深度体验兼顾。适合想深度体验阿尔山秋色的家庭。",
"description": "秋季南线经典版,5天4晚从容走遍呼伦贝尔草原与阿尔山秋色。扎罗木得断崖+秋野素拍、莫日格勒河牧云山顶下午茶、阿尔山国家森林公园AAAAA级、兴安天池、驼峰岭天池、白狼峰日落、ATV卡丁车森林穿越,核心秋景与深度体验兼顾。",
"highlights": [
"扎罗木得断崖+秋野素拍,定格秋日最美瞬间",
"扎罗木得断崖+秋野素拍,定格秋日动人瞬间",
"童话之城阿尔山+阿尔山国家森林公园AAAAA级",
"ATV卡丁车森林穿越+白狼峰日落"
],
"tag": "经典版",
"itinerary": [
"D1 各地→海拉尔(接机+自由闲逛)",
"D2 牧云山顶-莫日格勒河-扎罗木得断崖-秋野素拍-额尔古纳",
"D3 额尔古纳-黑山头骑马赏日落-ATV穿越湿地-柴河/阿尔山",
"D4 阿尔山国家森林公园-天池-驼峰岭天池-不冻河-白狼峰日落-阿尔山",
"D5 阿尔山-ATV卡丁车森林穿越-伊敏河湿地-海拉尔-返程"
"D1 各地→海拉尔(接机+自由活动)",
"D2 海拉尔-扎罗木得断崖-秋野素拍-牧云山顶-莫日格勒河-海拉尔200公里",
"D3 海拉尔-森林草原过渡带-兴安天池-三潭峡-不冻河-阿尔山国家森林公园340公里",
"D4 乌苏浪子湖-驼峰岭天池-石塘林-杜鹃湖-白狼峰日落-阿尔山100公里",
"D5 阿尔山-日式车站-ATV卡丁车穿越-伊敏河湿地-海拉尔散团300公里"
],
"pricing": [
{ "date": "9月1日-9月8日", "adult": 2699, "child": 1799 },
{ "date": "9月9日-9月12日", "adult": 2799, "child": 1799 },
{ "date": "9月13日-9月26日", "adult": 2999, "child": 1899 },
{ "date": "9月27日", "adult": 3299, "child": 2099 },
{ "date": "9月28日-10月3日", "adult": 3599, "child": 2199 },
{ "date": "10月4日", "adult": 3499, "child": 2099 },
{ "date": "10月5日-10月6日", "adult": 3199, "child": 1999 },
{ "date": "10月7日-10月15日", "adult": 2799, "child": 1799 }
]
},
{
@ -99,21 +91,36 @@
"days": 7,
"nights": 6,
"audience": "假期充足想深度探索秋色的家庭",
"description": "秋季南线深度版,7天6晚从容走遍呼伦贝尔草原与阿尔山秋色全部精华。在经典版基础上增加白桦林喂驯鹿、恩和骑马做列巴、柴河月亮天池等深度体验,节奏更从容舒适。适合假期充足想深度探索秋色的家庭。",
"description": "秋季南线深度版,7天6晚从容走遍呼伦贝尔草原、白桦林驯鹿、黑山头骑马与阿尔山秋色全部精华。相比5天版节奏更从容,加入黑山头ATV、骑马看日落、白桦林喂驯鹿、兴安天池+驼峰岭等深度体验。",
"highlights": [
"全部南线精华+更从容节奏",
"白桦林喂驯鹿+恩和骑马+做列巴",
"柴河月亮天池+同心湖,火山天池深度体验"
"黑山头草原ATV+骑马赏日落",
"白桦林喂驯鹿+牧云山顶下午茶",
"阿尔山国家森林公园+兴安天池+驼峰岭+白狼日落"
],
"tag": "深度版",
"itinerary": [
"D1 各地→海拉尔",
"D2 牧云山顶-莫日格勒河-扎罗木得断崖-秋野素拍-额尔古纳",
"D3 额尔古纳-白桦林喂驯鹿-恩和骑马-画套娃做列巴-恩和",
"D4 恩和-黑山头骑马赏日落-ATV穿越五彩湿地-柴河",
"D5 柴河月亮天池-同心湖-柴河",
"D6 阿尔山国家森林公园-天池-驼峰岭天池-不冻河-白狼峰日落-阿尔山",
"D7 阿尔山-ATV卡丁车森林穿越-伊敏河湿地-海拉尔-返程"
"D1 各地→海拉尔(接机+自由活动)",
"D2 海拉尔-黑山头草原-ATV卡丁车-小绵羊牧场-骑马看日落-黑山头/额市180公里",
"D3 黑山头/额市-白桦林景区-喂驯鹿-莫日格勒河-牧云山顶下午茶-海拉尔240公里",
"D4 海拉尔-扎罗木得断崖-秋野素拍-森林草原过渡带-阿尔山400公里",
"D5 阿尔山-兴安天池-石塘林-三潭峡-不冻河-阿尔山国家森林公园80公里",
"D6 乌苏浪子湖-驼峰岭天池-大峡谷-杜鹃湖-白狼峰日落-阿尔山100公里",
"D7 阿尔山-日式火车站-伊敏河湿地-海拉尔-散团300公里"
],
"pricing": [
{ "date": "9月1日-9月14日", "adult": 3699, "child": 2499 },
{ "date": "9月15日-9月24日", "adult": 4099, "child": 2799 },
{ "date": "9月25日", "adult": 4299, "child": 2799 },
{ "date": "9月26日", "adult": 4599, "child": 2899 },
{ "date": "9月27日", "adult": 4799, "child": 2999 },
{ "date": "9月28日-9月29日", "adult": 4899, "child": 2999 },
{ "date": "9月30日-10月1日", "adult": 5199, "child": 2999 },
{ "date": "10月2日", "adult": 4899, "child": 2999 },
{ "date": "10月3日", "adult": 4799, "child": 2999 },
{ "date": "10月4日", "adult": 4699, "child": 2999 },
{ "date": "10月5日", "adult": 4599, "child": 2999 },
{ "date": "10月6日", "adult": 4299, "child": 2999 },
{ "date": "10月7日-10月15日", "adult": 3999, "child": 2799 }
]
},
{
@ -124,14 +131,34 @@
"days": 7,
"nights": 6,
"audience": "想同时看边境风情和阿尔山秋色的家庭",
"description": "满洲里与南线的完美结合,7天6晚同时体验中俄蒙边境风情与阿尔山金秋。满洲里套娃广场+边境风情+阿尔山秋色一网打尽,适合想一次旅行看遍呼伦贝尔秋季精华的家庭。",
"description": "满洲里与南线深度结合,7天6晚同时体验中俄蒙边境风情与阿尔山金秋。国门景区+满洲里套娃酒店+扎罗木得断崖+阿尔山国家森林公园一次看全,适合想一次旅行看遍呼伦贝尔秋季精华的家庭。",
"highlights": [
"满洲里套娃广场+边境异域风情",
"阿尔山国家森林公园+火山天池秋色",
"扎罗木得断崖+秋野素拍+骑马赏日落"
"满洲里国门景区+套娃酒店,边境异域风情",
"扎罗木得断崖+秋野素拍+牧云山顶",
"阿尔山国家森林公园+白狼峰日落"
],
"tag": "满洲里+南线",
"itinerary": []
"itinerary": [
"D1 各地→海拉尔(接机+自由活动)",
"D2 海拉尔-黑山头草原-ATV卡丁车-访牧户-骑马看日落-黑山头/额市180公里",
"D3 黑山头/额市-白桦林景区-喂驯鹿-莫日格勒河-牧云山顶下午茶-海拉尔240公里",
"D4 海拉尔-扎罗木得断崖-秋野素拍-国门景区-入住套娃酒店320公里",
"D5 满洲里-兴安天池-不冻河-阿尔山国家森林公园450公里,约6-7小时",
"D6 乌苏浪子湖-蓝房子-驼峰岭天池-大峡谷-杜鹃湖-白狼峰日落-阿尔山100公里",
"D7 阿尔山-日式火车站-伊敏河湿地-海拉尔-散团300公里"
],
"pricing": [
{ "date": "9月1日-9月14日", "adult": 3799, "child": 2499 },
{ "date": "9月15日-9月25日", "adult": 3999, "child": 2799 },
{ "date": "9月26日", "adult": 4699, "child": 2899 },
{ "date": "9月27日", "adult": 4799, "child": 2999 },
{ "date": "9月28日-9月29日", "adult": 4899, "child": 2999 },
{ "date": "9月30日-10月1日", "adult": 4999, "child": 2999 },
{ "date": "10月2日-10月3日", "adult": 4799, "child": 2999 },
{ "date": "10月4日-10月5日", "adult": 4599, "child": 2999 },
{ "date": "10月6日", "adult": 4199, "child": 2999 },
{ "date": "10月7日-10月15日", "adult": 3999, "child": 2799 }
]
},
{
"id": "autumn-north-5d4n",
@ -141,19 +168,32 @@
"days": 5,
"nights": 4,
"audience": "想深入大兴安岭腹地赏秋的旅行者",
"description": "秋季北线经典版,5天4晚深入大兴安岭腹地赏秋。大兴安岭绝美公路根白路、龙岩山公园三体雷达峰、大兴安岭深处驯鹿园、太平川古村落,感受大兴安岭最原始的秋色。适合想深入大兴安岭腹地赏秋的旅行者。",
"description": "秋季北线经典版,5天4晚深入大兴安岭腹地赏秋。扎罗木得断崖秋野素拍、白桦林喂驯鹿、森林小火车、龙岩山公园三体雷达峰、太平川古村落、乌兰山滑草、骑马看日落、牧云山顶下午茶,感受大兴安岭原始秋色。",
"highlights": [
"大兴安岭绝美公路根白路,人在车中坐车在画中游",
"龙岩山公园三体雷达峰,俯瞰林区小镇全貌",
"大兴安岭深处驯鹿园+太平川古村落"
"白桦林喂驯鹿+森林小火车+太平川古村落",
"乌兰山滑草+黑山头骑马看日落"
],
"tag": "北线经典",
"itinerary": [
"D1 各地→海拉尔",
"D2 牧云山顶-莫日格勒河-扎罗木得断崖-秋野素拍-额尔古纳",
"D3 额尔古纳-白桦林喂驯鹿-恩和骑马-恩和",
"D4 恩和-室韦-太平川古村落-莫尔道嘎-龙岩山公园三体雷达峰-莫尔道嘎",
"D5 莫尔道嘎-根白路-敖鲁古雅驯鹿园-海拉尔-返程"
"D1 各地→海拉尔(接机+自由活动)",
"D2 海拉尔-扎罗木得断崖-秋野素拍-黑山头草原-ATV卡丁车-额尔古纳260公里",
"D3 额市-白桦林景区-兴安神鹿园-喂驯鹿-森林小火车-龙岩山公园-莫尔道嘎镇200公里",
"D4 莫镇-太平川-老鹰嘴-室韦-边防哨卡线-乌兰山滑草-骑马看日落-黑山头260公里",
"D5 黑山头-小绵羊牧场-莫日格勒河-牧云山顶上午茶-海拉尔-散团160公里"
],
"pricing": [
{ "date": "9月1日-9月15日", "adult": 2599, "child": 1799 },
{ "date": "9月16日-9月20日", "adult": 2699, "child": 1899 },
{ "date": "9月21日-9月24日", "adult": 2799, "child": 1999 },
{ "date": "9月25日-9月26日", "adult": 2999, "child": 1999 },
{ "date": "9月27日", "adult": 3299, "child": 2099 },
{ "date": "9月28日-9月29日", "adult": 3499, "child": 2199 },
{ "date": "9月30日-10月3日", "adult": 3599, "child": 2299 },
{ "date": "10月4日", "adult": 3399, "child": 2099 },
{ "date": "10月5日", "adult": 3299, "child": 2099 },
{ "date": "10月6日", "adult": 2899, "child": 1899 },
{ "date": "10月7日-10月15日", "adult": 2799, "child": 1899 }
]
},
{
@ -164,94 +204,109 @@
"days": 6,
"nights": 5,
"audience": "假期充足想更深度走北线的家庭",
"description": "秋季北线深度版,6天5晚更从容地深入大兴安岭。在北线5天基础上增加更多深度体验,节奏更舒适,每一处秋色都能细细品味。适合假期充足想更深度走北线的家庭。",
"description": "秋季北线深度版,6天5晚更从容地深入大兴安岭。在北线5天基础上增加恩和俄罗斯民族乡、根白路绝美公路、兴安神鹿园等深度体验,节奏更舒适,每一处秋色都能细细品味。",
"highlights": [
"在北线5天基础上更从容,节奏更舒适",
"增加更多深度体验,细品大兴安岭秋色",
"根白路+龙岩山+驯鹿园+太平川古村落"
"大兴安岭绝美公路根白路,人在车中坐车在画中游",
"恩和俄罗斯民族乡+白桦林喂驯鹿+兴安神鹿园",
"乌兰山滑草+骑马穿越+龙岩山公园"
],
"tag": "北线深度",
"itinerary": []
"itinerary": [
"D1 各地→海拉尔(接机+自由活动)",
"D2 海拉尔-扎罗木得断崖-秋野素拍-黑山头草原-ATV卡丁车-黑山头/额市260公里",
"D3 黑山头/额市-小绵羊-边防哨卡线-乌兰山滑草-骑马穿越-恩和俄罗斯民族乡180公里",
"D4 恩和-室韦-老鹰嘴-太平川-森林小火车-龙岩山-莫尔道嘎160公里",
"D5 莫尔道嘎-根白路-兴安神鹿园-喂驯鹿-白桦林景区-额尔古纳200公里",
"D6 额尔古纳-根河湿地-莫日格勒河-牧云山顶上/下午茶-海拉尔-散团130公里"
],
"pricing": [
{ "date": "9月1日-9月9日", "adult": 3199, "child": 2299 },
{ "date": "9月10日-9月19日", "adult": 3299, "child": 2299 },
{ "date": "9月20日-9月23日", "adult": 3499, "child": 2399 },
{ "date": "9月24日-9月25日", "adult": 3599, "child": 2499 },
{ "date": "9月26日-9月27日", "adult": 3999, "child": 2599 },
{ "date": "9月28日-9月30日", "adult": 4199, "child": 2699 },
{ "date": "10月1日-10月3日", "adult": 4299, "child": 2699 },
{ "date": "10月4日", "adult": 4099, "child": 2599 },
{ "date": "10月5日", "adult": 3699, "child": 2499 },
{ "date": "10月6日", "adult": 3399, "child": 2399 },
{ "date": "10月7日-10月15日", "adult": 3299, "child": 2299 }
]
},
{
"id": "autumn-south-north-8d7n",
"name": "游牧的森林V3·南+北线8天7晚",
"line": "南+北线",
"route": "呼伦贝尔+大兴安岭+阿尔山",
"days": 8,
"nights": 7,
"audience": "假期极充足想一次看遍南北秋色精华的家庭",
"description": "秋季南+北线全景版,8天7晚一次走遍呼伦贝尔、大兴安岭、阿尔山三大秋色核心。北线的白桦林、驯鹿、森林小火车、太平川古村落、骑马看日落,加上南线的阿尔山国家森林公园、兴安天池、驼峰岭、白狼峰日落,秋季大兴安岭所有精华一网打尽。",
"highlights": [
"南北线全精华,一次走遍呼伦贝尔+大兴安岭+阿尔山",
"白桦林喂驯鹿+森林小火车+太平川+龙岩山",
"阿尔山国家森林公园+兴安天池+驼峰岭+白狼峰日落"
],
"tag": "南+北全景版",
"itinerary": [
"D1 各地→海拉尔(接机+自由活动)",
"D2 海拉尔-扎罗木得断崖-秋野素拍-黑山头草原-ATV卡丁车-额尔古纳260公里",
"D3 额市-白桦林景区-兴安神鹿园-喂驯鹿-森林小火车-龙岩山公园-莫尔道嘎镇200公里",
"D4 莫镇-太平川-老鹰嘴-室韦-边防哨卡线-乌兰山滑草-骑马看日落-黑山头260公里",
"D5 黑山头-访牧户-莫日格勒河-牧云山顶上午茶-海拉尔160公里",
"D6 海拉尔-森林草原过渡带-兴安天池-三潭峡-不冻河-阿尔山国家森林公园340公里",
"D7 乌苏浪子湖-驼峰岭天池-石塘林-杜鹃湖-白狼峰日落-阿尔山100公里",
"D8 阿尔山-日式车站-伊敏河湿地-海拉尔散团300公里"
],
"pricing": [
{ "date": "9月1日-9月9日", "adult": 4799, "child": 3050 },
{ "date": "9月10日-9月20日", "adult": 4999, "child": 3150 },
{ "date": "9月21日-9月24日", "adult": 5299, "child": 3250 },
{ "date": "9月25日-9月26日", "adult": 5799, "child": 3250 },
{ "date": "9月27日-9月30日", "adult": 6399, "child": 3550 },
{ "date": "10月1日", "adult": 6199, "child": 3550 },
{ "date": "10月2日-10月3日", "adult": 5799, "child": 3550 },
{ "date": "10月4日-10月5日", "adult": 5499, "child": 3450 },
{ "date": "10月6日", "adult": 4999, "child": 3250 },
{ "date": "10月7日-10月15日", "adult": 4899, "child": 3150 }
]
}
],
"southHighlights": [
{
"title": "ATV卡丁车森林穿越",
"description": "在阿尔山安排一场秋日里的ATV穿越之旅,约50分钟,充分感受秋日森林的旖旎风光。"
},
{
"title": "人间仙境白狼日落",
"description": "白狼峰海拔1500米,藏在林海深处的小众宝藏地,每逢秋季变成一片黄色的海洋,蔚为壮观。"
},
{
"title": "打卡中国最小火车站",
"description": "始建于1937年东洋风格的日式建筑,中国最美的火车站,秋天的列车缓缓驶向远方。"
}
{ "title": "ATV卡丁车森林穿越", "description": "在阿尔山安排一场秋日里的ATV穿越之旅,约50分钟,充分感受秋日森林的旖旎风光。" },
{ "title": "人间仙境白狼日落", "description": "白狼峰海拔1500米,藏在林海深处的小众宝藏地,每逢秋季变成一片黄色的海洋,蔚为壮观。" },
{ "title": "打卡中国最小火车站", "description": "始建于1937年东洋风格的日式建筑,景致独特的小众火车站,秋天的列车缓缓驶向远方。" }
],
"northHighlights": [
{
"title": "大兴安岭北部林区绝美公路根白路",
"description": "乘车穿越在金黄色的海洋中,人在车中坐,车在画中游。"
},
{
"title": "龙岩山公园三体雷达峰",
"description": "在大兴安岭深处的莫尔道嘎小镇,登高望远俯瞰整个林区小镇,酷似三体书中的信号接收站。"
},
{
"title": "大兴安岭深处驯鹿园",
"description": "敖鲁古雅使鹿鄂温克,中国最后的狩猎部落,零距离喂苔藓。"
},
{
"title": "大兴安岭中的古村落太平川",
"description": "散发着原始氛围的小村庄,位于大兴安岭深山腹地之中,宛如世外桃源,有百年历史的老屋。"
}
{ "title": "大兴安岭北部林区绝美公路根白路", "description": "乘车穿越在金黄色的海洋中,人在车中坐,车在画中游。" },
{ "title": "龙岩山公园三体雷达峰", "description": "在大兴安岭深处的莫尔道嘎小镇,登高望远俯瞰整个林区小镇,酷似三体书中的信号接收站。" },
{ "title": "大兴安岭深处驯鹿园", "description": "敖鲁古雅使鹿鄂温克,保留游猎文化的驯鹿部落,零距离喂苔藓。" },
{ "title": "大兴安岭中的古村落太平川", "description": "散发着原始氛围的小村庄,位于大兴安岭深山腹地之中,宛如世外桃源,有百年历史的老屋。" }
],
"selectionGuide": {
"byVacationLength": [
{
"condition": "4天假期",
"recommendation": "autumn-south-4d3n",
"reason": "精华浓缩"
},
{
"condition": "5天假期想看阿尔山",
"recommendation": "autumn-south-5d4n",
"reason": "南线经典"
},
{
"condition": "5天假期想看大兴安岭",
"recommendation": "autumn-north-5d4n",
"reason": "北线经典"
},
{
"condition": "6天假期深入大兴安岭",
"recommendation": "autumn-north-6d5n",
"reason": "北线深度"
},
{
"condition": "7天假期看阿尔山",
"recommendation": "autumn-south-7d6n",
"reason": "南线深度"
},
{
"condition": "7天想看边境+阿尔山",
"recommendation": "autumn-manzhouli-south-7d6n",
"reason": "满洲里+南线"
}
{ "condition": "4天假期", "recommendation": "autumn-south-4d3n", "reason": "阿尔山精华浓缩" },
{ "condition": "5天假期想看阿尔山", "recommendation": "autumn-south-5d4n", "reason": "南线经典" },
{ "condition": "5天假期想看大兴安岭", "recommendation": "autumn-north-5d4n", "reason": "北线经典" },
{ "condition": "6天假期深入大兴安岭", "recommendation": "autumn-north-6d5n", "reason": "北线深度" },
{ "condition": "7天假期看阿尔山", "recommendation": "autumn-south-7d6n", "reason": "南线深度" },
{ "condition": "7天想看边境+阿尔山", "recommendation": "autumn-manzhouli-south-7d6n", "reason": "满洲里+南线" },
{ "condition": "8天想看遍南北精华", "recommendation": "autumn-south-north-8d7n", "reason": "南+北线全景版" }
]
},
"timeline": [
{
"version": "V3",
"date": "2025.09",
"title": "新增短途版+满洲里版,北线扩展6天版,全线升级素拍",
"title": "新增短途版/满洲里版/南+北全景版,北线扩展6天版,全线升级素拍",
"changes": [
{ "type": "add", "text": "南线新增4天3晚短途版,假期极短也能体验秋色精华" },
{ "type": "add", "text": "南线新增4天3晚短途版阿尔山精华浓缩,假期极短也能体验秋色精华" },
{ "type": "add", "text": "新增满洲里+南线7天6晚版,边境风情与阿尔山秋色一网打尽" },
{ "type": "add", "text": "北线增加6天5晚深度版,更从容地深入大兴安岭" },
{ "type": "add", "text": "新增南+北线8天7晚全景版,一次走遍呼伦贝尔+大兴安岭+阿尔山三大秋色核心" },
{ "type": "update", "text": "全线升级素拍服务,专业户外人像摄影师定格秋日美好" }
],
"reason": "V2版本产品线较少,无法满足不同假期长度的客户需求。V3新增短途版和满洲里版扩大覆盖面,北线增加深度版,同时全线升级素拍服务提升体验。"
"reason": "V2版本产品线较少,无法满足不同假期长度的客户需求。V3扩充短途版/满洲里版/南+北全景版,北线增加深度版,同时全线升级素拍服务提升体验。"
},
{
"version": "V2南线",
@ -262,7 +317,7 @@
{ "type": "add", "text": "增加扎罗木得断崖观赏点,24年新发掘的小众秘境" },
{ "type": "add", "text": "增加备品三件套,提升旅途舒适度" }
],
"reason": "牙克石凤凰山庄景区体验一般,替换为24年新发掘的扎罗木得断崖,兼顾草原、湿地和白桦林多种风景,成为秋季亮丽的名片。"
"reason": "牙克石凤凰山庄景区体验一般,替换为24年新发掘的扎罗木得断崖,兼顾草原、湿地和白桦林多种风景,成为秋季亮丽的名片之一。"
},
{
"version": "V1",
@ -273,7 +328,7 @@
{ "type": "add", "text": "白桦林喂驯鹿,与森林中的驯鹿精灵合拍" },
{ "type": "add", "text": "阿尔山国家森林公园,大兴安岭腹地AAAAA级景区" },
{ "type": "add", "text": "白狼峰日落,藏在林海深处的小众宝藏地" },
{ "type": "add", "text": "ATV穿越湿地,穿越亚洲最大的原生草本湿地" }
{ "type": "add", "text": "全地形越野车穿越湿地,穿越广阔的原生草本湿地" }
],
"reason": "呼籁深耕呼伦贝尔多年后首次推出秋季产品线。南线串联草原秋色与阿尔山火山天池,让大兴安岭的金秋不再只是一个遥远的梦想。"
}

文件差异因一行或多行过长而隐藏

查看文件

@ -40,8 +40,8 @@
"description": "呼籁自己的客运公司、自己的车、自己的师傅。不是接到订单再找第三方——你的车从头到尾只拉你一家"
},
{
"title": "独家体验,别的团去不了",
"description": "全地形越野车湿地穿越、寻龙诀秘境、牧云山顶下午茶——这些是呼籁签约的独家资源,不在公开景区"
"title": "特色体验,别的团去不了",
"description": "全地形越野车湿地穿越、寻龙诀秘境、牧云山顶下午茶——这些是呼籁签约的专属资源,不在公开景区"
},
{
"title": "自营旅拍,200+套服饰",
@ -69,6 +69,13 @@
{"step": 3, "label": "看价格", "to": "/pricing"},
{"step": 4, "label": "在线定制", "to": "/customize"}
],
"serviceEntity": "内蒙古呼籁国际旅行社有限公司",
"floatingCTA": {
"enabled": true,
"text": "咨询客服",
"to": "/contact",
"hideOnPaths": ["/contact"]
},
"icpEntity": "内蒙古呼籁文化科技有限公司(提供技术支持)",
"icp": "蒙ICP备2025033324号-2",
"icpUrl": "https://beian.miit.gov.cn",

查看文件

@ -1,28 +1,28 @@
{
"meta": {
"pageTitle": "呼伦贝尔最佳出行月份",
"pageTitle": "呼伦贝尔出行月份参考",
"pageIntro": "基于呼籁旅行11年带队数据,为您提供每月出行参考指南",
"bestTime": "6月9月",
"quickRecommendations": [
{
"label": "性价比最高",
"label": "性价比不错",
"months": "6月中下旬",
"reason": "草原已非常美,各项活动已开展,价格尚未进入旺季"
},
{
"label": "景色巅峰",
"label": "景色旺季",
"months": "7—8月",
"reason": "景色和活动都很棒,但7月初至8月28日价格较贵"
},
{
"label": "秋景最佳",
"label": "秋景推荐期",
"months": "9月20日—国庆",
"reason": "大兴安岭秋景的最佳时机,非常推荐"
"reason": "观赏大兴安岭秋景的推荐时机"
},
{
"label": "冬季体验",
"months": "12—2月",
"reason": "完全另一种美,冰雪那达慕、驯鹿部落"
"reason": "另一种风貌,冰雪那达慕、驯鹿部落"
}
],
"travelAdvice": [
@ -41,14 +41,14 @@
{
"period": "6月中旬—6月底",
"level": "best_value",
"levelText": "性价比最高",
"description": "草原已经非常美了,各项活动也都已开展。价格尚未进入旺季,是性价比最高的时段。"
"levelText": "性价比不错",
"description": "草原已经非常美了,各项活动也都已开展。价格尚未进入旺季,性价比较好。"
},
{
"period": "6月底—8月底",
"level": "peak",
"levelText": "景色巅峰",
"description": "景色和各项活动都很棒,完全不用担心。但从7月初至8月20日价格会相对贵一些。"
"description": "景色和各项活动都很棒,相对来说比较放心。但从7月初至8月20日价格会相对贵一些。"
},
{
"period": "9月初",
@ -60,19 +60,19 @@
"period": "9月20日—国庆结束",
"level": "highly_recommended",
"levelText": "非常推荐",
"description": "看呼伦贝尔大兴安岭秋景的最佳时机,错过再等一年。"
"description": "看呼伦贝尔大兴安岭秋景的推荐时机,错过再等一年。"
},
{
"period": "冬季12月—次年2月",
"level": "recommended",
"levelText": "推荐",
"description": "冬季旅游景色也还不错,是完全另一种美。冰雪那达慕、驯鹿部落值得体验。"
"description": "冬季旅游景色也还不错,是另一种风貌。冰雪那达慕、驯鹿部落值得体验。"
},
{
"period": "3月—4月 / 5月中上旬",
"level": "not_recommended",
"levelText": "不推荐",
"description": "来呼伦贝尔一趟还是比较折腾的,希望大家能在每个季节相对最美的时候过来。"
"description": "来呼伦贝尔一趟还是比较折腾的,希望大家能在每个季节景色较好的时候过来。"
}
]
},
@ -83,7 +83,7 @@
"season": "winter",
"ratingStars": 2,
"tempRange": "-30°C ~ -15°C",
"weatherFeature": "极寒晴朗,万里冰封,呼伦贝尔寒冷的月份",
"weatherFeature": "极寒晴朗,万里冰封,呼伦贝尔寒冷的月份",
"highlights": ["呼伦湖冰雪那达慕", "冰钓体验", "雪地徒步穿越", "零下30度的极寒星空"],
"clothingTips": "鹅绒羽绒服(-30°C标准+ 羊毛打底 + 防风冲锋裤 + 保暖雪地靴",
"isPeakSeason": false,
@ -148,12 +148,12 @@
"season": "summer",
"ratingStars": 5,
"tempRange": "15°C ~ 28°C",
"weatherFeature": "中旬起草原已非常美,各项活动全面开展,性价比全年最高的时段",
"highlights": ["6月中下旬性价比最高", "草原已非常美", "各项活动全面开展", "骑马体验舒适"],
"weatherFeature": "中旬起草原已非常美,各项活动全面开展,全年性价比较好的时段",
"highlights": ["6月中下旬性价比较好", "草原已非常美", "各项活动全面开展", "骑马体验较为舒适"],
"clothingTips": "薄外套 + 防晒衣 + 长裤,早晚加薄羽绒,备雨衣",
"isPeakSeason": true,
"recommendedProducts": ["额吉的故乡V9", "小蒙马亲子游学营"],
"tagline": "性价比最高,强烈推荐"
"tagline": "性价比不错,值得推荐"
},
{
"month": 7,
@ -161,8 +161,8 @@
"season": "summer",
"ratingStars": 5,
"tempRange": "18°C ~ 30°C",
"weatherFeature": "全年最热,草原旺季巅峰,那达慕节庆最集中,需提前3个月预订",
"highlights": ["那达慕传统节庆", "草原鲜花盛放至极", "额尔古纳湿地最美", "民俗体验最丰富"],
"weatherFeature": "全年较热,草原旺季高峰,那达慕节庆集中,需提前3个月预订",
"highlights": ["那达慕传统节庆", "草原鲜花盛放", "额尔古纳湿地景致动人", "民俗体验丰富"],
"clothingTips": "轻薄长袖 + 防晒 + 备风衣,中午注意防暑,水分补充充足",
"isPeakSeason": true,
"recommendedProducts": ["额吉的故乡V9", "小蒙马亲子游学营"],
@ -175,7 +175,7 @@
"ratingStars": 5,
"tempRange": "15°C ~ 28°C",
"weatherFeature": "夏末渐凉,草原仍绿,早晚清爽宜人,雨后彩虹频现",
"highlights": ["草原仍绿意盎然", "早晚清凉舒适", "夕阳草原最美角度", "雨后彩虹奇景常见"],
"highlights": ["草原仍绿意盎然", "早晚清凉舒适", "夕阳草原拍照角度佳", "雨后彩虹奇景常见"],
"clothingTips": "薄外套 + 长裤,备一件薄羽绒,早晚温差8-10°C不可忽视",
"isPeakSeason": true,
"recommendedProducts": ["额吉的故乡V9", "小蒙马亲子游学营"],
@ -187,12 +187,12 @@
"season": "autumn",
"ratingStars": 5,
"tempRange": "5°C ~ 20°C",
"weatherFeature": "月初草原不再最美但人少;9月20日起大兴安岭秋景进入最佳观赏期,非常推荐",
"highlights": ["9月20日后秋景最佳", "大兴安岭金秋绝景", "国庆期间秋色正浓", "开学后人少清净"],
"weatherFeature": "月初草原色彩转淡但人少;9月20日起大兴安岭秋景进入推荐观赏期",
"highlights": ["9月20日后秋景推荐", "大兴安岭金秋景致", "国庆期间秋色正浓", "开学后人少清净"],
"clothingTips": "冲锋衣 + 毛衣,早晚加薄羽绒,温差显著需分层穿搭",
"isPeakSeason": true,
"recommendedProducts": ["游牧的森林(秋季)"],
"tagline": "9月20日起秋景巅峰,非常推荐"
"tagline": "9月20日起秋景进入旺季,推荐出行"
},
{
"month": 10,
@ -215,7 +215,7 @@
"tempRange": "-20°C ~ -5°C",
"weatherFeature": "初冬降雪,河流结冰,草原进入冰封准备期,游客稀少",
"highlights": ["初雪草原独特美景", "河流开始结冰", "人迹罕至的静谧旷野"],
"clothingTips": "厚羽绒全套 + 防风裤 + 保暖靴,防寒为第一优先",
"clothingTips": "厚羽绒全套 + 防风裤 + 保暖靴,以防寒为首要",
"isPeakSeason": false,
"recommendedProducts": [],
"tagline": "初冬雪景,静谧独特"
@ -227,7 +227,7 @@
"ratingStars": 3,
"tempRange": "-30°C ~ -15°C",
"weatherFeature": "冰雪全封,冬景壮美,冰雪活动赛季正式开启",
"highlights": ["呼伦湖全冰封", "冰雪那达慕开幕", "极光偶现北疆", "冰钓与雪地越野"],
"highlights": ["呼伦湖冰封", "冰雪那达慕开幕", "极光偶现北疆", "冰钓与雪地越野"],
"clothingTips": "零下30°C专业装备鹅绒 + 羊毛打底 + 防风冲锋套装",
"isPeakSeason": false,
"recommendedProducts": ["嗨冰雪", "小蒙马冬季营"],

查看文件

@ -1,6 +1,6 @@
{
"philosophy": {
"headline": "草原是最好的课堂,自然是最好的老师",
"headline": "草原是生动的课堂,自然是温和的老师",
"tagline": "让孩子在真实的世界里学习真实的事",
"description": "小蒙马研学不是景区观光配个讲解员,也不是把教室搬到草原上。我们相信孩子天生有好奇心和行动力,课程设计的核心是给孩子提供真实的接触——真正的马、真正的牧民、真正的河流、真正的星空。学习在接触中自然发生。",
"values": [
@ -164,7 +164,7 @@
"草编入门:学习基础草编手法,编一个简单的小篮",
"高龄组:以「草原生态」为主题完成一件综合材料作品并展示"
],
"philosophy": "最好的画布不在美术教室里,在莫日格勒河边的泥地上。孩子不需要颜料,他们需要允许弄脏手的机会。"
"philosophy": "理想的画布不在美术教室里,而在莫日格勒河边的泥地上。孩子不需要颜料,他们需要允许弄脏手的机会。"
},
{
"id": "teamwork",
@ -224,7 +224,7 @@
},
{
"q": "研学课程和普通旅游有什么区别?",
"a": "最大的区别是有明确的学习目标和课程设计,而不只是带孩子去看风景。每个活动背后都有对应的教育目的,有观察记录、有任务完成、有成果展示。同时我们会放慢节奏,给孩子足够的时间真正接触和探索,而不是走马观花。"
"a": "主要区别是有明确的学习目标和课程设计,而不只是带孩子去看风景。每个活动背后都有对应的教育目的,有观察记录、有任务完成、有成果展示。同时我们会放慢节奏,给孩子足够的时间真正接触和探索,而不是走马观花。"
},
{
"q": "孩子如果对某个活动不感兴趣或不想参与怎么办?",

0
data/db.sqlite 普通文件
查看文件

查看文件

@ -6,9 +6,9 @@
"subtitle": "天下第一曲水 · 老舍笔下的草原河曲",
"tag": "草原核心",
"seasons": ["summer", "autumn", "winter"],
"description": "1961年老舍来这儿考察,脱口而出「天下第一曲水」,这名号就此传开。河本身不宽,有些河段窄到你觉得一步就能跨过去,但它在三百公里的草原上拐出了上千个弯。从牧云山顶或断崖制高点往下看,河道的S弯一层套一层,两岸吃草的牛羊像散落的棋子,那种辽阔感是照片拍不出来的。呼籁旅行的自营营地就扎在莫日格勒河畔,早起出帐篷两分钟走到河边,晨雾配合日出光线,出片率极高。草原深处没手机信号,跟着我们的车队走才不会迷路。",
"description": "1961年老舍来这儿考察,脱口而出「天下第一曲水」,这名号就此传开。河本身不宽,有些河段窄到你觉得一步就能跨过去,但它在三百公里的草原上拐出了上千个弯。从牧云山顶或断崖制高点往下看,河道的S弯一层套一层,两岸吃草的牛羊像散落的棋子,那种辽阔感是照片拍不出来的。",
"highlights": [
"牧云山顶/断崖俯瞰河曲全景——目前公认最佳机位,比金帐汗那边角度好",
"牧云山顶/断崖俯瞰河曲全景——广受推崇的拍摄机位",
"沿河岸骑马漫步,牛羊就在身边,比黑山头骑马更有草原腹地的感觉",
"呼籁自营营地入住,河边BBQ、旅拍、看星空一条龙",
"清晨河面起雾时拍大片,日出前后1小时是黄金时间",
@ -17,12 +17,12 @@
"bestSeason": {
"primary": "7月-8月",
"secondary": "6月、9月",
"description": "7-8月草最绿水最丰,骑马体验最佳。6月人少但草还没完全长起来,9月开始变黄也别有味道。冬季雪原覆盖的莫日格勒河又是另一种震撼。"
"description": "7-8月草原翠绿、水量充沛,适合骑马体验。6月人少但草还没完全长起来,9月开始变黄也别有味道。冬季雪原覆盖的莫日格勒河又是另一种震撼。"
},
"tips": [
"景区不能自驾随意进入,有摆渡费;住营地的可以自驾进",
"草原深处没手机信号,千万不要自己开车乱跑碾压草原,罚款很重",
"拍全景最佳时间早晨6-8点,正午光线太平拍不出层次",
"拍全景建议早晨6-8点,正午光线太平拍不出层次",
"穿长裤运动鞋,草地里有虫,别穿凉鞋和裙子",
"营地BBQ旺季名额有限,提前预约"
],
@ -51,52 +51,52 @@
"bestSeason": {
"primary": "9月中旬-10月初",
"secondary": "7月-8月",
"description": "秋季拍照最美,颜色层次极丰富。夏季生态最丰富,碧绿一片也很震撼。"
"description": "秋季色彩层次丰富,适合拍照。夏季生态茂盛,碧绿一片也很震撼。"
},
"tips": [
"爬山到观景台大概30-40分钟,有台阶但不算陡,老人孩子能走",
"夏季蚊虫多,驱蚊液必备",
"建议安排1.5-2小时游览",
"早晨有晨雾,建议6点前到达等最美光线"
"早晨有晨雾,建议6点前到达等柔和光线"
],
"heroImage": "/images/destinations/eergunashire.jpg",
"relatedProducts": ["v9-6d5n-family", "v9-7d6n-wild"],
"seo": {
"title": "额尔古纳湿地旅游攻略 - 亚洲第一湿地怎么玩 | 呼籁旅行",
"description": "额尔古纳湿地被誉为亚洲第一湿地,登顶看Ω形河弯。呼籁旅行带你错峰体验最美湿地生态景观。",
"description": "额尔古纳湿地被誉为亚洲第一湿地,登顶看Ω形河弯。呼籁旅行带你错峰体验湿地生态景观。",
"keywords": "额尔古纳湿地,亚洲第一湿地,根河湿地,呼伦贝尔秋景"
}
},
{
"id": "baihualin",
"name": "白桦林",
"subtitle": "国内面积最大的纯生白桦林 · 亲子拍照圣地",
"subtitle": "大面积纯生白桦林 · 亲子拍照场景",
"tag": "森林体验",
"seasons": ["summer", "autumn"],
"description": "在额尔古纳到恩和的省道边上,将近1000公顷的纯生白桦林,国内面积最大的。树干白得发亮,阳光透过树冠洒下来确实好看。景区开发得比较成熟,分了好几个主题区,不算原生态体验,但胜在拍照出片率高,带孩子来也有地方玩。呼籁安排的行程里,白桦林有非遗传承人现场教桦树皮画,按年龄分层体验——这个才是这里最值得的环节。秋季9月底白桦叶变金黄,配白色树干,随手拍都是大片。",
"description": "在额尔古纳到恩和的省道边上,将近1000公顷的纯生白桦林。树干白得发亮,阳光透过树冠洒下来确实好看。景区开发得比较成熟,分了好几个主题区,不算原生态体验,但胜在拍照出片率高,带孩子来也有地方玩。呼籁安排的行程里,白桦林有非遗传承人现场教桦树皮画,按年龄分层体验——这个环节值得深度参与。秋季9月底白桦叶变金黄,配白色树干,随手拍都是大片。",
"highlights": [
"非遗传承人现场教桦树皮画,按孩子年龄分层体验",
"空中栈道——架在树冠层的步道,从上往下看白桦林感觉不一样",
"驯鹿苑——景区养了驯鹿可以近距离喂食,孩子最爱的环节",
"驯鹿苑——景区养了驯鹿可以近距离喂食,孩子很喜欢的环节",
"秋季金黄白桦叶配白色树干,随手拍都是大片",
"全地形越野车穿越白桦林——往返12公里自由驰骋"
],
"bestSeason": {
"primary": "9月下旬-10月初",
"secondary": "7月-8月",
"description": "秋季色彩最美。夏季林中清凉,适合避暑和亲子体验。"
"description": "秋季色彩浓郁。夏季林中清凉,适合避暑和亲子体验。"
},
"tips": [
"拍照穿亮色衣服(红色、黄色),白桦林做背景很出效果",
"夏季林中蚊虫多,长袖长裤加驱蚊液",
"整个景区走一圈大概1.5-2小时",
"时间有限的话至少走空中栈道和驯鹿苑,这两个最值"
"时间有限的话建议走空中栈道和驯鹿苑,这两个较为推荐"
],
"heroImage": "/images/destinations/baihualin.jpg",
"relatedProducts": ["v9-5d4n-forest", "v9-6d5n-family", "v9-7d6n-family"],
"seo": {
"title": "白桦林景区攻略 - 非遗体验+亲子拍照 | 呼籁旅行",
"description": "白桦林景区是国内面积最大的纯生白桦林,呼籁旅行安排非遗传承人教桦树皮画、越野车穿越、驯鹿喂食等深度体验。",
"description": "白桦林景区为大面积纯生白桦林,呼籁旅行安排非遗传承人教桦树皮画、越野车穿越、驯鹿喂食等深度体验。",
"keywords": "白桦林,白桦林景区,呼伦贝尔白桦林,桦树皮画,亲子体验"
}
},
@ -117,7 +117,7 @@
"bestSeason": {
"primary": "7月-8月",
"secondary": "9月",
"description": "夏季绿意最浓,骑马体验最佳。9月秋色开始,边防公路两侧层林尽染。冬季恩和雪中骑马是独特体验。"
"description": "夏季绿意盎然,适合骑马体验。9月秋色开始,边防公路两侧层林尽染。冬季恩和雪中骑马是独特体验。"
},
"tips": [
"如果只能去一个,推荐恩和——更安静更有生活气息,商业味淡",
@ -136,25 +136,25 @@
{
"id": "heishantou",
"name": "黑山头",
"subtitle": "呼伦贝尔骑马首选地 · 边境日落",
"subtitle": "呼伦贝尔骑马热门地 · 边境日落",
"tag": "骑马体验",
"seasons": ["summer", "autumn"],
"description": "黑山头是呼伦贝尔公认的骑马最佳地点,原因很简单——这边草原特别开阔平坦,视野好,马场多且正规。不是景区里牵着走一小圈的那种,而是马倌带路你自己控马在草原上跑,大圈下来一个多小时。傍晚一定要去日落山,看夕阳把额尔古纳河和整片草原湿地染成金色。呼籁的探索版行程安排了恩和+黑山头两次骑马——白桦林间漫步和草原驰骋完全不同的体验。",
"description": "黑山头是呼伦贝尔广受推崇的骑马地点,原因很简单——这边草原特别开阔平坦,视野好,马场多且正规。不是景区里牵着走一小圈的那种,而是马倌带路你自己控马在草原上跑,大圈下来一个多小时。傍晚推荐去日落山,看夕阳把额尔古纳河和整片草原湿地染成金色。呼籁的探索版行程安排了恩和+黑山头两次骑马——白桦林间漫步和草原驰骋是截然不同的体验。",
"highlights": [
"草原骑马穿越——自己控马不是牵马,大圈1-1.5小时真实骑行",
"日落山观日落——看额尔古纳河湿地被夕阳染金,呼伦贝尔最美日落之一",
"日落山观日落——看额尔古纳河湿地被夕阳染金,呼伦贝尔很具代表性的日落场景",
"中俄边防公路——左边是额尔古纳河和俄罗斯,右边是无边草原,路本身就是景",
"ATV穿越湿地——秋季五彩湿地中驾驶全地形车穿行",
"全地形越野车穿越湿地——秋季五彩湿地中穿行",
"牧户家访——进蒙古包挤牛奶、喂小羊、喝奶茶"
],
"bestSeason": {
"primary": "7月-8月",
"secondary": "9月",
"description": "夏季草原最绿最适合骑马。9月草原变黄,日落更有层次。"
"description": "夏季草原翠绿,适合骑马。9月草原变黄,日落更有层次。"
},
"tips": [
"骑马穿长裤硬底鞋,别穿紧身裤和凉鞋,容易磨",
"小朋友必须马夫牵马,安全第一",
"小朋友必须马夫牵马,以安全为先",
"黑山头属边境管理区,必须带身份证",
"日落山建议下午5点后前往,留够等日落的时间"
],
@ -162,7 +162,7 @@
"relatedProducts": ["v9-6d5n-explore"],
"seo": {
"title": "黑山头旅游攻略 - 草原骑马与边境日落 | 呼籁旅行",
"description": "黑山头是呼伦贝尔骑马首选地,自己控马驰骋草原、日落山赏金色湿地。呼籁旅行安排正规马场深度骑行体验。",
"description": "黑山头是呼伦贝尔热门骑马地,自己控马驰骋草原、日落山赏金色湿地。呼籁旅行安排正规马场深度骑行体验。",
"keywords": "黑山头,草原骑马,黑山头日落,边境草原,呼伦贝尔骑马"
}
},
@ -172,7 +172,7 @@
"subtitle": "中俄蒙三国交汇的异域边城",
"tag": "边境文化",
"seasons": ["summer", "autumn", "winter"],
"description": "满洲里不像一个中国城市,更像是把俄罗斯小城搬了一半过来。街上到处是俄文招牌,餐厅菜单有俄文版,路上能碰到真正的俄罗斯人来采购。套娃广场那个巨型套娃建筑里面其实是个酒店,周围大大小小的套娃到了晚上全亮灯,效果很梦幻。国门景区能看到对面俄罗斯的后贝加尔斯克小镇。带孩子来最好——满洲里是少有的能让小孩主动问问题的地方:那个大套娃里面住着谁?对面河那边是哪个国家?",
"description": "满洲里不像一个中国城市,更像是把俄罗斯小城搬了一半过来。街上到处是俄文招牌,餐厅菜单有俄文版,路上能碰到真正的俄罗斯人来采购。套娃广场那个巨型套娃建筑里面其实是个酒店,周围大大小小的套娃到了晚上全亮灯,效果很梦幻。国门景区能看到对面俄罗斯的后贝加尔斯克小镇。",
"highlights": [
"套娃广场夜景——晚上亮灯后整个广场变成童话世界,白天别去",
"国门景区——第五代国门,一侧中国一侧俄罗斯",
@ -183,7 +183,7 @@
"bestSeason": {
"primary": "6月-8月",
"secondary": "12月-2月",
"description": "夏季气候最好,夜景最美。冬季雪景和冰灯也有特色,冬季北线行程必经。"
"description": "夏季气候宜人,夜景绚丽。冬季雪景和冰灯也有特色,冬季北线行程必经。"
},
"tips": [
"套娃广场白天看没什么感觉,一定要等到晚上灯亮了再去",
@ -207,23 +207,23 @@
"seasons": ["autumn", "winter"],
"description": "阿尔山不是一座山,是一座小城,蒙语意思是「热的圣水」,说的就是温泉。森林公园是AAAAA级加世界地质公园,面积10万公顷,里面有50多座火山锥、19个火山口、8个天池。一天根本逛不完,门票可以两天内重复进园。必须去的是驼峰岭天池——火山口积水形成的湖,蓝得不真实,需要爬山但不高。杜鹃湖也值得,月牙形湖面春天周围开满杜鹃花。冬季的阿尔山是嗨冰雪线路的核心——白狼峰日出、不冻河、百里雾凇画廊、泡温泉,都安排在这。",
"highlights": [
"驼峰岭天池——阿尔山天池群中最大最深的,蓝得不真实",
"白狼峰日出+泼水成冰——冬季必玩,零下三十度的视觉冲击",
"驼峰岭天池——阿尔山天池群中规模较大的一座,蓝得不真实",
"白狼峰日出+泼水成冰——冬季推荐项目,零下三十度的视觉冲击",
"不冻河——零下几十度依然流淌,岸边雾凇配河面蒸腾雾气",
"石塘林——火山喷发岩浆凝固的石林,像外星地表",
"ATV卡丁车森林穿越——在林间小道上驾驶全地形车"
"全地形越野车森林穿越——在林间小道上穿行"
],
"bestSeason": {
"primary": "9月中旬秋色",
"secondary": "12月-2月冬景、5-6月杜鹃花",
"description": "秋季金色林海最震撼。冬季白狼峰日出和不冻河是嗨冰雪核心体验。5-6月杜鹃花盛开漫山粉红。夏季7-8月避暑也不错。"
"description": "秋季金色林海景致开阔。冬季白狼峰日出和不冻河是嗨冰雪核心体验。5-6月杜鹃花盛开漫山粉红。夏季7-8月避暑也不错。"
},
"tips": [
"公园很大建议分两天逛,门票支持两日内重复进园",
"景点优先级:驼峰岭天池 > 大峡谷 > 杜鹃湖 > 天池 > 石塘林",
"石塘林步道有台阶,推婴儿车不方便",
"海拔较高早晚温差大,即使夏天也带件外套",
"冬季来务必全副武装,手机贴暖宝宝否则秒关机"
"冬季出行务必做好保暖,手机贴暖宝宝否则秒关机"
],
"heroImage": "/images/destinations/aershan.jpg",
"relatedProducts": ["winter-south-5d4n", "winter-south-6d5n", "autumn-south-4d3n", "autumn-south-5d4n", "autumn-south-7d6n"],
@ -236,25 +236,25 @@
{
"id": "daxinganling",
"name": "大兴安岭·莫尔道嘎",
"subtitle": "中国最后一片寒温带原始森林 · 森林小火车",
"subtitle": "寒温带明亮针叶原始森林 · 森林小火车",
"tag": "森林探秘",
"seasons": ["summer", "autumn"],
"description": "莫尔道嘎国家森林公园是中国最后一片寒温带明亮针叶原始森林,森林覆盖率94.97%。最大的卖点是森林观光小火车——三节车厢全程9.87公里,时速15公里,绕公园一圈大约一个半小时。小火车穿行在林海里确实有童话感,沿途设了几个站可以下车拍照。最后一站有野生小松鼠和可以喂的小鹿,孩子特别开心。呼籁夏季亲子版行程安排了森林小火车+兴安神鹿园喂驯鹿,从草原走进森林,一路风景完全不同。",
"description": "莫尔道嘎国家森林公园是珍贵的寒温带明亮针叶原始森林,森林覆盖率94.97%。主打卖点是森林观光小火车——三节车厢全程9.87公里,时速15公里,绕公园一圈大约一个半小时。小火车穿行在林海里确实有童话感,沿途设了几个站可以下车拍照。终点站有野生小松鼠和可以喂的小鹿,孩子特别开心。呼籁夏季亲子版行程安排了森林小火车+兴安神鹿园喂驯鹿,从草原走进森林,一路风景截然不同。",
"highlights": [
"森林小火车穿行原始森林——全程9.87公里,每站可下车游览拍照",
"兴安神鹿园零距离喂驯鹿——大兴安岭深处的亲子亮点",
"樟子松林观景台——从这里看出去就是无尽的「林海」",
"秋季层林尽染——9月底到10月初目光所及都是色彩",
"原始森林徒步——空气负氧离子含量极高,真正的森林浴"
"原始森林徒步——空气负氧离子含量丰富,沉浸式森林浴"
],
"bestSeason": {
"primary": "9月底-10月初",
"secondary": "7月-8月",
"description": "秋季色彩最震撼,但10月中旬后就开始落叶了别来太晚。夏季森林最绿最凉快。"
"description": "秋季色彩浓郁,但10月中旬后就开始落叶了别来太晚。夏季森林青翠凉爽。"
},
"tips": [
"小火车有开窗和封闭式两种车厢可选",
"至少在樟子松林站和最后一站下车,这两站最值",
"建议在樟子松林站和终点站下车,这两站较为推荐",
"森林里蚊虫多,驱蚊液必带",
"莫尔道嘎镇上住宿条件一般,心理准备要有"
],
@ -262,17 +262,17 @@
"relatedProducts": ["v9-5d4n-forest", "v9-7d6n-family"],
"seo": {
"title": "大兴安岭莫尔道嘎攻略 - 森林小火车+驯鹿 | 呼籁旅行",
"description": "莫尔道嘎国家森林公园是中国最后一片寒温带原始森林,森林小火车、喂驯鹿、秋季层林尽染。呼籁旅行带你从草原走进森林。",
"description": "莫尔道嘎国家森林公园是珍贵的寒温带原始森林,森林小火车、喂驯鹿、秋季层林尽染。呼籁旅行带你从草原走进森林。",
"keywords": "莫尔道嘎,大兴安岭,森林小火车,驯鹿,原始森林"
}
},
{
"id": "hulunhu",
"name": "呼伦湖",
"subtitle": "北方第一大湖 · 冬季穿越蓝冰",
"subtitle": "北方型淡水湖 · 冬季穿越蓝冰",
"tag": "湖泊景观",
"seasons": ["summer", "winter"],
"description": "呼伦湖是内蒙古大的湖泊,面积约2339平方公里。夏季碧波万顷,岸线蜿蜒800多公里,和草原连成一片,日落时分整个湖面被染成金色。但真正让人印象深刻的反而是冬季——呼伦湖结冰后形成壮观的蓝冰,嗨冰雪北线行程安排了穿越呼伦湖蓝冰,在冰面上行走的体验完全不一样。湖里产鲤鱼、鲫鱼、白鱼,现捕现做的全鱼宴是来这儿的必吃项目。",
"description": "呼伦湖是内蒙古面积较大的湖泊,面积约2339平方公里。夏季碧波万顷,岸线蜿蜒800多公里,和草原连成一片,日落时分整个湖面被染成金色。但真正让人印象深刻的反而是冬季——呼伦湖结冰后形成壮观的蓝冰,嗨冰雪北线行程安排了穿越呼伦湖蓝冰,在冰面上行走的体验截然不同。湖里产鲤鱼、鲫鱼、白鱼,现捕现做的全鱼宴是来这儿推荐的美食项目。",
"highlights": [
"冬季穿越蓝冰——呼伦湖结冰后形成的蓝冰奇观,嗨冰雪北线核心体验",
"湖边看日落——金色夕阳映照万顷碧波,草原与湖水连成一片",
@ -283,7 +283,7 @@
"bestSeason": {
"primary": "7月-8月",
"secondary": "12月-2月",
"description": "夏季湖水最美日落震撼。冬季蓝冰穿越是嗨冰雪北线的亮点。"
"description": "夏季湖水辽阔、日落震撼。冬季蓝冰穿越是嗨冰雪北线的亮点。"
},
"tips": [
"湖面开阔风大,夏季午后常有阵雨,备雨具",
@ -295,17 +295,17 @@
"relatedProducts": ["winter-north-7d6n"],
"seo": {
"title": "呼伦湖旅游攻略 - 蓝冰穿越与日落美景 | 呼籁旅行",
"description": "呼伦湖是内蒙古最大湖泊,夏季日落壮观,冬季蓝冰穿越震撼。呼籁旅行嗨冰雪北线带你冬季穿越呼伦湖蓝冰。",
"description": "呼伦湖是内蒙古面积较大的湖泊,夏季日落壮观,冬季蓝冰穿越震撼。呼籁旅行嗨冰雪北线带你冬季穿越呼伦湖蓝冰。",
"keywords": "呼伦湖,呼伦湖蓝冰,呼伦贝尔湖泊,草原日落,冬季穿越"
}
},
{
"id": "aoluguya",
"name": "敖鲁古雅驯鹿部落",
"subtitle": "中国唯一的驯鹿民族 · 鄂温克使鹿文化",
"subtitle": "鄂温克使鹿文化 · 驯鹿部落",
"tag": "民族探访",
"seasons": ["summer", "winter"],
"description": "这是中国唯一的驯鹿部落,鄂温克族是中国仅存的游猎民族。但说实话现在游客去的「敖鲁古雅」是政府修建的旅游景区,不是真正的猎民营地——真正的鄂温克猎民和驯鹿生活在150公里外的深山密林里。景区里能喂驯鹿、看博物馆、了解桦皮文化和萨满文化,商业化程度不低但如果调整好预期带孩子来还是很有教育意义的。冬季嗨冰雪北线安排了深入根河方向的布冬霞驯鹿部落,坐驯鹿雪橇、穿鄂温克民族服饰,体验更真实一些。",
"description": "这里是鄂温克驯鹿部落的聚居地,鄂温克族保留了传承已久的游猎民族文化。但说实话现在游客去的「敖鲁古雅」是政府修建的旅游景区,不是真正的猎民营地——真正的鄂温克猎民和驯鹿生活在150公里外的深山密林里。景区里能喂驯鹿、看博物馆、了解桦皮文化和萨满文化,商业化程度不低但如果调整好预期带孩子来还是很有教育意义的。冬季嗨冰雪北线安排了深入根河方向的布冬霞驯鹿部落,坐驯鹿雪橇、穿鄂温克民族服饰,体验更真实一些。",
"highlights": [
"近距离喂驯鹿——买苔藓喂食拍照,驯鹿很温顺",
"鄂温克博物馆——了解驯鹿文化、狩猎文化、桦皮文化和萨满文化",
@ -327,32 +327,32 @@
"heroImage": "/images/destinations/aoluguya.jpg",
"relatedProducts": ["winter-north-7d6n"],
"seo": {
"title": "敖鲁古雅驯鹿部落攻略 - 中国唯一驯鹿民族 | 呼籁旅行",
"description": "敖鲁古雅是中国唯一的驯鹿部落,鄂温克族使鹿文化独一无二。呼籁旅行嗨冰雪北线带你深入根河体验驯鹿雪橇。",
"title": "敖鲁古雅驯鹿部落攻略 - 鄂温克使鹿文化 | 呼籁旅行",
"description": "敖鲁古雅是鄂温克驯鹿部落聚居地,使鹿文化独具特色。呼籁旅行嗨冰雪北线带你深入根河体验驯鹿雪橇。",
"keywords": "敖鲁古雅,驯鹿部落,鄂温克族,驯鹿雪橇,根河"
}
},
{
"id": "genhe",
"name": "根河·中国冷极",
"subtitle": "历史最低温-58°C · 不冻河奇观",
"subtitle": "极寒气候之城 · 不冻河奇观",
"tag": "冷极探秘",
"seasons": ["winter", "summer"],
"description": "根河是中国官方认定的最冷城市,历史最低温零下58度。但大多数游客不是冬天来的——夏天的根河其实是个避暑地,七八月也就20度出头。冬天来的话,泼水成冰是必玩项目:零下三四十度往空中泼热水瞬间变成冰晶散开,怎么拍怎么好看。嗨冰雪北线的行程后安排在根河——布冬霞驯鹿部落喂驯鹿、铁锹烤肉、鄂温克民族服饰体验,然后从根河返回海拉尔。",
"description": "根河以极寒气候著称,历史极端低温可达零下五十多度。但大多数游客不是冬天来的——夏天的根河其实是个避暑地,七八月也就20度出头。冬天来的话,泼水成冰是推荐项目:零下三四十度往空中泼热水瞬间变成冰晶散开,怎么拍怎么好看。嗨冰雪北线的行程后安排在根河——布冬霞驯鹿部落喂驯鹿、铁锹烤肉、鄂温克民族服饰体验,然后从根河返回海拉尔。",
"highlights": [
"泼水成冰——零下三四十度的经典打卡体验,怎么拍怎么好看",
"泼水成冰——零下三四十度的特色打卡体验,怎么拍怎么好看",
"不冻河——零下几十度依然流淌,河面蒸腾的雾气配岸边雾凇",
"冷极碑打卡——标注「-58°C」的地标,冬季来必拍",
"冷极碑打卡——当地气候纪念地标,冬季推荐打卡",
"铁锹烤肉——用真铁锹当烤盘,根河特色美食",
"夏季避暑——七八月白天20度左右,原始森林负氧离子爆表"
"夏季避暑——七八月白天20度左右,原始森林负氧离子丰富"
],
"bestSeason": {
"primary": "12月-2月",
"secondary": "7月-8月",
"description": "冬季是核心点,零下三四十度的极寒体验。夏季避暑+森林生态也很舒服。"
"description": "冬季是核心点,零下三四十度的极寒体验。夏季避暑+森林生态也很舒服。"
},
"tips": [
"冬季来务必全副武装:及膝羽绒服、雪地靴、棉帽手套面罩一样不能少",
"冬季出行务必做好保暖:及膝羽绒服、雪地靴、棉帽手套面罩一样不能少",
"手机贴暖宝宝否则秒关机,多备几块暖宝宝",
"室外停留不要超过30分钟就进屋暖一下",
"夏季蚊虫很多,驱蚊液防晒霜都要带",
@ -361,9 +361,44 @@
"heroImage": "/images/destinations/genhe.jpg",
"relatedProducts": ["winter-north-7d6n"],
"seo": {
"title": "根河旅游攻略 - 中国冷极泼水成冰 | 呼籁旅行",
"description": "根河是中国最冷城市,历史最低温-58°C。泼水成冰、不冻河、铁锹烤肉——呼籁旅行嗨冰雪北线带你深入中国冷极。",
"keywords": "根河,中国冷极,泼水成冰,不冻河,铁锹烤肉"
"title": "根河旅游攻略 - 冷极体验泼水成冰 | 呼籁旅行",
"description": "根河是以极寒气候著称的林区城市,泼水成冰、不冻河、铁锹烤肉——呼籁旅行嗨冰雪北线带你深入根河冷极。",
"keywords": "根河,冷极,泼水成冰,不冻河,铁锹烤肉"
}
},
{
"id": "aobaoxianghui",
"name": "相约敖包景区",
"subtitle": "《敖包相会》歌曲起源地 · 呼籁自有营地",
"tag": "独家营地",
"seasons": ["summer", "autumn"],
"description": "相约敖包景区是经典民歌《敖包相会》的起源地,背靠「天下第一敖包」,俯瞰伊敏河湿地。2026 年呼籁旅行把自有营地迁到了这里——自有、不对外开放,与市场上多家共用的「私家牧场」有本质区别。旺季出行最怕人多排队、体验缩水,这里的玩法是错峰:别的团上午涌入莫日格勒河时,我们先带你来营地 BBQ、旅拍、放空;下午再登牧云山顶俯瞰河曲,视野里一个旅行团都没有。",
"highlights": [
"《敖包相会》歌曲起源地——背靠天下第一敖包,感受草原民歌的原生语境",
"俯瞰伊敏河湿地——草原+湿地双重景观一次打包",
"呼籁自有营地,不对外开放,小团独享专属草原空间",
"营地 BBQ + 奶茶下午茶 + 户外旅拍一条龙",
"错峰安排——先营地后景区,避开旅行团人潮",
"配合牧云山顶下午茶歇,俯瞰莫日格勒河九曲十八弯"
],
"bestSeason": {
"primary": "6月-8月",
"secondary": "9月",
"description": "呼籁营地自 6月1日起开放,7-8 月草原最绿、体验最完整;9月金秋亦可。"
},
"tips": [
"营地不对外开放,仅呼籁团队使用,需跟团入住或通过客服预约",
"营地 BBQ 幼童不含,有特殊饮食需求需提前告知领队",
"草原风大,即使夏季傍晚也建议带件外套",
"草原里蚊虫较多,驱蚊液、防晒霜必备",
"营地有 WiFi,但途中草原深处会有间断无信号区间"
],
"heroImage": "/images/destinations/aobaoxianghui.jpg",
"relatedProducts": ["v9-6d5n-explore", "v9-7d6n-family", "v9-7d6n-wild"],
"seo": {
"title": "相约敖包景区攻略 - 敖包相会起源地 · 呼籁自有营地 | 呼籁旅行",
"description": "相约敖包景区是《敖包相会》歌曲起源地,背靠天下第一敖包,俯瞰伊敏河湿地。呼籁自有营地,不对外开放,带你错峰体验草原腹地的 BBQ、旅拍与放空。",
"keywords": "相约敖包景区,敖包相会起源地,天下第一敖包,伊敏河湿地,呼籁自有营地,呼伦贝尔小众景点"
}
}
]

查看文件

@ -1,7 +1,7 @@
{
"title": "亲子游目的地怎么选?",
"subtitle": "一份客观对比,帮你找到适合全家的那个答案",
"intro": "很多家庭在规划暑假亲子游时,会在呼伦贝尔、新疆和内蒙古西部之间犹豫。三个目的地各有魅力,以下是我们基于真实数据的客观对比,不贬低任何目的地,只帮你找到适合带孩子去的那个。",
"subtitle": "一份客观对比,帮你找到适合全家的那个答案",
"intro": "很多家庭在规划暑假亲子游时,会在呼伦贝尔、新疆和内蒙古西部之间犹豫。三个目的地各有魅力,以下是我们基于真实数据的客观对比,不贬低任何目的地,只帮你找到适合带孩子去的那个。",
"destinations": [
{
"id": "hulunbuir",
@ -106,7 +106,7 @@
},
"closing": {
"title": "为什么呼籁只做呼伦贝尔?",
"text": "新疆很美,内蒙古西部也有独特风光。但我们选择只做呼伦贝尔一地,是因为11年的深耕让我们确信对带孩子的家庭来说,短车程、低海拔、凉爽气温、丰富体验——呼伦贝尔是综合最优解。我们把所有精力放在一个地方,才能把每一条路线、每一个营地、每一位领队都打磨到最好。不是呼伦贝尔什么都好,而是对家庭亲子游这件事,它真的很合适。"
"text": "新疆很美,内蒙古西部也有独特风光。但我们选择只做呼伦贝尔一地,是因为11年的深耕让我们确信对带孩子的家庭来说,短车程、低海拔、凉爽气温、丰富体验——呼伦贝尔是综合最优解。我们把所有精力放在一个地方,才能把每一条路线、每一个营地、每一位领队都悉心打磨。不是呼伦贝尔什么都好,而是对家庭亲子游这件事,它真的很合适。"
},
"dataSources": "气温数据来源中国气象局;旅游人次数据来源呼伦贝尔市2024年统计公报、新疆维吾尔自治区文旅厅;距离数据来源高德地图实测"
}

查看文件

@ -100,8 +100,8 @@
},
{
"id": "best-month",
"question": "呼伦贝尔几月去最好?",
"answer": "呼伦贝尔夏季旅游季节是5-10月,各月体验不同。5月草刚返青,白天5-26°C、夜间可低至-6°C,需厚外套甚至羽绒服,游客极少适合错峰。6月上旬草原开始变绿,下旬油菜花开放,白天13-28°C,体感舒适。7月是最旺季,草原最美,满眼绿色加野花,白天可达34°C但不闷热,是大多数家庭的选择。8月暑假高峰,下旬渐有秋意。9月白桦林开始变黄,秋色渐浓,蚊虫基本没了,早晚已经很凉需要厚外套。国庆期间秋末接近入冬,部分年份可能飘雪。冬季12-2月可以体验雪原风光。如果时间灵活,6月中下旬和9月初的体验也非常好,且价格更友好。更多各月穿搭建议请查看出行指南。",
"question": "呼伦贝尔几月去合适?",
"answer": "呼伦贝尔夏季旅游季节是5-10月,各月体验不同。5月草刚返青,白天5-26°C、夜间可低至-6°C,需厚外套甚至羽绒服,游客极少适合错峰。6月上旬草原开始变绿,下旬油菜花开放,白天13-28°C,体感舒适。7月是旺季高峰,草原景色动人,满眼绿色加野花,白天可达34°C但不闷热,是大多数家庭的选择。8月暑假高峰,下旬渐有秋意。9月白桦林开始变黄,秋色渐浓,蚊虫基本没了,早晚已经很凉需要厚外套。国庆期间秋末接近入冬,部分年份可能飘雪。冬季12-2月可以体验雪原风光。如果时间灵活,6月中下旬和9月初的体验也非常好,且价格更友好。更多各月穿搭建议请查看出行指南。",
"relatedLinks": [
{
"url": "/guides",
@ -112,7 +112,7 @@
{
"id": "bugs-prevention",
"question": "蚊虫多吗?防蚊怎么做?",
"answer": "草原上6-8月确实有蚊虫,湿地区域7月中旬为高峰期,日出前和日落后蚊虫最活跃。但做好防护就没什么大问题。建议带含DEET成分的防蚊喷雾,穿浅色长袖长裤效果好(深色衣服反而容易招蚊子)。另外需要注意蜱虫防护:户外活动时扎紧裤脚和袖口,每天活动结束后检查皮肤(尤其腋下、腰部、发际线、耳后),万一发现蜱虫叮咬不要用手硬拽,用尖头镊子贴近皮肤夹住蜱虫头部垂直拔出,然后碘伏消毒。呼籁的领队随车备有蚊不叮和防蚊喷雾,也携带急救包。大部分家庭反馈蚊虫影响可控,不必过度担心。",
"answer": "草原上6-8月确实有蚊虫,湿地区域7月中旬为高峰期,日出前和日落后蚊虫最活跃。但做好防护就没什么大问题。建议带含DEET成分的防蚊喷雾,穿浅色长袖长裤效果好(深色衣服反而容易招蚊子)。另外需要注意蜱虫防护:户外活动时扎紧裤脚和袖口,每天活动结束后检查皮肤(尤其腋下、腰部、发际线、耳后),万一发现蜱虫叮咬不要用手硬拽,用尖头镊子贴近皮肤夹住蜱虫头部垂直拔出,然后碘伏消毒。呼籁的领队随车备有蚊不叮和防蚊喷雾,也携带急救包。大部分家庭反馈蚊虫影响可控,不必过度担心。",
"relatedLinks": [
{
"url": "/guides",
@ -123,7 +123,7 @@
{
"id": "summer-peak-season",
"question": "暑假去草原人多吗?需要提前多久订?",
"answer": "7月15日至8月5日是全年最旺的黄金期,热门营地和优质住宿在旺季通常提前2-3个月就已订满。如果确定暑假出行,越早订越好,6月初甚至更早开始沟通是常规操作。人多并不代表体验差——呼籁的「一家一单一车」模式意味着整个行程只有你一家人,没有其他游客打扰;草原足够大,莫日格勒河沿线和边境公路不是密集景区,感受不到城市景点的「人挤人」。暑假草原亲子游的最大优势是:孩子正好放假、天气最好、草最绿、那达慕民俗活动最丰富。如果6月初或8月下旬出行,游客更少、价格相对友好,体验同样完整,是时间灵活家庭的优选。",
"answer": "7月15日至8月5日是全年最旺的黄金期,热门营地和优质住宿在旺季通常提前2-3个月就已订满。如果确定暑假出行,越早订越好,6月初甚至更早开始沟通是常规操作。人多并不代表体验差——呼籁的「一家一单一车」模式意味着整个行程只有你一家人,没有其他游客打扰;草原足够大,莫日格勒河沿线和边境公路不是密集景区,感受不到城市景点的「人挤人」。暑假草原亲子游的突出优势是:孩子正好放假、天气适宜、草色青翠、那达慕民俗活动丰富。如果6月初或8月下旬出行,游客更少、价格相对友好,体验同样完整,是时间灵活家庭的优选。",
"relatedLinks": [
{
"url": "/calendar",
@ -134,7 +134,7 @@
{
"id": "altitude-sickness",
"question": "呼伦贝尔有高原反应吗?",
"answer": "没有。呼伦贝尔的平均海拔在600-800米左右,属于平原地区,不会产生高原反应。这也是呼伦贝尔特别适合亲子游的原因之一——老人和小孩都不需要担心海拔问题,身体没有额外负担。唯一需要注意的是昼夜温差较大,注意增减衣物即可。",
"answer": "没有。呼伦贝尔的平均海拔在600-800米左右,属于平原地区,不会产生高原反应。这也是呼伦贝尔特别适合亲子游的原因之一——老人和小孩都不需要担心海拔问题,身体没有额外负担。需要注意的是昼夜温差较大,注意增减衣物即可。",
"relatedLinks": [
{
"url": "/guides",
@ -145,7 +145,7 @@
{
"id": "kids-safety",
"question": "带孩子去安全吗?",
"answer": "安全是呼籁最重视的事项。首先,呼籁的车辆和司机都有严格管理,车况定期检查,车辆配备儿童安全座椅。其次,呼籁配备专业领队,每天的活动都有安全评估和应急预案,领队随身携带急救包。第三,所有行程均购买旅游责任险保额1000万,签订正规电子合同。各项活动都有明确的安全规范骑马穿越全程有专业马师随行,年龄较小的孩子由马师牵引或抱着骑;ATV越野车2人一车系安全带,12岁以下需家长陪同;草原滑草要求身高1.2米以上独立乘坐,不足1.2米可和大人一起坐。0-3岁幼童有专属活动推荐,不建议参与骑马穿越和ATV等项目。呼籁额吉的故乡产品已服务超过10000个家庭、累计39000余人次出行,安全记录良好。",
"answer": "安全是呼籁最重视的事项。首先,呼籁的车辆和司机都有严格管理,车况定期检查,车辆配备儿童安全座椅。其次,呼籁配备专业领队,每天的活动都有安全评估和应急预案,领队随身携带急救包。第三,所有行程均购买旅游责任险保额1000万,签订正规电子合同。各项活动都有明确的安全规范骑马穿越全程有专业马师随行,年龄较小的孩子由马师牵引或抱着骑;全地形越野车2人一车系安全带,12岁以下需家长陪同;草原滑草要求身高1.2米以上独立乘坐,不足1.2米可和大人一起坐。0-3岁幼童有专属活动推荐,不建议参与骑马穿越和全地形越野车等项目。呼籁额吉的故乡产品已服务超过10000个家庭、累计39000余人次出行,安全记录良好。",
"relatedLinks": [
{
"url": "/reviews",
@ -177,7 +177,7 @@
{
"id": "is-legal",
"question": "呼籁是正规旅行社吗?",
"answer": "是的。呼籁旅行集团旗下的呼籁山河国旅,成立于2005年,持有正规旅行社经营许可证,所有行程均签订正规电子合同,缴纳旅行社质量保证金,受旅游管理部门监管。呼籁集团旗下9家企业,包括自有车队和营地,具备完整的旅行服务链条。旅游责任险保额1000万。选择有资质的旅行社,是出行安全的第一道保障。",
"answer": "是的。呼籁旅行集团旗下的呼籁山河国旅,成立于2005年,持有正规旅行社经营许可证,所有行程均签订正规电子合同,缴纳旅行社质量保证金,受旅游管理部门监管。呼籁集团旗下9家企业,包括自有车队和营地,具备完整的旅行服务链条。旅游责任险保额1000万。选择有资质的旅行社,是出行安全的重要保障。",
"relatedLinks": [
{
"url": "/about",
@ -260,7 +260,7 @@
{
"id": "horse-riding",
"question": "能骑马吗?孩子能骑吗?",
"answer": "可以骑马,这是呼伦贝尔最受家庭欢迎的体验之一。骑马穿越往返约1小时,马匹都是经过训练的温顺马,全程有专业马师随行陪同。如果不太敢骑或不熟练,马师会牵着马走,不额外收费;年龄较小的孩子由马师抱着骑。请穿长裤和运动鞋,呼籁会提前准备骑马手套。骑马时双手握住缰绳、身体放松,不要站在马的后面。0-3岁幼童不建议参与骑马穿越体验,但可以体验喂小羊、营地儿童游乐架、轮胎秋千等温和活动。除骑马外,行程中还可体验草原滑草身高1.2米以上可独立乘坐)、ATV越野2人一车等多种户外活动。",
"answer": "可以骑马,这是呼伦贝尔最受家庭欢迎的体验之一。骑马穿越往返约1小时,马匹都是经过训练的温顺马,全程有专业马师随行陪同。如果不太敢骑或不熟练,马师会牵着马走,不额外收费;年龄较小的孩子由马师抱着骑。请穿长裤和运动鞋,呼籁会提前准备骑马手套。骑马时双手握住缰绳、身体放松,不要站在马的后面。0-3岁幼童不建议参与骑马穿越体验,但可以体验喂小羊、营地儿童游乐架、轮胎秋千等温和活动。除骑马外,行程中还可体验草原滑草身高1.2米以上可独立乘坐)、全地形越野车2人一车等多种户外活动。",
"relatedLinks": [
{
"url": "/guides#activities",
@ -282,7 +282,7 @@
{
"id": "how-many-days-best",
"question": "呼伦贝尔玩几天合适?",
"answer": "如果时间允许,建议至少5天以上。4天能看到核心风光,但节奏会比较紧凑;5-6天是大多数家庭的选择,体验较为完整;7天能深入探索,包括牧云山顶、寻龙诀秘境等独家资源点。呼籁提供4-7天的多个版本,可以根据假期灵活选择。第一次来呼伦贝尔的家庭,6天5晚是比较推荐的时长,时间和体验的平衡较好。",
"answer": "如果时间允许,建议至少5天以上。4天能看到核心风光,但节奏会比较紧凑;5-6天是大多数家庭的选择,体验较为完整;7天能深入探索,包括牧云山顶、寻龙诀秘境等专属资源点。呼籁提供4-7天的多个版本,可以根据假期灵活选择。第一次来呼伦贝尔的家庭,6天5晚是比较推荐的时长,时间和体验的平衡较好。",
"relatedLinks": [
{
"url": "/products#guide",
@ -343,7 +343,7 @@
{
"id": "hulunbuir-vs-dali-xinjiang",
"question": "呼伦贝尔和大理、新疆比哪个值得去?",
"answer": "三个目的地各有侧重,不存在绝对的高下,主要看你的出行目的和家庭需求。大理适合休闲慢游、文艺感强,但草原体验几乎没有;新疆壮观震撼、景点分散,飞行时间长且行程强度大,带小孩子体力压力较大;呼伦贝尔是中国保存最完好的天然草原,海拔平均600-800米无高原反应、对孩子和老人特别友好、飞行时间仅2.5小时(北京出发)、夏季气候凉爽无酷暑,是三者中对亲子家庭最友好的目的地。如果你的核心需求是:草原体验、带孩子、身体无高反限制、行程不想太赶——呼伦贝尔是更合适的选择。如果你追求大西北的边境感或摄影极致风光,新疆更合适;如果你更喜欢文艺慢生活,大理更适合。",
"answer": "三个目的地各有侧重,很难简单比较高下,主要看你的出行目的和家庭需求。大理适合休闲慢游、文艺感强,但草原体验几乎没有;新疆壮观震撼、景点分散,飞行时间长且行程强度大,带小孩子体力压力较大;呼伦贝尔是中国保存最完好的天然草原,海拔平均600-800米无高原反应、对孩子和老人特别友好、飞行时间仅2.5小时(北京出发)、夏季气候凉爽无酷暑,是三者中对亲子家庭最友好的目的地。如果你的核心需求是:草原体验、带孩子、身体无高反限制、行程不想太赶——呼伦贝尔是更合适的选择。如果你追求大西北的边境感或摄影壮丽风光,新疆更合适;如果你更喜欢文艺慢生活,大理更适合。",
"relatedLinks": [
{
"url": "/blog/hulunbuir-travel-guide-2026",
@ -354,7 +354,7 @@
{
"id": "hulunbuir-food",
"question": "呼伦贝尔有什么特色美食?",
"answer": "呼伦贝尔的饮食文化以蒙古族和俄罗斯族两种风味为主,是国内少见的草原+边境美食组合。蒙古族必吃:手把肉(白水煮整块羊腿/羊排,蘸蒜汁或直接吃,鲜嫩无膻)、蒙古奶茶(用砖茶加鲜奶和盐煮成,咸香浓郁)、奶豆腐(新鲜牛奶制成的固体奶制品,微酸有嚼劲)、风干牛肉(高蛋白低脂,适合当零食)、黄油(牧民手工提炼,拌炒米吃)。俄罗斯族风味:列巴(恩和俄罗斯族村的手工大面包,外脆内软)、格瓦斯(发酵饮料,口感微酸清爽)。海拉尔市区还有蒙古烤肉、涮羊肉等选择。呼籁行程中会安排牧民家就餐和营地特色餐,让你吃到最正宗的草原味道。如有饮食过敏(特别是牛奶蛋白)请提前告知定制师。",
"answer": "呼伦贝尔的饮食文化以蒙古族和俄罗斯族两种风味为主,是国内少见的草原+边境美食组合。蒙古族必吃:手把肉(白水煮整块羊腿/羊排,蘸韭菜花或蒜蓉辣酱,鲜嫩无膻)、蒙古奶茶(用砖茶加鲜奶和盐煮成,咸香浓郁)、奶豆腐(新鲜牛奶制成的固体奶制品,微酸有嚼劲)、风干牛肉(高蛋白低脂,适合当零食)、黄油(牧民手工提炼,拌炒米吃)。俄罗斯族风味:列巴(恩和俄罗斯族村的手工大面包,外脆内软)、格瓦斯(发酵饮料,口感微酸清爽)。海拉尔市区还有蒙古烤肉、涮羊肉等选择。呼籁行程中会安排牧民家就餐和营地特色餐,让你吃到最正宗的草原味道。如有饮食过敏(特别是牛奶蛋白)请提前告知定制师。",
"relatedLinks": [
{
"url": "/guides",
@ -365,7 +365,7 @@
{
"id": "accommodation-types",
"question": "草原上住什么?蒙古包住宿是什么体验?",
"answer": "呼伦贝尔的住宿分三大类型:一是城区星级酒店(海拉尔市区,设施完善,适合过渡住宿);二是草原营地蒙古包(最具特色,帐篷或蒙古包形态,部分有独立卫浴,真实草原住宿体验);三是边境特色民宿(室韦/恩和俄罗斯风情民宿,木屋风格)。蒙古包住宿是什么体验?推开圆形的木门,里面是低矮的床铺和简约的蒙古族装饰,夜晚能听到风声和草虫声,清晨打开门第一眼就是连绵草原——这是城市里完全感受不到的空间感。呼籁自有营地的蒙古包配备独立卫浴,不存在「公共厕所在帐篷外面」的情况,带孩子完全放心。草原住宿的条件与城市有差距,但这正是体验的一部分——大多数家庭反馈这是整个行程记忆深的夜晚。",
"answer": "呼伦贝尔的住宿分三大类型:一是城区星级酒店(海拉尔市区,设施完善,适合过渡住宿);二是草原营地蒙古包(最具特色,帐篷或蒙古包形态,部分有独立卫浴,真实草原住宿体验);三是边境特色民宿(室韦/恩和俄罗斯风情民宿,木屋风格)。蒙古包住宿是什么体验?推开圆形的木门,里面是低矮的床铺和简约的蒙古族装饰,夜晚能听到风声和草虫声,清晨打开门第一眼就是连绵草原——这是城市里完全感受不到的空间感。呼籁自有营地的蒙古包配备独立卫浴,不存在「公共厕所在帐篷外面」的情况,带孩子完全放心。草原住宿的条件与城市有差距,但这正是体验的一部分——大多数家庭反馈这是整个行程记忆的夜晚。",
"relatedLinks": [
{
"url": "/products",
@ -387,7 +387,7 @@
{
"id": "vs-other-charter",
"question": "和其他呼伦贝尔包车有什么区别?",
"answer": "呼伦贝尔做包车的很多,但呼籁不只是包车。区别在于第一,呼籁是正规旅行社旗下呼籁山河国旅成立于2005年,签正规电子合同,旅游责任险1000万,有法律保障;很多包车是个人或无资质的车队。第二,呼籁提供完整的服务团队3管家+定制师+专属领队),不只是一个司机带路。第三,呼籁有自有营地、自有车队、牧云山顶和寻龙诀秘境等独家体验资源,这些是普通包车司机给不了的。第四,呼籁的行程经过专业设计和9次迭代优化,是一套成熟的旅行服务体系。",
"answer": "呼伦贝尔做包车的很多,但呼籁不只是包车。区别在于第一,呼籁是正规旅行社旗下呼籁山河国旅成立于2005年,签正规电子合同,旅游责任险1000万,有法律保障;很多包车是个人或无资质的车队。第二,呼籁提供完整的服务团队3管家+定制师+专属领队),不只是一个司机带路。第三,呼籁有自有营地、自有车队、牧云山顶和寻龙诀秘境等专属体验资源,这些是普通包车司机给不了的。第四,呼籁的行程经过专业设计和9次迭代优化,是一套成熟的旅行服务体系。",
"relatedLinks": [
{
"url": "/about",

查看文件

@ -1,16 +1,16 @@
{
"seo": {
"title": "呼籁旅拍 | 草原旅拍作品集 | 呼伦贝尔蒙古袍亲子旅拍",
"description": "呼籁旅行自营旅拍服务,200+件蒙古族服饰,呼籁自营营地独家取景。亲子旅拍/儿童写真/全家福,随行拍摄当日交付。",
"description": "呼籁旅行自营旅拍服务,200+件蒙古族服饰,呼籁自营营地特色取景。亲子旅拍/儿童写真/全家福,随行拍摄当日交付。",
"keywords": "草原旅拍,呼伦贝尔旅拍,蒙古袍旅拍,亲子旅拍,呼籁营地旅拍"
},
"intro": {
"description": "呼籁旅拍是呼籁旅行的自营旅拍服务,在呼籁自营营地进行拍摄,摄影师随行,用真实光线记录每个家庭最自然的草原瞬间。所有旅拍均含底片,精修照片当日交付,无任何隐形消费。",
"stats": [
{ "value": "200+", "label": "件蒙古服饰", "note": "传统袍服·节日盛装·亲子套装" },
{ "value": "11年", "label": "深耕呼伦贝尔", "note": "最懂草原光线的摄影团队" },
{ "value": "5000+", "label": "组旅拍作品", "note": "每组都是独一无二的家庭故事" },
{ "value": "1处", "label": "自营营地取景", "note": "呼籁营地·独家秘境" }
{ "value": "11年", "label": "深耕呼伦贝尔", "note": "熟悉草原光线的摄影团队" },
{ "value": "5000+", "label": "组旅拍作品", "note": "每组都是与众不同的家庭故事" },
{ "value": "1处", "label": "自营营地取景", "note": "呼籁营地·特色秘境" }
]
},
"categories": [

82
data/guide-custom.json 普通文件
查看文件

@ -0,0 +1,82 @@
{
"title": "呼伦贝尔定制游怎么选",
"tldr": "呼伦贝尔定制游是一家一单一车的私家出行方式,区别于传统跟团游的拼团大巴和走马观花。本文解释定制游的优势、适合人群、价格构成,帮你判断是否适合选择定制游。",
"seo": {
"title": "呼伦贝尔定制游怎么选 - 定制游vs跟团游vs自由行 - 呼籁旅行",
"description": "呼伦贝尔定制游是一家一单一车的私家出行方式。本文对比定制游、跟团游、自由行的区别,解释定制游的价格构成和适合人群,帮你判断是否适合选择呼伦贝尔定制游。",
"keywords": "呼伦贝尔定制游,定制游和跟团游的区别,呼伦贝尔旅游怎么选,呼伦贝尔一家一车,呼伦贝尔私家团,呼伦贝尔亲子游"
},
"sections": [
{
"type": "text",
"heading": "什么是定制游",
"paragraphs": [
"定制游的核心是「一家一单一车」——你的家庭独享一辆车和一位领队,行程根据你的需求量身安排,不与陌生人拼团。出发时间、游玩节奏、停留时长都可以灵活调整,孩子累了可以多休息,遇到好风景可以多停一会儿。",
"这不是高价版的跟团游,而是完全不同的旅行方式。"
]
},
{
"type": "table",
"heading": "定制游 vs 跟团游 vs 自由行",
"columns": ["定制游", "跟团游", "自由行"],
"rows": [
{ "label": "用车", "values": ["一家一车,专属司机", "拼团大巴,30-50人", "自驾或包车"] },
{ "label": "行程", "values": ["按家庭需求定制", "固定路线,统一安排", "完全自己规划"] },
{ "label": "节奏", "values": ["随走随停,灵活调整", "按时间表走,赶景点", "自由但费心"] },
{ "label": "领队", "values": ["专属领队全程陪同", "导游带大团", "无"] },
{ "label": "购物", "values": ["全程零购物", "可能有购物店", "无"] },
{ "label": "适合", "values": ["亲子家庭、品质出行", "预算有限、不想操心", "经验丰富的老手"] }
]
},
{
"type": "text",
"heading": "为什么亲子家庭更适合定制游",
"paragraphs": [
"带孩子旅行和大人出行完全不同。孩子需要按自己的节奏来——可能早上起不来,可能中午要午睡,可能在某个地方玩嗨了不想走。跟团游的固定时间表很难适配孩子的节奏,自由行又需要父母花大量精力规划路线。",
"定制游解决的正是这个问题:专业的定制师提前根据孩子年龄安排行程节奏,专属领队全程照顾,家长只需要带着孩子享受旅行。"
]
},
{
"type": "list",
"heading": "定制游的价格构成",
"intro": "定制游的价格包含车辆、住宿、领队服务、活动体验和保险,正餐自理。看起来比跟团游贵,但每一分钱都花在了体验上:",
"items": [
"没有购物店——不会被拉去消费",
"没有隐形消费——费用明细清晰透明",
"没有赶场式行程——每个地方都停够、看够",
"所有资源专属调配——你的领队只服务你一家"
],
"outro": "以呼籁旅行为例,5天4晚行程舒适套餐2580元/人起,高档套餐3380元/人起,价格随出行日期浮动。定金500元/人,出发前7天可无条件退改。"
},
{
"type": "list",
"heading": "怎么判断自己适不适合定制游",
"intro": "如果你符合以下任一条件,定制游大概率是更好的选择:",
"items": [
"带3-12岁孩子出行,需要灵活节奏",
"三代同行,需要照顾老人和孩子",
"对住宿品质有要求,不想住快捷酒店",
"不想和陌生人拼团,注重私密性",
"第一次去呼伦贝尔,不了解路线",
"想深度体验而不是走马观花"
],
"outro": ""
},
{
"type": "text",
"heading": "呼籁旅行的定制游有什么不同",
"paragraphs": [
"呼籁旅行深耕呼伦贝尔11年,是本地自营的亲子游品牌,不是中间商转手。我们有自己的车队、自己的营地、自己的旅拍团队,从产品设计到服务交付全链路自己把控。",
"目前提供额吉的故乡V9系列共6个版本5天4晚至7天6晚,每个版本都是针对不同家庭需求专门设计的,不是简单增减天数。每个家庭配备5人服务团队定制师+房务管家+车务管家+客服管家+领队。"
]
}
],
"cta": {
"heading": "想了解具体行程?",
"links": [
{ "text": "查看6个版本行程", "to": "/products", "primary": true },
{ "text": "1分钟匹配合适的版本", "to": "/selector", "primary": false },
{ "text": "联系定制师免费咨询", "to": "/contact", "primary": false }
]
}
}

查看文件

@ -1,10 +1,10 @@
{
"pageIntro": "11年带队,我们见过多的问题就是「出发前没准备好」。这份指南把穿搭、行李、证件、活动安全一次说清楚——你需要带什么,不需要带什么,哪些地方容易踩坑,全在这里。",
"pageIntro": "11年带队,我们见过多的问题就是「出发前没准备好」。这份指南把穿搭、行李、证件、活动安全一次说清楚——你需要带什么,不需要带什么,哪些地方容易踩坑,全在这里。",
"sections": [
{
"id": "documents",
"title": "证件准备",
"subtitle": "出发前第一件事",
"subtitle": "出发前首要准备",
"icon": "📋",
"content": "别的都能补,证件忘了真回不了头。出发前一晚,请全家人把证件放在一起,拍张照存手机里备用。",
"tips": [
@ -59,7 +59,7 @@
"tempNight": "1~14°C"
},
{
"desc": "草原进入最绿的时候,油菜花陆续开放,白天舒适宜人",
"desc": "草原进入翠绿时节,油菜花陆续开放,白天舒适宜人",
"name": "6月下旬",
"tempDay": "13~28°C",
"kidsWear": "短袖+薄外套,晚上篝火活动加一件长袖",
@ -68,9 +68,9 @@
"tempNight": "5~18°C"
},
{
"desc": "草原最美的季节,满眼绿色+野花,中午会有点晒但不闷热",
"name": "7月旺季)",
"note": "7月中下旬满洲里白天可达34°C,但早晚温差大,出门随身带件外套准没错",
"desc": "草原景致动人的季节,满眼绿色+野花,中午会有点晒但不闷热",
"name": "7月旺季)",
"note": "7月中下旬白天可达34°C,但早晚温差大,出门随身带件外套准没错",
"tempDay": "16~34°C",
"kidsWear": "短袖+薄长裤(草地上跑建议穿长裤防蚊虫),备一件拉链卫衣随时穿脱",
"adultWear": "短袖短裤+防晒衣,早晚加一件薄外套或卫衣。中午紫外线强,帽子墨镜防晒霜三件套必备",
@ -96,7 +96,7 @@
"tempNight": "-4~12°C"
},
{
"desc": "秋末接近入冬,部分年份可能飘雪,秋色也最浓烈",
"desc": "秋末接近入冬,部分年份可能飘雪,秋色浓烈",
"name": "国庆10.1-10.7",
"tempDay": "0~18°C",
"kidsWear": "同样按冬天标准,注意保暖帽和手套,风大时口罩也带上",
@ -141,7 +141,7 @@
"important": true
},
{
"text": "宽檐帽帽檐建议10cm以上,渔夫帽实用)+ UV400墨镜",
"text": "宽檐帽帽檐建议10cm以上,渔夫帽实用)+ UV400墨镜",
"important": true
},
{
@ -273,7 +273,7 @@
},
{
"icon": "🚙",
"name": "ATV全地形越野车",
"name": "全地形越野车",
"points": [
"每辆车2人一车,请系好安全带,听从教练指引",
"行驶中双手紧握扶手,不要将身体任何部位伸出车外",
@ -299,7 +299,7 @@
"icon": "🏕️",
"name": "呼籁营地",
"points": [
"营地位于敖包相会景区内,天下第一敖包旁,可俯瞰伊敏河湿地",
"营地位于敖包相会景区内「天下第一敖包」旁,可俯瞰伊敏河湿地",
"营地活动包括旅拍、BBQ等,注意脚下草原坑洼",
"营地下方是湿地有一定坡度,请看管好小朋友,不要靠近坡边",
"BBQ用餐时注意火源安全,不要让小朋友独自靠近烤炉",
@ -377,15 +377,15 @@
},
{
"name": "关于蚊虫",
"content": "6-8月户外活动时会有蚊虫,湿地区域7月中旬为高峰期。日出前和日落后蚊虫最活跃。穿浅色长袖长裤效果最好,深色衣服反而容易招蚊子。呼籁随车备有蚊不叮和防蚊喷雾。"
"content": "6-8月户外活动时会有蚊虫,湿地区域7月中旬为高峰期。日出前和日落后蚊虫较活跃。穿浅色长袖长裤效果较好,深色衣服反而容易招蚊子。呼籁随车备有蚊不叮和防蚊喷雾。"
},
{
"name": "关于蜱虫",
"content": "草原上偶尔会有蜱虫,户外活动时穿长袖长裤、扎紧裤脚和袖口。每天活动结束后仔细检查皮肤(尤其腋下、腰部、发际线、耳后)。万一发现蜱虫叮咬,不要用手硬拽,用尖头镊子贴近皮肤夹住蜱虫头部垂直拔出,碘伏消毒。如有不适及时就医,领队随身携带急救包。"
"content": "草原上偶尔会有蜱虫,户外活动时穿长袖长裤、扎紧裤脚和袖口。每天活动结束后仔细检查皮肤(尤其腋下、腰部、发际线、耳后)。万一发现蜱虫叮咬,不要用手硬拽,及时就医由医生专业处理,领队随身携带急救包并协助前往医院。"
},
{
"name": "关于草原天气",
"content": "呼伦贝尔平均海拔600-800米,不会高反,老人小朋友都可以放心来。草原夏季降雨以短暂阵雨为主,来得快去得也快,雨后容易遇到彩虹和晚霞。白天可能接近或超过30°C,但高温只集中在中午前后,行程会避开热时段。"
"content": "呼伦贝尔平均海拔600-800米,不会高反,老人小朋友都可以放心来。草原夏季降雨以短暂阵雨为主,来得快去得也快,雨后容易遇到彩虹和晚霞。白天可能接近或超过30°C,但高温只集中在中午前后,行程会避开热时段。"
}
],
"appNote": "草原日出约凌晨3:30-4:30,日落约19:00-20:30不同月份有差异。夏季银河肉眼可见。"
@ -503,7 +503,7 @@
"reason": "路线含草原、森林、河流、坡地,幼儿无法独立骑乘"
},
{
"name": "ATV越野车穿越",
"name": "全地形越野车穿越",
"reason": "震动强烈、地形不平,不适合2岁以下宝宝"
},
{

25
data/heroes.json 普通文件
查看文件

@ -0,0 +1,25 @@
{
"about": "一个深耕呼伦贝尔的旅行品牌",
"reviews": "来自真实家庭的旅行评价",
"guides": "证件·穿搭·行李清单·活动安全·幼童专属指南,帮您轻松准备呼伦贝尔之行",
"youji": "来自各平台的真实旅行故事,点击查看详情",
"xiaohongshu": "来自真实客户的旅行笔记,点击查看原文",
"stories": "来自小红书的真实旅行分享",
"winter-camp": "呼伦贝尔雪原亲子冬令营·7天6晚·每期8组家庭",
"calendar": "呼伦贝尔什么时候去合适?基于11年带队数据,为您提供每月出行参考",
"customize": "填写出行需求,定制师2小时内回复,免费咨询",
"blog": "呼伦贝尔旅行的干货指南,帮你把每一趟出行都过得值",
"courses": "小蒙马亲子研学·6大课程模块·3-12岁分龄设计",
"partners": "行业认证 · 平台背书 · 景区合作 · 高校联合",
"contact": "选择最方便的方式联系我们",
"summer-camp": "呼伦贝尔草原亲子游学营·6天5晚·每期7-9组家庭·5-12岁",
"destinations": "深耕呼伦贝尔11年,带你发现最真实的草原秘境",
"guide-custom": "定制游 vs 跟团游 vs 自由行,一篇讲透",
"news": "品牌动态、产品发布、活动公告,及时了解呼籁旅行最新资讯",
"products-winter": "独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景",
"products": "独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景",
"qualifications": "三重旅行社资质 · 1000万责任险 · 行业认证",
"selector": "5个问题 · 1分钟 · 找到适合你的版本",
"pricing": "不藏价格,不绕弯子",
"gallery": "自营摄影团队 · 200+件蒙古服饰 · 专属秘境取景地"
}

查看文件

@ -105,7 +105,7 @@
"to": "/company"
},
{
"text": "最新动态",
"text": "品牌资讯",
"to": "/news"
},
{
@ -136,7 +136,7 @@
"to": "/company"
},
{
"text": "最新动态",
"text": "品牌资讯",
"to": "/news"
},
{

查看文件

@ -1,5 +1,22 @@
{
"articles": [
{
"id": "news-8",
"title": "52名司机领队持证上岗呼籁组织红十字会救护员培训",
"date": "2026-04-17",
"category": "活动",
"coverImage": "/images/news-red-cross-1.jpg",
"summary": "2026年4月16-17日,呼籁旅行组织52名导游、领队、司机在呼伦贝尔市红十字会应急救护培训基地完成救护员培训,两天理论+实操考核全部通过,全员持《红十字会救护员证》上岗。",
"content": "2026年4月16-17日,呼籁旅行组织52名导游、领队、司机在呼伦贝尔市红十字会应急救护培训基地完成为期两天的应急救护员培训,经理论考试和实操考核,全员通过,获颁全国通用的《红十字会救护员证》持证上岗。\n\n呼伦贝尔旅行线路大多远离城市,草原上部分路段距离最近的医院一两个小时车程。一旦车上有孩子被食物噎住、有老人突发不适,等120到场可能来不及。呼籁每年冬天都组织培训,涵盖服务标准、安全规范、路线踩线等常规内容;本次在此基础上增加专业急救技能培训,由呼伦贝尔市红十字会开设呼籁旅行专场。\n\n两天课程涵盖四个核心模块一、心肺复苏和AED使用,应对心脏骤停黄金4分钟;二、气道异物梗阻急救,海姆立克急救法是每位领队必备技能;三、创伤救护四项技术——止血、包扎、固定、搬运,户外活动磕碰扭伤现场可处理;四、常见急症应对,包括中暑、过敏、蜂蜇等草原常见状况。\n\n52人全员参加、全员通过考核、全员取得证书。这是呼籁第一次组织红十字会救护员专场培训,后续将持续推进,目标是让每一位直接服务客人的一线伙伴,都具备专业急救能力。带孩子出来玩的家庭,把最珍贵的人交给呼籁;把每一个环节都准备到位,是呼籁对每一个家庭的承诺。"
},
{
"id": "news-4",
"title": "2026小蒙马亲子游学营开放报名·全面升级2.0版",
"date": "2026-04-16",
"category": "产品",
"summary": "2026年小蒙马亲子游学营2.0正式开放报名,6天5晚,每期7-9组家庭,5-12岁亲子同行。住宿全面升级、餐食全含、双领队+全程摄影师,2026年计划开设30期。",
"content": "2026年小蒙马亲子游学营2.0正式开放报名。小蒙马是呼籁旅行专为家庭设计的深度亲子游学营产品,行程6天5晚,每期7-9组家庭同行。2025年首发11期期期满员,2026年全面升级至2.0版本。行程融入草原音乐会、写生创作、白桦林非遗研学、亲子马术、做列巴、1200米滑草畅玩、勇士越野车穿越等体验。住宿全面升级蒙古包+独栋木屋别墅连住2晚+边境景观木屋,餐食全含5早5中4晚,配备双领队和全程专属摄影师。2026年计划开设30期早鸟2期+小高峰4期+暑假黄金24期,名额有限,报名请联系定制师。"
},
{
"id": "news-3",
"title": "额吉的故乡V9正式发布三大升级,一家一车",
@ -8,14 +25,6 @@
"summary": "呼籁旅行2026夏季旗舰产品「额吉的故乡V9」正式发布。V9在V8基础上完成三大升级营地迁入敖包相会景区、白桦林新增非遗传承人课程、深度合作云牧场。6天5晚,一家一车,5月开始接待。",
"content": "2026年3月7-8日,呼籁旅行2026夏季旗舰产品「额吉的故乡V9」正式对外发布。V9是额吉的故乡系列的第9次迭代,从2023年V1上线至今,已服务超过10000组家庭、39000余人次。本次V9在V8基础上完成三大核心升级一、营地升级——呼籁自营营地迁入敖包相会景区,在景区内拥有独立空间,上午安排营地烧烤和旅拍,避开下午客流高峰;二、非遗研学升级——白桦林增设非遗传承人专属课程,按年龄分层设计体验内容;三、牧场升级——与云牧场达成深度合作,体验近距离牧场互动,同时在附近自营空间安排蒙古包体验、捡牛粪、酥油茶、手工毡等活动。产品为6天5晚行程,分舒适套餐和尊享套餐两个版本,全程一家一单一车、独立成团。预售期间下单享早鸟优惠,5月正式开始接待。"
},
{
"id": "news-4",
"title": "2026小蒙马亲子游学营开放报名·全面升级2.0版",
"date": "2026-02-20",
"category": "产品",
"summary": "2026年小蒙马亲子游学营2.0正式开放报名,6天5晚,每期7-9组家庭,5-12岁亲子同行。住宿全面升级、餐食全含、双领队+全程摄影师,2026年计划开设30期。",
"content": "2026年小蒙马亲子游学营2.0正式开放报名。小蒙马是呼籁旅行专为家庭设计的深度亲子游学营产品,行程6天5晚,每期7-9组家庭同行。2025年首发11期期期满员,2026年全面升级至2.0版本。行程融入草原音乐会、写生创作、白桦林非遗研学、亲子马术、做列巴、1200米滑草畅玩、勇士越野车穿越等体验。住宿全面升级蒙古包+独栋木屋别墅连住2晚+边境景观木屋,餐食全含5早5中4晚,配备双领队和全程专属摄影师。2026年计划开设30期早鸟2期+小高峰4期+暑假黄金24期,名额有限,报名请联系定制师。"
},
{
"id": "news-5",
"title": "呼籁营地迁入敖包相会景区,2026赛季全面升级",
@ -37,8 +46,8 @@
"title": "呼籁荣获小红书WILL商业大会「年度成长型品牌」",
"date": "2026-01-05",
"category": "活动",
"summary": "呼籁旅行在2026小红书WILL商业大会上荣获「年度成长型品牌」,全国文旅行业仅6家品牌获此荣誉。呼籁是其中唯一一个深耕单一目的地的本地品牌。",
"content": "在2026年小红书WILL商业大会上,呼籁旅行荣获「年度成长型品牌」称号。全国文旅行业仅6家品牌入选,呼籁是其中唯一一个深耕单一目的地的本地品牌。呼籁小红书账号「Mr.小雷-呼籁旅行」由联合创始人孙雷运营,目前拥有9.6万+粉丝。这份荣誉属于每一位愿意在小红书分享呼伦贝尔旅行故事的客户和伙伴。"
"summary": "呼籁旅行在2026小红书WILL商业大会上荣获「年度成长型品牌」,全国文旅行业仅6家品牌获此荣誉。呼籁是其中少数深耕单一目的地的本地品牌。",
"content": "在2026年小红书WILL商业大会上,呼籁旅行荣获「年度成长型品牌」称号。全国文旅行业仅6家品牌入选,呼籁是其中少数深耕单一目的地的本地品牌。呼籁小红书账号「Mr.小雷-呼籁旅行」由联合创始人孙雷运营,目前拥有9.6万+粉丝。这份荣誉属于每一位愿意在小红书分享呼伦贝尔旅行故事的客户和伙伴。"
}
]
}

查看文件

@ -63,7 +63,7 @@
},
{
"name": "黑山头景区",
"description": "呼籁自有骑马营地所在地,独家草原骑马体验",
"description": "呼籁自有骑马营地所在地,专属草原骑马体验",
"verified": true
},
{

查看文件

@ -52,7 +52,7 @@
"id": "v9-7d6n-family",
"name": "额吉的故乡V9·7天6晚 亲子版",
"priceLabel": "3680元起",
"priceNote": "舒适套餐3680-6280元/人,高档套餐4580-7980元/人,完整亲子深度版",
"priceNote": "舒适套餐3680-6280元/人,高档套餐4580-7980元/人,完整亲子深度版",
"highlights": [
"自有营地旅拍+非遗传承人授课",
"森林小火车+驯鹿+蒙古文化",

42
data/product-lines.json 普通文件
查看文件

@ -0,0 +1,42 @@
[
{
"key": "products",
"name": "额吉的故乡",
"version": "V9",
"season": "summer",
"type": "tour",
"sortOrder": 1
},
{
"key": "autumn-products",
"name": "游牧的森林",
"version": "V3",
"season": "autumn",
"type": "tour",
"sortOrder": 2
},
{
"key": "winter-products",
"name": "嗨冰雪",
"version": "V3",
"season": "winter",
"type": "tour",
"sortOrder": 3
},
{
"key": "winter-camp",
"name": "冬令营",
"version": "",
"season": "winter",
"type": "camp",
"sortOrder": 4
},
{
"key": "courses",
"name": "亲子研学课程",
"version": "",
"season": "all",
"type": "camp",
"sortOrder": 5
}
]

查看文件

@ -65,7 +65,7 @@
"days": 5,
"nights": 4,
"audience": "想深度体验草原又不想进森林的家庭",
"description": "不赶路的五天,独家资源·错峰安排。从牧云山顶到白桦林,从恩和骑马到中俄边防卡线,一路畅玩千米滑草、云朵牧场、非遗研学、篝火晚会。第四天解锁呼籁签约的秘境——寻龙诀取景地,下午游览套娃景区和大马戏,夜游满洲里。最后一天看巴尔虎马之舞实景演出,在呼和诺尔湖畔收官。4天平均约225公里/天,单日最长不超过270公里。",
"description": "不赶路的五天,专属资源·错峰安排。从牧云山顶到白桦林,从恩和骑马到中俄边防卡线,一路畅玩千米滑草、云朵牧场、非遗研学、篝火晚会。第四天解锁呼籁签约的秘境——寻龙诀取景地,下午游览套娃景区和大马戏,夜游满洲里。最后一天看巴尔虎马之舞实景演出,在呼和诺尔湖畔收官。4天平均约225公里/天,单日最长不超过270公里。",
"highlights": [
"牧云山顶茶歇,俯瞰莫日格勒河九曲蜿蜒",
"恩和沉浸式骑马穿越草原、湿地、森林、河流",
@ -123,7 +123,7 @@
"days": 6,
"nights": 5,
"audience": "专为带孩子来草原的家庭设计,主推路线",
"description": "不赶路的六天,独家资源·错峰安排。在呼籁自有营地享受专属旅拍和BBQ,错峰登牧云山顶俯瞰莫日格勒河。白桦林中由非遗传承人亲自授课桦树皮画,按年龄分层体验,零距离喂驯鹿。恩和沉浸式骑马穿越草原、湿地、森林、河流。沿中俄边防卡线畅玩千米滑草,在云朵牧场体验蒙古族文化——搭蒙古包、捡牛粪、熬奶茶、做羊毛毡。解锁寻龙诀秘境、套娃大马戏、马之舞实景演出。5天平均约200公里/天,单日最长不超过300公里。",
"description": "不赶路的六天,专属资源·错峰安排。在呼籁自有营地享受专属旅拍和BBQ,错峰登牧云山顶俯瞰莫日格勒河。白桦林中由非遗传承人亲自授课桦树皮画,按年龄分层体验,零距离喂驯鹿。恩和沉浸式骑马穿越草原、湿地、森林、河流。沿中俄边防卡线畅玩千米滑草,在云朵牧场体验蒙古族文化——搭蒙古包、捡牛粪、熬奶茶、做羊毛毡。解锁寻龙诀秘境、套娃大马戏、马之舞实景演出。5天平均约200公里/天,单日最长不超过300公里。",
"highlights": [
"呼籁自有营地——专属旅拍+茶点+BBQ",
"白桦林非遗传承人现场授课,按年龄分层体验",
@ -187,11 +187,11 @@
"days": 6,
"nights": 5,
"audience": "更适合大孩子与成人的进阶体验",
"description": "越野穿越、两次骑马、不走寻常路。6天5晚的进阶版本,在呼籁自有营地享受专属旅拍和BBQ,错峰登上牧云山顶。独家大力山穿越路线全程不走柏油路,穿越草原、湿地、野生白桦林。安排恩和+黑山头两次骑马——白桦林间漫步与草原驰骋完全不同的体验。还有呼籁专属秘境、风车公路、满洲里国门、马之舞实景演出。5天平均约226公里/天。",
"description": "越野穿越、两次骑马、不走寻常路。6天5晚的进阶版本,在呼籁自有营地享受专属旅拍和BBQ,错峰登上牧云山顶。专属大力山穿越路线全程不走柏油路,穿越草原、湿地、野生白桦林。安排恩和+黑山头两次骑马——白桦林间漫步与草原驰骋完全不同的体验。还有呼籁专属秘境、风车公路、满洲里国门、马之舞实景演出。5天平均约226公里/天。",
"highlights": [
"呼籁自有营地——专属旅拍+茶点+BBQ",
"牧云山顶下午茶歇,错峰俯瞰莫日格勒河",
"大力山独家穿越路线,全程不走柏油路",
"大力山专属穿越路线,全程不走柏油路",
"恩和+黑山头两次骑马,白桦林与草原两种体验",
"全地形越野车穿越,自由驰骋",
"呼籁专属秘境·寻龙诀取景地"
@ -232,7 +232,7 @@
"included": {
"accommodation": "5晚住宿舒适/高档可选)",
"transport": "一家一单一车,独立用车",
"tickets": "呼籁自有营地(不对外)、牧云山顶下午茶歇、乌兰山景区(含观光车)、套娃广场、呼籁秘境(独家专属)、满洲里国门+北疆明珠塔、呼和诺尔湖、巴尔虎马之舞",
"tickets": "呼籁自有营地(不对外)、牧云山顶下午茶歇、乌兰山景区(含观光车)、套娃广场、呼籁秘境(专属场地)、满洲里国门+北疆明珠塔、呼和诺尔湖、巴尔虎马之舞",
"activities": "呼籁旅拍(自营)、营地茶点+BBQ、全地形越野车、大力山穿越、两次骑马恩和+黑山头)、千米滑草畅玩、云朵牧场(萌宠互动)、篝火晚会"
},
"pricing": {
@ -250,7 +250,7 @@
"days": 7,
"nights": 6,
"audience": "专为带孩子来草原的家庭设计",
"description": "从草原到大兴安岭,完整的亲子深度版。7天6晚从容走遍呼伦贝尔精华,第一天在呼籁自有营地享受专属旅拍和BBQ,错峰登牧云山顶。白桦林中由非遗传承人亲自授课桦树皮画,按年龄分层体验。走进大兴安岭坐森林小火车、零距离喂驯鹿。沿边防卡线畅玩千米滑草,在云朵牧场体验蒙古族文化——搭蒙古包、捡牛粪、熬奶茶、做羊毛毡。解锁寻龙诀秘境、套娃大马戏、马之舞实景演出。7天平均约233公里/天。",
"description": "从草原到大兴安岭,完整的亲子深度版。7天6晚从容走遍呼伦贝尔精华,第一天在呼籁自有营地享受专属旅拍和BBQ,错峰登牧云山顶。白桦林中由非遗传承人亲自授课桦树皮画,按年龄分层体验。走进大兴安岭坐森林小火车、零距离喂驯鹿。沿边防卡线畅玩千米滑草,在云朵牧场体验蒙古族文化——搭蒙古包、捡牛粪、熬奶茶、做羊毛毡。解锁寻龙诀秘境、套娃大马戏、马之舞实景演出。7天平均约233公里/天。",
"highlights": [
"呼籁自有营地——专属旅拍+茶点+BBQ",
"白桦林非遗传承人现场授课,按年龄分层体验",
@ -300,7 +300,7 @@
"included": {
"accommodation": "6晚住宿舒适/高档可选)",
"transport": "一家一单一车,独立用车",
"tickets": "呼籁营地(自营不对外)、牧云山顶下午茶歇、白桦林(含门票+观光车)、兴安神鹿园(含门票+苔藓)、乌兰山景区(含观光车)、套娃广场(含娱乐门票)、呼籁秘境(独家专属)、巴尔虎蒙古部落(含马之舞)、呼和诺尔湖(含门票+观光车)",
"tickets": "呼籁营地(自营不对外)、牧云山顶下午茶歇、白桦林(含门票+观光车)、兴安神鹿园(含门票+苔藓)、乌兰山景区(含观光车)、套娃广场(含娱乐门票)、呼籁秘境(专属场地)、巴尔虎蒙古部落(含马之舞)、呼和诺尔湖(含门票+观光车)",
"activities": "呼籁旅拍(自营)、营地茶点+BBQ、白桦林非遗传承人手作课、全地形越野车往返12公里、恩和沉浸式骑马约1小时、莫尔道嘎森林小火车、千米滑草畅玩、云朵牧场萌宠互动、呼籁牧场蒙古族文化体验搭蒙古包+捡牛粪+熬奶茶+做羊毛毡)、篝火晚会、生日礼遇"
},
"pricing": {
@ -318,7 +318,7 @@
"days": 7,
"nights": 6,
"audience": "追求深度体验、热爱户外探索的家庭",
"description": "旷野探索,解锁常规线路到不了的地方。7天6晚硬核户外版本,住特色帐篷营地和草原木屋,体验装甲车穿越、额尔古纳湿地漂流、大力山越野穿越。在呼籁自有营地旅拍BBQ,牧云山顶喝下午茶。沿最美边防卡线789卡穿越,天边草原骑马。深入寻龙诀秘境配专业航拍,二卡湿地游船。套娃大马戏、马之舞实景演出一个不少。7天合计约1540公里,平均约220公里/天。",
"description": "旷野探索,解锁常规线路到不了的地方。7天6晚硬核户外版本,住特色帐篷营地和草原木屋,体验装甲车穿越、额尔古纳湿地漂流、大力山越野穿越。在呼籁自有营地旅拍BBQ,牧云山顶喝下午茶。沿著名边防卡线789卡穿越,天边草原骑马。深入寻龙诀秘境配专业航拍,二卡湿地游船。套娃大马戏、马之舞实景演出一个不少。7天合计约1540公里,平均约220公里/天。",
"highlights": [
"呼籁自有营地——专属旅拍+茶点+BBQ",
"装甲车穿越草地和溪流,肾上腺素拉满",
@ -347,7 +347,7 @@
{
"day": 4,
"title": "越野车·边境卡线·滑草·篝火",
"summary": "卡鲁奔→全地形越野车(专属场地)→室韦边境小镇→最美边防卡线789卡→乌兰山千米滑草→篝火晚会220公里"
"summary": "卡鲁奔→全地形越野车(专属场地)→室韦边境小镇→著名边防卡线789卡→乌兰山千米滑草→篝火晚会220公里"
},
{
"day": 5,
@ -368,7 +368,7 @@
"included": {
"accommodation": "6晚住宿含特色帐篷营地和草原木屋",
"transport": "一家一单一车,独立用车",
"tickets": "呼籁自有营地(不对外)、乌兰山景区(含观光车)、套娃广场(活动套票)、呼籁秘境(独家专属)、满洲里国门+北疆明珠塔、呼和诺尔湖、牧云山顶下午茶歇、马之舞、二卡游船、卡鲁奔山",
"tickets": "呼籁自有营地(不对外)、乌兰山景区(含观光车)、套娃广场(活动套票)、呼籁秘境(专属场地)、满洲里国门+北疆明珠塔、呼和诺尔湖、牧云山顶下午茶歇、马之舞、二卡游船、卡鲁奔山",
"activities": "呼籁旅拍(自营)、营地茶点+BBQ、大力山越野穿越、额尔古纳湿地漂流、装甲车穿越、全地形越野车专属场地、天边草原骑马穿越、千米滑草畅玩、云朵牧场萌宠互动、呼籁牧场蒙古族文化体验、篝火晚会、专业航拍、生日礼遇"
},
"pricing": {
@ -618,7 +618,7 @@
},
{
"question": "草原上如果孩子生病或受伤怎么办?",
"answer": "导游全部持有急救资质,随队配备急救包。特殊药品建议自己提前买好,当地药店品种有限。恩和、黑山头等乡镇设有卫生院,额尔古纳市有二甲医院,海拉尔有三甲医院(呼伦贝尔市人民医院)。如遇紧急情况,导游会第一时间处理并协调就近就医。"
"answer": "导游全部持有急救资质,随队配备急救包。特殊药品建议自己提前买好,当地药店品种有限。恩和、黑山头等乡镇设有卫生院,额尔古纳市有二甲医院,海拉尔有三甲医院(呼伦贝尔市人民医院)。如遇紧急情况,导游会及时处理并协调就近就医。"
}
],
"differenceFromV9": "小蒙马和额吉的故乡都是呼籁出品,同样6天5晚,同样用心,但设计思路不一样。小蒙马亲子游学营7-9组家庭拼团出发,孩子有同龄玩伴;两位亲子领队全程带队,家长可以当甩手掌柜;每天都有设计好的研学课程和亲子活动;全程跟拍摄影师,每天精修照片发群;餐食全含,不用操心吃什么。适合想让孩子交朋友、有收获,自己也能放松的家庭。额吉的故乡亲子版一单一团,你的家庭独享一辆车、一位领队;路线更远,含满洲里,覆盖草原+森林+边境+城市;正餐不含,可以自己探索当地美食;累计10000+家庭选择,国家版权局版权认证产品。适合喜欢自由、一家人按自己节奏走的家庭。"

90
data/section-titles.json 普通文件
查看文件

@ -0,0 +1,90 @@
{
"home": {
"productTree": { "title": "怎么找到适合你的行程", "label": "FIND YOUR TRIP", "subtitle": "按天数 · 按人群 · 按季节 三个维度快速定位" },
"knowledgeHub": { "title": "11年积累的呼伦贝尔知识库", "label": "KNOWLEDGE", "subtitle": "从规划到出发,每个问题都有答案" },
"faq": { "title": "常见问答", "label": "FAQ", "subtitle": "您可能想了解的问题" },
"productPreview": { "title": "旅行产品", "subtitle": "四季不同风景,总有一条线路适合你的家庭" },
"differentiator": { "title": "为什么选择呼籁", "label": "WHY HULAI", "subtitle": "不只是包车,是一套成熟的旅行服务体系" },
"reviewHighlights": { "title": "客户怎么说", "label": "REVIEWS", "subtitle": "来自真实家庭的旅行评价" }
},
"products": {
"v9Series": { "title": "额吉的故乡V9系列", "subtitle": "5-7天多版本行程" },
"evolutionStats": { "title": "产品进化", "subtitle": "从V1到V9,每一次更新都是客户反馈驱动" },
"upgrades": { "title": "2026年核心升级", "subtitle": "基于客户反馈的三大重磅升级" },
"highlights": { "title": "更多V9亮点", "subtitle": "专属资源 · 自营服务 · 全程无忧" },
"compareV8V9": { "title": "V8 → V9 升级对比", "subtitle": "7项关键升级一目了然" },
"philosophy": { "title": "9次迭代背后的产品哲学", "subtitle": "不是做加法,而是做取舍" },
"pricingPhilosophy": { "title": "关于价格", "subtitle": "透明、清晰、不套路" },
"selectionGuide": { "title": "不知道选哪个?", "subtitle": "按您的情况推荐" }
},
"productsAutumn": {
"highlights": { "title": "秋季呼伦贝尔 · 不可错过的风景", "subtitle": "每年9月中旬-10月中旬" },
"southLine": { "title": "南线 · 呼伦贝尔+阿尔山", "subtitle": "草原秋色与火山天池" },
"southHighlights": { "title": "南线独有体验", "subtitle": "阿尔山专属" },
"northLine": { "title": "北线 · 呼伦贝尔+大兴安岭", "subtitle": "深入大兴安岭腹地赏秋" },
"northHighlights": { "title": "北线独有体验", "subtitle": "大兴安岭专属" },
"selection": { "title": "不知道选哪个?", "subtitle": "按假期长度推荐" }
},
"productsWinter": {
"versions": { "title": "嗨冰雪V3系列", "subtitle": "5-7天南北两线,专属私家团" },
"highlights": { "title": "冬季行程亮点", "subtitle": "每一条线路都会体验到" },
"south": { "title": "南线特色 · 呼伦贝尔+阿尔山", "subtitle": "大雪原与童话小城的碰撞" },
"north": { "title": "北线特色 · 呼伦贝尔+大兴安岭", "subtitle": "边境风情与驯鹿部落的秘境" }
},
"summerCamp": {
"principles": { "title": "行程设计四大原则", "subtitle": "每一处安排都经过精心考量" },
"itinerary": { "title": "6天行程概览", "subtitle": "不赶路、不折腾,每一天都有记忆点" },
"activities": { "title": "行程核心体验", "subtitle": "草原写生·非遗研学·民俗体验,每一天都有收获" },
"compare": { "title": "小蒙马亲子游学营 vs 额吉的故乡", "subtitle": "两种产品,两种精彩" },
"faq": { "title": "家长常见问题", "subtitle": "" }
},
"winterCamp": {
"whyHulunbuir": { "title": "为什么是呼伦贝尔?", "subtitle": "不是更冷,而是更纯粹" },
"advantages": { "title": "亲子营的优势", "subtitle": "和独立小包团相比" },
"hotels": { "title": "住宿安排", "subtitle": "住得舒服,玩得才能惬意" },
"itinerary": { "title": "7天行程概览", "subtitle": "不赶路、不折腾,每一天都有记忆点" },
"photographer": { "title": "随行摄影师", "subtitle": "不错过每一个自然的瞬间" },
"clothing": { "title": "冬季穿衣指南", "subtitle": "穿对了,冷也是一种享受" },
"serviceConfig": { "title": "随行服务配置", "subtitle": "比夏季更谨慎、更细致" },
"faq": { "title": "家长常见问题", "subtitle": "" }
},
"qualifications": {
"safety": { "title": "服务保障", "subtitle": "三重旅行社资质 · 1000万责任险 · 正规电子合同 · 全程无购物" },
"awards": { "title": "荣誉与认证", "subtitle": "行业机构和平台对呼籁服务质量的认可" }
},
"courses": {
"byAge": { "title": "按年龄找适合的课程", "subtitle": "不同年龄段,有不同的学习目标和课程深度" },
"modules": { "title": "6大课程模块", "subtitle": "每个模块有明确的学习目标、活动设计和教育理念" },
"mentors": { "title": "课程导师团队", "subtitle": "自然教育导师 + 草原领队,全程陪同每一位孩子" },
"faq": { "title": "家长常见问题", "subtitle": "" }
},
"partners": {
"platform": { "title": "平台认证与荣誉", "subtitle": "官方认证账号,平台公开可查" },
"government": { "title": "政府资质与行业背书", "subtitle": "来自官方机构的认可与合作" },
"universities": { "title": "校企合作", "subtitle": "联合高校,培养本地旅游人才" },
"scenic": { "title": "景区合作", "subtitle": "与呼伦贝尔核心景区深度合作,确保专属体验" },
"media": { "title": "媒体报道", "subtitle": "第三方媒体对呼籁旅行的报道与采访" }
},
"contact": {
"primary": { "title": "立即联系", "subtitle": "微信或电话,开启你的呼伦贝尔之旅" },
"more": { "title": "更多联系方式", "subtitle": "商务合作、投诉建议、加入团队" }
},
"gallery": {
"intro": { "title": "呼籁旅拍 · 草原动人的记忆", "subtitle": "随行拍摄,无需单独预约,每组旅拍都有独特的故事" },
"works": { "title": "旅拍作品", "subtitle": "每一帧都是真实客户的草原记忆" }
},
"pricing": {
"reference": { "title": "产品线价格参考", "subtitle": "6条定制游线路 + 1个亲子游学营 + 秋季产品" },
"explanation": { "title": "费用说明", "subtitle": "所有产品遵循同一套费用标准" }
},
"aboutPage": {
"culture": { "title": "呼籁文化理念", "subtitle": "" },
"xiaohongshu": { "title": "小红书上的呼籁", "subtitle": "XIAOHONGSHU" },
"team": { "title": "我们的团队", "subtitle": "" },
"copyright": { "title": "知识产权", "subtitle": "商标全品类注册 + 4项国家版权登记" },
"qualCert": { "title": "资质认证", "subtitle": "" },
"qualService": { "title": "服务保障", "subtitle": "" },
"cultureValues": { "title": "企业文化", "subtitle": "" },
"mascot": { "title": "品牌IP · 籁小咩", "subtitle": "LAI XIAO MIE" }
}
}

查看文件

@ -2,7 +2,7 @@
"questions": [
{
"id": "q1",
"hint": "根据你的假期长度,为你匹配合适的行程版本",
"hint": "根据你的假期长度,为你匹配合适的行程版本",
"text": "你打算玩几天?",
"options": [
{ "desc": "草原+森林 或 草原+满洲里", "label": "5天", "value": "5天" },
@ -40,7 +40,7 @@
"options": [
{ "desc": "控制预算,好玩就行", "label": "经济实惠", "value": "经济实惠" },
{ "desc": "住得好,玩得舒心", "label": "品质优先", "value": "品质优先" },
{ "desc": "最好的体验,价格不是问题", "label": "不差钱", "value": "不差钱" }
{ "desc": "追求高品质体验,价格不是问题", "label": "不差钱", "value": "不差钱" }
]
},
{
@ -50,7 +50,7 @@
"options": [
{ "desc": "骑马、非遗手作、蒙古文化、旅拍", "label": "亲子互动", "value": "亲子互动" },
{ "desc": "草原、森林、湿地、界河", "label": "自然风光", "value": "自然风光" },
{ "desc": "装甲车、漂流、帐篷营地、越野穿越", "label": "极致冒险", "value": "极致冒险" },
{ "desc": "装甲车、漂流、帐篷营地、越野穿越", "label": "户外冒险", "value": "户外冒险" },
{ "desc": "骑马穿越、大力山探索、秘境航拍", "label": "深度探索", "value": "深度探索" }
]
}
@ -81,7 +81,7 @@
"q5": {
"亲子互动": { "v9-6d5n-family": 10, "v9-7d6n-family": 8, "v9-5d4n-forest": 3 },
"自然风光": { "v9-5d4n-forest": 8, "v9-5d4n-prairie": 6, "v9-7d6n-family": 4 },
"极致冒险": { "v9-7d6n-wild": 10, "v9-6d5n-explore": 5 },
"户外冒险": { "v9-7d6n-wild": 10, "v9-6d5n-explore": 5 },
"深度探索": { "v9-6d5n-explore": 9, "v9-7d6n-wild": 6, "v9-7d6n-family": 4 }
}
},
@ -89,8 +89,8 @@
"v9-5d4n-forest": {
"default": "草原+大兴安岭森林双线深度,5天走遍两种风光",
"q1:5天": "5天假期想看草原又想进森林——森林版专为这种需求设计",
"q4:经济实惠": "5天4晚起步价亲民,森林小火车+驯鹿+骑马一个不少",
"q5:自然风光": "草原+白桦林+大兴安岭+森林小火车+驯鹿园,自然风光层次丰富"
"q4:经济实惠": "5天4晚起步价亲民,森林小火车+驯鹿+骑马一个不少",
"q5:自然风光": "草原+白桦林+大兴安岭+森林小火车+驯鹿园,自然风光层次丰富"
},
"v9-5d4n-prairie": {
"default": "不进森林,走草原+边境+满洲里环线,错峰安排",
@ -100,34 +100,34 @@
"v9-6d5n-family": {
"default": "6天5晚亲子主推版,自有营地旅拍+非遗手作+骑马越野+蒙古文化,专为带孩子的家庭设计",
"q1:6天": "6天时间刚好——自有营地旅拍+非遗传承人授课+骑马越野+蒙古文化体验全覆盖",
"q3:3-6岁": "非遗传承人按年龄分层教学+搭蒙古包+熬奶茶+做羊毛毡,学前宝宝爱的版本",
"q3:3-6岁": "非遗传承人按年龄分层教学+搭蒙古包+熬奶茶+做羊毛毡,学前宝宝爱的版本",
"q3:6-12岁": "骑马穿越+越野车+白桦林非遗手作+蒙古文化四合一,每天都有新体验",
"q3:3岁以下": "行程节奏舒缓,单日最长不超300公里,小宝宝也能轻松跟上",
"q5:亲子互动": "非遗传承人授课+搭蒙古包+捡牛粪+熬奶茶+做羊毛毡+专属亲子手册,亲子互动丰富",
"q3:3岁以下": "行程节奏舒缓,单日不超300公里,小宝宝也能轻松跟上",
"q5:亲子互动": "非遗传承人授课+搭蒙古包+捡牛粪+熬奶茶+做羊毛毡+专属亲子手册,亲子互动丰富",
"q2:带孩子的家庭": "主推亲子版——非遗按年龄分层、蒙古文化还原真实生活、专属亲子手册记录旅途发现"
},
"v9-6d5n-explore": {
"default": "越野穿越、两次骑马、不走寻常路,更适合大孩子与成人的进阶体验",
"q1:6天": "6天时间够从容——自有营地旅拍+大力山穿越+两次骑马+秘境探索",
"q2:情侣": "大力山穿越+两次骑马+秘境航拍+风车公路,很适合追求深度的两人世界",
"q3:12岁以上": "两次骑马+全地形越野车+大力山穿越,大孩子爱的进阶版本",
"q3:12岁以上": "两次骑马+全地形越野车+大力山穿越,大孩子爱的进阶版本",
"q5:深度探索": "大力山穿越全程不走柏油路+两次骑马+寻龙诀秘境,探索感十足"
},
"v9-7d6n-family": {
"default": "完整亲子深度版,自有营地旅拍+非遗手作+森林小火车+蒙古文化体验",
"q1:7天": "7天假期值得完整的行程——草原、森林、边境、满洲里全覆盖",
"q3:3-6岁": "非遗传承人按年龄分层教学+搭蒙古包+熬奶茶+做羊毛毡,内容丰富",
"default": "完整亲子深度版,自有营地旅拍+非遗手作+森林小火车+蒙古文化体验",
"q1:7天": "7天假期值得完整的行程——草原、森林、边境、满洲里全覆盖",
"q3:3-6岁": "非遗传承人按年龄分层教学+搭蒙古包+熬奶茶+做羊毛毡,内容丰富",
"q3:6-12岁": "森林小火车+驯鹿+白桦林非遗手作+蒙古文化体验,每天都有新鲜感",
"q5:亲子互动": "非遗传承人授课+搭蒙古包+捡牛粪+熬奶茶+做羊毛毡,亲子互动丰富",
"q5:亲子互动": "非遗传承人授课+搭蒙古包+捡牛粪+熬奶茶+做羊毛毡,亲子互动丰富",
"q2:带孩子的家庭": "专为带孩子的家庭设计,非遗按年龄分层,蒙古文化还原真实生活场景"
},
"v9-7d6n-wild": {
"default": "旷野探索,装甲车穿越+湿地漂流+帐篷营地+专业航拍,解锁常规线路到不了的地方",
"q1:7天": "7天硬核户外——住帐篷营地和草原木屋,装甲车+漂流+航拍全部安排",
"q2:朋友": "和好友一起装甲车穿越、湿地漂流、秘境航拍,肾上腺素拉满",
"q4:不差钱": "全程特色住宿+装甲车+漂流+航拍+自有营地旅拍,体验价值最高",
"q3:12岁以上": "装甲车穿越+湿地漂流+全地形越野车+帐篷营地,硬核的户外版本",
"q5:极致冒险": "装甲车穿越草地溪流+额尔古纳湿地漂流+寻龙诀秘境航拍,极致冒险之选"
"q4:不差钱": "全程特色住宿+装甲车+漂流+航拍+自有营地旅拍,体验价值丰厚",
"q3:12岁以上": "装甲车穿越+湿地漂流+全地形越野车+帐篷营地,硬核的户外版本",
"q5:户外冒险": "装甲车穿越草地溪流+额尔古纳湿地漂流+寻龙诀秘境航拍,户外冒险之选"
}
}
}

查看文件

@ -43,10 +43,10 @@
},
"guides": {
"title": "呼伦贝尔出行指南 - 穿搭·行李·活动安全·幼童专属攻略 - 呼籁旅行",
"description": "呼籁旅行出品的呼伦贝尔出行指南:各月穿搭建议(含气温数据)、行李清单、骑马/ATV/滑草安全须知、0-3岁幼童活动推荐、实用信息汇总。",
"description": "呼籁旅行出品的呼伦贝尔出行指南:各月穿搭建议(含气温数据)、行李清单、骑马/全地形越野车/滑草安全须知、0-3岁幼童活动推荐、实用信息汇总。",
"h1": "呼伦贝尔出行指南",
"ogImage": "/images/og-guides.jpg",
"keywords": "呼伦贝尔穿搭指南,呼伦贝尔行李清单,呼伦贝尔几月份去最好,呼伦贝尔骑马安全,草原旅游注意事项"
"keywords": "呼伦贝尔穿搭指南,呼伦贝尔行李清单,呼伦贝尔几月份去合适,呼伦贝尔骑马安全,草原旅游注意事项"
},
"summer-camp": {
"title": "小蒙马亲子游学营 - 呼伦贝尔草原亲子游学营·6天5晚 - 呼籁旅行",
@ -63,9 +63,9 @@
"keywords": "呼籁旅行资质,旅行社经营许可证,旅游责任险,呼伦贝尔旅行社认证,呼籁荣誉"
},
"news": {
"title": "最新动态 - 品牌动态·产品发布·活动公告 - 呼籁旅行",
"description": "呼籁旅行最新动态:品牌公告、产品发布、活动资讯,第一时间了解呼伦贝尔亲子游最新资讯。",
"h1": "最新动态",
"title": "品牌资讯 - 品牌动态·产品发布·活动公告 - 呼籁旅行",
"description": "呼籁旅行品牌资讯:品牌公告、产品发布、活动资讯,及时了解呼伦贝尔亲子游相关资讯。",
"h1": "品牌资讯",
"ogImage": "/images/og-about.jpg",
"keywords": "呼籁旅行动态,呼伦贝尔旅游资讯,呼籁旅行公告"
},
@ -77,11 +77,11 @@
"keywords": "呼籁旅行联系方式,呼伦贝尔定制游咨询,呼籁旅行微信,呼伦贝尔旅游电话"
},
"products-autumn": {
"title": "游牧的森林 - 呼伦贝尔秋季定制游·大兴安岭秋色 - 呼籁旅行",
"description": "呼籁旅行秋季产品「游牧的森林」,大兴安岭白桦林金黄秋色、额尔古纳湿地、中俄边境风光。9月中旬至国庆期间出发,一家一单一车,错过再等一年。",
"title": "游牧的森林V3 - 呼伦贝尔秋季定制游·4-8天7版本可选 - 呼籁旅行",
"description": "呼籁旅行秋季产品「游牧的森林」V3系列,南线呼伦贝尔+阿尔山、北线大兴安岭深度、南北8天7晚全景版,4-8天共7个版本可选。一家一单一车,9月中旬至国庆出发。",
"h1": "游牧的森林 · 呼伦贝尔秋季定制游",
"ogImage": "/images/og-products.jpg",
"keywords": "呼伦贝尔秋季旅游,大兴安岭秋色,游牧的森林,呼伦贝尔秋天,白桦林秋景,额尔古纳秋季"
"keywords": "呼伦贝尔秋季旅游,大兴安岭秋色,游牧的森林,呼伦贝尔秋天,白桦林秋景,额尔古纳秋季,呼伦贝尔8天,南北全景,阿尔山秋季,满洲里秋季"
},
"products-winter": {
"title": "嗨冰雪 - 呼伦贝尔冬季定制游·冰雪那达慕·驯鹿部落 - 呼籁旅行",
@ -92,24 +92,24 @@
},
"gallery": {
"title": "呼籁旅拍 - 草原蒙古袍亲子旅拍·200+服饰·自营营地取景 - 呼籁旅行",
"description": "呼籁旅行自营旅拍服务,200+件蒙古族服饰,在呼籁自营营地独家取景。亲子旅拍、儿童写真、全家福,摄影师随行拍摄,精修照片当日交付。",
"description": "呼籁旅行自营旅拍服务,200+件蒙古族服饰,在呼籁自营营地特色取景。亲子旅拍、儿童写真、全家福,摄影师随行拍摄,精修照片当日交付。",
"h1": "呼籁旅拍",
"ogImage": "/images/og-products.jpg",
"keywords": "草原旅拍,呼伦贝尔旅拍,蒙古袍旅拍,亲子旅拍,呼籁营地旅拍,草原亲子写真"
},
"destinations": {
"title": "亲子游目的地对比 - 呼伦贝尔vs新疆vs内蒙西部 - 呼籁旅行",
"description": "基于真实数据的亲子游目的地客观对比:气温、车程、海拔、景观密度、亲子适配度、费用参考。帮你找到适合带孩子去的目的地。",
"description": "基于真实数据的亲子游目的地客观对比:气温、车程、海拔、景观密度、亲子适配度、费用参考。帮你找到适合带孩子去的目的地。",
"h1": "亲子游目的地怎么选",
"ogImage": "/images/og-products.jpg",
"keywords": "亲子游目的地,呼伦贝尔和新疆哪个好,带孩子去哪旅游,呼伦贝尔vs新疆,草原旅游对比"
},
"calendar": {
"title": "呼伦贝尔最佳出行月份 - 各月景色·气温·穿搭全攻略 - 呼籁旅行",
"description": "基于呼籁旅行11年带队经验,详解呼伦贝尔5-10月各月景色特点、气温数据、穿搭建议和出行性价比,帮你选择合适的出行时间。",
"h1": "呼伦贝尔最佳出行月份",
"title": "呼伦贝尔出行月份参考 - 各月景色·气温·穿搭全攻略 - 呼籁旅行",
"description": "基于呼籁旅行11年带队经验,详解呼伦贝尔5-10月各月景色特点、气温数据、穿搭建议和出行性价比,帮你选择合适的出行时间。",
"h1": "呼伦贝尔出行月份参考",
"ogImage": "/images/og-guides.jpg",
"keywords": "呼伦贝尔几月去最好,呼伦贝尔最佳旅游时间,呼伦贝尔各月气温,呼伦贝尔旅游季节,草原什么时候去"
"keywords": "呼伦贝尔几月去合适,呼伦贝尔推荐旅游时间,呼伦贝尔各月气温,呼伦贝尔旅游季节,草原什么时候去"
},
"xiaohongshu": {
"title": "小红书游记墙 - 客户真实分享·呼伦贝尔亲子游 - 呼籁旅行",
@ -141,7 +141,7 @@
},
"selector": {
"title": "选版本 - 哪个额吉的故乡版本适合你家 - 呼籁旅行",
"description": "通过几个简单问题,帮你从额吉的故乡V9的6个版本中找到适合你家的行程方案。按假期天数、孩子年龄、出行偏好智能推荐。",
"description": "通过几个简单问题,帮你从额吉的故乡V9的6个版本中找到适合你家的行程方案。按假期天数、孩子年龄、出行偏好智能推荐。",
"h1": "哪个版本适合你",
"ogImage": "/images/og-products.jpg",
"keywords": "额吉的故乡哪个版本好,呼伦贝尔几天合适,呼伦贝尔行程推荐,亲子游选哪个版本"

查看文件

@ -69,7 +69,7 @@
"非遗传承人手把手教学 · 自营场地"
],
[
"ATV穿越(公共场地)",
"全地形越野车穿越(公共场地)",
"全地形越野车 · 呼籁自营运营"
],
[
@ -94,11 +94,11 @@
"changes": [
{
"type": "add",
"text": "独家私家牧场旅拍(从采购资源到自营)"
"text": "专属私家牧场旅拍(从采购资源到自营)"
},
{
"type": "add",
"text": "独家白桦林专场研学10:00-11:00 黄金时段)"
"text": "专属白桦林专场研学10:00-11:00 黄金时段)"
}
],
"reason": "第二年旺季路线打磨到第8版,我们意识到真正的品质不能依赖外部供应商。于是自建牧场、锁定白桦林专场时段——从采购到自营,品质自己把控。这一步,让呼籁的服务体系从概念变成了现实。"
@ -126,18 +126,18 @@
{
"version": "V6",
"date": "2023.07.26",
"title": "用独家秘境替代常规体验",
"title": "用专属秘境替代常规体验",
"changes": [
{
"type": "add",
"text": "草原腹地寻龙诀拍摄地(独家观赏点,常规行程覆盖不到)"
"text": "草原腹地寻龙诀拍摄地(专属观赏点,常规行程覆盖不到)"
},
{
"type": "remove",
"text": "森林研学寻迹探秘(旺季客流量大,体验打折扣)"
}
],
"reason": "旺季实测:进入盛夏旺季后,森林寻迹探秘因为游客多、体验不够深度,和我们想要的「独家感」差距太大。正好我们找到了草原腹地的寻龙诀拍摄地——一个常规行程根本到不了的地方,果断替换。"
"reason": "旺季实测:进入盛夏旺季后,森林寻迹探秘因为游客多、体验不够深度,和我们想要的「专属感」差距太大。正好我们找到了草原腹地的寻龙诀拍摄地——一个常规行程根本到不了的地方,果断替换。"
},
{
"version": "V5",
@ -146,7 +146,7 @@
"changes": [
{
"type": "add",
"text": "ATV全地形越野车穿越第一湿地(近距离到达「亚洲第一湿地」)"
"text": "全地形越野车穿越第一湿地(近距离到达「亚洲第一湿地」)"
},
{
"type": "add",
@ -157,7 +157,7 @@
"text": "额尔古纳第一湿地景区(木栈道太长对儿童老人不友好)"
}
],
"reason": "客户反馈驱动:连续几团客人反映湿地木栈道太长,老人走不动、孩子喊累。我们没有删掉这个点位,而是换了到达方式——ATV穿越,全家人用更有趣的方式亲近湿地。同一周,我们还加上了旅途生日蛋糕惊喜,因为细节里藏着温度。"
"reason": "客户反馈驱动:连续几团客人反映湿地木栈道太长,老人走不动、孩子喊累。我们没有删掉这个点位,而是换了到达方式——全地形越野车穿越,全家人用更有趣的方式亲近湿地。同一周,我们还加上了旅途生日蛋糕惊喜,因为细节里藏着温度。"
},
{
"version": "V4",
@ -239,7 +239,7 @@
},
{
"label": "体验安全化",
"text": "宁可砍项目也不降体验,孩子的安全永远是第一位"
"text": "宁可砍项目也不降体验,孩子的安全始终是首位"
},
{
"label": "服务标准化",

查看文件

@ -9,7 +9,7 @@
"deposit": 2000,
"season": "冬季11月-次年3月",
"route": "海拉尔 → 阿尔山 → 金江沟阿尔善 → 海拉尔",
"whyHulunbuir": "到北方看雪,很多人第一反应是哈尔滨。冰雕大、亮、好看,航班多,项目也密集。但热闹的反面是去哪都排队,假期被人流和「同款路线」填满。呼伦贝尔的冬天没有那么多「摆拍打卡点」,最打动人的就是那种一望无际的白:雪原铺到天际,风吹过来很干净,脚下踩雪的「咯吱」声,会让人一下子安静下来。不拥挤,不排队,也不赶时间。对带孩子的家庭来说,这种「纯粹的舒服」,反而比什么都值得。",
"whyHulunbuir": "到北方看雪,很多人首先想到哈尔滨。冰雕大、亮、好看,航班多,项目也密集。但热闹的反面是去哪都排队,假期被人流和「同款路线」填满。呼伦贝尔的冬天没有那么多「摆拍打卡点」,令人印象深刻的就是那种一望无际的白:雪原铺到天际,风吹过来很干净,脚下踩雪的「咯吱」声,会让人一下子安静下来。不拥挤,不排队,也不赶时间。对带孩子的家庭来说,这种「纯粹的舒服」,别有一番滋味。",
"campAdvantages": [
"孩子有年龄相仿的伙伴,有领队带着玩,不用一直黏着爸妈",
"每个环节都有安排、有照看,有专业研学领队看管孩子,孩子在玩的时候也在接触书本外的世界",
@ -27,13 +27,13 @@
"name": "阿尔山成悦大酒店",
"star": "携程4钻",
"nights": "D3+D6",
"description": "阿尔山市区最好的酒店之一,房间宽敞,早餐丰富。住得简单省心,回到房间就能放松。"
"description": "阿尔山市区品质不错的酒店,房间宽敞,早餐丰富。住得简单省心,回到房间就能放松。"
},
{
"name": "金江沟阿尔善温泉酒店",
"star": "携程5钻",
"nights": "D4-D5连住",
"description": "行程中特别的两晚。房间外就是独立泡池,不用预约不用排队。白天滑完雪,回房间就能泡温泉,外面是雪林,池子里是热气,反差感特别舒服。"
"description": "行程中特别的两晚。房间外就是独立泡池,不用预约不用排队。白天滑完雪,回房间就能泡温泉,外面是雪林,池子里是热气,反差感特别舒服。"
}
],
"itinerary": [
@ -93,7 +93,7 @@
"highlights": [
"乌苏浪子湖雾凇:树木被白霜包住,整片林子像被点亮,可遇不可求的自然奇观",
"不冻河漂流:零下二三十度的皮划艇,有机会拍到雾凇+喝水的牛,孩子的生态课",
"孩子托管研学制作冰灯、DIY太阳花,家长完全自由——泡温泉、补觉、散步",
"孩子托管研学制作冰灯、DIY太阳花,家长自由安排——泡温泉、补觉、散步",
"篝火+烟花之夜:脚下白雪,炽热火焰,绚烂烟花,满眼星辰"
],
"hotel": "金江沟阿尔善温泉酒店"
@ -103,7 +103,7 @@
"title": "驯鹿 · 越野车穿越林海雪原 · 冰糖葫芦 · 闭营",
"summary": "喂驯鹿 → 坦克300穿越林海 → DIY冰糖葫芦 → 包饺子 → 闭营仪式",
"highlights": [
"近距离接触驯鹿:喂食、摸鹿角、拍照,孩子爱的软萌体验",
"近距离接触驯鹿:喂食、摸鹿角、拍照,孩子爱的软萌体验",
"坦克300越野穿越林海雪原拐进林海深处,感受冬天的林海长什么样",
"DIY冰糖葫芦从蘸糖到糖衣凝固全程动手",
"包饺子+闭营仪式:体验北方年味,颁发冬令营纪念徽章"
@ -125,7 +125,7 @@
"photographer": {
"name": "小E",
"description": "夏季小蒙马11期的全能摄影师。不是那种拿着相机「拍你」的摄影师,更像一个能瞬间融入孩子世界的大哥哥。孩子们超级黏他,能捕捉到很多大人看不到的瞬间——自然、不刻意、带着真情绪的画面。",
"specialDay2": "行程第二天(大雪原日)额外增加一位摄影师协作,确保每个家庭在最好的光线、最舒服的状态下拍到属于自己的大片,并为每个家庭拍一支小Vlog。"
"specialDay2": "行程第二天(大雪原日)额外增加一位摄影师协作,确保每个家庭在理想光线、舒服的状态下拍到属于自己的大片,并为每个家庭拍一支小Vlog。"
},
"winterClothing": {
"upper": "保暖内衣(排汗)+ 抓绒/毛衣/卫衣做中层 + 滑雪服或防风保暖棉服/羽绒服",
@ -149,19 +149,19 @@
"faq": [
{
"q": "呼伦贝尔冬天冷不冷?孩子受得了吗?",
"a": "冷是肯定的,海拉尔、阿尔山冬季-20\u2103~-35\u2103为常态。但冷不等于「难受」。户外活动时,只要节奏安排得好、活动够有趣,孩子很少喊冷。真正怕冷的是「站着等、排队、干耗着」,我们把干耗着的时间压到最低。按照穿衣指南准备装备,完全没问题。"
"a": "冷是肯定的,海拉尔、阿尔山冬季-20\u2103~-35\u2103为常态。但冷不等于「难受」。户外活动时,只要节奏安排得好、活动够有趣,孩子很少喊冷。真正怕冷的是「站着等、排队、干耗着」,我们把干耗着的时间尽量压缩。按照穿衣指南准备装备,通常没问题。"
},
{
"q": "冬季营需要家长陪同吗?",
"a": "是的,小蒙马是亲子营,爸爸妈妈带着孩子一起出发。行程中D5特别设计了「孩子托管研学」时段——专业领队带孩子做冰灯、太阳花,家长可以完全自由:泡温泉、补觉、散步都行。"
"a": "是的,小蒙马是亲子营,爸爸妈妈带着孩子一起出发。行程中D5特别设计了「孩子托管研学」时段——专业领队带孩子做冰灯、太阳花,家长可以自由安排:泡温泉、补觉、散步都行。"
},
{
"q": "为什么选呼伦贝尔而不是哈尔滨?",
"a": "哈尔滨冰雕大、亮、好看,第一次看雪没毛病。但如果心里更想看一场真正纯净的雪,不想被人群裹挟着走,呼伦贝尔会更适合。一望无际的白,随便站在哪儿都有一大片只属于自己的雪原。不拥挤,不排队,也不赶时间。"
"a": "哈尔滨冰雕大、亮、好看,次看雪没毛病。但如果心里更想看一场真正纯净的雪,不想被人群裹挟着走,呼伦贝尔会更适合。一望无际的白,随便站在哪儿都有一大片只属于自己的雪原。不拥挤,不排队,也不赶时间。"
},
{
"q": "行程会因天气调整吗?",
"a": "冬季受天气影响较大。如遇暴雪、大风(≥6级)、道路封控、极端低温(-30\u2103以下)等情况,我们会调整体验顺序或进行替代项目。安全第一,不会在不安全的条件下带孩子户外体验。"
"a": "冬季受天气影响较大。如遇暴雪、大风(≥6级)、道路封控、极端低温(-30\u2103以下)等情况,我们会调整体验顺序或进行替代项目。以安全为先,不会在不安全的条件下带孩子户外体验。"
},
{
"q": "定金和退改政策是什么?",

查看文件

@ -145,7 +145,7 @@
},
{
"title": "呼伦湖穿越+蓝冰",
"description": "呼伦湖是我国北方第一大湖,一旦进入冬季千里冰封、万里雪飘的时候,呼伦湖就会变成一整块巨冰;湖面穿越+寻找蓝冰的体验活动,乘车行驶在一望无际的冰面上,是一次特别且难忘的旅行体验。"
"description": "呼伦湖是我国北方,一旦进入冬季千里冰封、万里雪飘的时候,呼伦湖就会变成一整块巨冰;湖面穿越+寻找蓝冰的体验活动,乘车行驶在一望无际的冰面上,是一次特别且难忘的旅行体验。"
}
],
"selectionGuide": {

查看文件

@ -9,7 +9,6 @@
<div class="logo-icon">H</div>
<div class="logo-text">
<h2>呼籁后台</h2>
<span>hulai.com</span>
</div>
</div>
@ -92,19 +91,43 @@ function submissionLabel() {
const menuOptions = computed(() => {
return [
{ label: menuLabel('数据概览'), key: '/admin' },
{ label: menuLabel('产品线管理'), key: '/admin/products' },
{ label: menuLabel('产品选择器'), key: '/admin/content/selector' },
{ label: submissionLabel(), key: '/admin/submissions' },
{ type: 'divider' },
// 线线
{
label: menuLabel('产品线'), key: 'g-products',
children: [
{ label: menuLabel('产品线总览'), key: '/admin/products' },
{ label: menuLabel('额吉的故乡 (夏·V9)'), key: '/admin/products/products' },
{ label: menuLabel('游牧的森林 (秋·V3)'), key: '/admin/products/autumn-products' },
{ label: menuLabel('嗨冰雪 (冬·V3)'), key: '/admin/products/winter-products' },
{ label: menuLabel('小蒙马亲子游学营'), key: '/admin/products/products' },
{ label: menuLabel('小蒙马冬令营'), key: '/admin/content/winter-camp' },
{ label: menuLabel('选版工具'), key: '/admin/content/selector' },
{ label: menuLabel('亲子研学课程'), key: '/admin/content/courses' },
{ label: menuLabel('价格配置'), key: '/admin/content/pricing' },
],
},
//
{
label: menuLabel('目的地'), key: 'g-destinations',
children: [
{ label: menuLabel('景点对比'), key: '/admin/content/destinations' },
{ label: menuLabel('景点详情'), key: '/admin/content/destinations-detail' },
{ label: menuLabel('首页目的地对比'), key: '/admin/content/destinations' },
{ label: menuLabel('旅行日历'), key: '/admin/content/calendar' },
{ label: menuLabel('出行指南'), key: '/admin/content/guides' },
{ label: menuLabel('定制游怎么选 (文章)'), key: '/admin/content/guide-custom' },
],
},
//
{ label: menuLabel('旅行攻略'), key: '/admin/blogs' },
//
{
label: menuLabel('客户口碑'), key: 'g-reviews',
label: menuLabel('客户评价'), key: 'g-reviews',
children: [
{ label: menuLabel('客户评价'), key: '/admin/reviews' },
{ label: menuLabel('小红书游记'), key: '/admin/content/xiaohongshu-wall' },
@ -113,26 +136,35 @@ const menuOptions = computed(() => {
{ label: menuLabel('客户故事'), key: '/admin/stories' },
],
},
//
{ label: menuLabel('常见问答'), key: '/admin/faqs' },
//
{
label: menuLabel('关于我们'), key: 'g-about',
children: [
{ label: menuLabel('关于呼籁'), key: '/admin/content/about' },
{ label: menuLabel('呼籁旅行百科'), key: '/admin/content/brand-wiki' },
{ label: menuLabel('呼籁旅行'), key: '/admin/content/brand-wiki' },
{ label: menuLabel('呼籁文旅集团'), key: '/admin/content/company-wiki' },
{ label: menuLabel('最新动态'), key: '/admin/articles' },
{ label: menuLabel('品牌资讯'), key: '/admin/articles' },
{ label: menuLabel('资质荣誉'), key: '/admin/content/qualifications' },
{ label: menuLabel('合作伙伴'), key: '/admin/content/partners' },
],
},
//
{ label: menuLabel('联系我们'), key: '/admin/content/contact' },
{ label: menuLabel('博客文章'), key: '/admin/blogs' },
{ label: submissionLabel(), key: '/admin/submissions' },
{ type: 'divider' },
//
{
label: menuLabel('系统设置'), key: 'g-settings',
children: [
{ label: menuLabel('导航菜单'), key: '/admin/navigation' },
{ label: menuLabel('页面副标题'), key: '/admin/content/heroes' },
{ label: menuLabel('栏目标题'), key: '/admin/content/section-titles' },
{ label: menuLabel('SEO 配置'), key: '/admin/content/seo' },
{ label: menuLabel('品牌信息'), key: '/admin/content/brand' },
{ label: menuLabel('图片配置'), key: '/admin/content/images' },
@ -168,11 +200,11 @@ const activeKey = computed(() => {
const expandedKeys = ref([])
const groupMappings = {
'g-products': [],
'g-destinations': ['destinations', 'calendar', 'guides'],
'g-products': ['products', 'pricing', 'courses', 'winter-camp'],
'g-destinations': ['destinations', 'calendar', 'guides', 'guide-custom'],
'g-reviews': ['reviews', 'xiaohongshu', 'youji', 'gallery', 'stories'],
'g-about': ['about', 'articles', 'qualifications', 'partners', 'brand-wiki', 'company-wiki'],
'g-settings': ['navigation', 'seo', 'brand', 'images', 'versions', 'customize'],
'g-settings': ['navigation', 'seo', 'brand', 'images', 'versions', 'customize', 'heroes', 'section-titles'],
}
watchEffect(() => {

查看文件

@ -6,6 +6,7 @@
<slot />
</main>
<LayoutAppFooter />
<CommonFloatingContact />
</div>
</template>

查看文件

@ -3,36 +3,13 @@ export default defineNuxtConfig({
ssr: true,
nitro: {
// preset: 'static' 已移除——静态模式下 server/api/ 不可用。
// 部署时使用 `nuxt build` + Node.js 服务器(如 PM2
// 若必须静态部署,需将表单改为第三方服务(飞书/EmailJS 等)
// 部署:`nuxt build` + Node.js 服务器(如 PM2;纯 SSR 模式,每次请求即时渲染
// 移除 prerender 路由列表——原静态化会把内容烘焙进 HTML,导致后台编辑不同步到前端
// 仅保留 sitemap.xml 静态化
prerender: {
failOnError: false,
crawlLinks: true,
routes: [
'/', '/about', '/products', '/summer-camp', '/winter-camp', '/courses', '/guides', '/guide-custom', '/faq', '/reviews', '/contact', '/customize', '/pricing', '/selector', '/sitemap.xml',
'/products/v9-5d4n-forest', '/products/v9-5d4n-prairie',
'/products/v9-6d5n-family', '/products/v9-6d5n-explore',
'/products/v9-7d6n-family', '/products/v9-7d6n-wild',
'/products/autumn', '/products/winter',
'/brand', '/company',
'/gallery', '/news', '/calendar', '/stories', '/xiaohongshu', '/youji',
'/blog',
'/blog/hulunbuir-travel-guide-2026',
'/blog/hulunbuir-family-travel-guide',
'/blog/hulunbuir-best-month-to-visit',
'/blog/hulunbuir-self-drive-vs-chartered-bus',
'/blog/hulunbuir-travel-cost-breakdown',
'/blog/hulunbuir-travel-with-toddlers-4-6',
'/blog/hulunbuir-travel-tips-avoid-pitfalls',
'/blog/grassland-photography-outfit-guide',
'/blog/hulai-red-cross-first-aid-training-2026',
'/destinations',
'/destinations/moriguole', '/destinations/eergunashire', '/destinations/baihualin',
'/destinations/enhe-shiwei', '/destinations/heishantou', '/destinations/manzhouli',
'/destinations/hulunhu', '/destinations/aershan',
'/destinations/daxinganling', '/destinations/aoluguya', '/destinations/genhe',
],
crawlLinks: false,
routes: ['/sitemap.xml'],
},
},
@ -79,7 +56,7 @@ export default defineNuxtConfig({
},
build: {
transpile: ['naive-ui', 'vueuc', '@css-render/vue3-ssr', 'css-render'],
transpile: ['naive-ui', 'vueuc', '@css-render/vue3-ssr', 'css-render', '@tiptap/vue-3'],
},
compatibilityDate: '2025-03-18',

6426
package-lock.json 自动生成的

文件差异内容过多而无法显示 加载差异

查看文件

@ -12,12 +12,16 @@
},
"dependencies": {
"@css-render/vue3-ssr": "^0.15.14",
"@tiptap/extension-image": "^3.22.3",
"@tiptap/extension-link": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"@tiptap/vue-3": "^3.22.3",
"better-sqlite3": "^12.8.0",
"drizzle-orm": "^0.45.1",
"less": "^4.6.4",
"naive-ui": "^2.44.1",
"nodemailer": "^8.0.3",
"nuxt": "^3.17.5"
"nuxt": "3.17.5"
},
"pnpm": {
"onlyBuiltDependencies": [

查看文件

@ -2,7 +2,7 @@
<div class="page-about">
<LayoutPageHero
:title="seo.h1 || '关于呼籁旅行'"
subtitle="一个深耕呼伦贝尔的旅行品牌"
:subtitle="heroes?.about || '一个深耕呼伦贝尔的旅行品牌'"
:breadcrumbs="[{ text: '关于我们' }]"
background-image="/images/seasons/summer-hero.jpg"
/>
@ -80,7 +80,7 @@
</div>
<!-- 价值观 -->
<CommonSectionTitle title="呼籁文化理念" />
<CommonSectionTitle :title="st('aboutPage.culture', '呼籁文化理念')" />
<div class="culture-values">
<div v-for="value in about.culture.values" :key="value.name" class="culture-value-card">
<h4 class="culture-value-card__name">{{ value.name }}</h4>
@ -119,6 +119,8 @@ const { data: about } = await usePublicApi('about')
const { data: brand } = await usePublicApi('brand')
const { data: contact } = await usePublicApi('contact')
const { data: images } = await usePublicApi('images')
const { data: heroes } = await usePublicApi('heroes')
const st = await useSectionTitles()
const seo = useSEO('about')
useScrollReveal()

查看文件

@ -32,7 +32,12 @@
<n-input v-model:value="form.summary" type="textarea" :rows="3" placeholder="简要描述文章内容(可选)" />
</n-form-item>
<n-form-item label="正文">
<n-input v-model:value="form.content" type="textarea" :rows="16" placeholder="输入文章正文,支持 HTML" />
<ClientOnly>
<AdminRichTextEditor v-model="form.content" placeholder="在这里写文章正文,工具栏可加标题/加粗/列表/链接/图片" />
<template #fallback>
<n-input v-model:value="form.content" type="textarea" :rows="16" />
</template>
</ClientOnly>
</n-form-item>
</n-form>
</n-card>

查看文件

@ -31,8 +31,13 @@
<n-form-item label="摘要">
<n-input v-model:value="form.summary" type="textarea" :rows="3" placeholder="简要描述" />
</n-form-item>
<n-form-item label="正文(支持 HTML">
<n-input v-model:value="form.content" type="textarea" :rows="20" placeholder="输入博客正文" />
<n-form-item label="正文">
<ClientOnly>
<AdminRichTextEditor v-model="form.content" placeholder="在这里写文章正文,工具栏可加标题/加粗/列表/链接/图片" />
<template #fallback>
<n-input v-model:value="form.content" type="textarea" :rows="20" />
</template>
</ClientOnly>
</n-form-item>
</n-form>
</n-card>

查看文件

@ -22,6 +22,7 @@
<n-gi><n-form-item label="网址"><n-input v-model:value="data.url" /></n-form-item></n-gi>
<n-gi><n-form-item label="情感 Slogan"><n-input v-model:value="data.slogan.emotional" /></n-form-item></n-gi>
<n-gi><n-form-item label="功能 Slogan"><n-input v-model:value="data.slogan.functional" /></n-form-item></n-gi>
<n-gi><n-form-item label="旅行服务主体"><n-input v-model:value="data.serviceEntity" placeholder="内蒙古呼籁国际旅行社有限公司" /></n-form-item></n-gi>
<n-gi><n-form-item label="ICP 主体"><n-input v-model:value="data.icpEntity" /></n-form-item></n-gi>
<n-gi><n-form-item label="ICP 备案号"><n-input v-model:value="data.icp" /></n-form-item></n-gi>
<n-gi><n-form-item label="ICP 链接"><n-input v-model:value="data.icpUrl" placeholder="https://beian.miit.gov.cn" /></n-form-item></n-gi>
@ -57,6 +58,19 @@
</n-space>
</n-card>
<n-card title="全站悬浮「咨询客服」按钮">
<n-form label-placement="top">
<n-grid :cols="3" :x-gap="12">
<n-gi><n-form-item label="是否启用"><n-switch v-model:value="floatingEnabled" /></n-form-item></n-gi>
<n-gi><n-form-item label="按钮文字"><n-input v-model:value="floatingText" placeholder="咨询客服" /></n-form-item></n-gi>
<n-gi><n-form-item label="跳转链接"><n-input v-model:value="floatingTo" placeholder="/contact" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="隐藏路径(一行一条,如 /contact 或 /admin">
<n-input v-model:value="floatingHideOnPathsText" type="textarea" :rows="2" placeholder="/contact" />
</n-form-item>
</n-form>
</n-card>
<n-card title="CTA 按钮">
<n-form label-placement="top">
<div v-for="(btnKey, btnLabel) in ctaLabels" :key="btnKey" style="margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0;">
@ -88,7 +102,7 @@
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSpin, NGrid, NGi } from 'naive-ui'
import { NButton, NCard, NSpace, NInput, NInputNumber, NForm, NFormItem, NSpin, NGrid, NGi, NSwitch } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
@ -111,6 +125,29 @@ function addConversionStep() {
data.value.conversionPath.push({ step: nextStep, label: '', to: '' })
}
const floatingEnabled = computed({
get() { return data.value?.floatingCTA?.enabled !== false },
set(v) {
if (!data.value.floatingCTA) data.value.floatingCTA = {}
data.value.floatingCTA.enabled = v
}
})
const floatingText = computed({
get() { return data.value?.floatingCTA?.text || '' },
set(v) { if (!data.value.floatingCTA) data.value.floatingCTA = {}; data.value.floatingCTA.text = v }
})
const floatingTo = computed({
get() { return data.value?.floatingCTA?.to || '' },
set(v) { if (!data.value.floatingCTA) data.value.floatingCTA = {}; data.value.floatingCTA.to = v }
})
const floatingHideOnPathsText = computed({
get() { return (data.value?.floatingCTA?.hideOnPaths || []).join('\n') },
set(v) {
if (!data.value.floatingCTA) data.value.floatingCTA = {}
data.value.floatingCTA.hideOnPaths = v.split('\n').map(s => s.trim()).filter(Boolean)
}
})
async function load() {
const res = await adminFetch('/api/admin/site-content?key=brand')
const d = res.data
@ -122,6 +159,7 @@ async function load() {
if (!d.conversionPath) d.conversionPath = []
if (!d.icpUrl) d.icpUrl = ''
if (!d.eContract) d.eContract = ''
if (!d.floatingCTA) d.floatingCTA = { enabled: true, text: '咨询客服', to: '/contact', hideOnPaths: ['/contact'] }
data.value = d
loaded.value = true
}

查看文件

@ -67,13 +67,18 @@
@input="form.tips = $event.split('\n').filter(Boolean)"
/>
</n-form-item>
<n-form-item v-if="form.seasons" label="可去季节(每行一个)">
<n-input
type="textarea"
:rows="2"
:value="(form.seasons||[]).join('\n')"
@input="form.seasons = $event.split('\n').filter(Boolean)"
/>
<n-form-item v-if="form.seasons" label="可去季节">
<n-checkbox-group
:value="form.seasons || []"
@update:value="form.seasons = $event"
>
<n-space>
<n-checkbox value="spring">春季</n-checkbox>
<n-checkbox value="summer">夏季</n-checkbox>
<n-checkbox value="autumn">秋季</n-checkbox>
<n-checkbox value="winter">冬季</n-checkbox>
</n-space>
</n-checkbox-group>
</n-form-item>
</n-card>
@ -118,7 +123,7 @@
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NBreadcrumb, NBreadcrumbItem, NCheckbox, NCheckboxGroup } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin', name: 'admin-content-destinations-edit' })

查看文件

@ -5,53 +5,139 @@
<div style="font-size: 20px; font-weight: 600; color: #111827; margin-bottom: 4px;">景点详情</div>
<div style="font-size: 13px; color: #9ca3af;">管理各景点的详细介绍 · 对应官网目的地详情页</div>
</div>
<a href="/destinations" target="_blank">
<n-button>查看前台</n-button>
</a>
<n-space>
<n-button v-if="!sortMode" @click="enterSortMode">调整排序</n-button>
<template v-else>
<n-button @click="cancelSort">取消</n-button>
<n-button type="primary" :loading="saving" @click="saveSort">保存排序</n-button>
</template>
<a href="/destinations" target="_blank">
<n-button>查看前台</n-button>
</a>
</n-space>
</n-space>
<n-alert v-if="sortMode" type="info" :show-icon="false" style="margin-bottom: 12px;">
排序模式使用 按钮调整顺序完成后点保存排序取消则不保存
</n-alert>
<n-spin v-if="!loaded" style="display:block; padding: 60px; text-align: center;" />
<template v-else>
<n-space vertical :size="8">
<NuxtLink
v-for="item in items"
:key="item._index"
:to="`/admin/content/destinations-detail/${item._index}`"
style="text-decoration: none;"
>
<template v-for="(item, idx) in items" :key="item._originalIndex">
<n-card hoverable>
<n-space justify="space-between" align="center">
<div>
<div style="font-size: 15px; font-weight: 600; color: #111827; margin-bottom: 4px;">{{ item.name || '未命名' }}</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 15px; font-weight: 600; color: #111827; margin-bottom: 4px;">
<span v-if="sortMode" style="color: #9ca3af; margin-right: 8px; font-weight: 400;">#{{ idx + 1 }}</span>
{{ item.name || '未命名' }}
</div>
<div v-if="item.subtitle" style="font-size: 13px; color: #6b7280;">{{ item.subtitle }}</div>
</div>
<n-space align="center">
<n-tag v-if="item.tag" type="success" size="small">{{ item.tag }}</n-tag>
<span style="font-size: 20px; color: #d1d5db;">&rsaquo;</span>
<!-- 排序模式显示上下移动按钮 -->
<template v-if="sortMode">
<n-button size="tiny" :disabled="idx === 0" @click="moveUp(idx)"></n-button>
<n-button size="tiny" :disabled="idx === items.length - 1" @click="moveDown(idx)"></n-button>
</template>
<!-- 编辑模式显示跳转箭头 -->
<NuxtLink
v-else
:to="`/admin/content/destinations-detail/${item._originalIndex}`"
style="text-decoration: none; color: #d1d5db; font-size: 20px; padding: 0 8px;"
>&rsaquo;</NuxtLink>
</n-space>
</n-space>
</n-card>
</NuxtLink>
</template>
</n-space>
</template>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NTag, NSpin } from 'naive-ui'
import { NButton, NCard, NSpace, NTag, NSpin, NAlert } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const items = ref([]); const loaded = ref(false)
onMounted(async () => {
const message = useMessage()
const items = ref([])
const loaded = ref(false)
const sortMode = ref(false)
const saving = ref(false)
//
const rawDestinations = ref([])
const originalOrder = ref([])
async function loadData() {
loaded.value = false
try {
const res = await adminFetch('/api/admin/site-content?key=destinations-detail')
const data = res.data
const arr = Array.isArray(data) ? data : (data.destinations || [])
rawDestinations.value = arr
items.value = arr.map((d, i) => ({
_index: i, name: d.name || '', subtitle: d.subtitle || '', tag: d.tag || '', id: d.id || '',
_originalIndex: i,
name: d.name || '',
subtitle: d.subtitle || '',
tag: d.tag || '',
id: d.id || '',
}))
} catch {}
} catch (e) {
message.error('加载失败')
}
loaded.value = true
})
}
function enterSortMode() {
originalOrder.value = items.value.map(i => i._originalIndex)
sortMode.value = true
}
function cancelSort() {
//
const restored = originalOrder.value.map(origIdx => {
return items.value.find(i => i._originalIndex === origIdx)
})
items.value = restored
sortMode.value = false
}
function moveUp(idx) {
if (idx === 0) return
const arr = [...items.value]
;[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]]
items.value = arr
}
function moveDown(idx) {
if (idx >= items.value.length - 1) return
const arr = [...items.value]
;[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]]
items.value = arr
}
async function saveSort() {
saving.value = true
try {
//
const reordered = items.value.map(i => rawDestinations.value[i._originalIndex])
await adminFetch('/api/admin/site-content', {
method: 'PUT',
body: { key: 'destinations-detail', data: { destinations: reordered } },
})
message.success('排序已保存')
sortMode.value = false
await loadData()
} catch (e) {
message.error('保存失败:' + (e.data?.message || e.message))
}
saving.value = false
}
onMounted(loadData)
</script>

查看文件

@ -0,0 +1,283 @@
<template>
<div style="max-width: 1100px">
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
<div>
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">定制游怎么选 (文章)</h1>
<p style="margin: 0; font-size: 13px; color: #9ca3af">编辑官网/guide-custom定制游对比文章内容</p>
</div>
<n-space>
<n-button tag="a" href="/guide-custom" target="_blank" secondary>查看前台 </n-button>
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
</n-space>
</n-space>
<n-spin :show="!loaded">
<n-space vertical :size="16">
<!-- 标题 & TL;DR -->
<n-card title="基础信息">
<n-form label-placement="top">
<n-form-item label="文章标题Hero 大标题)">
<n-input v-model:value="data.title" />
</n-form-item>
<n-form-item label="TL;DR 摘要(顶部绿色边框内的一段话)">
<n-input v-model:value="data.tldr" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" />
</n-form-item>
</n-form>
</n-card>
<!-- SEO -->
<n-card title="SEO 配置">
<n-form label-placement="top">
<n-form-item label="页面 Title (<title>)">
<n-input v-model:value="data.seo.title" />
</n-form-item>
<n-form-item label="Meta Description">
<n-input v-model:value="data.seo.description" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
</n-form-item>
<n-form-item label="Meta Keywords">
<n-input v-model:value="data.seo.keywords" />
</n-form-item>
</n-form>
</n-card>
<!-- 文章章节 -->
<n-card title="文章章节">
<template #header-extra>
<n-space>
<n-button size="small" @click="addSection('text')">+ 段落</n-button>
<n-button size="small" @click="addSection('list')">+ 列表</n-button>
<n-button size="small" @click="addSection('table')">+ 对比表</n-button>
</n-space>
</template>
<n-space vertical :size="16">
<div
v-for="(sec, sIdx) in data.sections"
:key="sIdx"
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 14px; background: #fafafa"
>
<n-space justify="space-between" align="center" style="margin-bottom: 10px">
<n-tag :bordered="false" size="small">{{ typeLabel(sec.type) }} #{{ sIdx + 1 }}</n-tag>
<n-space :size="4">
<n-button size="tiny" :disabled="sIdx === 0" @click="moveSection(sIdx, -1)"></n-button>
<n-button size="tiny" :disabled="sIdx === data.sections.length - 1" @click="moveSection(sIdx, 1)"></n-button>
<n-button size="tiny" type="error" quaternary @click="data.sections.splice(sIdx, 1)">删除</n-button>
</n-space>
</n-space>
<n-form label-placement="top">
<n-form-item label="h2 标题">
<n-input v-model:value="sec.heading" />
</n-form-item>
<!-- 文本段落 -->
<template v-if="sec.type === 'text'">
<n-form-item label="段落(每行一段)">
<n-space vertical :size="8" style="width: 100%">
<div v-for="(_, pIdx) in sec.paragraphs" :key="pIdx" style="display: flex; gap: 8px">
<n-input
v-model:value="sec.paragraphs[pIdx]"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
style="flex: 1"
/>
<n-button size="small" type="error" quaternary @click="sec.paragraphs.splice(pIdx, 1)"></n-button>
</div>
<n-button size="small" dashed @click="sec.paragraphs.push('')">+ 添加段落</n-button>
</n-space>
</n-form-item>
</template>
<!-- 列表 -->
<template v-else-if="sec.type === 'list'">
<n-form-item label="引言(列表上方的文字,可空)">
<n-input v-model:value="sec.intro" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
</n-form-item>
<n-form-item label="列表项">
<n-space vertical :size="6" style="width: 100%">
<div v-for="(_, iIdx) in sec.items" :key="iIdx" style="display: flex; gap: 8px">
<n-input v-model:value="sec.items[iIdx]" style="flex: 1" />
<n-button size="small" type="error" quaternary @click="sec.items.splice(iIdx, 1)"></n-button>
</div>
<n-button size="small" dashed @click="sec.items.push('')">+ 添加条目</n-button>
</n-space>
</n-form-item>
<n-form-item label="结语(列表下方的文字,可空)">
<n-input v-model:value="sec.outro" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
</n-form-item>
</template>
<!-- 对比表 -->
<template v-else-if="sec.type === 'table'">
<n-form-item label="列名(从左到右,首列会被高亮绿色)">
<n-space vertical :size="6" style="width: 100%">
<div v-for="(_, cIdx) in sec.columns" :key="cIdx" style="display: flex; gap: 8px">
<n-input v-model:value="sec.columns[cIdx]" style="flex: 1" />
<n-button size="small" type="error" quaternary @click="removeColumn(sec, cIdx)">删列</n-button>
</div>
<n-button size="small" dashed @click="addColumn(sec)">+ 添加列</n-button>
</n-space>
</n-form-item>
<n-form-item label="行">
<n-space vertical :size="10" style="width: 100%">
<div
v-for="(row, rIdx) in sec.rows"
:key="rIdx"
style="display: flex; flex-direction: column; gap: 6px; border: 1px dashed #d1d5db; padding: 10px; border-radius: 4px"
>
<div style="display: flex; gap: 8px; align-items: center">
<span style="font-size: 12px; color: #6b7280; min-width: 40px">行标</span>
<n-input v-model:value="row.label" style="flex: 1" />
<n-button size="tiny" type="error" quaternary @click="sec.rows.splice(rIdx, 1)">删行</n-button>
</div>
<div
v-for="(_, vIdx) in row.values"
:key="vIdx"
style="display: flex; gap: 8px; align-items: center"
>
<span style="font-size: 12px; color: #6b7280; min-width: 40px">{{ sec.columns[vIdx] || `${vIdx + 1}` }}</span>
<n-input v-model:value="row.values[vIdx]" style="flex: 1" />
</div>
</div>
<n-button size="small" dashed @click="addRow(sec)">+ 添加行</n-button>
</n-space>
</n-form-item>
</template>
</n-form>
</div>
</n-space>
</n-card>
<!-- CTA -->
<n-card title="底部 CTA">
<n-form label-placement="top">
<n-form-item label="标题">
<n-input v-model:value="data.cta.heading" />
</n-form-item>
<n-form-item label="按钮">
<n-space vertical :size="10" style="width: 100%">
<div
v-for="(link, lIdx) in data.cta.links"
:key="lIdx"
style="border: 1px dashed #d1d5db; padding: 10px; border-radius: 4px"
>
<n-grid :cols="2" :x-gap="10">
<n-gi>
<n-form-item label="按钮文字" :show-feedback="false">
<n-input v-model:value="link.text" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="跳转路径" :show-feedback="false">
<n-input v-model:value="link.to" />
</n-form-item>
</n-gi>
</n-grid>
<n-space style="margin-top: 8px" justify="space-between" align="center">
<n-checkbox v-model:checked="link.primary">主按钮绿色填充</n-checkbox>
<n-button size="tiny" type="error" quaternary @click="data.cta.links.splice(lIdx, 1)">删除</n-button>
</n-space>
</div>
<n-button size="small" dashed @click="data.cta.links.push({ text: '', to: '/', primary: false })">+ 添加按钮</n-button>
</n-space>
</n-form-item>
</n-form>
</n-card>
</n-space>
</n-spin>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NCheckbox, NTag } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const defaultData = () => ({
title: '',
tldr: '',
seo: { title: '', description: '', keywords: '' },
sections: [],
cta: { heading: '', links: [] },
})
const data = ref(defaultData())
const loaded = ref(false)
const saving = ref(false)
function typeLabel(type) {
return { text: '段落', list: '列表', table: '对比表' }[type] || type
}
function addSection(type) {
const templates = {
text: { type: 'text', heading: '', paragraphs: [''] },
list: { type: 'list', heading: '', intro: '', items: [''], outro: '' },
table: { type: 'table', heading: '', columns: ['列1', '列2'], rows: [{ label: '', values: ['', ''] }] },
}
data.value.sections.push(templates[type])
}
function moveSection(idx, delta) {
const target = idx + delta
if (target < 0 || target >= data.value.sections.length) return
const arr = data.value.sections
;[arr[idx], arr[target]] = [arr[target], arr[idx]]
}
function addColumn(sec) {
sec.columns.push('')
for (const row of sec.rows) row.values.push('')
}
function removeColumn(sec, cIdx) {
sec.columns.splice(cIdx, 1)
for (const row of sec.rows) row.values.splice(cIdx, 1)
}
function addRow(sec) {
sec.rows.push({ label: '', values: sec.columns.map(() => '') })
}
async function load() {
try {
const res = await adminFetch('/api/admin/site-content?key=guide-custom')
const d = res.data || {}
//
data.value = {
title: d.title || '',
tldr: d.tldr || '',
seo: { title: '', description: '', keywords: '', ...(d.seo || {}) },
sections: d.sections || [],
cta: {
heading: d.cta?.heading || '',
links: d.cta?.links || [],
},
}
} catch (e) {
data.value = defaultData()
} finally {
loaded.value = true
}
}
async function save() {
saving.value = true
try {
await adminFetch('/api/admin/site-content', {
method: 'PUT',
body: { key: 'guide-custom', data: data.value },
})
message.success('已保存')
} catch (e) {
message.error('保存失败:' + (e.data?.message || ''))
} finally {
saving.value = false
}
}
onMounted(load)
</script>

查看文件

@ -39,6 +39,7 @@
</div>
</template>
<div v-show="open[i]">
<!-- 公共基础字段 -->
<n-grid :cols="2" :x-gap="10" style="margin-bottom: 12px;">
<n-gi>
<n-form-item label="ID" :show-feedback="false">
@ -62,9 +63,11 @@
</n-gi>
</n-grid>
<n-form-item label="正文内容" :show-feedback="false" style="margin-bottom: 10px;">
<n-input v-model:value="s.content" type="textarea" :rows="4" />
<n-input v-model:value="s.content" type="textarea" :rows="3" />
</n-form-item>
<n-form-item v-if="s.tips" label="Tips每行一个" :show-feedback="false" style="margin-bottom: 10px;">
<!-- tipsdocuments / packing -->
<n-form-item v-if="'tips' in s" label="Tips每行一个" :show-feedback="false" style="margin-bottom: 10px;">
<n-input
type="textarea"
:rows="3"
@ -73,19 +76,20 @@
/>
</n-form-item>
<!-- itemsdocuments{text, important} -->
<div v-if="s.items && s.items.length" style="margin-top: 14px; border-top: 1px solid #f0f0f0; padding-top: 10px;">
<div style="font-size: 14px; color: #555; margin-bottom: 10px; font-weight: 500;">子项列表</div>
<n-space v-for="(item, ii) in s.items" :key="ii" align="center" style="margin-bottom: 6px;">
<n-space v-for="(item, ii) in s.items" :key="ii" align="center" style="margin-bottom: 6px; width: 100%;">
<template v-if="typeof item === 'string'">
<n-input
:value="item"
@input="s.items[ii] = $event"
size="small"
style="flex: 1; min-width: 200px;"
style="flex: 1; min-width: 300px;"
/>
</template>
<template v-else>
<n-input v-model:value="item.text" placeholder="文字" size="small" style="flex: 1; min-width: 200px;" />
<n-input v-model:value="item.text" placeholder="文字" size="small" style="flex: 1; min-width: 300px;" />
<n-tag
:type="item.important ? 'warning' : 'default'"
checkable
@ -96,47 +100,239 @@
重要
</n-tag>
</template>
<n-button size="tiny" quaternary type="error" @click="s.items.splice(ii, 1)">删除</n-button>
</n-space>
<n-button size="small" dashed @click="addItem(s)" style="margin-top: 6px;">+ 添加子项</n-button>
</div>
<n-card v-if="s.months" title="每月穿搭" size="small" style="margin-top: 12px;">
<n-collapse>
<n-collapse-item v-for="(m, mi) in s.months" :key="mi" :name="mi">
<template #header>{{ m.name }}</template>
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.desc" type="textarea" :rows="2" />
</n-form-item>
<n-grid :cols="2" :x-gap="12">
<n-gi>
<n-form-item label="日间温度" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.tempDay" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="夜间温度" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.tempNight" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="成人穿搭" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.adultWear" type="textarea" :rows="2" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="儿童穿搭" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.kidsWear" type="textarea" :rows="2" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="亮点" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.highlight" />
</n-form-item>
<n-form-item label="备注" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.note" />
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-card>
<!-- 穿搭months + extraTips -->
<template v-if="s.id === 'clothing'">
<n-card title="每月穿搭" size="small" style="margin-top: 12px;">
<n-collapse>
<n-collapse-item v-for="(m, mi) in s.months" :key="mi" :name="mi">
<template #header>{{ m.name }}</template>
<n-form-item label="月份名称" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.name" />
</n-form-item>
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.desc" type="textarea" :rows="2" />
</n-form-item>
<n-grid :cols="2" :x-gap="12">
<n-gi>
<n-form-item label="日间温度" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.tempDay" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="夜间温度" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.tempNight" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="成人穿搭" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.adultWear" type="textarea" :rows="2" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="儿童穿搭" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.kidsWear" type="textarea" :rows="2" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="亮点" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.highlight" />
</n-form-item>
<n-form-item label="备注" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="m.note" />
</n-form-item>
<div style="text-align: right;">
<n-button size="tiny" quaternary type="error" @click="s.months.splice(mi, 1)">删除此月份</n-button>
</div>
</n-collapse-item>
</n-collapse>
<n-button size="small" dashed @click="s.months.push({name:'',desc:'',tempDay:'',tempNight:'',adultWear:'',kidsWear:'',highlight:'',note:''})" style="margin-top: 10px;">+ 添加月份</n-button>
</n-card>
<n-form-item label="额外提示(每行一个)" :show-feedback="false" style="margin-top: 12px;">
<n-input
type="textarea"
:rows="3"
:value="(s.extraTips||[]).join('\n')"
@input="s.extraTips = $event.split('\n').filter(Boolean)"
/>
</n-form-item>
</template>
<!-- 行李清单categories + provided + dontBring -->
<template v-if="s.id === 'packing'">
<n-card title="分类清单" size="small" style="margin-top: 12px;">
<n-collapse>
<n-collapse-item v-for="(cat, ci) in s.categories" :key="ci" :name="ci">
<template #header>{{ cat.name || '(未命名)' }}</template>
<n-form-item label="分类名称" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="cat.name" />
</n-form-item>
<n-form-item label="备注说明" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="cat.note" />
</n-form-item>
<n-form-item label="条目(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
<n-input
type="textarea"
:rows="4"
:value="(cat.items||[]).join('\n')"
@input="cat.items = $event.split('\n').filter(Boolean)"
/>
</n-form-item>
<div style="text-align: right;">
<n-button size="tiny" quaternary type="error" @click="s.categories.splice(ci, 1)">删除此分类</n-button>
</div>
</n-collapse-item>
</n-collapse>
<n-button size="small" dashed @click="s.categories.push({name:'',note:'',items:[]})" style="margin-top: 10px;">+ 添加分类</n-button>
</n-card>
<n-card title="已为您准备好的" size="small" style="margin-top: 12px;">
<n-form-item label="标题" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="s.provided.title" />
</n-form-item>
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="s.provided.description" type="textarea" :rows="2" />
</n-form-item>
<n-form-item label="物品列表(逗号或空格分隔的文字)" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="s.provided.items" type="textarea" :rows="2" />
</n-form-item>
</n-card>
<n-form-item label="不用带(每行一个)" :show-feedback="false" style="margin-top: 12px;">
<n-input
type="textarea"
:rows="4"
:value="(s.dontBring||[]).join('\n')"
@input="s.dontBring = $event.split('\n').filter(Boolean)"
/>
</n-form-item>
</template>
<!-- 活动安全activities -->
<template v-if="s.id === 'activities'">
<n-card title="活动卡片" size="small" style="margin-top: 12px;">
<n-collapse>
<n-collapse-item v-for="(a, ai) in s.activities" :key="ai" :name="ai">
<template #header>{{ a.icon || '' }} {{ a.name || '(未命名)' }}</template>
<n-grid :cols="2" :x-gap="10">
<n-gi>
<n-form-item label="图标" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="a.icon" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="活动名称" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="a.name" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="安全要点(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
<n-input
type="textarea"
:rows="5"
:value="(a.points||[]).join('\n')"
@input="a.points = $event.split('\n').filter(Boolean)"
/>
</n-form-item>
<div style="text-align: right;">
<n-button size="tiny" quaternary type="error" @click="s.activities.splice(ai, 1)">删除此活动</n-button>
</div>
</n-collapse-item>
</n-collapse>
<n-button size="small" dashed @click="s.activities.push({icon:'',name:'',points:[]})" style="margin-top: 10px;">+ 添加活动</n-button>
</n-card>
</template>
<!-- 实用信息topics + apps + appNote -->
<template v-if="s.id === 'practical'">
<n-card title="常见话题" size="small" style="margin-top: 12px;">
<n-space v-for="(t, ti) in s.topics" :key="ti" align="start" style="margin-bottom: 8px; width: 100%;">
<n-input v-model:value="t.name" placeholder="话题名称" size="small" style="width: 160px;" />
<n-input v-model:value="t.content" type="textarea" :rows="2" placeholder="内容" size="small" style="flex: 1; min-width: 400px;" />
<n-button size="tiny" quaternary type="error" @click="s.topics.splice(ti, 1)">删除</n-button>
</n-space>
<n-button size="small" dashed @click="s.topics.push({name:'',content:''})">+ 添加话题</n-button>
</n-card>
<n-card title="推荐 App" size="small" style="margin-top: 12px;">
<n-space v-for="(app, pi) in s.apps" :key="pi" align="center" style="margin-bottom: 6px; width: 100%;">
<n-input v-model:value="app.category" placeholder="类别" size="small" style="width: 140px;" />
<n-input v-model:value="app.names" placeholder="App 名称" size="small" style="flex: 1; min-width: 360px;" />
<n-button size="tiny" quaternary type="error" @click="s.apps.splice(pi, 1)">删除</n-button>
</n-space>
<n-button size="small" dashed @click="s.apps.push({category:'',names:''})">+ 添加 App</n-button>
</n-card>
<n-form-item label="App 说明 / 额外提示" :show-feedback="false" style="margin-top: 12px;">
<n-input v-model:value="s.appNote" type="textarea" :rows="2" />
</n-form-item>
</template>
<!-- 幼童指南notes / dining / equipment / conditional / recommended / notRecommended -->
<template v-if="s.id === 'toddler'">
<n-card title="注意事项 notes" size="small" style="margin-top: 12px;">
<n-space v-for="(n, ni) in s.notes" :key="ni" align="start" style="margin-bottom: 8px; width: 100%;">
<n-input v-model:value="n.title" placeholder="标题" size="small" style="width: 180px;" />
<n-input v-model:value="n.content" type="textarea" :rows="2" placeholder="内容" size="small" style="flex: 1; min-width: 400px;" />
<n-button size="tiny" quaternary type="error" @click="s.notes.splice(ni, 1)">删除</n-button>
</n-space>
<n-button size="small" dashed @click="s.notes.push({title:'',content:''})">+ 添加</n-button>
</n-card>
<n-form-item label="餐饮 dining每行一个" :show-feedback="false" style="margin-top: 12px;">
<n-input
type="textarea"
:rows="4"
:value="(s.dining||[]).join('\n')"
@input="s.dining = $event.split('\n').filter(Boolean)"
/>
</n-form-item>
<n-card title="装备 equipment" size="small" style="margin-top: 12px;">
<n-space v-for="(e, ei) in s.equipment" :key="ei" align="start" style="margin-bottom: 8px; width: 100%;">
<n-input v-model:value="e.name" placeholder="名称" size="small" style="width: 140px;" />
<n-input v-model:value="e.desc" type="textarea" :rows="2" placeholder="描述" size="small" style="flex: 1; min-width: 300px;" />
<n-tag
:type="e.provided ? 'success' : 'default'"
checkable
:checked="!!e.provided"
@update:checked="e.provided = $event"
size="small"
>
呼籁提供
</n-tag>
<n-button size="tiny" quaternary type="error" @click="s.equipment.splice(ei, 1)">删除</n-button>
</n-space>
<n-button size="small" dashed @click="s.equipment.push({name:'',desc:'',provided:false})">+ 添加装备</n-button>
</n-card>
<n-card
v-for="key in ['recommended','conditional','notRecommended']"
:key="key"
:title="toddlerGroupTitle(key)"
size="small"
style="margin-top: 12px;"
>
<n-form-item label="标题" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="s[key].title" />
</n-form-item>
<n-form-item label="描述" :show-feedback="false" style="margin-bottom: 8px;">
<n-input v-model:value="s[key].desc" type="textarea" :rows="2" />
</n-form-item>
<n-form-item label="条目(每行一个)" :show-feedback="false" style="margin-bottom: 8px;">
<n-input
type="textarea"
:rows="4"
:value="(s[key].items||[]).join('\n')"
@input="s[key].items = $event.split('\n').filter(Boolean)"
/>
</n-form-item>
</n-card>
</template>
</div>
</n-card>
</template>
@ -155,7 +351,48 @@ const loaded = ref(false)
const saving = ref(false)
const open = reactive({})
async function load() { const res = await adminFetch('/api/admin/site-content?key=guides'); data.value = res.data; loaded.value = true }
function toddlerGroupTitle(key) {
return { recommended: '推荐参与 recommended', conditional: '视情况而定 conditional', notRecommended: '不建议 notRecommended' }[key]
}
function addItem(s) {
const sample = s.items[0]
if (typeof sample === 'string' || sample === undefined) s.items.push('')
else s.items.push({ text: '', important: false })
}
async function load() {
const res = await adminFetch('/api/admin/site-content?key=guides')
const d = res.data || { pageIntro: '', sections: [] }
// section v-model undefined
for (const s of d.sections || []) {
if (s.id === 'packing') {
s.categories = s.categories || []
s.provided = s.provided || { title: '', description: '', items: '' }
s.dontBring = s.dontBring || []
}
if (s.id === 'clothing') {
s.months = s.months || []
s.extraTips = s.extraTips || []
}
if (s.id === 'activities') s.activities = s.activities || []
if (s.id === 'practical') {
s.topics = s.topics || []
s.apps = s.apps || []
s.appNote = s.appNote || ''
}
if (s.id === 'toddler') {
s.notes = s.notes || []
s.dining = s.dining || []
s.equipment = s.equipment || []
s.recommended = s.recommended || { title: '', desc: '', items: [] }
s.conditional = s.conditional || { title: '', desc: '', items: [] }
s.notRecommended = s.notRecommended || { title: '', desc: '', items: [] }
}
}
data.value = d
loaded.value = true
}
async function save() {
saving.value = true
try {

查看文件

@ -0,0 +1,117 @@
<template>
<div style="max-width: 1100px">
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
<div>
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">页面副标题</h1>
<p style="margin: 0; font-size: 13px; color: #9ca3af">统一管理各页面顶部 Hero 区域的副标题文案</p>
</div>
<n-space>
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
</n-space>
</n-space>
<n-spin :show="!loaded">
<n-space vertical :size="12">
<n-card
v-for="item in entries"
:key="item.key"
size="small"
>
<template #header>
<n-space align="center" :size="12">
<span style="font-size: 14px; font-weight: 600; color: #111827">{{ item.label }}</span>
<n-button
size="tiny"
tag="a"
:href="item.preview"
target="_blank"
quaternary
style="color: #6b7280"
>
查看前台
</n-button>
</n-space>
</template>
<n-input
v-model:value="data[item.key]"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
:placeholder="`${item.label} 的副标题`"
/>
</n-card>
</n-space>
</n-spin>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NSpin } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
// 23 key heroes.json
const entries = [
{ key: 'about', label: '关于我们', preview: '/about' },
{ key: 'reviews', label: '客户评价', preview: '/reviews' },
{ key: 'guides', label: '出行指南', preview: '/guides' },
{ key: 'guide-custom', label: '定制游怎么选', preview: '/guide-custom' },
{ key: 'youji', label: '客户游记列表', preview: '/youji' },
{ key: 'xiaohongshu', label: '小红书游记列表', preview: '/xiaohongshu' },
{ key: 'stories', label: '客户故事列表', preview: '/stories' },
{ key: 'winter-camp', label: '冬令营', preview: '/winter-camp' },
{ key: 'summer-camp', label: '夏令营', preview: '/summer-camp' },
{ key: 'calendar', label: '旅行日历', preview: '/calendar' },
{ key: 'customize', label: '定制入口', preview: '/customize' },
{ key: 'blog', label: '博客列表', preview: '/blog' },
{ key: 'courses', label: '亲子研学课程', preview: '/courses' },
{ key: 'partners', label: '合作伙伴', preview: '/partners' },
{ key: 'contact', label: '联系我们', preview: '/contact' },
{ key: 'destinations', label: '目的地列表', preview: '/destinations' },
{ key: 'news', label: '最新动态', preview: '/news' },
{ key: 'products', label: '产品总览', preview: '/products' },
{ key: 'products-winter', label: '冬季产品', preview: '/products/winter' },
{ key: 'qualifications', label: '资质荣誉', preview: '/qualifications' },
{ key: 'selector', label: '产品选择器', preview: '/selector' },
{ key: 'pricing', label: '价格公开', preview: '/pricing' },
{ key: 'gallery', label: '呼籁旅拍', preview: '/gallery' },
]
const data = ref({})
const loaded = ref(false)
const saving = ref(false)
async function load() {
try {
const res = await adminFetch('/api/admin/site-content?key=heroes')
data.value = res.data || {}
} catch (e) {
data.value = {}
} finally {
// 23 key v-model
for (const item of entries) {
if (typeof data.value[item.key] !== 'string') data.value[item.key] = ''
}
loaded.value = true
}
}
async function save() {
saving.value = true
try {
await adminFetch('/api/admin/site-content', {
method: 'PUT',
body: { key: 'heroes', data: data.value },
})
message.success('已保存')
} catch (e) {
message.error('保存失败:' + (e.data?.message || ''))
} finally {
saving.value = false
}
}
onMounted(load)
</script>

查看文件

@ -142,7 +142,16 @@ const saving = ref(false)
async function load() {
const res = await adminFetch('/api/admin/site-content?key=pricing')
data.value = res.data
const d = res.data || {}
// v-model undefined
data.value = {
intro: d.intro || '',
products: d.products || [],
included: d.included || [],
excluded: d.excluded || [],
philosophy: { title: '', content: '', promise: '', ...(d.philosophy || {}) },
cta: { title: '', description: '', to: '', button: '', ...(d.cta || {}) },
}
loaded.value = true
}

查看文件

@ -0,0 +1,254 @@
<template>
<div style="max-width: 1100px">
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
<div>
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">栏目标题</h1>
<p style="margin: 0; font-size: 13px; color: #9ca3af">统一管理各页面内部每个内容板块的标题与副标题CommonSectionTitle</p>
</div>
<n-space>
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
</n-space>
</n-space>
<n-spin :show="!loaded">
<n-space vertical :size="20">
<n-card
v-for="group in groups"
:key="group.key"
>
<template #header>
<n-space align="center" :size="10">
<span style="font-size: 15px; font-weight: 600; color: #111827">{{ group.label }}</span>
<n-button
v-if="group.preview"
size="tiny"
tag="a"
:href="group.preview"
target="_blank"
quaternary
style="color: #6b7280"
>
查看前台
</n-button>
</n-space>
</template>
<n-space vertical :size="14">
<div
v-for="item in group.items"
:key="item.key"
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px 14px; background: #fafafa"
>
<n-tag :bordered="false" size="small" style="margin-bottom: 10px">{{ item.label }}</n-tag>
<n-form label-placement="top" :show-feedback="false" style="gap: 10px">
<n-form-item label="主标题">
<n-input
v-model:value="ensureEntry(group.key, item.key).title"
placeholder="主标题"
/>
</n-form-item>
<n-form-item v-if="item.hasLabel" label="英文标签label" style="margin-top: 10px">
<n-input
v-model:value="ensureEntry(group.key, item.key).label"
placeholder="英文标签"
/>
</n-form-item>
<n-form-item label="副标题" style="margin-top: 10px">
<n-input
v-model:value="ensureEntry(group.key, item.key).subtitle"
type="textarea"
:autosize="{ minRows: 1, maxRows: 3 }"
placeholder="副标题"
/>
</n-form-item>
</n-form>
</div>
</n-space>
</n-card>
</n-space>
</n-spin>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NTag } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
// key
const groups = [
{
key: 'home', label: '首页', preview: '/',
items: [
{ key: 'productTree', label: '怎么找到适合你的行程', hasLabel: true },
{ key: 'knowledgeHub', label: '知识库', hasLabel: true },
{ key: 'faq', label: '常见问答', hasLabel: true },
{ key: 'productPreview', label: '旅行产品预览', hasLabel: false },
{ key: 'differentiator', label: '为什么选择呼籁', hasLabel: true },
{ key: 'reviewHighlights', label: '客户怎么说', hasLabel: true },
],
},
{
key: 'products', label: '产品页(额吉的故乡)', preview: '/products',
items: [
{ key: 'v9Series', label: 'V9系列主标题' },
{ key: 'evolutionStats', label: '产品进化' },
{ key: 'upgrades', label: '2026核心升级' },
{ key: 'highlights', label: '更多V9亮点' },
{ key: 'compareV8V9', label: 'V8→V9 对比' },
{ key: 'philosophy', label: '产品哲学' },
{ key: 'pricingPhilosophy', label: '价格透明' },
{ key: 'selectionGuide', label: '选版指南' },
],
},
{
key: 'productsAutumn', label: '秋季产品', preview: '/products/autumn',
items: [
{ key: 'highlights', label: '秋季不可错过' },
{ key: 'southLine', label: '南线产品' },
{ key: 'southHighlights', label: '南线独有体验' },
{ key: 'northLine', label: '北线产品' },
{ key: 'northHighlights', label: '北线独有体验' },
{ key: 'selection', label: '选版指南' },
],
},
{
key: 'productsWinter', label: '冬季产品', preview: '/products/winter',
items: [
{ key: 'versions', label: 'V3系列' },
{ key: 'highlights', label: '冬季行程亮点' },
{ key: 'south', label: '南线特色' },
{ key: 'north', label: '北线特色' },
],
},
{
key: 'summerCamp', label: '夏令营', preview: '/summer-camp',
items: [
{ key: 'principles', label: '行程设计原则' },
{ key: 'itinerary', label: '行程概览' },
{ key: 'activities', label: '核心体验' },
{ key: 'compare', label: '营 vs 额吉的故乡' },
{ key: 'faq', label: '常见问题' },
],
},
{
key: 'winterCamp', label: '冬令营', preview: '/winter-camp',
items: [
{ key: 'whyHulunbuir', label: '为什么是呼伦贝尔' },
{ key: 'advantages', label: '亲子营优势' },
{ key: 'hotels', label: '住宿安排' },
{ key: 'itinerary', label: '行程概览' },
{ key: 'photographer', label: '随行摄影师' },
{ key: 'clothing', label: '穿衣指南' },
{ key: 'serviceConfig', label: '服务配置' },
{ key: 'faq', label: '常见问题' },
],
},
{
key: 'qualifications', label: '资质荣誉', preview: '/qualifications',
items: [
{ key: 'safety', label: '服务保障' },
{ key: 'awards', label: '荣誉与认证' },
],
},
{
key: 'courses', label: '研学课程', preview: '/courses',
items: [
{ key: 'byAge', label: '按年龄找课程' },
{ key: 'modules', label: '6大课程模块' },
{ key: 'mentors', label: '课程导师团队' },
{ key: 'faq', label: '常见问题' },
],
},
{
key: 'partners', label: '合作伙伴', preview: '/partners',
items: [
{ key: 'platform', label: '平台认证' },
{ key: 'government', label: '政府资质' },
{ key: 'universities', label: '校企合作' },
{ key: 'scenic', label: '景区合作' },
{ key: 'media', label: '媒体报道' },
],
},
{
key: 'contact', label: '联系我们', preview: '/contact',
items: [
{ key: 'primary', label: '立即联系' },
{ key: 'more', label: '更多联系方式' },
],
},
{
key: 'gallery', label: '相册', preview: '/gallery',
items: [
{ key: 'intro', label: '开篇' },
{ key: 'works', label: '作品墙' },
],
},
{
key: 'pricing', label: '价格页', preview: '/pricing',
items: [
{ key: 'reference', label: '价格参考' },
{ key: 'explanation', label: '费用说明' },
],
},
{
key: 'aboutPage', label: '关于我们(页内)', preview: '/about',
items: [
{ key: 'culture', label: '呼籁文化理念' },
{ key: 'xiaohongshu', label: '小红书上的呼籁' },
{ key: 'team', label: '我们的团队' },
{ key: 'copyright', label: '知识产权' },
{ key: 'qualCert', label: '资质认证' },
{ key: 'qualService', label: '服务保障(页内)' },
{ key: 'cultureValues', label: '企业文化' },
{ key: 'mascot', label: '品牌IP·籁小咩' },
],
},
]
const data = ref({})
const loaded = ref(false)
const saving = ref(false)
function ensureEntry(groupKey, itemKey) {
if (!data.value[groupKey]) data.value[groupKey] = {}
if (!data.value[groupKey][itemKey]) data.value[groupKey][itemKey] = { title: '', subtitle: '', label: '' }
// v-model 访 undefined
const e = data.value[groupKey][itemKey]
if (e.title === undefined) e.title = ''
if (e.subtitle === undefined) e.subtitle = ''
if (e.label === undefined) e.label = ''
return e
}
async function load() {
try {
const res = await adminFetch('/api/admin/site-content?key=section-titles')
data.value = res.data || {}
} catch (e) {
data.value = {}
} finally {
loaded.value = true
}
}
async function save() {
saving.value = true
try {
await adminFetch('/api/admin/site-content', {
method: 'PUT',
body: { key: 'section-titles', data: data.value },
})
message.success('已保存')
} catch (e) {
message.error('保存失败:' + (e.data?.message || ''))
} finally {
saving.value = false
}
}
onMounted(load)
</script>

查看文件

@ -39,8 +39,17 @@ const { adminFetch } = useAdmin()
const message = useMessage()
const list = ref([]); const total = ref(0); const page = ref(1); const totalPages = ref(1); const loading = ref(false); const search = ref('')
const { moveUp, moveDown } = useAdminSort('/api/admin/faqs')
const columns = [
{ title: '排序', key: 'sortOrder', width: 60, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.sortOrder) },
{
title: '排序', key: 'sortOrder', width: 110,
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
h('span', { style: 'color: #9ca3af; font-size: 12px; min-width: 20px' }, row.sortOrder),
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
]),
},
{
title: '问题', key: 'question',
render: (row) => h('a', { style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer', onClick: () => navigateTo(`/admin/faqs/${row.id}`) }, row.question),

查看文件

@ -83,6 +83,7 @@ import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const { moveUp, moveDown } = useAdminSort('/api/admin/gallery')
const message = useMessage()
const list = ref([])
const total = ref(0)
@ -133,6 +134,14 @@ async function saveConfig() {
const columns = [
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
{
title: '排序', key: 'sortOrder', width: 110,
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
h('span', { style: 'color: #9ca3af; font-size: 12px; min-width: 20px' }, row.sortOrder),
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
]),
},
{
title: '缩略',
key: 'image',

查看文件

@ -1,31 +1,83 @@
<template>
<div>
<h1 style="margin: 0 0 24px; font-size: 24px; color: #111827; font-weight: 600">数据概览</h1>
<div class="page-head">
<h1>数据概览</h1>
<span v-if="stats" class="updated-at">更新于 {{ nowText }}</span>
</div>
<n-spin :show="!stats">
<n-grid v-if="stats" :cols="4" :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
<n-gi v-for="item in statCards" :key="item.key" span="4 s:2 m:1">
<n-card hoverable style="cursor: pointer" @click="item.to && navigateTo(item.to)">
<n-statistic :label="item.label" :value="item.value">
<template #suffix>
<span style="font-size: 14px; color: #9ca3af">{{ item.sub }}</span>
</template>
</n-statistic>
</n-card>
</n-gi>
</n-grid>
<template v-if="stats">
<h2 style="font-size: 18px; color: #333; margin: 32px 0 16px; font-weight: 600">快捷操作</h2>
<!-- 统计卡片 -->
<n-grid :cols="4" :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
<n-gi v-for="item in statCards" :key="item.key" span="4 s:2 m:1">
<n-card
hoverable
class="stat-card"
:class="{ 'stat-card--alert': item.alert }"
@click="item.to && navigateTo(item.to)"
>
<div class="stat-label">
{{ item.label }}
<n-tag v-if="item.badge" size="tiny" :type="item.badgeType || 'default'" :bordered="false" class="stat-badge">
{{ item.badge }}
</n-tag>
</div>
<div class="stat-value">{{ item.value }}</div>
<div class="stat-sub">{{ item.sub }}</div>
</n-card>
</n-gi>
</n-grid>
<!-- 7 天动态 + 待办 -->
<n-grid :cols="3" :x-gap="16" :y-gap="16" responsive="screen" item-responsive style="margin-top: 24px">
<n-gi span="3 s:3 m:1">
<n-card title="近 7 天真实咨询">
<template #header-extra>
<span class="card-hint">仅统计用户提交</span>
</template>
<div class="trend-row"><span>总提交</span><strong>{{ stats.recent.submissions7d }}</strong></div>
<div class="trend-row"><span>联系表单</span><strong>{{ stats.recent.contact7d }}</strong></div>
<div class="trend-row"><span>定制咨询</span><strong>{{ stats.recent.customize7d }}</strong></div>
</n-card>
</n-gi>
<n-gi span="3 s:3 m:1">
<n-card title="最近提交" :segmented="{ content: true }">
<template #header-extra>
<n-button size="tiny" text @click="navigateTo('/admin/submissions')">查看全部 </n-button>
</template>
<div v-if="!stats.recentSubmissions.length" class="empty">暂无提交</div>
<div v-for="s in stats.recentSubmissions" :key="s.id" class="list-row">
<n-tag size="tiny" :type="s.type === 'customize' ? 'warning' : 'default'" :bordered="false">
{{ s.type === 'customize' ? '定制' : '联系' }}
</n-tag>
<span class="list-title">#{{ s.id }}</span>
<span class="list-meta">{{ fmtDate(s.createdAt) }}</span>
<n-tag v-if="!s.read" size="tiny" type="error" :bordered="false">未读</n-tag>
</div>
</n-card>
</n-gi>
<n-gi span="3 s:3 m:1">
<n-card title="最近评价" :segmented="{ content: true }">
<template #header-extra>
<n-button size="tiny" text @click="navigateTo('/admin/reviews')">查看全部 </n-button>
</template>
<div v-if="!stats.recentReviews.length" class="empty">暂无评价</div>
<div v-for="r in stats.recentReviews" :key="r.id" class="list-row">
<span class="list-star">{{ '★'.repeat(r.rating || 5) }}</span>
<span class="list-title">{{ r.author }}</span>
<span class="list-meta">{{ fmtDate(r.createdAt) }}</span>
</div>
</n-card>
</n-gi>
</n-grid>
<!-- 快捷操作 -->
<h2 class="section-title">快捷操作</h2>
<n-grid :cols="4" :x-gap="12" :y-gap="12" responsive="screen" item-responsive>
<n-gi v-for="q in quickLinks" :key="q.to" span="4 s:2 m:1">
<n-card hoverable size="small" style="cursor: pointer" @click="navigateTo(q.to)">
<n-space align="center">
<div>
<div style="font-size: 14px; font-weight: 500; color: #333">{{ q.title }}</div>
<div style="font-size: 12px; color: #999">{{ q.desc }}</div>
</div>
</n-space>
<n-card hoverable size="small" class="quick-card" @click="navigateTo(q.to)">
<div class="quick-title">{{ q.title }}</div>
<div class="quick-desc">{{ q.desc }}</div>
</n-card>
</n-gi>
</n-grid>
@ -35,34 +87,194 @@
</template>
<script setup>
import { NCard, NGrid, NGi, NStatistic, NSpace, NSpin } from 'naive-ui'
import { NCard, NGrid, NGi, NSpace, NSpin, NTag, NButton } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const stats = ref(null)
const nowText = ref('')
const statCards = computed(() => {
if (!stats.value) return []
const s = stats.value
return [
{ key: 'articles', label: '文章总数', value: stats.value.articles, sub: '新闻 + 博客', to: '/admin/articles' },
{ key: 'reviews', label: '客户评价', value: stats.value.reviews, sub: '来自真实客户', to: '/admin/reviews' },
{ key: 'faqs', label: '常见问答', value: stats.value.faqs, sub: 'FAQ 条目', to: '/admin/faqs' },
{ key: 'gallery', label: '旅拍照片', value: stats.value.gallery, sub: '呼籁旅拍', to: '/admin/gallery' },
{ key: 'stories', label: '客户故事', value: stats.value.stories, sub: '深度体验', to: '/admin/stories' },
{ key: 'submissions', label: '表单提交', value: stats.value.submissions, sub: '联系 + 定制咨询', to: '/admin/submissions' },
{ key: 'siteContent', label: '站点配置', value: stats.value.siteContent, sub: '可编辑配置项' },
{
key: 'articles', label: '文章总数', value: s.articles.total,
sub: `新闻 ${s.articles.news} · 博客 ${s.articles.blog}`,
to: '/admin/articles',
},
{
key: 'submissions', label: '表单提交', value: s.submissions.total,
sub: s.submissions.unread > 0 ? `${s.submissions.unread} 条未读` : '已全部查看',
badge: s.submissions.unread > 0 ? `${s.submissions.unread} 未读` : null,
badgeType: 'error',
alert: s.submissions.unread > 0,
to: '/admin/submissions',
},
{ key: 'reviews', label: '客户评价', value: s.reviews, sub: '来自真实客户', to: '/admin/reviews' },
{ key: 'faqs', label: '常见问答', value: s.faqs, sub: 'FAQ 条目', to: '/admin/faqs' },
{ key: 'gallery', label: '旅拍照片', value: s.gallery, sub: '呼籁旅拍', to: '/admin/gallery' },
{ key: 'stories', label: '客户故事', value: s.stories, sub: '深度体验', to: '/admin/stories' },
{ key: 'productLines', label: '产品线', value: s.productLines, sub: '夏/秋/冬 + 营地课程', to: '/admin/products' },
{ key: 'siteContent', label: '站点配置', value: s.siteContent, sub: '可编辑配置项', to: '/admin/content/brand' },
]
})
const quickLinks = [
{ title: '产品线管理', desc: '管理所有产品线', to: '/admin/products' },
{ title: '导航菜单', desc: '编辑顶部和底部导航', to: '/admin/navigation' },
{ title: '查看表单', desc: '客户咨询和留言', to: '/admin/submissions' },
{ title: '产品线管理', desc: '所有产品线概览', to: '/admin/products' },
{ title: '文章管理', desc: '新闻 + 博客', to: '/admin/articles' },
{ title: '博客管理', desc: '品牌博客', to: '/admin/blogs' },
{ title: '客户评价', desc: '评价审核 / 排序', to: '/admin/reviews' },
{ title: 'FAQ 管理', desc: '常见问答维护', to: '/admin/faqs' },
{ title: '图库管理', desc: '旅拍照片', to: '/admin/gallery' },
{ title: '客户故事', desc: '深度体验稿件', to: '/admin/stories' },
{ title: '表单提交', desc: '联系 + 定制咨询', to: '/admin/submissions' },
{ title: '导航菜单', desc: '顶部和底部导航', to: '/admin/navigation' },
{ title: 'SEO 配置', desc: '页面标题和描述', to: '/admin/content/seo' },
{ title: '品牌信息', desc: '联系方式 / 悬浮按钮', to: '/admin/content/brand' },
{ title: '首页配置', desc: 'Hero / 栏目文案', to: '/admin/content/heroes' },
]
function fmtDate(iso) {
if (!iso) return ''
const d = new Date(iso)
const today = new Date()
const isSameDay = d.toDateString() === today.toDateString()
if (isSameDay) return d.toTimeString().slice(0, 5)
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${m}-${day}`
}
onMounted(async () => {
try { stats.value = await adminFetch('/api/admin/stats') } catch (e) { console.error('加载统计失败', e) }
try {
stats.value = await adminFetch('/api/admin/stats')
nowText.value = new Date().toTimeString().slice(0, 5)
} catch (e) { console.error('加载统计失败', e) }
})
</script>
<style scoped>
.page-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 24px;
}
.page-head h1 {
margin: 0;
font-size: 24px;
color: #111827;
font-weight: 600;
}
.updated-at {
font-size: 12px;
color: #9ca3af;
}
.section-title {
font-size: 18px;
color: #111827;
margin: 32px 0 16px;
font-weight: 600;
}
.stat-card {
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-card--alert {
border-color: #f87171;
}
.stat-label {
font-size: 13px;
color: #6b7280;
display: flex;
align-items: center;
gap: 6px;
}
.stat-badge {
margin-left: auto;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #111827;
line-height: 1.2;
margin-top: 8px;
}
.stat-sub {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
.trend-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
color: #374151;
}
.trend-row + .trend-row {
border-top: 1px dashed #e5e7eb;
}
.trend-row strong {
color: #3a7d44;
font-size: 16px;
}
.card-hint {
font-size: 12px;
color: #9ca3af;
}
.list-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
font-size: 13px;
color: #374151;
}
.list-row + .list-row {
border-top: 1px dashed #e5e7eb;
}
.list-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-meta {
color: #9ca3af;
font-size: 12px;
}
.list-star {
color: #f59e0b;
font-size: 12px;
}
.empty {
text-align: center;
color: #9ca3af;
font-size: 13px;
padding: 16px 0;
}
.quick-card {
cursor: pointer;
transition: transform 0.15s;
}
.quick-card:hover {
transform: translateY(-2px);
}
.quick-title {
font-size: 14px;
font-weight: 500;
color: #111827;
}
.quick-desc {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
</style>

查看文件

@ -90,28 +90,66 @@
<n-form-item label="亮点"><n-dynamic-input v-model:value="ver.highlights" placeholder="输入亮点" /></n-form-item>
</n-form>
</n-card>
<n-alert v-if="ver._v3Itinerary || ver._v3Pricing" type="info" style="margin-top:12px" :show-icon="true" title="此版本使用 V3 结构(分天字符串行程 + 分日期报价)">
<template #default>
行程用纯文本分天表达报价按日期区间记录可直接修改不会破坏原始结构如需深度改动加字段/重排结构请联系开发
</template>
</n-alert>
<n-card title="行程安排" style="margin-top:12px">
<div v-for="(it, ii) in ver.itinerary" :key="ii" style="margin-bottom:12px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
<n-space justify="space-between" style="margin-bottom:8px"><n-tag size="small">Day {{ it.day }}</n-tag><n-button size="tiny" type="error" quaternary @click="ver.itinerary.splice(ii,1)">删除</n-button></n-space>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="it.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="住宿" :show-feedback="false"><n-input v-model:value="it.hotel" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="概要" :show-feedback="false" style="margin-top:8px"><n-input v-model:value="it.summary" type="textarea" :rows="2" /></n-form-item>
<n-space justify="space-between" style="margin-bottom:8px"><n-tag size="small">Day {{ it.day }}</n-tag><n-button size="tiny" type="error" quaternary @click="ver.itinerary.splice(ii,1); if(ver._v3Itinerary) ver._v3Itinerary.splice(ii,1)">删除</n-button></n-space>
<n-form-item v-if="ver._v3Itinerary" label="当日行程(纯文本)" :show-feedback="false">
<n-input v-model:value="it.title" type="textarea" :rows="2" placeholder="D1 各地→海拉尔(接机+自由活动)" />
</n-form-item>
<template v-else>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="标题" :show-feedback="false"><n-input v-model:value="it.title" /></n-form-item></n-gi>
<n-gi><n-form-item label="住宿" :show-feedback="false"><n-input v-model:value="it.hotel" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="概要" :show-feedback="false" style="margin-top:8px"><n-input v-model:value="it.summary" type="textarea" :rows="2" /></n-form-item>
</template>
</div>
<n-button dashed block @click="addItineraryDay(ver)">添加行程日</n-button>
</n-card>
<n-card title="价格配置" style="margin-top:12px">
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="价格标签"><n-input v-model:value="ver.pricing.label" placeholder="2580元起" /></n-form-item></n-gi>
<n-gi><n-form-item label="备注"><n-input v-model:value="ver.pricing.note" /></n-form-item></n-gi>
</n-grid>
<n-grid :cols="4" :x-gap="12">
<n-gi><n-form-item label="起步价"><n-input-number v-model:value="ver.pricing.startPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="旺季价"><n-input-number v-model:value="ver.pricing.peakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="高档起步"><n-input-number v-model:value="ver.pricing.highStartPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="高档旺季"><n-input-number v-model:value="ver.pricing.highPeakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
</n-grid>
<template v-if="ver._v3Pricing">
<n-alert type="warning" :show-icon="false" style="margin-bottom:12px">
此产品使用 V3 分日期报价请在下方表格直接编辑空日期或非正数价格会保留但不会生效
</n-alert>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:#f5f7fa">
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb;width:40%">出行日期</th>
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb">成人价</th>
<th style="padding:8px;text-align:left;border:1px solid #e5e7eb">儿童价</th>
<th style="padding:8px;border:1px solid #e5e7eb;width:60px"></th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in ver._v3Pricing" :key="ri">
<td style="padding:4px;border:1px solid #e5e7eb"><n-input v-model:value="row.date" placeholder="9月1日-9月9日" size="small" /></td>
<td style="padding:4px;border:1px solid #e5e7eb"><n-input-number v-model:value="row.adult" :min="0" size="small" style="width:100%" /></td>
<td style="padding:4px;border:1px solid #e5e7eb"><n-input-number v-model:value="row.child" :min="0" size="small" style="width:100%" /></td>
<td style="padding:4px;border:1px solid #e5e7eb;text-align:center"><n-button size="tiny" type="error" quaternary @click="ver._v3Pricing.splice(ri,1)"></n-button></td>
</tr>
</tbody>
</table>
</div>
<n-button dashed block size="small" style="margin-top:8px" @click="ver._v3Pricing.push({date:'',adult:0,child:0})">添加一行</n-button>
</template>
<template v-else>
<n-grid :cols="2" :x-gap="12">
<n-gi><n-form-item label="价格标签"><n-input v-model:value="ver.pricing.label" placeholder="2580元起" /></n-form-item></n-gi>
<n-gi><n-form-item label="备注"><n-input v-model:value="ver.pricing.note" /></n-form-item></n-gi>
</n-grid>
<n-grid :cols="4" :x-gap="12">
<n-gi><n-form-item label="起步价"><n-input-number v-model:value="ver.pricing.startPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="旺季价"><n-input-number v-model:value="ver.pricing.peakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="高档起步"><n-input-number v-model:value="ver.pricing.highStartPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
<n-gi><n-form-item label="高档旺季"><n-input-number v-model:value="ver.pricing.highPeakPrice" :min="0" style="width:100%" /></n-form-item></n-gi>
</n-grid>
</template>
</n-card>
<n-card title="费用说明" style="margin-top:12px">
<n-form-item label="住宿"><n-input v-model:value="ver.included.accommodation" /></n-form-item>
@ -398,7 +436,7 @@ import { h } from 'vue'
import {
NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NForm, NFormItem,
NInput, NInputNumber, NBreadcrumb, NBreadcrumbItem,
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty, NSwitch,
NTabs, NTabPane, NCollapse, NCollapseItem, NDynamicInput, NEmpty, NSwitch, NAlert,
} from 'naive-ui'
import { useMessage } from 'naive-ui'
@ -497,19 +535,33 @@ function normalizeData(raw) {
return d
}
// V3 pricing / itinerary
function isV3Pricing(p) { return Array.isArray(p) }
function isV3Itinerary(it) { return Array.isArray(it) && it.length > 0 && typeof it[0] === 'string' }
function normalizeVersion(v) {
const v3Pricing = isV3Pricing(v.pricing) ? v.pricing : null
const v3Itinerary = isV3Itinerary(v.itinerary) ? [...v.itinerary] : null
return {
id: v.id || '', name: v.name || '', tag: v.tag || '',
days: v.days || 0, nights: v.nights || 0, line: v.line || '', route: v.route || '',
audience: v.audience || '', description: v.description || '', isMainProduct: v.isMainProduct || false,
highlights: Array.isArray(v.highlights) ? v.highlights : [],
itinerary: normalizeItinerary(v.itinerary || []),
pricing: {
label: v.pricing?.label || '', note: v.pricing?.note || '',
startPrice: v.pricing?.startPrice || 0, peakPrice: v.pricing?.peakPrice || 0,
highStartPrice: v.pricing?.highStartPrice || 0, highPeakPrice: v.pricing?.highPeakPrice || 0,
tiers: v.pricing?.tiers || [],
},
// V3
_v3Pricing: v3Pricing,
_v3Itinerary: v3Itinerary,
itinerary: v3Itinerary
? v3Itinerary.map((str, i) => ({ day: i + 1, title: str, summary: '', hotel: '', _v3: true }))
: normalizeItinerary(v.itinerary || []),
pricing: v3Pricing
? { _v3: true, label: '', note: '', startPrice: 0, peakPrice: 0, highStartPrice: 0, highPeakPrice: 0, tiers: [] }
: {
label: v.pricing?.label || '', note: v.pricing?.note || '',
startPrice: v.pricing?.startPrice || 0, peakPrice: v.pricing?.peakPrice || 0,
highStartPrice: v.pricing?.highStartPrice || 0, highPeakPrice: v.pricing?.highPeakPrice || 0,
tiers: v.pricing?.tiers || [],
},
included: {
accommodation: v.included?.accommodation || '', transport: v.included?.transport || '',
tickets: v.included?.tickets || '', activities: v.included?.activities || '',
@ -529,6 +581,38 @@ function normalizeItinerary(items) {
})
}
// V3 pricing/itinerary UI
function serializeForSave(raw) {
const clone = JSON.parse(JSON.stringify(raw))
if (Array.isArray(clone.versions)) {
clone.versions = clone.versions.map(v => {
const out = { ...v }
// V3
if (Array.isArray(v._v3Itinerary)) {
out.itinerary = (v.itinerary || []).map(it => it.title || '').filter(Boolean)
}
// V3
if (Array.isArray(v._v3Pricing)) {
out.pricing = v._v3Pricing
}
delete out._v3Pricing
delete out._v3Itinerary
if (out.itinerary && Array.isArray(out.itinerary)) {
out.itinerary = out.itinerary.map(it => {
if (it && typeof it === 'object') { const { _v3, ...rest } = it; return rest }
return it
})
}
if (out.pricing && typeof out.pricing === 'object' && !Array.isArray(out.pricing)) {
const { _v3, ...rest } = out.pricing
out.pricing = rest
}
return out
})
}
return clone
}
//
function addVersion() {
if (!data.value.versions) data.value.versions = []
@ -565,7 +649,7 @@ async function handleSave() {
try {
await adminFetch('/api/admin/site-content', {
method: 'PUT',
body: { key: route.params.key, data: data.value },
body: { key: route.params.key, data: serializeForSave(data.value) },
})
message.success('保存成功')
} catch (e) {

查看文件

@ -11,7 +11,7 @@
<n-spin :show="loading">
<n-grid :cols="1" :y-gap="12">
<n-gi v-for="(item, index) in productLines" :key="item.key">
<n-card hoverable>
<n-card hoverable translate="no">
<n-space justify="space-between" align="center">
<n-space align="center" :size="16">
<span style="font-size: 14px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: #f0f7f1; border-radius: 8px; font-weight: 600; color: #3a7d44">{{ item.name?.charAt(0) || 'P' }}</span>
@ -20,10 +20,10 @@
{{ item.name }}
<n-tag v-if="item.version" size="small" type="info" :bordered="false" style="margin-left: 8px">{{ item.version }}</n-tag>
<n-tag size="small" :type="seasonType(item.season)" :bordered="false" style="margin-left: 4px">{{ seasonLabel(item.season) }}</n-tag>
<n-tag size="small" :bordered="false" style="margin-left: 4px">{{ item.type === 'camp' ? '营地' : '线路' }}</n-tag>
<n-tag size="small" :bordered="false" style="margin-left: 4px">{{ typeLabel(item.type) }}</n-tag>
</div>
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px">
key: {{ item.key }} · 排序: {{ item.sortOrder }}
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px" translate="no">
排序 {{ item.sortOrder }}
</div>
</div>
</n-space>
@ -72,7 +72,7 @@
</n-gi>
<n-gi>
<n-form-item label="类型">
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }]" />
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }, { label: '课程', value: 'course' }]" />
</n-form-item>
</n-gi>
</n-grid>
@ -106,6 +106,9 @@ const newItem = ref({ name: '', key: '', version: '', season: 'summer', type: 't
function seasonLabel(s) {
return { spring: '春', summer: '夏', autumn: '秋', winter: '冬', all: '全年' }[s] || s
}
function typeLabel(t) {
return { tour: '线路', camp: '营地', course: '课程' }[t] || t
}
function seasonType(s) {
return { spring: 'success', summer: 'success', autumn: 'warning', winter: 'info', all: 'default' }[s] || 'default'

查看文件

@ -44,6 +44,20 @@
<NFormItem label="出行日期">
<NInput v-model:value="form.date" placeholder="如2026年3月" />
</NFormItem>
<NFormItem label="评价截图">
<div class="screenshot-wrapper">
<AdminImageUpload v-model="form.screenshot" />
<div v-if="form.screenshot" class="screenshot-actions">
<NButton size="tiny" @click="previewOpen = true">🔍 查看大图</NButton>
<NButton
size="tiny"
:loading="ocrLoading"
:disabled="ocrLoading"
@click="handleOcr"
> 从截图识别文字</NButton>
</div>
</div>
</NFormItem>
<NFormItem label="内容" required>
<NInput
v-model:value="form.content"
@ -54,6 +68,31 @@
</NFormItem>
</NForm>
</NCard>
<NCard title="分类标签" style="margin-top: 16px;">
<NForm label-placement="top" :show-feedback="false">
<NFormItem label="出行场景(多选)">
<NSelect
v-model:value="form.scenes"
multiple
filterable
tag
:options="SCENE_OPTIONS"
placeholder="选择或输入场景"
/>
</NFormItem>
<NFormItem label="关心维度(多选)">
<NSelect
v-model:value="form.concerns"
multiple
filterable
tag
:options="CONCERN_OPTIONS"
placeholder="选择或输入关心点"
/>
</NFormItem>
</NForm>
</NCard>
</NGi>
<!-- 侧栏1 -->
@ -61,14 +100,17 @@
<NCard title="发布设置">
<NForm label-placement="top" :show-feedback="false">
<NFormItem label="状态">
<NSelect
v-model:value="form.published"
:options="publishedOptions"
/>
<NSelect v-model:value="form.published" :options="publishedOptions" />
</NFormItem>
<NFormItem label="已验证">
<NSwitch v-model:value="form.verified" />
</NFormItem>
<NFormItem label="排序">
<NInputNumber v-model:value="form.sortOrder" :min="0" style="width:100%" />
<div style="font-size: 11px; color: #9ca3af; margin-top: 4px;">
数字越小越靠前0 为默认
</div>
</NFormItem>
</NForm>
</NCard>
</NGi>
@ -81,6 +123,11 @@
保存
</NButton>
</div>
<!-- 截图 Lightbox -->
<NModal v-model:show="previewOpen" preset="card" style="max-width: 640px;" :bordered="false" title="评价截图">
<img v-if="form.screenshot" :src="form.screenshot" style="width:100%;display:block;" />
</NModal>
</template>
</div>
</template>
@ -88,7 +135,8 @@
<script setup>
import {
NButton, NCard, NForm, NFormItem, NInput, NSelect, NSwitch, NRate,
NBreadcrumb, NBreadcrumbItem, NGrid, NGi, NSpin, useMessage,
NBreadcrumb, NBreadcrumbItem, NGrid, NGi, NSpin, NInputNumber, NModal,
useMessage,
} from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
@ -101,13 +149,19 @@ const isNew = computed(() => route.params.id === 'new')
const pageLoading = ref(!isNew.value)
const loadError = ref('')
const saving = ref(false)
const previewOpen = ref(false)
const ocrLoading = ref(false)
const form = ref({
author: '',
title: '',
date: '',
content: '',
screenshot: '',
scenes: [],
concerns: [],
rating: 5,
sortOrder: 0,
published: true,
verified: false,
})
@ -117,14 +171,30 @@ const publishedOptions = [
{ label: '草稿', value: false },
]
const SCENE_OPTIONS = [
'家庭出游', '亲子', '带老人', '摄影', '蜜月', '毕业游', '多家庭',
].map(v => ({ label: v, value: v }))
const CONCERN_OPTIONS = [
'领队服务', '行程安排', '住宿体验', '摄影旅拍', '错峰不排队',
'性价比', '安全放心', '特色体验', '饮食',
].map(v => ({ label: v, value: v }))
async function loadItem() {
if (isNew.value) return
pageLoading.value = true
try {
const data = await adminFetch(`/api/admin/reviews/${route.params.id}`)
form.value = {
...data,
author: data.author || '',
title: data.title || '',
date: data.date || '',
content: data.content || '',
screenshot: data.screenshot || '',
scenes: Array.isArray(data.scenes) ? data.scenes : [],
concerns: Array.isArray(data.concerns) ? data.concerns : [],
rating: data.rating ?? 5,
sortOrder: data.sortOrder || 0,
published: !!data.published,
verified: !!data.verified,
}
@ -156,6 +226,24 @@ async function handleSave() {
}
}
async function handleOcr() {
if (!form.value.screenshot) { message.warning('请先上传截图'); return }
ocrLoading.value = true
try {
const res = await adminFetch('/api/admin/ocr', { method: 'POST', body: { image: form.value.screenshot } })
if (res?.text) {
form.value.content = res.text.trim()
message.success('已识别,可在内容区继续调整')
} else {
message.warning('未识别到文字')
}
} catch (e) {
message.error(e.data?.message || 'OCR 失败')
} finally {
ocrLoading.value = false
}
}
onMounted(loadItem)
</script>
@ -191,6 +279,16 @@ onMounted(loadItem)
margin-bottom: 0;
}
.screenshot-wrapper {
width: 100%;
}
.screenshot-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.save-bar {
position: sticky;
bottom: 0;

查看文件

@ -1,5 +1,5 @@
<template>
<div style="max-width: 1200px">
<div style="max-width: 1400px">
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
<div>
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">客户评价</h1>
@ -12,17 +12,81 @@
</n-space>
<n-card>
<n-space style="margin-bottom: 16px">
<n-input v-model:value="search" placeholder="搜索客户、内容..." clearable style="width: 260px" @keyup.enter="page = 1; loadData()">
<!-- 筛选栏 -->
<div class="filter-row">
<n-input
v-model:value="search"
placeholder="搜索客户、内容..."
clearable
style="width: 220px"
@keyup.enter="resetAndLoad"
>
<template #prefix><span style="color: #9ca3af">&#128269;</span></template>
</n-input>
<n-button @click="page = 1; loadData()">搜索</n-button>
</n-space>
<n-select
v-model:value="filters.published"
:options="publishedFilterOptions"
placeholder="发布状态"
clearable
style="width: 130px"
@update:value="resetAndLoad"
/>
<n-select
v-model:value="filters.hasScreenshot"
:options="screenshotFilterOptions"
placeholder="截图"
clearable
style="width: 130px"
@update:value="resetAndLoad"
/>
<n-select
v-model:value="filters.scene"
:options="sceneOptions"
placeholder="场景"
clearable
style="width: 150px"
@update:value="resetAndLoad"
/>
<n-select
v-model:value="filters.concern"
:options="concernOptions"
placeholder="关心维度"
clearable
style="width: 150px"
@update:value="resetAndLoad"
/>
<n-button @click="resetAndLoad">搜索</n-button>
<n-button quaternary @click="clearFilters">重置</n-button>
</div>
<n-data-table :columns="columns" :data="list" :loading="loading" :row-key="(row) => row.id" :pagination="false" size="small" striped />
<!-- 批量操作工具栏 -->
<div v-if="checkedIds.length" class="bulk-toolbar">
<span class="bulk-count">已选 {{ checkedIds.length }} </span>
<n-button size="small" @click="bulk('publish')">批量发布</n-button>
<n-button size="small" @click="bulk('unpublish')">批量下线</n-button>
<n-popconfirm @positive-click="bulk('delete')">
<template #trigger>
<n-button size="small" type="error">批量删除</n-button>
</template>
确定删除选中的 {{ checkedIds.length }} 条评价
</n-popconfirm>
<n-button size="tiny" quaternary @click="checkedIds = []">清空选择</n-button>
</div>
<n-space justify="end" style="margin-top: 16px" v-if="totalPages > 1">
<n-pagination v-model:page="page" :page-count="totalPages" @update:page="loadData" />
<n-data-table
:columns="columns"
:data="list"
:loading="loading"
:row-key="(row) => row.id"
v-model:checked-row-keys="checkedIds"
:pagination="false"
size="small"
striped
/>
<n-space justify="space-between" align="center" style="margin-top: 16px" v-if="total">
<span style="color: #9ca3af; font-size: 12px;"> {{ total }} </span>
<n-pagination v-if="totalPages > 1" v-model:page="page" :page-count="totalPages" @update:page="loadData" />
</n-space>
</n-card>
</div>
@ -30,39 +94,111 @@
<script setup>
import { h } from 'vue'
import { NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm, NRate } from 'naive-ui'
import { useMessage } from 'naive-ui'
import {
NButton, NCard, NSpace, NInput, NDataTable, NTag, NPagination, NPopconfirm,
NRate, NSelect, NInputNumber, useMessage,
} from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const { moveUp, moveDown } = useAdminSort('/api/admin/reviews')
const list = ref([])
const total = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(false)
const search = ref('')
const checkedIds = ref([])
const filters = reactive({
published: null,
hasScreenshot: null,
scene: null,
concern: null,
})
const publishedFilterOptions = [
{ label: '已发布', value: 'true' },
{ label: '草稿', value: 'false' },
]
const screenshotFilterOptions = [
{ label: '有截图', value: 'true' },
{ label: '无截图', value: 'false' },
]
const sceneOptions = [
'家庭出游', '亲子', '带老人', '摄影', '蜜月', '毕业游', '多家庭',
].map(v => ({ label: v, value: v }))
const concernOptions = [
'领队服务', '行程安排', '住宿体验', '摄影旅拍', '错峰不排队',
'性价比', '安全放心', '特色体验', '饮食',
].map(v => ({ label: v, value: v }))
function clearFilters() {
search.value = ''
filters.published = null
filters.hasScreenshot = null
filters.scene = null
filters.concern = null
resetAndLoad()
}
function resetAndLoad() {
page.value = 1
loadData()
}
const columns = [
{ type: 'selection', width: 40 },
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
{
title: '客户', key: 'author',
title: '截图', key: 'screenshot', width: 72,
render: (row) => row.screenshot
? h('img', {
src: row.screenshot,
style: 'width: 48px; height: 48px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #e5e7eb;',
referrerpolicy: 'no-referrer',
onClick: () => window.open(row.screenshot, '_blank'),
})
: h('span', { style: 'color: #d1d5db; font-size: 11px;' }, '无图'),
},
{
title: '客户', key: 'author', width: 100,
render: (row) => h('a', {
style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer',
onClick: () => navigateTo(`/admin/reviews/${row.id}`),
}, row.author),
},
{
title: '评分', key: 'rating', width: 120,
title: '评分', key: 'rating', width: 110,
render: (row) => h(NRate, { value: row.rating || 5, readonly: true, size: 'small' }),
},
{ title: '产品版本', key: 'title', width: 100 },
{ title: '产品版本', key: 'title', width: 160, ellipsis: { tooltip: true } },
{
title: '内容摘要', key: 'content',
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.content?.slice(0, 50)),
title: '内容摘要', key: 'content', minWidth: 200,
render: (row) => h('span', { style: 'color: #6b7280; font-size: 12px' }, row.content?.slice(0, 60)),
},
{
title: '标签', key: 'tags', width: 220,
render: (row) => h('div', { style: 'display: flex; flex-wrap: wrap; gap: 4px;' }, [
...(row.scenes || []).map(s => h(NTag, { size: 'small', type: 'info', bordered: false }, () => s)),
...(row.concerns || []).map(c => h(NTag, { size: 'small', type: 'success', bordered: false }, () => c)),
]),
},
{ title: '出行日期', key: 'date', width: 90, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.date) },
{
title: '排序', key: 'sortOrder', width: 140,
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
h(NInputNumber, {
value: row.sortOrder || 0, size: 'tiny', min: 0, showButton: false,
style: 'width: 52px;',
'onUpdate:value': (v) => updateSortOrder(row, v),
}),
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
]),
},
{
title: '状态', key: 'published', width: 70,
render: (row) => h(NTag, {
@ -72,7 +208,7 @@ const columns = [
}, () => row.published ? '已发布' : '草稿'),
},
{
title: '操作', key: 'actions', width: 120,
title: '操作', key: 'actions', width: 110, fixed: 'right',
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, { size: 'tiny', text: true, type: 'primary', onClick: () => navigateTo(`/admin/reviews/${row.id}`) }, () => '编辑'),
h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
@ -86,8 +222,19 @@ const columns = [
async function loadData() {
loading.value = true
try {
const d = await adminFetch(`/api/admin/reviews?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`)
list.value = d.items; total.value = d.total; totalPages.value = d.totalPages
const params = new URLSearchParams({
page: page.value,
limit: 20,
search: search.value,
})
if (filters.published) params.set('published', filters.published)
if (filters.hasScreenshot) params.set('hasScreenshot', filters.hasScreenshot)
if (filters.scene) params.set('scene', filters.scene)
if (filters.concern) params.set('concern', filters.concern)
const d = await adminFetch(`/api/admin/reviews?${params}`)
list.value = d.items
total.value = d.total
totalPages.value = d.totalPages
} catch { message.error('加载失败') } finally { loading.value = false }
}
@ -98,6 +245,14 @@ async function togglePublish(item) {
} catch { message.error('操作失败') }
}
async function updateSortOrder(item, value) {
const v = Number(value) || 0
item.sortOrder = v
try {
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'PUT', body: { sortOrder: v } })
} catch { message.error('排序更新失败') }
}
async function handleDelete(item) {
try {
await adminFetch(`/api/admin/reviews/${item.id}`, { method: 'DELETE' })
@ -106,5 +261,44 @@ async function handleDelete(item) {
} catch { message.error('删除失败') }
}
async function bulk(action) {
if (!checkedIds.value.length) return
try {
const res = await adminFetch('/api/admin/reviews/bulk', {
method: 'POST',
body: { action, ids: checkedIds.value },
})
message.success(`${ {publish:'发布',unpublish:'下线',delete:'删除'}[action] } ${res.affected}`)
checkedIds.value = []
loadData()
} catch { message.error('操作失败') }
}
onMounted(loadData)
</script>
<style scoped>
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 16px;
}
.bulk-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
}
.bulk-count {
font-size: 13px;
font-weight: 500;
color: #065f46;
margin-right: 4px;
}
</style>

查看文件

@ -46,6 +46,29 @@
<n-form-item label="副标题">
<n-input v-model:value="form.subtitle" placeholder="输入副标题(可选)" />
</n-form-item>
<n-form-item label="卡片摘要">
<n-input
v-model:value="form.summary"
type="textarea"
:rows="2"
placeholder="在客户故事墙卡片上展示的 1-2 句话(代替副标题)"
/>
</n-form-item>
<n-grid :cols="2" :x-gap="16">
<n-gi>
<n-form-item label="作者 / 客户昵称">
<n-input v-model:value="form.author" placeholder="如:张女士" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="外部来源 URL">
<n-input v-model:value="form.sourceUrl" placeholder="如小红书链接,有则卡片外跳" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="标签">
<n-dynamic-tags v-model:value="form.tags" />
</n-form-item>
</n-form>
</n-card>
@ -169,6 +192,12 @@
</template>
<script setup>
import {
NBreadcrumb, NBreadcrumbItem, NButton, NSpace, NCard, NGrid, NGi,
NForm, NFormItem, NInput, NSelect, NDynamicInput, NDynamicTags,
useMessage,
} from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const route = useRoute()
@ -184,6 +213,10 @@ const form = ref({
title: '',
slug: '',
subtitle: '',
summary: '',
author: '',
sourceUrl: '',
tags: [],
familyType: '',
childrenAge: '',
fromCity: '',
@ -205,6 +238,12 @@ async function loadItem() {
...data,
published: !!data.published,
coverImage: data.coverImage || '',
summary: data.summary || '',
author: data.author || '',
sourceUrl: data.sourceUrl || '',
tags: Array.isArray(data.tags)
? data.tags
: (typeof data.tags === 'string' ? (() => { try { return JSON.parse(data.tags || '[]') } catch { return [] } })() : []),
sections:
typeof data.sections === 'string'
? JSON.parse(data.sections || '[]')
@ -233,11 +272,8 @@ async function handleSave() {
saving.value = true
const payload = {
...form.value,
sections: JSON.stringify(form.value.sections),
keyMoments: JSON.stringify(form.value.keyMoments),
}
// stringify
const payload = { ...form.value }
try {
if (isNew.value) {

查看文件

@ -37,10 +37,19 @@ definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const { moveUp, moveDown } = useAdminSort('/api/admin/stories')
const list = ref([]); const total = ref(0); const page = ref(1); const totalPages = ref(1); const loading = ref(false); const search = ref('')
const columns = [
{ title: 'ID', key: 'id', width: 50, render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px' }, row.id) },
{
title: '排序', key: 'sortOrder', width: 110,
render: (row, idx) => h(NSpace, { size: 4, align: 'center' }, () => [
h('span', { style: 'color: #9ca3af; font-size: 12px; min-width: 20px' }, row.sortOrder),
h(NButton, { size: 'tiny', disabled: idx === 0, onClick: async () => { await moveUp(list.value, idx); message.success('已上移') } }, () => '↑'),
h(NButton, { size: 'tiny', disabled: idx === list.value.length - 1, onClick: async () => { await moveDown(list.value, idx); message.success('已下移') } }, () => '↓'),
]),
},
{
title: '标题', key: 'title',
render: (row) => h('a', { style: 'color: #111827; text-decoration: none; font-weight: 500; cursor: pointer', onClick: () => navigateTo(`/admin/stories/${row.id}`) }, row.title),

查看文件

@ -2,18 +2,27 @@
<div style="max-width: 1200px;">
<div style="margin-bottom: 20px;">
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827;">表单提交</h1>
<p style="margin: 0; font-size: 13px; color: #9ca3af;">查看客户提交的联系留言和定制咨询</p>
<p style="margin: 0; font-size: 13px; color: #9ca3af;">查看编辑回复删除客户提交的联系留言和定制咨询</p>
</div>
<NSpace vertical :size="16">
<NSpace align="center" justify="space-between">
<NButtonGroup>
<NButton
v-for="tab in tabs" :key="tab.value"
:type="filterType === tab.value ? 'primary' : 'default'"
@click="onFilterChange(tab.value)"
>{{ tab.label }}</NButton>
</NButtonGroup>
<NSpace :size="8" align="center">
<NButtonGroup>
<NButton
v-for="tab in tabs" :key="tab.value"
:type="filterType === tab.value ? 'primary' : 'default'"
@click="onFilterChange(tab.value)"
>{{ tab.label }}</NButton>
</NButtonGroup>
<NButtonGroup>
<NButton
v-for="tab in statusTabs" :key="tab.value"
:type="filterStatus === tab.value ? 'primary' : 'default'"
@click="onStatusChange(tab.value)"
>{{ tab.label }}</NButton>
</NButtonGroup>
</NSpace>
<NInput
v-model:value="search"
placeholder="搜索姓名、手机..."
@ -25,7 +34,7 @@
<NDataTable
:columns="columns"
:data="list"
:data="filteredList"
:loading="loading"
:row-key="row => row.id"
:bordered="false"
@ -43,10 +52,10 @@
</NSpace>
<!-- 详情抽屉 -->
<NDrawer v-model:show="drawerVisible" :width="480">
<NDrawer v-model:show="drawerVisible" :width="520">
<NDrawerContent title="提交详情" closable>
<template v-if="currentItem">
<NDescriptions bordered :column="1" label-placement="left">
<NDescriptions bordered :column="1" label-placement="left" size="small">
<NDescriptionsItem label="ID">{{ currentItem.id }}</NDescriptionsItem>
<NDescriptionsItem label="类型">
<NTag :type="currentItem.type === 'contact' ? 'info' : 'warning'" size="small">
@ -56,20 +65,24 @@
<NDescriptionsItem label="姓名">{{ currentItem.data?.name }}</NDescriptionsItem>
<NDescriptionsItem label="手机">{{ currentItem.data?.phone }}</NDescriptionsItem>
<NDescriptionsItem label="邮箱" v-if="currentItem.data?.email">{{ currentItem.data?.email }}</NDescriptionsItem>
<NDescriptionsItem label="微信" v-if="currentItem.data?.wechat">{{ currentItem.data?.wechat }}</NDescriptionsItem>
<NDescriptionsItem label="内容">
<div style="white-space: pre-wrap;">{{ currentItem.data?.message }}</div>
<div style="white-space: pre-wrap;">{{ currentItem.data?.message || currentItem.data?.remarks || '(无)' }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="状态">
<NTag :type="currentItem.read ? 'success' : 'warning'" size="small">
{{ currentItem.read ? '已读' : '未读' }}
</NTag>
<NSpace :size="4">
<NTag :type="statusTag(currentItem).type" size="small">{{ statusTag(currentItem).label }}</NTag>
<NTag v-if="currentItem.repliedAt" type="default" size="small">
回复于 {{ formatTime(currentItem.repliedAt) }}
</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem label="提交时间">{{ formatTime(currentItem.createdAt) }}</NDescriptionsItem>
<NDescriptionsItem label="IP" v-if="currentItem.ip">{{ currentItem.ip }}</NDescriptionsItem>
</NDescriptions>
<!-- 显示 data 中除 name/phone/email/message 外的其他字段 -->
<template v-if="extraFields.length > 0">
<h4 style="margin: 16px 0 8px;">其他信息</h4>
<NDescriptions bordered :column="1" label-placement="left">
<NDescriptions bordered :column="1" label-placement="left" size="small">
<NDescriptionsItem v-for="field in extraFields" :key="field.key" :label="field.key">
{{ field.value }}
</NDescriptionsItem>
@ -77,24 +90,77 @@
</template>
</template>
<template #footer>
<NButton
v-if="currentItem && !currentItem.read"
type="primary"
@click="markRead(currentItem)"
>标记已读</NButton>
<NSpace>
<NButton
v-if="currentItem && !currentItem.read"
@click="markRead(currentItem)"
>标记已读</NButton>
<NButton
v-if="currentItem && !currentItem.replied"
type="primary"
@click="markReplied(currentItem)"
>标记已回复</NButton>
<NButton
v-if="currentItem && currentItem.replied"
@click="unmarkReplied(currentItem)"
>撤销已回复</NButton>
<NButton @click="openEdit(currentItem)">编辑</NButton>
</NSpace>
</template>
</NDrawerContent>
</NDrawer>
<!-- 编辑弹窗 -->
<NModal v-model:show="editVisible" preset="card" title="编辑提交" style="width: 540px;">
<NForm
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-placement="left"
label-width="70"
>
<NFormItem label="姓名" path="name">
<NInput v-model:value="editForm.name" placeholder="姓名" />
</NFormItem>
<NFormItem label="手机" path="phone">
<NInput v-model:value="editForm.phone" placeholder="11 位手机号" />
</NFormItem>
<NFormItem label="邮箱">
<NInput v-model:value="editForm.email" placeholder="可选" />
</NFormItem>
<NFormItem label="微信">
<NInput v-model:value="editForm.wechat" placeholder="可选" />
</NFormItem>
<NFormItem label="内容">
<NInput
v-model:value="editForm.message"
type="textarea"
:rows="4"
placeholder="留言内容 / 备注"
/>
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="editVisible = false">取消</NButton>
<NButton type="primary" :loading="saving" @click="saveEdit">保存</NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
<script setup>
import { NButton, NTag, NDataTable, NInput, NSpace, NDrawer, NDrawerContent, NDescriptions, NDescriptionsItem, NBadge, NButtonGroup, NPagination } from 'naive-ui'
import { useMessage } from 'naive-ui'
import {
NButton, NTag, NDataTable, NInput, NSpace, NDrawer, NDrawerContent,
NDescriptions, NDescriptionsItem, NButtonGroup, NPagination,
NModal, NForm, NFormItem, useDialog, useMessage
} from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const dialog = useDialog()
const list = ref([])
const total = ref(0)
@ -103,18 +169,53 @@ const totalPages = ref(1)
const loading = ref(false)
const search = ref('')
const filterType = ref('')
const filterStatus = ref('')
const drawerVisible = ref(false)
const currentItem = ref(null)
const editVisible = ref(false)
const editFormRef = ref(null)
const saving = ref(false)
const editingId = ref(null)
const editForm = reactive({ name: '', phone: '', email: '', wechat: '', message: '' })
const editRules = {
name: { required: true, message: '请输入姓名', trigger: 'blur' },
phone: {
required: true,
trigger: 'blur',
validator(_rule, v) {
if (!v) return new Error('请输入手机号')
if (!/^\d{6,15}$/.test(v)) return new Error('手机号格式不正确')
return true
}
}
}
const tabs = [
{ label: '全部', value: '' },
{ label: '全部类型', value: '' },
{ label: '留言', value: 'contact' },
{ label: '定制', value: 'customize' },
]
const statusTabs = [
{ label: '全部状态', value: '' },
{ label: '未读', value: 'unread' },
{ label: '已读未回', value: 'read' },
{ label: '已回复', value: 'replied' },
]
const filteredList = computed(() => {
if (!filterStatus.value) return list.value
return list.value.filter(r => {
if (filterStatus.value === 'unread') return !r.read && !r.replied
if (filterStatus.value === 'read') return r.read && !r.replied
if (filterStatus.value === 'replied') return r.replied
return true
})
})
const extraFields = computed(() => {
if (!currentItem.value?.data) return []
const knownKeys = ['name', 'phone', 'email', 'message']
const knownKeys = ['name', 'phone', 'email', 'wechat', 'message', 'remarks']
return Object.entries(currentItem.value.data)
.filter(([k]) => !knownKeys.includes(k))
.map(([key, value]) => ({ key, value: typeof value === 'object' ? JSON.stringify(value) : String(value) }))
@ -125,90 +226,60 @@ function formatTime(t) {
return t.slice(0, 16).replace('T', ' ')
}
function statusTag(row) {
if (row.replied) return { type: 'success', label: '已回复' }
if (row.read) return { type: 'info', label: '已读' }
return { type: 'warning', label: '未读' }
}
const columns = [
{
title: 'ID',
key: 'id',
width: 60,
render(row) {
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.id)
}
title: 'ID', key: 'id', width: 60,
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.id)
},
{
title: '类型',
key: 'type',
width: 80,
render(row) {
return h(NTag, {
type: row.type === 'contact' ? 'info' : 'warning',
size: 'small',
}, () => row.type === 'contact' ? '留言' : '定制')
}
title: '类型', key: 'type', width: 70,
render: (row) => h(NTag, {
type: row.type === 'contact' ? 'info' : 'warning',
size: 'small',
}, () => row.type === 'contact' ? '留言' : '定制')
},
{
title: '姓名',
key: 'name',
width: 100,
render(row) {
return row.data?.name || ''
}
title: '姓名', key: 'name', width: 100,
render: (row) => row.data?.name || ''
},
{
title: '手机',
key: 'phone',
width: 130,
render(row) {
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
}
title: '手机', key: 'phone', width: 130,
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
},
{
title: '内容',
key: 'message',
ellipsis: { tooltip: true },
render(row) {
const msg = row.data?.message || ''
title: '内容', key: 'message', ellipsis: { tooltip: true },
render: (row) => {
const msg = row.data?.message || row.data?.remarks || ''
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, msg.length > 50 ? msg.slice(0, 50) + '...' : msg)
}
},
{
title: '提交时间',
key: 'createdAt',
width: 150,
render(row) {
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, formatTime(row.createdAt))
title: '提交时间', key: 'createdAt', width: 150,
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, formatTime(row.createdAt))
},
{
title: '状态', key: 'status', width: 80,
render: (row) => {
const s = statusTag(row)
return h(NTag, { type: s.type, size: 'small' }, () => s.label)
}
},
{
title: '状态',
key: 'read',
width: 70,
title: '操作', key: 'actions', width: 260,
render(row) {
return h(NTag, {
type: row.read ? 'success' : 'warning',
size: 'small',
}, () => row.read ? '已读' : '未读')
}
},
{
title: '操作',
key: 'actions',
width: 160,
render(row) {
return h(NSpace, { size: 8 }, () => [
h(NButton, {
size: 'small',
quaternary: true,
type: 'info',
onClick: () => openDetail(row),
}, () => '查看'),
!row.read
? h(NButton, {
size: 'small',
quaternary: true,
type: 'primary',
onClick: () => markRead(row),
}, () => '标记已读')
: null,
return h(NSpace, { size: 4 }, () => [
h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '查看'),
h(NButton, { size: 'small', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
!row.replied
? h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => markReplied(row) }, () => '标记已回复')
: h(NButton, { size: 'small', quaternary: true, onClick: () => unmarkReplied(row) }, () => '撤销回复'),
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, () => '删除'),
])
}
},
@ -250,23 +321,133 @@ function onFilterChange(type) {
loadData()
}
function onStatusChange(status) {
filterStatus.value = status
}
function openDetail(row) {
currentItem.value = row
drawerVisible.value = true
//
if (!row.read) markRead(row, { silent: true })
}
async function markRead(row) {
function openEdit(row) {
editingId.value = row.id
editForm.name = row.data?.name || ''
editForm.phone = row.data?.phone || ''
editForm.email = row.data?.email || ''
editForm.wechat = row.data?.wechat || ''
editForm.message = row.data?.message || row.data?.remarks || ''
editVisible.value = true
}
async function saveEdit() {
if (!editFormRef.value) return
try {
await editFormRef.value.validate()
} catch (e) {
return
}
saving.value = true
try {
const row = list.value.find(r => r.id === editingId.value)
if (!row) {
message.error('记录不存在')
editVisible.value = false
return
}
// data
const nextData = { ...(row.data || {}) }
nextData.name = editForm.name
nextData.phone = editForm.phone
if (editForm.email !== undefined) nextData.email = editForm.email
if (editForm.wechat !== undefined) nextData.wechat = editForm.wechat
// message message remarks
if ('remarks' in nextData && !('message' in nextData)) {
nextData.remarks = editForm.message
} else {
nextData.message = editForm.message
}
const updated = await adminFetch(`/api/admin/submissions/${editingId.value}`, {
method: 'PUT',
body: { data: nextData },
})
Object.assign(row, updated)
if (currentItem.value?.id === editingId.value) currentItem.value = updated
message.success('已保存')
editVisible.value = false
} catch (e) {
message.error('保存失败:' + (e.data?.message || e.message || '未知错误'))
} finally {
saving.value = false
}
}
async function markRead(row, opts = {}) {
try {
await adminFetch(`/api/admin/submissions/${row.id}`, {
method: 'PUT',
body: { read: true },
})
row.read = true
message.success('已标记为已读')
if (!opts.silent) message.success('已标记为已读')
} catch (e) {
if (!opts.silent) message.error('操作失败')
}
}
async function markReplied(row) {
try {
const updated = await adminFetch(`/api/admin/submissions/${row.id}`, {
method: 'PUT',
body: { replied: true },
})
Object.assign(row, updated)
if (currentItem.value?.id === row.id) currentItem.value = updated
message.success('已标记为已回复')
} catch (e) {
message.error('操作失败')
}
}
async function unmarkReplied(row) {
try {
const updated = await adminFetch(`/api/admin/submissions/${row.id}`, {
method: 'PUT',
body: { replied: false },
})
Object.assign(row, updated)
if (currentItem.value?.id === row.id) currentItem.value = updated
message.success('已撤销回复')
} catch (e) {
message.error('操作失败')
}
}
function confirmDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定要删除来自「${row.data?.name || '匿名'}」(ID ${row.id}) 的提交记录吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await adminFetch(`/api/admin/submissions/${row.id}`, { method: 'DELETE' })
list.value = list.value.filter(r => r.id !== row.id)
if (currentItem.value?.id === row.id) {
drawerVisible.value = false
currentItem.value = null
}
total.value = Math.max(0, total.value - 1)
message.success('已删除')
} catch (e) {
message.error('删除失败')
}
}
})
}
onMounted(loadData)
</script>

查看文件

@ -79,7 +79,7 @@
<div class="sidebar-card sidebar-card--cta">
<h3 class="sidebar-card__title">免费获取定制方案</h3>
<p class="sidebar-card__desc">看完攻略有疑问扫码添加定制师微信一对一解答</p>
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="sidebar-qrcode" />
<img :src="'/images/contact/qrcode-wechat.jpg'" alt="定制师微信二维码" class="sidebar-qrcode" />
<span class="sidebar-qrcode-label">扫码添加定制师微信</span>
<CommonInternalLink to="/contact" text="其他联系方式" />
</div>

查看文件

@ -2,7 +2,7 @@
<div class="page-blog">
<LayoutPageHero
title="旅行攻略"
subtitle="呼伦贝尔旅行的干货指南,帮你把每一趟出行都过得值"
:subtitle="heroes?.blog || '呼伦贝尔旅行的干货指南,帮你把每一趟出行都过得值'"
:breadcrumbs="[{ text: '攻略' }]"
background-image="/images/seasons/summer-river.jpg"
/>
@ -87,11 +87,12 @@ import { useBreadcrumbSchema } from '~/composables/useJsonLd'
import useScrollReveal from '~/composables/useScrollReveal'
const { data: blogData } = await usePublicApi('blog')
const { data: heroes } = await usePublicApi('heroes')
useHead({
title: '呼伦贝尔旅行攻略 - 亲子游·避坑·穿搭·行程干货 - 呼籁旅行',
meta: [
{ name: 'description', content: '呼籁旅行旅行攻略频道:呼伦贝尔亲子游完全指南、几月份去最好、旅行花费明细、避坑指南、草原旅拍穿搭,深耕11年的实用干货。' },
{ name: 'description', content: '呼籁旅行旅行攻略频道:呼伦贝尔亲子游综合指南、几月份去合适、旅行花费明细、避坑指南、草原旅拍穿搭,深耕11年的实用干货。' },
{ name: 'keywords', content: '呼伦贝尔旅游攻略,呼伦贝尔亲子游攻略,呼伦贝尔几月去,呼伦贝尔旅游花费,草原旅拍穿搭' },
{ property: 'og:title', content: '呼伦贝尔旅行攻略 - 呼籁旅行' },
{ property: 'og:description', content: '呼伦贝尔亲子游·避坑指南·穿搭攻略,深耕11年的实用干货。' },

查看文件

@ -2,7 +2,7 @@
<div class="page-calendar">
<LayoutPageHero
:title="seo.h1"
subtitle="呼伦贝尔什么时候去最好?基于11年带队数据,为您提供每月详细出行参考"
:subtitle="heroes?.calendar || '呼伦贝尔什么时候去合适?基于11年带队数据,为您提供每月出行参考'"
:breadcrumbs="[{ text: '行程日历' }]"
background-image="/images/seasons/autumn-lake.jpg"
/>
@ -11,7 +11,7 @@
<section class="page-tldr" aria-label="行程日历摘要">
<div class="container">
<div class="page-tldr-inner">
<p>呼伦贝尔逐月出行指南基于11年带队数据详解1-12月天气景色适合活动和推荐行程帮你选择最佳出行时间</p>
<p>呼伦贝尔逐月出行指南基于11年带队数据详解1-12月天气景色适合活动和推荐行程帮你选择合适的出行时间</p>
</div>
</div>
</section>
@ -21,7 +21,7 @@
<!-- Quick recommendation summary -->
<div class="cal-summary">
<h2 class="cal-summary-title">呼伦贝尔最佳出行时间一览</h2>
<h2 class="cal-summary-title">呼伦贝尔出行时间一览</h2>
<div class="summary-grid">
<div
v-for="rec in calData.meta.quickRecommendations"
@ -38,7 +38,7 @@
<!-- 出行时段建议 -->
<div class="advice-section">
<h2 class="advice-title">什么时候来呼伦贝尔</h2>
<p class="advice-subtitle">来呼伦贝尔一趟还是比较折腾的希望大家能在每个季节相对最美的时候过来</p>
<p class="advice-subtitle">来呼伦贝尔一趟还是比较折腾的希望大家能在每个季节景色较好的时候过来</p>
<div class="advice-timeline">
<div
v-for="item in calData.meta.travelAdvice"
@ -116,16 +116,16 @@
<!-- SEO text answer section -->
<div class="cal-answer">
<h2>呼伦贝尔几月份去最好详细解答</h2>
<h2>呼伦贝尔几月份去合适详细解答</h2>
<p>
呼伦贝尔全年皆可出行但来一趟毕竟不轻松建议选对时间再出发<strong>6月中下旬</strong>性价比最高草原已非常美各项活动全面开展且尚未进入旺季价格<strong>7月初至8月28日</strong>景色和活动巅峰但价格也相对较高<strong>9月20日至国庆</strong>是看大兴安岭秋景的最佳时机非常推荐<strong>冬季12月至次年2月</strong>完全另一种美冰雪那达慕驯鹿部落值得体验5月底可以提前出发避人群但5月初和3-4月不太推荐
呼伦贝尔全年皆可出行但来一趟毕竟不轻松建议选对时间再出发<strong>6月中下旬</strong>性价比不错草原已非常美各项活动全面开展且尚未进入旺季价格<strong>7月初至8月28日</strong>景色和活动巅峰但价格也相对较高<strong>9月20日至国庆</strong>是看大兴安岭秋景的推荐时机非常推荐<strong>冬季12月至次年2月</strong>另一种风貌冰雪那达慕驯鹿部落值得体验5月底可以提前出发避人群但5月初和3-4月不太推荐
</p>
<div class="cal-links">
<CommonInternalLink to="/guides" text="出行指南(穿搭 · 行李清单)" />
<CommonInternalLink to="/products" text="夏季产品额吉的故乡V9" />
<CommonInternalLink to="/products/autumn" text="秋季产品:游牧的森林" />
<CommonInternalLink to="/products/winter" text="冬季产品:嗨冰雪" />
<CommonInternalLink to="/contact" text="咨询最佳出行方案" />
<CommonInternalLink to="/contact" text="咨询出行方案" />
</div>
</div>
@ -134,7 +134,7 @@
<CommonWechatCTA
title="确定了出行时间?扫码找定制师"
description="告诉定制师你的出行日期和家庭情况,帮你匹配合适的行程方案。"
description="告诉定制师你的出行日期和家庭情况,帮你匹配合适的行程方案。"
:links="[{ to: '/products', text: '查看产品线' }]"
/>
</div>
@ -144,6 +144,7 @@
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: calData } = await usePublicApi('calendar')
const { data: heroes } = await usePublicApi('heroes')
const seo = useSEO('calendar')
@ -161,7 +162,7 @@ useHead({
'@context': 'https://schema.org',
'@type': 'TouristDestination',
name: '呼伦贝尔大草原',
description: '呼伦贝尔是中国最美草原之一,位于内蒙古自治区东北部,拥有莫日格勒河、额尔古纳湿地、白桦林、呼伦湖等核心景观。最佳旅游时节为6-9月,夏季草原翠绿,秋季白桦金黄。',
description: '呼伦贝尔是中国著名草原之一,位于内蒙古自治区东北部,拥有莫日格勒河、额尔古纳湿地、白桦林、呼伦湖等核心景观。推荐旅游时节为6-9月,夏季草原翠绿,秋季白桦金黄。',
url: 'https://1814.love/calendar',
image: 'https://1814.love/images/team/hero-grassland.jpg',
geo: {
@ -177,10 +178,10 @@ useHead({
},
touristType: ['家庭游客', '亲子家庭', '摄影爱好者', '自然爱好者'],
includesAttraction: [
{ '@type': 'TouristAttraction', name: '莫日格勒河(额吉的故乡)', description: '呼伦贝尔最美河湾,夏季翠绿如画,是呼籁旅行夏季核心景点' },
{ '@type': 'TouristAttraction', name: '额尔古纳湿地', description: '中国最大湿地之一,鸟类天堂,秋季倒影绝美' },
{ '@type': 'TouristAttraction', name: '莫日格勒河(额吉的故乡)', description: '呼伦贝尔著名河湾,夏季翠绿如画,是呼籁旅行夏季核心景点' },
{ '@type': 'TouristAttraction', name: '额尔古纳湿地', description: '中国大型湿地之一,鸟类天堂,秋季倒影优美' },
{ '@type': 'TouristAttraction', name: '白桦林', description: '九月金黄,摄影圣地,游牧的森林秋季产品核心景点' },
{ '@type': 'TouristAttraction', name: '呼伦湖', description: '中国北方第一大湖,冬季冰钓胜地,夏季湖畔骑马' },
{ '@type': 'TouristAttraction', name: '呼伦湖', description: '中国北方,冬季冰钓胜地,夏季湖畔骑马' },
{ '@type': 'TouristAttraction', name: '中俄边境公路', description: '室韦至黑山头沿线,风景壮阔,国境线骑行体验独特' },
],
}),
@ -191,8 +192,8 @@ useHead({
const seasonalEvents = [
{
key: 'event-summer',
name: '呼伦贝尔夏季旅游最佳6-8月',
description: '呼伦贝尔草原夏季旅游高峰,绿草如茵,那达慕传统节庆,骑马、露营、亲子出行最佳时段。呼籁旅行提供额吉的故乡V9系列定制游及小蒙马亲子游学营。',
name: '呼伦贝尔夏季旅游推荐6-8月',
description: '呼伦贝尔草原夏季旅游高峰,绿草如茵,那达慕传统节庆,骑马、露营、亲子出行推荐时段。呼籁旅行提供额吉的故乡V9系列定制游及小蒙马亲子游学营。',
startDate: '2026-06-01',
endDate: '2026-08-31',
},

查看文件

@ -2,7 +2,7 @@
<div v-if="contact" class="page-contact">
<LayoutPageHero
:title="seo.h1"
subtitle="选择最方便的方式联系我们"
:subtitle="heroes?.contact || '选择最方便的方式联系我们'"
:breadcrumbs="[{ text: '联系我们' }]"
background-image="/images/summer-camp/grassland-group.jpg"
/>
@ -19,7 +19,7 @@
<!-- 主要联系方式微信 + 电话 -->
<section class="section reveal" aria-label="主要联系方式">
<div class="container">
<CommonSectionTitle title="立即联系" subtitle="微信或电话,开启你的呼伦贝尔之旅" />
<CommonSectionTitle :title="st('contact.primary', '立即联系')" :subtitle="st('contact.primary', '微信或电话,开启你的呼伦贝尔之旅', 'subtitle')" />
<div class="contact-primary">
<!-- 微信客服 -->
<div class="contact-card contact-card--wechat">
@ -62,7 +62,7 @@
<!-- 其他联系方式 -->
<section class="section section--gray reveal" aria-label="更多联系方式">
<div class="container">
<CommonSectionTitle title="更多联系方式" subtitle="商务合作、投诉建议、加入团队" />
<CommonSectionTitle :title="st('contact.more', '更多联系方式')" :subtitle="st('contact.more', '商务合作、投诉建议、加入团队', 'subtitle')" />
<div class="contact-grid">
<div v-for="ch in secondaryChannels" :key="ch.type" class="contact-grid-item">
<div class="contact-grid-item__icon">
@ -126,7 +126,9 @@
import { useOrganizationSchema, useBreadcrumbSchema, useContactPageSchema } from '~/composables/useJsonLd'
const { data: contact } = await usePublicApi('contact')
const { data: heroes } = await usePublicApi('heroes')
const { data: brand } = await usePublicApi('brand')
const st = await useSectionTitles()
const visibleChannels = computed(() => (contact.value?.channels || []).filter(ch => !ch.hidden))
const wechatChannel = computed(() => visibleChannels.value.find(ch => ch.type === 'wechat'))
@ -148,13 +150,35 @@ function scrollToTop() {
</script>
<style lang="less" scoped>
// ===== hero/ CTAhero =====
.page-contact :deep(.page-hero--has-bg) {
padding: @space-lg 0 @space-xl;
min-height: 140px;
.respond-md({
padding: @space-xl 0 @space-2xl;
min-height: 200px;
});
}
// TL;DR CTA
.page-contact :deep(.page-tldr) {
padding: @space-md 0;
}
// section padding CTA
.page-contact > section:nth-of-type(2) {
padding-top: @space-lg;
padding-bottom: @space-xl;
}
// ===== =====
.contact-primary {
display: grid;
grid-template-columns: 1fr;
gap: @space-xl;
max-width: 800px;
margin: @space-xl auto 0;
margin: @space-md auto 0;
.respond-md({
grid-template-columns: repeat(2, 1fr);

查看文件

@ -2,7 +2,7 @@
<div class="page-courses">
<LayoutPageHero
:title="seo.h1"
subtitle="小蒙马亲子研学·6大课程模块·3-12岁分龄设计"
:subtitle="heroes?.courses || '小蒙马亲子研学·6大课程模块·3-12岁分龄设计'"
:breadcrumbs="[{ text: '小蒙马亲子游学营', to: '/summer-camp' }, { text: '研学课程体系' }]"
background-image="/images/summer-camp/birch-group.jpg"
/>
@ -43,8 +43,8 @@
<section v-if="courses" class="section reveal">
<div class="container">
<CommonSectionTitle
title="按年龄找适合的课程"
subtitle="不同年龄段,有不同的学习目标和课程深度"
:title="st('courses.byAge', '按年龄找适合的课程')"
:subtitle="st('courses.byAge', '不同年龄段,有不同的学习目标和课程深度', 'subtitle')"
/>
<div class="age-tabs">
<button
@ -105,8 +105,8 @@
<section v-if="courses" class="section section--gray reveal">
<div class="container">
<CommonSectionTitle
title="6大课程模块"
subtitle="每个模块有明确的学习目标、活动设计和教育理念"
:title="st('courses.modules', '6大课程模块')"
:subtitle="st('courses.modules', '每个模块有明确的学习目标、活动设计和教育理念', 'subtitle')"
/>
<div class="modules-grid">
<div
@ -153,26 +153,17 @@
</div>
</section>
<!-- 师资介绍占位 -->
<section v-if="courses" class="section reveal">
<!-- 师资介绍仅在有真实导师数据时显示 -->
<section v-if="courses && realInstructors.length" class="section reveal">
<div class="container">
<CommonSectionTitle
title="课程导师团队"
subtitle="自然教育导师 + 草原领队,全程陪同每一位孩子"
:title="st('courses.mentors', '课程导师团队')"
:subtitle="st('courses.mentors', '自然教育导师 + 草原领队,全程陪同每一位孩子', 'subtitle')"
/>
<div class="instructor-placeholder-box">
<span class="instructor-placeholder-icon"></span>
<div class="instructor-placeholder-text">
<p class="instructor-placeholder-title">此处需补充真实导师信息</p>
<p class="instructor-placeholder-desc">
请提供导师姓名资质认证自然教育导师证/急救证等从业经历和个人介绍由运营团队填充
</p>
</div>
</div>
<div class="instructor-cards">
<div v-for="inst in courses.instructors?.placeholder || []" :key="inst.title" class="instructor-card">
<div class="instructor-avatar-placeholder">
<span></span>
<div v-for="inst in realInstructors" :key="inst.name" class="instructor-card">
<div v-if="inst.avatar" class="instructor-avatar">
<img :src="inst.avatar" :alt="inst.name" loading="lazy" />
</div>
<div class="instructor-info">
<p class="instructor-name">{{ inst.name }}</p>
@ -188,7 +179,7 @@
<!-- 家长常见问题 -->
<section v-if="courses" class="section section--warm">
<div class="container">
<CommonSectionTitle title="家长常见问题" />
<CommonSectionTitle :title="st('courses.faq', '家长常见问题')" />
<div class="courses-faq">
<FaqItem
v-for="(item, i) in courses.faq"
@ -217,6 +208,8 @@
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: courses } = await usePublicApi('courses')
const { data: heroes } = await usePublicApi('heroes')
const st = await useSectionTitles()
const seo = useSEO('courses')
useScrollReveal()
@ -275,6 +268,9 @@ useHead({
})
//
const realInstructors = computed(() =>
(courses.value?.instructors?.list || []).filter(i => i?.name && !String(JSON.stringify(i)).includes('⚠️'))
)
const activeGroup = ref('child')
const activeGroupData = computed(() => (courses.value?.ageGroups || []).find((g) => g.id === activeGroup.value))
const activeGroupModules = computed(() =>

查看文件

@ -2,7 +2,7 @@
<div class="page-customize">
<LayoutPageHero
:title="seo.h1"
subtitle="填写出行需求,定制师2小时内回复,免费咨询"
:subtitle="heroes?.customize || '填写出行需求,定制师2小时内回复,免费咨询'"
:breadcrumbs="[{ text: '在线定制' }]"
background-image="/images/seasons/summer-hero.jpg"
/>
@ -46,7 +46,7 @@
</p>
<div class="success-wechat">
<span class="wechat-label">也可主动添加定制师微信</span>
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="wechat-qrcode wechat-qrcode--success" />
<img :src="'/images/contact/qrcode-wechat.jpg'" alt="定制师微信二维码" class="wechat-qrcode wechat-qrcode--success" />
</div>
<p class="success-tip">{{ customize.contact.tip }}</p>
<button class="btn-reset" @click="resetForm">重新填写</button>
@ -250,7 +250,7 @@
<div class="aside-card aside-wechat">
<h3 class="aside-title">不想填表</h3>
<p class="aside-desc">扫码添加定制师微信随时咨询</p>
<img src="/images/contact/qrcode-wechat.jpg" alt="定制师微信二维码" class="wechat-qrcode" />
<img :src="'/images/contact/qrcode-wechat.jpg'" alt="定制师微信二维码" class="wechat-qrcode" />
<p class="wechat-tip">{{ customize.contact.tip }}</p>
</div>
</aside>
@ -262,7 +262,7 @@
<!-- 内链闭环 -->
<CommonSectionCTA
title="想先了解产品?"
description="查看我们的行程版本,找到适合您家庭的天数"
description="查看我们的行程版本,找到适合您家庭的天数"
:links="[
{ to: '/products', text: '查看4-7天行程版本' },
{ to: '/faq', text: '查看常见问题解答' },
@ -276,6 +276,7 @@
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: customize } = await usePublicApi('customize')
const { data: heroes } = await usePublicApi('heroes')
const seo = useSEO('customize')
useBreadcrumbSchema([

查看文件

@ -30,16 +30,8 @@
<section class="dest-season">
<div class="container">
<h2>最佳游览季节</h2>
<h2>推荐游览季节</h2>
<div class="season-card">
<div class="season-primary">
<span class="season-label">最佳时节</span>
<span class="season-value">{{ dest.bestSeason.primary }}</span>
</div>
<div class="season-secondary" v-if="dest.bestSeason.secondary">
<span class="season-label">次佳时节</span>
<span class="season-value">{{ dest.bestSeason.secondary }}</span>
</div>
<p class="season-desc">{{ dest.bestSeason.description }}</p>
</div>
</div>

查看文件

@ -2,7 +2,7 @@
<div class="page-destinations">
<LayoutPageHero
title="呼伦贝尔核心景区攻略"
subtitle="深耕呼伦贝尔11年,带你发现最真实的草原秘境"
:subtitle="heroes?.destinations || '深耕呼伦贝尔11年,带你发现最真实的草原秘境'"
:breadcrumbs="[{ text: '景区攻略' }]"
background-image="/images/seasons/summer-river.jpg"
/>
@ -11,7 +11,7 @@
<section class="page-tldr" aria-label="景区攻略摘要">
<div class="container">
<div class="page-tldr-inner">
<p>呼伦贝尔核心景区覆盖草原湿地森林边境火山地质等多种景观类型本页梳理了呼籁旅行三大产品线额吉的故乡·夏季游牧的森林·秋季嗨冰雪·冬季覆盖的所有核心景区包括莫日格勒河额尔古纳湿地白桦林恩和室韦黑山头满洲里阿尔山国家森林公园大兴安岭莫尔道嘎敖鲁古雅驯鹿部落根河等提供每处景区的实用攻略最佳时间和旅行贴士</p>
<p>呼伦贝尔核心景区覆盖草原湿地森林边境火山地质等多种景观类型本页梳理了呼籁旅行三大产品线额吉的故乡·夏季游牧的森林·秋季嗨冰雪·冬季覆盖的所有核心景区包括莫日格勒河额尔古纳湿地白桦林恩和室韦黑山头满洲里阿尔山国家森林公园大兴安岭莫尔道嘎敖鲁古雅驯鹿部落根河等提供每处景区的实用攻略推荐时间和旅行贴士</p>
</div>
</div>
</section>
@ -69,6 +69,7 @@
<span v-for="(h, i) in dest.highlights.slice(0, 2)" :key="i" class="dest-card__highlight">{{ h.split('')[0] }}</span>
</div>
<div class="dest-card__seasons">
<span v-if="dest.seasons.includes('spring')" class="season-dot season-dot--spring" title="春季"></span>
<span v-if="dest.seasons.includes('summer')" class="season-dot season-dot--summer" title="额吉的故乡·夏季"></span>
<span v-if="dest.seasons.includes('autumn')" class="season-dot season-dot--autumn" title="游牧的森林·秋季"></span>
<span v-if="dest.seasons.includes('winter')" class="season-dot season-dot--winter" title="嗨冰雪·冬季"></span>
@ -80,7 +81,7 @@
<!-- CTA -->
<div class="dest-cta">
<h2>不确定去哪些景区让定制师帮你组合</h2>
<p>呼籁旅行定制师深耕呼伦贝尔11年根据你的时间季节孩子年龄帮你搭配合适的景区组合和行程节奏</p>
<p>呼籁旅行定制师深耕呼伦贝尔11年根据你的时间季节孩子年龄帮你搭配合适的景区组合和行程节奏</p>
<div class="dest-cta__links">
<CommonInternalLink to="/contact" text="免费咨询定制师" />
<CommonInternalLink to="/products" text="查看定制游产品" />
@ -97,6 +98,7 @@ import { useBreadcrumbSchema } from '~/composables/useJsonLd'
import useScrollReveal from '~/composables/useScrollReveal'
const { data: destinationsData } = await usePublicApi('destinations-detail')
const { data: heroes } = await usePublicApi('heroes')
const destinations = computed(() => destinationsData.value?.destinations || [])
@ -130,7 +132,7 @@ const filteredDestinations = computed(() => {
useSEO('destinations', {
title: '呼伦贝尔核心景区攻略 - 莫日格勒河 额尔古纳 阿尔山等景点详解 | 呼籁旅行',
description: '呼籁旅行深耕呼伦贝尔11年,为你梳理三大产品线覆盖的核心景区攻略莫日格勒河、额尔古纳湿地、白桦林、恩和室韦、黑山头、满洲里、阿尔山、大兴安岭、敖鲁古雅驯鹿部落、根河,含实用贴士和最佳时间。',
description: '呼籁旅行深耕呼伦贝尔11年,为你梳理三大产品线覆盖的核心景区攻略莫日格勒河、额尔古纳湿地、白桦林、恩和室韦、黑山头、满洲里、阿尔山、大兴安岭、敖鲁古雅驯鹿部落、根河,含实用贴士和推荐时间。',
})
useHead({
@ -410,6 +412,11 @@ useScrollReveal()
font-size: 11px;
font-weight: @font-weight-medium;
&--spring {
background: #FCE4EC;
color: #AD1457;
}
&--summer {
background: #E8F5E9;
color: #2E7D32;

查看文件

@ -3,7 +3,7 @@
<!-- 页面标题 -->
<LayoutPageHero
:title="seo.h1"
:subtitle="`${totalFAQ}个高频问题详细解答,帮您做出最好的选择`"
:subtitle="`${totalFAQ}个高频问题详细解答,帮您做出适合的选择`"
:breadcrumbs="[{ text: '常见问答' }]"
background-image="/images/summer-camp/grassland-group.jpg"
/>
@ -12,7 +12,7 @@
<section class="page-tldr" aria-label="常见问答摘要">
<div class="container">
<div class="page-tldr-inner">
<p>本页汇总呼籁旅行被问到最多{{ totalFAQ }}个问题涵盖价格区间最佳出行时间适合人群行程安排安全保障退改条款等核心关切按分类整理并支持关键词搜索还有疑问可直接联系定制师</p>
<p>本页汇总呼籁旅行被问到的{{ totalFAQ }}个问题涵盖价格区间推荐出行时间适合人群行程安排安全保障退改条款等核心关切按分类整理并支持关键词搜索还有疑问可直接联系定制师</p>
</div>
</div>
</section>

查看文件

@ -2,7 +2,7 @@
<div class="page-gallery">
<LayoutPageHero
:title="seo.h1"
subtitle="自营摄影团队 · 200+件蒙古服饰 · 专属秘境取景地"
:subtitle="heroes?.gallery || '自营摄影团队 · 200+件蒙古服饰 · 专属秘境取景地'"
:breadcrumbs="[{ text: '旅拍作品' }]"
background-image="/images/seasons/autumn-forest.jpg"
/>
@ -33,8 +33,8 @@
<section v-if="gallery" class="gallery-intro section">
<div class="container">
<CommonSectionTitle
title="呼籁旅拍 · 草原最美的记忆"
subtitle="随行拍摄,无需单独预约,每组旅拍都是独一无二的故事"
:title="st('gallery.intro', '呼籁旅拍 · 草原动人的记忆')"
:subtitle="st('gallery.intro', '随行拍摄,无需单独预约,每组旅拍都有独特的故事', 'subtitle')"
align="center"
/>
<p class="gallery-intro__desc">{{ gallery.intro.description }}</p>
@ -50,13 +50,13 @@
<span class="gallery-highlight__icon">📷</span>
<div>
<strong>专业旅拍摄影师</strong>
<p>全程随行把握每一个自然光线最好的瞬间</p>
<p>全程随行把握每一个自然光线理想的瞬间</p>
</div>
</div>
<div class="gallery-highlight">
<span class="gallery-highlight__icon">🏔</span>
<div>
<strong>独家秘境取景地</strong>
<strong>专属秘境取景地</strong>
<p>莫日格勒河源头营地花海白桦林深处不对外开放</p>
</div>
</div>
@ -68,8 +68,8 @@
<section v-if="gallery" class="section section--gray">
<div class="container">
<CommonSectionTitle
title="旅拍作品"
subtitle="每一帧都是真实客户的草原记忆"
:title="st('gallery.works', '旅拍作品')"
:subtitle="st('gallery.works', '每一帧都是真实客户的草原记忆', 'subtitle')"
align="center"
/>
@ -171,6 +171,8 @@ import { useBreadcrumbSchema, useGallerySchema } from '~/composables/useJsonLd'
import useScrollReveal from '~/composables/useScrollReveal'
const { data: gallery } = await usePublicApi('gallery')
const { data: heroes } = await usePublicApi('heroes')
const st = await useSectionTitles()
const seo = useSEO('gallery')
useScrollReveal()

查看文件

@ -1,114 +1,79 @@
<template>
<div class="page-guide-custom">
<LayoutPageHero
title="呼伦贝尔定制游怎么选"
subtitle="定制游 vs 跟团游 vs 自由行,一篇讲透"
:title="article?.title || '呼伦贝尔定制游怎么选'"
:subtitle="heroes?.['guide-custom'] || '定制游 vs 跟团游 vs 自由行,一篇讲透'"
:breadcrumbs="[{ text: '出行指南', to: '/guides' }, { text: '定制游怎么选' }]"
background-image="/images/seasons/summer-river.jpg"
/>
<section class="page-tldr" aria-label="定制游摘要">
<section v-if="article?.tldr" class="page-tldr" aria-label="定制游摘要">
<div class="container">
<div class="page-tldr-inner">
<p>呼伦贝尔定制游是一家一单一车的私家出行方式区别于传统跟团游的拼团大巴和走马观花本文解释定制游的优势适合人群价格构成帮你判断是否适合选择定制游</p>
<p>{{ article.tldr }}</p>
</div>
</div>
</section>
<article class="section">
<div class="container article-body">
<template v-for="(section, sIdx) in article?.sections || []" :key="sIdx">
<!-- 文本段落型 -->
<template v-if="section.type === 'text'">
<h2 v-if="section.heading">{{ section.heading }}</h2>
<p v-for="(p, i) in section.paragraphs" :key="i">{{ p }}</p>
</template>
<h2>什么是定制游</h2>
<p>定制游的核心是一家一单一车你的家庭独享一辆车和一位领队行程根据你的需求量身安排不与陌生人拼团出发时间游玩节奏停留时长都可以灵活调整孩子累了可以多休息遇到好风景可以多停一会儿</p>
<p>这不是高价版的跟团游而是完全不同的旅行方式</p>
<!-- 对比表格型 -->
<template v-else-if="section.type === 'table'">
<h2 v-if="section.heading">{{ section.heading }}</h2>
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr>
<th></th>
<th v-for="col in section.columns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rIdx) in section.rows" :key="rIdx">
<td class="row-label">{{ row.label }}</td>
<td
v-for="(val, vIdx) in row.values"
:key="vIdx"
:class="{ highlight: vIdx === 0 }"
>
{{ val }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<h2>定制游 vs 跟团游 vs 自由行</h2>
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr>
<th></th>
<th>定制游</th>
<th>跟团游</th>
<th>自由行</th>
</tr>
</thead>
<tbody>
<tr>
<td class="row-label">用车</td>
<td class="highlight">一家一车专属司机</td>
<td>拼团大巴30-50</td>
<td>自驾或包车</td>
</tr>
<tr>
<td class="row-label">行程</td>
<td class="highlight">按家庭需求定制</td>
<td>固定路线统一安排</td>
<td>完全自己规划</td>
</tr>
<tr>
<td class="row-label">节奏</td>
<td class="highlight">随走随停灵活调整</td>
<td>按时间表走赶景点</td>
<td>自由但费心</td>
</tr>
<tr>
<td class="row-label">领队</td>
<td class="highlight">专属领队全程陪同</td>
<td>导游带大团</td>
<td></td>
</tr>
<tr>
<td class="row-label">购物</td>
<td class="highlight">全程零购物</td>
<td>可能有购物店</td>
<td></td>
</tr>
<tr>
<td class="row-label">适合</td>
<td class="highlight">亲子家庭品质出行</td>
<td>预算有限不想操心</td>
<td>经验丰富的老手</td>
</tr>
</tbody>
</table>
</div>
<!-- 列表型 -->
<template v-else-if="section.type === 'list'">
<h2 v-if="section.heading">{{ section.heading }}</h2>
<p v-if="section.intro">{{ section.intro }}</p>
<ul v-if="section.items && section.items.length">
<li v-for="(item, i) in section.items" :key="i">{{ item }}</li>
</ul>
<p v-if="section.outro">{{ section.outro }}</p>
</template>
</template>
<h2>为什么亲子家庭更适合定制游</h2>
<p>带孩子旅行和大人出行完全不同孩子需要按自己的节奏来可能早上起不来可能中午要午睡可能在某个地方玩嗨了不想走跟团游的固定时间表很难适配孩子的节奏自由行又需要父母花大量精力规划路线</p>
<p>定制游解决的正是这个问题专业的定制师提前根据孩子年龄安排行程节奏专属领队全程照顾家长只需要带着孩子享受旅行</p>
<h2>定制游的价格构成</h2>
<p>定制游的价格包含车辆住宿领队服务活动体验和保险正餐自理看起来比跟团游贵但每一分钱都花在了体验上</p>
<ul>
<li>没有购物店不会被拉去消费</li>
<li>没有隐形消费费用明细清晰透明</li>
<li>没有赶场式行程每个地方都停够看够</li>
<li>所有资源专属调配你的领队只服务你一家</li>
</ul>
<p>以呼籁旅行为例5天4晚行程舒适套餐2580元/人起高档套餐3380元/人起价格随出行日期浮动定金500元/出发前7天可无条件退改</p>
<h2>怎么判断自己适不适合定制游</h2>
<p>如果你符合以下任一条件定制游大概率是更好的选择</p>
<ul>
<li>带3-12岁孩子出行需要灵活节奏</li>
<li>三代同行需要照顾老人和孩子</li>
<li>对住宿品质有要求不想住快捷酒店</li>
<li>不想和陌生人拼团注重私密性</li>
<li>第一次去呼伦贝尔不了解路线</li>
<li>想深度体验而不是走马观花</li>
</ul>
<h2>呼籁旅行的定制游有什么不同</h2>
<p>呼籁旅行深耕呼伦贝尔11年是本地自营的亲子游品牌不是中间商转手我们有自己的车队自己的营地自己的旅拍团队从产品设计到服务交付全链路自己把控</p>
<p>目前提供额吉的故乡V9系列共6个版本5天4晚至7天6晚每个版本都是针对不同家庭需求专门设计的不是简单增减天数每个家庭配备5人服务团队定制师+房务管家+车务管家+客服管家+领队</p>
<div class="article-cta">
<h3>想了解具体行程</h3>
<div v-if="article?.cta" class="article-cta">
<h3 v-if="article.cta.heading">{{ article.cta.heading }}</h3>
<div class="cta-links">
<NuxtLink to="/products" class="cta-btn cta-btn--primary">查看6个版本行程</NuxtLink>
<NuxtLink to="/selector" class="cta-btn cta-btn--outline">1分钟匹配最适合的版本</NuxtLink>
<NuxtLink to="/contact" class="cta-btn cta-btn--outline">联系定制师免费咨询</NuxtLink>
<NuxtLink
v-for="(link, i) in article.cta.links || []"
:key="i"
:to="link.to"
class="cta-btn"
:class="link.primary ? 'cta-btn--primary' : 'cta-btn--outline'"
>
{{ link.text }}
</NuxtLink>
</div>
</div>
</div>
@ -117,15 +82,18 @@
</template>
<script setup>
import { useBreadcrumbSchema, useFAQPageSchema } from '~/composables/useJsonLd'
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
useHead({
title: '呼伦贝尔定制游怎么选 - 定制游vs跟团游vs自由行 - 呼籁旅行',
const { data: article } = await usePublicApi('guide-custom')
const { data: heroes } = await usePublicApi('heroes')
useHead(() => ({
title: article.value?.seo?.title || '呼伦贝尔定制游怎么选 - 定制游vs跟团游vs自由行 - 呼籁旅行',
meta: [
{ name: 'description', content: '呼伦贝尔定制游是一家一单一车的私家出行方式。本文对比定制游、跟团游、自由行的区别,解释定制游的价格构成和适合人群,帮你判断是否适合选择呼伦贝尔定制游。' },
{ name: 'keywords', content: '呼伦贝尔定制游,定制游和跟团游的区别,呼伦贝尔旅游怎么选,呼伦贝尔一家一车,呼伦贝尔私家团,呼伦贝尔亲子游' },
{ name: 'description', content: article.value?.seo?.description || '呼伦贝尔定制游是一家一单一车的私家出行方式。本文对比定制游、跟团游、自由行的区别,解释定制游的价格构成和适合人群,帮你判断是否适合选择呼伦贝尔定制游。' },
{ name: 'keywords', content: article.value?.seo?.keywords || '呼伦贝尔定制游,定制游和跟团游的区别,呼伦贝尔旅游怎么选,呼伦贝尔一家一车,呼伦贝尔私家团,呼伦贝尔亲子游' },
],
})
}))
useBreadcrumbSchema([
{ text: '首页', to: '/' },

查看文件

@ -2,7 +2,7 @@
<div class="page-guides">
<LayoutPageHero
:title="seo.h1"
subtitle="证件·穿搭·行李清单·活动安全·幼童专属指南,帮您轻松准备呼伦贝尔之行"
:subtitle="heroes?.guides || '证件·穿搭·行李清单·活动安全·幼童专属指南,帮您轻松准备呼伦贝尔之行'"
:breadcrumbs="[{ text: '出行指南' }]"
background-image="/images/seasons/summer-car.jpg"
/>
@ -173,6 +173,7 @@
import { useBreadcrumbSchema, useHowToSchema } from '~/composables/useJsonLd'
const { data: guidesData } = await usePublicApi('guides')
const { data: heroes } = await usePublicApi('heroes')
const seo = useSEO('guides')

查看文件

@ -21,7 +21,7 @@
<!-- 产品语义树帮助 AI 按维度理解产品分类-->
<section class="section section--gray reveal" aria-label="产品分类导航">
<div class="container">
<CommonSectionTitle title="怎么找到适合你的行程" label="FIND YOUR TRIP" subtitle="按天数 · 按人群 · 按季节 三个维度快速定位" />
<CommonSectionTitle :title="st('home.productTree', '怎么找到适合你的行程')" :label="st('home.productTree', 'FIND YOUR TRIP', 'label')" :subtitle="st('home.productTree', '按天数 · 按人群 · 按季节 三个维度快速定位', 'subtitle')" />
<div class="product-tree-grid">
<div class="product-tree-card product-tree-card--days">
<div class="product-tree-icon">
@ -74,14 +74,14 @@
<!-- 知识库入口告诉 AI 这是持续产出内容的权威站点-->
<section class="section reveal" aria-label="旅行知识库">
<div class="container">
<CommonSectionTitle title="11年积累的呼伦贝尔知识库" label="KNOWLEDGE" subtitle="从规划到出发,每个问题都有答案" />
<CommonSectionTitle :title="st('home.knowledgeHub', '11年积累的呼伦贝尔知识库')" :label="st('home.knowledgeHub', 'KNOWLEDGE', 'label')" :subtitle="st('home.knowledgeHub', '从规划到出发,每个问题都有答案', 'subtitle')" />
<div class="knowledge-hub-grid">
<NuxtLink to="/calendar" class="knowledge-hub-card">
<div class="knowledge-hub-card__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</div>
<h3 class="knowledge-hub-card__title">出行时间日历</h3>
<p class="knowledge-hub-card__desc">每月天气景色推荐指数找到你的最佳出行窗口</p>
<p class="knowledge-hub-card__desc">每月天气景色推荐指数找到你的出行窗口</p>
</NuxtLink>
<NuxtLink to="/guides" class="knowledge-hub-card">
<div class="knowledge-hub-card__icon">
@ -111,7 +111,7 @@
<!-- FAQ预览 -->
<section v-if="faq" class="section section--gray reveal">
<div class="container">
<CommonSectionTitle title="常见问答" label="FAQ" subtitle="您可能想了解的问题" />
<CommonSectionTitle :title="st('home.faq', '常见问答')" :label="st('home.faq', 'FAQ', 'label')" :subtitle="st('home.faq', '您可能想了解的问题', 'subtitle')" />
<div class="faq-preview">
<details v-for="q in featuredFAQ" :key="q.id" class="faq-preview-item">
<summary class="faq-preview-q">
@ -152,6 +152,7 @@ const { data: faq } = await usePublicApi('faq')
const { data: reviews } = await usePublicApi('reviews')
const { data: contact } = await usePublicApi('contact')
const { data: navData } = await usePublicApi('navigation')
const st = await useSectionTitles()
// SEO
const seo = useSEO('home')

查看文件

@ -2,16 +2,16 @@
<div class="page-news">
<LayoutPageHero
:title="seo.h1"
subtitle="品牌动态、产品发布、活动公告,第一时间了解呼籁旅行最新资讯"
:breadcrumbs="[{ text: '最新动态' }]"
:subtitle="heroes?.news || '品牌动态、产品发布、活动公告,及时了解呼籁旅行最新资讯'"
:breadcrumbs="[{ text: '品牌资讯' }]"
background-image="/images/seasons/autumn-hero.jpg"
/>
<!-- TL;DR -->
<section class="page-tldr" aria-label="最新动态摘要">
<section class="page-tldr" aria-label="品牌资讯摘要">
<div class="container">
<div class="page-tldr-inner">
<p>呼籁旅行最新动态品牌公告产品发布活动资讯行业合作等信息第一时间了解呼伦贝尔亲子游最新资讯</p>
<p>呼籁旅行品牌资讯品牌公告产品发布活动资讯行业合作等信息及时了解呼伦贝尔亲子游相关资讯</p>
</div>
</div>
</section>
@ -37,13 +37,24 @@
v-for="article in filteredArticles"
:key="article.id"
class="news-card"
:class="{ 'news-card--with-cover': article.coverImage }"
>
<div class="news-meta">
<span :class="['news-cat', `news-cat--${article.category}`]">{{ article.category }}</span>
<time class="news-date" :datetime="article.date || article.createdAt">{{ formatDate(article.date || article.createdAt) }}</time>
<div v-if="article.coverImage" class="news-card__cover">
<img
:src="article.coverImage"
:alt="article.title"
loading="lazy"
referrerpolicy="no-referrer"
/>
</div>
<div class="news-card__body">
<div class="news-meta">
<span :class="['news-cat', `news-cat--${article.category}`]">{{ article.category }}</span>
<time class="news-date" :datetime="article.date || article.createdAt">{{ formatDate(article.date || article.createdAt) }}</time>
</div>
<h2 class="news-title">{{ article.title }}</h2>
<p class="news-summary">{{ article.summary }}</p>
</div>
<h2 class="news-title">{{ article.title }}</h2>
<p class="news-summary">{{ article.summary }}</p>
</article>
</div>
@ -67,12 +78,13 @@
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: newsData } = await usePublicApi('news')
const { data: heroes } = await usePublicApi('heroes')
const seo = useSEO('news')
useBreadcrumbSchema([
{ text: '首页', to: '/' },
{ text: '最新动态' },
{ text: '品牌资讯' },
])
// NewsArticle JSON-LD for each article
@ -167,6 +179,39 @@ function formatDate(dateStr) {
}
}
.news-card--with-cover {
display: grid;
grid-template-columns: 1fr;
gap: @space-md;
padding: @space-lg;
.respond-md({
grid-template-columns: 240px 1fr;
gap: @space-xl;
});
}
.news-card__cover {
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: @border-radius-sm;
background: @color-bg-gray;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.news-card__body {
display: flex;
flex-direction: column;
justify-content: center;
}
.news-meta {
display: flex;
align-items: center;

查看文件

@ -2,7 +2,7 @@
<div class="page-partners">
<LayoutPageHero
:title="seo.h1"
subtitle="行业认证 · 平台背书 · 景区合作 · 高校联合"
:subtitle="heroes?.partners || '行业认证 · 平台背书 · 景区合作 · 高校联合'"
:breadcrumbs="[{ text: '关于我们', to: '/about' }, { text: '合作伙伴' }]"
background-image="/images/team/campsite-team.jpg"
/>
@ -20,8 +20,8 @@
<section v-if="partners" class="section section-platforms">
<div class="container">
<CommonSectionTitle
title="平台认证与荣誉"
subtitle="官方认证账号,平台公开可查"
:title="st('partners.platform', '平台认证与荣誉')"
:subtitle="st('partners.platform', '官方认证账号,平台公开可查', 'subtitle')"
/>
<div class="platforms-grid reveal">
<div
@ -42,8 +42,8 @@
<section v-if="partners" class="section section-gov section-alt">
<div class="container">
<CommonSectionTitle
title="政府资质与行业背书"
subtitle="来自官方机构的认可与合作"
:title="st('partners.government', '政府资质与行业背书')"
:subtitle="st('partners.government', '来自官方机构的认可与合作', 'subtitle')"
/>
<div class="partners-list reveal">
<div class="partners-list__group">
@ -59,9 +59,7 @@
</div>
</div>
<div class="partners-list__group">
<h3 class="partners-list__group-title">行业协会
<span class="group-title-note"> 以下信息需确认</span>
</h3>
<h3 class="partners-list__group-title">行业协会</h3>
<div class="partners-items">
<div
v-for="item in partners.associations.slice(1)"
@ -85,8 +83,8 @@
<section v-if="partners" class="section section-academic">
<div class="container">
<CommonSectionTitle
title="校企合作"
subtitle="联合高校,培养本地旅游人才"
:title="st('partners.universities', '校企合作')"
:subtitle="st('partners.universities', '联合高校,培养本地旅游人才', 'subtitle')"
/>
<div class="academic-grid reveal">
<div
@ -110,8 +108,8 @@
<section v-if="partners" class="section section-scenic section-alt">
<div class="container">
<CommonSectionTitle
title="景区合作"
subtitle="与呼伦贝尔核心景区深度合作,确保专属体验"
:title="st('partners.scenic', '景区合作')"
:subtitle="st('partners.scenic', '与呼伦贝尔核心景区深度合作,确保专属体验', 'subtitle')"
/>
<div class="scenic-grid reveal">
<div
@ -123,23 +121,17 @@
<p class="scenic-card__desc">{{ scenic.description }}</p>
</div>
</div>
<p v-if="unverifiedScenicCount > 0" class="section-placeholder-note">
还有 {{ unverifiedScenicCount }} 个合作景区待确认请补充完整
</p>
</div>
</section>
<!-- 媒体报道 -->
<section v-if="partners" class="section section-media">
<!-- 媒体报道有真实报道才展示 -->
<section v-if="partners?.mediaReports?.length" class="section section-media">
<div class="container">
<div class="section-header-with-note reveal">
<CommonSectionTitle
title="媒体报道"
subtitle="第三方媒体对呼籁旅行的报道与采访"
:title="st('partners.media', '媒体报道')"
:subtitle="st('partners.media', '第三方媒体对呼籁旅行的报道与采访', 'subtitle')"
/>
<div class="placeholder-banner">
本栏目所有内容均为占位框架需补充真实媒体报道链接后方可正式上线
</div>
</div>
<div class="media-grid reveal">
<div
@ -152,7 +144,7 @@
<p class="media-card__summary">{{ report.summary }}</p>
<div class="media-card__footer">
<span class="media-card__date">{{ report.date }}</span>
<span class="media-card__link-placeholder"> 链接待补充</span>
<a v-if="report.url" :href="report.url" target="_blank" rel="noopener" class="media-card__link">查看原文 </a>
</div>
</div>
</div>
@ -175,8 +167,10 @@
import { useOrganizationSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: partners } = await usePublicApi('partners')
const { data: heroes } = await usePublicApi('heroes')
const { data: brand } = await usePublicApi('brand')
const { data: contact } = await usePublicApi('contact')
const st = await useSectionTitles()
const seo = useSEO('partners')
useScrollReveal()

查看文件

@ -2,7 +2,7 @@
<div class="page-pricing">
<LayoutPageHero
:title="seo.h1"
subtitle="不藏价格,不绕弯子"
:subtitle="heroes?.pricing || '不藏价格,不绕弯子'"
:breadcrumbs="[{ text: '价格参考' }]"
background-image="/images/team/hero-grassland.jpg"
/>
@ -35,8 +35,8 @@
<section class="section reveal">
<div class="container">
<CommonSectionTitle
title="产品线价格参考"
subtitle="6条定制游线路 + 1个亲子游学营 + 秋季产品"
:title="st('pricing.reference', '产品线价格参考')"
:subtitle="st('pricing.reference', '6条定制游线路 + 1个亲子游学营 + 秋季产品', 'subtitle')"
/>
<div class="pricing-grid">
<div
@ -71,8 +71,8 @@
<section v-if="pricing" class="section section-inclusions reveal">
<div class="container">
<CommonSectionTitle
title="费用说明"
subtitle="所有产品遵循同一套费用标准"
:title="st('pricing.explanation', '费用说明')"
:subtitle="st('pricing.explanation', '所有产品遵循同一套费用标准', 'subtitle')"
/>
<div class="inclusions-grid">
<div class="inclusion-col included">
@ -127,6 +127,8 @@ import { useOrganizationSchema, useBreadcrumbSchema, usePricingSchema } from '~/
const { data: pricing } = await usePublicApi('pricing')
const { data: brand } = await usePublicApi('brand')
const { data: contact } = await usePublicApi('contact')
const { data: heroes } = await usePublicApi('heroes')
const st = await useSectionTitles()
const seo = useSEO('pricing')
useScrollReveal()

查看文件

@ -17,7 +17,7 @@
<!-- 风景大图 -->
<div class="scenic-banner">
<img src="/images/seasons/autumn-hero.jpg" alt="秋季大兴安岭金色白桦林" loading="eager" />
<img :src="'/images/seasons/autumn-hero.jpg'" alt="秋季大兴安岭金色白桦林" loading="eager" />
</div>
<!-- 产品设计理念 -->
@ -26,7 +26,7 @@
<!-- 秋季亮点 -->
<section v-if="autumnData" class="section reveal">
<div class="container">
<CommonSectionTitle title="秋季呼伦贝尔 · 不可错过的风景" subtitle="每年9月中旬-10月中旬" />
<CommonSectionTitle :title="st('productsAutumn.highlights', '秋季呼伦贝尔 · 不可错过的风景')" :subtitle="st('productsAutumn.highlights', '每年9月中旬-10月中旬', 'subtitle')" />
<div class="highlights-grid">
<div v-for="h in autumnData.highlights" :key="h.title" class="highlight-card">
<h3 class="highlight-title">{{ h.title }}</h3>
@ -39,7 +39,7 @@
<!-- 南线产品卡片 -->
<section class="section reveal">
<div class="container">
<CommonSectionTitle title="南线 · 呼伦贝尔+阿尔山" subtitle="草原秋色与火山天池" />
<CommonSectionTitle :title="st('productsAutumn.southLine', '南线 · 呼伦贝尔+阿尔山')" :subtitle="st('productsAutumn.southLine', '草原秋色与火山天池', 'subtitle')" />
<div class="products-grid">
<AutumnProductCard v-for="p in southVersions" :key="p.id" :product="p" />
</div>
@ -49,7 +49,7 @@
<!-- 南线特色体验 -->
<section v-if="autumnData" class="section section--warm reveal">
<div class="container">
<CommonSectionTitle title="南线独有体验" subtitle="阿尔山专属" />
<CommonSectionTitle :title="st('productsAutumn.southHighlights', '南线独有体验')" :subtitle="st('productsAutumn.southHighlights', '阿尔山专属', 'subtitle')" />
<div class="highlights-grid highlights-grid--small">
<div v-for="h in autumnData.southHighlights" :key="h.title" class="highlight-card">
<h3 class="highlight-title">{{ h.title }}</h3>
@ -62,14 +62,14 @@
<!-- 中间转化入口 -->
<CommonSectionCTA
title="不确定选南线还是北线?"
description="联系定制师,根据您的假期和偏好推荐最适合的行程"
description="联系定制师,根据您的假期和偏好推荐合适的行程"
:links="[{ to: '/contact', text: '联系定制师' }]"
/>
<!-- 北线产品卡片 -->
<section class="section reveal">
<div class="container">
<CommonSectionTitle title="北线 · 呼伦贝尔+大兴安岭" subtitle="深入大兴安岭腹地赏秋" />
<CommonSectionTitle :title="st('productsAutumn.northLine', '北线 · 呼伦贝尔+大兴安岭')" :subtitle="st('productsAutumn.northLine', '深入大兴安岭腹地赏秋', 'subtitle')" />
<div class="products-grid">
<AutumnProductCard v-for="p in northVersions" :key="p.id" :product="p" />
</div>
@ -79,7 +79,7 @@
<!-- 北线特色体验 -->
<section v-if="autumnData" class="section section--warm reveal">
<div class="container">
<CommonSectionTitle title="北线独有体验" subtitle="大兴安岭专属" />
<CommonSectionTitle :title="st('productsAutumn.northHighlights', '北线独有体验')" :subtitle="st('productsAutumn.northHighlights', '大兴安岭专属', 'subtitle')" />
<div class="highlights-grid highlights-grid--small">
<div v-for="h in autumnData.northHighlights" :key="h.title" class="highlight-card">
<h3 class="highlight-title">{{ h.title }}</h3>
@ -92,7 +92,7 @@
<!-- 选择指南 -->
<section v-if="autumnData?.selectionGuide" id="guide" class="section section--gray reveal">
<div class="container">
<CommonSectionTitle title="不知道选哪个?" subtitle="按假期长度推荐" />
<CommonSectionTitle :title="st('productsAutumn.selection', '不知道选哪个?')" :subtitle="st('productsAutumn.selection', '按假期长度推荐', 'subtitle')" />
<div class="guide-items">
<div v-for="item in autumnData.selectionGuide.byVacationLength" :key="item.condition" class="guide-item">
<div class="guide-item-top">
@ -127,10 +127,11 @@
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: autumnData } = await usePublicApi('autumn-products')
const st = await useSectionTitles()
const seo = useSEO('products-autumn', {
title: '游牧的森林V3 - 呼伦贝尔秋季定制游 - 呼籁旅行',
description: '呼籁旅行呼伦贝尔秋季定制游产品「游牧的森林」V3系列,南线呼伦贝尔+阿尔山、北线呼伦贝尔+大兴安岭,4-7天6个版本可选。独立成团不拼团,一家一单一车。',
title: '游牧的森林V3 - 呼伦贝尔秋季定制游·4-8天7版本可选 - 呼籁旅行',
description: '呼籁旅行呼伦贝尔秋季定制游产品「游牧的森林」V3系列,南线呼伦贝尔+阿尔山、北线呼伦贝尔+大兴安岭、南北8天7晚全景版,4-8天共7个版本可选。独立成团不拼团,一家一单一车。',
h1: '游牧的森林 · 呼伦贝尔秋季定制游',
})

查看文件

@ -2,7 +2,7 @@
<div class="page-products">
<LayoutPageHero
:title="seo.h1"
subtitle="独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景"
:subtitle="heroes?.products || '独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景'"
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '额吉的故乡' }]"
/>
@ -17,7 +17,7 @@
<!-- 风景大图 -->
<div class="scenic-banner">
<img src="/images/seasons/summer-hero.jpg" alt="夏季呼伦贝尔莫日格勒河草原全景" loading="eager" />
<img :src="'/images/seasons/summer-hero.jpg'" alt="夏季呼伦贝尔莫日格勒河草原全景" loading="eager" />
</div>
<!-- 产品设计理念 -->
@ -26,7 +26,7 @@
<!-- 产品卡片 -->
<section v-if="products" class="section reveal">
<div class="container">
<CommonSectionTitle title="额吉的故乡V9系列" subtitle="5-7天多版本行程" />
<CommonSectionTitle :title="st('products.v9Series', '额吉的故乡V9系列')" :subtitle="st('products.v9Series', '5-7天多版本行程', 'subtitle')" />
<div class="products-grid">
<ProductsProductCard v-for="p in products.versions" :key="p.id" :product="p" />
</div>
@ -35,14 +35,14 @@
<!-- 夏季风景 -->
<div class="scenic-duo reveal">
<img src="/images/seasons/summer-river.jpg" alt="夏季草原蒙古包莫日格勒河全景" loading="lazy" />
<img src="/images/seasons/summer-car.jpg" alt="呼伦贝尔一单一车草原越野" loading="lazy" />
<img :src="'/images/seasons/summer-river.jpg'" alt="夏季草原蒙古包莫日格勒河全景" loading="lazy" />
<img :src="'/images/seasons/summer-car.jpg'" alt="呼伦贝尔一单一车草原越野" loading="lazy" />
</div>
<!-- 中间转化入口 -->
<CommonSectionCTA
title="不确定选哪个版本?"
description="5个问题,1分钟,找到适合你家庭的行程版本"
description="5个问题,1分钟,找到适合你家庭的行程版本"
:links="[{ to: '/selector', text: '使用选版工具' }, { to: '/contact', text: '联系定制师' }]"
/>
@ -92,7 +92,9 @@
import { useProductsListSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: products } = await usePublicApi('products')
const { data: heroes } = await usePublicApi('heroes')
const { data: versions } = await usePublicApi('versions')
const st = await useSectionTitles()
const seo = useSEO('products')
useScrollReveal()

查看文件

@ -2,7 +2,7 @@
<div class="page-winter">
<LayoutPageHero
:title="seo.h1"
subtitle="独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景"
:subtitle="heroes?.['products-winter'] || '独立成团不拼团 · 一单一车 · 随走随停 · 不错过每处风景'"
:breadcrumbs="[{ text: '产品线', to: '/products' }, { text: '嗨冰雪' }]"
background-image="/images/seasons/winter-hero.jpg"
/>
@ -22,7 +22,7 @@
<!-- 产品卡片 -->
<section v-if="winterData" class="section reveal">
<div class="container">
<CommonSectionTitle title="嗨冰雪V3系列" subtitle="5-7天南北两线,专属私家团" />
<CommonSectionTitle :title="st('productsWinter.versions', '嗨冰雪V3系列')" :subtitle="st('productsWinter.versions', '5-7天南北两线,专属私家团', 'subtitle')" />
<div class="products-grid">
<WinterProductCard v-for="p in winterData.versions" :key="p.id" :product="p" />
</div>
@ -39,7 +39,7 @@
<!-- 共同亮点 -->
<section v-if="winterData" class="section reveal">
<div class="container">
<CommonSectionTitle title="冬季行程亮点" subtitle="每一条线路都会体验到" />
<CommonSectionTitle :title="st('productsWinter.highlights', '冬季行程亮点')" :subtitle="st('productsWinter.highlights', '每一条线路都会体验到', 'subtitle')" />
<div class="highlights-grid">
<div v-for="(h, i) in winterData.highlights" :key="i" class="highlight-card">
<h3 class="highlight-title">{{ h.title }}</h3>
@ -52,7 +52,7 @@
<!-- 南线特色 -->
<section v-if="winterData" class="section section--warm reveal">
<div class="container">
<CommonSectionTitle title="南线特色 · 呼伦贝尔+阿尔山" subtitle="大雪原与童话小城的碰撞" />
<CommonSectionTitle :title="st('productsWinter.south', '南线特色 · 呼伦贝尔+阿尔山')" :subtitle="st('productsWinter.south', '大雪原与童话小城的碰撞', 'subtitle')" />
<div class="highlights-grid">
<div v-for="(h, i) in winterData.southHighlights" :key="i" class="highlight-card">
<h3 class="highlight-title">{{ h.title }}</h3>
@ -65,7 +65,7 @@
<!-- 北线特色 -->
<section v-if="winterData" class="section reveal">
<div class="container">
<CommonSectionTitle title="北线特色 · 呼伦贝尔+大兴安岭" subtitle="边境风情与驯鹿部落的秘境" />
<CommonSectionTitle :title="st('productsWinter.north', '北线特色 · 呼伦贝尔+大兴安岭')" :subtitle="st('productsWinter.north', '边境风情与驯鹿部落的秘境', 'subtitle')" />
<div class="highlights-grid">
<div v-for="(h, i) in winterData.northHighlights" :key="i" class="highlight-card">
<h3 class="highlight-title">{{ h.title }}</h3>
@ -88,6 +88,8 @@
import { useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: winterData } = await usePublicApi('winter-products')
const { data: heroes } = await usePublicApi('heroes')
const st = await useSectionTitles()
const seo = useSEO('products-winter', {
title: '嗨冰雪 - 呼伦贝尔冬季定制游 · 南北两线5-7天 - 呼籁旅行',

查看文件

@ -2,7 +2,7 @@
<div class="page-qualifications">
<LayoutPageHero
:title="seo.h1 || '资质荣誉'"
subtitle="三重旅行社资质 · 1000万责任险 · 行业认证"
:subtitle="heroes?.qualifications || '三重旅行社资质 · 1000万责任险 · 行业认证'"
:breadcrumbs="[{ text: '资质荣誉' }]"
background-image="/images/seasons/summer-hero.jpg"
/>
@ -19,7 +19,7 @@
<!-- 服务保障 -->
<section v-if="about" class="section" aria-label="服务保障">
<div class="container">
<CommonSectionTitle title="服务保障" subtitle="三重旅行社资质 · 1000万责任险 · 正规电子合同 · 全程无购物" />
<CommonSectionTitle :title="st('qualifications.safety', '服务保障')" :subtitle="st('qualifications.safety', '三重旅行社资质 · 1000万责任险 · 正规电子合同 · 全程无购物', 'subtitle')" />
<!-- 保障要点 -->
<div class="guarantee-grid">
@ -80,7 +80,7 @@
<!-- 荣誉认证 -->
<section v-if="about" class="section section--green" aria-label="荣誉与认证">
<div class="container">
<CommonSectionTitle title="荣誉与认证" subtitle="行业机构和平台对呼籁服务质量的认可" />
<CommonSectionTitle :title="st('qualifications.awards', '荣誉与认证')" :subtitle="st('qualifications.awards', '行业机构和平台对呼籁服务质量的认可', 'subtitle')" />
<div class="awards-grid">
<div v-for="award in about.awards" :key="award.title" class="award-card">
<div class="award-card__year">{{ award.year }}</div>
@ -108,8 +108,10 @@
import { useOrganizationSchema, useBreadcrumbSchema } from '~/composables/useJsonLd'
const { data: qualData } = await usePublicApi('qualifications')
const { data: heroes } = await usePublicApi('heroes')
const { data: brand } = await usePublicApi('brand')
const { data: contact } = await usePublicApi('contact')
const st = await useSectionTitles()
// qualifications key safetyGuarantees guarantees
const about = computed(() => {

查看文件

@ -2,7 +2,7 @@
<div class="page-reviews">
<LayoutPageHero
:title="seo.h1"
subtitle="来自真实家庭的旅行评价"
:subtitle="heroes?.reviews || '来自真实家庭的旅行评价'"
:breadcrumbs="[{ text: '客户评价' }]"
background-image="/images/summer-camp/camp-graduation.jpg"
/>
@ -80,6 +80,7 @@ import { ref, computed } from 'vue'
import { useBreadcrumbSchema, useAggregateRatingSchema } from '~/composables/useJsonLd'
const { data: reviews } = await usePublicApi('reviews')
const { data: heroes } = await usePublicApi('heroes')
const seo = useSEO('reviews')
useScrollReveal()

某些文件未显示,因为此 diff 中更改的文件太多 显示更多