hulai-website/pages/guides.vue
Mimingguang ee078b61bc feat: 后台管理系统完整重构 + 前后台数据联通
- 后台全面引入 Naive UI 组件库,统一 UI 规范
- 前台 usePublicApi 切换到 API 调用(GET /api/content/[key])
- 前后台数据源统一(site_content 表)
- 产品线统一管理(tour/camp/course 三套编辑器)
- 产品数据归一化(itinerary/faq/pricing 格式统一)
- 表单提交 API 改写入 submissions 表
- 新增 submissions 标记已读 API
- site-content PUT 支持 upsert
- 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb)
- 消除 PageHero 类型警告(useSEO reactive 修复)
- 25 个前台页面全部 HTTP 200,展示效果不变

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:48:02 +08:00

431 行
11 KiB
Vue

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

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

<template>
<div class="page-guides">
<LayoutPageHero
:title="seo.h1"
subtitle="证件·穿搭·行李清单·活动安全·幼童专属指南,帮您轻松准备呼伦贝尔之行"
:breadcrumbs="[{ text: '出行指南' }]"
background-image="/images/seasons/summer-car.jpg"
/>
<!-- TL;DR告诉 AI/搜索引擎这个页面回答了什么 -->
<section class="page-tldr" aria-label="出行指南摘要">
<div class="container">
<div class="page-tldr-inner">
<p>去呼伦贝尔出行前需准备中国居民身份证14岁以下携带户口本草原早晚温差大需分层穿搭必备防晒防蚊产品本指南涵盖六个章节证件清单按月穿搭参考6-9月及冬季行李打包建议草原活动安全须知实用信息与推荐App幼童专属注意事项全部来自11年带队实践经验</p>
</div>
</div>
</section>
<section v-if="guidesData" class="section">
<div class="container">
<p class="guide-intro">{{ guidesData.pageIntro }}</p>
<!-- 锚点导航 -->
<GuidesGuideNav
:sections="guidesData.sections"
:active="activeSection"
@select="scrollToSection"
/>
<!-- 01 证件 -->
<div :id="sDocuments.id" class="guide-section">
<CommonSectionTitle
:title="sDocuments.title"
:subtitle="sDocuments.subtitle"
align="left"
/>
<p class="guide-text">{{ sDocuments.content }}</p>
<CommonContentCard :hoverable="false">
<GuidesGuideChecklist :items="sDocuments.items" />
</CommonContentCard>
<div v-if="sDocuments.tips" class="guide-tips">
<p v-for="(tip, i) in sDocuments.tips" :key="i" class="guide-tip">{{ tip }}</p>
</div>
</div>
<!-- 02 穿搭 -->
<div :id="sClothing.id" class="guide-section">
<CommonSectionTitle
:title="sClothing.title"
:subtitle="sClothing.subtitle"
align="left"
/>
<p class="guide-text">{{ sClothing.content }}</p>
<div class="clothing-grid">
<GuidesGuideClothingCard
v-for="month in sClothing.months"
:key="month.name"
:month="month"
/>
</div>
<div class="guide-tips">
<p v-for="(tip, i) in sClothing.extraTips" :key="i" class="guide-tip">{{ tip }}</p>
</div>
</div>
<!-- 03 行李清单 -->
<div :id="sPacking.id" class="guide-section">
<CommonSectionTitle
:title="sPacking.title"
:subtitle="sPacking.subtitle"
align="left"
/>
<div class="packing-categories">
<div v-for="cat in sPacking.categories" :key="cat.name" class="packing-category">
<h3 class="packing-cat-name">{{ cat.name }}</h3>
<p v-if="cat.note" class="packing-cat-note">{{ cat.note }}</p>
<CommonContentCard :hoverable="false">
<GuidesGuideChecklist :items="cat.items" />
</CommonContentCard>
</div>
</div>
<!-- 已帮您准备好的 -->
<div class="provided-block">
<h3 class="provided-title">{{ sPacking.provided.title }}</h3>
<p class="provided-desc">{{ sPacking.provided.description }}</p>
<p class="provided-items">{{ sPacking.provided.items }}</p>
</div>
<!-- 不用带 -->
<div class="dont-bring">
<h4 class="dont-bring-title">这些不用带</h4>
<p v-for="(item, i) in sPacking.dontBring" :key="i" class="dont-bring-item"> {{ item }}</p>
</div>
<div v-if="sPacking.tips" class="guide-tips">
<p v-for="(tip, i) in sPacking.tips" :key="i" class="guide-tip">{{ tip }}</p>
</div>
</div>
<!-- 04 活动安全 -->
<div :id="sActivities.id" class="guide-section">
<CommonSectionTitle
:title="sActivities.title"
:subtitle="sActivities.subtitle"
align="left"
/>
<p class="guide-text">{{ sActivities.content }}</p>
<div class="activities-grid">
<GuidesGuideActivityCard
v-for="activity in sActivities.activities"
:key="activity.name"
:activity="activity"
/>
</div>
</div>
<!-- 05 实用信息 -->
<div :id="sPractical.id" class="guide-section">
<CommonSectionTitle
:title="sPractical.title"
:subtitle="sPractical.subtitle"
align="left"
/>
<div class="topics-grid">
<div v-for="topic in sPractical.topics" :key="topic.name" class="topic-card">
<h4 class="topic-name">{{ topic.name }}</h4>
<p class="topic-content">{{ topic.content }}</p>
</div>
</div>
<!-- 推荐App -->
<div class="apps-block">
<h3 class="apps-title">出发前手机里装什么</h3>
<div class="apps-list">
<div v-for="app in sPractical.apps" :key="app.category" class="app-item">
<span class="app-category">{{ app.category }}</span>
<span class="app-names">{{ app.names }}</span>
</div>
</div>
<p class="apps-note">{{ sPractical.appNote }}</p>
</div>
</div>
<!-- 06 幼童指南 -->
<div :id="sToddler.id" class="guide-section">
<CommonSectionTitle
:title="sToddler.title"
:subtitle="sToddler.subtitle"
align="left"
/>
<p class="guide-text">{{ sToddler.content }}</p>
<GuidesGuideToddlerSection :data="sToddler" />
</div>
<!-- 内链闭环 -->
<div class="guide-cta">
<h2>准备好了吗</h2>
<p>草原的风已经在等您了</p>
<div class="guide-cta-links">
<CommonInternalLink to="/products" text="查看4-7天亲子游产品" />
<CommonInternalLink to="/faq" text="查看常见问答" />
<CommonInternalLink to="/contact" text="联系定制师获取方案" />
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { useBreadcrumbSchema, useHowToSchema } from '~/composables/useJsonLd'
const { data: guidesData } = await usePublicApi('guides')
const seo = useSEO('guides')
useHowToSchema(guidesData.value?.sections)
useBreadcrumbSchema([
{ text: '首页', to: '/' },
{ text: '出行指南' },
])
// 按 id 索引 sections,避免硬编码数组下标
const sections = computed(() => guidesData.value?.sections || [])
function getSection(id) {
return sections.value.find(s => s.id === id)
}
const sDocuments = computed(() => getSection('documents'))
const sClothing = computed(() => getSection('clothing'))
const sPacking = computed(() => getSection('packing'))
const sActivities = computed(() => getSection('activities'))
const sPractical = computed(() => getSection('practical'))
const sToddler = computed(() => getSection('toddler'))
const activeSection = ref('')
function scrollToSection(id) {
activeSection.value = id
if (import.meta.client) {
const el = document.getElementById(id)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
}
</script>
<style lang="less" scoped>
.guide-intro {
font-size: @font-size-md;
color: @color-text-secondary;
max-width: 700px;
margin: 0 auto @space-xl;
text-align: center;
line-height: @line-height-base;
}
.guide-section {
margin-bottom: @space-3xl;
padding-top: @space-lg;
scroll-margin-top: 80px;
}
.guide-text {
font-size: @font-size-base;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-lg;
}
.guide-tips {
margin-top: @space-lg;
}
.guide-tip {
padding: @space-sm @space-md;
background: @color-primary-bg;
border-radius: @border-radius-sm;
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin-bottom: @space-sm;
}
// Clothing
.clothing-grid {
display: flex;
flex-direction: column;
gap: @space-md;
}
// Packing
.packing-categories {
display: flex;
flex-direction: column;
gap: @space-xl;
}
.packing-cat-name {
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-primary;
margin: 0 0 @space-xs;
}
.packing-cat-note {
font-size: @font-size-sm;
color: @color-accent;
margin-bottom: @space-md;
}
.provided-block {
margin-top: @space-xl;
padding: @space-lg;
background: @color-bg-white;
border: 1px solid @color-border;
border-top: 3px solid @color-primary;
border-radius: @border-radius-md;
}
.provided-title {
font-size: @font-size-base;
font-weight: @font-weight-bold;
color: @color-primary;
margin: 0 0 @space-sm;
}
.provided-desc {
font-size: @font-size-sm;
color: @color-text-secondary;
margin-bottom: @space-sm;
}
.provided-items {
font-size: @font-size-sm;
color: @color-text-muted;
line-height: @line-height-loose;
}
.dont-bring {
margin-top: @space-lg;
}
.dont-bring-title {
font-size: @font-size-sm;
color: @color-text-muted;
margin: 0 0 @space-sm;
}
.dont-bring-item {
font-size: @font-size-sm;
color: @color-text-muted;
line-height: @line-height-loose;
margin: 0;
}
// Activities
.activities-grid {
display: flex;
flex-direction: column;
gap: @space-md;
}
// Practical
.topics-grid {
display: grid;
grid-template-columns: 1fr;
gap: @space-md;
.respond-md({
grid-template-columns: 1fr 1fr;
});
}
.topic-card {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
padding: @space-lg;
}
.topic-name {
font-size: @font-size-base;
font-weight: @font-weight-bold;
color: @color-primary;
margin: 0 0 @space-sm;
}
.topic-content {
font-size: @font-size-sm;
color: @color-text-secondary;
line-height: @line-height-base;
margin: 0;
}
// Apps
.apps-block {
margin-top: @space-xl;
}
.apps-title {
font-size: @font-size-md;
font-weight: @font-weight-bold;
color: @color-primary;
margin: 0 0 @space-md;
}
.apps-list {
background: @color-bg-white;
border: 1px solid @color-border;
border-radius: @border-radius-md;
overflow: hidden;
}
.app-item {
display: flex;
gap: @space-md;
padding: @space-md @space-lg;
border-bottom: 1px solid @color-bg-gray;
font-size: @font-size-sm;
&:last-child {
border-bottom: none;
}
}
.app-category {
font-weight: @font-weight-medium;
color: @color-primary;
min-width: 80px;
flex-shrink: 0;
}
.app-names {
color: @color-text-secondary;
}
.apps-note {
margin-top: @space-sm;
font-size: @font-size-sm;
color: @color-text-muted;
}
// CTA
.guide-cta {
text-align: center;
margin-top: @space-3xl;
padding: @space-2xl;
background: @color-primary-bg;
border-radius: @border-radius-lg;
h2 {
margin-bottom: @space-sm;
}
p {
color: @color-text-secondary;
margin-bottom: @space-xl;
}
}
.guide-cta-links {
display: flex;
justify-content: center;
gap: @space-xl;
flex-wrap: wrap;
}
</style>